Files
multica/server/internal/integrations/channel/engine/session.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

333 lines
13 KiB
Go

package engine
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/integrations/channel"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// This file is the SHARED, channel-agnostic chat-session service every IM
// adapter reuses (MUL-3516). It was lifted out of the Feishu-specific
// lark.chatSessionService so that adding an IM never re-implements the
// session/append/`/issue` machinery — the platform adapter contributes only a
// channel_type, its session titles, and (because enrichment is
// platform-specific) the command-parse source. The logic — find-or-create
// session + binding, append message + touch + reply-target + in-tx dedup mark,
// `/issue` parse — is identical across platforms and carries the channel_type
// discriminator through the generalized channel_* tables.
const pgSQLStateUniqueViolation = "23505"
// TxStarter abstracts transaction creation. Satisfied by *pgxpool.Pool. Kept
// local to the engine so the integration layer never back-references
// internal/service.
type TxStarter interface {
Begin(ctx context.Context) (pgx.Tx, error)
}
// SessionQueries is the narrow slice of the generated queries the ChatSession
// service needs. *db.Queries satisfies it through the dbSessionQueries adapter
// (whose WithTx returns the interface type); tests supply an in-memory fake.
type SessionQueries interface {
WithTx(tx pgx.Tx) SessionQueries
GetChannelChatSessionBinding(ctx context.Context, arg db.GetChannelChatSessionBindingParams) (db.ChannelChatSessionBinding, error)
CreateChatSession(ctx context.Context, arg db.CreateChatSessionParams) (db.ChatSession, error)
CreateChannelChatSessionBinding(ctx context.Context, arg db.CreateChannelChatSessionBindingParams) (db.ChannelChatSessionBinding, error)
CreateChatMessage(ctx context.Context, arg db.CreateChatMessageParams) (db.ChatMessage, error)
TouchChatSession(ctx context.Context, id pgtype.UUID) error
GetMostRecentUserChatMessage(ctx context.Context, chatSessionID pgtype.UUID) (db.ChatMessage, error)
UpdateChannelChatSessionBindingReplyTarget(ctx context.Context, arg db.UpdateChannelChatSessionBindingReplyTargetParams) error
MarkChannelInboundDedupProcessed(ctx context.Context, arg db.MarkChannelInboundDedupProcessedParams) (int64, error)
}
// dbSessionQueries adapts *db.Queries to SessionQueries — the only purpose is
// to give WithTx an interface return type so the transactional path stays
// behind SessionQueries.
type dbSessionQueries struct{ q *db.Queries }
func (a dbSessionQueries) WithTx(tx pgx.Tx) SessionQueries {
return dbSessionQueries{q: a.q.WithTx(tx)}
}
func (a dbSessionQueries) GetChannelChatSessionBinding(ctx context.Context, arg db.GetChannelChatSessionBindingParams) (db.ChannelChatSessionBinding, error) {
return a.q.GetChannelChatSessionBinding(ctx, arg)
}
func (a dbSessionQueries) CreateChatSession(ctx context.Context, arg db.CreateChatSessionParams) (db.ChatSession, error) {
return a.q.CreateChatSession(ctx, arg)
}
func (a dbSessionQueries) CreateChannelChatSessionBinding(ctx context.Context, arg db.CreateChannelChatSessionBindingParams) (db.ChannelChatSessionBinding, error) {
return a.q.CreateChannelChatSessionBinding(ctx, arg)
}
func (a dbSessionQueries) CreateChatMessage(ctx context.Context, arg db.CreateChatMessageParams) (db.ChatMessage, error) {
return a.q.CreateChatMessage(ctx, arg)
}
func (a dbSessionQueries) TouchChatSession(ctx context.Context, id pgtype.UUID) error {
return a.q.TouchChatSession(ctx, id)
}
func (a dbSessionQueries) GetMostRecentUserChatMessage(ctx context.Context, chatSessionID pgtype.UUID) (db.ChatMessage, error) {
return a.q.GetMostRecentUserChatMessage(ctx, chatSessionID)
}
func (a dbSessionQueries) UpdateChannelChatSessionBindingReplyTarget(ctx context.Context, arg db.UpdateChannelChatSessionBindingReplyTargetParams) error {
return a.q.UpdateChannelChatSessionBindingReplyTarget(ctx, arg)
}
func (a dbSessionQueries) MarkChannelInboundDedupProcessed(ctx context.Context, arg db.MarkChannelInboundDedupProcessedParams) (int64, error) {
return a.q.MarkChannelInboundDedupProcessed(ctx, arg)
}
// SessionTitles are the per-platform display titles a freshly created
// chat_session gets (the first message has not been appended yet, so the title
// cannot be derived from content). The adapter supplies its own wording.
type SessionTitles struct {
Group string
Direct string
Fallback string
}
func (t SessionTitles) forType(ct channel.ChatType) string {
switch ct {
case channel.ChatTypeGroup:
return t.Group
case channel.ChatTypeP2P:
return t.Direct
default:
return t.Fallback
}
}
// ChatSession is the shared chat-session service. One instance is built per
// channel_type (so the binding rows carry the right discriminator); the logic
// is otherwise platform-neutral.
type ChatSession struct {
q SessionQueries
tx TxStarter
channelType channel.Type
titles SessionTitles
}
// NewChatSession builds the shared service over the generated queries. tx is
// required: AppendUserMessage runs the dedup Mark inside the chat_message
// transaction so the durable write and the Mark commit (or roll back) together.
func NewChatSession(q *db.Queries, tx TxStarter, channelType channel.Type, titles SessionTitles) *ChatSession {
return &ChatSession{q: dbSessionQueries{q: q}, tx: tx, channelType: channelType, titles: titles}
}
// newChatSessionWith is the test seam: it accepts a SessionQueries directly so
// an in-memory fake can stand in for *db.Queries.
func newChatSessionWith(q SessionQueries, tx TxStarter, channelType channel.Type, titles SessionTitles) *ChatSession {
return &ChatSession{q: q, tx: tx, channelType: channelType, titles: titles}
}
// EnsureSessionInput is the channel-agnostic input for EnsureSession.
//
// BindingKey is the SESSION-ISOLATION key (stored as channel_chat_id; one
// chat_session per (installation_id, BindingKey)). It is intentionally NOT the
// same thing as "the chat to reply into": the adapter composes it so that
// distinct conversations get distinct sessions — Feishu passes the chat id;
// Slack passes the channel id for a DM, and the channel id PLUS the thread root
// for a channel/thread, so two @bot threads in one Slack channel do not collapse
// into one transcript (the Hermes model: IM-independent, Slack groups isolated
// by thread root). A raw platform chat id must never be passed straight through
// as the key for a threaded platform.
//
// BindingConfig is opaque platform routing the key alone cannot carry — e.g.
// Slack's real channel_id when BindingKey is a composite — persisted on the
// binding's config for the outbound path to read back. nil means "{}".
//
// Sender is the already-resolved Multica user (the session creator: the sole
// human for p2p, the installer for group chats — the caller decides which).
type EnsureSessionInput struct {
WorkspaceID pgtype.UUID
AgentID pgtype.UUID
InstallationID pgtype.UUID
Sender pgtype.UUID
BindingKey string
BindingConfig []byte
ChatType channel.ChatType
}
// EnsureSession returns the chat_session.id bound to (installation, BindingKey),
// creating it (with its channel_chat_session_binding) on first contact. The
// race between two concurrent first messages is resolved by the
// UNIQUE (installation_id, channel_chat_id) constraint: the loser re-reads the
// winner's row.
func (s *ChatSession) EnsureSession(ctx context.Context, in EnsureSessionInput) (pgtype.UUID, error) {
lookup := db.GetChannelChatSessionBindingParams{InstallationID: in.InstallationID, ChannelChatID: in.BindingKey}
existing, err := s.q.GetChannelChatSessionBinding(ctx, lookup)
if err == nil {
return existing.ChatSessionID, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return pgtype.UUID{}, fmt.Errorf("lookup chat session binding: %w", err)
}
id, err := s.createSessionAndBinding(ctx, in)
if err == nil {
return id, nil
}
if isUniqueViolation(err) {
existing, lookupErr := s.q.GetChannelChatSessionBinding(ctx, lookup)
if lookupErr == nil {
return existing.ChatSessionID, nil
}
return pgtype.UUID{}, fmt.Errorf("race re-read after unique violation: %w", lookupErr)
}
return pgtype.UUID{}, err
}
func (s *ChatSession) createSessionAndBinding(ctx context.Context, in EnsureSessionInput) (pgtype.UUID, error) {
tx, err := s.tx.Begin(ctx)
if err != nil {
return pgtype.UUID{}, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
qtx := s.q.WithTx(tx)
session, err := qtx.CreateChatSession(ctx, db.CreateChatSessionParams{
WorkspaceID: in.WorkspaceID,
AgentID: in.AgentID,
CreatorID: in.Sender,
Title: s.titles.forType(in.ChatType),
})
if err != nil {
return pgtype.UUID{}, fmt.Errorf("create chat session: %w", err)
}
bindingConfig := in.BindingConfig
if len(bindingConfig) == 0 {
bindingConfig = []byte("{}")
}
if _, err := qtx.CreateChannelChatSessionBinding(ctx, db.CreateChannelChatSessionBindingParams{
ChatSessionID: session.ID,
InstallationID: in.InstallationID,
ChannelType: string(s.channelType),
ChannelChatID: in.BindingKey,
ChatType: string(in.ChatType),
Config: bindingConfig,
}); err != nil {
return pgtype.UUID{}, err
}
if err := tx.Commit(ctx); err != nil {
return pgtype.UUID{}, fmt.Errorf("commit: %w", err)
}
return session.ID, nil
}
// AppendInput is the channel-agnostic input for AppendUserMessage. Body is the
// full stored text (including any platform enrichment); CommandText is the
// user's OWN typed text used for `/issue` parsing (empty falls back to Body) —
// the adapter supplies it because enrichment is platform-specific. ClaimToken
// is the dedup owner-fence: when valid, the Mark runs inside this method's tx.
//
// MessageID and ThreadID are the REAL platform message id and thread id of this
// trigger — the outbound reply target recorded on the binding (last_message_id /
// last_thread_id), NOT the session BindingKey. Because each isolated session has
// its own binding row, recording the real thread here per session does not clash
// across sibling threads.
type AppendInput struct {
SessionID pgtype.UUID
Sender pgtype.UUID
InstallationID pgtype.UUID
Body string
CommandText string
MessageID string
ThreadID string
ClaimToken pgtype.UUID
}
// AppendUserMessage writes the user message into the chat_session (touching it
// and recording the reply target), runs the in-tx dedup Mark when a claim token
// is supplied, and returns the parsed `/issue` command when present. Returns
// ErrClaimLost when a concurrent reclaim rotated the dedup token mid-flight, in
// which case the whole transaction rolls back (no chat_message lands).
func (s *ChatSession) AppendUserMessage(ctx context.Context, in AppendInput) (AppendResult, error) {
tx, err := s.tx.Begin(ctx)
if err != nil {
return AppendResult{}, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
qtx := s.q.WithTx(tx)
// Parse before the insert so the bare-`/issue` previous-message fallback
// queries the message set that does NOT yet include this message.
commandSource := in.CommandText
if commandSource == "" {
commandSource = in.Body
}
cmd, _ := ParseIssueCommand(commandSource)
if cmd != nil && cmd.Title == "" {
prev, err := qtx.GetMostRecentUserChatMessage(ctx, in.SessionID)
if err == nil {
cmd.Title = titleFromPreviousMessage(prev.Content)
} else if !errors.Is(err, pgx.ErrNoRows) {
return AppendResult{}, fmt.Errorf("previous message lookup: %w", err)
}
}
if _, err := qtx.CreateChatMessage(ctx, db.CreateChatMessageParams{
ChatSessionID: in.SessionID,
Role: "user",
Content: in.Body,
}); err != nil {
return AppendResult{}, fmt.Errorf("create chat message: %w", err)
}
if err := qtx.TouchChatSession(ctx, in.SessionID); err != nil {
return AppendResult{}, fmt.Errorf("touch chat session: %w", err)
}
// Record the latest trigger so the decoupled outbound patcher can thread
// its reply back into the originating topic.
if in.MessageID != "" {
if err := qtx.UpdateChannelChatSessionBindingReplyTarget(ctx, db.UpdateChannelChatSessionBindingReplyTargetParams{
ChatSessionID: in.SessionID,
LastMessageID: textOrNull(in.MessageID),
LastThreadID: textOrNull(in.ThreadID),
}); err != nil {
return AppendResult{}, fmt.Errorf("update reply target: %w", err)
}
}
markedInTx := false
if in.ClaimToken.Valid && in.MessageID != "" {
rows, err := qtx.MarkChannelInboundDedupProcessed(ctx, db.MarkChannelInboundDedupProcessedParams{
InstallationID: in.InstallationID,
MessageID: in.MessageID,
ClaimToken: in.ClaimToken,
})
if err != nil {
return AppendResult{}, fmt.Errorf("mark dedup processed: %w", err)
}
if rows == 0 {
// Another worker re-claimed the dedup row; roll back via the
// deferred Rollback so no second chat_message lands.
return AppendResult{}, ErrClaimLost
}
markedInTx = true
}
if err := tx.Commit(ctx); err != nil {
return AppendResult{}, fmt.Errorf("commit: %w", err)
}
return AppendResult{IssueCommand: cmd, DedupMarked: markedInTx}, nil
}
func isUniqueViolation(err error) bool {
var pg *pgconn.PgError
if errors.As(err, &pg) {
return pg.Code == pgSQLStateUniqueViolation
}
return false
}
func textOrNull(s string) pgtype.Text {
if s == "" {
return pgtype.Text{}
}
return pgtype.Text{String: s, Valid: true}
}