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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Bohan Jiang
2026-06-24 12:46:20 +08:00
committed by GitHub
co-authored by multica-agent J
parent 3103ed1082
commit ce28d0aa0e
53 changed files with 4529 additions and 1999 deletions
+16
View File
@@ -356,6 +356,22 @@ func main() {
// Lark do not pay any goroutine cost. Lifecycle is bound to
// sweepCtx so the Hub winds down alongside the other long-running
// workers, AFTER the HTTP server has drained.
// Cutover control (MUL-3515): MULTICA_LARK_HUB_DISABLED parks the Lark
// inbound hub WITHOUT taking down the rest of the API — the process still
// serves HTTP normally, it just never claims a WS lease or opens a Feishu
// connection. The switch is generation-agnostic: in a pre-cutover build it
// parks the OLD (lark_*) hub, in this build it parks the NEW (channel_*)
// hub. The lark_*->channel_* rollout uses it twice — first to stop the old
// hub on the current build so migration 124 takes a clean snapshot, then to
// hold this build's new hub dormant until that migration has run and the old
// pods have drained, so the two never double-process the same bot. Nil-ing
// LarkHub here reuses the same "Lark not configured" path, so the shutdown
// join below also skips it. The operator flips it off last to bring the new
// hub up on channel_*. See migration 124's ROLLOUT note for the full order.
if h.LarkHub != nil && os.Getenv("MULTICA_LARK_HUB_DISABLED") == "true" {
slog.Warn("Lark inbound hub disabled via MULTICA_LARK_HUB_DISABLED; API serves normally but no Feishu WebSocket is opened")
h.LarkHub = nil
}
if h.LarkHub != nil {
go h.LarkHub.Run(sweepCtx)
}
+15 -8
View File
@@ -224,13 +224,20 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
Logger: slog.Default(),
})
h.LarkAPIClient = larkClient
patcher := lark.NewPatcher(queries, installSvc, larkClient, lark.PatcherConfig{})
// Channel-backed store: routes the lark package's DB seams
// onto the channel_* tables (MUL-3515). Interface-wired
// consumers (patcher, typing indicator, dispatcher, hub,
// backfills) take it directly; the constructor-based services
// wrap *db.Queries internally, so they keep taking queries.
cs := lark.NewChannelStore(queries)
patcher := lark.NewPatcher(cs, installSvc, larkClient, lark.PatcherConfig{})
patcher.Register(bus)
// Typing indicator: shows a "processing" reaction on the user's
// message while the agent is working, then removes it before the
// reply is sent. Best-effort; failures are logged only.
typingIndicator := lark.NewTypingIndicatorManager(larkClient, installSvc, queries, slog.Default())
typingIndicator := lark.NewTypingIndicatorManager(larkClient, installSvc, cs, slog.Default())
patcher.SetTypingIndicatorManager(typingIndicator)
// Inbound pipeline: lark_inbound_audit logger,
@@ -244,7 +251,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
auditLogger := lark.NewAuditLogger(queries)
chatSvc := lark.NewChatSessionService(queries, pool)
dispatcher := &lark.Dispatcher{
Queries: queries,
Queries: cs,
Chat: chatSvc,
Audit: auditLogger,
IssueService: h.IssueService,
@@ -271,7 +278,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
// config is fixed, with the boot log labelling the mode
// "noop" so operators can spot it.
connectorFactory, connectorLabel := buildLarkConnectorFactory(installSvc, larkClient)
h.LarkHub = lark.NewHub(queries, connectorFactory, dispatcher, lark.HubConfig{})
h.LarkHub = lark.NewHub(cs, connectorFactory, dispatcher, lark.HubConfig{})
h.LarkHub.SetTypingIndicatorManager(typingIndicator)
// OutcomeReplier wires the outbound side of the
@@ -306,7 +313,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
// bot_union_id during the device-flow finalize, so this
// is bridge code — it will simply find no rows to update
// on a fresh deployment and exit. MUL-2671.
go lark.BackfillBotUnionIDs(context.Background(), queries, larkClient, installSvc, slog.Default())
go lark.BackfillBotUnionIDs(context.Background(), cs, larkClient, installSvc, slog.Default())
// Upgrade repair for deployments that ran the whole
// integration against Lark international via the deployment-
@@ -316,7 +323,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
// operator clears the override. No-op on mainland / fresh
// deployments. Off the hot startup path like the union_id
// backfill. MUL-3083.
go lark.BackfillRegionFromLegacyOverride(context.Background(), queries,
go lark.BackfillRegionFromLegacyOverride(context.Background(), cs,
strings.TrimSpace(os.Getenv("MULTICA_LARK_HTTP_BASE_URL")),
strings.TrimSpace(os.Getenv("MULTICA_LARK_CALLBACK_BASE_URL")),
slog.Default())
@@ -1034,7 +1041,7 @@ func buildLarkConnectorFactory(installSvc *lark.InstallationService, apiClient l
}
decoder := lark.NewLarkJSONFrameDecoder()
dialer := lark.NewGorillaDialer()
credsProvider := lark.CredentialsProviderFunc(func(ctx context.Context, inst db.LarkInstallation) (lark.InstallationCredentials, error) {
credsProvider := lark.CredentialsProviderFunc(func(ctx context.Context, inst lark.Installation) (lark.InstallationCredentials, error) {
secret, err := installSvc.DecryptAppSecret(inst)
if err != nil {
return lark.InstallationCredentials{}, err
@@ -1070,7 +1077,7 @@ func buildLarkConnectorFactory(installSvc *lark.InstallationService, apiClient l
slog.Error("lark ws: connector init failed; falling back to noop", "error", err)
return lark.NoopConnectorFactory(slog.Default()), "noop"
}
return func(_ db.LarkInstallation) (lark.EventConnector, error) {
return func(_ lark.Installation) (lark.EventConnector, error) {
return conn, nil
}, "ws-long-conn"
}
+8
View File
@@ -348,6 +348,14 @@ func (h *Handler) DeleteChatSession(w http.ResponseWriter, r *http.Request) {
return
}
// channel_chat_session_binding used to carry a chat_session FK with
// ON DELETE CASCADE; MUL-3515 §4 dropped every channel_* foreign key, so
// prune the binding here in the same tx that deletes its chat_session.
if err := qtx.DeleteChannelChatSessionBindingBySession(r.Context(), session.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete chat session binding")
return
}
if err := qtx.DeleteChatSession(r.Context(), db.DeleteChatSessionParams{
ID: session.ID,
WorkspaceID: session.WorkspaceID,
+61
View File
@@ -444,3 +444,64 @@ func TestListChatMessagesPage_RejectsInvalidLimit(t *testing.T) {
t.Fatalf("ListChatMessagesPage invalid limit: expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// TestDeleteChatSession_PrunesChannelChatSessionBinding verifies the
// application-layer replacement for the channel_chat_session_binding
// chat_session-FK cascade (MUL-3515 §4): deleting a chat session prunes its
// channel binding in the same tx that deletes the session row.
func TestDeleteChatSession_PrunesChannelChatSessionBinding(t *testing.T) {
agentID := createHandlerTestAgent(t, "ChatDeleteBindingAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID)
ctx := context.Background()
const appID = "cli_chat_delete_binding"
const channelChatID = "oc_chat_delete_binding"
// channel_* rows have no FK to chat_session/workspace (MUL-3515 §4), so
// they outlive the helper's chat_session cleanup; clear by deterministic
// key before and after.
cleanChannel := func() {
_, _ = testPool.Exec(context.Background(),
`DELETE FROM channel_chat_session_binding WHERE channel_chat_id = $1`, channelChatID)
_, _ = testPool.Exec(context.Background(),
`DELETE FROM channel_installation WHERE channel_type = 'feishu' AND config->>'app_id' = $1`, appID)
}
cleanChannel()
t.Cleanup(cleanChannel)
var installID string
if err := testPool.QueryRow(ctx, `
INSERT INTO channel_installation (workspace_id, agent_id, channel_type, config, installer_user_id)
VALUES ($1, $2, 'feishu', jsonb_build_object('app_id', $3::text), $4)
RETURNING id
`, testWorkspaceID, agentID, appID, testUserID).Scan(&installID); err != nil {
t.Fatalf("insert channel_installation: %v", err)
}
if _, err := testPool.Exec(ctx, `
INSERT INTO channel_chat_session_binding (chat_session_id, installation_id, channel_type, channel_chat_id, chat_type)
VALUES ($1, $2, 'feishu', $3, 'p2p')
`, sessionID, installID, channelChatID); err != nil {
t.Fatalf("insert channel_chat_session_binding: %v", err)
}
req := httptest.NewRequest(http.MethodDelete, "/api/chat/sessions/"+sessionID, nil)
req.Header.Set("X-User-ID", testUserID)
req = withURLParam(req, "sessionId", sessionID)
req = withChatTestWorkspaceCtx(t, req)
w := httptest.NewRecorder()
testHandler.DeleteChatSession(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("DeleteChatSession: expected 204, got %d: %s", w.Code, w.Body.String())
}
var bindingExists bool
if err := testPool.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM channel_chat_session_binding WHERE channel_chat_id = $1)`, channelChatID).Scan(&bindingExists); err != nil {
t.Fatalf("query chat session binding: %v", err)
}
if bindingExists {
t.Fatal("deleted chat session's channel_chat_session_binding was not pruned")
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ type LarkInstallationResponse struct {
UpdatedAt string `json:"updated_at"`
}
func larkInstallationToResponse(row db.LarkInstallation) LarkInstallationResponse {
func larkInstallationToResponse(row lark.Installation) LarkInstallationResponse {
resp := LarkInstallationResponse{
ID: uuidToString(row.ID),
WorkspaceID: uuidToString(row.WorkspaceID),
+58
View File
@@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
@@ -160,3 +161,60 @@ func TestListLarkInstallations_NotConfigured_HardCodedInstallSupportedFalse(t *t
t.Fatalf("install_supported must be false in the early-return branch even with a non-nil APIClient")
}
}
// TestListActiveLarkInstallations_SkipsOrphans pins the MUL-3515 hub-boot
// guard: ListActiveChannelInstallations is JOINed to live workspace + agent,
// so an active channel_installation whose workspace or agent has been deleted
// (channel_* has no FK cascade) is never returned — otherwise the Hub would
// keep opening a WebSocket for a bot whose owner is gone. It also stays
// channel_type='feishu'-scoped. Runs against the real test DB.
func TestListActiveLarkInstallations_SkipsOrphans(t *testing.T) {
ctx := context.Background()
agentID := createHandlerTestAgent(t, "LarkActiveScopeAgent", []byte("[]"))
const (
liveApp = "cli_active_live"
orphanApp = "cli_active_orphan"
slackApp = "cli_active_slack"
// Deliberately non-existent workspace/agent so the JOIN drops the row.
orphanWS = "5d0a0000-0000-4000-8000-000000000001"
orphanAg = "5d0a0000-0000-4000-8000-000000000002"
)
clean := func() {
_, _ = testPool.Exec(context.Background(),
`DELETE FROM channel_installation WHERE config->>'app_id' = ANY($1)`,
[]string{liveApp, orphanApp, slackApp})
}
clean()
t.Cleanup(clean)
seed := func(ws, ag, channelType, app string) {
if _, err := testPool.Exec(ctx, `
INSERT INTO channel_installation (workspace_id, agent_id, channel_type, config, installer_user_id, status)
VALUES ($1, $2, $3, jsonb_build_object('app_id', $4::text), $5, 'active')
`, ws, ag, channelType, app, testUserID); err != nil {
t.Fatalf("seed %s installation: %v", app, err)
}
}
seed(testWorkspaceID, agentID, "feishu", liveApp) // live workspace + agent -> listed
seed(orphanWS, orphanAg, "feishu", orphanApp) // deleted workspace + agent -> dropped
seed(testWorkspaceID, agentID, "slack", slackApp) // wrong channel_type -> dropped
active, err := lark.NewChannelStore(testHandler.Queries).ListActiveLarkInstallations(ctx)
if err != nil {
t.Fatalf("ListActiveLarkInstallations: %v", err)
}
seen := map[string]bool{}
for _, inst := range active {
seen[inst.AppID] = true
}
if !seen[liveApp] {
t.Fatal("expected the live-workspace/agent Feishu installation to be listed")
}
if seen[orphanApp] {
t.Fatal("orphaned installation (deleted workspace/agent) must not be listed — the hub would connect a dead bot")
}
if seen[slackApp] {
t.Fatal("non-Feishu installation must not be listed by the Feishu hub")
}
}
@@ -112,6 +112,19 @@ func (h *Handler) revokeAndRemoveMember(ctx context.Context, workspaceID, userID
}
}
// channel_user_binding used to carry a member FK with ON DELETE CASCADE, so
// a removed member's IM bindings vanished automatically. MUL-3515 §4 dropped
// every channel_* foreign key, moving that integrity rule to the application
// layer: prune the bindings here, in the same tx as the member-row delete.
// The inbound path also re-checks membership (see ChannelStore.IsWorkspaceMember),
// but pruning stops a stale binding from lingering across a remove/re-add.
if err := qtx.DeleteChannelUserBindingsByWorkspaceMember(ctx, db.DeleteChannelUserBindingsByWorkspaceMemberParams{
WorkspaceID: workspaceID,
MulticaUserID: userID,
}); err != nil {
return empty, err
}
// Member row deletion lives inside the same tx so a successful revoke is
// never followed by a failed member-delete (which would leave the user
// still a member with a dead runtime), and a failed revoke never leaves
+80
View File
@@ -596,6 +596,86 @@ func TestDeleteMember_RevokesTargetRuntimes(t *testing.T) {
assertRevoked(t, fx)
}
// TestDeleteMember_PrunesChannelUserBindings verifies the application-layer
// replacement for the channel_user_binding member-FK cascade (MUL-3515 §4):
// removing a member prunes that member's channel bindings, in the same tx as
// the member-row delete, while leaving a remaining member's binding intact.
func TestDeleteMember_PrunesChannelUserBindings(t *testing.T) {
fx := setupRevocationFixture(t, "handler-tests-revoke-binding", "daemon-revoke-binding")
ctx := context.Background()
const appID = "cli_revoke_binding"
const removedOpenID = "ou_revoke_binding_removed"
const keepOpenID = "ou_revoke_binding_keep"
// channel_* rows have no FK to workspace (MUL-3515 §4), so the fixture's
// workspace-delete cleanup never reaches them; clear by deterministic key
// both before (in case a prior run was killed mid-test) and after.
cleanChannel := func() {
_, _ = testPool.Exec(context.Background(),
`DELETE FROM channel_user_binding WHERE channel_user_id = ANY($1)`,
[]string{removedOpenID, keepOpenID})
_, _ = testPool.Exec(context.Background(),
`DELETE FROM channel_installation WHERE channel_type = 'feishu' AND config->>'app_id' = $1`, appID)
}
cleanChannel()
t.Cleanup(cleanChannel)
var installID string
if err := testPool.QueryRow(ctx, `
INSERT INTO channel_installation (workspace_id, agent_id, channel_type, config, installer_user_id)
VALUES ($1, $2, 'feishu', jsonb_build_object('app_id', $3::text), $4)
RETURNING id
`, fx.WorkspaceID, fx.AgentID, appID, testUserID).Scan(&installID); err != nil {
t.Fatalf("insert channel_installation: %v", err)
}
// Binding for the member being removed — must be pruned.
if _, err := testPool.Exec(ctx, `
INSERT INTO channel_user_binding (workspace_id, multica_user_id, installation_id, channel_type, channel_user_id)
VALUES ($1, $2, $3, 'feishu', $4)
`, fx.WorkspaceID, fx.TargetUserID, installID, removedOpenID); err != nil {
t.Fatalf("insert removed-member binding: %v", err)
}
// Binding for the requester (an owner who stays) — must survive, proving
// the prune is scoped to the removed user, not the whole workspace.
if _, err := testPool.Exec(ctx, `
INSERT INTO channel_user_binding (workspace_id, multica_user_id, installation_id, channel_type, channel_user_id)
VALUES ($1, $2, $3, 'feishu', $4)
`, fx.WorkspaceID, testUserID, installID, keepOpenID); err != nil {
t.Fatalf("insert remaining-member binding: %v", err)
}
w := httptest.NewRecorder()
req := newRequest("DELETE", "/api/workspaces/"+fx.WorkspaceID+"/members/"+fx.MemberID, nil)
req.Header.Set("X-Workspace-ID", fx.WorkspaceID)
req = withURLParams(req, "id", fx.WorkspaceID, "memberId", fx.MemberID)
testHandler.DeleteMember(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("DeleteMember: expected 204, got %d: %s", w.Code, w.Body.String())
}
var removedExists bool
if err := testPool.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM channel_user_binding WHERE channel_user_id = $1)`, removedOpenID).Scan(&removedExists); err != nil {
t.Fatalf("query removed-member binding: %v", err)
}
if removedExists {
t.Fatal("removed member's channel_user_binding was not pruned")
}
var keepExists bool
if err := testPool.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM channel_user_binding WHERE channel_user_id = $1)`, keepOpenID).Scan(&keepExists); err != nil {
t.Fatalf("query remaining-member binding: %v", err)
}
if !keepExists {
t.Fatal("remaining member's channel_user_binding was wrongly pruned")
}
}
// TestLeaveWorkspace_RevokesOwnRuntimes is the self-removal counterpart: when
// a member leaves a workspace voluntarily, their own runtimes are revoked
// with the same atomic write set as DeleteMember.
@@ -0,0 +1,94 @@
package channel
import "strings"
// Capability is a bitmask a Channel uses to DECLARE what it supports. It
// is declaration only: this package contains no degrade logic. A caller
// that wants to degrade output (rich card → plain text when CapRichCard
// is absent) reads the bitmask and decides for itself, so adding a new
// platform never forces a branch into the core. Capabilities() returns a
// Channel's fixed set; the zero value declares nothing.
type Capability uint64
const (
// CapText — can deliver a plain text message. Every Channel is
// expected to declare at least this.
CapText Capability = 1 << iota
// CapRichCard — can render a rich / interactive card (Lark
// interactive card, Slack Block Kit, …).
CapRichCard
// CapThreadReply — can post a reply into a thread / topic.
CapThreadReply
// CapQuoteReply — can quote-reply to a specific message.
CapQuoteReply
// CapAttachment — can send and/or receive media attachments.
CapAttachment
// CapVoice — can handle voice / audio messages.
CapVoice
// CapTypingIndicator — can show a typing / "thinking" indicator.
CapTypingIndicator
// CapMessageEdit — can edit a message after it was sent (Lark card
// patch, Slack chat.update, …).
CapMessageEdit
)
// capabilityNames maps single-bit capabilities to a stable, lower-case
// name for String(). Order matches the bit order above so String() reads
// least-significant-bit first.
var capabilityNames = []struct {
bit Capability
name string
}{
{CapText, "text"},
{CapRichCard, "rich_card"},
{CapThreadReply, "thread_reply"},
{CapQuoteReply, "quote_reply"},
{CapAttachment, "attachment"},
{CapVoice, "voice"},
{CapTypingIndicator, "typing_indicator"},
{CapMessageEdit, "message_edit"},
}
// Has reports whether c declares every capability in want. Has(0) is
// true (the empty requirement is always satisfied). Because want may be a
// combination of bits, this is an "includes all of" test, not "any of".
func (c Capability) Has(want Capability) bool {
return c&want == want
}
// String renders the set bits as a "|"-joined list of names ("text|
// thread_reply"), "none" for the zero value, and appends any unknown
// high bits as a hex remainder so a forgotten name never silently
// vanishes from logs. It is for diagnostics only.
func (c Capability) String() string {
if c == 0 {
return "none"
}
var (
parts []string
remaining = c
)
for _, cn := range capabilityNames {
if remaining&cn.bit == cn.bit {
parts = append(parts, cn.name)
remaining &^= cn.bit
}
}
if remaining != 0 {
parts = append(parts, "0x"+strings.TrimLeft(hex(uint64(remaining)), "0"))
}
return strings.Join(parts, "|")
}
// hex formats v as a fixed lower-case hex string without importing fmt
// (keeps this leaf file dependency-free). Only used by String() for the
// rare unknown-bit remainder.
func hex(v uint64) string {
const digits = "0123456789abcdef"
var buf [16]byte
for i := 15; i >= 0; i-- {
buf[i] = digits[v&0xf]
v >>= 4
}
return string(buf[:])
}
@@ -0,0 +1,67 @@
package channel
import "testing"
func TestCapability_Has(t *testing.T) {
c := CapText | CapThreadReply
if !c.Has(CapText) {
t.Errorf("Has(CapText) = false, want true")
}
if !c.Has(CapText | CapThreadReply) {
t.Errorf("Has(text|thread) = false, want true (includes-all)")
}
if c.Has(CapRichCard) {
t.Errorf("Has(CapRichCard) = true, want false")
}
if c.Has(CapText | CapRichCard) {
t.Errorf("Has(text|rich) = true, want false (one bit missing)")
}
// The empty requirement is always satisfied.
if !c.Has(0) {
t.Errorf("Has(0) = false, want true")
}
}
func TestCapability_String(t *testing.T) {
tests := []struct {
name string
cap Capability
want string
}{
{"zero", 0, "none"},
{"single", CapText, "text"},
{"ordered_lsb_first", CapThreadReply | CapText, "text|thread_reply"},
{"all_named", CapMessageEdit, "message_edit"},
{"unknown_high_bit", CapText | (1 << 40), "text|0x10000000000"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.cap.String(); got != tt.want {
t.Errorf("Capability(%#x).String() = %q, want %q", uint64(tt.cap), got, tt.want)
}
})
}
}
// TestCapability_BitsDistinct guards against two constants accidentally
// sharing a bit after an edit to the iota block.
func TestCapability_BitsDistinct(t *testing.T) {
all := []Capability{
CapText, CapRichCard, CapThreadReply, CapQuoteReply,
CapAttachment, CapVoice, CapTypingIndicator, CapMessageEdit,
}
var seen Capability
for i, c := range all {
if c == 0 {
t.Fatalf("capability index %d is zero", i)
}
if c&(c-1) != 0 {
t.Fatalf("capability index %d (%#x) is not a single bit", i, uint64(c))
}
if seen&c != 0 {
t.Fatalf("capability index %d (%#x) overlaps an earlier bit", i, uint64(c))
}
seen |= c
}
}
@@ -0,0 +1,81 @@
package channel
import (
"context"
"encoding/json"
)
// Type identifies an inbound channel platform — the discriminator the
// Registry keys on and the value persisted in the channel_type column of
// the generalized channel_* tables. Use the lower-case platform slug
// ("feishu", "slack", "wecom", …); keep it stable, it is durable data.
type Type string
const (
// TypeFeishu is the Feishu / Lark adapter — the only implementation
// in phase 1. It serves both the mainland Feishu cloud and the Lark
// international cloud; the cloud (region) is per-installation config,
// not a separate Type.
TypeFeishu Type = "feishu"
)
// Channel is the platform-agnostic contract every IM integration
// implements. An adapter keeps ALL platform specifics behind these five
// methods: the core supervisor calls Connect/Disconnect to manage the
// link, Send to deliver an outbound reply, and reads Capabilities to
// decide how to render; it never touches platform SDKs or wire formats.
//
// Inbound is intentionally NOT on this interface. A Channel pushes
// normalized InboundMessage values into the core router via the wiring
// established at construction (the adapter owns its receive loop); the
// core does not poll the Channel for messages.
type Channel interface {
// Type reports the platform discriminator. It MUST equal the Type
// the Channel was registered under, and is stable for the lifetime
// of the instance.
Type() Type
// Connect establishes the platform link (e.g. dials the outbound
// WebSocket long-conn, or starts the inbound HTTP listener). The
// connection mode is the implementation's choice and invisible to
// the core. Connect blocks only until the link is established (or
// fails); the ongoing receive loop runs in the background and is
// torn down by Disconnect or by ctx cancellation.
Connect(ctx context.Context) error
// Disconnect tears the platform link down and releases its
// resources. It is safe to call after a failed Connect and safe to
// call more than once; a Channel that is already disconnected
// returns nil.
Disconnect(ctx context.Context) error
// Send delivers a single outbound message and returns the platform's
// identifier for the delivered message. A non-nil error is reserved
// for real delivery failures (network, auth, rate limit) that the
// caller may retry.
Send(ctx context.Context, out OutboundMessage) (SendResult, error)
// Capabilities declares what this Channel supports. It is a pure
// declaration with no side effects and a stable result; callers read
// it to choose a rendering and degrade on their own (this package
// performs no degradation — see the Capability docs).
Capabilities() Capability
}
// Config is the normalized per-installation configuration a Factory
// consumes. Type is the platform discriminator; Raw is the platform's
// own credential/config blob (Feishu's app_id / encrypted app_secret /
// tenant_key / region, Slack's bot/app tokens, …), carried opaquely so
// the foundation never grows a per-platform field. It maps directly onto
// the channel_type column + JSONB config of a channel_installation row
// (MUL-3515 decision §3).
type Config struct {
Type Type
Raw json.RawMessage
}
// Factory builds a Channel from its per-installation Config. Each adapter
// registers exactly one Factory under its Type; the Registry calls it to
// instantiate a per-installation Channel. A Factory should validate Raw
// and return an error rather than a half-built Channel.
type Factory func(cfg Config) (Channel, error)
@@ -0,0 +1,45 @@
// Package channel is the platform-agnostic foundation for Multica's
// inbound IM integrations (Feishu/Lark, Slack, WeCom, …). It owns the
// contract every integration implements so the core never learns what a
// given platform's event JSON looks like — design tracked in MUL-3506,
// phase-1 foundation in MUL-3515.
//
// The contract has four pieces:
//
// 1. Channel — the per-integration interface
// (Type / Connect / Disconnect / Send / Capabilities). An adapter
// translates platform payloads in both directions and owns its own
// connection mode (outbound WebSocket long-conn, inbound HTTP, …);
// the core only calls these five methods.
//
// 2. InboundMessage / OutboundMessage — the normalized message
// envelopes. Every platform's inbound payload is translated by its
// adapter into one InboundMessage; the core routes, dedups, and
// persists only this struct. Outbound is the minimal text reply
// (ChatID + Text + optional thread / reply target) — rich cards,
// media, and outbound webhooks are deliberately out of scope here
// and stay inside the adapter that supports them.
//
// 3. Capability — a bitmask each Channel uses to DECLARE what it can
// do (rich cards, threads, attachments, …). This package only
// models the declaration; it intentionally contains no degrade
// logic. Callers that want to degrade (rich card → plain text)
// read the bitmask and decide for themselves, so adding a platform
// never forces an if/else into the core.
//
// 4. Registry — a Type→Factory map with last-writer-wins semantics.
// Adding a platform is "register a factory", not "edit the core".
//
// Boundary rule (MUL-3515 decision §2): the envelope holds ONLY fields
// that are true across every platform — Text, a normalized message-type
// enum, media references, the reply/thread anchors, the routing Source,
// and the event/message ids used for dedup. Anything platform-specific
// (a Lark raw msg_type, parent_id, root_id, …) lives in Raw and is read
// ONLY by the adapter that produced it. The core never reads Raw.
//
// This package is pure: it has no database, network, or platform
// dependencies, and nothing in it imports another integration package.
// The concrete Feishu/Lark adapter, the DB-backed installation/identity/
// session resolvers, and the supervisor that drives Connect/Disconnect
// are wired in the follow-up cutover (see MUL-3515).
package channel
@@ -0,0 +1,175 @@
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
// 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
}
@@ -0,0 +1,82 @@
package channel
import (
"encoding/json"
"testing"
)
// TestInboundMessage_RawIsOpaque proves the boundary contract: a
// platform-specific payload survives a JSON round-trip through Raw
// byte-for-byte, so an adapter can stash anything there and read it back
// while the core leaves it untouched.
func TestInboundMessage_RawIsOpaque(t *testing.T) {
raw := json.RawMessage(`{"msg_type":"merge_forward","parent_id":"om_x","mentions":[{"id":"ou_1"}]}`)
in := InboundMessage{
EventID: "evt_1",
MessageID: "om_1",
Source: Source{
ChannelType: TypeFeishu,
ChatID: "oc_1",
ChatType: ChatTypeGroup,
SenderID: "ou_sender",
SenderStableID: "on_union",
ThreadID: "omt_1",
},
Type: MsgTypeText,
Text: "hello",
AddressedToBot: true,
ReplyTo: &ReplyCtx{MessageID: "om_parent", RootID: "om_root"},
Raw: raw,
}
encoded, err := json.Marshal(in)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var got InboundMessage
if err := json.Unmarshal(encoded, &got); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if got.Source != in.Source {
t.Errorf("Source round-trip mismatch:\n got %+v\nwant %+v", got.Source, in.Source)
}
if got.ReplyTo == nil || *got.ReplyTo != *in.ReplyTo {
t.Errorf("ReplyTo round-trip mismatch: got %+v", got.ReplyTo)
}
if !got.AddressedToBot {
t.Errorf("AddressedToBot lost in round-trip")
}
// Raw must be semantically identical (compare as parsed JSON to be
// whitespace-insensitive).
var a, b any
if err := json.Unmarshal(got.Raw, &a); err != nil {
t.Fatalf("Raw not valid JSON after round-trip: %v", err)
}
if err := json.Unmarshal(raw, &b); err != nil {
t.Fatalf("seed Raw not valid JSON: %v", err)
}
if string(mustMarshal(t, a)) != string(mustMarshal(t, b)) {
t.Errorf("Raw payload changed across round-trip:\n got %s\nwant %s", got.Raw, raw)
}
}
func mustMarshal(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
return b
}
// TestOutboundMessage_Minimal documents that the outbound envelope is the
// minimal text reply (decision §6): a text body, optionally threaded or
// quoting, and nothing card/media-shaped.
func TestOutboundMessage_Minimal(t *testing.T) {
out := OutboundMessage{ChatID: "oc_1", Text: "hi", ThreadID: "omt_1", ReplyTo: "om_1"}
if out.ChatID == "" || out.Text == "" {
t.Fatalf("ChatID and Text are the required fields")
}
}
@@ -0,0 +1,77 @@
package channel
import (
"fmt"
"sort"
"sync"
)
// ErrUnknownType is returned by Registry.Build when no Factory is
// registered for the requested Type. Callers can test for it with
// errors.Is.
var ErrUnknownType = fmt.Errorf("channel: no factory registered for type")
// Registry maps a channel Type to the Factory that builds it. Adding a
// platform is "register a factory here", never "edit the core". The
// Registry is safe for concurrent use.
//
// Registration is last-writer-wins: registering a Type that already has a
// Factory replaces it silently. This mirrors the plugin-registry pattern
// from the reference design (MUL-3506) where the last adapter to register
// a type wins, so a deployment can override a built-in adapter by
// registering its own afterwards without a removal step.
type Registry struct {
mu sync.RWMutex
factories map[Type]Factory
}
// NewRegistry returns an empty Registry ready for use.
func NewRegistry() *Registry {
return &Registry{factories: make(map[Type]Factory)}
}
// Register binds factory to t, replacing any factory previously
// registered for t (last-writer-wins). A nil factory or an empty Type is
// ignored — registering either would only set up a guaranteed failure at
// Build time, so the Registry refuses to record it.
func (r *Registry) Register(t Type, factory Factory) {
if t == "" || factory == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.factories[t] = factory
}
// Lookup returns the Factory registered for t and whether one exists.
func (r *Registry) Lookup(t Type) (Factory, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
factory, ok := r.factories[t]
return factory, ok
}
// Build instantiates a Channel for cfg.Type using the registered Factory.
// It returns ErrUnknownType (wrapped, with the type name) when no Factory
// is registered, and otherwise returns whatever the Factory returns.
func (r *Registry) Build(cfg Config) (Channel, error) {
factory, ok := r.Lookup(cfg.Type)
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownType, cfg.Type)
}
return factory(cfg)
}
// Types returns the registered types sorted lexicographically, so the
// result is stable across calls (map iteration order is not). Useful for
// diagnostics and for enumerating which platforms a deployment supports.
func (r *Registry) Types() []Type {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Type, 0, len(r.factories))
for t := range r.factories {
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
@@ -0,0 +1,130 @@
package channel
import (
"context"
"errors"
"reflect"
"sync"
"testing"
)
// fakeChannel is a minimal Channel used to assert which Factory the
// Registry returned. id distinguishes instances built by different
// factories.
type fakeChannel struct {
typ Type
id string
}
func (f fakeChannel) Type() Type { return f.typ }
func (f fakeChannel) Connect(context.Context) error { return nil }
func (f fakeChannel) Disconnect(context.Context) error { return nil }
func (f fakeChannel) Send(context.Context, OutboundMessage) (SendResult, error) {
return SendResult{}, nil
}
func (f fakeChannel) Capabilities() Capability { return CapText }
func factoryReturning(typ Type, id string) Factory {
return func(Config) (Channel, error) { return fakeChannel{typ: typ, id: id}, nil }
}
func TestRegistry_LookupAndBuild(t *testing.T) {
r := NewRegistry()
r.Register(TypeFeishu, factoryReturning(TypeFeishu, "feishu-a"))
if _, ok := r.Lookup(TypeFeishu); !ok {
t.Fatalf("Lookup(%q) ok = false, want true", TypeFeishu)
}
ch, err := r.Build(Config{Type: TypeFeishu})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
got, ok := ch.(fakeChannel)
if !ok {
t.Fatalf("Build returned %T, want fakeChannel", ch)
}
if got.id != "feishu-a" {
t.Fatalf("Build used factory id %q, want %q", got.id, "feishu-a")
}
if got.Type() != TypeFeishu {
t.Fatalf("built channel Type() = %q, want %q", got.Type(), TypeFeishu)
}
}
// TestRegistry_LastWriterWins is the explicit acceptance item: a second
// Register for the same Type silently replaces the first, and Build/Lookup
// resolve to the LATEST factory.
func TestRegistry_LastWriterWins(t *testing.T) {
r := NewRegistry()
r.Register(TypeFeishu, factoryReturning(TypeFeishu, "first"))
r.Register(TypeFeishu, factoryReturning(TypeFeishu, "second"))
ch, err := r.Build(Config{Type: TypeFeishu})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
if got := ch.(fakeChannel).id; got != "second" {
t.Fatalf("last-writer-wins failed: Build used %q, want %q", got, "second")
}
// Registering a type must not create duplicate Types() entries.
if types := r.Types(); !reflect.DeepEqual(types, []Type{TypeFeishu}) {
t.Fatalf("Types() = %v, want exactly [%q]", types, TypeFeishu)
}
}
func TestRegistry_BuildUnknownType(t *testing.T) {
r := NewRegistry()
_, err := r.Build(Config{Type: "nope"})
if !errors.Is(err, ErrUnknownType) {
t.Fatalf("Build unknown type err = %v, want errors.Is ErrUnknownType", err)
}
}
func TestRegistry_RegisterIgnoresInvalid(t *testing.T) {
r := NewRegistry()
r.Register("", factoryReturning(TypeFeishu, "x")) // empty type ignored
r.Register(TypeFeishu, nil) // nil factory ignored
if _, ok := r.Lookup(""); ok {
t.Fatalf("empty Type was registered, want ignored")
}
if _, ok := r.Lookup(TypeFeishu); ok {
t.Fatalf("nil factory was registered, want ignored")
}
if len(r.Types()) != 0 {
t.Fatalf("Types() = %v, want empty", r.Types())
}
}
func TestRegistry_TypesSorted(t *testing.T) {
r := NewRegistry()
r.Register("wecom", factoryReturning("wecom", "w"))
r.Register("feishu", factoryReturning("feishu", "f"))
r.Register("slack", factoryReturning("slack", "s"))
got := r.Types()
want := []Type{"feishu", "slack", "wecom"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Types() = %v, want sorted %v", got, want)
}
}
// TestRegistry_ConcurrentAccess exercises the RWMutex under the race
// detector: concurrent Register/Lookup/Build/Types must not data-race.
func TestRegistry_ConcurrentAccess(t *testing.T) {
r := NewRegistry()
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
r.Register(TypeFeishu, factoryReturning(TypeFeishu, "x"))
_, _ = r.Lookup(TypeFeishu)
_, _ = r.Build(Config{Type: TypeFeishu})
_ = r.Types()
}()
}
wg.Wait()
}
+11 -11
View File
@@ -12,24 +12,24 @@ import (
// indirection — the RecordLarkInboundDrop query rejects any column
// that could carry a message body, and this struct does too.
type dbAuditLogger struct {
queries *db.Queries
queries *ChannelStore
}
// NewAuditLogger returns an AuditLogger that writes drop events to the
// lark_inbound_audit table. The interface signature does not accept a
// message body, mirroring §4.7 of the design (non-content audit only).
// channel_inbound_audit table. The interface signature does not accept
// a message body, mirroring §4.7 of the design (non-content audit only).
func NewAuditLogger(queries *db.Queries) AuditLogger {
return &dbAuditLogger{queries: queries}
return &dbAuditLogger{queries: NewChannelStore(queries)}
}
func (l *dbAuditLogger) RecordDrop(ctx context.Context, p AuditDropParams) error {
return l.queries.RecordLarkInboundDrop(ctx, db.RecordLarkInboundDropParams{
EventType: p.EventType,
DropReason: string(p.Reason),
InstallationID: p.InstallationID,
LarkChatID: textIfNonEmpty(string(p.ChatID)),
LarkEventID: textIfNonEmpty(p.LarkEventID),
LarkMessageID: textIfNonEmpty(p.LarkMessageID),
return l.queries.RecordLarkInboundDrop(ctx, RecordInboundDropParams{
EventType: p.EventType,
DropReason: string(p.Reason),
InstallationID: p.InstallationID,
ChannelChatID: textIfNonEmpty(string(p.ChatID)),
ChannelEventID: textIfNonEmpty(p.LarkEventID),
ChannelMessageID: textIfNonEmpty(p.LarkMessageID),
})
}
@@ -11,7 +11,6 @@ import (
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
@@ -45,12 +44,12 @@ type RedeemedBindingToken struct {
// Implementations MUST be idempotent on (installation_id, lark_open_id):
// a re-install by the same user should not error.
//
// `qtx` is the *db.Queries handle to run the bind against. The caller
// opens the transaction so the installation insert and the binding
// write commit together; nil means "use the service's own
// `qtx` is the channel-backed handle to run the bind against. The
// caller opens the transaction so the installation insert and the
// binding write commit together; nil means "use the service's own
// (non-transactional) queries handle".
type InstallerBinder interface {
BindInstallerTx(ctx context.Context, qtx *db.Queries, p InstallerBindParams) error
BindInstallerTx(ctx context.Context, qtx *ChannelStore, p InstallerBindParams) error
}
// InstallerBindParams carries the inputs InstallerBinder needs. Kept
@@ -72,7 +71,7 @@ type InstallerBindParams struct {
// failed bind never burns a token, and a successful bind never
// leaves a consumed-but-unused token behind.
type BindingTokenService struct {
queries *db.Queries
queries *ChannelStore
tx TxStarter
now func() time.Time
}
@@ -86,9 +85,10 @@ func NewBindingTokenService(queries *db.Queries, tx TxStarter) *BindingTokenServ
}
// NewBindingTokenServiceWithClock is the seam for tests; production
// callers should use NewBindingTokenService.
// callers should use NewBindingTokenService. queries is wrapped in a
// ChannelStore so lark_* calls resolve to channel_* rows (MUL-3515).
func NewBindingTokenServiceWithClock(queries *db.Queries, tx TxStarter, now func() time.Time) *BindingTokenService {
return &BindingTokenService{queries: queries, tx: tx, now: now}
return &BindingTokenService{queries: NewChannelStore(queries), tx: tx, now: now}
}
// Mint creates a new single-use binding token and returns the raw
@@ -104,11 +104,11 @@ func (s *BindingTokenService) Mint(ctx context.Context, workspaceID, installatio
hash := hashToken(raw)
expiresAt := s.now().Add(BindingTokenTTL)
if _, err := s.queries.CreateLarkBindingToken(ctx, db.CreateLarkBindingTokenParams{
if _, err := s.queries.CreateLarkBindingToken(ctx, CreateBindingTokenParams{
TokenHash: hash,
WorkspaceID: workspaceID,
InstallationID: installationID,
LarkOpenID: string(openID),
ChannelUserID: string(openID),
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
}); err != nil {
return BindingToken{}, fmt.Errorf("persist token: %w", err)
@@ -161,28 +161,33 @@ func (s *BindingTokenService) RedeemAndBind(ctx context.Context, raw string, mul
return RedeemedBindingToken{}, fmt.Errorf("consume token: %w", err)
}
_, err = qtx.CreateLarkUserBinding(ctx, db.CreateLarkUserBindingParams{
// Explicit membership gate. The lark_user_binding -> member FK that
// used to reject a non-member redeemer is gone (MUL-3515 §4), so we
// check it here. Returning before Commit rolls the consume back, so
// a non-member's attempt does not burn the token — same outcome the
// FK violation produced.
isMember, err := qtx.IsWorkspaceMember(ctx, row.WorkspaceID, multicaUserID)
if err != nil {
return RedeemedBindingToken{}, fmt.Errorf("check membership: %w", err)
}
if !isMember {
return RedeemedBindingToken{}, ErrBindingNotWorkspaceMember
}
_, err = qtx.CreateLarkUserBinding(ctx, CreateUserBindingParams{
WorkspaceID: row.WorkspaceID,
MulticaUserID: multicaUserID,
InstallationID: row.InstallationID,
LarkOpenID: row.LarkOpenID,
ChannelUserID: row.ChannelUserID,
})
if err != nil {
// pgx.ErrNoRows here means the conflict row exists but its
// multica_user_id differs from ours, so the WHERE clause on
// the ON CONFLICT DO UPDATE rejected the rebind. See the
// comment on CreateLarkUserBinding in queries/lark.sql.
// comment on CreateChannelUserBinding in queries/channel.sql.
if errors.Is(err, pgx.ErrNoRows) {
return RedeemedBindingToken{}, ErrBindingAlreadyAssigned
}
// 23503 is foreign_key_violation. The relevant FK here is
// lark_user_binding_member_fk (workspace_id, multica_user_id)
// → member; tripping it means the redeemer is not a member
// of the token's workspace.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
return RedeemedBindingToken{}, ErrBindingNotWorkspaceMember
}
return RedeemedBindingToken{}, fmt.Errorf("create binding: %w", err)
}
@@ -192,7 +197,7 @@ func (s *BindingTokenService) RedeemAndBind(ctx context.Context, raw string, mul
return RedeemedBindingToken{
WorkspaceID: row.WorkspaceID,
InstallationID: row.InstallationID,
LarkOpenID: OpenID(row.LarkOpenID),
LarkOpenID: OpenID(row.ChannelUserID),
}, nil
}
@@ -228,25 +233,31 @@ func (s *BindingTokenService) RedeemAndBind(ctx context.Context, raw string, mul
// frontend surfaces it as "this Lark account is bound elsewhere",
// preventing one workspace admin from silently rebinding another's
// PersonalAgent install.
func (s *BindingTokenService) BindInstallerTx(ctx context.Context, qtx *db.Queries, p InstallerBindParams) error {
func (s *BindingTokenService) BindInstallerTx(ctx context.Context, qtx *ChannelStore, p InstallerBindParams) error {
q := qtx
if q == nil {
q = s.queries
}
_, err := q.CreateLarkUserBinding(ctx, db.CreateLarkUserBindingParams{
// Explicit membership gate, replacing the removed member FK
// (MUL-3515 §4): the installer must be a member of the workspace
// they are binding into.
isMember, err := q.IsWorkspaceMember(ctx, p.WorkspaceID, p.MulticaUserID)
if err != nil {
return fmt.Errorf("check membership: %w", err)
}
if !isMember {
return ErrBindingNotWorkspaceMember
}
_, err = q.CreateLarkUserBinding(ctx, CreateUserBindingParams{
WorkspaceID: p.WorkspaceID,
MulticaUserID: p.MulticaUserID,
InstallationID: p.InstallationID,
LarkOpenID: string(p.LarkOpenID),
ChannelUserID: string(p.LarkOpenID),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrBindingAlreadyAssigned
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
return ErrBindingNotWorkspaceMember
}
return fmt.Errorf("bind installer: %w", err)
}
return nil
@@ -267,10 +278,11 @@ var ErrBindingTokenInvalid = errors.New("binding token invalid or expired")
// cannot be used to grab an already-bound open_id from another user.
var ErrBindingAlreadyAssigned = errors.New("lark open_id is already bound to a different user")
// ErrBindingNotWorkspaceMember is returned by RedeemAndBind when the
// redeemer is not (or no longer) a member of the token's workspace,
// detected as a foreign-key violation against member(workspace_id,
// user_id). Translated to 403 at the HTTP boundary.
// ErrBindingNotWorkspaceMember is returned by RedeemAndBind and
// BindInstallerTx when the user is not (or no longer) a member of the
// target workspace, detected by an explicit IsWorkspaceMember check
// (MUL-3515 §4 removed the member FK that used to enforce this).
// Translated to 403 at the HTTP boundary.
var ErrBindingNotWorkspaceMember = errors.New("redeemer is not a workspace member")
func randomToken(n int) (string, error) {
@@ -0,0 +1,398 @@
package lark
// ChannelStore is the production data layer for the Feishu integration after
// MUL-3515 generalized lark_* into channel_*. It embeds *db.Queries (so every
// generic query — chat_session, chat_message, member, workspace, agent — is
// available unchanged) and adds the feishu-specific store methods, each backed
// by a channel_* query and translating at the JSONB-config boundary (store.go).
//
// The methods take and return the package's flat domain types (Installation,
// UserBinding, ChatSessionBinding, InboundMessageDedup, BindingTokenRow,
// OutboundCardMessage) and the *Params types in params.go. This store reads
// and writes only channel_*, never lark_*; queries/lark.sql is deleted. The
// physical lark_* tables are retained one release for rollout/rollback safety
// (see migration 124's ROLLOUT note) and dropped by a later cleanup migration.
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// channelTypeFeishu is the channel_type discriminator for every row this
// Feishu-backed store reads or writes.
const channelTypeFeishu = "feishu"
type ChannelStore struct {
*db.Queries
}
// NewChannelStore wraps a *db.Queries so the lark package's DB seams resolve to
// channel_* rows.
func NewChannelStore(q *db.Queries) *ChannelStore {
return &ChannelStore{Queries: q}
}
// WithTx returns a ChannelStore bound to tx. It shadows db.Queries.WithTx (which
// returns *db.Queries) so transactional callers (chat ingest, token redemption)
// keep the channel-backed store methods inside their tx.
func (s *ChannelStore) WithTx(tx pgx.Tx) *ChannelStore {
return &ChannelStore{Queries: s.Queries.WithTx(tx)}
}
// IsWorkspaceMember reports whether userID is currently a member of
// workspaceID. With the lark_user_binding -> member foreign key removed
// (MUL-3515 §4), a binding row no longer proves membership, so the inbound
// identity step calls this to re-check it explicitly. ErrNoRows -> not a member.
func (s *ChannelStore) IsWorkspaceMember(ctx context.Context, workspaceID, userID pgtype.UUID) (bool, error) {
_, err := s.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
UserID: userID,
WorkspaceID: workspaceID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, err
}
return true, nil
}
// ---- installation ----
func (s *ChannelStore) GetLarkInstallationByAppID(ctx context.Context, appID string) (Installation, error) {
row, err := s.Queries.GetChannelInstallationByAppID(ctx, db.GetChannelInstallationByAppIDParams{
ChannelType: channelTypeFeishu,
AppID: appID,
})
if err != nil {
return Installation{}, err
}
return installationFromRow(row)
}
func (s *ChannelStore) GetLarkInstallation(ctx context.Context, id pgtype.UUID) (Installation, error) {
row, err := s.Queries.GetChannelInstallation(ctx, db.GetChannelInstallationParams{
ID: id,
ChannelType: channelTypeFeishu,
})
if err != nil {
return Installation{}, err
}
return installationFromRow(row)
}
func (s *ChannelStore) GetLarkInstallationInWorkspace(ctx context.Context, arg GetInstallationInWorkspaceParams) (Installation, error) {
row, err := s.Queries.GetChannelInstallationInWorkspace(ctx, db.GetChannelInstallationInWorkspaceParams{
ID: arg.ID,
WorkspaceID: arg.WorkspaceID,
ChannelType: channelTypeFeishu,
})
if err != nil {
return Installation{}, err
}
return installationFromRow(row)
}
func (s *ChannelStore) ListLarkInstallationsByWorkspace(ctx context.Context, workspaceID pgtype.UUID) ([]Installation, error) {
rows, err := s.Queries.ListChannelInstallationsByWorkspace(ctx, db.ListChannelInstallationsByWorkspaceParams{
WorkspaceID: workspaceID,
ChannelType: channelTypeFeishu,
})
if err != nil {
return nil, err
}
return installationsFromRows(rows)
}
func (s *ChannelStore) ListActiveLarkInstallations(ctx context.Context) ([]Installation, error) {
rows, err := s.Queries.ListActiveChannelInstallations(ctx, channelTypeFeishu)
if err != nil {
return nil, err
}
return installationsFromRows(rows)
}
func (s *ChannelStore) UpsertLarkInstallation(ctx context.Context, arg UpsertInstallationParams) (Installation, error) {
cfg, err := encodeInstallConfig(Installation{
AppID: arg.AppID,
AppSecretEncrypted: arg.AppSecretEncrypted,
TenantKey: arg.TenantKey,
BotOpenID: arg.BotOpenID,
BotUnionID: arg.BotUnionID,
Region: arg.Region,
})
if err != nil {
return Installation{}, err
}
row, err := s.Queries.UpsertChannelInstallation(ctx, db.UpsertChannelInstallationParams{
WorkspaceID: arg.WorkspaceID,
AgentID: arg.AgentID,
ChannelType: channelTypeFeishu,
Config: cfg,
InstallerUserID: arg.InstallerUserID,
})
if err != nil {
return Installation{}, err
}
return installationFromRow(row)
}
func (s *ChannelStore) SetLarkInstallationStatus(ctx context.Context, arg SetInstallationStatusParams) error {
return s.Queries.SetChannelInstallationStatus(ctx, db.SetChannelInstallationStatusParams{
ID: arg.ID,
Status: arg.Status,
})
}
// SetLarkInstallationBotUnionID folds bot_union_id into the JSONB config via a
// read-modify-write through SetChannelInstallationConfig (channel_installation
// has no dedicated union_id column). This is the operator union_id backfill,
// keyed by id and effectively single-writer, so the non-atomic RMW is safe —
// the same shape the channel.sql comment documents for this query.
func (s *ChannelStore) SetLarkInstallationBotUnionID(ctx context.Context, arg SetInstallationBotUnionIDParams) error {
row, err := s.Queries.GetChannelInstallation(ctx, db.GetChannelInstallationParams{
ID: arg.ID,
ChannelType: channelTypeFeishu,
})
if err != nil {
return err
}
inst, err := installationFromRow(row)
if err != nil {
return err
}
inst.BotUnionID = arg.BotUnionID
cfg, err := encodeInstallConfig(inst)
if err != nil {
return err
}
return s.Queries.SetChannelInstallationConfig(ctx, db.SetChannelInstallationConfigParams{
ID: arg.ID,
Config: cfg,
})
}
func (s *ChannelStore) BackfillLarkInstallationRegionToLark(ctx context.Context) (int64, error) {
return s.Queries.BackfillChannelInstallationRegionToFeishuLark(ctx)
}
// ---- WS lease ----
func (s *ChannelStore) AcquireLarkWSLease(ctx context.Context, arg AcquireWSLeaseParams) (Installation, error) {
row, err := s.Queries.AcquireChannelWSLease(ctx, db.AcquireChannelWSLeaseParams{
NewToken: arg.NewToken,
NewExpiresAt: arg.NewExpiresAt,
ID: arg.ID,
})
if err != nil {
return Installation{}, err
}
return installationFromRow(row)
}
func (s *ChannelStore) ReleaseLarkWSLease(ctx context.Context, arg ReleaseWSLeaseParams) error {
return s.Queries.ReleaseChannelWSLease(ctx, db.ReleaseChannelWSLeaseParams{
ID: arg.ID,
CurrentToken: arg.CurrentToken,
})
}
// ---- user binding ----
func (s *ChannelStore) GetLarkUserBindingByOpenID(ctx context.Context, arg GetUserBindingByOpenIDParams) (UserBinding, error) {
row, err := s.Queries.GetChannelUserBindingByUserID(ctx, db.GetChannelUserBindingByUserIDParams{
InstallationID: arg.InstallationID,
ChannelUserID: arg.ChannelUserID,
})
if err != nil {
return UserBinding{}, err
}
return userBindingFromRow(row)
}
func (s *ChannelStore) CreateLarkUserBinding(ctx context.Context, arg CreateUserBindingParams) (UserBinding, error) {
cfg, err := encodeBindingConfig(UserBinding{UnionID: arg.UnionID})
if err != nil {
return UserBinding{}, err
}
row, err := s.Queries.CreateChannelUserBinding(ctx, db.CreateChannelUserBindingParams{
WorkspaceID: arg.WorkspaceID,
MulticaUserID: arg.MulticaUserID,
InstallationID: arg.InstallationID,
ChannelType: channelTypeFeishu,
ChannelUserID: arg.ChannelUserID,
Config: cfg,
})
if err != nil {
return UserBinding{}, err
}
return userBindingFromRow(row)
}
// ---- chat session binding ----
func (s *ChannelStore) GetLarkChatSessionBinding(ctx context.Context, arg GetChatSessionBindingParams) (ChatSessionBinding, error) {
row, err := s.Queries.GetChannelChatSessionBinding(ctx, db.GetChannelChatSessionBindingParams{
InstallationID: arg.InstallationID,
ChannelChatID: arg.ChannelChatID,
})
if err != nil {
return ChatSessionBinding{}, err
}
return chatSessionBindingFromRow(row), nil
}
func (s *ChannelStore) GetLarkChatSessionBindingBySession(ctx context.Context, chatSessionID pgtype.UUID) (ChatSessionBinding, error) {
row, err := s.Queries.GetChannelChatSessionBindingBySession(ctx, db.GetChannelChatSessionBindingBySessionParams{
ChatSessionID: chatSessionID,
ChannelType: channelTypeFeishu,
})
if err != nil {
return ChatSessionBinding{}, err
}
return chatSessionBindingFromRow(row), nil
}
func (s *ChannelStore) CreateLarkChatSessionBinding(ctx context.Context, arg CreateChatSessionBindingParams) (ChatSessionBinding, error) {
row, err := s.Queries.CreateChannelChatSessionBinding(ctx, db.CreateChannelChatSessionBindingParams{
ChatSessionID: arg.ChatSessionID,
InstallationID: arg.InstallationID,
ChannelType: channelTypeFeishu,
ChannelChatID: arg.ChannelChatID,
ChatType: arg.ChatType,
})
if err != nil {
return ChatSessionBinding{}, err
}
return chatSessionBindingFromRow(row), nil
}
func (s *ChannelStore) UpdateLarkChatSessionBindingReplyTarget(ctx context.Context, arg UpdateChatSessionBindingReplyTargetParams) error {
return s.Queries.UpdateChannelChatSessionBindingReplyTarget(ctx, db.UpdateChannelChatSessionBindingReplyTargetParams{
ChatSessionID: arg.ChatSessionID,
LastMessageID: arg.LastMessageID,
LastThreadID: arg.LastThreadID,
})
}
// ---- inbound dedup ----
func (s *ChannelStore) ClaimLarkInboundDedup(ctx context.Context, arg ClaimInboundDedupParams) (InboundMessageDedup, error) {
row, err := s.Queries.ClaimChannelInboundDedup(ctx, db.ClaimChannelInboundDedupParams{
InstallationID: arg.InstallationID,
MessageID: arg.MessageID,
})
if err != nil {
return InboundMessageDedup{}, err
}
return dedupFromRow(row), nil
}
func (s *ChannelStore) MarkLarkInboundDedupProcessed(ctx context.Context, arg MarkInboundDedupProcessedParams) (int64, error) {
return s.Queries.MarkChannelInboundDedupProcessed(ctx, db.MarkChannelInboundDedupProcessedParams{
InstallationID: arg.InstallationID,
MessageID: arg.MessageID,
ClaimToken: arg.ClaimToken,
})
}
func (s *ChannelStore) ReleaseLarkInboundDedup(ctx context.Context, arg ReleaseInboundDedupParams) (int64, error) {
return s.Queries.ReleaseChannelInboundDedup(ctx, db.ReleaseChannelInboundDedupParams{
InstallationID: arg.InstallationID,
MessageID: arg.MessageID,
ClaimToken: arg.ClaimToken,
})
}
// ---- audit ----
func (s *ChannelStore) RecordLarkInboundDrop(ctx context.Context, arg RecordInboundDropParams) error {
return s.Queries.RecordChannelInboundDrop(ctx, db.RecordChannelInboundDropParams{
ChannelType: channelTypeFeishu,
EventType: arg.EventType,
DropReason: arg.DropReason,
InstallationID: arg.InstallationID,
ChannelChatID: arg.ChannelChatID,
ChannelEventID: arg.ChannelEventID,
ChannelMessageID: arg.ChannelMessageID,
})
}
// ---- binding token ----
func (s *ChannelStore) CreateLarkBindingToken(ctx context.Context, arg CreateBindingTokenParams) (BindingTokenRow, error) {
row, err := s.Queries.CreateChannelBindingToken(ctx, db.CreateChannelBindingTokenParams{
TokenHash: arg.TokenHash,
WorkspaceID: arg.WorkspaceID,
InstallationID: arg.InstallationID,
ChannelType: channelTypeFeishu,
ChannelUserID: arg.ChannelUserID,
ExpiresAt: arg.ExpiresAt,
})
if err != nil {
return BindingTokenRow{}, err
}
return bindingTokenFromRow(row), nil
}
func (s *ChannelStore) ConsumeLarkBindingToken(ctx context.Context, tokenHash string) (BindingTokenRow, error) {
row, err := s.Queries.ConsumeChannelBindingToken(ctx, tokenHash)
if err != nil {
return BindingTokenRow{}, err
}
return bindingTokenFromRow(row), nil
}
// ---- outbound card ----
func (s *ChannelStore) GetLarkOutboundCardByTask(ctx context.Context, taskID pgtype.UUID) (OutboundCardMessage, error) {
row, err := s.Queries.GetChannelOutboundCardByTask(ctx, db.GetChannelOutboundCardByTaskParams{
TaskID: taskID,
ChannelType: channelTypeFeishu,
})
if err != nil {
return OutboundCardMessage{}, err
}
return outboundCardFromRow(row), nil
}
func (s *ChannelStore) CreateLarkOutboundCardMessage(ctx context.Context, arg CreateOutboundCardMessageParams) (OutboundCardMessage, error) {
row, err := s.Queries.CreateChannelOutboundCardMessage(ctx, db.CreateChannelOutboundCardMessageParams{
ChatSessionID: arg.ChatSessionID,
ChannelType: channelTypeFeishu,
ChannelChatID: arg.ChannelChatID,
ChannelCardMessageID: arg.ChannelCardMessageID,
Status: arg.Status,
TaskID: arg.TaskID,
})
if err != nil {
return OutboundCardMessage{}, err
}
return outboundCardFromRow(row), nil
}
func (s *ChannelStore) UpdateLarkOutboundCardStatus(ctx context.Context, arg UpdateOutboundCardStatusParams) error {
return s.Queries.UpdateChannelOutboundCardStatus(ctx, db.UpdateChannelOutboundCardStatusParams{
ID: arg.ID,
Status: arg.Status,
})
}
// installationsFromRows maps a slice of channel_installation rows to domain
// Installations, surfacing the first config-decode error.
func installationsFromRows(rows []db.ChannelInstallation) ([]Installation, error) {
out := make([]Installation, len(rows))
for i, row := range rows {
inst, err := installationFromRow(row)
if err != nil {
return nil, err
}
out[i] = inst
}
return out, nil
}
@@ -0,0 +1,154 @@
package lark
import (
"context"
"errors"
"os"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// channelScopeTestDB connects to the test Postgres (DATABASE_URL or the default
// local DSN, same as the handler suite) and returns a pool, or skips when no
// migrated database is reachable. Kept local to this file so the rest of the
// lark package stays DB-free.
func channelScopeTestDB(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
dsn = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Skipf("no database: %v", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
t.Skipf("database not reachable: %v", err)
}
var present bool
if err := pool.QueryRow(ctx, "SELECT to_regclass('public.channel_installation') IS NOT NULL").Scan(&present); err != nil || !present {
pool.Close()
t.Skip("channel_installation not present (database not migrated)")
}
t.Cleanup(pool.Close)
return pool
}
// TestChannelStore_ScopesToFeishu is the MUL-3515 regression guard: the
// Lark/Feishu wrappers on ChannelStore must never read another channel_type's
// rows, even when a non-Feishu installation / chat-session binding / outbound
// card shares the same workspace, chat_session, or task. (Member-removal and
// chat-session cleanup deliberately stay all-channel; that is covered by the
// handler tests.)
func TestChannelStore_ScopesToFeishu(t *testing.T) {
pool := channelScopeTestDB(t)
ctx := context.Background()
store := NewChannelStore(db.New(pool))
// Synthetic, distinctive identifiers. channel_* has no foreign keys, so
// these rows need no parent records and nothing cascades — which is also
// why the test cleans up explicitly, by deterministic key, before and
// after (a killed prior run must not leave colliding rows behind).
const (
feishuApp = "cli_scope_feishu"
slackApp = "cli_scope_slack"
wsID = "5c09e000-0000-4000-8000-000000000001"
agentID = "5c09e000-0000-4000-8000-000000000002"
chatSessionID = "5c09e000-0000-4000-8000-000000000003"
taskID = "5c09e000-0000-4000-8000-000000000004"
installerID = "5c09e000-0000-4000-8000-000000000005"
)
clean := func() {
_, _ = pool.Exec(context.Background(),
`DELETE FROM channel_installation WHERE config->>'app_id' = ANY($1)`,
[]string{feishuApp, slackApp})
_, _ = pool.Exec(context.Background(),
`DELETE FROM channel_chat_session_binding WHERE chat_session_id = $1`, chatSessionID)
_, _ = pool.Exec(context.Background(),
`DELETE FROM channel_outbound_card_message WHERE task_id = $1`, taskID)
}
clean()
t.Cleanup(clean)
insertInstallation := func(channelType, app string) pgtype.UUID {
var id string
if err := pool.QueryRow(ctx, `
INSERT INTO channel_installation (workspace_id, agent_id, channel_type, config, installer_user_id)
VALUES ($1, $2, $3, jsonb_build_object('app_id', $4::text), $5)
RETURNING id
`, wsID, agentID, channelType, app, installerID).Scan(&id); err != nil {
t.Fatalf("insert %s installation: %v", channelType, err)
}
return util.MustParseUUID(id)
}
feishuID := insertInstallation("feishu", feishuApp)
slackID := insertInstallation("slack", slackApp)
// A non-Feishu binding/card sharing this test's chat_session and task.
if _, err := pool.Exec(ctx, `
INSERT INTO channel_chat_session_binding (chat_session_id, installation_id, channel_type, channel_chat_id, chat_type)
VALUES ($1, $2, 'slack', 'oc_scope_slack', 'p2p')
`, chatSessionID, slackID); err != nil {
t.Fatalf("insert slack chat binding: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO channel_outbound_card_message (chat_session_id, task_id, channel_type, channel_chat_id, channel_card_message_id, status)
VALUES ($1, $2, 'slack', 'oc_scope_slack', 'om_scope_slack', 'pending')
`, chatSessionID, taskID); err != nil {
t.Fatalf("insert slack outbound card: %v", err)
}
wsUUID := util.MustParseUUID(wsID)
sessionUUID := util.MustParseUUID(chatSessionID)
taskUUID := util.MustParseUUID(taskID)
// --- installation reads: Feishu visible, Slack invisible ---
if got, err := store.GetLarkInstallation(ctx, feishuID); err != nil || got.AppID != feishuApp {
t.Fatalf("GetLarkInstallation(feishu): got app=%q err=%v, want app=%q nil", got.AppID, err, feishuApp)
}
if _, err := store.GetLarkInstallation(ctx, slackID); !errors.Is(err, pgx.ErrNoRows) {
t.Fatalf("GetLarkInstallation(slack): err=%v, want pgx.ErrNoRows (scoped out)", err)
}
if got, err := store.GetLarkInstallationInWorkspace(ctx, GetInstallationInWorkspaceParams{ID: feishuID, WorkspaceID: wsUUID}); err != nil || got.AppID != feishuApp {
t.Fatalf("GetLarkInstallationInWorkspace(feishu): got app=%q err=%v, want app=%q nil", got.AppID, err, feishuApp)
}
if _, err := store.GetLarkInstallationInWorkspace(ctx, GetInstallationInWorkspaceParams{ID: slackID, WorkspaceID: wsUUID}); !errors.Is(err, pgx.ErrNoRows) {
t.Fatalf("GetLarkInstallationInWorkspace(slack): err=%v, want pgx.ErrNoRows (scoped out)", err)
}
// list-by-workspace: only the Feishu installation in this workspace
byWs, err := store.ListLarkInstallationsByWorkspace(ctx, wsUUID)
if err != nil {
t.Fatalf("ListLarkInstallationsByWorkspace: %v", err)
}
if len(byWs) != 1 || byWs[0].AppID != feishuApp {
apps := make([]string, len(byWs))
for i, r := range byWs {
apps[i] = r.AppID
}
t.Fatalf("ListLarkInstallationsByWorkspace: got apps=%v, want exactly [%s]", apps, feishuApp)
}
// (ListActiveLarkInstallations channel-type + live workspace/agent scoping
// is covered by TestListActiveLarkInstallations_SkipsOrphans in the handler
// package, which has real workspace/agent fixtures the JOIN now requires.)
// --- outbound reads: a Slack binding/card must not be seen as Feishu ---
if _, err := store.GetLarkChatSessionBindingBySession(ctx, sessionUUID); !errors.Is(err, pgx.ErrNoRows) {
t.Fatalf("GetLarkChatSessionBindingBySession(slack-bound session): err=%v, want pgx.ErrNoRows (scoped out)", err)
}
if _, err := store.GetLarkOutboundCardByTask(ctx, taskUUID); !errors.Is(err, pgx.ErrNoRows) {
t.Fatalf("GetLarkOutboundCardByTask(slack card): err=%v, want pgx.ErrNoRows (scoped out)", err)
}
}
@@ -66,15 +66,17 @@ type TxStarter interface {
// failure (rolled-back tx → no chat_message → next replay
// re-processes).
type chatSessionService struct {
queries *db.Queries
queries *ChannelStore
txStarter TxStarter
}
// NewChatSessionService constructs a ChatSessionService backed by the
// supplied queries and tx starter. The tx starter is required;
// without it, AppendUserMessage cannot run dedup + insert atomically.
// queries is wrapped in a ChannelStore so the lark_* calls resolve to
// channel_* rows (MUL-3515).
func NewChatSessionService(queries *db.Queries, tx TxStarter) ChatSessionService {
return &chatSessionService{queries: queries, txStarter: tx}
return &chatSessionService{queries: NewChannelStore(queries), txStarter: tx}
}
// EnsureChatSession returns the chat_session.id bound to the given
@@ -93,9 +95,9 @@ func NewChatSessionService(queries *db.Queries, tx TxStarter) ChatSessionService
// returns its chat_session_id.
func (s *chatSessionService) EnsureChatSession(ctx context.Context, p EnsureChatSessionParams) (pgtype.UUID, error) {
// Fast path: existing binding.
existing, err := s.queries.GetLarkChatSessionBinding(ctx, db.GetLarkChatSessionBindingParams{
existing, err := s.queries.GetLarkChatSessionBinding(ctx, GetChatSessionBindingParams{
InstallationID: p.InstallationID,
LarkChatID: string(p.ChatID),
ChannelChatID: string(p.ChatID),
})
if err == nil {
return existing.ChatSessionID, nil
@@ -113,9 +115,9 @@ func (s *chatSessionService) EnsureChatSession(ctx context.Context, p EnsureChat
// Lost the race: another goroutine created the binding between our
// lookup and our insert. Re-read and return the winner's session.
if isUniqueViolation(err) {
existing, lookupErr := s.queries.GetLarkChatSessionBinding(ctx, db.GetLarkChatSessionBindingParams{
existing, lookupErr := s.queries.GetLarkChatSessionBinding(ctx, GetChatSessionBindingParams{
InstallationID: p.InstallationID,
LarkChatID: string(p.ChatID),
ChannelChatID: string(p.ChatID),
})
if lookupErr == nil {
return existing.ChatSessionID, nil
@@ -143,11 +145,11 @@ func (s *chatSessionService) createSessionAndBinding(ctx context.Context, p Ensu
return pgtype.UUID{}, fmt.Errorf("create chat session: %w", err)
}
if _, err := qtx.CreateLarkChatSessionBinding(ctx, db.CreateLarkChatSessionBindingParams{
if _, err := qtx.CreateLarkChatSessionBinding(ctx, CreateChatSessionBindingParams{
ChatSessionID: session.ID,
InstallationID: p.InstallationID,
LarkChatID: string(p.ChatID),
LarkChatType: string(p.ChatType),
ChannelChatID: string(p.ChatID),
ChatType: string(p.ChatType),
}); err != nil {
return pgtype.UUID{}, err
}
@@ -245,10 +247,10 @@ func (s *chatSessionService) AppendUserMessage(ctx context.Context, p AppendUser
// chat-level send path. Skipped when there is no Lark message_id
// (defensive: every real inbound carries one).
if p.LarkMessageID != "" {
if err := qtx.UpdateLarkChatSessionBindingReplyTarget(ctx, db.UpdateLarkChatSessionBindingReplyTargetParams{
ChatSessionID: p.ChatSessionID,
LastLarkMessageID: textOrNull(p.LarkMessageID),
LastLarkThreadID: textOrNull(p.LarkThreadID),
if err := qtx.UpdateLarkChatSessionBindingReplyTarget(ctx, UpdateChatSessionBindingReplyTargetParams{
ChatSessionID: p.ChatSessionID,
LastMessageID: textOrNull(p.LarkMessageID),
LastThreadID: textOrNull(p.LarkThreadID),
}); err != nil {
return AppendResult{}, fmt.Errorf("update reply target: %w", err)
}
@@ -263,7 +265,7 @@ func (s *chatSessionService) AppendUserMessage(ctx context.Context, p AppendUser
// while another worker also wrote one.
markedInTx := false
if p.ClaimToken.Valid && p.LarkMessageID != "" {
rows, err := qtx.MarkLarkInboundDedupProcessed(ctx, db.MarkLarkInboundDedupProcessedParams{
rows, err := qtx.MarkLarkInboundDedupProcessed(ctx, MarkInboundDedupProcessedParams{
InstallationID: p.InstallationID,
MessageID: p.LarkMessageID,
ClaimToken: p.ClaimToken,
+44 -19
View File
@@ -189,18 +189,25 @@ type ChatTaskEnqueuer interface {
// commit and Mark" window. See lark_inbound_message_dedup comment
// in 109_lark_integration.up.sql for the full invariant set.
type DispatcherQueries interface {
GetLarkInstallationByAppID(ctx context.Context, appID string) (db.LarkInstallation, error)
GetLarkUserBindingByOpenID(ctx context.Context, arg db.GetLarkUserBindingByOpenIDParams) (db.LarkUserBinding, error)
GetLarkInstallationByAppID(ctx context.Context, appID string) (Installation, error)
GetLarkUserBindingByOpenID(ctx context.Context, arg GetUserBindingByOpenIDParams) (UserBinding, error)
GetChatSession(ctx context.Context, id pgtype.UUID) (db.ChatSession, error)
ClaimLarkInboundDedup(ctx context.Context, arg db.ClaimLarkInboundDedupParams) (db.LarkInboundMessageDedup, error)
MarkLarkInboundDedupProcessed(ctx context.Context, arg db.MarkLarkInboundDedupProcessedParams) (int64, error)
ReleaseLarkInboundDedup(ctx context.Context, arg db.ReleaseLarkInboundDedupParams) (int64, error)
ClaimLarkInboundDedup(ctx context.Context, arg ClaimInboundDedupParams) (InboundMessageDedup, error)
MarkLarkInboundDedupProcessed(ctx context.Context, arg MarkInboundDedupProcessedParams) (int64, error)
ReleaseLarkInboundDedup(ctx context.Context, arg ReleaseInboundDedupParams) (int64, error)
// GetWorkspace is needed to read IssuePrefix so the /issue
// confirmation message can render the workspace-qualified key
// ("MUL-42"). A lookup failure is non-fatal — we degrade to
// emitting just the issue number — so callers handle the error
// inline rather than aborting the whole dispatch.
GetWorkspace(ctx context.Context, id pgtype.UUID) (db.Workspace, error)
// IsWorkspaceMember re-checks current membership in the inbound
// identity step. With the lark_user_binding -> member foreign key
// removed (MUL-3515 §4), a binding row no longer proves the bound
// user is still a workspace member, so the dispatcher verifies it
// explicitly instead of trusting the row's existence.
IsWorkspaceMember(ctx context.Context, workspaceID, userID pgtype.UUID) (bool, error)
}
// Dispatcher is the single per-message entry point on the inbound
@@ -240,7 +247,7 @@ type Dispatcher struct {
// FlushReplyFunc matches OutcomeReplier.Reply so the production replier can
// be injected directly. It is invoked from the debounced flush goroutine
// to deliver the agent-offline / agent-archived notice.
type FlushReplyFunc func(ctx context.Context, inst db.LarkInstallation, msg InboundMessage, res DispatchResult)
type FlushReplyFunc func(ctx context.Context, inst Installation, msg InboundMessage, res DispatchResult)
// chatRunFlushTimeout bounds the detached flush (session reload +
// EnqueueChatTask + offline/archived notice). The flush runs on its own
@@ -339,7 +346,7 @@ func (d *Dispatcher) Handle(ctx context.Context, msg InboundMessage) (DispatchRe
var claimToken pgtype.UUID
claimed := false
if msg.MessageID != "" {
claim, err := d.Queries.ClaimLarkInboundDedup(ctx, db.ClaimLarkInboundDedupParams{
claim, err := d.Queries.ClaimLarkInboundDedup(ctx, ClaimInboundDedupParams{
InstallationID: inst.ID,
MessageID: msg.MessageID,
})
@@ -415,7 +422,7 @@ const (
// - Post-AppendUserMessage error (issue create, session reload,
// task enqueue) → chat_message already committed but the
// in-tx Mark also already committed → finalizeNone.
func (d *Dispatcher) processClaimed(ctx context.Context, msg InboundMessage, inst db.LarkInstallation, claimToken pgtype.UUID) (DispatchResult, dedupFinalize, error) {
func (d *Dispatcher) processClaimed(ctx context.Context, msg InboundMessage, inst Installation, claimToken pgtype.UUID) (DispatchResult, dedupFinalize, error) {
// 3. Group-mention filter (group chats only). We do this BEFORE
// identity check so that an unbound user's idle group chatter
// never produces an "you need to bind" reply card spam — the
@@ -424,12 +431,14 @@ func (d *Dispatcher) processClaimed(ctx context.Context, msg InboundMessage, ins
return d.drop(ctx, msg, inst.ID, DropReasonNotAddressedInGroup), finalizeMark, nil
}
// 4. Identity check. A row in lark_user_binding means the open_id
// maps to a current workspace member (the composite FK to
// member cascades the binding away on membership revocation).
binding, err := d.Queries.GetLarkUserBindingByOpenID(ctx, db.GetLarkUserBindingByOpenIDParams{
// 4. Identity check. A row in lark_user_binding maps the open_id to
// a Multica user. Its mere existence USED to prove current
// workspace membership (a composite FK to member cascaded the
// binding away on revocation); MUL-3515 §4 removed that FK, so
// step 4b re-checks membership explicitly below.
binding, err := d.Queries.GetLarkUserBindingByOpenID(ctx, GetUserBindingByOpenIDParams{
InstallationID: inst.ID,
LarkOpenID: string(msg.SenderOpenID),
ChannelUserID: string(msg.SenderOpenID),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -451,6 +460,22 @@ func (d *Dispatcher) processClaimed(ctx context.Context, msg InboundMessage, ins
return DispatchResult{}, finalizeRelease, fmt.Errorf("load user binding: %w", err)
}
// 4b. Re-check workspace membership. With the binding->member FK gone
// (MUL-3515 §4), a stale binding can outlive the user's
// membership, so the row from step 4 is necessary but no longer
// sufficient. A former member's leftover binding must not leak
// content into a chat_session (§4.3); we drop + audit it the same
// way as the group-mention filter (durable audit row →
// finalizeMark), with the non_workspace_member reason that used
// to be unreachable while the FK guaranteed it could not happen.
isMember, err := d.Queries.IsWorkspaceMember(ctx, inst.WorkspaceID, binding.MulticaUserID)
if err != nil {
return DispatchResult{}, finalizeRelease, fmt.Errorf("check workspace membership: %w", err)
}
if !isMember {
return d.drop(ctx, msg, inst.ID, DropReasonNonWorkspaceMember), finalizeMark, nil
}
// 5. Resolve the chat_session. For group chats, the session
// creator is the INSTALLER (stable workspace identity that
// won't cascade-delete when individual group members churn);
@@ -575,7 +600,7 @@ func (d *Dispatcher) processClaimed(ctx context.Context, msg InboundMessage, ins
// it inline when batching is disabled). The flush closure captures this
// message's installation + InboundMessage so the offline/archived notice,
// if any, targets the right chat; the latest message in a window wins.
func (d *Dispatcher) scheduleRun(inst db.LarkInstallation, msg InboundMessage, sessionID pgtype.UUID, initiatorUserID pgtype.UUID) {
func (d *Dispatcher) scheduleRun(inst Installation, msg InboundMessage, sessionID pgtype.UUID, initiatorUserID pgtype.UUID) {
flush := func() { d.flushChatRun(inst, msg, sessionID, initiatorUserID) }
if d.batcher == nil {
// Batching disabled (unit tests / degenerate config): trigger the
@@ -596,7 +621,7 @@ func (d *Dispatcher) scheduleRun(inst db.LarkInstallation, msg InboundMessage, s
// returned to a caller (the message is already ACKed and durable), so they
// are logged: a failed enqueue leaves the message in the session to be
// picked up by the next message's run.
func (d *Dispatcher) flushChatRun(inst db.LarkInstallation, msg InboundMessage, sessionID pgtype.UUID, initiatorUserID pgtype.UUID) {
func (d *Dispatcher) flushChatRun(inst Installation, msg InboundMessage, sessionID pgtype.UUID, initiatorUserID pgtype.UUID) {
ctx, cancel := context.WithTimeout(context.Background(), chatRunFlushTimeout)
defer cancel()
@@ -628,7 +653,7 @@ func (d *Dispatcher) flushChatRun(inst db.LarkInstallation, msg InboundMessage,
}
// emitFlushReply delivers an offline/archived notice for a flushed run.
func (d *Dispatcher) emitFlushReply(ctx context.Context, inst db.LarkInstallation, msg InboundMessage, sessionID pgtype.UUID, outcome Outcome) {
func (d *Dispatcher) emitFlushReply(ctx context.Context, inst Installation, msg InboundMessage, sessionID pgtype.UUID, outcome Outcome) {
if d.FlushReply == nil {
return
}
@@ -659,13 +684,13 @@ func keyForSession(sessionID pgtype.UUID) string {
func (d *Dispatcher) applyFinalize(ctx context.Context, installationID pgtype.UUID, messageID string, claimToken pgtype.UUID, action dedupFinalize) {
switch action {
case finalizeMark:
_, _ = d.Queries.MarkLarkInboundDedupProcessed(ctx, db.MarkLarkInboundDedupProcessedParams{
_, _ = d.Queries.MarkLarkInboundDedupProcessed(ctx, MarkInboundDedupProcessedParams{
InstallationID: installationID,
MessageID: messageID,
ClaimToken: claimToken,
})
case finalizeRelease:
_, _ = d.Queries.ReleaseLarkInboundDedup(ctx, db.ReleaseLarkInboundDedupParams{
_, _ = d.Queries.ReleaseLarkInboundDedup(ctx, ReleaseInboundDedupParams{
InstallationID: installationID,
MessageID: messageID,
ClaimToken: claimToken,
@@ -694,7 +719,7 @@ func (d *Dispatcher) drop(ctx context.Context, msg InboundMessage, instID pgtype
func (d *Dispatcher) createIssueFromCommand(
ctx context.Context,
inst db.LarkInstallation,
inst Installation,
creatorUserID pgtype.UUID,
sessionID pgtype.UUID,
cmd IssueCommand,
@@ -37,27 +37,34 @@ type fakeDedupRow struct {
// processed=false. The default empty map means "first delivery for
// every message_id".
type fakeQueries struct {
installationByApp db.LarkInstallation
installationByApp Installation
installationErr error
userBinding db.LarkUserBinding
userBinding UserBinding
userBindingErr error
// userBindingByOpenID, when set, overrides userBinding per Lark open ID so a
// test can simulate distinct senders in one chat (MUL-2645 latest-sender-wins).
userBindingByOpenID map[string]db.LarkUserBinding
userBindingByOpenID map[string]UserBinding
chatSession db.ChatSession
chatSessionErr error
workspace db.Workspace
workspaceErr error
dedup map[string]*fakeDedupRow
dedupClaimErr error
dedupReclaim bool // when true, in-flight rows are re-claimable (simulates staleness)
nextTokenByte byte // monotonically incremented; ensures each minted token is distinct
calledUserBinding int
calledChatSession int
calledInstallation int
calledClaim int
calledMark int
calledRelease int
// notWorkspaceMember inverts the default so the zero value means
// "is a member" — existing tests that resolve a binding keep
// flowing through the identity step without setting it. Set it true
// to simulate a former member whose binding outlived membership.
notWorkspaceMember bool
membershipErr error
calledMembership int
dedup map[string]*fakeDedupRow
dedupClaimErr error
dedupReclaim bool // when true, in-flight rows are re-claimable (simulates staleness)
nextTokenByte byte // monotonically incremented; ensures each minted token is distinct
calledUserBinding int
calledChatSession int
calledInstallation int
calledClaim int
calledMark int
calledRelease int
}
// mintToken produces a deterministic, distinct token per call so
@@ -68,15 +75,15 @@ func (f *fakeQueries) mintToken() pgtype.UUID {
return validUUID(0xA0 + f.nextTokenByte)
}
func (f *fakeQueries) GetLarkInstallationByAppID(ctx context.Context, appID string) (db.LarkInstallation, error) {
func (f *fakeQueries) GetLarkInstallationByAppID(ctx context.Context, appID string) (Installation, error) {
f.calledInstallation++
return f.installationByApp, f.installationErr
}
func (f *fakeQueries) GetLarkUserBindingByOpenID(ctx context.Context, arg db.GetLarkUserBindingByOpenIDParams) (db.LarkUserBinding, error) {
func (f *fakeQueries) GetLarkUserBindingByOpenID(ctx context.Context, arg GetUserBindingByOpenIDParams) (UserBinding, error) {
f.calledUserBinding++
if f.userBindingByOpenID != nil {
if b, ok := f.userBindingByOpenID[arg.LarkOpenID]; ok {
if b, ok := f.userBindingByOpenID[arg.ChannelUserID]; ok {
return b, nil
}
}
@@ -97,10 +104,10 @@ func (f *fakeQueries) GetChatSession(ctx context.Context, id pgtype.UUID) (db.Ch
// returns the row.
// - Row present otherwise → ON CONFLICT WHERE filter excludes the
// UPDATE → RETURNING returns 0 rows → pgx.ErrNoRows.
func (f *fakeQueries) ClaimLarkInboundDedup(ctx context.Context, arg db.ClaimLarkInboundDedupParams) (db.LarkInboundMessageDedup, error) {
func (f *fakeQueries) ClaimLarkInboundDedup(ctx context.Context, arg ClaimInboundDedupParams) (InboundMessageDedup, error) {
f.calledClaim++
if f.dedupClaimErr != nil {
return db.LarkInboundMessageDedup{}, f.dedupClaimErr
return InboundMessageDedup{}, f.dedupClaimErr
}
if f.dedup == nil {
f.dedup = map[string]*fakeDedupRow{}
@@ -110,7 +117,7 @@ func (f *fakeQueries) ClaimLarkInboundDedup(ctx context.Context, arg db.ClaimLar
if !exists {
token := f.mintToken()
f.dedup[key] = &fakeDedupRow{token: token, rotations: 1}
return db.LarkInboundMessageDedup{
return InboundMessageDedup{
InstallationID: arg.InstallationID,
MessageID: arg.MessageID,
ClaimToken: token,
@@ -124,13 +131,13 @@ func (f *fakeQueries) ClaimLarkInboundDedup(ctx context.Context, arg db.ClaimLar
// tx rolls back.
row.token = f.mintToken()
row.rotations++
return db.LarkInboundMessageDedup{
return InboundMessageDedup{
InstallationID: arg.InstallationID,
MessageID: arg.MessageID,
ClaimToken: row.token,
}, nil
}
return db.LarkInboundMessageDedup{}, pgx.ErrNoRows
return InboundMessageDedup{}, pgx.ErrNoRows
}
// dedupKey mirrors the production (installation_id, message_id) composite
@@ -152,7 +159,7 @@ func dedupKey(installationID pgtype.UUID, messageID string) string {
// while it is still in-flight (processed_at IS NULL). Mismatched token
// or already-terminal row returns 0 rows affected (and nil error) —
// the dispatcher relies on this for the in-tx ErrClaimLost path.
func (f *fakeQueries) MarkLarkInboundDedupProcessed(ctx context.Context, arg db.MarkLarkInboundDedupProcessedParams) (int64, error) {
func (f *fakeQueries) MarkLarkInboundDedupProcessed(ctx context.Context, arg MarkInboundDedupProcessedParams) (int64, error) {
f.calledMark++
if f.dedup == nil {
return 0, nil
@@ -178,7 +185,15 @@ func (f *fakeQueries) GetWorkspace(ctx context.Context, id pgtype.UUID) (db.Work
return f.workspace, f.workspaceErr
}
func (f *fakeQueries) ReleaseLarkInboundDedup(ctx context.Context, arg db.ReleaseLarkInboundDedupParams) (int64, error) {
func (f *fakeQueries) IsWorkspaceMember(ctx context.Context, workspaceID, userID pgtype.UUID) (bool, error) {
f.calledMembership++
if f.membershipErr != nil {
return false, f.membershipErr
}
return !f.notWorkspaceMember, nil
}
func (f *fakeQueries) ReleaseLarkInboundDedup(ctx context.Context, arg ReleaseInboundDedupParams) (int64, error) {
f.calledRelease++
if f.dedup == nil {
return 0, nil
@@ -246,7 +261,7 @@ func (f *fakeChat) AppendUserMessage(ctx context.Context, p AppendUserMessagePar
// supplies a claim token, Mark in-tx; zero rows ↔ stale-reclaim
// rotated the token under our feet, surface ErrClaimLost.
if f.queries != nil && p.ClaimToken.Valid && p.LarkMessageID != "" {
rows, err := f.queries.MarkLarkInboundDedupProcessed(ctx, db.MarkLarkInboundDedupProcessedParams{
rows, err := f.queries.MarkLarkInboundDedupProcessed(ctx, MarkInboundDedupProcessedParams{
InstallationID: p.InstallationID,
MessageID: p.LarkMessageID,
ClaimToken: p.ClaimToken,
@@ -308,8 +323,8 @@ func validUUID(b byte) pgtype.UUID {
return u
}
func activeInstallation() db.LarkInstallation {
return db.LarkInstallation{
func activeInstallation() Installation {
return Installation{
ID: validUUID(0x11),
WorkspaceID: validUUID(0x22),
AgentID: validUUID(0x33),
@@ -326,13 +341,13 @@ func seedDedupKey(messageID string) string {
return dedupKey(validUUID(0x11), messageID)
}
func boundUser() db.LarkUserBinding {
return db.LarkUserBinding{
func boundUser() UserBinding {
return UserBinding{
ID: validUUID(0x44),
WorkspaceID: validUUID(0x22),
MulticaUserID: validUUID(0x55),
InstallationID: validUUID(0x11),
LarkOpenID: "ou_user_a",
ChannelUserID: "ou_user_a",
}
}
@@ -423,6 +438,72 @@ func TestDispatcher_UnboundUserAsksForBinding(t *testing.T) {
}
}
// A binding row that outlived its user's workspace membership (possible
// now that MUL-3515 §4 removed the member FK that used to cascade it
// away) must be dropped at the explicit re-check, never reaching
// chat_session — the §4.3 safety property.
func TestDispatcher_FormerMemberWithStaleBindingDropped(t *testing.T) {
queries := &fakeQueries{
installationByApp: activeInstallation(),
userBinding: boundUser(), // binding row still present...
notWorkspaceMember: true, // ...but the user is no longer a member
}
audit := &fakeAudit{}
chat := &fakeChat{}
d := &Dispatcher{Queries: queries, Audit: audit, Chat: chat}
res, err := d.Handle(context.Background(), InboundMessage{
AppID: "ok",
ChatType: ChatTypeP2P,
SenderOpenID: "ou_user_a",
MessageID: "msg-stale",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.Outcome != OutcomeDropped {
t.Fatalf("expected OutcomeDropped, got %q", res.Outcome)
}
if res.DropReason != DropReasonNonWorkspaceMember {
t.Fatalf("expected non_workspace_member drop reason, got %q", res.DropReason)
}
if queries.calledMembership != 1 {
t.Fatalf("expected exactly one membership check, got %d", queries.calledMembership)
}
if len(audit.drops) != 1 || audit.drops[0].Reason != DropReasonNonWorkspaceMember {
t.Fatalf("expected one non_workspace_member audit row, got %+v", audit.drops)
}
if chat.calledEnsure != 0 {
t.Fatalf("a non-member must not reach chat_session; got %d ensure calls", chat.calledEnsure)
}
}
// An infra error from the membership re-check is surfaced (not silently
// treated as "not a member"), so the dispatcher releases the dedup claim
// and the WS adapter can retry instead of permanently dropping the event.
func TestDispatcher_MembershipCheckErrorSurfaces(t *testing.T) {
queries := &fakeQueries{
installationByApp: activeInstallation(),
userBinding: boundUser(),
membershipErr: errors.New("member lookup failed"),
}
chat := &fakeChat{}
d := &Dispatcher{Queries: queries, Audit: &fakeAudit{}, Chat: chat}
_, err := d.Handle(context.Background(), InboundMessage{
AppID: "ok",
ChatType: ChatTypeP2P,
SenderOpenID: "ou_user_a",
MessageID: "msg-err",
})
if err == nil {
t.Fatal("expected the membership-check error to surface")
}
if chat.calledEnsure != 0 {
t.Fatalf("must not reach chat_session on a membership-check error; got %d", chat.calledEnsure)
}
}
func TestDispatcher_PlainMessageEnqueuesTask(t *testing.T) {
sessionID := validUUID(0x66)
queries := &fakeQueries{
@@ -907,7 +988,7 @@ type captureReply struct {
results []DispatchResult
}
func (c *captureReply) reply(_ context.Context, _ db.LarkInstallation, _ InboundMessage, res DispatchResult) {
func (c *captureReply) reply(_ context.Context, _ Installation, _ InboundMessage, res DispatchResult) {
c.count++
c.results = append(c.results, res)
}
@@ -1114,7 +1195,7 @@ func TestDispatcher_LatestSenderWinsAsInitiator(t *testing.T) {
queries := &fakeQueries{
installationByApp: activeInstallation(),
chatSession: db.ChatSession{ID: sessionID, AgentID: validUUID(0x33)},
userBindingByOpenID: map[string]db.LarkUserBinding{"ou_alice": alice, "ou_bob": bob},
userBindingByOpenID: map[string]UserBinding{"ou_alice": alice, "ou_bob": bob},
}
chat := &fakeChat{ensureID: sessionID, appendResult: AppendResult{}}
enq := &fakeEnqueuer{task: db.AgentTaskQueue{ID: validUUID(0x77)}}
@@ -1540,7 +1621,7 @@ func TestDispatcher_StaleReclaimRaceDoesNotDoubleWrite(t *testing.T) {
// Make the existing in-flight row reclaimable, then have
// worker B re-Claim. This rotates claim_token under A's feet.
queries.dedupReclaim = true
if _, err := queries.ClaimLarkInboundDedup(context.Background(), db.ClaimLarkInboundDedupParams{
if _, err := queries.ClaimLarkInboundDedup(context.Background(), ClaimInboundDedupParams{
InstallationID: p.InstallationID,
MessageID: p.LarkMessageID,
}); err != nil {
+13 -14
View File
@@ -15,16 +15,15 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// HubQueries is the narrow subset of *db.Queries the Hub needs for
// installation enumeration and lease management. *db.Queries satisfies
// it directly; tests substitute a fake.
type HubQueries interface {
ListActiveLarkInstallations(ctx context.Context) ([]db.LarkInstallation, error)
AcquireLarkWSLease(ctx context.Context, arg db.AcquireLarkWSLeaseParams) (db.LarkInstallation, error)
ReleaseLarkWSLease(ctx context.Context, arg db.ReleaseLarkWSLeaseParams) error
ListActiveLarkInstallations(ctx context.Context) ([]Installation, error)
AcquireLarkWSLease(ctx context.Context, arg AcquireWSLeaseParams) (Installation, error)
ReleaseLarkWSLease(ctx context.Context, arg ReleaseWSLeaseParams) error
}
// EventEmitter is the per-message callback the Hub hands to an
@@ -78,14 +77,14 @@ type EventEmitter func(ctx context.Context, msg InboundMessage) (DispatchResult,
// The connector MUST NOT bypass the Dispatcher by writing to the DB
// directly; emit is the only ingress path.
type EventConnector interface {
Run(ctx context.Context, inst db.LarkInstallation, emit EventEmitter) error
Run(ctx context.Context, inst Installation, emit EventEmitter) error
}
// ConnectorFactory builds an EventConnector for a specific installation
// row. The factory exists so the Hub doesn't need to know about Lark
// SDK construction (auth, app credentials decryption) — call sites
// inject a factory configured with their APIClient + secretbox box.
type ConnectorFactory func(inst db.LarkInstallation) (EventConnector, error)
type ConnectorFactory func(inst Installation) (EventConnector, error)
// HubConfig tunes the Hub's lifecycle loops. All fields have sensible
// production defaults via withDefaults; tests typically set Now and
@@ -431,7 +430,7 @@ func (h *Hub) sweep(ctx context.Context) {
// cancel — the deleted entry's goroutine releases its lease as part of
// teardown, and the new supervisor's acquireLease takes over on its
// own clock.
func (h *Hub) maybeRestartOnRotation(id string, row db.LarkInstallation) {
func (h *Hub) maybeRestartOnRotation(id string, row Installation) {
want := installationFingerprint(row)
h.mu.Lock()
entry, ok := h.supervisors[id]
@@ -448,7 +447,7 @@ func (h *Hub) maybeRestartOnRotation(id string, row db.LarkInstallation) {
h.mu.Unlock()
}
func (h *Hub) startSupervisor(parent context.Context, inst db.LarkInstallation) {
func (h *Hub) startSupervisor(parent context.Context, inst Installation) {
id := uuidString(inst.ID)
h.mu.Lock()
if h.stopped {
@@ -499,7 +498,7 @@ func leaseToken(nodeID string, gen uint64) string {
// different bot identity), and a SHA-256 of the encrypted app_secret
// blob so a re-encrypt at rest also triggers a restart. The plaintext
// secret is never extracted; the encrypted ciphertext is fine to hash.
func installationFingerprint(inst db.LarkInstallation) string {
func installationFingerprint(inst Installation) string {
sum := sha256.Sum256(inst.AppSecretEncrypted)
// region is part of the fingerprint: if a re-install corrects the
// cloud (e.g. a row mis-detected as feishu is re-scanned as lark),
@@ -512,7 +511,7 @@ func installationFingerprint(inst db.LarkInstallation) string {
// acquire lease → spin up connector → renew lease while connector is
// running → on connector exit, back off → repeat. Returns (and the
// goroutine ends) when ctx is cancelled.
func (h *Hub) supervise(ctx context.Context, inst db.LarkInstallation, id string, gen uint64) {
func (h *Hub) supervise(ctx context.Context, inst Installation, id string, gen uint64) {
defer h.wg.Done()
defer func() {
// Only clear the supervisors map entry if it still belongs to
@@ -642,7 +641,7 @@ func (h *Hub) supervise(ctx context.Context, inst db.LarkInstallation, id string
// lease row.
func (h *Hub) acquireLease(ctx context.Context, instID pgtype.UUID, token string) (bool, error) {
expires := h.cfg.Now().Add(h.cfg.LeaseTTL)
_, err := h.queries.AcquireLarkWSLease(ctx, db.AcquireLarkWSLeaseParams{
_, err := h.queries.AcquireLarkWSLease(ctx, AcquireWSLeaseParams{
ID: instID,
NewToken: pgtype.Text{String: token, Valid: true},
NewExpiresAt: pgtype.Timestamptz{Time: expires, Valid: true},
@@ -727,7 +726,7 @@ func (h *Hub) renewLeaseUntil(ctx context.Context, cancelRun context.CancelFunc,
func (h *Hub) releaseLease(instID pgtype.UUID, token string) {
ctx, cancel := context.WithTimeout(context.Background(), h.cfg.LeaseReleaseTimeout)
defer cancel()
if err := h.queries.ReleaseLarkWSLease(ctx, db.ReleaseLarkWSLeaseParams{
if err := h.queries.ReleaseLarkWSLease(ctx, ReleaseWSLeaseParams{
ID: instID,
CurrentToken: pgtype.Text{String: token, Valid: true},
}); err != nil {
@@ -761,7 +760,7 @@ func (h *Hub) releaseLease(instID pgtype.UUID, token string) {
// reply is detached. Each reply runs in its own goroutine under a
// fresh context bounded by ReplyTimeout (strictly under 3s). Hub.Wait
// joins on replyWg so shutdown still drains in-flight replies.
func (h *Hub) handleEvent(ctx context.Context, inst db.LarkInstallation, log *slog.Logger, msg InboundMessage) (DispatchResult, error) {
func (h *Hub) handleEvent(ctx context.Context, inst Installation, log *slog.Logger, msg InboundMessage) (DispatchResult, error) {
if h.dispatcher == nil {
log.Warn("lark hub: dispatcher not configured; dropping event",
"event_id", msg.EventID,
@@ -806,7 +805,7 @@ func (h *Hub) handleEvent(ctx context.Context, inst db.LarkInstallation, log *sl
// can happen mid-reply on a normal reconnect cycle. Inheriting would
// kill the outbound reply for no reason — the binding card / offline
// notice is still wanted. ReplyTimeout is the only guard we need.
func (h *Hub) scheduleReply(inst db.LarkInstallation, msg InboundMessage, res DispatchResult, log *slog.Logger) {
func (h *Hub) scheduleReply(inst Installation, msg InboundMessage, res DispatchResult, log *slog.Logger) {
r := h.replier
if r == nil {
return
+63 -64
View File
@@ -12,7 +12,6 @@ import (
"time"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// fakeHubQueries is the unit-test seam for HubQueries. The lease state
@@ -20,7 +19,7 @@ import (
// and "another replica holds the lease" scenarios across one test.
type fakeHubQueries struct {
mu sync.Mutex
installations []db.LarkInstallation
installations []Installation
listErr error
leaseOwner map[string]string // installation_id -> ws_lease_token
leaseExpiresAt map[string]time.Time // installation_id -> expiry
@@ -50,23 +49,23 @@ func newFakeHubQueries() *fakeHubQueries {
}
}
func (f *fakeHubQueries) ListActiveLarkInstallations(ctx context.Context) ([]db.LarkInstallation, error) {
func (f *fakeHubQueries) ListActiveLarkInstallations(ctx context.Context) ([]Installation, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.listErr != nil {
return nil, f.listErr
}
out := make([]db.LarkInstallation, len(f.installations))
out := make([]Installation, len(f.installations))
copy(out, f.installations)
return out, nil
}
func (f *fakeHubQueries) AcquireLarkWSLease(ctx context.Context, arg db.AcquireLarkWSLeaseParams) (db.LarkInstallation, error) {
func (f *fakeHubQueries) AcquireLarkWSLease(ctx context.Context, arg AcquireWSLeaseParams) (Installation, error) {
atomic.AddInt32(&f.acquireCount, 1)
f.mu.Lock()
defer f.mu.Unlock()
if f.acquireErr != nil {
return db.LarkInstallation{}, f.acquireErr
return Installation{}, f.acquireErr
}
id := uuidString(arg.ID)
owner, hasOwner := f.leaseOwner[id]
@@ -78,13 +77,13 @@ func (f *fakeHubQueries) AcquireLarkWSLease(ctx context.Context, arg db.AcquireL
f.leaseExpiresAt[id] = arg.NewExpiresAt.Time
// Return the (synthetic) row — the supervise loop only checks
// the error, not the row contents.
return db.LarkInstallation{ID: arg.ID}, nil
return Installation{ID: arg.ID}, nil
}
// Live lease held by someone else.
return db.LarkInstallation{}, errPgxNoRows
return Installation{}, errPgxNoRows
}
func (f *fakeHubQueries) ReleaseLarkWSLease(ctx context.Context, arg db.ReleaseLarkWSLeaseParams) error {
func (f *fakeHubQueries) ReleaseLarkWSLease(ctx context.Context, arg ReleaseWSLeaseParams) error {
f.mu.Lock()
block := f.releaseBlock
f.mu.Unlock()
@@ -132,7 +131,7 @@ type fakeConnector struct {
emit EventEmitter
}
func (f *fakeConnector) Run(ctx context.Context, _ db.LarkInstallation, emit EventEmitter) error {
func (f *fakeConnector) Run(ctx context.Context, _ Installation, emit EventEmitter) error {
f.mu.Lock()
idx := f.runs
f.runs++
@@ -170,10 +169,10 @@ func newDiscardLogger() *slog.Logger {
func TestHubAcquiresLeaseAndStartsSupervisor(t *testing.T) {
q := newFakeHubQueries()
instID := uuidFromString(t, "11111111-1111-1111-1111-111111111111")
q.installations = []db.LarkInstallation{{ID: instID, Status: "active"}}
q.installations = []Installation{{ID: instID, Status: "active"}}
conn := &fakeConnector{}
factory := func(_ db.LarkInstallation) (EventConnector, error) { return conn, nil }
factory := func(_ Installation) (EventConnector, error) { return conn, nil }
hub := NewHub(q, factory, nil, HubConfig{
LeaseTTL: 500 * time.Millisecond,
@@ -208,12 +207,12 @@ func TestHubAcquiresLeaseAndStartsSupervisor(t *testing.T) {
func TestHubSkipsInstallationWhenAnotherReplicaHoldsLease(t *testing.T) {
q := newFakeHubQueries()
instID := uuidFromString(t, "22222222-2222-2222-2222-222222222222")
q.installations = []db.LarkInstallation{{ID: instID, Status: "active"}}
q.installations = []Installation{{ID: instID, Status: "active"}}
// Another replica already owns the lease for the next 10 seconds.
q.presetLease(instID, "other-replica", time.Now().Add(10*time.Second))
conn := &fakeConnector{}
factory := func(_ db.LarkInstallation) (EventConnector, error) { return conn, nil }
factory := func(_ Installation) (EventConnector, error) { return conn, nil }
hub := NewHub(q, factory, nil, HubConfig{
LeaseTTL: 500 * time.Millisecond,
@@ -242,13 +241,13 @@ func TestHubSkipsInstallationWhenAnotherReplicaHoldsLease(t *testing.T) {
func TestHubReclaimsLeaseAfterAnotherReplicaExpires(t *testing.T) {
q := newFakeHubQueries()
instID := uuidFromString(t, "33333333-3333-3333-3333-333333333333")
q.installations = []db.LarkInstallation{{ID: instID, Status: "active"}}
q.installations = []Installation{{ID: instID, Status: "active"}}
// Set the other replica's lease to expire in 80ms so the hub
// (which polls/renews on 20ms intervals) will pick it up.
q.presetLease(instID, "other-replica", time.Now().Add(80*time.Millisecond))
conn := &fakeConnector{}
factory := func(_ db.LarkInstallation) (EventConnector, error) { return conn, nil }
factory := func(_ Installation) (EventConnector, error) { return conn, nil }
hub := NewHub(q, factory, nil, HubConfig{
LeaseTTL: 500 * time.Millisecond,
@@ -273,10 +272,10 @@ func TestHubReclaimsLeaseAfterAnotherReplicaExpires(t *testing.T) {
func TestHubReapsSupervisorWhenInstallationRevoked(t *testing.T) {
q := newFakeHubQueries()
instID := uuidFromString(t, "44444444-4444-4444-4444-444444444444")
q.installations = []db.LarkInstallation{{ID: instID, Status: "active"}}
q.installations = []Installation{{ID: instID, Status: "active"}}
conn := &fakeConnector{}
factory := func(_ db.LarkInstallation) (EventConnector, error) { return conn, nil }
factory := func(_ Installation) (EventConnector, error) { return conn, nil }
hub := NewHub(q, factory, nil, HubConfig{
LeaseTTL: 500 * time.Millisecond,
@@ -330,7 +329,7 @@ func TestHubReapsSupervisorWhenInstallationRevoked(t *testing.T) {
func TestHubRestartsSupervisorOnCredentialsRotation(t *testing.T) {
q := newFakeHubQueries()
instID := uuidFromString(t, "abcdabcd-abcd-abcd-abcd-abcdabcdabcd")
q.installations = []db.LarkInstallation{{
q.installations = []Installation{{
ID: instID,
Status: "active",
AppID: "app_one",
@@ -341,7 +340,7 @@ func TestHubRestartsSupervisorOnCredentialsRotation(t *testing.T) {
type seenInst struct{ AppID string }
var mu sync.Mutex
var seen []seenInst
factory := func(inst db.LarkInstallation) (EventConnector, error) {
factory := func(inst Installation) (EventConnector, error) {
mu.Lock()
seen = append(seen, seenInst{AppID: inst.AppID})
mu.Unlock()
@@ -404,7 +403,7 @@ func TestHubRestartsSupervisorOnCredentialsRotation(t *testing.T) {
func TestHubDoesNotRestartSupervisorOnUnchangedRow(t *testing.T) {
q := newFakeHubQueries()
instID := uuidFromString(t, "bcdebcde-bcde-bcde-bcde-bcdebcdebcde")
q.installations = []db.LarkInstallation{{
q.installations = []Installation{{
ID: instID,
Status: "active",
AppID: "app_stable",
@@ -413,7 +412,7 @@ func TestHubDoesNotRestartSupervisorOnUnchangedRow(t *testing.T) {
}}
starts := int32(0)
factory := func(_ db.LarkInstallation) (EventConnector, error) {
factory := func(_ Installation) (EventConnector, error) {
atomic.AddInt32(&starts, 1)
return &fakeConnector{}, nil
}
@@ -452,13 +451,13 @@ func TestHubDoesNotRestartSupervisorOnUnchangedRow(t *testing.T) {
// tokens, both old and new supervisors of the same Hub used the same
// hub-wide nodeID as their lease token. The rotation race went:
//
// 1. Old supervisor cancelled by maybeRestartOnRotation.
// 2. New supervisor started; acquireLease takes the lease.
// 3. Old supervisor finishes post-cancel unwind and calls
// releaseLease(nodeID).
// 4. DB row's CurrentToken still equals nodeID (because new
// supervisor wrote the SAME token). DELETE matches → DB row
// cleared → new supervisor's lease silently lost.
// 1. Old supervisor cancelled by maybeRestartOnRotation.
// 2. New supervisor started; acquireLease takes the lease.
// 3. Old supervisor finishes post-cancel unwind and calls
// releaseLease(nodeID).
// 4. DB row's CurrentToken still equals nodeID (because new
// supervisor wrote the SAME token). DELETE matches → DB row
// cleared → new supervisor's lease silently lost.
//
// Per-supervisor tokens (nodeID + "-g" + gen) make step 3's
// CurrentToken belong to the OLD supervisor, and the DB row's actual
@@ -481,7 +480,7 @@ func TestHubRotationStaleReleaseDoesNotClearSuccessorLease(t *testing.T) {
}
// Old supervisor acquires.
if _, err := q.AcquireLarkWSLease(context.Background(), db.AcquireLarkWSLeaseParams{
if _, err := q.AcquireLarkWSLease(context.Background(), AcquireWSLeaseParams{
ID: instID,
NewToken: pgtype.Text{String: tokenA, Valid: true},
NewExpiresAt: pgtype.Timestamptz{Time: expires, Valid: true},
@@ -507,7 +506,7 @@ func TestHubRotationStaleReleaseDoesNotClearSuccessorLease(t *testing.T) {
delete(q.leaseOwner, uuidString(instID))
delete(q.leaseExpiresAt, uuidString(instID))
q.mu.Unlock()
if _, err := q.AcquireLarkWSLease(context.Background(), db.AcquireLarkWSLeaseParams{
if _, err := q.AcquireLarkWSLease(context.Background(), AcquireWSLeaseParams{
ID: instID,
NewToken: pgtype.Text{String: tokenB, Valid: true},
NewExpiresAt: pgtype.Timestamptz{Time: expires, Valid: true},
@@ -522,7 +521,7 @@ func TestHubRotationStaleReleaseDoesNotClearSuccessorLease(t *testing.T) {
// Old supervisor's defer / cleanup fires AFTER new acquired.
// Without per-supervisor tokens this would clobber tokenB's lease.
if err := q.ReleaseLarkWSLease(context.Background(), db.ReleaseLarkWSLeaseParams{
if err := q.ReleaseLarkWSLease(context.Background(), ReleaseWSLeaseParams{
ID: instID,
CurrentToken: pgtype.Text{String: tokenA, Valid: true},
}); err != nil {
@@ -545,10 +544,10 @@ func TestHubRotationStaleReleaseDoesNotClearSuccessorLease(t *testing.T) {
// rotation race through the live supervise loop — not just the lease
// state machine in isolation. Drives a hub through:
//
// 1. install row with credentials A → supervisor1 acquires lease(A)
// 2. credentials rotate to B → maybeRestartOnRotation cancels sup1
// 3. supervisor2 starts, acquires lease(B)
// 4. sup1's post-cancel releaseLease(A) runs; must NOT clear lease(B)
// 1. install row with credentials A → supervisor1 acquires lease(A)
// 2. credentials rotate to B → maybeRestartOnRotation cancels sup1
// 3. supervisor2 starts, acquires lease(B)
// 4. sup1's post-cancel releaseLease(A) runs; must NOT clear lease(B)
//
// Even with the timing being non-deterministic (real goroutines), the
// fake's lease map either ends up with sup2's token or empty — empty
@@ -558,7 +557,7 @@ func TestHubRotationStaleReleaseDoesNotClearSuccessorLease(t *testing.T) {
func TestHubRotationEndToEndKeepsSuccessorLeased(t *testing.T) {
q := newFakeHubQueries()
instID := uuidFromString(t, "feedfeed-feed-feed-feed-feedfeedfeed")
q.installations = []db.LarkInstallation{{
q.installations = []Installation{{
ID: instID,
Status: "active",
AppID: "app_one",
@@ -570,7 +569,7 @@ func TestHubRotationEndToEndKeepsSuccessorLeased(t *testing.T) {
// running and refreshes the lease at each tick, mirroring
// production timing for the rotation handoff.
factoryCalls := int32(0)
factory := func(_ db.LarkInstallation) (EventConnector, error) {
factory := func(_ Installation) (EventConnector, error) {
atomic.AddInt32(&factoryCalls, 1)
return &fakeConnector{}, nil
}
@@ -641,10 +640,10 @@ func TestHubRotationEndToEndKeepsSuccessorLeased(t *testing.T) {
func TestHubBacksOffOnFactoryError(t *testing.T) {
q := newFakeHubQueries()
instID := uuidFromString(t, "55555555-5555-5555-5555-555555555555")
q.installations = []db.LarkInstallation{{ID: instID, Status: "active"}}
q.installations = []Installation{{ID: instID, Status: "active"}}
factoryCalls := int32(0)
factory := func(_ db.LarkInstallation) (EventConnector, error) {
factory := func(_ Installation) (EventConnector, error) {
atomic.AddInt32(&factoryCalls, 1)
return nil, errors.New("boom")
}
@@ -686,7 +685,7 @@ func TestHubBacksOffOnFactoryError(t *testing.T) {
func TestHubLeaseLossCancelsConnector(t *testing.T) {
q := newFakeHubQueries()
instID := uuidFromString(t, "66666666-6666-6666-6666-666666666666")
q.installations = []db.LarkInstallation{{ID: instID, Status: "active"}}
q.installations = []Installation{{ID: instID, Status: "active"}}
// fakeConnector default behavior blocks on ctx.Done — perfect for
// "simulate a socket that never returns until we explicitly cancel
@@ -702,7 +701,7 @@ func TestHubLeaseLossCancelsConnector(t *testing.T) {
},
},
}
factory := func(_ db.LarkInstallation) (EventConnector, error) { return conn, nil }
factory := func(_ Installation) (EventConnector, error) { return conn, nil }
hub := NewHub(q, factory, nil, HubConfig{
LeaseTTL: 500 * time.Millisecond,
@@ -748,7 +747,7 @@ func TestHubLeaseLossCancelsConnector(t *testing.T) {
func TestHubEmitReturnsDispatchResultAndError(t *testing.T) {
q := newFakeHubQueries()
instID := uuidFromString(t, "77777777-7777-7777-7777-777777777777")
q.installations = []db.LarkInstallation{{ID: instID, Status: "active"}}
q.installations = []Installation{{ID: instID, Status: "active"}}
// Capture what emit returned on the first invocation so the
// connector goroutine can stash it for the test.
@@ -776,7 +775,7 @@ func TestHubEmitReturnsDispatchResultAndError(t *testing.T) {
},
},
}
factory := func(_ db.LarkInstallation) (EventConnector, error) { return conn, nil }
factory := func(_ Installation) (EventConnector, error) { return conn, nil }
// No dispatcher wired -> emit must return ErrDispatcherNotConfigured.
// The point is the error surfaces back to the connector instead of
@@ -821,10 +820,10 @@ func TestHubReleaseLeaseBoundedByTimeout(t *testing.T) {
q := newFakeHubQueries()
q.releaseBlock = make(chan struct{}) // never closed; release always sees ctx.Done
instID := uuidFromString(t, "88888888-8888-8888-8888-888888888888")
q.installations = []db.LarkInstallation{{ID: instID, Status: "active"}}
q.installations = []Installation{{ID: instID, Status: "active"}}
conn := &fakeConnector{}
factory := func(_ db.LarkInstallation) (EventConnector, error) { return conn, nil }
factory := func(_ Installation) (EventConnector, error) { return conn, nil }
releaseTimeout := 50 * time.Millisecond
hub := NewHub(q, factory, nil, HubConfig{
@@ -880,10 +879,10 @@ func TestHubReleaseLeaseBoundedByTimeout(t *testing.T) {
func TestHubWaitWithTimeoutReturnsTrueWhenSupervisorsExit(t *testing.T) {
q := newFakeHubQueries()
instID := uuidFromString(t, "99999999-9999-9999-9999-999999999999")
q.installations = []db.LarkInstallation{{ID: instID, Status: "active"}}
q.installations = []Installation{{ID: instID, Status: "active"}}
conn := &fakeConnector{}
factory := func(_ db.LarkInstallation) (EventConnector, error) { return conn, nil }
factory := func(_ Installation) (EventConnector, error) { return conn, nil }
hub := NewHub(q, factory, nil, HubConfig{
LeaseTTL: 500 * time.Millisecond,
@@ -919,10 +918,10 @@ func TestHubWaitWithTimeoutReturnsFalseWhenSupervisorStuck(t *testing.T) {
q := newFakeHubQueries()
q.releaseBlock = make(chan struct{}) // never closed
instID := uuidFromString(t, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
q.installations = []db.LarkInstallation{{ID: instID, Status: "active"}}
q.installations = []Installation{{ID: instID, Status: "active"}}
conn := &fakeConnector{}
factory := func(_ db.LarkInstallation) (EventConnector, error) { return conn, nil }
factory := func(_ Installation) (EventConnector, error) { return conn, nil }
// LeaseReleaseTimeout > ShutdownTimeout so the release is still
// blocked when the join deadline expires. This pins the "join
@@ -994,12 +993,12 @@ func waitFor(timeout time.Duration, cond func() bool) bool {
// invariant: a Reply that exceeds ReplyTimeout MUST get its ctx
// cancelled instead of running unbounded.
type slowReplier struct {
delay time.Duration
startCh chan struct{}
finishCh chan struct{}
mu sync.Mutex
callCount int
lastCtxErr error // ctx.Err() observed when Reply returned
delay time.Duration
startCh chan struct{}
finishCh chan struct{}
mu sync.Mutex
callCount int
lastCtxErr error // ctx.Err() observed when Reply returned
}
func newSlowReplier(delay time.Duration) *slowReplier {
@@ -1010,7 +1009,7 @@ func newSlowReplier(delay time.Duration) *slowReplier {
}
}
func (s *slowReplier) Reply(ctx context.Context, _ db.LarkInstallation, _ InboundMessage, _ DispatchResult) {
func (s *slowReplier) Reply(ctx context.Context, _ Installation, _ InboundMessage, _ DispatchResult) {
s.mu.Lock()
s.callCount++
s.mu.Unlock()
@@ -1060,7 +1059,7 @@ func TestHubScheduleReplyReturnsImmediately(t *testing.T) {
hub.SetOutcomeReplier(rep)
start := time.Now()
hub.scheduleReply(db.LarkInstallation{}, InboundMessage{EventID: "e1"},
hub.scheduleReply(Installation{}, InboundMessage{EventID: "e1"},
DispatchResult{Outcome: OutcomeNeedsBinding}, newDiscardLogger())
elapsed := time.Since(start)
@@ -1103,7 +1102,7 @@ func TestHubReplyTimeoutCancelsHungReplier(t *testing.T) {
hub.SetOutcomeReplier(rep)
start := time.Now()
hub.scheduleReply(db.LarkInstallation{}, InboundMessage{EventID: "e2"},
hub.scheduleReply(Installation{}, InboundMessage{EventID: "e2"},
DispatchResult{Outcome: OutcomeAgentOffline}, newDiscardLogger())
select {
@@ -1138,7 +1137,7 @@ func TestHubWaitDrainsInFlightReplies(t *testing.T) {
})
hub.SetOutcomeReplier(rep)
hub.scheduleReply(db.LarkInstallation{}, InboundMessage{EventID: "e3"},
hub.scheduleReply(Installation{}, InboundMessage{EventID: "e3"},
DispatchResult{Outcome: OutcomeNeedsBinding}, newDiscardLogger())
// Wait should block until the reply finishes its 30ms work.
@@ -1174,7 +1173,7 @@ func TestHubNoopReplierInlineNoGoroutine(t *testing.T) {
// goroutines schedule, but with the fast-path it must return
// instantly.
for i := 0; i < 1000; i++ {
hub.scheduleReply(db.LarkInstallation{}, InboundMessage{EventID: "e"},
hub.scheduleReply(Installation{}, InboundMessage{EventID: "e"},
DispatchResult{Outcome: OutcomeNeedsBinding}, newDiscardLogger())
}
done := make(chan struct{})
@@ -1220,7 +1219,7 @@ func TestHubACKNotBlockedByOutboundReply(t *testing.T) {
t.Parallel()
conn := newFakeWSConn()
decoder := FrameDecoderFunc(func(payload []byte, _ db.LarkInstallation) (InboundMessage, bool, error) {
decoder := FrameDecoderFunc(func(payload []byte, _ Installation) (InboundMessage, bool, error) {
return InboundMessage{EventID: string(payload)}, true, nil
})
c := quietConnector(t, conn, decoder, time.Hour) // disable ping
@@ -1245,7 +1244,7 @@ func TestHubACKNotBlockedByOutboundReply(t *testing.T) {
Outcome: OutcomeNeedsBinding,
SenderOpenID: "ou_user_42",
}
hub.scheduleReply(db.LarkInstallation{}, msg, res, newDiscardLogger())
hub.scheduleReply(Installation{}, msg, res, newDiscardLogger())
return res, nil
}
@@ -1253,7 +1252,7 @@ func TestHubACKNotBlockedByOutboundReply(t *testing.T) {
defer cancel()
done := make(chan error, 1)
go func() {
done <- c.Run(ctx, db.LarkInstallation{AppID: "test_app"}, emit)
done <- c.Run(ctx, Installation{AppID: "test_app"}, emit)
}()
start := time.Now()
@@ -34,7 +34,7 @@ type InstallationParams struct {
// with plaintext credentials — the only path to writing
// lark_installation goes through here.
type InstallationService struct {
queries *db.Queries
queries *ChannelStore
box *secretbox.Box
}
@@ -47,7 +47,7 @@ func NewInstallationService(queries *db.Queries, box *secretbox.Box) (*Installat
if box == nil {
return nil, errors.New("lark: InstallationService requires a non-nil secretbox.Box")
}
return &InstallationService{queries: queries, box: box}, nil
return &InstallationService{queries: NewChannelStore(queries), box: box}, nil
}
// Upsert creates a new installation or refreshes an existing one in
@@ -56,15 +56,15 @@ func NewInstallationService(queries *db.Queries, box *secretbox.Box) (*Installat
// the hub's concern, not ours. The returned row is the post-write
// state; the encrypted secret column is included for completeness but
// callers SHOULD NOT log or persist it elsewhere.
func (s *InstallationService) Upsert(ctx context.Context, p InstallationParams) (db.LarkInstallation, error) {
func (s *InstallationService) Upsert(ctx context.Context, p InstallationParams) (Installation, error) {
if err := validateInstallationParams(p); err != nil {
return db.LarkInstallation{}, err
return Installation{}, err
}
sealed, err := s.box.Seal([]byte(p.AppSecret))
if err != nil {
return db.LarkInstallation{}, fmt.Errorf("encrypt app_secret: %w", err)
return Installation{}, fmt.Errorf("encrypt app_secret: %w", err)
}
return s.queries.UpsertLarkInstallation(ctx, db.UpsertLarkInstallationParams{
return s.queries.UpsertLarkInstallation(ctx, UpsertInstallationParams{
WorkspaceID: p.WorkspaceID,
AgentID: p.AgentID,
AppID: p.AppID,
@@ -82,7 +82,7 @@ func (s *InstallationService) Upsert(ctx context.Context, p InstallationParams)
// queryable; a subsequent re-install via Upsert flips status back to
// 'active' atomically.
func (s *InstallationService) Revoke(ctx context.Context, id pgtype.UUID) error {
return s.queries.SetLarkInstallationStatus(ctx, db.SetLarkInstallationStatusParams{
return s.queries.SetLarkInstallationStatus(ctx, SetInstallationStatusParams{
ID: id,
Status: string(InstallationRevoked),
})
@@ -93,7 +93,7 @@ func (s *InstallationService) Revoke(ctx context.Context, id pgtype.UUID) error
// authenticate against the Lark API on behalf of an installation; do
// NOT use this for read-only display surfaces. The plaintext value
// must never round-trip through an HTTP response.
func (s *InstallationService) DecryptAppSecret(inst db.LarkInstallation) (string, error) {
func (s *InstallationService) DecryptAppSecret(inst Installation) (string, error) {
plain, err := s.box.Open(inst.AppSecretEncrypted)
if err != nil {
return "", fmt.Errorf("decrypt app_secret: %w", err)
@@ -107,16 +107,16 @@ func (s *InstallationService) DecryptAppSecret(inst db.LarkInstallation) (string
// know the workspace and should use this so a forged installation_id
// from a different workspace returns NotFound instead of leaking
// existence.
func (s *InstallationService) GetInWorkspace(ctx context.Context, id, workspaceID pgtype.UUID) (db.LarkInstallation, error) {
row, err := s.queries.GetLarkInstallationInWorkspace(ctx, db.GetLarkInstallationInWorkspaceParams{
func (s *InstallationService) GetInWorkspace(ctx context.Context, id, workspaceID pgtype.UUID) (Installation, error) {
row, err := s.queries.GetLarkInstallationInWorkspace(ctx, GetInstallationInWorkspaceParams{
ID: id,
WorkspaceID: workspaceID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return db.LarkInstallation{}, ErrInstallationNotFound
return Installation{}, ErrInstallationNotFound
}
return db.LarkInstallation{}, err
return Installation{}, err
}
return row, nil
}
@@ -124,7 +124,7 @@ func (s *InstallationService) GetInWorkspace(ctx context.Context, id, workspaceI
// ListByWorkspace returns every installation rooted at the workspace,
// active and revoked, oldest first. The status column lets the UI
// distinguish "wired up" from "torn down but kept for audit".
func (s *InstallationService) ListByWorkspace(ctx context.Context, workspaceID pgtype.UUID) ([]db.LarkInstallation, error) {
func (s *InstallationService) ListByWorkspace(ctx context.Context, workspaceID pgtype.UUID) ([]Installation, error) {
return s.queries.ListLarkInstallationsByWorkspace(ctx, workspaceID)
}
@@ -3,8 +3,6 @@ package lark
import (
"context"
"log/slog"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// NoopConnector satisfies EventConnector by holding the run context
@@ -42,7 +40,7 @@ func NewNoopConnector(logger *slog.Logger) *NoopConnector {
// long as the lease is held, the Hub's "uptime >= ResetBackoffAfter"
// branch will reset the backoff on every supervisor cycle — which is
// the right thing for a placeholder.
func (c *NoopConnector) Run(ctx context.Context, inst db.LarkInstallation, _ EventEmitter) error {
func (c *NoopConnector) Run(ctx context.Context, inst Installation, _ EventEmitter) error {
c.logger.Info("lark noop connector: holding lease (real long-conn not yet implemented)",
"installation_id", uuidString(inst.ID),
"app_id", inst.AppID,
@@ -60,7 +58,7 @@ func (c *NoopConnector) Run(ctx context.Context, inst db.LarkInstallation, _ Eve
// connector factory once the wire-protocol implementation lands.
func NoopConnectorFactory(logger *slog.Logger) ConnectorFactory {
c := NewNoopConnector(logger)
return func(_ db.LarkInstallation) (EventConnector, error) {
return func(_ Installation) (EventConnector, error) {
return c, nil
}
}
@@ -6,8 +6,6 @@ import (
"log/slog"
"testing"
"time"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func TestNoopConnectorRunBlocksUntilContextCancel(t *testing.T) {
@@ -18,7 +16,7 @@ func TestNoopConnectorRunBlocksUntilContextCancel(t *testing.T) {
done := make(chan error, 1)
go func() {
done <- c.Run(ctx, db.LarkInstallation{}, func(context.Context, InboundMessage) (DispatchResult, error) {
done <- c.Run(ctx, Installation{}, func(context.Context, InboundMessage) (DispatchResult, error) {
t.Errorf("noop connector must not emit events")
return DispatchResult{}, nil
})
@@ -48,11 +46,11 @@ func TestNoopConnectorFactoryReturnsSameConnector(t *testing.T) {
factory := NoopConnectorFactory(slog.New(slog.NewTextHandler(io.Discard, nil)))
c1, err := factory(db.LarkInstallation{})
c1, err := factory(Installation{})
if err != nil {
t.Fatalf("factory call 1: %v", err)
}
c2, err := factory(db.LarkInstallation{})
c2, err := factory(Installation{})
if err != nil {
t.Fatalf("factory call 2: %v", err)
}
+16 -16
View File
@@ -144,18 +144,18 @@ type PatcherQueries interface {
GetAgentTask(ctx context.Context, id pgtype.UUID) (db.AgentTaskQueue, error)
GetChatSession(ctx context.Context, id pgtype.UUID) (db.ChatSession, error)
GetAgent(ctx context.Context, id pgtype.UUID) (db.Agent, error)
GetLarkInstallation(ctx context.Context, id pgtype.UUID) (db.LarkInstallation, error)
GetLarkChatSessionBindingBySession(ctx context.Context, chatSessionID pgtype.UUID) (db.LarkChatSessionBinding, error)
GetLarkOutboundCardByTask(ctx context.Context, taskID pgtype.UUID) (db.LarkOutboundCardMessage, error)
CreateLarkOutboundCardMessage(ctx context.Context, arg db.CreateLarkOutboundCardMessageParams) (db.LarkOutboundCardMessage, error)
UpdateLarkOutboundCardStatus(ctx context.Context, arg db.UpdateLarkOutboundCardStatusParams) error
GetLarkInstallation(ctx context.Context, id pgtype.UUID) (Installation, error)
GetLarkChatSessionBindingBySession(ctx context.Context, chatSessionID pgtype.UUID) (ChatSessionBinding, error)
GetLarkOutboundCardByTask(ctx context.Context, taskID pgtype.UUID) (OutboundCardMessage, error)
CreateLarkOutboundCardMessage(ctx context.Context, arg CreateOutboundCardMessageParams) (OutboundCardMessage, error)
UpdateLarkOutboundCardStatus(ctx context.Context, arg UpdateOutboundCardStatusParams) error
}
// CredentialsResolver decrypts an installation's app_secret for the
// transport layer. *InstallationService satisfies it directly; tests
// substitute a fake.
type CredentialsResolver interface {
DecryptAppSecret(inst db.LarkInstallation) (string, error)
DecryptAppSecret(inst Installation) (string, error)
}
// PatcherConfig tunes the outbound Patcher. Defaults via withDefaults;
@@ -354,7 +354,7 @@ func (p *Patcher) processEvent(ctx context.Context, e events.Event) error {
// the task without producing visible output, which only happens for
// edge cases like a chat task that just acknowledged a system event;
// not emitting a message there is the right product call.
func (p *Patcher) sendChatReply(ctx context.Context, creds InstallationCredentials, binding db.LarkChatSessionBinding, payload any) error {
func (p *Patcher) sendChatReply(ctx context.Context, creds InstallationCredentials, binding ChatSessionBinding, payload any) error {
content := chatDoneContent(payload)
if content == "" {
return nil
@@ -364,7 +364,7 @@ func (p *Patcher) sendChatReply(ctx context.Context, creds InstallationCredentia
return sendWithThreadFallback(p.cfg.Logger, "send markdown card", target, func(t ReplyTarget) error {
_, err := p.client.SendMarkdownCard(ctx, SendMarkdownCardParams{
InstallationID: creds,
ChatID: ChatID(binding.LarkChatID),
ChatID: ChatID(binding.ChannelChatID),
Markdown: content,
ReplyTarget: t,
})
@@ -374,7 +374,7 @@ func (p *Patcher) sendChatReply(ctx context.Context, creds InstallationCredentia
return sendWithThreadFallback(p.cfg.Logger, "send text message", target, func(t ReplyTarget) error {
_, err := p.client.SendTextMessage(ctx, SendTextParams{
InstallationID: creds,
ChatID: ChatID(binding.LarkChatID),
ChatID: ChatID(binding.ChannelChatID),
Text: content,
ReplyTarget: t,
})
@@ -389,10 +389,10 @@ func (p *Patcher) sendChatReply(ctx context.Context, creds InstallationCredentia
// path, and only an @-mention that happened inside a thread gets a
// threaded reply (replying to last_lark_message_id with reply_in_thread).
// The zero ReplyTarget means "send at the chat level".
func threadReplyTarget(binding db.LarkChatSessionBinding) ReplyTarget {
if binding.LastLarkThreadID.Valid && binding.LastLarkThreadID.String != "" &&
binding.LastLarkMessageID.Valid && binding.LastLarkMessageID.String != "" {
return ReplyTarget{MessageID: binding.LastLarkMessageID.String, InThread: true}
func threadReplyTarget(binding ChatSessionBinding) ReplyTarget {
if binding.LastThreadID.Valid && binding.LastThreadID.String != "" &&
binding.LastMessageID.Valid && binding.LastMessageID.String != "" {
return ReplyTarget{MessageID: binding.LastMessageID.String, InThread: true}
}
return ReplyTarget{}
}
@@ -432,7 +432,7 @@ func sendWithThreadFallback(log *slog.Logger, op string, target ReplyTarget, sen
return fmt.Errorf("%s: %w", op, err)
}
func (p *Patcher) installationCredentials(inst db.LarkInstallation) (InstallationCredentials, error) {
func (p *Patcher) installationCredentials(inst Installation) (InstallationCredentials, error) {
if p.credentials == nil {
return InstallationCredentials{}, errors.New("lark patcher: credentials resolver missing")
}
@@ -460,7 +460,7 @@ func (p *Patcher) installationCredentials(inst db.LarkInstallation) (Installatio
// One-shot send (no patching, no DB row): if the task fails a second
// time we'd just send a second card, which is fine — failure is
// usually a single terminal event.
func (p *Patcher) fail(ctx context.Context, creds InstallationCredentials, binding db.LarkChatSessionBinding, taskID pgtype.UUID, agentName string, payload any) error {
func (p *Patcher) fail(ctx context.Context, creds InstallationCredentials, binding ChatSessionBinding, taskID pgtype.UUID, agentName string, payload any) error {
render, err := p.cfg.Renderer.Render(RenderInput{
Kind: CardKindError,
AgentName: agentName,
@@ -473,7 +473,7 @@ func (p *Patcher) fail(ctx context.Context, creds InstallationCredentials, bindi
return sendWithThreadFallback(p.cfg.Logger, "send error card", threadReplyTarget(binding), func(t ReplyTarget) error {
_, err := p.client.SendInteractiveCard(ctx, SendCardParams{
InstallationID: creds,
ChatID: ChatID(binding.LarkChatID),
ChatID: ChatID(binding.ChannelChatID),
CardJSON: render.JSON,
ReplyTarget: t,
})
@@ -18,17 +18,17 @@ import (
type fakePatcherQueries struct {
mu sync.Mutex
binding db.LarkChatSessionBinding
binding ChatSessionBinding
bindingErr error
installation db.LarkInstallation
installation Installation
installationErr error
agent db.Agent
agentErr error
card db.LarkOutboundCardMessage
card OutboundCardMessage
cardErr error
created []db.CreateLarkOutboundCardMessageParams
createReturn db.LarkOutboundCardMessage
statusUpdates []db.UpdateLarkOutboundCardStatusParams
created []CreateOutboundCardMessageParams
createReturn OutboundCardMessage
statusUpdates []UpdateOutboundCardStatusParams
}
func (f *fakePatcherQueries) GetAgentTask(ctx context.Context, id pgtype.UUID) (db.AgentTaskQueue, error) {
@@ -40,22 +40,22 @@ func (f *fakePatcherQueries) GetChatSession(ctx context.Context, id pgtype.UUID)
func (f *fakePatcherQueries) GetAgent(ctx context.Context, id pgtype.UUID) (db.Agent, error) {
return f.agent, f.agentErr
}
func (f *fakePatcherQueries) GetLarkInstallation(ctx context.Context, id pgtype.UUID) (db.LarkInstallation, error) {
func (f *fakePatcherQueries) GetLarkInstallation(ctx context.Context, id pgtype.UUID) (Installation, error) {
return f.installation, f.installationErr
}
func (f *fakePatcherQueries) GetLarkChatSessionBindingBySession(ctx context.Context, sessID pgtype.UUID) (db.LarkChatSessionBinding, error) {
func (f *fakePatcherQueries) GetLarkChatSessionBindingBySession(ctx context.Context, sessID pgtype.UUID) (ChatSessionBinding, error) {
return f.binding, f.bindingErr
}
func (f *fakePatcherQueries) GetLarkOutboundCardByTask(ctx context.Context, taskID pgtype.UUID) (db.LarkOutboundCardMessage, error) {
func (f *fakePatcherQueries) GetLarkOutboundCardByTask(ctx context.Context, taskID pgtype.UUID) (OutboundCardMessage, error) {
return f.card, f.cardErr
}
func (f *fakePatcherQueries) CreateLarkOutboundCardMessage(ctx context.Context, arg db.CreateLarkOutboundCardMessageParams) (db.LarkOutboundCardMessage, error) {
func (f *fakePatcherQueries) CreateLarkOutboundCardMessage(ctx context.Context, arg CreateOutboundCardMessageParams) (OutboundCardMessage, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.created = append(f.created, arg)
return f.createReturn, nil
}
func (f *fakePatcherQueries) UpdateLarkOutboundCardStatus(ctx context.Context, arg db.UpdateLarkOutboundCardStatusParams) error {
func (f *fakePatcherQueries) UpdateLarkOutboundCardStatus(ctx context.Context, arg UpdateOutboundCardStatusParams) error {
f.mu.Lock()
defer f.mu.Unlock()
f.statusUpdates = append(f.statusUpdates, arg)
@@ -64,7 +64,7 @@ func (f *fakePatcherQueries) UpdateLarkOutboundCardStatus(ctx context.Context, a
type fakeCredentials struct{ secret string }
func (f fakeCredentials) DecryptAppSecret(inst db.LarkInstallation) (string, error) {
func (f fakeCredentials) DecryptAppSecret(inst Installation) (string, error) {
return f.secret, nil
}
@@ -162,13 +162,13 @@ func (f *fakeAPIClient) DeleteMessageReaction(ctx context.Context, p DeleteReact
func newTestPatcher(t *testing.T) (*Patcher, *fakePatcherQueries, *fakeAPIClient) {
t.Helper()
q := &fakePatcherQueries{
binding: db.LarkChatSessionBinding{
binding: ChatSessionBinding{
ChatSessionID: uuidFromString(t, "cccccccc-cccc-cccc-cccc-cccccccccccc"),
InstallationID: uuidFromString(t, "1111aaaa-1111-1111-1111-111111111111"),
LarkChatID: "oc_test_chat",
LarkChatType: "p2p",
ChannelChatID: "oc_test_chat",
ChatType: "p2p",
},
installation: db.LarkInstallation{
installation: Installation{
ID: uuidFromString(t, "1111aaaa-1111-1111-1111-111111111111"),
AppID: "cli_test_app",
AppSecretEncrypted: []byte("ciphertext"),
@@ -215,8 +215,8 @@ func TestPatcherSendsPlainTextOnChatDone(t *testing.T) {
if got.Text != "Hello! I'm cc, a coding agent…" {
t.Errorf("text mismatch: got %q", got.Text)
}
if got.ChatID != ChatID(q.binding.LarkChatID) {
t.Errorf("chat_id mismatch: got %q want %q", got.ChatID, q.binding.LarkChatID)
if got.ChatID != ChatID(q.binding.ChannelChatID) {
t.Errorf("chat_id mismatch: got %q want %q", got.ChatID, q.binding.ChannelChatID)
}
if got.InstallationID.AppID != "cli_test_app" {
t.Errorf("expected installation app_id propagated; got %q", got.InstallationID.AppID)
@@ -258,8 +258,8 @@ func TestPatcherRoutesMarkdownReplyToCard(t *testing.T) {
if got.Markdown != body {
t.Errorf("markdown body must be forwarded verbatim; got %q", got.Markdown)
}
if got.ChatID != ChatID(q.binding.LarkChatID) {
t.Errorf("chat_id mismatch: got %q want %q", got.ChatID, q.binding.LarkChatID)
if got.ChatID != ChatID(q.binding.ChannelChatID) {
t.Errorf("chat_id mismatch: got %q want %q", got.ChatID, q.binding.ChannelChatID)
}
if len(api.textSent) != 0 {
t.Errorf("markdown body must NOT also fire SendTextMessage; got %d", len(api.textSent))
@@ -495,8 +495,8 @@ func TestDefaultRendererConfigCarriesUpdateMulti(t *testing.T) {
// reply_in_thread=true, so it lands inside the 话题 instead of the group.
func TestPatcherRepliesInThreadWhenTriggerWasInThread(t *testing.T) {
p, q, api := newTestPatcher(t)
q.binding.LastLarkMessageID = pgtype.Text{String: "om_trigger", Valid: true}
q.binding.LastLarkThreadID = pgtype.Text{String: "omt_topic", Valid: true}
q.binding.LastMessageID = pgtype.Text{String: "om_trigger", Valid: true}
q.binding.LastThreadID = pgtype.Text{String: "omt_topic", Valid: true}
taskID := uuidFromString(t, "ee666666-ee66-ee66-ee66-eeeeeeeeeeee")
p.handleEvent(events.Event{
@@ -525,7 +525,7 @@ func TestPatcherRepliesInThreadWhenTriggerWasInThread(t *testing.T) {
func TestPatcherSendsToChatWhenNoThread(t *testing.T) {
p, q, api := newTestPatcher(t)
// binding has a message id but NO thread id → must not thread.
q.binding.LastLarkMessageID = pgtype.Text{String: "om_trigger", Valid: true}
q.binding.LastMessageID = pgtype.Text{String: "om_trigger", Valid: true}
taskID := uuidFromString(t, "ee777777-ee77-ee77-ee77-eeeeeeeeeeee")
p.handleEvent(events.Event{
@@ -550,8 +550,8 @@ func TestPatcherSendsToChatWhenNoThread(t *testing.T) {
// card path also threads when the trigger was in a topic.
func TestPatcherThreadReplyMarkdownRoutesToThread(t *testing.T) {
p, q, api := newTestPatcher(t)
q.binding.LastLarkMessageID = pgtype.Text{String: "om_trigger", Valid: true}
q.binding.LastLarkThreadID = pgtype.Text{String: "omt_topic", Valid: true}
q.binding.LastMessageID = pgtype.Text{String: "om_trigger", Valid: true}
q.binding.LastThreadID = pgtype.Text{String: "omt_topic", Valid: true}
taskID := uuidFromString(t, "ee888888-ee88-ee88-ee88-eeeeeeeeeeee")
p.handleEvent(events.Event{
@@ -580,8 +580,8 @@ func TestPatcherThreadReplyMarkdownRoutesToThread(t *testing.T) {
func TestPatcherThreadReplyFallsBackToChatLevel(t *testing.T) {
p, q, api := newTestPatcher(t)
api.threadReplyErr = errThreadReplyClassified
q.binding.LastLarkMessageID = pgtype.Text{String: "om_trigger", Valid: true}
q.binding.LastLarkThreadID = pgtype.Text{String: "omt_topic", Valid: true}
q.binding.LastMessageID = pgtype.Text{String: "om_trigger", Valid: true}
q.binding.LastThreadID = pgtype.Text{String: "omt_topic", Valid: true}
taskID := uuidFromString(t, "ee999999-ee99-ee99-ee99-eeeeeeeeeeee")
p.handleEvent(events.Event{
@@ -611,8 +611,8 @@ func TestPatcherThreadReplyFallsBackToChatLevel(t *testing.T) {
func TestPatcherThreadReplyDoesNotFallBackOnAmbiguousError(t *testing.T) {
p, q, api := newTestPatcher(t)
api.threadReplyErr = errThreadReplyTransport
q.binding.LastLarkMessageID = pgtype.Text{String: "om_trigger", Valid: true}
q.binding.LastLarkThreadID = pgtype.Text{String: "omt_topic", Valid: true}
q.binding.LastMessageID = pgtype.Text{String: "om_trigger", Valid: true}
q.binding.LastThreadID = pgtype.Text{String: "omt_topic", Valid: true}
taskID := uuidFromString(t, "ee888888-ee88-ee88-ee88-eeeeeeeeeeee")
p.handleEvent(events.Event{
@@ -27,7 +27,7 @@ import (
// are logged and swallowed; the next inbound message for the same
// user retries the reply on its own.
type OutcomeReplier interface {
Reply(ctx context.Context, inst db.LarkInstallation, msg InboundMessage, res DispatchResult)
Reply(ctx context.Context, inst Installation, msg InboundMessage, res DispatchResult)
}
// OutcomeReplierQueries is the narrow subset of *db.Queries the
@@ -44,7 +44,7 @@ type noopReplier struct {
log *slog.Logger
}
func (n *noopReplier) Reply(ctx context.Context, inst db.LarkInstallation, msg InboundMessage, res DispatchResult) {
func (n *noopReplier) Reply(ctx context.Context, inst Installation, msg InboundMessage, res DispatchResult) {
switch res.Outcome {
case OutcomeNeedsBinding, OutcomeAgentOffline, OutcomeAgentArchived:
n.log.Warn("lark outcome replier: outbound reply skipped (replier not wired)",
@@ -145,7 +145,7 @@ func NewLarkOutcomeReplier(cfg OutcomeReplierConfig) OutcomeReplier {
// Reply implements OutcomeReplier. Reads carefully — the switch is
// the SOURCE OF TRUTH for which outcomes generate a reply, and a
// missing branch silently drops the user-visible side effect.
func (r *LarkOutcomeReplier) Reply(ctx context.Context, inst db.LarkInstallation, msg InboundMessage, res DispatchResult) {
func (r *LarkOutcomeReplier) Reply(ctx context.Context, inst Installation, msg InboundMessage, res DispatchResult) {
switch res.Outcome {
case OutcomeNeedsBinding:
if err := r.sendBindingPrompt(ctx, inst, res); err != nil {
@@ -196,7 +196,7 @@ func (r *LarkOutcomeReplier) Reply(ctx context.Context, inst db.LarkInstallation
}
}
func (r *LarkOutcomeReplier) sendBindingPrompt(ctx context.Context, inst db.LarkInstallation, res DispatchResult) error {
func (r *LarkOutcomeReplier) sendBindingPrompt(ctx context.Context, inst Installation, res DispatchResult) error {
if res.SenderOpenID == "" {
return errors.New("missing sender open_id")
}
@@ -226,7 +226,7 @@ func (r *LarkOutcomeReplier) sendBindingPrompt(ctx context.Context, inst db.Lark
// after MUL-2671's plain-text refactor. The link to Multica is
// included on its own line so Lark's auto-linker turns it into a
// tappable URL.
func (r *LarkOutcomeReplier) sendIssueCreated(ctx context.Context, inst db.LarkInstallation, msg InboundMessage, res DispatchResult) error {
func (r *LarkOutcomeReplier) sendIssueCreated(ctx context.Context, inst Installation, msg InboundMessage, res DispatchResult) error {
if msg.ChatID == "" {
return errors.New("missing chat_id")
}
@@ -288,7 +288,7 @@ func issueCreatedText(res DispatchResult, publicURL string) string {
return line + "\n" + strings.TrimRight(publicURL, "/") + "/issues/" + identifier
}
func (r *LarkOutcomeReplier) sendChatNotice(ctx context.Context, inst db.LarkInstallation, msg InboundMessage, body string) error {
func (r *LarkOutcomeReplier) sendChatNotice(ctx context.Context, inst Installation, msg InboundMessage, body string) error {
if msg.ChatID == "" {
return errors.New("missing chat_id")
}
@@ -318,7 +318,7 @@ func (r *LarkOutcomeReplier) sendChatNotice(ctx context.Context, inst db.LarkIns
})
}
func (r *LarkOutcomeReplier) installationCredentials(inst db.LarkInstallation) (InstallationCredentials, error) {
func (r *LarkOutcomeReplier) installationCredentials(inst Installation) (InstallationCredentials, error) {
secret, err := r.credentials.DecryptAppSecret(inst)
if err != nil {
return InstallationCredentials{}, fmt.Errorf("decrypt app_secret: %w", err)
@@ -103,7 +103,7 @@ func (s *stubAPIClientWithRecorder) DeleteMessageReaction(ctx context.Context, p
// stubCredentialsResolver returns a fixed plaintext secret.
type stubCredentialsResolver struct{ secret string }
func (s stubCredentialsResolver) DecryptAppSecret(inst db.LarkInstallation) (string, error) {
func (s stubCredentialsResolver) DecryptAppSecret(inst Installation) (string, error) {
if s.secret == "" {
return "", errors.New("no secret configured")
}
@@ -198,7 +198,7 @@ func TestLarkOutcomeReplierAgentOfflineSendsCard(t *testing.T) {
PublicURL: "https://multica.test",
Logger: log,
})
inst := db.LarkInstallation{AppID: "cli_x"}
inst := Installation{AppID: "cli_x"}
inst.ID = mustUUID("11111111-1111-1111-1111-111111111111")
msg := InboundMessage{ChatID: "oc_chat_1", SenderOpenID: "ou_user_1"}
rep.Reply(context.Background(), inst, msg, DispatchResult{Outcome: OutcomeAgentOffline})
@@ -234,7 +234,7 @@ func TestLarkOutcomeReplierAgentArchivedSendsCard(t *testing.T) {
Logger: log,
})
msg := InboundMessage{ChatID: "oc_chat_arch"}
rep.Reply(context.Background(), db.LarkInstallation{}, msg, DispatchResult{Outcome: OutcomeAgentArchived})
rep.Reply(context.Background(), Installation{}, msg, DispatchResult{Outcome: OutcomeAgentArchived})
if len(stub.interactiveOut) != 1 {
t.Fatalf("expected one SendInteractiveCard call, got %d", len(stub.interactiveOut))
}
@@ -259,8 +259,8 @@ func TestLarkOutcomeReplierIngestedAndDroppedAreSilent(t *testing.T) {
Logger: log,
})
msg := InboundMessage{ChatID: "oc_x"}
rep.Reply(context.Background(), db.LarkInstallation{}, msg, DispatchResult{Outcome: OutcomeIngested})
rep.Reply(context.Background(), db.LarkInstallation{}, msg, DispatchResult{Outcome: OutcomeDropped, DropReason: DropReasonDuplicate})
rep.Reply(context.Background(), Installation{}, msg, DispatchResult{Outcome: OutcomeIngested})
rep.Reply(context.Background(), Installation{}, msg, DispatchResult{Outcome: OutcomeDropped, DropReason: DropReasonDuplicate})
if len(stub.interactiveOut) != 0 || len(stub.bindingCalls) != 0 {
t.Errorf("Ingested/Dropped should not trigger any APIClient call; got interactive=%d binding=%d",
len(stub.interactiveOut), len(stub.bindingCalls))
@@ -284,7 +284,7 @@ func TestLarkOutcomeReplierOfflineSwallowsAPIError(t *testing.T) {
Logger: log,
})
// Should NOT panic.
rep.Reply(context.Background(), db.LarkInstallation{}, InboundMessage{ChatID: "oc"}, DispatchResult{Outcome: OutcomeAgentOffline})
rep.Reply(context.Background(), Installation{}, InboundMessage{ChatID: "oc"}, DispatchResult{Outcome: OutcomeAgentOffline})
}
// TestNoopReplierIsHandledByHub verifies that NewHub installs a noop
@@ -322,7 +322,7 @@ func TestLarkOutcomeReplierIssueCreatedSendsConfirmation(t *testing.T) {
Logger: log,
})
inst := db.LarkInstallation{AppID: "cli_x"}
inst := Installation{AppID: "cli_x"}
inst.ID = mustUUID("11111111-1111-1111-1111-111111111111")
msg := InboundMessage{ChatID: "oc_chat_42", SenderOpenID: "ou_user"}
rep.Reply(context.Background(), inst, msg, DispatchResult{
@@ -376,7 +376,7 @@ func TestLarkOutcomeReplierOutcomeIngestedSilentWithoutIssue(t *testing.T) {
Logger: log,
})
rep.Reply(context.Background(), db.LarkInstallation{}, InboundMessage{ChatID: "oc"},
rep.Reply(context.Background(), Installation{}, InboundMessage{ChatID: "oc"},
DispatchResult{Outcome: OutcomeIngested}) // no IssueID
stub.mu.Lock()
@@ -410,7 +410,7 @@ func TestLarkOutcomeReplierIssueCreatedThreadFallback(t *testing.T) {
Logger: log,
})
rep.Reply(context.Background(), db.LarkInstallation{AppID: "cli_x"}, threadedInboundMsg("oc_chat_42"), DispatchResult{
rep.Reply(context.Background(), Installation{AppID: "cli_x"}, threadedInboundMsg("oc_chat_42"), DispatchResult{
Outcome: OutcomeIngested,
IssueID: mustUUID("22222222-2222-2222-2222-222222222222"),
IssueNumber: 42,
@@ -447,7 +447,7 @@ func TestLarkOutcomeReplierIssueCreatedNoFallbackOnAmbiguous(t *testing.T) {
Logger: log,
})
rep.Reply(context.Background(), db.LarkInstallation{AppID: "cli_x"}, threadedInboundMsg("oc_chat_42"), DispatchResult{
rep.Reply(context.Background(), Installation{AppID: "cli_x"}, threadedInboundMsg("oc_chat_42"), DispatchResult{
Outcome: OutcomeIngested,
IssueID: mustUUID("22222222-2222-2222-2222-222222222222"),
IssueNumber: 42,
@@ -479,7 +479,7 @@ func TestLarkOutcomeReplierNoticeThreadFallback(t *testing.T) {
Logger: log,
})
rep.Reply(context.Background(), db.LarkInstallation{AppID: "cli_x"}, threadedInboundMsg("oc_chat_1"), DispatchResult{Outcome: OutcomeAgentOffline})
rep.Reply(context.Background(), Installation{AppID: "cli_x"}, threadedInboundMsg("oc_chat_1"), DispatchResult{Outcome: OutcomeAgentOffline})
stub.mu.Lock()
defer stub.mu.Unlock()
@@ -509,7 +509,7 @@ func TestLarkOutcomeReplierNoticeNoFallbackOnAmbiguous(t *testing.T) {
Logger: log,
})
rep.Reply(context.Background(), db.LarkInstallation{}, threadedInboundMsg("oc_chat_arch"), DispatchResult{Outcome: OutcomeAgentArchived})
rep.Reply(context.Background(), Installation{}, threadedInboundMsg("oc_chat_arch"), DispatchResult{Outcome: OutcomeAgentArchived})
stub.mu.Lock()
defer stub.mu.Unlock()
+145
View File
@@ -0,0 +1,145 @@
package lark
import "github.com/jackc/pgx/v5/pgtype"
// Domain parameter types for the channel-backed Feishu store. They replace the
// retired db.*LarkParams shapes generated from queries/lark.sql, using the same
// channel-neutral field names as the domain entities in store.go. The store
// (channel_store.go) maps them onto the channel_* writes, folding the
// feishu-specific identifiers into the JSONB config at the DB boundary.
// GetInstallationInWorkspaceParams scopes an installation lookup to a workspace.
type GetInstallationInWorkspaceParams struct {
ID pgtype.UUID
WorkspaceID pgtype.UUID
}
// UpsertInstallationParams carries the flat feishu installation fields for an
// install / re-install.
type UpsertInstallationParams struct {
WorkspaceID pgtype.UUID
AgentID pgtype.UUID
AppID string
AppSecretEncrypted []byte
BotOpenID string
InstallerUserID pgtype.UUID
TenantKey pgtype.Text
BotUnionID pgtype.Text
Region string
}
// SetInstallationStatusParams flips an installation's status (active/revoked).
type SetInstallationStatusParams struct {
ID pgtype.UUID
Status string
}
// SetInstallationBotUnionIDParams records the bot's union_id (backfill).
type SetInstallationBotUnionIDParams struct {
ID pgtype.UUID
BotUnionID pgtype.Text
}
// AcquireWSLeaseParams fences the WS supervisor lease for an installation.
type AcquireWSLeaseParams struct {
NewToken pgtype.Text
NewExpiresAt pgtype.Timestamptz
ID pgtype.UUID
}
// ReleaseWSLeaseParams releases a WS supervisor lease the caller still holds.
type ReleaseWSLeaseParams struct {
ID pgtype.UUID
CurrentToken pgtype.Text
}
// GetUserBindingByOpenIDParams looks up a binding by its channel-native user id.
type GetUserBindingByOpenIDParams struct {
InstallationID pgtype.UUID
ChannelUserID string
}
// CreateUserBindingParams binds a workspace member to a channel-native user id.
type CreateUserBindingParams struct {
WorkspaceID pgtype.UUID
MulticaUserID pgtype.UUID
InstallationID pgtype.UUID
ChannelUserID string
UnionID pgtype.Text
}
// GetChatSessionBindingParams looks up a chat binding by its channel chat id.
type GetChatSessionBindingParams struct {
InstallationID pgtype.UUID
ChannelChatID string
}
// CreateChatSessionBindingParams binds a chat_session to a channel chat.
type CreateChatSessionBindingParams struct {
ChatSessionID pgtype.UUID
InstallationID pgtype.UUID
ChannelChatID string
ChatType string
}
// UpdateChatSessionBindingReplyTargetParams records the latest inbound trigger
// message + thread so the outbound patcher can thread its reply.
type UpdateChatSessionBindingReplyTargetParams struct {
ChatSessionID pgtype.UUID
LastMessageID pgtype.Text
LastThreadID pgtype.Text
}
// ClaimInboundDedupParams claims the two-phase idempotency row for a message.
type ClaimInboundDedupParams struct {
InstallationID pgtype.UUID
MessageID string
}
// MarkInboundDedupProcessedParams marks a claimed message processed (fenced).
type MarkInboundDedupProcessedParams struct {
InstallationID pgtype.UUID
MessageID string
ClaimToken pgtype.UUID
}
// ReleaseInboundDedupParams releases a claim on processing failure (fenced).
type ReleaseInboundDedupParams struct {
InstallationID pgtype.UUID
MessageID string
ClaimToken pgtype.UUID
}
// RecordInboundDropParams writes a non-content drop audit row.
type RecordInboundDropParams struct {
EventType string
DropReason string
InstallationID pgtype.UUID
ChannelChatID pgtype.Text
ChannelEventID pgtype.Text
ChannelMessageID pgtype.Text
}
// CreateBindingTokenParams mints a short-lived channel binding token.
type CreateBindingTokenParams struct {
TokenHash string
WorkspaceID pgtype.UUID
InstallationID pgtype.UUID
ChannelUserID string
ExpiresAt pgtype.Timestamptz
}
// CreateOutboundCardMessageParams records an outbound card for a task/session.
type CreateOutboundCardMessageParams struct {
ChatSessionID pgtype.UUID
ChannelChatID string
ChannelCardMessageID string
Status string
TaskID pgtype.UUID
}
// UpdateOutboundCardStatusParams transitions an outbound card's status.
type UpdateOutboundCardStatusParams struct {
ID pgtype.UUID
Status string
}
@@ -5,8 +5,6 @@ import (
"log/slog"
"net/url"
"strings"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// BackfillRegionFromLegacyOverride is the upgrade-repair path for self-host
@@ -32,7 +30,7 @@ import (
//
// Callers should fire this from a goroutine at boot, like
// BackfillBotUnionIDs, so a slow DB write cannot block listener startup.
func BackfillRegionFromLegacyOverride(ctx context.Context, queries *db.Queries, httpOverride, callbackOverride string, log *slog.Logger) {
func BackfillRegionFromLegacyOverride(ctx context.Context, queries *ChannelStore, httpOverride, callbackOverride string, log *slog.Logger) {
if log == nil {
log = slog.Default()
}
@@ -106,7 +106,7 @@ type RegistrationService struct {
cfg RegistrationServiceConfig
client *RegistrationClient
api APIClient
queries *db.Queries
queries *ChannelStore
tx TxStarter
installs *InstallationService
binder InstallerBinder
@@ -167,7 +167,7 @@ func NewRegistrationService(
cfg: cfg.withDefaults(),
client: client,
api: api,
queries: queries,
queries: NewChannelStore(queries),
tx: tx,
installs: installs,
binder: binder,
@@ -563,7 +563,7 @@ func (s *RegistrationService) finishSuccess(ctx context.Context, sess *registrat
defer tx.Rollback(ctx)
qtx := s.queries.WithTx(tx)
inst, err := qtx.UpsertLarkInstallation(ctx, db.UpsertLarkInstallationParams{
inst, err := qtx.UpsertLarkInstallation(ctx, UpsertInstallationParams{
WorkspaceID: sess.workspaceID,
AgentID: sess.agentID,
AppID: res.ClientID,
@@ -291,7 +291,7 @@ type fakeInstallerBinder struct {
err error
}
func (f *fakeInstallerBinder) BindInstallerTx(_ context.Context, _ *db.Queries, p InstallerBindParams) error {
func (f *fakeInstallerBinder) BindInstallerTx(_ context.Context, _ *ChannelStore, p InstallerBindParams) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, p)
+298
View File
@@ -0,0 +1,298 @@
package lark
// Channel-backed store for the Feishu integration.
//
// MUL-3515 generalized the lark_* tables into channel_* (a channel_type
// discriminator + a JSONB `config` blob for the platform-specific
// identifiers/credentials). This file owns the one boundary where that JSONB
// is (de)serialized: the rest of the package keeps working with flat domain
// structs whose fields mirror the retired db.Lark* rows one-for-one, so the
// call sites are a mechanical rename rather than a reshape.
//
// The feishu config blob carries exactly the columns that used to be flat on
// lark_installation / lark_user_binding:
//
// installation: app_id, app_secret_encrypted (base64), tenant_key,
// bot_open_id, bot_union_id, region
// user binding: union_id
//
// app_secret_encrypted is secretbox ciphertext stored as a base64 string.
// The decoder is whitespace-tolerant on purpose: the migration backfill writes
// it via PostgreSQL encode(...,'base64'), which MIME-wraps every 76 chars, and
// a sealed ~72-byte secret exceeds that. The encoder always emits unwrapped
// base64, so rows written by Go are already clean; stripping on read keeps
// both sources interchangeable.
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// Installation is the flat, feishu-shaped view of a channel_installation row.
// It keeps field parity with the lark_installation row it replaced, so the
// cutover was a rename at the ~190 call sites. The feishu-specific fields
// (AppID, AppSecretEncrypted, TenantKey, BotOpenID, BotUnionID, Region) come
// from the JSONB config; the rest are flat columns.
type Installation struct {
ID pgtype.UUID
WorkspaceID pgtype.UUID
AgentID pgtype.UUID
AppID string
AppSecretEncrypted []byte
TenantKey pgtype.Text
BotOpenID string
InstallerUserID pgtype.UUID
Status string
WsLeaseToken pgtype.Text
WsLeaseExpiresAt pgtype.Timestamptz
InstalledAt pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
BotUnionID pgtype.Text
Region string
}
// UserBinding is the flat view of a channel_user_binding row. ChannelUserID is
// the feishu open_id; UnionID (secondary identity) lives in the JSONB config.
type UserBinding struct {
ID pgtype.UUID
WorkspaceID pgtype.UUID
MulticaUserID pgtype.UUID
InstallationID pgtype.UUID
ChannelUserID string
UnionID pgtype.Text
BoundAt pgtype.Timestamptz
}
// ChatSessionBinding is the flat view of a channel_chat_session_binding row.
// Every field is a flat column (config is unused for feishu today), so this is
// a pure copy with no JSON involved.
type ChatSessionBinding struct {
ID pgtype.UUID
ChatSessionID pgtype.UUID
InstallationID pgtype.UUID
ChannelChatID string
ChatType string
CreatedAt pgtype.Timestamptz
LastMessageID pgtype.Text
LastThreadID pgtype.Text
}
// InboundMessageDedup is the flat view of a channel_inbound_message_dedup row.
// Every field is a flat column (no JSON), so this mirrors the channel row 1:1.
type InboundMessageDedup struct {
InstallationID pgtype.UUID
MessageID string
ReceivedAt pgtype.Timestamptz
ProcessedAt pgtype.Timestamptz
ClaimToken pgtype.UUID
}
// BindingTokenRow is the flat view of a channel_binding_token row. ChannelUserID
// is the feishu open_id the token will bind once redeemed. (Named *Row to avoid
// colliding with BindingToken in binding_token.go, which is the freshly-minted
// raw-token shape returned to the caller.)
type BindingTokenRow struct {
TokenHash string
WorkspaceID pgtype.UUID
InstallationID pgtype.UUID
ChannelUserID string
ExpiresAt pgtype.Timestamptz
ConsumedAt pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
}
// OutboundCardMessage is the flat view of a channel_outbound_card_message row.
type OutboundCardMessage struct {
ID pgtype.UUID
ChatSessionID pgtype.UUID
TaskID pgtype.UUID
ChannelChatID string
ChannelCardMessageID string
Status string
LastPatchedAt pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
}
// feishuInstallConfig is the JSON shape of channel_installation.config for the
// feishu channel. app_secret_encrypted is decoded by hand (see decodeSecret)
// rather than as a json []byte field, so MIME-wrapped base64 from the SQL
// backfill round-trips too. omitempty mirrors the migration's jsonb_strip_nulls.
type feishuInstallConfig struct {
AppID string `json:"app_id"`
AppSecretEncrypted string `json:"app_secret_encrypted,omitempty"`
TenantKey string `json:"tenant_key,omitempty"`
BotOpenID string `json:"bot_open_id,omitempty"`
BotUnionID string `json:"bot_union_id,omitempty"`
Region string `json:"region,omitempty"`
}
// feishuBindingConfig is the JSON shape of channel_user_binding.config.
type feishuBindingConfig struct {
UnionID string `json:"union_id,omitempty"`
}
// installationFromRow decodes a channel_installation row (flat columns + JSONB
// config) into the flat Installation domain struct.
func installationFromRow(row db.ChannelInstallation) (Installation, error) {
var cfg feishuInstallConfig
if len(row.Config) > 0 {
if err := json.Unmarshal(row.Config, &cfg); err != nil {
return Installation{}, fmt.Errorf("decode installation config: %w", err)
}
}
secret, err := decodeSecret(cfg.AppSecretEncrypted)
if err != nil {
return Installation{}, fmt.Errorf("decode app_secret_encrypted: %w", err)
}
return Installation{
ID: row.ID,
WorkspaceID: row.WorkspaceID,
AgentID: row.AgentID,
AppID: cfg.AppID,
AppSecretEncrypted: secret,
TenantKey: textOrNull(cfg.TenantKey),
BotOpenID: cfg.BotOpenID,
InstallerUserID: row.InstallerUserID,
Status: row.Status,
WsLeaseToken: row.WsLeaseToken,
WsLeaseExpiresAt: row.WsLeaseExpiresAt,
InstalledAt: row.InstalledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
BotUnionID: textOrNull(cfg.BotUnionID),
Region: cfg.Region,
}, nil
}
// encodeInstallConfig builds the channel_installation.config JSONB from the
// feishu fields of an Installation. The secret is emitted as unwrapped base64.
func encodeInstallConfig(inst Installation) ([]byte, error) {
cfg := feishuInstallConfig{
AppID: inst.AppID,
TenantKey: inst.TenantKey.String,
BotOpenID: inst.BotOpenID,
BotUnionID: inst.BotUnionID.String,
Region: inst.Region,
}
if len(inst.AppSecretEncrypted) > 0 {
cfg.AppSecretEncrypted = base64.StdEncoding.EncodeToString(inst.AppSecretEncrypted)
}
return json.Marshal(cfg)
}
// userBindingFromRow decodes a channel_user_binding row into UserBinding.
func userBindingFromRow(row db.ChannelUserBinding) (UserBinding, error) {
var cfg feishuBindingConfig
if len(row.Config) > 0 {
if err := json.Unmarshal(row.Config, &cfg); err != nil {
return UserBinding{}, fmt.Errorf("decode user binding config: %w", err)
}
}
return UserBinding{
ID: row.ID,
WorkspaceID: row.WorkspaceID,
MulticaUserID: row.MulticaUserID,
InstallationID: row.InstallationID,
ChannelUserID: row.ChannelUserID,
UnionID: textOrNull(cfg.UnionID),
BoundAt: row.BoundAt,
}, nil
}
// encodeBindingConfig builds channel_user_binding.config from a UserBinding.
// Returns the JSON null-stripped (an absent union_id is "{}"), so the
// upsert's `config || jsonb_strip_nulls(EXCLUDED.config)` merge never clobbers
// a previously-captured union_id with this write.
func encodeBindingConfig(b UserBinding) ([]byte, error) {
return json.Marshal(feishuBindingConfig{UnionID: b.UnionID.String})
}
// chatSessionBindingFromRow copies a channel_chat_session_binding row into the
// flat domain struct. No JSON: every feishu field is already a flat column.
func chatSessionBindingFromRow(row db.ChannelChatSessionBinding) ChatSessionBinding {
return ChatSessionBinding{
ID: row.ID,
ChatSessionID: row.ChatSessionID,
InstallationID: row.InstallationID,
ChannelChatID: row.ChannelChatID,
ChatType: row.ChatType,
CreatedAt: row.CreatedAt,
LastMessageID: row.LastMessageID,
LastThreadID: row.LastThreadID,
}
}
// dedupFromRow copies a channel_inbound_message_dedup row into the flat domain
// struct. No JSON: every field is a flat column.
func dedupFromRow(row db.ChannelInboundMessageDedup) InboundMessageDedup {
return InboundMessageDedup{
InstallationID: row.InstallationID,
MessageID: row.MessageID,
ReceivedAt: row.ReceivedAt,
ProcessedAt: row.ProcessedAt,
ClaimToken: row.ClaimToken,
}
}
// bindingTokenFromRow copies a channel_binding_token row into the flat domain
// struct.
func bindingTokenFromRow(row db.ChannelBindingToken) BindingTokenRow {
return BindingTokenRow{
TokenHash: row.TokenHash,
WorkspaceID: row.WorkspaceID,
InstallationID: row.InstallationID,
ChannelUserID: row.ChannelUserID,
ExpiresAt: row.ExpiresAt,
ConsumedAt: row.ConsumedAt,
CreatedAt: row.CreatedAt,
}
}
// outboundCardFromRow copies a channel_outbound_card_message row into the flat
// domain struct.
func outboundCardFromRow(row db.ChannelOutboundCardMessage) OutboundCardMessage {
return OutboundCardMessage{
ID: row.ID,
ChatSessionID: row.ChatSessionID,
TaskID: row.TaskID,
ChannelChatID: row.ChannelChatID,
ChannelCardMessageID: row.ChannelCardMessageID,
Status: row.Status,
LastPatchedAt: row.LastPatchedAt,
CreatedAt: row.CreatedAt,
}
}
// decodeSecret base64-decodes the stored app secret ciphertext. It tolerates
// the newline wrapping PostgreSQL's encode(...,'base64') inserts, so secrets
// written by the SQL backfill and by encodeInstallConfig both decode. An empty
// string yields a nil slice (an installation mid-registration before the
// secret is sealed).
func decodeSecret(s string) ([]byte, error) {
if s == "" {
return nil, nil
}
return base64.StdEncoding.DecodeString(stripWhitespace(s))
}
// stripWhitespace removes the ASCII whitespace MIME base64 wrapping introduces.
func stripWhitespace(s string) string {
if !strings.ContainsAny(s, "\n\r \t") {
return s
}
return strings.Map(func(r rune) rune {
switch r {
case '\n', '\r', ' ', '\t':
return -1
default:
return r
}
}, s)
}
@@ -0,0 +1,231 @@
package lark
import (
"bytes"
"encoding/base64"
"encoding/json"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func uuidFrom(b byte) pgtype.UUID {
var u pgtype.UUID
for i := range u.Bytes {
u.Bytes[i] = b
}
u.Valid = true
return u
}
// sealedSecret returns an n-byte value standing in for a secretbox-sealed app
// secret. A real sealed Lark secret is ~72 bytes, which base64-encodes to >76
// chars and so triggers PostgreSQL's MIME line wrapping.
func sealedSecret(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = byte(i % 251)
}
return b
}
// mimeWrap reproduces PostgreSQL encode(...,'base64') line wrapping: a newline
// every 76 characters.
func mimeWrap(s string) string {
var out strings.Builder
for i := 0; i < len(s); i += 76 {
end := i + 76
if end > len(s) {
end = len(s)
}
out.WriteString(s[i:end])
if end < len(s) {
out.WriteByte('\n')
}
}
return out.String()
}
func TestInstallationConfigRoundTrip(t *testing.T) {
secret := sealedSecret(72)
in := Installation{
ID: uuidFrom(0x11),
WorkspaceID: uuidFrom(0x22),
AgentID: uuidFrom(0x33),
AppID: "cli_app_123",
AppSecretEncrypted: secret,
TenantKey: pgtype.Text{String: "tenant_xyz", Valid: true},
BotOpenID: "ou_bot_open",
InstallerUserID: uuidFrom(0x44),
Region: "lark",
BotUnionID: pgtype.Text{String: "on_union_999", Valid: true},
}
cfg, err := encodeInstallConfig(in)
if err != nil {
t.Fatalf("encodeInstallConfig: %v", err)
}
// The encoder must emit unwrapped base64 — embedded newlines are exactly
// what breaks Go's json []byte decode path.
if bytes.ContainsAny(cfg, "\n\r") {
t.Fatalf("encoded config carries wrapped base64 / newlines: %s", cfg)
}
row := db.ChannelInstallation{
ID: in.ID,
WorkspaceID: in.WorkspaceID,
AgentID: in.AgentID,
ChannelType: "feishu",
Config: cfg,
Status: "active",
InstallerUserID: in.InstallerUserID,
WsLeaseToken: pgtype.Text{String: "node-1-g3", Valid: true},
}
got, err := installationFromRow(row)
if err != nil {
t.Fatalf("installationFromRow: %v", err)
}
if !bytes.Equal(got.AppSecretEncrypted, secret) {
t.Fatalf("secret round-trip mismatch:\n got %x\nwant %x", got.AppSecretEncrypted, secret)
}
if got.AppID != in.AppID || got.BotOpenID != in.BotOpenID || got.Region != in.Region {
t.Fatalf("scalar config mismatch: %+v", got)
}
if got.TenantKey != in.TenantKey || got.BotUnionID != in.BotUnionID {
t.Fatalf("optional config mismatch: tenant=%+v union=%+v", got.TenantKey, got.BotUnionID)
}
// Flat columns must come straight from the row, not the config.
if got.Status != "active" || got.WsLeaseToken.String != "node-1-g3" {
t.Fatalf("flat columns not preserved: status=%q lease=%q", got.Status, got.WsLeaseToken.String)
}
if got.ID != in.ID || got.WorkspaceID != in.WorkspaceID || got.InstallerUserID != in.InstallerUserID {
t.Fatalf("flat id columns not preserved: %+v", got)
}
}
// TestInstallationToleratesMimeWrappedSecret simulates the migration backfill:
// channel_installation.config.app_secret_encrypted holds base64 wrapped at 76
// chars (PostgreSQL encode default). Decoding must still recover the bytes.
func TestInstallationToleratesMimeWrappedSecret(t *testing.T) {
secret := sealedSecret(72)
wrapped := mimeWrap(base64.StdEncoding.EncodeToString(secret))
if !strings.Contains(wrapped, "\n") {
t.Fatal("test setup: expected wrapped base64 to contain a newline")
}
cfg, err := json.Marshal(map[string]string{
"app_id": "cli_app_123",
"app_secret_encrypted": wrapped,
"region": "feishu",
})
if err != nil {
t.Fatalf("marshal config: %v", err)
}
got, err := installationFromRow(db.ChannelInstallation{Config: cfg, Status: "active"})
if err != nil {
t.Fatalf("installationFromRow with wrapped secret: %v", err)
}
if !bytes.Equal(got.AppSecretEncrypted, secret) {
t.Fatalf("wrapped secret round-trip mismatch:\n got %x\nwant %x", got.AppSecretEncrypted, secret)
}
}
func TestInstallConfigOmitsEmptyOptionalFields(t *testing.T) {
cfg, err := encodeInstallConfig(Installation{
AppID: "cli_app_123",
BotOpenID: "ou_bot",
Region: "feishu",
// TenantKey, BotUnionID, AppSecretEncrypted all empty.
})
if err != nil {
t.Fatalf("encodeInstallConfig: %v", err)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(cfg, &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, k := range []string{"tenant_key", "bot_union_id", "app_secret_encrypted"} {
if _, ok := raw[k]; ok {
t.Fatalf("expected %q to be omitted, config=%s", k, cfg)
}
}
// And they decode back to the zero/invalid pgtype values.
got, err := installationFromRow(db.ChannelInstallation{Config: cfg})
if err != nil {
t.Fatalf("installationFromRow: %v", err)
}
if got.TenantKey.Valid || got.BotUnionID.Valid || got.AppSecretEncrypted != nil {
t.Fatalf("empty optionals should decode invalid/nil: %+v", got)
}
}
func TestUserBindingConfigRoundTrip(t *testing.T) {
in := UserBinding{
ID: uuidFrom(0x55),
WorkspaceID: uuidFrom(0x22),
MulticaUserID: uuidFrom(0x66),
InstallationID: uuidFrom(0x11),
ChannelUserID: "ou_sender",
UnionID: pgtype.Text{String: "on_union_777", Valid: true},
}
cfg, err := encodeBindingConfig(in)
if err != nil {
t.Fatalf("encodeBindingConfig: %v", err)
}
got, err := userBindingFromRow(db.ChannelUserBinding{
ID: in.ID,
WorkspaceID: in.WorkspaceID,
MulticaUserID: in.MulticaUserID,
InstallationID: in.InstallationID,
ChannelUserID: in.ChannelUserID,
Config: cfg,
})
if err != nil {
t.Fatalf("userBindingFromRow: %v", err)
}
if got.UnionID != in.UnionID || got.ChannelUserID != in.ChannelUserID || got.MulticaUserID != in.MulticaUserID {
t.Fatalf("user binding round-trip mismatch: %+v", got)
}
}
// An absent union_id must serialize to "{}" so the upsert's
// `config || jsonb_strip_nulls(EXCLUDED.config)` merge cannot wipe an
// already-stored union_id when this bind does not carry one.
func TestBindingConfigNullStrip(t *testing.T) {
cfg, err := encodeBindingConfig(UserBinding{ChannelUserID: "ou_sender"})
if err != nil {
t.Fatalf("encodeBindingConfig: %v", err)
}
if got := strings.TrimSpace(string(cfg)); got != "{}" {
t.Fatalf("expected empty union_id to encode as {}, got %q", got)
}
}
func TestChatSessionBindingFromRow(t *testing.T) {
row := db.ChannelChatSessionBinding{
ID: uuidFrom(0x77),
ChatSessionID: uuidFrom(0x88),
InstallationID: uuidFrom(0x11),
ChannelType: "feishu",
ChannelChatID: "oc_chat_1",
ChatType: "group",
LastMessageID: pgtype.Text{String: "om_last", Valid: true},
LastThreadID: pgtype.Text{String: "omt_thread", Valid: true},
}
got := chatSessionBindingFromRow(row)
if got.ChannelChatID != "oc_chat_1" || got.ChatType != "group" {
t.Fatalf("chat fields mismatch: %+v", got)
}
if got.LastMessageID != row.LastMessageID || got.LastThreadID != row.LastThreadID {
t.Fatalf("reply-target fields mismatch: %+v", got)
}
if got.ChatSessionID != row.ChatSessionID || got.InstallationID != row.InstallationID {
t.Fatalf("id fields mismatch: %+v", got)
}
}
@@ -8,7 +8,6 @@ import (
"time"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// typingEmoji is the Lark emoji_type used for the "processing" indicator.
@@ -28,8 +27,8 @@ type TypingIndicatorState struct {
// TypingIndicatorQueries is the narrow DB surface the manager needs.
type TypingIndicatorQueries interface {
GetLarkChatSessionBindingBySession(ctx context.Context, chatSessionID pgtype.UUID) (db.LarkChatSessionBinding, error)
GetLarkInstallation(ctx context.Context, id pgtype.UUID) (db.LarkInstallation, error)
GetLarkChatSessionBindingBySession(ctx context.Context, chatSessionID pgtype.UUID) (ChatSessionBinding, error)
GetLarkInstallation(ctx context.Context, id pgtype.UUID) (Installation, error)
}
// TypingIndicatorManager owns the "processing" reaction lifecycle for
@@ -74,7 +73,7 @@ func NewTypingIndicatorManager(client APIClient, credentials CredentialsResolver
// Messages older than typingIndicatorMaxAge are silently skipped so that
// WebSocket replays and stale reconnects do not surface misleading "processing"
// badges on long-finished conversations.
func (m *TypingIndicatorManager) Add(ctx context.Context, inst db.LarkInstallation, chatSessionID pgtype.UUID, messageID string, createTime string) {
func (m *TypingIndicatorManager) Add(ctx context.Context, inst Installation, chatSessionID pgtype.UUID, messageID string, createTime string) {
if messageID == "" {
return
}
@@ -203,7 +202,7 @@ func isMessageTooOld(createTime string) bool {
return time.Since(time.UnixMilli(ms)) > typingIndicatorMaxAge
}
func (m *TypingIndicatorManager) resolveCredentials(inst db.LarkInstallation) (InstallationCredentials, error) {
func (m *TypingIndicatorManager) resolveCredentials(inst Installation) (InstallationCredentials, error) {
secret, err := m.credentials.DecryptAppSecret(inst)
if err != nil {
return InstallationCredentials{}, err
@@ -8,7 +8,6 @@ import (
"time"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// fakeTypingAPIClient records reaction calls and can be programmed to fail.
@@ -70,22 +69,22 @@ func (f *fakeTypingAPIClient) DeleteMessageReaction(_ context.Context, p DeleteR
}
type fakeTypingQueries struct {
binding db.LarkChatSessionBinding
installation db.LarkInstallation
binding ChatSessionBinding
installation Installation
bindingErr error
installErr error
}
func (f *fakeTypingQueries) GetLarkChatSessionBindingBySession(context.Context, pgtype.UUID) (db.LarkChatSessionBinding, error) {
func (f *fakeTypingQueries) GetLarkChatSessionBindingBySession(context.Context, pgtype.UUID) (ChatSessionBinding, error) {
return f.binding, f.bindingErr
}
func (f *fakeTypingQueries) GetLarkInstallation(context.Context, pgtype.UUID) (db.LarkInstallation, error) {
func (f *fakeTypingQueries) GetLarkInstallation(context.Context, pgtype.UUID) (Installation, error) {
return f.installation, f.installErr
}
type fakeTypingCreds struct{ secret string }
func (f fakeTypingCreds) DecryptAppSecret(inst db.LarkInstallation) (string, error) {
func (f fakeTypingCreds) DecryptAppSecret(inst Installation) (string, error) {
return f.secret, nil
}
@@ -94,7 +93,7 @@ func TestTypingIndicatorAddRecordsState(t *testing.T) {
queries := &fakeTypingQueries{}
mgr := NewTypingIndicatorManager(api, fakeTypingCreds{secret: "shh"}, queries, newDiscardLogger())
inst := db.LarkInstallation{AppID: "cli_test", Region: "feishu"}
inst := Installation{AppID: "cli_test", Region: "feishu"}
session := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true}
mgr.Add(context.Background(), inst, session, "msg-1", "")
@@ -119,7 +118,7 @@ func TestTypingIndicatorAddSkipsOnEmptyMessageID(t *testing.T) {
api := &fakeTypingAPIClient{addReturn: "reaction-123"}
mgr := NewTypingIndicatorManager(api, fakeTypingCreds{secret: "shh"}, &fakeTypingQueries{}, newDiscardLogger())
inst := db.LarkInstallation{AppID: "cli_test", Region: "feishu"}
inst := Installation{AppID: "cli_test", Region: "feishu"}
session := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
mgr.Add(context.Background(), inst, session, "", "")
@@ -133,7 +132,7 @@ func TestTypingIndicatorAddSkipsOldMessages(t *testing.T) {
api := &fakeTypingAPIClient{addReturn: "reaction-123"}
mgr := NewTypingIndicatorManager(api, fakeTypingCreds{secret: "shh"}, &fakeTypingQueries{}, newDiscardLogger())
inst := db.LarkInstallation{AppID: "cli_test", Region: "feishu"}
inst := Installation{AppID: "cli_test", Region: "feishu"}
session := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
oldTime := time.Now().Add(-3 * time.Minute).UnixMilli()
@@ -148,7 +147,7 @@ func TestTypingIndicatorAddLogsOnAPIError(t *testing.T) {
api := &fakeTypingAPIClient{addErr: errors.New("lark down")}
mgr := NewTypingIndicatorManager(api, fakeTypingCreds{secret: "shh"}, &fakeTypingQueries{}, newDiscardLogger())
inst := db.LarkInstallation{AppID: "cli_test", Region: "feishu"}
inst := Installation{AppID: "cli_test", Region: "feishu"}
session := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
mgr.Add(context.Background(), inst, session, "msg-1", "")
@@ -169,10 +168,10 @@ func TestTypingIndicatorAddLogsOnAPIError(t *testing.T) {
func TestTypingIndicatorClearDeletesReactions(t *testing.T) {
api := &fakeTypingAPIClient{addReturn: "reaction-abc"}
queries := &fakeTypingQueries{
binding: db.LarkChatSessionBinding{
binding: ChatSessionBinding{
InstallationID: pgtype.UUID{Bytes: [16]byte{9, 9, 9, 9}, Valid: true},
},
installation: db.LarkInstallation{
installation: Installation{
ID: pgtype.UUID{Bytes: [16]byte{9, 9, 9, 9}, Valid: true},
AppID: "cli_test",
Region: "feishu",
@@ -180,7 +179,7 @@ func TestTypingIndicatorClearDeletesReactions(t *testing.T) {
}
mgr := NewTypingIndicatorManager(api, fakeTypingCreds{secret: "shh"}, queries, newDiscardLogger())
inst := db.LarkInstallation{AppID: "cli_test", Region: "feishu"}
inst := Installation{AppID: "cli_test", Region: "feishu"}
session := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4}, Valid: true}
mgr.Add(context.Background(), inst, session, "msg-1", "")
@@ -209,10 +208,10 @@ func TestTypingIndicatorClearDeletesReactions(t *testing.T) {
func TestTypingIndicatorClearNoOpWhenEmpty(t *testing.T) {
api := &fakeTypingAPIClient{}
queries := &fakeTypingQueries{
binding: db.LarkChatSessionBinding{
binding: ChatSessionBinding{
InstallationID: pgtype.UUID{Bytes: [16]byte{9}, Valid: true},
},
installation: db.LarkInstallation{
installation: Installation{
ID: pgtype.UUID{Bytes: [16]byte{9}, Valid: true},
AppID: "cli_test",
Region: "feishu",
@@ -231,10 +230,10 @@ func TestTypingIndicatorClearNoOpWhenEmpty(t *testing.T) {
func TestTypingIndicatorClearLogsOnDeleteError(t *testing.T) {
api := &fakeTypingAPIClient{addReturn: "reaction-xyz", deleteErr: errors.New("delete failed")}
queries := &fakeTypingQueries{
binding: db.LarkChatSessionBinding{
binding: ChatSessionBinding{
InstallationID: pgtype.UUID{Bytes: [16]byte{9}, Valid: true},
},
installation: db.LarkInstallation{
installation: Installation{
ID: pgtype.UUID{Bytes: [16]byte{9}, Valid: true},
AppID: "cli_test",
Region: "feishu",
@@ -242,7 +241,7 @@ func TestTypingIndicatorClearLogsOnDeleteError(t *testing.T) {
}
mgr := NewTypingIndicatorManager(api, fakeTypingCreds{secret: "shh"}, queries, newDiscardLogger())
inst := db.LarkInstallation{AppID: "cli_test", Region: "feishu"}
inst := Installation{AppID: "cli_test", Region: "feishu"}
session := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4}, Valid: true}
mgr.Add(context.Background(), inst, session, "msg-1", "")
@@ -256,10 +255,10 @@ func TestTypingIndicatorClearLogsOnDeleteError(t *testing.T) {
func TestTypingIndicatorMultipleMessagesPerSession(t *testing.T) {
api := &fakeTypingAPIClient{addReturn: "reaction-n"}
queries := &fakeTypingQueries{
binding: db.LarkChatSessionBinding{
binding: ChatSessionBinding{
InstallationID: pgtype.UUID{Bytes: [16]byte{9}, Valid: true},
},
installation: db.LarkInstallation{
installation: Installation{
ID: pgtype.UUID{Bytes: [16]byte{9}, Valid: true},
AppID: "cli_test",
Region: "feishu",
@@ -267,7 +266,7 @@ func TestTypingIndicatorMultipleMessagesPerSession(t *testing.T) {
}
mgr := NewTypingIndicatorManager(api, fakeTypingCreds{secret: "shh"}, queries, newDiscardLogger())
inst := db.LarkInstallation{AppID: "cli_test", Region: "feishu"}
inst := Installation{AppID: "cli_test", Region: "feishu"}
session := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4}, Valid: true}
mgr.Add(context.Background(), inst, session, "msg-a", "")
@@ -287,10 +286,10 @@ func TestTypingIndicatorMultipleMessagesPerSession(t *testing.T) {
func TestTypingIndicatorConcurrentAddAndClear(t *testing.T) {
api := &fakeTypingAPIClient{addReturn: "reaction-concurrent"}
queries := &fakeTypingQueries{
binding: db.LarkChatSessionBinding{
binding: ChatSessionBinding{
InstallationID: pgtype.UUID{Bytes: [16]byte{9}, Valid: true},
},
installation: db.LarkInstallation{
installation: Installation{
ID: pgtype.UUID{Bytes: [16]byte{9}, Valid: true},
AppID: "cli_test",
Region: "feishu",
@@ -298,7 +297,7 @@ func TestTypingIndicatorConcurrentAddAndClear(t *testing.T) {
}
mgr := NewTypingIndicatorManager(api, fakeTypingCreds{secret: "shh"}, queries, newDiscardLogger())
inst := db.LarkInstallation{AppID: "cli_test", Region: "feishu"}
inst := Installation{AppID: "cli_test", Region: "feishu"}
session := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4}, Valid: true}
done := make(chan struct{})
@@ -4,8 +4,6 @@ import (
"context"
"log/slog"
"time"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// BackfillBotUnionIDs walks every active lark_installation row whose
@@ -38,7 +36,7 @@ import (
// already overwrites both identifiers via UpsertLarkInstallation.
func BackfillBotUnionIDs(
ctx context.Context,
queries *db.Queries,
queries *ChannelStore,
api APIClient,
creds CredentialsDecrypter,
log *slog.Logger,
@@ -100,7 +98,7 @@ func BackfillBotUnionIDs(
missed++
continue
}
if err := queries.SetLarkInstallationBotUnionID(ctx, db.SetLarkInstallationBotUnionIDParams{
if err := queries.SetLarkInstallationBotUnionID(ctx, SetInstallationBotUnionIDParams{
ID: row.ID,
BotUnionID: textOrNull(info.UnionID),
}); err != nil {
@@ -129,5 +127,5 @@ func BackfillBotUnionIDs(
// with a stub that returns canned plaintext without spinning up the
// secretbox machinery.
type CredentialsDecrypter interface {
DecryptAppSecret(inst db.LarkInstallation) (string, error)
DecryptAppSecret(inst Installation) (string, error)
}
@@ -11,7 +11,6 @@ import (
"time"
"github.com/gorilla/websocket"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// WSLongConnConnector is the production EventConnector that holds the
@@ -188,7 +187,7 @@ func NewWSLongConnConnector(cfg WSConnectorConfig) (*WSLongConnConnector, error)
// binary Frame envelopes until either the ctx is cancelled or the
// connection errors, and returns. Nil return = clean exit; non-nil
// return = connection failed (Hub steps up backoff).
func (c *WSLongConnConnector) Run(ctx context.Context, inst db.LarkInstallation, emit EventEmitter) error {
func (c *WSLongConnConnector) Run(ctx context.Context, inst Installation, emit EventEmitter) error {
log := c.cfg.Logger.With(
"installation_id", uuidString(inst.ID),
"app_id", inst.AppID,
@@ -501,19 +500,19 @@ type WSEndpoint struct {
// the connection. The decoder receives the JSON payload bytes — the
// outer binary Frame envelope is stripped by the connector.
type FrameDecoder interface {
Decode(payload []byte, inst db.LarkInstallation) (msg InboundMessage, ok bool, err error)
Decode(payload []byte, inst Installation) (msg InboundMessage, ok bool, err error)
}
// CredentialsProvider supplies the plaintext InstallationCredentials a
// connector needs for its EndpointFetcher call.
type CredentialsProvider interface {
Credentials(ctx context.Context, inst db.LarkInstallation) (InstallationCredentials, error)
Credentials(ctx context.Context, inst Installation) (InstallationCredentials, error)
}
// CredentialsProviderFunc adapts a free function.
type CredentialsProviderFunc func(ctx context.Context, inst db.LarkInstallation) (InstallationCredentials, error)
type CredentialsProviderFunc func(ctx context.Context, inst Installation) (InstallationCredentials, error)
func (f CredentialsProviderFunc) Credentials(ctx context.Context, inst db.LarkInstallation) (InstallationCredentials, error) {
func (f CredentialsProviderFunc) Credentials(ctx context.Context, inst Installation) (InstallationCredentials, error) {
return f(ctx, inst)
}
@@ -525,9 +524,9 @@ func (f EndpointFetcherFunc) Endpoint(ctx context.Context, creds InstallationCre
}
// FrameDecoderFunc adapts a plain function to FrameDecoder.
type FrameDecoderFunc func(payload []byte, inst db.LarkInstallation) (InboundMessage, bool, error)
type FrameDecoderFunc func(payload []byte, inst Installation) (InboundMessage, bool, error)
func (f FrameDecoderFunc) Decode(payload []byte, inst db.LarkInstallation) (InboundMessage, bool, error) {
func (f FrameDecoderFunc) Decode(payload []byte, inst Installation) (InboundMessage, bool, error) {
return f(payload, inst)
}
@@ -7,8 +7,6 @@ import (
"sync"
"testing"
"time"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// recordingEnricher captures what the connector hands it and rewrites
@@ -34,7 +32,7 @@ func (e *recordingEnricher) Enrich(ctx context.Context, msg InboundMessage, cred
func TestWSConnectorEnrichesBeforeEmit(t *testing.T) {
t.Parallel()
conn := newFakeWSConn()
decoder := FrameDecoderFunc(func(payload []byte, _ db.LarkInstallation) (InboundMessage, bool, error) {
decoder := FrameDecoderFunc(func(payload []byte, _ Installation) (InboundMessage, bool, error) {
return InboundMessage{
EventID: string(payload),
AppID: "test_app",
@@ -51,7 +49,7 @@ func TestWSConnectorEnrichesBeforeEmit(t *testing.T) {
}),
FrameDecoder: decoder,
Enricher: enr,
CredentialsProvider: CredentialsProviderFunc(func(context.Context, db.LarkInstallation) (InstallationCredentials, error) {
CredentialsProvider: CredentialsProviderFunc(func(context.Context, Installation) (InstallationCredentials, error) {
return InstallationCredentials{AppID: "test_app", AppSecret: "secret"}, nil
}),
PingInterval: time.Hour,
@@ -76,7 +74,7 @@ func TestWSConnectorEnrichesBeforeEmit(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- c.Run(ctx, db.LarkInstallation{AppID: "test_app"}, emit) }()
go func() { done <- c.Run(ctx, Installation{AppID: "test_app"}, emit) }()
pushDataFrame(conn, []byte("evt-1"), "m1")
@@ -15,7 +15,6 @@ import (
"time"
"github.com/gorilla/websocket"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// fakeWSConn is a programmable WSConn driven by tests. ReadMessage
@@ -114,12 +113,12 @@ func (d *fakeWSDialer) DialContext(ctx context.Context, urlStr string, h http.He
func quietConnector(t *testing.T, conn *fakeWSConn, decoder FrameDecoder, pingInterval time.Duration) *WSLongConnConnector {
t.Helper()
c, err := NewWSLongConnConnector(WSConnectorConfig{
Dialer: &fakeWSDialer{conn: conn},
Dialer: &fakeWSDialer{conn: conn},
EndpointFetcher: EndpointFetcherFunc(func(context.Context, InstallationCredentials) (WSEndpoint, error) {
return WSEndpoint{URL: "wss://test/ignored", ServiceID: 7, PingInterval: pingInterval}, nil
}),
FrameDecoder: decoder,
CredentialsProvider: CredentialsProviderFunc(func(context.Context, db.LarkInstallation) (InstallationCredentials, error) {
FrameDecoder: decoder,
CredentialsProvider: CredentialsProviderFunc(func(context.Context, Installation) (InstallationCredentials, error) {
return InstallationCredentials{AppID: "test_app", AppSecret: "secret"}, nil
}),
PingInterval: pingInterval,
@@ -151,7 +150,7 @@ func pushDataFrame(conn *fakeWSConn, payload []byte, messageID string) {
func TestWSConnectorRunReturnsOnCtxCancelEvenWhenReadIsBlocked(t *testing.T) {
t.Parallel()
conn := newFakeWSConn()
decoder := FrameDecoderFunc(func([]byte, db.LarkInstallation) (InboundMessage, bool, error) {
decoder := FrameDecoderFunc(func([]byte, Installation) (InboundMessage, bool, error) {
return InboundMessage{}, false, nil
})
c := quietConnector(t, conn, decoder, 10*time.Millisecond)
@@ -159,7 +158,7 @@ func TestWSConnectorRunReturnsOnCtxCancelEvenWhenReadIsBlocked(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- c.Run(ctx, db.LarkInstallation{AppID: "test_app"}, func(context.Context, InboundMessage) (DispatchResult, error) {
done <- c.Run(ctx, Installation{AppID: "test_app"}, func(context.Context, InboundMessage) (DispatchResult, error) {
t.Errorf("emit unexpectedly called")
return DispatchResult{}, nil
})
@@ -188,7 +187,7 @@ func TestWSConnectorRunReturnsOnCtxCancelEvenWhenReadIsBlocked(t *testing.T) {
func TestWSConnectorEmitsDecodedFramesAndAcks(t *testing.T) {
t.Parallel()
conn := newFakeWSConn()
decoder := FrameDecoderFunc(func(payload []byte, _ db.LarkInstallation) (InboundMessage, bool, error) {
decoder := FrameDecoderFunc(func(payload []byte, _ Installation) (InboundMessage, bool, error) {
if string(payload) == "heartbeat" {
return InboundMessage{}, false, nil
}
@@ -214,7 +213,7 @@ func TestWSConnectorEmitsDecodedFramesAndAcks(t *testing.T) {
done := make(chan error, 1)
go func() {
done <- c.Run(ctx, db.LarkInstallation{AppID: "test_app"}, emit)
done <- c.Run(ctx, Installation{AppID: "test_app"}, emit)
}()
pushDataFrame(conn, []byte("evt-1"), "m1")
@@ -273,7 +272,7 @@ func TestWSConnectorEmitsDecodedFramesAndAcks(t *testing.T) {
func TestWSConnectorRespondsToServerPingWithPong(t *testing.T) {
t.Parallel()
conn := newFakeWSConn()
decoder := FrameDecoderFunc(func([]byte, db.LarkInstallation) (InboundMessage, bool, error) {
decoder := FrameDecoderFunc(func([]byte, Installation) (InboundMessage, bool, error) {
return InboundMessage{}, false, nil
})
c := quietConnector(t, conn, decoder, time.Hour)
@@ -283,7 +282,7 @@ func TestWSConnectorRespondsToServerPingWithPong(t *testing.T) {
done := make(chan error, 1)
go func() {
done <- c.Run(ctx, db.LarkInstallation{AppID: "test_app"}, func(context.Context, InboundMessage) (DispatchResult, error) {
done <- c.Run(ctx, Installation{AppID: "test_app"}, func(context.Context, InboundMessage) (DispatchResult, error) {
return DispatchResult{}, nil
})
}()
@@ -327,7 +326,7 @@ func TestWSConnectorRespondsToServerPingWithPong(t *testing.T) {
func TestWSConnectorEmitInfraErrorSendsNackAndReturns(t *testing.T) {
t.Parallel()
conn := newFakeWSConn()
decoder := FrameDecoderFunc(func([]byte, db.LarkInstallation) (InboundMessage, bool, error) {
decoder := FrameDecoderFunc(func([]byte, Installation) (InboundMessage, bool, error) {
return InboundMessage{EventID: "x"}, true, nil
})
c := quietConnector(t, conn, decoder, time.Hour)
@@ -338,7 +337,7 @@ func TestWSConnectorEmitInfraErrorSendsNackAndReturns(t *testing.T) {
done := make(chan error, 1)
go func() {
done <- c.Run(ctx, db.LarkInstallation{AppID: "test_app"}, func(context.Context, InboundMessage) (DispatchResult, error) {
done <- c.Run(ctx, Installation{AppID: "test_app"}, func(context.Context, InboundMessage) (DispatchResult, error) {
return DispatchResult{}, infra
})
}()
@@ -375,7 +374,7 @@ func TestWSConnectorEmitInfraErrorSendsNackAndReturns(t *testing.T) {
func TestWSConnectorSendsAppLayerPings(t *testing.T) {
t.Parallel()
conn := newFakeWSConn()
decoder := FrameDecoderFunc(func([]byte, db.LarkInstallation) (InboundMessage, bool, error) {
decoder := FrameDecoderFunc(func([]byte, Installation) (InboundMessage, bool, error) {
return InboundMessage{}, false, nil
})
c := quietConnector(t, conn, decoder, 10*time.Millisecond)
@@ -385,7 +384,7 @@ func TestWSConnectorSendsAppLayerPings(t *testing.T) {
done := make(chan error, 1)
go func() {
done <- c.Run(ctx, db.LarkInstallation{AppID: "test_app"}, func(context.Context, InboundMessage) (DispatchResult, error) {
done <- c.Run(ctx, Installation{AppID: "test_app"}, func(context.Context, InboundMessage) (DispatchResult, error) {
return DispatchResult{}, nil
})
}()
@@ -421,7 +420,7 @@ func TestWSConnectorDecoderErrorAcksAndContinues(t *testing.T) {
t.Parallel()
conn := newFakeWSConn()
decodeCount := int32(0)
decoder := FrameDecoderFunc(func(payload []byte, _ db.LarkInstallation) (InboundMessage, bool, error) {
decoder := FrameDecoderFunc(func(payload []byte, _ Installation) (InboundMessage, bool, error) {
n := atomic.AddInt32(&decodeCount, 1)
if n == 1 {
return InboundMessage{}, false, errors.New("synthetic decode failure")
@@ -440,7 +439,7 @@ func TestWSConnectorDecoderErrorAcksAndContinues(t *testing.T) {
defer cancel()
done := make(chan error, 1)
go func() {
done <- c.Run(ctx, db.LarkInstallation{AppID: "test_app"}, emit)
done <- c.Run(ctx, Installation{AppID: "test_app"}, emit)
}()
pushDataFrame(conn, []byte("bad"), "mb")
@@ -462,7 +461,7 @@ func TestWSConnectorDecoderErrorAcksAndContinues(t *testing.T) {
func TestWSConnectorReadErrorReturnsToHub(t *testing.T) {
t.Parallel()
conn := newFakeWSConn()
decoder := FrameDecoderFunc(func([]byte, db.LarkInstallation) (InboundMessage, bool, error) {
decoder := FrameDecoderFunc(func([]byte, Installation) (InboundMessage, bool, error) {
return InboundMessage{}, false, nil
})
c := quietConnector(t, conn, decoder, time.Hour)
@@ -470,7 +469,7 @@ func TestWSConnectorReadErrorReturnsToHub(t *testing.T) {
ctx := context.Background()
done := make(chan error, 1)
go func() {
done <- c.Run(ctx, db.LarkInstallation{AppID: "test_app"}, func(context.Context, InboundMessage) (DispatchResult, error) {
done <- c.Run(ctx, Installation{AppID: "test_app"}, func(context.Context, InboundMessage) (DispatchResult, error) {
return DispatchResult{}, nil
})
}()
@@ -497,24 +496,30 @@ func TestWSConnectorRequiresAllDeps(t *testing.T) {
cfg WSConnectorConfig
}{
{"no dialer", WSConnectorConfig{
EndpointFetcher: EndpointFetcherFunc(func(context.Context, InstallationCredentials) (WSEndpoint, error) { return WSEndpoint{}, nil }),
FrameDecoder: FrameDecoderFunc(func([]byte, db.LarkInstallation) (InboundMessage, bool, error) { return InboundMessage{}, false, nil }),
CredentialsProvider: CredentialsProviderFunc(func(context.Context, db.LarkInstallation) (InstallationCredentials, error) { return InstallationCredentials{}, nil }),
EndpointFetcher: EndpointFetcherFunc(func(context.Context, InstallationCredentials) (WSEndpoint, error) { return WSEndpoint{}, nil }),
FrameDecoder: FrameDecoderFunc(func([]byte, Installation) (InboundMessage, bool, error) { return InboundMessage{}, false, nil }),
CredentialsProvider: CredentialsProviderFunc(func(context.Context, Installation) (InstallationCredentials, error) {
return InstallationCredentials{}, nil
}),
}},
{"no endpoint fetcher", WSConnectorConfig{
Dialer: &fakeWSDialer{},
FrameDecoder: FrameDecoderFunc(func([]byte, db.LarkInstallation) (InboundMessage, bool, error) { return InboundMessage{}, false, nil }),
CredentialsProvider: CredentialsProviderFunc(func(context.Context, db.LarkInstallation) (InstallationCredentials, error) { return InstallationCredentials{}, nil }),
Dialer: &fakeWSDialer{},
FrameDecoder: FrameDecoderFunc(func([]byte, Installation) (InboundMessage, bool, error) { return InboundMessage{}, false, nil }),
CredentialsProvider: CredentialsProviderFunc(func(context.Context, Installation) (InstallationCredentials, error) {
return InstallationCredentials{}, nil
}),
}},
{"no decoder", WSConnectorConfig{
Dialer: &fakeWSDialer{},
EndpointFetcher: EndpointFetcherFunc(func(context.Context, InstallationCredentials) (WSEndpoint, error) { return WSEndpoint{}, nil }),
CredentialsProvider: CredentialsProviderFunc(func(context.Context, db.LarkInstallation) (InstallationCredentials, error) { return InstallationCredentials{}, nil }),
Dialer: &fakeWSDialer{},
EndpointFetcher: EndpointFetcherFunc(func(context.Context, InstallationCredentials) (WSEndpoint, error) { return WSEndpoint{}, nil }),
CredentialsProvider: CredentialsProviderFunc(func(context.Context, Installation) (InstallationCredentials, error) {
return InstallationCredentials{}, nil
}),
}},
{"no credentials provider", WSConnectorConfig{
Dialer: &fakeWSDialer{},
EndpointFetcher: EndpointFetcherFunc(func(context.Context, InstallationCredentials) (WSEndpoint, error) { return WSEndpoint{}, nil }),
FrameDecoder: FrameDecoderFunc(func([]byte, db.LarkInstallation) (InboundMessage, bool, error) { return InboundMessage{}, false, nil }),
FrameDecoder: FrameDecoderFunc(func([]byte, Installation) (InboundMessage, bool, error) { return InboundMessage{}, false, nil }),
}},
}
for _, tc := range cases {
@@ -532,10 +537,12 @@ func TestWSConnectorDialErrorIsReturned(t *testing.T) {
t.Parallel()
dialErr := errors.New("dial blew up")
c, err := NewWSLongConnConnector(WSConnectorConfig{
Dialer: &fakeWSDialer{dialErr: dialErr},
EndpointFetcher: EndpointFetcherFunc(func(context.Context, InstallationCredentials) (WSEndpoint, error) { return WSEndpoint{URL: "wss://x", ServiceID: 1}, nil }),
FrameDecoder: FrameDecoderFunc(func([]byte, db.LarkInstallation) (InboundMessage, bool, error) { return InboundMessage{}, false, nil }),
CredentialsProvider: CredentialsProviderFunc(func(context.Context, db.LarkInstallation) (InstallationCredentials, error) {
Dialer: &fakeWSDialer{dialErr: dialErr},
EndpointFetcher: EndpointFetcherFunc(func(context.Context, InstallationCredentials) (WSEndpoint, error) {
return WSEndpoint{URL: "wss://x", ServiceID: 1}, nil
}),
FrameDecoder: FrameDecoderFunc(func([]byte, Installation) (InboundMessage, bool, error) { return InboundMessage{}, false, nil }),
CredentialsProvider: CredentialsProviderFunc(func(context.Context, Installation) (InstallationCredentials, error) {
return InstallationCredentials{AppID: "a"}, nil
}),
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
@@ -543,7 +550,7 @@ func TestWSConnectorDialErrorIsReturned(t *testing.T) {
if err != nil {
t.Fatalf("constructor: %v", err)
}
err = c.Run(context.Background(), db.LarkInstallation{}, func(context.Context, InboundMessage) (DispatchResult, error) {
err = c.Run(context.Background(), Installation{}, func(context.Context, InboundMessage) (DispatchResult, error) {
return DispatchResult{}, nil
})
if err == nil || !errors.Is(err, dialErr) {
@@ -584,7 +591,7 @@ func TestWSConnectorReassemblesChunkedDataFrame(t *testing.T) {
conn := newFakeWSConn()
var decodedPayloads [][]byte
var decodeMu sync.Mutex
decoder := FrameDecoderFunc(func(payload []byte, _ db.LarkInstallation) (InboundMessage, bool, error) {
decoder := FrameDecoderFunc(func(payload []byte, _ Installation) (InboundMessage, bool, error) {
decodeMu.Lock()
decodedPayloads = append(decodedPayloads, append([]byte(nil), payload...))
decodeMu.Unlock()
@@ -602,7 +609,7 @@ func TestWSConnectorReassemblesChunkedDataFrame(t *testing.T) {
defer cancel()
done := make(chan error, 1)
go func() {
done <- c.Run(ctx, db.LarkInstallation{AppID: "test_app"}, emit)
done <- c.Run(ctx, Installation{AppID: "test_app"}, emit)
}()
// Three chunks of a single logical event "ABC".
@@ -720,10 +727,12 @@ func TestWSConnectorCredentialsErrorIsReturned(t *testing.T) {
t.Parallel()
credsErr := errors.New("decrypt failed")
c, err := NewWSLongConnConnector(WSConnectorConfig{
Dialer: &fakeWSDialer{conn: newFakeWSConn()},
EndpointFetcher: EndpointFetcherFunc(func(context.Context, InstallationCredentials) (WSEndpoint, error) { return WSEndpoint{URL: "wss://x"}, nil }),
FrameDecoder: FrameDecoderFunc(func([]byte, db.LarkInstallation) (InboundMessage, bool, error) { return InboundMessage{}, false, nil }),
CredentialsProvider: CredentialsProviderFunc(func(context.Context, db.LarkInstallation) (InstallationCredentials, error) {
Dialer: &fakeWSDialer{conn: newFakeWSConn()},
EndpointFetcher: EndpointFetcherFunc(func(context.Context, InstallationCredentials) (WSEndpoint, error) {
return WSEndpoint{URL: "wss://x"}, nil
}),
FrameDecoder: FrameDecoderFunc(func([]byte, Installation) (InboundMessage, bool, error) { return InboundMessage{}, false, nil }),
CredentialsProvider: CredentialsProviderFunc(func(context.Context, Installation) (InstallationCredentials, error) {
return InstallationCredentials{}, credsErr
}),
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
@@ -731,7 +740,7 @@ func TestWSConnectorCredentialsErrorIsReturned(t *testing.T) {
if err != nil {
t.Fatalf("constructor: %v", err)
}
err = c.Run(context.Background(), db.LarkInstallation{}, func(context.Context, InboundMessage) (DispatchResult, error) {
err = c.Run(context.Background(), Installation{}, func(context.Context, InboundMessage) (DispatchResult, error) {
return DispatchResult{}, nil
})
if err == nil || !errors.Is(err, credsErr) {
@@ -6,8 +6,6 @@ import (
"fmt"
"sort"
"strings"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// LarkJSONFrameDecoder decodes the JSON event payload Lark nests
@@ -36,7 +34,7 @@ type LarkJSONFrameDecoder struct{}
func NewLarkJSONFrameDecoder() *LarkJSONFrameDecoder { return &LarkJSONFrameDecoder{} }
// Decode implements FrameDecoder.
func (d *LarkJSONFrameDecoder) Decode(payload []byte, inst db.LarkInstallation) (InboundMessage, bool, error) {
func (d *LarkJSONFrameDecoder) Decode(payload []byte, inst Installation) (InboundMessage, bool, error) {
if len(payload) == 0 {
return InboundMessage{}, false, nil
}
@@ -5,7 +5,6 @@ import (
"testing"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func TestLarkJSONFrameDecoderTextMessageInP2P(t *testing.T) {
@@ -33,7 +32,7 @@ func TestLarkJSONFrameDecoderTextMessageInP2P(t *testing.T) {
}`)
d := NewLarkJSONFrameDecoder()
msg, ok, err := d.Decode(raw, db.LarkInstallation{BotOpenID: "ou_bot"})
msg, ok, err := d.Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
@@ -79,7 +78,7 @@ func TestLarkJSONFrameDecoderGroupMentionDiscrimination(t *testing.T) {
d := NewLarkJSONFrameDecoder()
t.Run("mentions bot", func(t *testing.T) {
msg, ok, err := d.Decode(mkRaw("ou_bot"), db.LarkInstallation{BotOpenID: "ou_bot"})
msg, ok, err := d.Decode(mkRaw("ou_bot"), Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
@@ -92,7 +91,7 @@ func TestLarkJSONFrameDecoderGroupMentionDiscrimination(t *testing.T) {
})
t.Run("mentions other user", func(t *testing.T) {
msg, ok, err := d.Decode(mkRaw("ou_other"), db.LarkInstallation{BotOpenID: "ou_bot"})
msg, ok, err := d.Decode(mkRaw("ou_other"), Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
@@ -136,7 +135,7 @@ func TestLarkJSONFrameDecoderGroupMentionUnionID(t *testing.T) {
// which is what /bot/v3/info returned), but the union_id is
// the stable identifier we captured at install. The match
// must succeed.
inst := db.LarkInstallation{
inst := Installation{
BotOpenID: "ou_bot_a_canonical",
BotUnionID: pgText("on_bot_a_union"),
}
@@ -155,7 +154,7 @@ func TestLarkJSONFrameDecoderGroupMentionUnionID(t *testing.T) {
// 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 := db.LarkInstallation{
inst := Installation{
BotOpenID: "ou_bot_a_canonical",
BotUnionID: pgText("on_bot_a_union"),
}
@@ -172,7 +171,7 @@ func TestLarkJSONFrameDecoderGroupMentionUnionID(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 := db.LarkInstallation{BotOpenID: "ou_bot_a_canonical"}
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)
@@ -216,7 +215,7 @@ func TestLarkJSONFrameDecoderMentionPlaceholderRewrite(t *testing.T) {
d := NewLarkJSONFrameDecoder()
t.Run("strips bot self-mention via union_id", func(t *testing.T) {
inst := db.LarkInstallation{
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
@@ -231,7 +230,7 @@ func TestLarkJSONFrameDecoderMentionPlaceholderRewrite(t *testing.T) {
})
t.Run("substitutes other-user mention with display name", func(t *testing.T) {
inst := db.LarkInstallation{
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
@@ -253,7 +252,7 @@ func TestLarkJSONFrameDecoderMentionPlaceholderRewrite(t *testing.T) {
// 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 := db.LarkInstallation{
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
@@ -268,7 +267,7 @@ func TestLarkJSONFrameDecoderMentionPlaceholderRewrite(t *testing.T) {
})
t.Run("no mentions leaves body unchanged", func(t *testing.T) {
inst := db.LarkInstallation{
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
@@ -286,7 +285,7 @@ func TestLarkJSONFrameDecoderMentionPlaceholderRewrite(t *testing.T) {
// 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 := db.LarkInstallation{
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
@@ -307,7 +306,7 @@ func TestLarkJSONFrameDecoderMentionPlaceholderRewrite(t *testing.T) {
// participants exposes both `@_user_1` and `@_user_10`. Naive
// ReplaceAll for `@_user_1` would mangle `@_user_10`, so we
// match longest-first.
inst := db.LarkInstallation{
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
@@ -332,7 +331,7 @@ func TestLarkJSONFrameDecoderMentionPlaceholderRewrite(t *testing.T) {
// mention should be stripped; the sibling bot renders as
// @<displayName> so the agent receives a faithful transcript
// of the user intent.
inst := db.LarkInstallation{
inst := Installation{
BotOpenID: "ou_self_canonical",
BotUnionID: pgText("on_self_union"),
}
@@ -356,7 +355,7 @@ func TestLarkJSONFrameDecoderMentionPlaceholderRewrite(t *testing.T) {
// 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 := db.LarkInstallation{
inst := Installation{
BotOpenID: "ou_self_canonical",
BotUnionID: pgText("on_self_union"),
}
@@ -382,7 +381,7 @@ func TestLarkJSONFrameDecoderDropsHeartbeat(t *testing.T) {
[]byte(`{"type":"event_callback","header":{"event_type":"im.message.unknown_kind"}}`),
}
for _, raw := range cases {
msg, ok, err := d.Decode(raw, db.LarkInstallation{})
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)
}
@@ -394,7 +393,7 @@ func TestLarkJSONFrameDecoderDropsHeartbeat(t *testing.T) {
func TestLarkJSONFrameDecoderEmptyRaw(t *testing.T) {
t.Parallel()
msg, ok, err := NewLarkJSONFrameDecoder().Decode(nil, db.LarkInstallation{})
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)
}
@@ -402,7 +401,7 @@ func TestLarkJSONFrameDecoderEmptyRaw(t *testing.T) {
func TestLarkJSONFrameDecoderMalformedReturnsError(t *testing.T) {
t.Parallel()
_, ok, err := NewLarkJSONFrameDecoder().Decode([]byte("not-json"), db.LarkInstallation{})
_, ok, err := NewLarkJSONFrameDecoder().Decode([]byte("not-json"), Installation{})
if err == nil {
t.Fatal("expected error on malformed envelope")
}
@@ -421,7 +420,7 @@ func TestLarkJSONFrameDecoderMessageContentEmptyOnInvalidContentJSON(t *testing.
"message":{"message_id":"m","chat_id":"c","chat_type":"p2p","message_type":"text","content":"not-json"}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, db.LarkInstallation{})
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{})
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
@@ -440,7 +439,7 @@ func TestLarkJSONFrameDecoderNonTextMessageHasEmptyBody(t *testing.T) {
"message":{"message_id":"m","chat_id":"c","chat_type":"p2p","message_type":"image","content":"{\"image_key\":\"img1\"}"}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, db.LarkInstallation{})
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{})
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
@@ -471,7 +470,7 @@ func TestLarkJSONFrameDecoderPostMessageFlattened(t *testing.T) {
"message":{"message_id":"m","chat_id":"c","chat_type":"p2p","message_type":"post","content":` + string(escaped) + `}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, db.LarkInstallation{BotOpenID: "ou_bot"})
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
@@ -510,7 +509,7 @@ func TestLarkJSONFrameDecoderPostResolvesMentions(t *testing.T) {
}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, db.LarkInstallation{BotOpenID: "ou_bot"})
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
@@ -541,7 +540,7 @@ func TestLarkJSONFrameDecoderCapturesReplyLinkage(t *testing.T) {
}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, db.LarkInstallation{BotOpenID: "ou_bot"})
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
@@ -578,7 +577,7 @@ func TestLarkJSONFrameDecoderCapturesThreadID(t *testing.T) {
}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, db.LarkInstallation{BotOpenID: "ou_bot"})
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
@@ -603,7 +602,7 @@ func TestLarkJSONFrameDecoderNonThreadHasEmptyThreadID(t *testing.T) {
}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, db.LarkInstallation{BotOpenID: "ou_bot"})
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
@@ -0,0 +1,10 @@
-- Reverse 124_channel_generalization.up.sql. The lark_* tables were left
-- in place by the up migration, so rolling back only needs to drop the
-- channel_* tables that were added.
DROP TABLE IF EXISTS channel_binding_token;
DROP TABLE IF EXISTS channel_outbound_card_message;
DROP TABLE IF EXISTS channel_inbound_audit;
DROP TABLE IF EXISTS channel_inbound_message_dedup;
DROP TABLE IF EXISTS channel_chat_session_binding;
DROP TABLE IF EXISTS channel_user_binding;
DROP TABLE IF EXISTS channel_installation;
@@ -0,0 +1,318 @@
-- Generalize the Feishu/Lark-specific integration tables into
-- platform-agnostic channel_* tables (MUL-3515, parent MUL-3506). Each
-- lark_* table gains a `channel_type` discriminator and moves its
-- platform-specific identifiers/config into a JSONB `config` column; the
-- cross-platform columns stay flat. Existing Feishu rows are backfilled
-- with channel_type='feishu'.
--
-- Two hard rules from the design:
--
-- * NO foreign keys and NO cascades (MUL-3515 §4). The lark_* tables
-- leaned on composite FKs to enforce "a binding's workspace matches
-- its installation" and "a binding dies when workspace membership is
-- revoked / a chat_session is deleted". Those integrity rules now
-- live in the application layer (the cutover PR adds the membership
-- check + cleanup). The columns are kept so the app can still join,
-- but the database enforces nothing.
--
-- * The lark_* tables are NOT dropped here — that happens in a later
-- migration once the Go cutover has landed, so this migration can
-- ship green on its own. This migration only ADDS channel_* and
-- copies the data forward.
--
-- * ROLLOUT. This backfill is a one-time copy and the Lark hub is an
-- in-process goroutine, so a cutover has TWO independent invariants:
-- (a) channel_* must exist BEFORE any new (channel_*) build serves the
-- HTTP paths that touch it — installation list/install/revoke,
-- chat-session delete, member revoke — or those paths 500.
-- (b) the OLD (lark_*) hub and the NEW (channel_*) hub must never run at
-- once: each would claim the same Feishu bot's WS lease on its own
-- table and open a duplicate connection, double-processing inbound
-- events (duplicate messages / /issue / runs). The two table sets
-- never cross-deduplicate.
--
-- SELF-HOST (Docker Compose / Helm) satisfies both automatically and needs
-- NO flags or manual steps. The backend entrypoint runs `migrate up`
-- before the server starts, so channel_* exists before the new build
-- serves (a); the deployment is single-replica `Recreate`, so the old pod
-- (and its hub) fully stops before the new pod starts (b). A normal
-- version upgrade is a clean cutover. Only a self-host re-tuned to
-- multi-replica RollingUpdate needs the prd procedure below.
--
-- PRD (rolling multica-api, maxUnavailable:0) overlaps old and new pods,
-- so use the MULTICA_LARK_HUB_DISABLED switch (cmd/server/main.go) to park
-- a hub while the API stays up. For a clean, drift-free cutover:
-- 1. Pre-release the hub-park switch on the CURRENT build and set
-- MULTICA_LARK_HUB_DISABLED=true. The old hub stops everywhere (no
-- more lease/dedup/binding/thread writes to lark_*); the API stays up.
-- 2. Run this migration — a clean snapshot, no live hub writing lark_*.
-- 3. Deploy the channel build with the switch still ON. channel_* already
-- exists (step 2), so the new HTTP paths never 500, and the new hub
-- stays parked while old pods drain.
-- 4. Flip MULTICA_LARK_HUB_DISABLED off — the new hub comes up on
-- channel_*. Only the Feishu bot is unavailable across steps 1-4; the
-- API stays up throughout.
-- The earlier "ship new code, THEN migrate after pods drain" order is
-- wrong: it serves channel_* HTTP before channel_* exists, violating (a).
-- Rollback to a pre-cutover build is not lossless once the new hub has
-- written Feishu state into channel_*. See the PR "Deployment / rollout"
-- section for the full runbook (incl. a lower-effort single-deploy variant
-- that trades a small transient drift for one fewer release).
--
-- app_secret_encrypted is BYTEA; it is carried into the JSONB config as a
-- base64 string. PostgreSQL's encode(...,'base64') MIME-wraps the output
-- with a newline every 76 chars, and a secretbox-sealed app secret (~72
-- bytes) exceeds that, so we strip the newlines: Go's encoding/json decodes
-- a base64 string into a []byte field with base64.StdEncoding, which rejects
-- embedded newlines. Stripping keeps the bytea -> JSON -> []byte round-trip
-- symmetric (the Go writer emits unwrapped base64 too) and the ciphertext is
-- never stored in plaintext.
-- =====================
-- channel_installation
-- =====================
CREATE TABLE channel_installation (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
agent_id UUID NOT NULL,
channel_type TEXT NOT NULL,
-- Platform-specific identifiers/config. For feishu:
-- app_id, app_secret_encrypted (base64), tenant_key, bot_open_id,
-- bot_union_id, region.
config JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'revoked')),
ws_lease_token TEXT,
ws_lease_expires_at TIMESTAMPTZ,
installer_user_id UUID NOT NULL,
installed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- One installation per (agent, channel_type): an agent may connect more
-- than one IM at once (feishu + slack + ...), but only one of each kind.
-- The old lark_installation had UNIQUE(workspace_id, agent_id) because
-- feishu was the only channel; with a channel_type discriminator the
-- natural generalization adds it to the key. In the current feishu-only
-- world this is behaviorally identical (one row per agent). If the
-- product later wants "one agent, at most one IM regardless of type",
-- that is an application-layer rule (MUL-3515 §4), not a DB constraint.
UNIQUE (workspace_id, agent_id, channel_type)
);
CREATE INDEX idx_channel_installation_workspace ON channel_installation(workspace_id);
CREATE INDEX idx_channel_installation_agent ON channel_installation(agent_id);
CREATE INDEX idx_channel_installation_lease ON channel_installation(ws_lease_expires_at)
WHERE status = 'active';
-- Routing key. Inbound events carry only the platform app identifier
-- (Feishu app_id); the dispatcher routes on (channel_type, app_id). The
-- functional unique index replaces the old global UNIQUE(app_id) and is
-- scoped per channel_type. Rows without an app_id (a future channel that
-- routes differently) store JSON null here, and Postgres allows many
-- NULLs in a unique index, so they do not collide.
CREATE UNIQUE INDEX idx_channel_installation_type_appid
ON channel_installation(channel_type, (config ->> 'app_id'));
INSERT INTO channel_installation (
id, workspace_id, agent_id, channel_type, config, status,
ws_lease_token, ws_lease_expires_at, installer_user_id,
installed_at, created_at, updated_at
)
SELECT
id, workspace_id, agent_id, 'feishu',
jsonb_strip_nulls(jsonb_build_object(
'app_id', app_id,
'app_secret_encrypted', replace(encode(app_secret_encrypted, 'base64'), E'\n', ''),
'tenant_key', tenant_key,
'bot_open_id', bot_open_id,
'bot_union_id', bot_union_id,
'region', region
)),
status, ws_lease_token, ws_lease_expires_at, installer_user_id,
installed_at, created_at, updated_at
FROM lark_installation;
-- =====================
-- channel_user_binding
-- =====================
-- channel_user_id is the platform-native, per-installation user id
-- (Feishu open_id). union_id and any other secondary identity goes in
-- config. The member-FK that used to make a row's existence proof of
-- workspace membership is gone; the cutover PR validates membership in
-- the identity check and prunes bindings on member removal.
CREATE TABLE channel_user_binding (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
multica_user_id UUID NOT NULL,
installation_id UUID NOT NULL,
channel_type TEXT NOT NULL,
channel_user_id TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
bound_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (installation_id, channel_user_id)
);
CREATE INDEX idx_channel_user_binding_user
ON channel_user_binding(multica_user_id, workspace_id);
CREATE INDEX idx_channel_user_binding_workspace_user
ON channel_user_binding(workspace_id, channel_user_id);
INSERT INTO channel_user_binding (
id, workspace_id, multica_user_id, installation_id,
channel_type, channel_user_id, config, bound_at
)
SELECT
id, workspace_id, multica_user_id, installation_id,
'feishu', lark_open_id,
jsonb_strip_nulls(jsonb_build_object('union_id', union_id)),
bound_at
FROM lark_user_binding;
-- =====================
-- channel_chat_session_binding
-- =====================
CREATE TABLE channel_chat_session_binding (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_session_id UUID NOT NULL,
installation_id UUID NOT NULL,
channel_type TEXT NOT NULL,
channel_chat_id TEXT NOT NULL,
chat_type TEXT NOT NULL
CHECK (chat_type IN ('p2p', 'group')),
-- Most-recent inbound trigger, so the decoupled outbound patcher can
-- thread its reply back into the originating topic. Nullable; a NULL
-- thread id keeps the chat-level send path.
last_message_id TEXT,
last_thread_id TEXT,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (installation_id, channel_chat_id),
UNIQUE (chat_session_id)
);
CREATE INDEX idx_channel_chat_session_binding_session
ON channel_chat_session_binding(chat_session_id);
INSERT INTO channel_chat_session_binding (
id, chat_session_id, installation_id, channel_type,
channel_chat_id, chat_type, last_message_id, last_thread_id, created_at
)
SELECT
id, chat_session_id, installation_id, 'feishu',
lark_chat_id, lark_chat_type, last_lark_message_id, last_lark_thread_id, created_at
FROM lark_chat_session_binding;
-- =====================
-- channel_inbound_message_dedup
-- =====================
-- Two-phase idempotency with owner fencing, unchanged in shape from
-- lark_inbound_message_dedup (keyed per installation + message). Transient
-- 24h cache; copied forward for completeness.
CREATE TABLE channel_inbound_message_dedup (
installation_id UUID NOT NULL,
message_id TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
claim_token UUID NOT NULL DEFAULT gen_random_uuid(),
PRIMARY KEY (installation_id, message_id)
);
CREATE INDEX idx_channel_inbound_dedup_received
ON channel_inbound_message_dedup(received_at);
INSERT INTO channel_inbound_message_dedup (
installation_id, message_id, received_at, processed_at, claim_token
)
SELECT installation_id, message_id, received_at, processed_at, claim_token
FROM lark_inbound_message_dedup;
-- =====================
-- channel_inbound_audit
-- =====================
-- Non-content drop audit. installation_id is nullable (the old ON DELETE
-- SET NULL is now just a nullable column the app may leave NULL).
CREATE TABLE channel_inbound_audit (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
installation_id UUID,
channel_type TEXT NOT NULL,
channel_chat_id TEXT,
event_type TEXT NOT NULL,
channel_event_id TEXT,
channel_message_id TEXT,
drop_reason TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_channel_inbound_audit_installation
ON channel_inbound_audit(installation_id, received_at DESC);
CREATE INDEX idx_channel_inbound_audit_reason
ON channel_inbound_audit(drop_reason, received_at DESC);
INSERT INTO channel_inbound_audit (
id, installation_id, channel_type, channel_chat_id, event_type,
channel_event_id, channel_message_id, drop_reason, received_at
)
SELECT
id, installation_id, 'feishu', lark_chat_id, event_type,
lark_event_id, lark_message_id, drop_reason, received_at
FROM lark_inbound_audit;
-- =====================
-- channel_outbound_card_message
-- =====================
CREATE TABLE channel_outbound_card_message (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_session_id UUID NOT NULL,
task_id UUID,
channel_type TEXT NOT NULL,
channel_chat_id TEXT NOT NULL,
channel_card_message_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'streaming', 'final', 'error')),
last_patched_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_channel_outbound_card_task
ON channel_outbound_card_message(task_id)
WHERE task_id IS NOT NULL;
CREATE INDEX idx_channel_outbound_card_session
ON channel_outbound_card_message(chat_session_id, created_at DESC);
INSERT INTO channel_outbound_card_message (
id, chat_session_id, task_id, channel_type, channel_chat_id,
channel_card_message_id, status, last_patched_at, created_at
)
SELECT
id, chat_session_id, task_id, 'feishu', lark_chat_id,
lark_card_message_id, status, last_patched_at, created_at
FROM lark_outbound_card_message;
-- =====================
-- channel_binding_token
-- =====================
CREATE TABLE channel_binding_token (
token_hash TEXT PRIMARY KEY,
workspace_id UUID NOT NULL,
installation_id UUID NOT NULL,
channel_type TEXT NOT NULL,
channel_user_id TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Keep the product TTL cap in lockstep with channel.BindingTokenTTL
-- (15 minutes), same as the old lark_binding_token CHECK.
CONSTRAINT channel_binding_token_ttl_cap
CHECK (expires_at <= created_at + INTERVAL '15 minutes')
);
CREATE INDEX idx_channel_binding_token_installation
ON channel_binding_token(installation_id, expires_at);
INSERT INTO channel_binding_token (
token_hash, workspace_id, installation_id, channel_type,
channel_user_id, expires_at, consumed_at, created_at
)
SELECT
token_hash, workspace_id, installation_id, 'feishu',
lark_open_id, expires_at, consumed_at, created_at
FROM lark_binding_token;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+82
View File
@@ -180,6 +180,88 @@ type AutopilotTrigger struct {
EventFilters []byte `json:"event_filters"`
}
type ChannelBindingToken struct {
TokenHash string `json:"token_hash"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
InstallationID pgtype.UUID `json:"installation_id"`
ChannelType string `json:"channel_type"`
ChannelUserID string `json:"channel_user_id"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
ConsumedAt pgtype.Timestamptz `json:"consumed_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type ChannelChatSessionBinding struct {
ID pgtype.UUID `json:"id"`
ChatSessionID pgtype.UUID `json:"chat_session_id"`
InstallationID pgtype.UUID `json:"installation_id"`
ChannelType string `json:"channel_type"`
ChannelChatID string `json:"channel_chat_id"`
ChatType string `json:"chat_type"`
LastMessageID pgtype.Text `json:"last_message_id"`
LastThreadID pgtype.Text `json:"last_thread_id"`
Config []byte `json:"config"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type ChannelInboundAudit struct {
ID pgtype.UUID `json:"id"`
InstallationID pgtype.UUID `json:"installation_id"`
ChannelType string `json:"channel_type"`
ChannelChatID pgtype.Text `json:"channel_chat_id"`
EventType string `json:"event_type"`
ChannelEventID pgtype.Text `json:"channel_event_id"`
ChannelMessageID pgtype.Text `json:"channel_message_id"`
DropReason string `json:"drop_reason"`
ReceivedAt pgtype.Timestamptz `json:"received_at"`
}
type ChannelInboundMessageDedup struct {
InstallationID pgtype.UUID `json:"installation_id"`
MessageID string `json:"message_id"`
ReceivedAt pgtype.Timestamptz `json:"received_at"`
ProcessedAt pgtype.Timestamptz `json:"processed_at"`
ClaimToken pgtype.UUID `json:"claim_token"`
}
type ChannelInstallation struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
AgentID pgtype.UUID `json:"agent_id"`
ChannelType string `json:"channel_type"`
Config []byte `json:"config"`
Status string `json:"status"`
WsLeaseToken pgtype.Text `json:"ws_lease_token"`
WsLeaseExpiresAt pgtype.Timestamptz `json:"ws_lease_expires_at"`
InstallerUserID pgtype.UUID `json:"installer_user_id"`
InstalledAt pgtype.Timestamptz `json:"installed_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type ChannelOutboundCardMessage struct {
ID pgtype.UUID `json:"id"`
ChatSessionID pgtype.UUID `json:"chat_session_id"`
TaskID pgtype.UUID `json:"task_id"`
ChannelType string `json:"channel_type"`
ChannelChatID string `json:"channel_chat_id"`
ChannelCardMessageID string `json:"channel_card_message_id"`
Status string `json:"status"`
LastPatchedAt pgtype.Timestamptz `json:"last_patched_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type ChannelUserBinding struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
MulticaUserID pgtype.UUID `json:"multica_user_id"`
InstallationID pgtype.UUID `json:"installation_id"`
ChannelType string `json:"channel_type"`
ChannelUserID string `json:"channel_user_id"`
Config []byte `json:"config"`
BoundAt pgtype.Timestamptz `json:"bound_at"`
}
type ChatMessage struct {
ID pgtype.UUID `json:"id"`
ChatSessionID pgtype.UUID `json:"chat_session_id"`
+355
View File
@@ -0,0 +1,355 @@
-- Platform-agnostic inbound channel queries (MUL-3515). These operate on
-- the channel_* tables created in migration 124. Each installation carries
-- a `channel_type` discriminator and a JSONB `config` blob for
-- platform-specific identifiers/credentials; the cross-platform columns
-- stay flat. The Go layer owns building/parsing config — these queries
-- treat it as opaque JSON except for the routing index on config->>'app_id'.
--
-- No foreign keys exist on these tables (MUL-3515 §4): the integrity the
-- old composite FKs enforced (binding workspace matches installation;
-- binding dies with membership / chat_session) is maintained in the
-- application layer via the membership check in the inbound identity step
-- and the *DeleteChannel*BindingsBy* cleanup queries below.
-- =====================
-- channel_installation
-- =====================
-- name: UpsertChannelInstallation :one
-- Install / re-install path. `config` is the opaque per-channel JSONB the
-- Go layer assembles (for feishu: app_id, app_secret_encrypted, tenant_key,
-- bot_open_id, bot_union_id, region). Re-installing the same agent on the
-- same channel_type replaces the whole config and forces status back to
-- 'active'. The conflict key is (workspace_id, agent_id, channel_type) so an
-- agent may hold one installation per channel_type (feishu + slack + ...)
-- without one install clobbering another. The WS lease is intentionally NOT
-- reset here — the inbound hub owns lease lifecycle.
INSERT INTO channel_installation (
workspace_id, agent_id, channel_type, config, installer_user_id
) VALUES (
$1, $2, $3, $4, $5
)
ON CONFLICT (workspace_id, agent_id, channel_type) DO UPDATE SET
channel_type = EXCLUDED.channel_type,
config = EXCLUDED.config,
installer_user_id = EXCLUDED.installer_user_id,
status = 'active',
installed_at = now(),
updated_at = now()
RETURNING *;
-- name: GetChannelInstallation :one
-- Scoped by channel_type: a per-channel caller (e.g. the Feishu store)
-- must never resolve another channel's installation by guessing its UUID.
SELECT * FROM channel_installation
WHERE id = sqlc.arg('id') AND channel_type = sqlc.arg('channel_type');
-- name: GetChannelInstallationInWorkspace :one
SELECT * FROM channel_installation
WHERE id = sqlc.arg('id')
AND workspace_id = sqlc.arg('workspace_id')
AND channel_type = sqlc.arg('channel_type');
-- name: GetChannelInstallationByAppID :one
-- Inbound routing. The platform event carries only the channel's app
-- identifier (Feishu app_id); the dispatcher's installation resolver routes
-- on (channel_type, config->>'app_id'). Backed by the functional unique
-- index idx_channel_installation_type_appid.
--
-- Both params are named + explicitly typed: `config ->> 'app_id'` makes sqlc
-- attribute a bare `$2` to the JSONB `config` column (it would emit
-- `Config []byte`), so we pin the app_id arg to ::text to get AppID string.
SELECT * FROM channel_installation
WHERE channel_type = sqlc.arg('channel_type')
AND config ->> 'app_id' = sqlc.arg('app_id')::text;
-- name: ListChannelInstallationsByWorkspace :many
-- Scoped by channel_type so a per-channel management surface (e.g. the Lark
-- installation list) only ever sees its own platform's installations.
SELECT * FROM channel_installation
WHERE workspace_id = sqlc.arg('workspace_id')
AND channel_type = sqlc.arg('channel_type')
ORDER BY created_at ASC;
-- name: ListActiveChannelInstallations :many
-- Boot path for a per-channel-type inbound hub: every active installation of
-- the given channel_type, so a hub claims leases and opens connections only
-- for its own platform and never supervises another channel's installation.
--
-- The JOINs require the owning workspace and agent rows to still exist.
-- channel_installation has no FK (MUL-3515 §4), so unlike the old
-- lark_installation (which cascaded away on workspace/agent deletion) an
-- installation can be orphaned when its workspace is deleted or its agent is
-- hard-deleted (e.g. runtime teardown). Without this guard the hub would keep
-- opening a WebSocket for a bot whose workspace/agent is gone. The JOIN matches
-- the old ON DELETE CASCADE semantics: it filters on row existence, not agent
-- archival, so an archived-but-present agent's installation is still listed.
SELECT ci.* FROM channel_installation ci
JOIN workspace w ON w.id = ci.workspace_id
JOIN agent a ON a.id = ci.agent_id
WHERE ci.status = 'active'
AND ci.channel_type = sqlc.arg('channel_type')
ORDER BY ci.created_at ASC;
-- name: SetChannelInstallationStatus :exec
UPDATE channel_installation
SET status = $2, updated_at = now()
WHERE id = $1;
-- name: SetChannelInstallationConfig :exec
-- Replaces the whole config blob for one installation. Used by the
-- operator backfills (e.g. setting a freshly-fetched bot_union_id) that
-- read-modify-write the JSON in Go and persist it back atomically by id.
UPDATE channel_installation
SET config = $2, updated_at = now()
WHERE id = $1;
-- name: BackfillChannelInstallationRegionToFeishuLark :execrows
-- Operator repair, feishu-only: flip every feishu installation still
-- carrying region='feishu' to 'lark'. Called only on deployments whose
-- legacy global base-URL override pointed at Lark international. Idempotent.
UPDATE channel_installation
SET config = jsonb_set(config, '{region}', '"lark"'),
updated_at = now()
WHERE channel_type = 'feishu'
AND config ->> 'region' = 'feishu';
-- name: AcquireChannelWSLease :one
-- Atomically claims the WebSocket lease. CAS predicate accepts when no
-- holder exists, the holder expired, or the holder is us (renewal).
UPDATE channel_installation
SET ws_lease_token = sqlc.arg('new_token'),
ws_lease_expires_at = sqlc.arg('new_expires_at'),
updated_at = now()
WHERE id = sqlc.arg('id')
AND status = 'active'
AND (
ws_lease_token IS NULL
OR ws_lease_expires_at < now()
OR ws_lease_token = sqlc.arg('new_token')
)
RETURNING *;
-- name: ReleaseChannelWSLease :exec
-- Drops the lease iff we are still the holder.
UPDATE channel_installation
SET ws_lease_token = NULL,
ws_lease_expires_at = NULL,
updated_at = now()
WHERE id = $1
AND ws_lease_token = sqlc.arg('current_token');
-- =====================
-- channel_user_binding
-- =====================
-- name: CreateChannelUserBinding :one
-- Records that a platform user id (per-installation; Feishu open_id) maps
-- to a Multica user. The old composite member-FK is gone, so this no
-- longer fails when the redeemer is not a workspace member — the caller
-- (BindingTokenService.RedeemAndBind) validates membership explicitly
-- before calling. ON CONFLICT DO UPDATE is still gated on multica_user_id
-- matching, so a second redeemer cannot steal an already-bound user id;
-- a cross-user conflict updates zero rows and the caller maps that to
-- ErrBindingAlreadyAssigned. config carries secondary identity (union_id).
INSERT INTO channel_user_binding (
workspace_id, multica_user_id, installation_id,
channel_type, channel_user_id, config
) VALUES (
$1, $2, $3, $4, $5, $6
)
ON CONFLICT (installation_id, channel_user_id) DO UPDATE SET
-- jsonb_strip_nulls(EXCLUDED.config) preserves the old lark semantics
-- `union_id = COALESCE(EXCLUDED.union_id, lark_user_binding.union_id)`:
-- a re-bind that carries `{"union_id": null}` (or omits the key) must NOT
-- erase a union_id we already captured. Only non-null incoming keys win.
config = channel_user_binding.config || jsonb_strip_nulls(EXCLUDED.config),
bound_at = now()
WHERE channel_user_binding.multica_user_id = EXCLUDED.multica_user_id
RETURNING *;
-- name: GetChannelUserBindingByUserID :one
-- The inbound identity lookup: does this platform user id map to a Multica
-- user for this installation? With the member-FK removed, a row's
-- existence no longer proves current workspace membership — the dispatcher
-- re-checks membership after this lookup.
SELECT * FROM channel_user_binding
WHERE installation_id = $1 AND channel_user_id = $2;
-- name: DeleteChannelUserBindingsByWorkspaceMember :exec
-- Application-layer integrity (replaces the old member-FK ON DELETE
-- CASCADE): prune every binding for a user who has been removed from a
-- workspace, across all installations in that workspace.
DELETE FROM channel_user_binding
WHERE workspace_id = $1 AND multica_user_id = $2;
-- =====================
-- channel_chat_session_binding
-- =====================
-- name: CreateChannelChatSessionBinding :one
INSERT INTO channel_chat_session_binding (
chat_session_id, installation_id, channel_type, channel_chat_id, chat_type
) VALUES (
$1, $2, $3, $4, $5
)
RETURNING *;
-- name: GetChannelChatSessionBinding :one
-- Lookup-by-channel-chat: the inbound dispatcher finds the existing
-- chat_session before deciding whether to create one.
SELECT * FROM channel_chat_session_binding
WHERE installation_id = $1 AND channel_chat_id = $2;
-- name: GetChannelChatSessionBindingBySession :one
-- Reverse lookup for the outbound patcher: given a chat_session_id, find
-- its channel binding to know which (installation, chat_id) to send to.
-- Scoped by channel_type so a future non-Feishu binding on the same
-- chat_session is never treated as a Feishu reply target.
SELECT * FROM channel_chat_session_binding
WHERE chat_session_id = sqlc.arg('chat_session_id')
AND channel_type = sqlc.arg('channel_type');
-- name: UpdateChannelChatSessionBindingReplyTarget :exec
-- Records the most recent inbound trigger message + thread so the decoupled
-- outbound patcher can thread its reply back into the originating topic.
UPDATE channel_chat_session_binding
SET last_message_id = sqlc.narg('last_message_id'),
last_thread_id = sqlc.narg('last_thread_id')
WHERE chat_session_id = $1;
-- name: DeleteChannelChatSessionBindingBySession :exec
-- Application-layer integrity (replaces the old chat_session-FK ON DELETE
-- CASCADE): drop the binding when its chat_session is deleted.
DELETE FROM channel_chat_session_binding
WHERE chat_session_id = $1;
-- =====================
-- channel_inbound_message_dedup
-- =====================
-- name: ClaimChannelInboundDedup :one
-- Two-phase idempotency gate with owner fencing. Returns the row when a
-- claim is acquired (fresh insert, or stale-reclaim of an in-flight claim
-- older than 60s); returns no rows when terminal (processed) or actively
-- in-flight. Every claim mints a fresh claim_token; Mark/Release are
-- fenced on it. See the table comment in migration 124 / the lark
-- predecessor for the full invariant set.
INSERT INTO channel_inbound_message_dedup (installation_id, message_id, claim_token)
VALUES ($1, $2, gen_random_uuid())
ON CONFLICT (installation_id, message_id) DO UPDATE
SET received_at = now(),
claim_token = gen_random_uuid()
WHERE channel_inbound_message_dedup.processed_at IS NULL
AND channel_inbound_message_dedup.received_at < now() - INTERVAL '60 seconds'
RETURNING installation_id, message_id, received_at, processed_at, claim_token;
-- name: MarkChannelInboundDedupProcessed :execrows
-- Locks a claim in as permanently processed after a durable outcome.
-- Invoked inside the chat_message tx (via qtx) on the ingest path so the
-- durable write and the Mark commit atomically. Token mismatch returns
-- zero rows (a reclaim happened); the caller rolls back its in-tx write.
UPDATE channel_inbound_message_dedup
SET processed_at = now()
WHERE installation_id = $1
AND message_id = $2
AND claim_token = $3
AND processed_at IS NULL;
-- name: ReleaseChannelInboundDedup :execrows
-- Releases an in-flight claim when an infra error occurred before any
-- durable side effect, so a retry can re-acquire immediately. Fenced on
-- processed_at IS NULL and claim_token.
DELETE FROM channel_inbound_message_dedup
WHERE installation_id = $1
AND message_id = $2
AND claim_token = $3
AND processed_at IS NULL;
-- name: PurgeChannelInboundDedup :exec
-- Vacuum job: remove dedup rows older than the supplied cutoff (e.g. 24h).
DELETE FROM channel_inbound_message_dedup
WHERE received_at < $1;
-- =====================
-- channel_inbound_audit
-- =====================
-- name: RecordChannelInboundDrop :exec
-- The only write path for dropped events. Deliberately carries no body
-- column — only routing / identity / drop_reason / timestamp.
INSERT INTO channel_inbound_audit (
installation_id, channel_type, channel_chat_id, event_type,
channel_event_id, channel_message_id, drop_reason
) VALUES (
sqlc.narg('installation_id'),
$1,
sqlc.narg('channel_chat_id'),
$2,
sqlc.narg('channel_event_id'),
sqlc.narg('channel_message_id'),
$3
);
-- name: ListChannelInboundAuditByInstallation :many
SELECT * FROM channel_inbound_audit
WHERE installation_id = $1
ORDER BY received_at DESC
LIMIT $2 OFFSET $3;
-- =====================
-- channel_outbound_card_message
-- =====================
-- name: CreateChannelOutboundCardMessage :one
INSERT INTO channel_outbound_card_message (
chat_session_id, task_id, channel_type, channel_chat_id,
channel_card_message_id, status
) VALUES (
$1, sqlc.narg('task_id'), $2, $3, $4, $5
)
RETURNING *;
-- name: GetChannelOutboundCardByTask :one
-- The partial unique index on (task_id) WHERE task_id IS NOT NULL
-- guarantees at most one row. Scoped by channel_type so a future non-Feishu
-- card for the same task is not patched as a Feishu card.
SELECT * FROM channel_outbound_card_message
WHERE task_id = sqlc.arg('task_id')
AND channel_type = sqlc.arg('channel_type');
-- name: UpdateChannelOutboundCardStatus :exec
UPDATE channel_outbound_card_message
SET status = $2,
last_patched_at = now()
WHERE id = $1;
-- =====================
-- channel_binding_token
-- =====================
-- name: CreateChannelBindingToken :one
-- Mints a single-use binding token for an unbound platform user. TTL cap
-- (15 min) enforced by the table CHECK in lockstep with
-- channel.BindingTokenTTL. The HASH is stored, never the raw token.
INSERT INTO channel_binding_token (
token_hash, workspace_id, installation_id, channel_type,
channel_user_id, expires_at
) VALUES (
$1, $2, $3, $4, $5, $6
)
RETURNING *;
-- name: ConsumeChannelBindingToken :one
-- Atomic redemption: returns the row only if the hash exists, is
-- unconsumed, and unexpired. Two simultaneous redemptions cannot both win.
UPDATE channel_binding_token
SET consumed_at = now()
WHERE token_hash = $1
AND consumed_at IS NULL
AND expires_at > now()
RETURNING *;
-- name: PurgeExpiredChannelBindingTokens :exec
DELETE FROM channel_binding_token
WHERE expires_at < $1;
-420
View File
@@ -1,420 +0,0 @@
-- Lark (飞书) Bot integration queries. The migration that defines these
-- tables lives at server/migrations/109_lark_integration.up.sql; the
-- architectural boundaries the package enforces on top of them are
-- documented in server/internal/integrations/lark/doc.go.
--
-- Scoping convention: every public-facing read goes through a
-- workspace-scoped variant where one exists. The lookups that take only
-- a UUID PK (e.g. GetLarkInstallation) are reserved for internal trusted
-- callers (the WS lease scanner, the inbound dispatcher after identity
-- resolution); HTTP handlers should prefer the *InWorkspace forms.
-- =====================
-- lark_installation
-- =====================
-- name: CreateLarkInstallation :one
-- Used by the OAuth callback. `app_secret_encrypted` is the ciphertext
-- produced by internal/util/secretbox — never plaintext. The
-- (workspace_id, agent_id) UNIQUE constraint enforces the spec rule
-- "one Multica Agent ↔ one Lark Bot"; re-installing on the same agent
-- goes through UpsertLarkInstallation instead.
INSERT INTO lark_installation (
workspace_id, agent_id, app_id, app_secret_encrypted,
tenant_key, bot_open_id, bot_union_id, installer_user_id
) VALUES (
$1, $2, $3, $4, sqlc.narg('tenant_key'), $5, sqlc.narg('bot_union_id'), $6
)
RETURNING *;
-- name: UpsertLarkInstallation :one
-- Re-install path: a user who already bound this agent to Lark scans
-- the QR again (e.g. they rotated their Lark app secret, or revoked +
-- reinstalled). We refresh the app credentials, bot identity, and
-- installer attribution, and force status back to 'active'. The WS
-- lease is intentionally NOT reset here — the inbound hub owns lease
-- lifecycle.
INSERT INTO lark_installation (
workspace_id, agent_id, app_id, app_secret_encrypted,
tenant_key, bot_open_id, bot_union_id, installer_user_id, region
) VALUES (
$1, $2, $3, $4, sqlc.narg('tenant_key'), $5, sqlc.narg('bot_union_id'), $6, sqlc.arg('region')
)
ON CONFLICT (workspace_id, agent_id) DO UPDATE SET
app_id = EXCLUDED.app_id,
app_secret_encrypted = EXCLUDED.app_secret_encrypted,
tenant_key = EXCLUDED.tenant_key,
bot_open_id = EXCLUDED.bot_open_id,
bot_union_id = EXCLUDED.bot_union_id,
installer_user_id = EXCLUDED.installer_user_id,
region = EXCLUDED.region,
status = 'active',
installed_at = now(),
updated_at = now()
RETURNING *;
-- name: BackfillLarkInstallationRegionToLark :execrows
-- Upgrade repair: flip every installation still carrying the migration-116
-- default ('feishu') to 'lark'. Called ONLY by
-- BackfillRegionFromLegacyOverride, and ONLY when the deployment's global
-- base-URL override pointed at Lark international — on such a deployment the
-- whole integration talked to open.larksuite.com, so every existing install
-- is really Lark and the migration's mainland default mislabels it.
-- Idempotent: once flipped there is nothing left at 'feishu' to update, and
-- new installs already carry the device-flow-detected region.
UPDATE lark_installation
SET region = 'lark',
updated_at = now()
WHERE region = 'feishu';
-- name: SetLarkInstallationBotUnionID :exec
-- Operator-only backfill for installations created before the
-- bot_union_id column existed (migration 112). Production reads do
-- NOT use this — finishSuccess writes union_id during install, and
-- the upsert path writes it on re-install. Kept as a focused single-
-- column UPDATE so the backfill cannot accidentally overwrite app
-- credentials, status, or lease state.
UPDATE lark_installation
SET bot_union_id = $2,
updated_at = now()
WHERE id = $1;
-- name: GetLarkInstallation :one
SELECT * FROM lark_installation WHERE id = $1;
-- name: GetLarkInstallationInWorkspace :one
SELECT * FROM lark_installation
WHERE id = $1 AND workspace_id = $2;
-- name: GetLarkInstallationByAgent :one
SELECT * FROM lark_installation
WHERE workspace_id = $1 AND agent_id = $2;
-- name: GetLarkInstallationByAppID :one
-- Used by the OAuth callback to detect re-install vs first-install,
-- and by the inbound dispatcher to route an event payload (which only
-- carries app_id) to its installation row.
SELECT * FROM lark_installation WHERE app_id = $1;
-- name: ListLarkInstallationsByWorkspace :many
SELECT * FROM lark_installation
WHERE workspace_id = $1
ORDER BY created_at ASC;
-- name: ListActiveLarkInstallations :many
-- Boot path for the WebSocket hub: enumerate every active installation
-- so the hub can claim leases and open long connections. Excludes
-- revoked rows — their WS should already be torn down.
SELECT * FROM lark_installation
WHERE status = 'active'
ORDER BY created_at ASC;
-- name: SetLarkInstallationStatus :exec
UPDATE lark_installation
SET status = $2, updated_at = now()
WHERE id = $1;
-- name: AcquireLarkWSLease :one
-- Atomically claims the WebSocket lease for an installation. The CAS
-- predicate accepts the lease when (a) no current holder exists, (b)
-- the holder's lease has expired, or (c) the holder is us (renewal).
-- Returns the row when the lease was successfully claimed; returns no
-- rows when another live holder still owns it.
UPDATE lark_installation
SET ws_lease_token = sqlc.arg('new_token'),
ws_lease_expires_at = sqlc.arg('new_expires_at'),
updated_at = now()
WHERE id = sqlc.arg('id')
AND status = 'active'
AND (
ws_lease_token IS NULL
OR ws_lease_expires_at < now()
OR ws_lease_token = sqlc.arg('new_token')
)
RETURNING *;
-- name: ReleaseLarkWSLease :exec
-- Drops the lease iff we're still the holder. A racing acquirer that
-- already took over will not have its lease cleared.
UPDATE lark_installation
SET ws_lease_token = NULL,
ws_lease_expires_at = NULL,
updated_at = now()
WHERE id = $1
AND ws_lease_token = sqlc.arg('current_token');
-- =====================
-- lark_user_binding
-- =====================
-- name: CreateLarkUserBinding :one
-- Records that a Lark open_id (per-installation) maps to a Multica
-- user.
--
-- Two structural guarantees:
-- 1. The composite FK to member(workspace_id, user_id) makes this
-- statement fail when the redeemer is not (or no longer) a
-- workspace member — that is §4.3 of the design.
-- 2. ON CONFLICT DO UPDATE is gated on `multica_user_id` matching
-- the existing binding, so a second redeemer holding their own
-- valid binding token CANNOT silently steal an already-bound
-- open_id. If the conflict row points at a different user, the
-- UPDATE is skipped and the statement returns ZERO rows — the
-- caller (lark.BindingTokenService.RedeemAndBind) translates
-- that into ErrBindingAlreadyAssigned.
--
-- The same-user case still updates metadata (union_id refresh,
-- bound_at bump) so an idempotent re-bind by the original user
-- continues to work; only a cross-user re-assignment is rejected.
-- True account changes must go through an explicit unbind flow, not
-- through a binding token.
INSERT INTO lark_user_binding (
workspace_id, multica_user_id, installation_id, lark_open_id, union_id
) VALUES (
$1, $2, $3, $4, sqlc.narg('union_id')
)
ON CONFLICT (installation_id, lark_open_id) DO UPDATE SET
union_id = COALESCE(EXCLUDED.union_id, lark_user_binding.union_id),
bound_at = now()
WHERE lark_user_binding.multica_user_id = EXCLUDED.multica_user_id
RETURNING *;
-- name: GetLarkUserBindingByOpenID :one
-- The inbound identity check. A row here means: this open_id maps to a
-- Multica user who IS currently a workspace member (the composite FK
-- cascades the binding away when membership is revoked, so a row's
-- existence is itself the membership proof).
SELECT * FROM lark_user_binding
WHERE installation_id = $1 AND lark_open_id = $2;
-- name: ListLarkUserBindingsByInstallation :many
SELECT * FROM lark_user_binding
WHERE installation_id = $1
ORDER BY bound_at DESC;
-- name: DeleteLarkUserBinding :exec
DELETE FROM lark_user_binding WHERE id = $1;
-- =====================
-- lark_chat_session_binding
-- =====================
-- name: CreateLarkChatSessionBinding :one
INSERT INTO lark_chat_session_binding (
chat_session_id, installation_id, lark_chat_id, lark_chat_type
) VALUES (
$1, $2, $3, $4
)
RETURNING *;
-- name: GetLarkChatSessionBinding :one
-- Lookup-by-Lark-chat path. Used by the inbound dispatcher to find the
-- existing chat_session before deciding whether to create one. The
-- UNIQUE (installation_id, lark_chat_id) constraint means at most one
-- row matches.
SELECT * FROM lark_chat_session_binding
WHERE installation_id = $1 AND lark_chat_id = $2;
-- name: GetLarkChatSessionBindingBySession :one
-- Reverse lookup: given a chat_session_id, find its Lark binding. Used
-- by the outbound card patcher to know which (installation, chat_id)
-- to PATCH when an agent emits a stream event for this session.
SELECT * FROM lark_chat_session_binding
WHERE chat_session_id = $1;
-- name: UpdateLarkChatSessionBindingReplyTarget :exec
-- Records the most recent inbound trigger message + the Lark topic
-- (thread) it belongs to, so the decoupled outbound patcher can thread
-- its reply back into the originating 话题. Called on every ingested
-- message (in the same tx as the chat_message write). last_lark_thread_id
-- is NULL for non-thread messages, which keeps the outbound on the
-- existing chat-level send path; a non-NULL value flips the next reply
-- into a thread reply targeting last_lark_message_id.
UPDATE lark_chat_session_binding
SET last_lark_message_id = sqlc.narg('last_lark_message_id'),
last_lark_thread_id = sqlc.narg('last_lark_thread_id')
WHERE chat_session_id = $1;
-- =====================
-- lark_inbound_message_dedup
-- =====================
-- name: ClaimLarkInboundDedup :one
-- The two-phase idempotency gate. The dispatcher uses this BEFORE
-- group filter / identity check / chat-session lookup so a WebSocket
-- reconnect that replays an event cannot re-trigger binding prompts,
-- re-write drop audit rows, or re-touch chat_session.
--
-- Returns the row when a claim is acquired:
-- - newly inserted (first delivery of this message_id), OR
-- - re-taken from a stale in-flight claim. A claim is stale when
-- processed_at IS NULL AND received_at is older than 60 seconds —
-- the previous worker crashed or lost its DB connection between
-- claim and finalize, and a retry should be allowed to proceed.
--
-- Returns NO rows (pgx.ErrNoRows) when the claim cannot be acquired:
-- - the row exists with processed_at IS NOT NULL (terminal: prior
-- attempt reached a durable outcome), OR
-- - the row exists with processed_at IS NULL AND received_at within
-- the last 60 seconds (another worker is actively processing).
--
-- Owner fencing: every successful Claim mints a fresh UUID into
-- `claim_token`. The Caller passes that token to MarkLarkInbound-
-- DedupProcessed / ReleaseLarkInboundDedup; mismatched tokens are
-- ignored. A stale-reclaim that re-takes the row ROTATES the token,
-- so the previous (slow but still alive) worker can no longer Mark
-- the row — its same-tx Mark returns zero rows and the chat_message
-- write rolls back. See lark_inbound_message_dedup table comment.
--
-- The dispatcher MUST follow up every successful claim with exactly one
-- of MarkLarkInboundDedupProcessed (durable outcome) or
-- ReleaseLarkInboundDedup (infra failure before durable outcome),
-- supplying the returned claim_token. Otherwise the row sits as an
-- in-flight claim and the next replay attempt must wait for the
-- staleness TTL.
INSERT INTO lark_inbound_message_dedup (installation_id, message_id, claim_token)
VALUES ($1, $2, gen_random_uuid())
ON CONFLICT (installation_id, message_id) DO UPDATE
SET received_at = now(),
claim_token = gen_random_uuid()
WHERE lark_inbound_message_dedup.processed_at IS NULL
AND lark_inbound_message_dedup.received_at < now() - INTERVAL '60 seconds'
RETURNING installation_id, message_id, received_at, processed_at, claim_token;
-- name: MarkLarkInboundDedupProcessed :execrows
-- Locks in a claim as permanently processed. Called by the dispatcher
-- after a durable outcome has been reached:
-- - a drop audit row was persisted (group filter / unbound user /
-- revoked / invalid event), OR
-- - chat_message + chat_session.updated_at were committed (ingest
-- path, including ingest paths that subsequently fail at issue
-- creation / task enqueue — the user-visible message is already in
-- the session).
-- For the chat_message ingest path the dispatcher invokes this query
-- INSIDE the chat_message+session transaction (via qtx), so the
-- durable write and the Mark commit atomically. A token mismatch
-- (another worker has re-claimed the row in the meantime) returns
-- zero rows; the caller treats that as a lost claim and rolls back the
-- in-tx invocation, so no second chat_message is written.
--
-- Guarded by processed_at IS NULL so a successful Mark is itself
-- idempotent: replaying it cannot resurrect a row that was already
-- terminal.
UPDATE lark_inbound_message_dedup
SET processed_at = now()
WHERE installation_id = $1
AND message_id = $2
AND claim_token = $3
AND processed_at IS NULL;
-- name: ReleaseLarkInboundDedup :execrows
-- Releases an in-flight claim. Called by the dispatcher when an infra
-- error occurred BEFORE any durable side effect (e.g. EnsureChatSession
-- or AppendUserMessage returned an error and its transaction rolled
-- back). Deleting the row lets the WS adapter's retry re-acquire the
-- claim immediately, instead of waiting for the 60-second staleness
-- TTL. Guarded by processed_at IS NULL so an out-of-order Release
-- cannot undo a Mark; guarded by claim_token so a slow-but-alive worker
-- whose claim was reclaimed cannot delete the new holder's row.
DELETE FROM lark_inbound_message_dedup
WHERE installation_id = $1
AND message_id = $2
AND claim_token = $3
AND processed_at IS NULL;
-- name: PurgeLarkInboundDedup :exec
-- Removes dedup rows older than the supplied cutoff. The vacuum job
-- (separate cron) calls this with cutoff = now() - INTERVAL '24h'.
-- Sweeps both processed and (very old) abandoned in-flight rows.
DELETE FROM lark_inbound_message_dedup
WHERE received_at < $1;
-- =====================
-- lark_inbound_audit
-- =====================
-- name: RecordLarkInboundDrop :exec
-- The ONLY write path for events that fail identity check or the
-- group-mention filter. Deliberately accepts no body column — the
-- AuditLogger interface in internal/integrations/lark mirrors that
-- shape so a caller cannot accidentally hand a body to this row.
INSERT INTO lark_inbound_audit (
installation_id, lark_chat_id, event_type,
lark_event_id, lark_message_id, drop_reason
) VALUES (
sqlc.narg('installation_id'),
sqlc.narg('lark_chat_id'),
$1,
sqlc.narg('lark_event_id'),
sqlc.narg('lark_message_id'),
$2
);
-- name: ListLarkInboundAuditByInstallation :many
-- Ops debugging view; paged via the (installation_id, received_at) idx.
SELECT * FROM lark_inbound_audit
WHERE installation_id = $1
ORDER BY received_at DESC
LIMIT $2 OFFSET $3;
-- =====================
-- lark_outbound_card_message
-- =====================
-- name: CreateLarkOutboundCardMessage :one
INSERT INTO lark_outbound_card_message (
chat_session_id, task_id, lark_chat_id, lark_card_message_id, status
) VALUES (
$1, sqlc.narg('task_id'), $2, $3, $4
)
RETURNING *;
-- name: GetLarkOutboundCardByTask :one
-- Most card patches arrive keyed by task_id (we're streaming an agent
-- run's output). The partial unique index on (task_id) WHERE task_id IS
-- NOT NULL guarantees this returns at most one row.
SELECT * FROM lark_outbound_card_message
WHERE task_id = $1;
-- name: UpdateLarkOutboundCardStatus :exec
UPDATE lark_outbound_card_message
SET status = $2,
last_patched_at = now()
WHERE id = $1;
-- =====================
-- lark_binding_token
-- =====================
-- name: CreateLarkBindingToken :one
-- Mints a single-use binding token for an unbound Lark user. The TTL
-- cap (`expires_at <= created_at + INTERVAL '15 minutes'`) is enforced
-- by the DB CHECK on the table, in lockstep with lark.BindingTokenTTL.
-- We store the HASH, not the raw token; the raw value is returned to
-- the caller exactly once (in the URL it embeds in the Bot's reply
-- card) and never persisted server-side.
INSERT INTO lark_binding_token (
token_hash, workspace_id, installation_id, lark_open_id, expires_at
) VALUES (
$1, $2, $3, $4, $5
)
RETURNING *;
-- name: ConsumeLarkBindingToken :one
-- Atomic redemption. Returns the row only if (a) the hash exists, (b)
-- it has not been consumed, and (c) it has not expired. The UPDATE +
-- RETURNING pattern guarantees that two simultaneous redemptions of
-- the same token cannot both succeed — exactly one row update wins,
-- the other sees zero rows.
UPDATE lark_binding_token
SET consumed_at = now()
WHERE token_hash = $1
AND consumed_at IS NULL
AND expires_at > now()
RETURNING *;
-- name: PurgeExpiredLarkBindingTokens :exec
-- Tokens are tiny but unbounded over time. The same vacuum cron that
-- handles dedup can sweep these too.
DELETE FROM lark_binding_token
WHERE expires_at < $1;