Files
multica/server/internal/integrations/lark/ws_frame_decoder_test.go
Bohan Jiang ce28d0aa0e feat(integrations): add platform-agnostic channel foundation (MUL-3515) (#4412)
* feat(integrations): add platform-agnostic channel foundation

Introduce server/internal/integrations/channel — the contract every
inbound IM integration implements, so the core never learns a platform's
event JSON. Four pieces:

- Channel interface (Type/Connect/Disconnect/Send/Capabilities) + Factory
  + Config (channel_type + opaque JSON blob, maps to channel_installation).
- Normalized InboundMessage/OutboundMessage envelopes + Source/MediaRef/
  ReplyCtx/MsgType/ChatType. Envelope holds only cross-platform-true
  fields; platform specifics live in Raw, read only by the adapter.
- Capability bitmask: declaration only, no degrade logic in core.
- Registry: Type->Factory map, last-writer-wins, concurrency-safe.

Pure package (no DB/network/platform deps). Foundation for MUL-3515; the
lark cutover + lark_*->channel_* generalization land in follow-up PRs.

MUL-3515

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

* feat(channel): generalize lark_* tables into channel_* (DB layer)

Migration 123 creates channel_installation / channel_user_binding /
channel_chat_session_binding / channel_inbound_message_dedup /
channel_inbound_audit / channel_outbound_card_message /
channel_binding_token. Each carries a channel_type discriminator and a
JSONB config for platform-specific identifiers/credentials; cross-platform
columns stay flat. Existing Feishu rows are backfilled (channel_type=
'feishu', app_secret_encrypted via base64). NO foreign keys / cascades
(MUL-3515 §4) — integrity moves to the app layer in the cutover.

queries/channel.sql ports the lark query surface to channel_*, JSONB-aware,
plus DeleteChannelUserBindingsByWorkspaceMember /
DeleteChannelChatSessionBindingBySession for the app-layer cleanup that
replaces the removed cascades.

lark_* tables/queries are left in place here and removed once the Go
cutover lands, so this commit ships green on its own.

Verified: sqlc generate, go build ./..., full migrate chain (1..123) on
Postgres 17, and a real-data backfill spot-check (base64 round-trip,
NULL-strip, functional unique index on (channel_type, app_id)).

MUL-3515

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

* fix(channel): name app_id query param + multi-IM install key + null-safe binding merge

Addresses review on MUL-3515 (PR #4412):

- GetChannelInstallationByAppID: explicitly name params and cast app_id to
  ::text so sqlc emits AppID string. A bare $2 next to `config ->> 'app_id'`
  was mis-attributed to the JSONB config column, generating Config []byte.

- channel_installation uniqueness -> (workspace_id, agent_id, channel_type),
  with the UpsertChannelInstallation conflict key matched. Lets one agent
  hold one installation per IM (feishu + slack + ...) instead of a later
  install clobbering an earlier one. Behaviorally identical in the current
  feishu-only world; "one agent, at most one IM overall" stays an app-layer
  rule per MUL-3515 §4, not a DB constraint.

- CreateChannelUserBinding merges jsonb_strip_nulls(EXCLUDED.config) so a
  re-bind carrying {"union_id": null} no longer erases an already-captured
  union_id, restoring the old COALESCE(EXCLUDED.union_id, ...) semantics.

Regenerated with sqlc v1.31.1. Verified on PG17: re-install replaces in
place, feishu+slack coexist, null re-bind keeps union_id, real union_id wins.

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

* feat(lark): channel-backed Feishu store + fix base64 backfill wrapping

Cutover step 1 of switching the lark Go code from lark_* onto the channel_*
tables (MUL-3515). Introduces the JSONB config boundary the rest of the
cutover sits on, and fixes a latent backfill bug surfaced while building it.

- migration 123: strip newlines from the app_secret_encrypted base64 backfill.
  PostgreSQL encode(...,'base64') MIME-wraps at 76 chars, and a secretbox-
  sealed ~72-byte secret exceeds that. Go's encoding/json decodes a JSON
  string into []byte with base64.StdEncoding, which rejects embedded newlines,
  so without the strip every migrated installation would fail to decrypt its
  app secret once reads move to channel_installation.config.

- store.go: flat domain types (Installation / UserBinding / ChatSessionBinding)
  with field parity to the retired db.Lark* rows, plus the feishu config codec.
  Row->domain mappers decode the JSONB config; the secret decoder is
  whitespace-tolerant so legacy MIME-wrapped data still round-trips, while the
  encoder emits unwrapped base64. Binding config encodes an absent union_id as
  "{}" so the upsert's jsonb_strip_nulls merge never clobbers a stored union_id.

- store_test.go: 72-byte secret round-trip, MIME-wrapped tolerance, optional
  null-strip, and flat-column preservation. Verified on PG17.

Field parity keeps the upcoming ~190 db.LarkInstallation call sites a
mechanical rename. No call sites switched yet; behavior unchanged.

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

* feat(lark): route inbound integration onto channel_* + explicit membership checks

Cutover step 2 (MUL-3515): switch the Feishu Go code from the lark_* queries to
channel_* via a ChannelStore adapter, and replace the removed member foreign key
with explicit application-layer membership checks. No user-visible behavior change.

- channel_store.go: ChannelStore embeds *db.Queries and SHADOWS the ~24 lark
  query methods with channel_*-backed equivalents, keeping the db.Lark*
  signatures so the dispatcher/hub/services and their ~20k lines of tests stay
  untouched; the feishu JSONB config is (de)coded by store.go. Adds
  IsWorkspaceMember and a tx-aware WithTx. Only production wiring swaps
  *db.Queries for *ChannelStore.

- Membership re-check (§4 removed the lark_user_binding -> member FK, so a
  binding row no longer proves current membership):
  * the dispatcher inbound identity step verifies membership after the binding
    lookup; a former member's stale binding is dropped as non_workspace_member
    + audited and never reaches chat_session (§4.3 safety property).
  * RedeemAndBind and BindInstallerTx replace the now-dead FK (23503) branch
    with an explicit IsWorkspaceMember gate, preserving the existing
    ErrBindingNotWorkspaceMember outcome without burning the token.

- router wires the ChannelStore into the patcher, typing indicator, dispatcher,
  hub, and the union_id/region backfills; constructor-based services wrap
  *db.Queries internally so their signatures and nil-check tests are unchanged.

Verified: go build ./... ; go vet ; gofmt ; go test -race ./internal/integrations/...
(full lark suite green unchanged + new membership drop/error tests). Adapter
field mappings (secret base64, union_id RMW, chat-id/open-id remaps, dedup,
token, card) checked end-to-end against a PG17 channel_* schema.

lark_* tables and queries remain (unused at runtime) until the S3 cleanup-hooks
and S4 drop-tables/rename commits.

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

* fix(channel): renumber generalization migration 123 -> 124

main merged 123_issue_stage after this branch forked, so the branch's 123_channel_generalization now collides on the migration number. The runner keys schema_migrations by full version string and would still apply both, but a duplicate number is a merge hazard and convention violation, so move the channel migration to the next free slot (124).

issue_stage (ALTER issue ADD COLUMN stage) and the channel generalization touch disjoint tables; verified on PG17 that 123_issue_stage applies cleanly on a DB already carrying 124_channel_generalization, so the two are order-independent. sqlc regenerated (v1.31.1): only the migration-number comment changed.

MUL-3515

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

* feat(channel): prune channel bindings on member removal + chat session delete

MUL-3515 §4 dropped every channel_* foreign key, so the old ON DELETE CASCADE that cleared a user's channel_user_binding when they left a workspace, and a chat's channel_chat_session_binding when its chat_session was deleted, no longer fires. Re-establish that integrity in the application layer, inside the existing transactions: revokeAndRemoveMember -> DeleteChannelUserBindingsByWorkspaceMember, DeleteChatSession -> DeleteChannelChatSessionBindingBySession.

Adds real-DB tests for both paths, including a scoping check that a remaining member's binding survives the prune. Verified on PG17: both new tests plus the existing revocation tests and the full handler package pass.

MUL-3515

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

* fix(channel): scope Lark/Feishu store reads to channel_type='feishu'

The S2 cutover routed the Feishu integration onto channel_*, but the Lark-facing ChannelStore wrappers read installation / chat-session-binding / outbound-card rows across ALL channel_type values. Once a second IM exists, that would let the Lark hub supervise a non-Feishu installation, the Lark install list show it, /lark/installations/{id} revoke another channel's row, and the outbound patcher / typing indicator act on a non-Feishu chat binding or card.

Add a channel_type predicate to the six read/list channel queries and pass channelTypeFeishu from every wrapper: GetChannelInstallation, GetChannelInstallationInWorkspace, ListChannelInstallationsByWorkspace, ListActiveChannelInstallations, GetChannelChatSessionBindingBySession, GetChannelOutboundCardByTask.

The S3 cleanup deletes (DeleteChannelUserBindingsByWorkspaceMember / DeleteChannelChatSessionBindingBySession) stay all-channel on purpose: a member leaving or a chat_session being deleted should clear every IM's binding. Adds a real-DB test that seeds a Slack installation/binding/card next to the Feishu ones and asserts the Lark wrappers never return them.

MUL-3515

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

* refactor(channel): replace db.Lark* translation layer with lark domain types

S2 introduced ChannelStore as a translation layer that read/wrote channel_* but kept the retired db.Lark* struct/param shapes so the dispatcher/hub/services and their ~20k lines of tests did not have to change. This collapses that layer: the store now takes and returns the package's flat domain types (Installation, UserBinding, ChatSessionBinding, InboundMessageDedup, BindingTokenRow, OutboundCardMessage) and the *Params types in params.go, with channel-neutral field names (ChannelUserID / ChannelChatID / ...). All call sites, fakes, and tests move to the domain types.

No behavior change: only channel_* is read/written (as before); db.Lark* is now unused, and the lark_* tables + queries/lark.sql are removed in the next commit. Verified on PG17: go build / vet / gofmt clean, go test -race ./internal/integrations/... green (the ~20k-line fake suite), and the lark + handler suites pass.

MUL-3515

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

* refactor(channel): drop lark_* tables and queries (remove old path)

The Go cutover (previous commit) moved the lark package entirely onto channel_* and the domain types, leaving the lark_* tables, queries/lark.sql, and the generated db.Lark* models unused. Remove them per the design (§5: replace, do not keep both): migration 125 drops the seven lark_* tables (data already lives in channel_* since migration 124), and queries/lark.sql is deleted + sqlc regenerated, removing the db.Lark* models and lark query methods.

The 125 down recreates the authoritative pre-drop schema (bot_union_id, region, per-installation dedup PK, thread-reply columns). Verified on PG17: fresh migrate up ends with lark_* gone + channel_* present; isolated 125 down/up round-trips correctly; go build / vet / gofmt clean; go test -race ./internal/integrations/... and the handler suite pass.

MUL-3515

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

* fix(migrations): remove trailing blank line at EOF of 125 down migration

git diff --check flagged a blank line at EOF of 125_drop_lark_tables.down.sql (a pg_dump-generation artifact). Whitespace only; the recreate SQL is unchanged.

MUL-3515

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

* refactor(channel): defer lark_* table drop to a follow-up migration

Preflight deploy review: dropping lark_* in the same release that cuts over (old migration 125) is not rollback/rolling-safe — the v0.3.27 release still reads lark_*, so a rolling deploy or a post-deploy code rollback would hit "relation does not exist". Remove the drop and keep the old tables for one release (standard expand/contract): migration 124 already backfilled lark_* -> channel_*, the new code reads/writes only channel_*, and the physical drop moves to a separate cleanup migration once this ships and is observed.

The lark_* tables remain in the schema, so sqlc regenerates the (now unused) db.Lark* models; queries/lark.sql stays deleted (the new code uses channel_*). No code path reads lark_* — only the destructive drop is deferred, keeping the design's no-compat-layer / no-dual-write rule while being deploy-safe.

MUL-3515

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

* fix(channel): skip orphaned installations in hub-boot active scan

Preflight deploy review: channel_installation dropped the workspace/agent FK (MUL-3515 §4), so unlike lark_installation it does not cascade away when its workspace is deleted or its agent is hard-deleted (e.g. runtime teardown). The hub-boot query then keeps opening a WebSocket for a bot whose owner is gone.

JOIN ListActiveChannelInstallations to live workspace + agent so an orphaned installation is never connected, uniformly for every deletion path. The JOIN matches the old ON DELETE CASCADE semantics (row existence, not agent archival), so an archived-but-present agent's installation is still listed; the orphaned row's encrypted secret is thereby never decrypted/used.

Tests: a real-DB handler test asserts a deleted-workspace/agent installation and a non-Feishu one are both excluded; the lark scope test's active-list assertion moved there since the JOIN now needs real workspace/agent fixtures. (Physically deleting dormant orphaned channel rows on workspace/agent deletion is a separate app-layer-cleanup follow-up.)

MUL-3515

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

* docs(channel): document non-rolling cutover constraint for the lark->channel migration

Elon deploy review: keeping the lark_* tables (deferred drop) stops old v0.3.27 code from crashing, but is not full expand/contract. Migration 124 is a one-time backfill; afterwards new code runs on channel_* (lease + dedup on channel_*) while pre-cutover code runs on lark_* (lease + dedup on lark_*). If both run concurrently during a rolling deploy, each side claims the same Feishu bot's WS lease on its own table and double-processes inbound events.

This release therefore requires a NON-ROLLING cutover (stop the old hub before applying migration 124 + starting new code; rollback is not lossless once new code writes channel_*). Documented where deployers/reviewers see it: migration 124 header gains a ROLLOUT note; the channel_store.go header is corrected (lark_* tables are retained one release for rollback safety, not "gone"; the store still never touches them). Comment-only — no schema/codegen/behavior change.

MUL-3515

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

* feat(lark): add MULTICA_LARK_HUB_DISABLED switch for the channel cutover

The lark_*->channel_* cutover needs a way to make the Feishu bot briefly unavailable WITHOUT taking down the whole multica-api process — the Lark hub is a goroutine inside it, not a separate Deployment. MULTICA_LARK_HUB_DISABLED=true parks the hub at startup: the API serves HTTP normally but never claims a WS lease or opens a Feishu connection.

Rollout (see migration 124 ROLLOUT note): ship the new release with the flag SET so new pods run API-only while old pods (hub on lark_*) drain during the rolling deploy — the two hubs never overlap. After the old pods are gone and migration 124 has run, flip the flag off; the new hub comes up on channel_*. The old backend does NOT need this switch — its hub stops when k8s terminates the old pods, not via a flag. Nil-ing LarkHub reuses the existing not-configured path so both the startup start and the shutdown join skip it.

MUL-3515

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

* docs(channel): point migration 124 ROLLOUT note at the hub-disable switch

Refine the rollout note to use MULTICA_LARK_HUB_DISABLED for a bot-only cutover (new pods serve API with the hub parked while old pods drain; flip the switch off after the migration), instead of the earlier whole-API recreate. Comment-only.

MUL-3515

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

* docs(channel): fix migration 124 rollout order and document self-host cutover

The previous ROLLOUT note shipped the new (channel_*) build before
running migration 124, so the channel_*-backed HTTP paths (installation
list/install/revoke, chat-session delete, member revoke) would 500 in
the window between new-pod boot and the deferred migration. Restate the
runbook around two explicit invariants — channel_* must exist before the
new build serves those paths, and the old/new hubs must never overlap —
and order the steps so channel_* is created first (park old hub -> snapshot
-> deploy parked new build -> unpark). Document that default self-host
(entrypoint migrate + single-replica Recreate) satisfies both invariants
automatically and needs no manual steps; only prd / multi-replica rolling
self-host needs the switch procedure. Clarify in main.go that the
hub-park switch is generation-agnostic (parks whichever hub the build
carries), which is what enables the preparatory release.

Refs MUL-3515

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 12:46:20 +08:00

613 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lark
import (
"encoding/json"
"testing"
"github.com/jackc/pgx/v5/pgtype"
)
func TestLarkJSONFrameDecoderTextMessageInP2P(t *testing.T) {
t.Parallel()
raw := []byte(`{
"type":"event_callback",
"header":{
"event_id":"evt-1",
"event_type":"im.message.receive_v1",
"app_id":"cli_app_x"
},
"event":{
"sender":{
"sender_id":{"open_id":"ou_user"},
"sender_type":"user"
},
"message":{
"message_id":"om_1",
"chat_id":"oc_1",
"chat_type":"p2p",
"message_type":"text",
"content":"{\"text\":\"hello\"}"
}
}
}`)
d := NewLarkJSONFrameDecoder()
msg, ok, err := d.Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
if msg.EventID != "evt-1" {
t.Errorf("EventID = %q", msg.EventID)
}
if msg.AppID != "cli_app_x" {
t.Errorf("AppID = %q", msg.AppID)
}
if msg.ChatType != ChatTypeP2P {
t.Errorf("ChatType = %q", msg.ChatType)
}
if msg.MessageID != "om_1" {
t.Errorf("MessageID = %q", msg.MessageID)
}
if msg.SenderOpenID != "ou_user" {
t.Errorf("SenderOpenID = %q", msg.SenderOpenID)
}
if msg.Body != "hello" {
t.Errorf("Body = %q", msg.Body)
}
if msg.AddressedToBot {
t.Errorf("P2P AddressedToBot should not be true")
}
}
func TestLarkJSONFrameDecoderGroupMentionDiscrimination(t *testing.T) {
t.Parallel()
mkRaw := func(mentionOpenID string) []byte {
return []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{
"message_id":"m","chat_id":"c","chat_type":"group",
"message_type":"text","content":"{\"text\":\"hi\"}",
"mentions":[{"id":{"open_id":"` + mentionOpenID + `"}}]
}
}
}`)
}
d := NewLarkJSONFrameDecoder()
t.Run("mentions bot", func(t *testing.T) {
msg, ok, err := d.Decode(mkRaw("ou_bot"), Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.ChatType != ChatTypeGroup {
t.Errorf("ChatType = %q", msg.ChatType)
}
if !msg.AddressedToBot {
t.Error("AddressedToBot = false; expected true")
}
})
t.Run("mentions other user", func(t *testing.T) {
msg, ok, err := d.Decode(mkRaw("ou_other"), Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.AddressedToBot {
t.Error("AddressedToBot = true; expected false")
}
})
}
// TestLarkJSONFrameDecoderGroupMentionUnionID exercises the MUL-2671
// fix: in a multi-bot group chat the per-app `mentions[].id.open_id`
// is structurally inverted across WS perspectives, so we route on
// `union_id` (the stable, cross-app identifier captured at install
// time) when the installation row knows it. The open_id path remains
// as a transitional fallback for installations that haven't been
// backfilled yet.
func TestLarkJSONFrameDecoderGroupMentionUnionID(t *testing.T) {
t.Parallel()
mkRaw := func(mentionOpenID, mentionUnionID string) []byte {
return []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{
"message_id":"m","chat_id":"c","chat_type":"group",
"message_type":"text","content":"{\"text\":\"hi\"}",
"mentions":[{"id":{"open_id":"` + mentionOpenID + `","union_id":"` + mentionUnionID + `"}}]
}
}
}`)
}
d := NewLarkJSONFrameDecoder()
pgText := func(s string) pgtype.Text { return pgtype.Text{String: s, Valid: true} }
t.Run("union_id match wins even when open_id mismatches", func(t *testing.T) {
// Two-bot group chat, this bot's WS perspective:
// payload.mentions[0].open_id is the WIRE-form open_id Lark
// hands us (not equal to our installation's bot_open_id,
// which is what /bot/v3/info returned), but the union_id is
// the stable identifier we captured at install. The match
// must succeed.
inst := Installation{
BotOpenID: "ou_bot_a_canonical",
BotUnionID: pgText("on_bot_a_union"),
}
msg, ok, err := d.Decode(mkRaw("ou_bot_a_wire", "on_bot_a_union"), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if !msg.AddressedToBot {
t.Error("AddressedToBot = false; expected true via union_id")
}
})
t.Run("union_id mismatch wins even when open_id matches", func(t *testing.T) {
// The other bot in the group was @-mentioned; Lark hands
// THIS bot's WS a payload whose mentions[].id.open_id
// happens to equal our bot_open_id (the inverse-mapping
// quirk Bohan's live triage surfaced). The match must NOT
// fire — union_id is the source of truth.
inst := Installation{
BotOpenID: "ou_bot_a_canonical",
BotUnionID: pgText("on_bot_a_union"),
}
msg, ok, err := d.Decode(mkRaw("ou_bot_a_canonical", "on_bot_b_union"), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.AddressedToBot {
t.Error("AddressedToBot = true; expected false because union_id points at the OTHER bot")
}
})
t.Run("falls back to open_id when union_id is unknown", func(t *testing.T) {
// Pre-backfill installation row: no union_id yet. Decoder
// must keep working in the single-bot case via the legacy
// open_id comparison.
inst := Installation{BotOpenID: "ou_bot_a_canonical"}
msg, ok, err := d.Decode(mkRaw("ou_bot_a_canonical", "on_anything"), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if !msg.AddressedToBot {
t.Error("AddressedToBot = false; expected true via legacy open_id fallback")
}
})
}
// TestLarkJSONFrameDecoderMentionPlaceholderRewrite covers the body
// cleanup: Lark inlines `@_user_N` placeholders inside the text and
// resolves them via the `mentions` array. We strip the bot's own
// mention (the dispatcher already routes the event), substitute
// other users with `@<displayName>`, and leave the agent with a
// natural-looking message body.
func TestLarkJSONFrameDecoderMentionPlaceholderRewrite(t *testing.T) {
t.Parallel()
pgText := func(s string) pgtype.Text { return pgtype.Text{String: s, Valid: true} }
mkRaw := func(text, mentionsJSON string) []byte {
// Lark wraps text in a `{"text": ...}` JSON envelope inside
// `message.content`; we double-encode below to match wire.
contentDoc := map[string]string{"text": text}
contentBytes, _ := json.Marshal(contentDoc)
contentEsc, _ := json.Marshal(string(contentBytes))
return []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{
"message_id":"m","chat_id":"c","chat_type":"group",
"message_type":"text",
"content":` + string(contentEsc) + `,
"mentions":` + mentionsJSON + `
}
}
}`)
}
d := NewLarkJSONFrameDecoder()
t.Run("strips bot self-mention via union_id", func(t *testing.T) {
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
mentions := `[{"key":"@_user_1","name":"My Bot","id":{"open_id":"ou_bot_wire","union_id":"on_bot"}}]`
msg, ok, err := d.Decode(mkRaw("@_user_1 ping test", mentions), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != "ping test" {
t.Errorf("Body = %q; want %q", msg.Body, "ping test")
}
})
t.Run("substitutes other-user mention with display name", func(t *testing.T) {
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
mentions := `[
{"key":"@_user_1","name":"My Bot","id":{"open_id":"ou_bot_wire","union_id":"on_bot"}},
{"key":"@_user_2","name":"Alice","id":{"open_id":"ou_alice","union_id":"on_alice"}}
]`
msg, ok, err := d.Decode(mkRaw("@_user_1 hey @_user_2 take a look", mentions), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != "hey @Alice take a look" {
t.Errorf("Body = %q; want %q", msg.Body, "hey @Alice take a look")
}
})
t.Run("preserves newlines after stripped mention", func(t *testing.T) {
// Strip the bot mention + one adjacent space; the newline that
// follows stays put so the rest of the message keeps its
// shape. User-typed extra spaces (the double space here) are
// preserved verbatim — we do not globally collapse whitespace.
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
mentions := `[{"key":"@_user_1","name":"My Bot","id":{"open_id":"ou_bot_wire","union_id":"on_bot"}}]`
msg, ok, err := d.Decode(mkRaw("@_user_1 first line\nsecond line", mentions), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != " first line\nsecond line" {
t.Errorf("Body = %q; want %q", msg.Body, " first line\nsecond line")
}
})
t.Run("no mentions leaves body unchanged", func(t *testing.T) {
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
msg, ok, err := d.Decode(mkRaw("just a normal message", `[]`), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != "just a normal message" {
t.Errorf("Body = %q; want %q", msg.Body, "just a normal message")
}
})
t.Run("preserves indentation and tabs around stripped mention", func(t *testing.T) {
// Code-block / indented messages: stripping the bot mention
// must not eat the surrounding indent, tabs, or any internal
// whitespace the user intentionally typed. We only consume a
// single space directly adjacent to the placeholder.
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
mentions := `[{"key":"@_user_1","name":"My Bot","id":{"open_id":"ou_bot_wire","union_id":"on_bot"}}]`
raw := " @_user_1 review this snippet:\n\tfunc add(a, b int) int {\n\t\treturn a + b\n\t}"
want := " review this snippet:\n\tfunc add(a, b int) int {\n\t\treturn a + b\n\t}"
msg, ok, err := d.Decode(mkRaw(raw, mentions), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != want {
t.Errorf("Body = %q; want %q", msg.Body, want)
}
})
t.Run("avoids @_user_1 / @_user_10 prefix collision", func(t *testing.T) {
// Lark assigns mention keys positionally; a chat with eleven+
// participants exposes both `@_user_1` and `@_user_10`. Naive
// ReplaceAll for `@_user_1` would mangle `@_user_10`, so we
// match longest-first.
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
mentions := `[
{"key":"@_user_1","name":"My Bot","id":{"open_id":"ou_bot_wire","union_id":"on_bot"}},
{"key":"@_user_10","name":"Alice","id":{"open_id":"ou_alice","union_id":"on_alice"}}
]`
raw := "@_user_1 forward this to @_user_10 please"
want := "forward this to @Alice please"
msg, ok, err := d.Decode(mkRaw(raw, mentions), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != want {
t.Errorf("Body = %q; want %q", msg.Body, want)
}
})
t.Run("@-ing both bots in one message strips only self, renders other by name", func(t *testing.T) {
// Multi-bot group chat where the user @-mentions BOTH bots in
// the same message. From this WS's perspective only the self
// mention should be stripped; the sibling bot renders as
// @<displayName> so the agent receives a faithful transcript
// of the user intent.
inst := Installation{
BotOpenID: "ou_self_canonical",
BotUnionID: pgText("on_self_union"),
}
mentions := `[
{"key":"@_user_1","name":"Self Bot","id":{"open_id":"ou_self_wire","union_id":"on_self_union"}},
{"key":"@_user_2","name":"Sibling Bot","id":{"open_id":"ou_sibling_wire","union_id":"on_sibling_union"}}
]`
raw := "@_user_1 @_user_2 please coordinate"
want := "@Sibling Bot please coordinate"
msg, ok, err := d.Decode(mkRaw(raw, mentions), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != want {
t.Errorf("Body = %q; want %q", msg.Body, want)
}
})
t.Run("open_id match does NOT strip when union_id known but differs", func(t *testing.T) {
// Mirror of containsMention's union_id-first rule: when we
// know our union_id, an open_id-only match means the mention
// is for the OTHER bot (the inverse-mapping quirk), so we
// must render it as @<name>, not strip it.
inst := Installation{
BotOpenID: "ou_self_canonical",
BotUnionID: pgText("on_self_union"),
}
mentions := `[{"key":"@_user_1","name":"Sibling Bot","id":{"open_id":"ou_self_canonical","union_id":"on_sibling_union"}}]`
raw := "@_user_1 hi"
want := "@Sibling Bot hi"
msg, ok, err := d.Decode(mkRaw(raw, mentions), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != want {
t.Errorf("Body = %q; want %q", msg.Body, want)
}
})
}
func TestLarkJSONFrameDecoderDropsHeartbeat(t *testing.T) {
t.Parallel()
d := NewLarkJSONFrameDecoder()
cases := [][]byte{
[]byte(`{"type":"heartbeat"}`),
[]byte(`{"type":"frame_ack","data":{"id":"1"}}`),
[]byte(`{"type":"event_callback","header":{"event_type":"im.message.unknown_kind"}}`),
}
for _, raw := range cases {
msg, ok, err := d.Decode(raw, Installation{})
if err != nil || ok {
t.Errorf("Decode(%q) ok=%v err=%v; expected (false, nil)", raw, ok, err)
}
if msg.EventID != "" {
t.Errorf("expected zero-value InboundMessage on drop, got %+v", msg)
}
}
}
func TestLarkJSONFrameDecoderEmptyRaw(t *testing.T) {
t.Parallel()
msg, ok, err := NewLarkJSONFrameDecoder().Decode(nil, Installation{})
if ok || err != nil {
t.Fatalf("expected (zero, false, nil) for empty raw; got ok=%v err=%v msg=%+v", ok, err, msg)
}
}
func TestLarkJSONFrameDecoderMalformedReturnsError(t *testing.T) {
t.Parallel()
_, ok, err := NewLarkJSONFrameDecoder().Decode([]byte("not-json"), Installation{})
if err == nil {
t.Fatal("expected error on malformed envelope")
}
if ok {
t.Error("ok should be false on decode failure")
}
}
func TestLarkJSONFrameDecoderMessageContentEmptyOnInvalidContentJSON(t *testing.T) {
t.Parallel()
raw := []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{"message_id":"m","chat_id":"c","chat_type":"p2p","message_type":"text","content":"not-json"}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{})
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != "" {
t.Errorf("Body = %q; expected empty on unparseable content", msg.Body)
}
}
func TestLarkJSONFrameDecoderNonTextMessageHasEmptyBody(t *testing.T) {
t.Parallel()
raw := []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{"message_id":"m","chat_id":"c","chat_type":"p2p","message_type":"image","content":"{\"image_key\":\"img1\"}"}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{})
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != "" {
t.Errorf("Body = %q; non-text messages should have empty body in MVP", msg.Body)
}
if msg.MessageID == "" {
t.Error("MessageID should still be populated for non-text events")
}
}
// TestLarkJSONFrameDecoderPostMessageFlattened verifies that a rich-text
// `post` message is flattened to plain text end-to-end through Decode —
// the MUL-2951 example. Body.content is the JSON-encoded post object; we
// marshal a Go string to get the correctly-escaped content field.
func TestLarkJSONFrameDecoderPostMessageFlattened(t *testing.T) {
t.Parallel()
postContent := `{"title":"周报","content":[[{"tag":"text","text":"本周完成:"}],[{"tag":"text","text":"Lark 集成"},{"tag":"a","href":"https://github.com/multica-ai/multica/pull/3277","text":"PR #3277"}]]}`
escaped, err := json.Marshal(postContent)
if err != nil {
t.Fatalf("marshal: %v", err)
}
raw := []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{"message_id":"m","chat_id":"c","chat_type":"p2p","message_type":"post","content":` + string(escaped) + `}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
want := "周报\n本周完成\nLark 集成 PR #3277 (https://github.com/multica-ai/multica/pull/3277)"
if msg.Body != want {
t.Errorf("post Body\n got = %q\nwant = %q", msg.Body, want)
}
if msg.MessageType != "post" {
t.Errorf("MessageType = %q want post", msg.MessageType)
}
}
// TestLarkJSONFrameDecoderPostResolvesMentions checks that @-mentions in
// a post (carried as `at` spans with @_user_N placeholders) are resolved
// through the same mention pipeline as text, including stripping the
// bot's own mention.
func TestLarkJSONFrameDecoderPostResolvesMentions(t *testing.T) {
t.Parallel()
postContent := `{"content":[[{"tag":"at","user_id":"@_user_1","user_name":""},{"tag":"text","text":"please review"},{"tag":"at","user_id":"@_user_2","user_name":""}]]}`
escaped, err := json.Marshal(postContent)
if err != nil {
t.Fatalf("marshal: %v", err)
}
raw := []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{
"message_id":"m","chat_id":"c","chat_type":"group","message_type":"post",
"content":` + string(escaped) + `,
"mentions":[
{"key":"@_user_1","id":{"open_id":"ou_bot"},"name":"Bot"},
{"key":"@_user_2","id":{"open_id":"ou_alice"},"name":"Alice"}
]
}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
// @_user_1 is the bot → stripped; @_user_2 → @Alice.
want := "please review @Alice"
if msg.Body != want {
t.Errorf("post Body\n got = %q\nwant = %q", msg.Body, want)
}
if !msg.AddressedToBot {
t.Error("AddressedToBot should be true (bot was @-mentioned)")
}
}
// TestLarkJSONFrameDecoderCapturesReplyLinkage verifies parent_id /
// root_id from a quote-reply event land on the InboundMessage so the
// enricher can expand them.
func TestLarkJSONFrameDecoderCapturesReplyLinkage(t *testing.T) {
t.Parallel()
raw := []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{
"message_id":"om_child","chat_id":"c","chat_type":"group","message_type":"text",
"content":"{\"text\":\"去实现\"}",
"parent_id":"om_parent","root_id":"om_root"
}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
if msg.ParentID != "om_parent" {
t.Errorf("ParentID = %q want om_parent", msg.ParentID)
}
if msg.RootID != "om_root" {
t.Errorf("RootID = %q want om_root", msg.RootID)
}
if msg.MessageType != "text" {
t.Errorf("MessageType = %q want text", msg.MessageType)
}
// CommandBody snapshots the user's own text (pre-enrichment) so
// /issue parsing survives the enricher's prepended context blocks.
if msg.CommandBody != "去实现" {
t.Errorf("CommandBody = %q want 去实现", msg.CommandBody)
}
}
// TestLarkJSONFrameDecoderCapturesThreadID verifies thread_id from a
// topic (话题) message lands on the InboundMessage so the outbound
// patcher can reply back into the thread.
func TestLarkJSONFrameDecoderCapturesThreadID(t *testing.T) {
t.Parallel()
raw := []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{
"message_id":"om_in_thread","chat_id":"c","chat_type":"group","message_type":"text",
"content":"{\"text\":\"@bot help\"}",
"thread_id":"omt_topic_123"
}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
if msg.ThreadID != "omt_topic_123" {
t.Errorf("ThreadID = %q want omt_topic_123", msg.ThreadID)
}
}
// TestLarkJSONFrameDecoderNonThreadHasEmptyThreadID verifies a normal
// chat message (no thread_id in the event) leaves ThreadID empty, which
// keeps the outbound on the unchanged chat-level send path.
func TestLarkJSONFrameDecoderNonThreadHasEmptyThreadID(t *testing.T) {
t.Parallel()
raw := []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{
"message_id":"om_plain","chat_id":"c","chat_type":"group","message_type":"text",
"content":"{\"text\":\"hi\"}"
}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
if msg.ThreadID != "" {
t.Errorf("ThreadID = %q want empty for non-thread message", msg.ThreadID)
}
}