Files
multica/server/internal/integrations
aa684997f1 MUL-7024 fix(wecom): clear the previous bot's rows when an agent swaps bots (#7985)
* fix(wecom): clear the previous bot's rows when an agent swaps bots

Pointing an agent at a different bot reuses the installation row —
UpsertChannelInstallation conflicts on (workspace_id, agent_id,
channel_type) and only rewrites config — so every dependent row keyed on
installation_id crossed over into a bot it did not belong to.

They are not merely stale. A WeCom aibot userid is anonymized per (bot,
user), which is the premise the whole binding flow rests on, so a
carried-over channel_user_binding holds an id from a namespace the new
bot does not share. Outbound.tryDeliverInbox finds that binding by
(workspace, member, channel_type), resolves the sender by its reused
installation_id — now the LIVE NEW bot — and addresses the OLD bot's
userid over it. A p2p chat binding carries the same userid as its
channel_chat_id, and a queued task delivery carries it again.

ClearChannelInstallationBotScopedRows is the reuse-case counterpart to
the cleanup ReclaimDeadChannelInstallationByAppID already does for the
dead-owner case. It runs in the install transaction, before the upsert,
so a swap that fails later leaves the old bot's rows intact rather than
half-cleared.

The installation row itself, its audit trail and the Chats stay: the row
is the same connection under new credentials, an inbound-audit row still
records something an operator can act on, and a Chat is the user's work
product that outlives whichever bot carried it — deleting the binding
detaches the route, the same shape a disconnect leaves behind.

A same-bot secret rotation is deliberately NOT a swap; clearing there
would silently unbind every member. Both directions are tested.

Closes #6547

* fix(wecom): report what a bot swap threw away, and cover every swept table

Review follow-ups on #6547, none of which change what the sweep deletes.

ClearChannelInstallationBotScopedRows becomes :one and returns a count
per swept table. A queued channel_task_delivery is a RUNNING task's
answer: dropping it is right — its address is the old bot's userid,
unreachable from the new connection either way — but processEvent then
finds no row and returns nil, with no counter and no line of its own, so
the install path is the only place that can say where the answer went.
InstallationService gains a nil-safe logger the way
channel_media_reconciler already has one, and logs the three counts.

The comment now says why the sweep is not airtight. The slot lock is the
NEW bot's; the OLD bot's socket stays live until the Supervisor tears it
down, so a message arriving under it between the DELETE and the COMMIT
re-inserts a binding that outlives the sweep. Short and self-healing —
that binding is the old namespace, so the next delivery fails to address
and the user re-binds — and closing it properly needs a second clear
once the old connection is confirmed down, which this transaction cannot
observe.

The test seeded four of the seven bot-scoped tables, so three deletes
were asserted by the query text alone. It now seeds channel_task_delivery,
channel_outbound_message and channel_outbound_card_message too, and
counts the card table by chat_session_id since it carries no
installation_id. With the swap gate forced off every one of the seven
reports surviving rows; with it on, both directions still pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015xUxTgkvJSCUpBTJDWZTYF

* fix(wecom): count the queued outbound sends a bot swap drops

Review's two nits from the second round.

The log covered three of the seven swept tables, and the argument for
logging channel_task_delivery — a queued answer disappears, processEvent
finds no row and returns nil with no counter and no line — is the same one
for channel_outbound_message. It is likely zero for WeCom in practice, which
is a reason to count it rather than to argue about it: the query returns a
fourth count and the swap line reports it.

The race-window comment said the stray binding is self-healing without
saying what healing looks like from the other side. It is one message that
gets no answer and no line explaining why, until the user re-binds — the
same silence the counts above just fixed one table over.

The counts had no test. They go nowhere but the log — Upsert returns an
Installation, not a report — so the swap test now captures the line through
the service's own logger and asserts a count per swept table. Dropping the
new field from the log fails it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Wv2qRKBY6fCBnhVae7Mj58

---------

Co-authored-by: YeJing <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-16 01:01:31 +08:00
..