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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user