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>
249 lines
8.9 KiB
Go
249 lines
8.9 KiB
Go
package slack
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
|
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// This file is the Slack ResolverSet: the platform-specific seams the
|
|
// channel-agnostic engine.Router runs the inbound pipeline through. It mirrors
|
|
// the Feishu ResolverSet but is built entirely on the generic channel_* queries
|
|
// (no new query, no schema change) plus the shared engine.ChatSession — so
|
|
// "adding Slack" stays "implement Channel + register a ResolverSet".
|
|
|
|
// originSlackChat is the issue.origin_type label for issues created via the
|
|
// Slack /issue command.
|
|
const originSlackChat = "slack_chat"
|
|
|
|
// NewSlackResolverSet assembles the Slack ResolverSet over the generated
|
|
// queries + a tx starter (for the shared session service). Replier/Typing are
|
|
// left nil for now: the outbound binding-prompt / notice path is a later step
|
|
// (the inbound pipeline — route, identity, dedup, session, /issue, run trigger
|
|
// — is fully functional without them).
|
|
func NewSlackResolverSet(q *db.Queries, tx engine.TxStarter) engine.ResolverSet {
|
|
return engine.ResolverSet{
|
|
Installation: &installationResolver{q: q},
|
|
Identity: &identityResolver{q: q},
|
|
Dedup: &deduper{q: q},
|
|
Session: &sessionBinder{session: engine.NewChatSession(q, tx, TypeSlack, engine.SessionTitles{
|
|
Group: "Slack channel",
|
|
Direct: "Slack direct message",
|
|
Fallback: "Slack chat",
|
|
})},
|
|
Audit: &auditor{q: q},
|
|
OriginType: originSlackChat,
|
|
}
|
|
}
|
|
|
|
var (
|
|
_ engine.InstallationResolver = (*installationResolver)(nil)
|
|
_ engine.IdentityResolver = (*identityResolver)(nil)
|
|
_ engine.Deduper = (*deduper)(nil)
|
|
_ engine.SessionBinder = (*sessionBinder)(nil)
|
|
_ engine.Auditor = (*auditor)(nil)
|
|
)
|
|
|
|
// slackBindingConfig is the opaque outbound routing persisted on the chat
|
|
// binding's config. When the binding key is a composite (Slack channel thread),
|
|
// the real channel id lives here so the outbound path can post back.
|
|
type slackBindingConfig struct {
|
|
ChannelID string `json:"channel_id"`
|
|
}
|
|
|
|
// slackSessionRouting derives, from one inbound Slack message, the three things
|
|
// the session layer needs kept distinct (Elon's round-2 must-fix):
|
|
//
|
|
// - bindingKey: the session-isolation key (stored as channel_chat_id). A DM is
|
|
// one continuous session per channel, so the key is the channel id. A
|
|
// channel/group message is isolated by THREAD ROOT — key = "channel:root" —
|
|
// so two @bot threads in one channel are two sessions, matching Hermes. The
|
|
// thread root is the inbound thread_ts when replying in a thread, else the
|
|
// message ts (a top-level @mention starts a new root).
|
|
// - config: the real channel id, so outbound works even when the key is
|
|
// composite.
|
|
// - replyThread: the thread_ts to reply into (the thread root for groups; the
|
|
// inbound thread for DMs, which may be empty for a top-level send).
|
|
//
|
|
// It is a pure function so the isolation contract is unit-tested without a DB.
|
|
func slackSessionRouting(msg channel.InboundMessage) (bindingKey string, config []byte, replyThread string) {
|
|
chatID := msg.Source.ChatID
|
|
cfg, _ := json.Marshal(slackBindingConfig{ChannelID: chatID})
|
|
if msg.Source.ChatType == channel.ChatTypeP2P {
|
|
return chatID, cfg, msg.Source.ThreadID
|
|
}
|
|
threadRoot := msg.Source.ThreadID
|
|
if threadRoot == "" {
|
|
threadRoot = msg.MessageID
|
|
}
|
|
return chatID + ":" + threadRoot, cfg, threadRoot
|
|
}
|
|
|
|
func decodeSlackRaw(msg channel.InboundMessage) (slackRawEvent, error) {
|
|
var raw slackRawEvent
|
|
if len(msg.Raw) == 0 {
|
|
return slackRawEvent{}, errors.New("slack: inbound message Raw is empty")
|
|
}
|
|
if err := json.Unmarshal(msg.Raw, &raw); err != nil {
|
|
return slackRawEvent{}, fmt.Errorf("decode slack inbound raw: %w", err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func nullText(s string) pgtype.Text {
|
|
if s == "" {
|
|
return pgtype.Text{}
|
|
}
|
|
return pgtype.Text{String: s, Valid: true}
|
|
}
|
|
|
|
// ---- installation routing ----
|
|
|
|
type installationResolver struct{ q *db.Queries }
|
|
|
|
func (r *installationResolver) ResolveInstallation(ctx context.Context, msg channel.InboundMessage) (engine.ResolvedInstallation, error) {
|
|
raw, err := decodeSlackRaw(msg)
|
|
if err != nil {
|
|
return engine.ResolvedInstallation{}, err
|
|
}
|
|
inst, err := r.q.GetChannelInstallationByAppID(ctx, db.GetChannelInstallationByAppIDParams{
|
|
ChannelType: string(TypeSlack),
|
|
AppID: raw.TeamID, // Slack team_id is stored in the routing-key slot
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return engine.ResolvedInstallation{}, engine.ErrInstallationNotFound
|
|
}
|
|
return engine.ResolvedInstallation{}, err
|
|
}
|
|
return engine.ResolvedInstallation{
|
|
ID: inst.ID,
|
|
WorkspaceID: inst.WorkspaceID,
|
|
AgentID: inst.AgentID,
|
|
InstallerUserID: inst.InstallerUserID,
|
|
Active: inst.Status == "active",
|
|
Platform: inst,
|
|
}, nil
|
|
}
|
|
|
|
// ---- identity ----
|
|
|
|
type identityResolver struct{ q *db.Queries }
|
|
|
|
func (r *identityResolver) ResolveSender(ctx context.Context, inst engine.ResolvedInstallation, msg channel.InboundMessage) (engine.ResolvedIdentity, error) {
|
|
binding, err := r.q.GetChannelUserBindingByUserID(ctx, db.GetChannelUserBindingByUserIDParams{
|
|
InstallationID: inst.ID,
|
|
ChannelUserID: msg.Source.SenderID,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return engine.ResolvedIdentity{}, engine.ErrSenderUnbound
|
|
}
|
|
return engine.ResolvedIdentity{}, err
|
|
}
|
|
// Binding existence no longer proves membership (no FK); re-check.
|
|
if _, err := r.q.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
|
UserID: binding.MulticaUserID,
|
|
WorkspaceID: inst.WorkspaceID,
|
|
}); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return engine.ResolvedIdentity{}, engine.ErrSenderNotMember
|
|
}
|
|
return engine.ResolvedIdentity{}, err
|
|
}
|
|
return engine.ResolvedIdentity{UserID: binding.MulticaUserID}, nil
|
|
}
|
|
|
|
// ---- dedup ----
|
|
|
|
type deduper struct{ q *db.Queries }
|
|
|
|
func (r *deduper) Claim(ctx context.Context, installationID pgtype.UUID, messageID string) (pgtype.UUID, error) {
|
|
claim, err := r.q.ClaimChannelInboundDedup(ctx, db.ClaimChannelInboundDedupParams{
|
|
InstallationID: installationID,
|
|
MessageID: messageID,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return pgtype.UUID{}, engine.ErrDuplicate
|
|
}
|
|
return pgtype.UUID{}, err
|
|
}
|
|
return claim.ClaimToken, nil
|
|
}
|
|
|
|
func (r *deduper) Mark(ctx context.Context, installationID pgtype.UUID, messageID string, claimToken pgtype.UUID) error {
|
|
_, err := r.q.MarkChannelInboundDedupProcessed(ctx, db.MarkChannelInboundDedupProcessedParams{
|
|
InstallationID: installationID,
|
|
MessageID: messageID,
|
|
ClaimToken: claimToken,
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (r *deduper) Release(ctx context.Context, installationID pgtype.UUID, messageID string, claimToken pgtype.UUID) error {
|
|
_, err := r.q.ReleaseChannelInboundDedup(ctx, db.ReleaseChannelInboundDedupParams{
|
|
InstallationID: installationID,
|
|
MessageID: messageID,
|
|
ClaimToken: claimToken,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// ---- session bind / append ----
|
|
|
|
type sessionBinder struct{ session *engine.ChatSession }
|
|
|
|
func (r *sessionBinder) EnsureSession(ctx context.Context, p engine.EnsureSessionParams) (pgtype.UUID, error) {
|
|
bindingKey, config, _ := slackSessionRouting(p.Message)
|
|
return r.session.EnsureSession(ctx, engine.EnsureSessionInput{
|
|
WorkspaceID: p.Installation.WorkspaceID,
|
|
AgentID: p.Installation.AgentID,
|
|
InstallationID: p.Installation.ID,
|
|
Sender: p.Sender,
|
|
BindingKey: bindingKey,
|
|
BindingConfig: config,
|
|
ChatType: p.Message.Source.ChatType,
|
|
})
|
|
}
|
|
|
|
func (r *sessionBinder) AppendMessage(ctx context.Context, p engine.AppendParams) (engine.AppendResult, error) {
|
|
_, _, replyThread := slackSessionRouting(p.Message)
|
|
return r.session.AppendUserMessage(ctx, engine.AppendInput{
|
|
SessionID: p.SessionID,
|
|
Sender: p.Sender,
|
|
InstallationID: p.InstallationID,
|
|
Body: p.Message.Text,
|
|
// Slack text is not enriched, so the command source is the body itself.
|
|
CommandText: p.Message.Text,
|
|
MessageID: p.Message.MessageID,
|
|
ThreadID: replyThread,
|
|
ClaimToken: p.ClaimToken,
|
|
})
|
|
}
|
|
|
|
// ---- audit ----
|
|
|
|
type auditor struct{ q *db.Queries }
|
|
|
|
func (r *auditor) RecordDrop(ctx context.Context, instID pgtype.UUID, msg channel.InboundMessage, reason engine.DropReason) error {
|
|
raw, _ := decodeSlackRaw(msg) // event_type is best-effort; a decode miss still audits the drop
|
|
return r.q.RecordChannelInboundDrop(ctx, db.RecordChannelInboundDropParams{
|
|
ChannelType: string(TypeSlack),
|
|
EventType: raw.EventType,
|
|
DropReason: string(reason),
|
|
InstallationID: instID,
|
|
ChannelChatID: nullText(msg.Source.ChatID),
|
|
ChannelEventID: nullText(msg.EventID),
|
|
ChannelMessageID: nullText(msg.MessageID),
|
|
})
|
|
}
|