refactor(channels,router): move all policy to router; bridge is transport

Follow-up to b159722. That shrank the bridge's shouldEngage to a flood
gate + coarse sticky-subscribe signal. This completes the move —
policy lives exclusively in the router, the bridge is transport-only,
and the conversations map + ChannelSetup.conversations +
ChannelAdapter.updateConversations are all gone.

Key shifts:

1. Subscribe moves from bridge to router.

   Bridge used to call `thread.subscribe()` from its onNewMention /
   onDirectMessage handlers based on a coarse "any mention-sticky wiring
   exists on this channel" check. That forced the decision before the
   router could apply per-wiring engage logic, and it relied on the
   conversations map being current (staleness risk).

   ChannelAdapter gains `subscribe?(platformId, threadId)`. The Chat
   SDK bridge implements it via SqliteStateAdapter.subscribe(threadId)
   (idempotent — a repeat call on an already-subscribed thread is a
   no-op). The router's fan-out loop calls it once per message when
   the first mention-sticky wiring actually engages. Precise, not
   coarse.

2. Short-circuit the drop path with one combined query.

   New `getMessagingGroupWithAgentCount(channelType, platformId)` does
   the messaging_groups lookup AND counts wirings in a single SELECT,
   using the existing UNIQUE(channel_type, platform_id) index on
   messaging_groups and UNIQUE(messaging_group_id, agent_group_id) on
   messaging_group_agents for the JOIN. No new indexes needed.

   routeInbound now short-circuits:
     - No messaging_groups row AND not addressed (no mention/DM)
       → return silently. One DB read, nothing written. This is the
       Discord-bot-in-a-big-guild case; we no longer auto-create rows
       for every plain message in every channel the bot can see.
     - Messaging group exists but no wirings AND not addressed
       → return silently. One DB read.
     - Otherwise fall through to sender resolution + fan-out as before.

   Behavioral change: plain chatter on unwired channels no longer gets
   dropped_messages audit rows, which used to bloat the table. Audit
   still fires on addressed-to-bot drops where the admin cares
   ("someone @-mentioned us but nobody's wired").

3. Bridge is now purely transport.

   Deleted entirely: ConversationConfig, ChannelSetup.conversations,
   ChannelAdapter.updateConversations?, bridge's `conversations` map,
   buildConversationMap, shouldEngage, EngageSource, engageDecision,
   bridge.updateConversations method, src/index.ts
   buildConversationConfigs. Four handlers reduce to "resolve channel
   id, build InboundMessage with isMention, call onInbound". Net
   ~130 LOC deleted from the bridge.

   Collateral: the conversations-map staleness problem is gone. The
   upcoming channel-registration feature doesn't need any map-refresh
   plumbing — when an approval creates a new wiring, the next message
   hits the DB fresh and just works.

Bridge tests prune to the narrow platform-adjacent surface (openDM
delegation, subscribe presence). Host-core test that asserted the
old "auto-create on every unknown message" behavior updates to
reflect the new escalation-gated semantics: plain messages on
unknown channels don't auto-create, mentions do.

159 tests pass (was 172 — net -13, almost entirely from
bridge-engage-mode tests that covered logic now owned by the router
and exercised through host-core.test.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-20 13:55:49 +03:00
parent b15972284b
commit a4061a0012
8 changed files with 173 additions and 309 deletions

View File

@@ -20,7 +20,11 @@
import { getChannelAdapter } from './channels/channel-registry.js';
import { getAgentGroup } from './db/agent-groups.js';
import { recordDroppedMessage } from './db/dropped-messages.js';
import { getMessagingGroupByPlatform, createMessagingGroup, getMessagingGroupAgents } from './db/messaging-groups.js';
import {
createMessagingGroup,
getMessagingGroupAgents,
getMessagingGroupWithAgentCount,
} from './db/messaging-groups.js';
import { findSessionForAgent } from './db/sessions.js';
import { startTypingRefresh } from './modules/typing/index.js';
import { log } from './log.js';
@@ -143,10 +147,21 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
event = { ...event, threadId: null };
}
// 1. Resolve messaging group
let mg = getMessagingGroupByPlatform(event.channelType, event.platformId);
const isMention = event.message.isMention === true;
// 1. Combined lookup: messaging_group row + count of wired agents in a
// single query. Cheap short-circuit for the common "unwired channel"
// case — one DB read and we're out, no auto-create, no sender
// resolution, no log spam.
const found = getMessagingGroupWithAgentCount(event.channelType, event.platformId);
let mg: MessagingGroup;
if (!found) {
// No messaging_groups row. Auto-create only when the message warrants
// attention (the bot was addressed — @mention or DM). Plain chatter in
// channels we merely sit in stays silent — no row, no DB writes.
if (!isMention) return;
if (!mg) {
const mgId = `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
mg = {
id: mgId,
@@ -154,9 +169,6 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
platform_id: event.platformId,
name: null,
is_group: 0,
// Let the schema default (currently 'request_approval') apply rather
// than hardcoding 'strict' — the schema is the source of truth for
// the default policy. See migration 011.
unknown_sender_policy: 'request_approval',
created_at: new Date().toISOString(),
};
@@ -166,6 +178,30 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
channelType: event.channelType,
platformId: event.platformId,
});
} else {
mg = found.mg;
if (found.agentCount === 0) {
// Messaging group exists but has no wirings. Stay silent for plain
// messages; only log + record on explicit mention/DM so admins can
// see that someone tried to reach the bot on an unwired channel.
if (!isMention) return;
log.warn('MESSAGE DROPPED — no agent groups wired to this channel. Run setup register step to configure.', {
messagingGroupId: mg.id,
channelType: event.channelType,
platformId: event.platformId,
});
const parsed = safeParseContent(event.message.content);
recordDroppedMessage({
channel_type: event.channelType,
platform_id: event.platformId,
user_id: null,
sender_name: parsed.sender ?? null,
reason: 'no_agent_wired',
messaging_group_id: mg.id,
agent_group_id: null,
});
return;
}
}
// 2. Sender resolution (permissions module upserts the users row as a
@@ -173,27 +209,9 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
// Without the module, userId is null — downstream tolerates it.
const userId: string | null = senderResolver ? senderResolver(event) : null;
// 3. Resolve agent groups wired to this messaging group. Structural
// drops record to dropped_messages for audit.
// 3. Fetch wired agents in full (we already know the count is > 0; now
// we need their actual rows for fan-out).
const agents = getMessagingGroupAgents(mg.id);
if (agents.length === 0) {
log.warn('MESSAGE DROPPED — no agent groups wired to this channel. Run setup register step to configure.', {
messagingGroupId: mg.id,
channelType: event.channelType,
platformId: event.platformId,
});
const parsed = safeParseContent(event.message.content);
recordDroppedMessage({
channel_type: event.channelType,
platform_id: event.platformId,
user_id: userId,
sender_name: parsed.sender ?? null,
reason: 'no_agent_wired',
messaging_group_id: mg.id,
agent_group_id: null,
});
return;
}
// 4. Fan-out: evaluate each wired agent independently against engage_mode,
// sender_scope, and access gate. An agent that engages gets its own
@@ -201,12 +219,18 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
// ignored_message_policy='accumulate' still gets the message stored in
// its session (trigger=0) so the context is available when it does
// engage later. Drop policy = skip silently.
//
// Subscribe (for mention-sticky wirings on threaded platforms) fires
// once per message from this loop — the first engaging mention-sticky
// wiring triggers adapter.subscribe(...); subsequent wirings don't
// re-subscribe (chat.subscribe is idempotent anyway, but the flag
// avoids the extra await).
const parsed = safeParseContent(event.message.content);
const messageText = parsed.text ?? '';
const isMention = event.message.isMention === true;
let engagedCount = 0;
let accumulatedCount = 0;
let subscribed = false;
for (const agent of agents) {
const agentGroup = getAgentGroup(agent.agent_group_id);
@@ -220,6 +244,27 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
if (engages && accessOk && scopeOk) {
await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, true);
engagedCount++;
// Mention-sticky: ask the adapter to subscribe the thread so the
// platform's subscribed-message path carries follow-ups without
// requiring another @mention. Threaded-adapter only; DMs and
// non-threaded platforms skip.
if (
!subscribed &&
agent.engage_mode === 'mention-sticky' &&
adapter?.supportsThreads &&
adapter.subscribe &&
event.threadId !== null &&
mg.is_group !== 0
) {
subscribed = true;
// Fire-and-forget — subscribe is platform-side bookkeeping and
// shouldn't block message routing. Errors are logged inside the
// adapter (or by the promise rejection handler below).
void adapter.subscribe(event.platformId, event.threadId).catch((err) => {
log.warn('adapter.subscribe failed', { channelType: event.channelType, threadId: event.threadId, err });
});
}
} else if (agent.ignored_message_policy === 'accumulate') {
await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false);
accumulatedCount++;