Files
multica/server/internal/integrations/slack/channel.go
Bohan Jiang cb6616f530 feat(slack): Socket Mode channel.Channel adapter (MUL-3516) (#4523)
* feat(slack): Socket Mode channel.Channel adapter (MUL-3516)

First slice of the Slack adapter: implements channel.Channel (Type/Connect/Disconnect/Send/Capabilities) over Slack Socket Mode, normalizes inbound events to channel.InboundMessage (DM, channel @mention, thread reply; bot-loop + edit/delete guards), decodes the per-installation config/secret blob, and registers the Factory under TypeSlack. No engine, core, or channel_* schema change. Unit-tested (translation, capabilities, config decode, chunking, Send via httptest). Resolvers + engine wiring + Block Kit binding replier follow.

Co-authored-by: multica-agent <github@multica.ai>

* fix(slack): address adapter review (MUL-3516)

- Propagate InboundHandler errors through dispatchEventsAPI/handleSocketEvent to Connect so an infra failure tears down the connection for Supervisor reconnect/backoff instead of being silently swallowed (ACK still happens first).
- Capabilities: declare only CapText | CapThreadReply; drop CapRichCard/CapAttachment/CapMessageEdit until those Send paths are wired.
- slackChatType: map mpim (multi-party DM) to group, not p2p, so the 'must address bot' filter applies; only 1:1 im is p2p.
- Document the group-addressing decision: explicit @bot mention required in groups; mention-free thread continuation deferred to the session-aware layer.
- Tests: handler-error propagation, slackChatType table, mpim-requires-mention, capabilities negative assertions.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(channel): shared channel-agnostic ChatSession service (MUL-3516)

Extract the session/append//issue machinery — currently locked inside the Feishu-pinned lark.chatSessionService — into a shared engine.ChatSession parameterized by channel_type + session titles, so every IM adapter reuses it instead of re-implementing it. Logic is verbatim (find-or-create session+binding with unique-violation race re-read; append+touch+reply-target+in-tx dedup Mark; /issue parse with bare-command previous-message fallback) but channel-neutral: command-parse source is supplied by the adapter (enrichment is platform-specific). Backed by a narrow SessionQueries interface so it is unit-tested with an in-memory fake (no DB). /issue parser moved to engine.ParseIssueCommand. Next: migrate Feishu onto it and wire Slack's ResolverSet, removing the lark duplicate.

Co-authored-by: multica-agent <github@multica.ai>

* fix(channel): decouple session binding key from outbound target (MUL-3516)

Addresses Elon's round-2 review. engine.ChatSession.EnsureSession previously keyed the binding on a raw chat id (EnsureSessionInput.ChatID), so a resolver wiring Slack straight through would collapse every @bot thread in one channel into a single chat_session and overwrite last_thread_id. Make the API un-misusable:

- EnsureSessionInput.ChatID -> BindingKey: the explicit session-isolation key (Feishu: chat id; Slack DM: channel id; Slack channel: channel id + thread root), documented so a raw threaded-platform chat id is never passed straight through.
- Add EnsureSessionInput.BindingConfig (opaque) persisted on the binding's config column, so the real outbound channel/thread is preserved when BindingKey is composite — outbound routing stays separate from the isolation key.
- channel.sql CreateChannelChatSessionBinding now writes config (additive, uses the existing NOT NULL column; lark caller passes '{}', no schema change, no Feishu regression).
- Tests: TestEnsureSession_ThreadRootIsolation (two thread roots in one channel -> two sessions; same root reuses) and TestEnsureSession_StoresBindingConfig.

No production wiring change yet (per review, the not-yet-wired shared service is an accepted preparatory state); this makes the API correct before Feishu/Slack are migrated onto it.

Co-authored-by: multica-agent <github@multica.ai>

* feat(slack): Slack ResolverSet with thread-root session isolation (MUL-3516)

Wires Slack into the channel-agnostic engine.Router via a ResolverSet built on the generic channel_* queries (installation route by team_id, identity + workspace-membership recheck, two-phase dedup, audit) plus the shared engine.ChatSession. No new query, no schema change.

slackSessionRouting is the per-message isolation rule (Elon round-2 / Niko round-3): a DM is one session per channel; a channel/group message is isolated by thread root (key = channel:threadRoot, root = inbound thread_ts or the message ts for a top-level @mention), so two @bot threads in one channel are two sessions. The real channel id rides in BindingConfig for outbound; the reply thread is returned separately. Tests cover DM/channel/thread routing, config, and that distinct thread roots isolate while a same-thread follow-up reuses its key.

Not yet wired into router.go (still a preparatory commit, per review); Feishu migration onto the shared service, router/config wiring, and the Slack outbound path follow.

Co-authored-by: multica-agent <github@multica.ai>

* feat(slack): Markdown->mrkdwn outbound formatting (MUL-3516)

Slack renders mrkdwn, not Markdown, so an unconverted agent reply shows literal ** , ## and [text](url). Add formatMrkdwn — a faithful Go port of Hermes Agent's slack format_message (MIT) — and apply it in slackChannel.Send before chunking/posting. Protects fenced+inline code, converted links, and existing Slack entities behind placeholders; converts headers/bold/italic/strike/links; escapes control chars. Unit tests cover each construct plus fenced-code protection and a link nested in bold.

Co-authored-by: multica-agent <github@multica.ai>

* docs(slack): preserve Hermes MIT notice for ported mrkdwn converter (MUL-3516)

Addresses Niko's review. formatMrkdwn is a substantial port of Hermes Agent's slack format_message; MIT requires preserving the copyright + permission notice. Add the full Hermes MIT copyright/permission notice + source URL as a header on mrkdwn.go (no repo-level third-party notice file exists, and the header cannot get separated from the ported code). Also add the suggested Send-layer regression test (TestSend_AppliesMrkdwn) that pins the wiring: slackChannel.Send converts Markdown to mrkdwn before posting.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(lark): migrate Feishu onto shared engine.ChatSession, drop duplicate (MUL-3516)

Completes 'every IM reuses one shared session service' and removes the dual-path the reviewers flagged as temporary. Feishu's ResolverSet now drives the channel-agnostic engine.ChatSession (channel_type=feishu, Lark session titles preserved) instead of the Feishu-specific lark.chatSessionService, which is deleted. Behavior is unchanged: engine.ChatSession is the verbatim port of the old logic and is unit-tested; the new Feishu binder param-mapping (BindingKey=chat id, CommandText=un-enriched CommandBody from Raw) is covered by feishu_resolvers_test.go.

- Delete chat_service.go (chatSessionService + helpers) and issue_command.go/_test.go (parser now engine.ParseIssueCommand). Relocate the shared TxStarter interface to tx.go (still used by binding-token + registration services).
- chat.go keeps only the AuditLogger seam; remove the now-dead ChatSessionService / EnsureChatSessionParams / AppendUserMessageParams / AppendResult / IssueCommand types.
- router.go constructs engine.NewChatSession for Feishu; inbound_enricher_test + doc.go updated.

make-test parity: go build ./..., go vet, gofmt, and go test ./internal/integrations/{lark,channel/...,slack} all pass (full Feishu suite green).

Co-authored-by: multica-agent <github@multica.ai>

* feat(slack): wire Slack adapter + ResolverSet + outbound into router (MUL-3516)

Activates the full Slack pipeline, gated by MULTICA_SLACK_SECRET_KEY (the bot/app-token decryption key). When unset the block is skipped, so existing deployments are unaffected and Feishu is untouched.

- router.go registers slack.RegisterSlack (Socket Mode connect/send Factory) + channelRouter.Register(TypeSlack, NewSlackResolverSet) (inbound pipeline) + slack.NewOutbound(...).Register(bus) (outbound).
- New slack/outbound.go: an EventChatDone subscriber mirroring the Feishu Patcher. It finds the Slack chat binding for the finished session, recovers the real channel from the binding config (the channel_chat_id may be a composite thread-isolation key) + the reply thread from last_thread_id, and posts via slackChannel.Send (reusing formatMrkdwn / chunking / threading). Sessions with no Slack binding are ignored, so it coexists with the Feishu Patcher on the shared bus.
- Tests: posts to the bound channel/thread with the real channel id; ignores non-Slack sessions, empty completions, revoked installations, and non-chat events.

Slack now shares engine.ChatSession, channel_* tables, IssueService and TaskService with Feishu. Remaining: config-driven installation provisioning (an operator currently creates the channel_type='slack' row; the config block shape — which workspace/agent — is a product decision) and a live end-to-end smoke. go build ./..., go vet, gofmt, and go test ./internal/integrations/{slack,channel/...,lark} all pass.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 14:29:00 +08:00

453 lines
16 KiB
Go

package slack
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"regexp"
"strings"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/slack-go/slack/socketmode"
"github.com/multica-ai/multica/server/internal/integrations/channel"
)
// TypeSlack is the channel discriminator for the Slack adapter. It is defined
// here (not in the channel core package) on purpose: registering a new platform
// must not require editing the core, so the Type value lives with its adapter.
const TypeSlack channel.Type = "slack"
// maxMessageRunes caps a single outbound chat.postMessage body. Slack hard-caps
// a message around 40k characters; we chunk below that with headroom.
const maxMessageRunes = 38000
// slackChannel is the Slack implementation of channel.Channel. One instance is
// built per channel_installation by the registered Factory. It holds only what
// Connect/Send need (the decoded credentials + an API client); the installation
// identity is resolved per message by the Router, so it is absent here — the
// same split the Feishu adapter uses.
type slackChannel struct {
creds credentials
api *slack.Client
handler channel.InboundHandler
logger *slog.Logger
mentionRe *regexp.Regexp
}
var _ channel.Channel = (*slackChannel)(nil)
func (c *slackChannel) Type() channel.Type { return TypeSlack }
// Connect opens the Slack Socket Mode WebSocket and runs the receive loop,
// blocking until ctx is cancelled or the connection drops — the contract
// engine.Supervisor relies on to tie lease renewal to connection liveness
// (matching feishuChannel.Connect). Each decoded Events API message is
// normalized to a channel.InboundMessage and handed to the engine handler. The
// envelope is ACKed immediately on receipt (Slack expires un-ACKed envelopes in
// ~3s) so the handler's slower DB work never races the ACK.
func (c *slackChannel) Connect(ctx context.Context) error {
if c.handler == nil {
return errors.New("slack: inbound handler not configured")
}
if c.api == nil {
return errors.New("slack: api client not configured")
}
sm := socketmode.New(c.api)
runErr := make(chan error, 1)
go func() { runErr <- sm.RunContext(ctx) }()
for {
select {
case <-ctx.Done():
// Graceful teardown: the Supervisor cancelled the run context.
return nil
case err := <-runErr:
// The managed connection loop ended. On ctx cancellation this is a
// graceful stop; otherwise it is a real failure the Supervisor
// retries under backoff.
if ctx.Err() != nil {
return nil
}
if err != nil {
return err
}
return errors.New("slack: socket mode connection closed")
case evt, ok := <-sm.Events:
if !ok {
if ctx.Err() != nil {
return nil
}
return errors.New("slack: socket mode event stream closed")
}
if err := c.handleSocketEvent(ctx, sm, evt); err != nil {
// A handler error is an infrastructure failure (InboundHandler
// contract): surface it so the Supervisor tears the connection
// down and reconnects under backoff, instead of silently
// dropping every subsequent event. ctx cancellation is a
// graceful stop, not a failure.
if ctx.Err() != nil {
return nil
}
return err
}
}
}
}
// Disconnect is a no-op: the Socket Mode loop is torn down by ctx cancellation
// (the Supervisor cancels the run context), mirroring feishuChannel.Disconnect.
func (c *slackChannel) Disconnect(ctx context.Context) error { return nil }
// Send delivers a minimal text reply via chat.postMessage, threading into
// out.ThreadID when set so a decoupled reply lands back in the originating
// thread. Long bodies are chunked under Slack's per-message cap; the returned
// SendResult carries the timestamp of the LAST posted chunk.
func (c *slackChannel) Send(ctx context.Context, out channel.OutboundMessage) (channel.SendResult, error) {
if c.api == nil {
return channel.SendResult{}, errors.New("slack: api client not configured")
}
threadTS := outboundThreadTS(out)
var lastTS string
// Convert the agent's standard Markdown to Slack mrkdwn before posting so
// bold/headers/links render instead of showing literal markup.
for _, chunk := range chunkMessage(formatMrkdwn(out.Text), maxMessageRunes) {
opts := []slack.MsgOption{
slack.MsgOptionText(chunk, false),
slack.MsgOptionDisableLinkUnfurl(),
}
if threadTS != "" {
opts = append(opts, slack.MsgOptionTS(threadTS))
}
_, ts, err := c.api.PostMessageContext(ctx, out.ChatID, opts...)
if err != nil {
return channel.SendResult{}, fmt.Errorf("slack: chat.postMessage: %w", err)
}
lastTS = ts
}
return channel.SendResult{MessageID: lastTS}, nil
}
// Capabilities declares what the Slack adapter supports TODAY. Declaration
// only — the engine performs no degradation, and callers pick a rendering from
// these bits, so declaring a capability the Send path cannot fulfil would
// mislead them. The minimal Send delivers text into a chat or thread, so only
// CapText | CapThreadReply are declared. Block Kit (CapRichCard), file
// attachments (CapAttachment) and chat.update edits (CapMessageEdit) are
// deferred until those paths are actually wired.
func (c *slackChannel) Capabilities() channel.Capability {
return channel.CapText | channel.CapThreadReply
}
// ---- inbound ----
func (c *slackChannel) handleSocketEvent(ctx context.Context, sm *socketmode.Client, evt socketmode.Event) error {
switch evt.Type {
case socketmode.EventTypeEventsAPI:
eventsAPI, ok := evt.Data.(slackevents.EventsAPIEvent)
if !ok {
return nil
}
// ACK first: Slack expires un-ACKed envelopes in ~3s, far below the
// handler's DB work. The ACK is independent of the handler outcome —
// a handler error is surfaced to the Supervisor (reconnect/backoff),
// not retried through the un-ACK path.
if evt.Request != nil {
if err := sm.Ack(*evt.Request); err != nil {
c.logger.WarnContext(ctx, "slack: ack failed", "error", err)
}
}
return c.dispatchEventsAPI(ctx, eventsAPI)
case socketmode.EventTypeConnecting, socketmode.EventTypeConnected, socketmode.EventTypeHello:
c.logger.DebugContext(ctx, "slack: socket mode", "event", evt.Type)
case socketmode.EventTypeIncomingError, socketmode.EventTypeErrorBadMessage:
c.logger.WarnContext(ctx, "slack: socket mode error", "event", evt.Type)
default:
// Interactive / slash-command / other events are out of scope for the
// minimal adapter; ACK so Slack does not retry, then ignore.
if evt.Request != nil {
_ = sm.Ack(*evt.Request)
}
}
return nil
}
func (c *slackChannel) dispatchEventsAPI(ctx context.Context, e slackevents.EventsAPIEvent) error {
var (
msg channel.InboundMessage
ok bool
)
switch inner := e.InnerEvent.Data.(type) {
case *slackevents.AppMentionEvent:
msg, ok = c.inboundFromAppMention(e, inner)
case *slackevents.MessageEvent:
msg, ok = c.inboundFromMessage(e, inner)
default:
return nil
}
if !ok {
return nil
}
// A non-nil handler error is an infrastructure failure; propagate it so the
// Supervisor reconnects (InboundHandler contract). A legitimate product
// drop (dedup hit / unbound sender / group filter) returns nil — not an
// error — so it does not tear the connection down.
return c.handler(ctx, msg)
}
// inboundFromMessage normalizes a Slack message event. It returns ok=false for
// events that must not reach the core: the bot's own messages and other bots'
// messages (loop guard), and edits/deletes/joins and similar subtyped system
// messages (only brand-new user messages are ingested).
//
// Group addressing policy (v1, deliberate): a group message is addressed to the
// bot only when it carries an explicit <@bot> mention. Mention-free follow-ups
// inside a thread the bot is already engaged in are NOT auto-addressed here:
// "reply to a bot message" is session state, so it belongs in the session-aware
// shared service / resolver layer (which can detect an existing bound session
// for the thread and survive reconnects) rather than in per-connection adapter
// memory. Until that lands, channel/thread continuation requires re-mentioning
// the bot. P2P (DM) ingests every message, unchanged.
func (c *slackChannel) inboundFromMessage(e slackevents.EventsAPIEvent, m *slackevents.MessageEvent) (channel.InboundMessage, bool) {
if m.BotID != "" || m.SubType == "bot_message" {
return channel.InboundMessage{}, false
}
if m.User == "" || (c.creds.BotUserID != "" && m.User == c.creds.BotUserID) {
return channel.InboundMessage{}, false
}
if !isIngestableSubtype(m.SubType) {
return channel.InboundMessage{}, false
}
chatType := slackChatType(m.Channel, m.ChannelType)
addressed := chatType == channel.ChatTypeP2P || c.mentionsBot(m.Text)
return c.buildInbound(e, buildInboundParams{
eventType: "message",
subType: m.SubType,
channelID: m.Channel,
userID: m.User,
text: m.Text,
ts: m.TimeStamp,
threadTS: m.ThreadTimeStamp,
chatType: chatType,
addressed: addressed,
}), true
}
// inboundFromAppMention normalizes an app_mention event. An app_mention is, by
// definition, addressed to the bot and occurs in a channel (group). The same
// channel @mention also arrives as a message event with the identical ts, so
// the engine's (installation, message_id=ts) dedup collapses the pair — no
// special-casing needed here.
func (c *slackChannel) inboundFromAppMention(e slackevents.EventsAPIEvent, m *slackevents.AppMentionEvent) (channel.InboundMessage, bool) {
if m.BotID != "" || m.User == "" || (c.creds.BotUserID != "" && m.User == c.creds.BotUserID) {
return channel.InboundMessage{}, false
}
return c.buildInbound(e, buildInboundParams{
eventType: "app_mention",
channelID: m.Channel,
userID: m.User,
text: m.Text,
ts: m.TimeStamp,
threadTS: m.ThreadTimeStamp,
chatType: channel.ChatTypeGroup,
addressed: true,
}), true
}
type buildInboundParams struct {
eventType string
subType string
channelID string
userID string
text string
ts string
threadTS string
chatType channel.ChatType
addressed bool
}
func (c *slackChannel) buildInbound(e slackevents.EventsAPIEvent, p buildInboundParams) channel.InboundMessage {
teamID := e.TeamID
if teamID == "" {
teamID = c.creds.TeamID
}
raw, _ := json.Marshal(slackRawEvent{
TeamID: teamID,
APIAppID: e.APIAppID,
EventType: p.eventType,
SubType: p.subType,
ChannelType: string(p.chatType),
})
var reply *channel.ReplyCtx
if p.threadTS != "" && p.threadTS != p.ts {
reply = &channel.ReplyCtx{MessageID: p.threadTS, RootID: p.threadTS}
}
return channel.InboundMessage{
EventID: p.ts,
MessageID: p.ts,
Type: channel.MsgTypeText,
Text: c.cleanText(p.text),
ReplyTo: reply,
AddressedToBot: p.addressed,
Source: channel.Source{
ChannelType: TypeSlack,
ChatID: p.channelID,
ChatType: p.chatType,
SenderID: p.userID,
ThreadID: p.threadTS,
},
Raw: raw,
}
}
// slackRawEvent carries the Slack-specific fields the cross-platform envelope
// does not — read back only inside the Slack resolvers (team_id routes the
// installation; the core never reads Raw).
type slackRawEvent struct {
TeamID string `json:"team_id"`
APIAppID string `json:"api_app_id,omitempty"`
EventType string `json:"event_type"`
SubType string `json:"subtype,omitempty"`
ChannelType string `json:"channel_type,omitempty"`
}
// cleanText strips a leading/embedded bot mention token and trims surrounding
// whitespace so the core sees the user's actual prompt, not "<@U123> hi".
func (c *slackChannel) cleanText(text string) string {
if c.mentionRe != nil {
text = c.mentionRe.ReplaceAllString(text, "")
}
return strings.TrimSpace(text)
}
// mentionsBot reports whether text contains an @-mention of this bot. Slack
// renders a mention as <@U123> or <@U123|name>.
func (c *slackChannel) mentionsBot(text string) bool {
return c.mentionRe != nil && c.mentionRe.MatchString(text)
}
// ---- helpers ----
// slackChatType maps a Slack channel id / channel_type to the normalized
// ChatType. Only a 1:1 direct message ("im", or a "D…" channel id) is p2p;
// everything else — public/private channels AND multi-party DMs ("mpim", which
// are multi-person conversations) — is a group. A group routes through the
// engine's "must address the bot" filter, so plain chatter in a multi-party DM
// is not mistaken for a prompt to the bot.
func slackChatType(channelID, channelType string) channel.ChatType {
switch channelType {
case "im":
return channel.ChatTypeP2P
case "mpim", "channel", "group", "private_channel":
return channel.ChatTypeGroup
}
if strings.HasPrefix(channelID, "D") {
return channel.ChatTypeP2P
}
return channel.ChatTypeGroup
}
// isIngestableSubtype reports whether a message subtype is a brand-new user
// message the core should ingest. Empty subtype is the normal case;
// thread_broadcast and file_share are real user messages; everything else
// (message_changed, message_deleted, channel_join, …) is a system/edit event.
func isIngestableSubtype(subType string) bool {
switch subType {
case "", "thread_broadcast", "file_share":
return true
default:
return false
}
}
// outboundThreadTS picks the Slack thread_ts for an outbound reply: an explicit
// quote target wins, else the thread the inbound message belonged to.
func outboundThreadTS(out channel.OutboundMessage) string {
if out.ReplyTo != "" {
return out.ReplyTo
}
return out.ThreadID
}
// chunkMessage splits text into <=maxRunes-rune pieces on rune boundaries so a
// long agent reply does not exceed Slack's per-message cap. An empty body
// yields a single empty chunk (Slack rejects truly empty text, but the caller
// guards against that upstream).
func chunkMessage(text string, maxRunes int) []string {
if maxRunes <= 0 || len([]rune(text)) <= maxRunes {
return []string{text}
}
runes := []rune(text)
var chunks []string
for len(runes) > 0 {
n := maxRunes
if n > len(runes) {
n = len(runes)
}
chunks = append(chunks, string(runes[:n]))
runes = runes[n:]
}
return chunks
}
// ---- registration ----
// SlackChannelDeps bundles the shared dependencies the Slack Factory closes
// over. The inbound handler is supplied per-build by the engine via
// channel.Config.Handler, mirroring FeishuChannelDeps.
type SlackChannelDeps struct {
// Decrypt turns the stored bot/app token ciphertext into plaintext. A nil
// Decrypter treats stored tokens as plaintext (tests / un-encrypted dev).
Decrypt Decrypter
Logger *slog.Logger
}
// RegisterSlack registers the Slack Factory on reg under TypeSlack so the
// engine.Supervisor can build a slackChannel per installation. "Adding a
// channel" is this call plus the adapter — no engine edit.
func RegisterSlack(reg *channel.Registry, deps SlackChannelDeps) {
reg.Register(TypeSlack, newSlackFactory(deps))
}
func newSlackFactory(deps SlackChannelDeps) channel.Factory {
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return func(cfg channel.Config) (channel.Channel, error) {
creds, err := decodeCredentials(cfg.Raw, deps.Decrypt)
if err != nil {
return nil, err
}
if creds.BotToken == "" || creds.AppToken == "" {
return nil, errors.New("slack: installation config missing bot or app token")
}
return newSlackChannel(creds, slack.New(creds.BotToken, slack.OptionAppLevelToken(creds.AppToken)), cfg.Handler, logger), nil
}
}
// newSlackChannel builds a slackChannel from decoded credentials and a
// configured API client. Kept separate from the Factory so tests can inject a
// client pointed at an httptest server.
func newSlackChannel(creds credentials, api *slack.Client, handler channel.InboundHandler, logger *slog.Logger) *slackChannel {
if logger == nil {
logger = slog.Default()
}
var mentionRe *regexp.Regexp
if creds.BotUserID != "" {
mentionRe = regexp.MustCompile(`<@` + regexp.QuoteMeta(creds.BotUserID) + `(\|[^>]*)?>`)
}
return &slackChannel{
creds: creds,
api: api,
handler: handler,
logger: logger,
mentionRe: mentionRe,
}
}