Files
multica/server/internal/integrations/channel/message.go
Bohan Jiang 3e21e58df0 feat(channel): channel-agnostic engine (Supervisor + Router); Feishu as channel.Channel (MUL-3620) (#4512)
* feat(channel): add channel-agnostic engine Supervisor (MUL-3620)

Stage-1 (MUL-3515) shipped the channel abstraction but nothing drove it.
Add the generic engine that does:

- channel.InboundHandler + Config.Handler: the single shared inbound entry
  the engine injects into every adapter (Hermes set_message_handler model).
- channel.Channel.Connect now blocks for the connection lifetime (doc), so
  the supervisor can tie lease renewal to connection liveness.
- new package channel/engine: Supervisor, generalized out of lark.Hub. It
  enumerates active installations across ALL channel types (no hard-coded
  feishu), fences each behind the WS lease CAS, builds the platform Channel
  via channel.Registry, drives Connect/Disconnect with backoff+jitter, and
  restarts on credential rotation. Knows nothing about any platform.

channel.Channel is now driven by an engine; integrations/channel has an
external consumer. Feishu adapter + boot cutover follow next.

Tests: supervisor_test.go covers lease CAS, reclaim, reap-on-revoke,
rotation restart + token fencing, backoff on build error, lease-loss
teardown, bounded release, shutdown timeout. Race-clean.

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

* feat(lark): drive Feishu through the channel engine; remove lark.Hub (MUL-3620)

Refactor Feishu into the first channel.Channel and cut boot over to the
channel-agnostic engine.Supervisor, removing the Feishu-only Hub.

- feishuChannel implements channel.Channel: Connect runs the existing
  WS long-conn connector for one installation; Send posts a text reply
  via the Lark IM API; Capabilities declares Feishu's feature set.
  RegisterFeishu wires it to channel.TypeFeishu — adding a platform is
  now 'register a Factory', no engine edit.
- FeishuRuntime extracts the former Hub.handleEvent / scheduleReply:
  runs the Dispatcher and drives the detached typing indicator +
  OutcomeReplier off the connector ACK path. main.go drains it on
  shutdown after the supervisor stops delivering events.
- channelInstallationStore (engine.InstallationStore) enumerates active
  installations across ALL channel types via the new de-hardcoded query
  ListAllActiveChannelInstallations; the Supervisor routes each row to
  its registered Factory by channel_type. Generic per-row fingerprint
  replaces the feishu-specific one.
- boot: engine.Supervisor replaces lark.Hub.Run; MULTICA_LARK_HUB_DISABLED
  keeps its name for runbook compatibility.
- delete hub.go / hub_pgx.go / hub_test.go; relocate the connector
  contract (EventConnector/EventEmitter), uuidString, and the reply-path
  tests (-> feishu_runtime_test.go) so coverage is preserved.

No channel_* schema change. Feishu behaviour unchanged; lark + channel +
engine tests green under -race; go build/vet ./... clean.

Remaining (follow-up): lift the Dispatcher pipeline into a channel-
agnostic engine.Router over channel.InboundMessage + resolver interfaces,
so the inbound core stops being Lark-shaped and adding a channel needs
zero core edits (validated by Slack, MUL-3516).

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

* feat(channel): add channel-agnostic engine.Router (inbound pipeline) (MUL-3620)

Generalize lark.Dispatcher's inbound pipeline into engine.Router: the single
shared channel.InboundHandler the Supervisor injects into every Channel. It
routes by ChannelType to a registered ResolverSet and runs the same ordered
pipeline for every platform (install route -> two-phase dedup -> group @bot
filter -> identity+membership -> ensure session -> append+mark -> /issue ->
debounced run), then drives the detached OutboundReplier + typing indicator.

Platform specifics live behind resolver interfaces (InstallationResolver,
IdentityResolver, Deduper, SessionBinder, Auditor, OutboundReplier,
TypingNotifier) + shared services (IssueCreator/TaskEnqueuer/SessionReader).
Adding a platform is 'register a ResolverSet', not 'edit the Router'. Outcome
/ DropReason values match the legacy lark ones 1:1.

Additive: lark.Dispatcher untouched and still wired; the feishu ResolverSet,
the cutover, and the old-path removal land next. channel.InboundMessage gains
ForceFresh (the normalized /fresh affordance). Batcher moved into engine.

router_test.go covers the pipeline invariants (routing, dedup finalize
states, group filter, identity, membership, ensure/append, /issue, debounce,
flush offline, force-fresh, drain) with generic fakes; race-clean.

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

* feat(lark): cut Feishu over to engine.Router; remove lark.Dispatcher; core no longer Lark-shaped (MUL-3620)

Wire the channel-agnostic engine.Router (added in the prior commit) as the
shared inbound handler and refactor Feishu into a ResolverSet, completing the
generic-engine cutover. The inbound core (engine.Router) now contains zero
platform specifics.

- Feishu ResolverSet (feishu_resolvers.go): InstallationResolver,
  IdentityResolver, Deduper, SessionBinder, Auditor, OutboundReplier,
  TypingNotifier — each backed by the existing ChannelStore / ChatSessionService
  / OutcomeReplier / typing indicator, translating at the channel.InboundMessage
  boundary (platform fields read from Raw). origin_type stays 'lark_chat'.
- feishuChannel now produces a normalized channel.InboundMessage and hands it to
  the engine handler via channel.Config.Handler; the old Raw round-trip through
  lark.Dispatcher is gone.
- Remove lark.Dispatcher, FeishuRuntime, and lark's pending_batcher (the engine
  owns the pipeline + batcher now); their behavioural coverage moved to
  engine.Router tests. Surviving native types (InboundMessage / Outcome /
  DispatchResult) relocated to feishu_types.go.

elon review nits addressed:
- The channel engine (Registry + Router + Supervisor) is now built
  UNCONDITIONALLY, outside the MULTICA_LARK_SECRET_KEY gate, so a non-Lark
  deployment runs it; Feishu registers its Factory + ResolverSet only when its
  key is present.
- channel.Config.Raw is now genuinely the platform config JSONB
  (channel_installation.config): the feishu factory builds a credentials-only
  Installation from it, and the workspace/agent identity is resolved per message
  by the Router — no full-db-row marshaling.
- feishuChannel gains direct unit tests: factory config decode, Send text +
  reply-target mapping, Capabilities, inbound normalization + Raw round-trip,
  msg-type + result mapping.

No channel_* schema change. go build/vet ./... clean; channel + engine + lark
green under -race. Feishu behaviour preserved (pipeline logic lifted verbatim,
only generalized).

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

* docs(channel): fix stale comments on the channel engine boot (MUL-3620)

Address Elon's review nit: three comments still described the pre-cutover
behavior.

- handler.go: ChannelSupervisor is built UNCONDITIONALLY now, not nil when
  MULTICA_LARK_SECRET_KEY is unset.
- main.go: same — the supervisor always exists; only MULTICA_LARK_HUB_DISABLED
  parks it.
- router.go: with no platform registered the store still lists active rows;
  Registry.Build returns ErrUnknownType and the supervisor backs off (it does
  not 'find no installations').

Comment-only; no behavior change.

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-24 17:01:33 +08:00

182 lines
7.2 KiB
Go

package channel
import "encoding/json"
// ChatType discriminates a 1:1 direct conversation with the bot from a
// multi-party group chat. Product behavior differs: direct chats ingest
// every message; group chats only ingest messages explicitly addressed
// to the bot (@-mention or reply to a bot message). The wire values match
// the existing lark_chat_session_binding.lark_chat_type constraint so the
// generalized channel_* table backfills 1:1.
type ChatType string
const (
// ChatTypeP2P is a direct (peer-to-peer) conversation with the bot.
ChatTypeP2P ChatType = "p2p"
// ChatTypeGroup is a multi-party group conversation.
ChatTypeGroup ChatType = "group"
)
// MsgType is the normalized, cross-platform message kind. Adapters map
// their platform's native type onto this small closed set; the platform's
// raw type string (Lark "post" / "merge_forward" / "interactive", …) is
// NOT represented here — it stays in InboundMessage.Raw and is read only
// by the adapter. The core only ever needs to know "text vs media, and
// which media".
type MsgType string
const (
// MsgTypeText is a plain or rich text message. The human-readable
// content is flattened into InboundMessage.Text by the adapter.
MsgTypeText MsgType = "text"
// MsgTypeImage is an image attachment.
MsgTypeImage MsgType = "image"
// MsgTypeFile is a generic file attachment.
MsgTypeFile MsgType = "file"
// MsgTypeAudio is a voice / audio attachment.
MsgTypeAudio MsgType = "audio"
// MsgTypeVideo is a video attachment.
MsgTypeVideo MsgType = "video"
// MsgTypeUnknown is the fallback for a platform type the adapter does
// not map. The core treats it as a non-text, non-actionable message.
MsgTypeUnknown MsgType = "unknown"
)
// Source carries the cross-platform routing identity of an inbound
// message — every field here is true on every platform. Platform-specific
// routing keys (a Lark app_id, a Slack team id) are resolved to an
// installation by the adapter and do NOT appear on Source.
type Source struct {
// ChannelType is the platform the message arrived on; it equals the
// owning Channel's Type.
ChannelType Type
// ChatID is the platform conversation identifier. One ChatID maps to
// one Multica chat_session via the channel_chat_session_binding.
ChatID string
// ChatType discriminates direct from group conversations.
ChatType ChatType
// SenderID is the platform-native, per-installation user identifier
// (Lark open_id, Slack user id, …). It is stable WITHIN one
// installation and is the key the identity binding is stored under.
// It is NOT comparable across installations.
SenderID string
// SenderStableID is the platform's cross-installation stable identity
// for the sender when one exists (Lark union_id, …), otherwise empty.
// Captured opportunistically for future cross-installation identity
// merging; the core treats an empty value as "not available".
SenderStableID string
// ThreadID is the platform thread / topic the message belongs to,
// when threading applies and the message is inside a thread. Empty
// means a top-level conversation message. The core persists it so a
// decoupled outbound reply can be threaded back into the same topic.
ThreadID string
}
// MediaRef references a media attachment that the adapter has ALREADY
// persisted to object storage before the message reaches the core. The
// core never holds raw bytes — only this reference — so the envelope
// stays small and platform-neutral.
type MediaRef struct {
// Type is the normalized media kind (image / file / audio / video).
Type MsgType
// StorageKey locates the persisted object in Multica object storage.
StorageKey string
// Filename is the original display name, when the platform supplies
// one.
Filename string
// MimeType is the content type, when known.
MimeType string
// SizeBytes is the object size in bytes, or 0 when unknown.
SizeBytes int64
}
// ReplyCtx describes the message an inbound message quotes / replies to.
// It is nil when the inbound message is not a reply.
type ReplyCtx struct {
// MessageID is the immediate parent message's platform id (the
// message being quoted).
MessageID string
// RootID is the thread/root anchor the platform reports, when any.
RootID string
}
// InboundMessage is the single normalized shape the core consumes. Every
// adapter translates its platform's raw payload into this struct; the
// core's router, dedup, identity check, and persistence read ONLY these
// fields. Per the boundary rule (MUL-3515 §2) the struct holds only
// cross-platform-true fields; everything platform-specific lives in Raw.
type InboundMessage struct {
// EventID is the platform's delivery/event identifier and MessageID
// is the platform's message identifier. Together they back the
// idempotency layer: a platform may redeliver the same event on
// reconnect, and dedup keys on (installation, MessageID).
EventID string
MessageID string
// Source is the routing identity (chat, sender, thread).
Source Source
// Type is the normalized message kind.
Type MsgType
// Text is the human-readable content, flattened by the adapter. For
// non-text messages it may be empty or a short placeholder; the media
// itself is in MediaRefs.
Text string
// MediaRefs are the attachments, already persisted to object storage.
MediaRefs []MediaRef
// ReplyTo is the quoted/replied-to context, or nil.
ReplyTo *ReplyCtx
// AddressedToBot is the adapter's normalized verdict on whether a
// GROUP message is an interaction with the bot (@-mention or reply to
// a bot message). It is meaningless for direct (p2p) chats and the
// core ignores it there. It is a normalized boolean, not platform
// data — the platform-specific signals it was derived from (mention
// arrays, parent ids) stay in Raw.
AddressedToBot bool
// ForceFresh asks the core to start a fresh agent session for this
// message instead of resuming the prior one (the platform's "/fresh"
// affordance). The adapter normalizes its platform-specific trigger
// into this boolean; the core only reads the flag.
ForceFresh bool
// Raw is the untouched platform payload. Adapters stash platform-
// specific fields here (Lark raw msg_type / parent_id / root_id /
// mention arrays, …) and read them back only inside the adapter. The
// core never reads Raw — that is the whole point of the boundary.
Raw json.RawMessage
}
// OutboundMessage is the minimal outbound reply the core can ask any
// Channel to deliver: a text body into a chat, optionally threaded or
// quoting a specific message. Rich cards, media uploads, and outbound
// webhooks are deliberately NOT modeled here (MUL-3515 decision §6) — an
// adapter that supports richer output exposes it on its own type, not on
// this cross-platform envelope.
type OutboundMessage struct {
// ChatID is the destination conversation (the platform chat id).
ChatID string
// Text is the message body.
Text string
// ThreadID, when set, threads the reply into the given platform
// thread / topic. Empty sends at the chat level.
ThreadID string
// ReplyTo, when set, quote-replies to the given platform message id.
ReplyTo string
}
// SendResult is the outcome of Channel.Send.
type SendResult struct {
// MessageID is the platform's identifier for the delivered message.
MessageID string
}