feat(permissions): unknown-channel registration flow with owner approval

When the router sees a mention or DM on a messaging group that isn't wired
to any agent, it now escalates to an owner for approval instead of silently
dropping. Mirrors the existing unknown-sender approval pattern (ACTION-ITEMS
item 22).

Schema (migration 012):
- `messaging_groups.denied_at TEXT NULL` — timestamp set on deny so future
  mentions stop escalating. ALTER TABLE ADD COLUMN, FK-safe (unlike the
  rebuild that bit migration 011).
- `pending_channel_approvals` — PK on `messaging_group_id` gives free
  in-flight dedup. One card per channel, no spam on rapid retries.

Router:
- New hook `setChannelRequestGate(mg, event) => Promise<void>`, invoked
  from the no-wirings branch when the message was addressed to the bot
  (isMention=true). Hook is fire-and-forget.
- Checks `mg.denied_at` before escalating — denied channels drop silently
  and do not re-prompt.
- The two "no-wirings" branches (fresh auto-create and existing mg with
  no agents) are consolidated into one escalation path that calls the
  gate once. Without the module, behavior is log + record (no regression).

Permissions module:
- `channel-approval.ts::requestChannelApproval` — MVP picker: target
  agent is `getAllAgentGroups()[0]`, card names it explicitly ("Wire it
  to <Andy>?"). Approver via existing `pickApprover` + `pickApprovalDelivery`
  primitives.
- Response handler: same click-auth pattern as sender-approval (clicker
  must be the designated approver OR have admin privilege over the
  target agent group).
- Approve defaults per the feature spec:
    engage_mode = 'mention-sticky' for groups, 'pattern' + '.' for DMs
    sender_scope = 'known'
    ignored_message_policy = 'accumulate'
    session_mode = 'shared'
  DM vs group inferred from the original event's threadId (non-null →
  group) because the auto-created mg has a placeholder is_group=0 until
  the adapter fills it in.
- Triggering sender is auto-added to agent_group_members so sender_scope=
  'known' doesn't bounce the replayed message into a sender-approval
  cascade.
- Deny: stamps messaging_groups.denied_at, clears pending row.
- Failure modes — no owner, no agent groups, no reachable DM — log and
  drop without creating a pending row, letting a future attempt try
  again (same as sender-approval).

9 new integration tests cover every branch: mention triggers card, DM
triggers card, dedup, approve creates correct wiring + admits sender +
replays, approve-on-DM uses pattern/'.' defaults, deny sets denied_at
and future mentions drop silently, unauthorized clicker rejected,
no-owner drops, no-agent-groups drops.

168 tests pass (was 159; +9).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-20 14:34:00 +03:00
parent a4061a0012
commit 719f97e483
9 changed files with 882 additions and 18 deletions

View File

@@ -127,6 +127,27 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void {
senderScopeGate = fn;
}
/**
* Channel-registration hook. Runs when the router sees a mention/DM on a
* messaging group that has no wirings AND hasn't been denied. The hook is
* expected to escalate to an owner (card, etc.) and arrange for future
* replay via routeInbound after approval. Fire-and-forget from the
* router's perspective.
*
* Registered by the permissions module. Without the module the router
* silently records the drop with reason='no_agent_wired' and moves on.
*/
export type ChannelRequestGateFn = (mg: MessagingGroup, event: InboundEvent) => Promise<void>;
let channelRequestGate: ChannelRequestGateFn | null = null;
export function setChannelRequestGate(fn: ChannelRequestGateFn): void {
if (channelRequestGate) {
log.warn('Channel-request gate overwritten');
}
channelRequestGate = fn;
}
function safeParseContent(raw: string): { text?: string; sender?: string; senderId?: string } {
try {
return JSON.parse(raw);
@@ -156,12 +177,12 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
const found = getMessagingGroupWithAgentCount(event.channelType, event.platformId);
let mg: MessagingGroup;
let agentCount: number;
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;
const mgId = `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
mg = {
id: mgId,
@@ -170,6 +191,7 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
name: null,
is_group: 0,
unknown_sender_policy: 'request_approval',
denied_at: null,
created_at: new Date().toISOString(),
};
createMessagingGroup(mg);
@@ -178,30 +200,51 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
channelType: event.channelType,
platformId: event.platformId,
});
agentCount = 0;
} 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.', {
agentCount = found.agentCount;
}
// 1b. No wirings — either silent drop (plain chatter / denied channel) or
// escalate to owner for channel-registration approval.
if (agentCount === 0) {
if (!isMention) return;
if (mg.denied_at) {
log.debug('Message dropped — channel was denied by owner', {
messagingGroupId: mg.id,
deniedAt: mg.denied_at,
});
return;
}
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,
});
if (channelRequestGate) {
// Fire-and-forget escalation. The gate is expected to build a card,
// persist pending_channel_approvals, and replay the event via
// routeInbound after approval. Errors are logged internally — the
// user's message still stays dropped here either way.
void channelRequestGate(mg, event).catch((err) =>
log.error('Channel-request gate threw', { messagingGroupId: mg.id, err }),
);
} else {
log.warn('MESSAGE DROPPED — no agent groups wired and no channel-request gate registered', {
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;
}
return;
}
// 2. Sender resolution (permissions module upserts the users row as a