mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
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:
co-authored by
multica-agent
J
parent
3103ed1082
commit
ce28d0aa0e
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user