mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* 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>
453 lines
16 KiB
Go
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,
|
|
}
|
|
}
|