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
+14
View File
@@ -100,6 +100,20 @@ export function deleteMessagingGroup(id: string): void {
getDb().prepare('DELETE FROM messaging_groups WHERE id = ?').run(id);
}
/**
* Mark a messaging group as denied by the owner (channel-registration flow).
* Future mentions on this channel silently drop until an admin explicitly
* wires it via `createMessagingGroupAgent`, which implicitly clears the
* denied state by making `agentCount > 0` — the router's denied-channel
* check sits on the `agentCount === 0` branch.
*
* Passing null unsets the flag (used by tests or a future "unblock channel"
* admin command).
*/
export function setMessagingGroupDeniedAt(id: string, deniedAt: string | null): void {
getDb().prepare('UPDATE messaging_groups SET denied_at = ? WHERE id = ?').run(deniedAt, id);
}
// ── Messaging Group Agents ──
/**
@@ -0,0 +1,48 @@
/**
* Unknown-channel registration flow.
*
* When a channel that isn't wired to any agent group receives a mention or
* DM, the router escalates to the owner for approval before wiring. Approve
* creates a `messaging_group_agents` row (with conservative defaults) and
* replays the triggering event. Deny marks the channel denied forever
* (stored as a timestamp on `messaging_groups.denied_at`) so future
* messages on that channel drop silently without re-prompting.
*
* Two changes:
* 1. `messaging_groups.denied_at TEXT NULL` — set on deny, checked in the
* router before re-escalating. ALTER TABLE ADD COLUMN is FK-safe
* unlike the table rebuild that bit us in migration 011.
* 2. `pending_channel_approvals` table. PRIMARY KEY on
* `messaging_group_id` gives free in-flight dedup — a second mention
* while the card is pending is silently dropped by INSERT OR IGNORE,
* preventing card spam.
*/
import type Database from 'better-sqlite3';
import type { Migration } from './index.js';
export const migration012: Migration = {
version: 12,
name: 'channel-registration',
up: (db: Database.Database) => {
// 1. Add denied_at to messaging_groups. Idempotent guard in case the
// column was added by some other path before this migration ran.
const cols = db.prepare("PRAGMA table_info('messaging_groups')").all() as Array<{ name: string }>;
if (!cols.some((c) => c.name === 'denied_at')) {
db.exec(`ALTER TABLE messaging_groups ADD COLUMN denied_at TEXT`);
}
// 2. pending_channel_approvals.
db.exec(`
CREATE TABLE IF NOT EXISTS pending_channel_approvals (
messaging_group_id TEXT PRIMARY KEY REFERENCES messaging_groups(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
-- The agent the approved wiring will target.
-- Picked at request time (currently: earliest
-- agent_group by created_at).
original_message TEXT NOT NULL, -- JSON serialized InboundEvent
approver_user_id TEXT NOT NULL,
created_at TEXT NOT NULL
);
`);
},
};
+2
View File
@@ -8,6 +8,7 @@ import { migration008 } from './008-dropped-messages.js';
import { migration009 } from './009-drop-pending-credentials.js';
import { migration010 } from './010-engage-modes.js';
import { migration011 } from './011-pending-sender-approvals.js';
import { migration012 } from './012-channel-registration.js';
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
@@ -27,6 +28,7 @@ const migrations: Migration[] = [
migration009,
migration010,
migration011,
migration012,
];
export function runMigrations(db: Database.Database): void {
@@ -0,0 +1,392 @@
/**
* Integration tests for the unknown-channel registration flow (ACTION-ITEMS
* item 22).
*
* Covers:
* - Mention on an unwired channel fires an owner-approval card
* - DM on an unwired channel fires a card (engage_mode will default to pattern='.')
* - In-flight dedup: second mention while a card is pending doesn't spam
* - Approve: wiring created with correct defaults, triggering sender added
* as member, replay wakes the container
* - Deny: messaging_groups.denied_at set, future mentions drop silently
* - Unauthorized clicker is rejected (same pattern as sender-approval)
* - No-owner install: no card, no row
* - No agent groups configured: no card, no row
*/
import fs from 'fs';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { createMessagingGroup, getMessagingGroupByPlatform } from '../../db/messaging-groups.js';
import { upsertUser } from './db/users.js';
import { grantRole } from './db/user-roles.js';
// Mock container runner — prevent actual docker spawn.
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
isContainerRunning: vi.fn().mockReturnValue(false),
getActiveContainerCount: vi.fn().mockReturnValue(0),
killContainer: vi.fn(),
}));
// Mock delivery adapter.
const deliverMock = vi.fn().mockResolvedValue('plat-msg-id');
vi.mock('../../delivery.js', () => ({
getDeliveryAdapter: () => ({ deliver: deliverMock }),
}));
// Mock ensureUserDm — look up the owner's preconfigured DM row instead of
// hitting a real openDM RPC.
vi.mock('./user-dm.js', () => ({
ensureUserDm: vi.fn(async (userId: string) => {
const { getDb } = await import('../../db/connection.js');
const row = getDb()
.prepare(
`SELECT mg.* FROM messaging_groups mg
JOIN user_dms ud ON ud.messaging_group_id = mg.id
WHERE ud.user_id = ?`,
)
.get(userId);
return row;
}),
}));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-channel-approval' };
});
const TEST_DIR = '/tmp/nanoclaw-test-channel-approval';
function now() {
return new Date().toISOString();
}
beforeEach(async () => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
await import('./index.js'); // register hooks
// Base fixtures: one agent group + owner with a DM on 'telegram'.
createAgentGroup({ id: 'ag-1', name: 'Andy', folder: 'andy', agent_provider: null, created_at: now() });
upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() });
grantRole({
user_id: 'telegram:owner',
role: 'owner',
agent_group_id: null,
granted_by: null,
granted_at: now(),
});
// Pre-seed owner's DM messaging group + user_dms mapping.
createMessagingGroup({
id: 'mg-dm-owner',
channel_type: 'telegram',
platform_id: 'dm-owner',
name: 'Owner DM',
is_group: 0,
unknown_sender_policy: 'public',
created_at: now(),
});
const { getDb } = await import('../../db/connection.js');
getDb()
.prepare(
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
VALUES (?, ?, ?, ?)`,
)
.run('telegram:owner', 'telegram', 'mg-dm-owner', now());
deliverMock.mockClear();
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
function groupMention(platformId: string, text = '@bot hello') {
return {
channelType: 'telegram',
platformId,
threadId: 'thread-1', // non-null → is_group=true per channel-approval default-picker logic
message: {
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat' as const,
content: JSON.stringify({ senderId: 'caller', senderName: 'Caller', text }),
timestamp: now(),
isMention: true,
},
};
}
function dmEvent(platformId: string, text = 'hello') {
return {
channelType: 'telegram',
platformId,
threadId: null,
message: {
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat' as const,
content: JSON.stringify({ senderId: 'stranger', senderName: 'Stranger', text }),
timestamp: now(),
isMention: true, // DM bridge sets isMention=true
},
};
}
describe('unknown-channel registration flow', () => {
it('delivers an approval card on mention into an unwired group', async () => {
const { routeInbound } = await import('../../router.js');
await routeInbound(groupMention('chat-new'));
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).toHaveBeenCalledTimes(1);
const [channel, platformId, thread, kind, content] = deliverMock.mock.calls[0];
expect(channel).toBe('telegram');
expect(platformId).toBe('dm-owner'); // delivered to owner's DM
expect(thread).toBeNull();
expect(kind).toBe('chat-sdk');
const payload = JSON.parse(content as string);
expect(payload.type).toBe('ask_question');
// Card names the target agent so the owner knows what they're wiring to.
expect(payload.question).toContain('Andy');
const { getDb } = await import('../../db/connection.js');
const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{
messaging_group_id: string;
}>;
expect(rows).toHaveLength(1);
});
it('delivers a card on DM too (non-threaded event)', async () => {
const { routeInbound } = await import('../../router.js');
await routeInbound(dmEvent('dm-new-user'));
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).toHaveBeenCalledTimes(1);
const { getDb } = await import('../../db/connection.js');
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c;
expect(count).toBe(1);
});
it('dedups a second mention while the card is pending', async () => {
const { routeInbound } = await import('../../router.js');
await routeInbound(groupMention('chat-busy'));
await new Promise((r) => setTimeout(r, 10));
await routeInbound(groupMention('chat-busy', '@bot still here'));
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).toHaveBeenCalledTimes(1);
const { getDb } = await import('../../db/connection.js');
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c;
expect(count).toBe(1);
});
it('approve → creates wiring, admits triggering sender, replays', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
const { wakeContainer } = await import('../../container-runner.js');
(wakeContainer as unknown as ReturnType<typeof vi.fn>).mockClear();
await routeInbound(groupMention('chat-approve'));
await new Promise((r) => setTimeout(r, 10));
const { getDb } = await import('../../db/connection.js');
const pending = getDb()
.prepare('SELECT messaging_group_id FROM pending_channel_approvals')
.get() as { messaging_group_id: string };
expect(pending).toBeDefined();
// Owner clicks approve.
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: 'approve',
userId: 'owner', // raw platform id — handler namespaces it
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
});
if (claimed) break;
}
// Wiring created with MVP defaults.
const mga = getDb()
.prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(pending.messaging_group_id) as {
engage_mode: string;
engage_pattern: string | null;
sender_scope: string;
ignored_message_policy: string;
agent_group_id: string;
};
expect(mga).toBeDefined();
expect(mga.engage_mode).toBe('mention-sticky'); // group (threadId != null)
expect(mga.engage_pattern).toBeNull();
expect(mga.sender_scope).toBe('known');
expect(mga.ignored_message_policy).toBe('accumulate');
expect(mga.agent_group_id).toBe('ag-1');
// Triggering sender auto-admitted so sender_scope='known' doesn't
// bounce the replay into sender-approval.
const member = getDb()
.prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
.get('telegram:caller', 'ag-1');
expect(member).toBeDefined();
// Pending row cleared and container woken via replay.
const stillPending = (
getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }
).c;
expect(stillPending).toBe(0);
expect(wakeContainer).toHaveBeenCalled();
});
it('approve on a DM wires with pattern="." defaults', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
await routeInbound(dmEvent('dm-approve-user'));
await new Promise((r) => setTimeout(r, 10));
const { getDb } = await import('../../db/connection.js');
const pending = getDb()
.prepare('SELECT messaging_group_id FROM pending_channel_approvals')
.get() as { messaging_group_id: string };
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: 'approve',
userId: 'owner',
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
});
if (claimed) break;
}
const mga = getDb()
.prepare('SELECT engage_mode, engage_pattern FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(pending.messaging_group_id) as { engage_mode: string; engage_pattern: string };
expect(mga.engage_mode).toBe('pattern');
expect(mga.engage_pattern).toBe('.');
});
it('deny → sets denied_at; future mentions drop silently without a second card', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
await routeInbound(groupMention('chat-deny'));
await new Promise((r) => setTimeout(r, 10));
const { getDb } = await import('../../db/connection.js');
const pending = getDb()
.prepare('SELECT messaging_group_id FROM pending_channel_approvals')
.get() as { messaging_group_id: string };
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: 'reject',
userId: 'owner',
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
});
if (claimed) break;
}
// denied_at set, pending row cleared, no wiring.
const mg = getMessagingGroupByPlatform('telegram', 'chat-deny');
expect(mg?.denied_at).not.toBeNull();
expect(mg?.denied_at).toBeTruthy();
const mgaCount = (
getDb()
.prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(pending.messaging_group_id) as { c: number }
).c;
expect(mgaCount).toBe(0);
// A follow-up mention on the denied channel: no new card, no new pending row.
deliverMock.mockClear();
await routeInbound(groupMention('chat-deny', '@bot please'));
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).not.toHaveBeenCalled();
const stillPending = (
getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }
).c;
expect(stillPending).toBe(0);
});
it('rejects clicks from an unauthorized user (prevents self-admit via forwarded card)', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
await routeInbound(groupMention('chat-unauth'));
await new Promise((r) => setTimeout(r, 10));
const { getDb } = await import('../../db/connection.js');
const pending = getDb()
.prepare('SELECT messaging_group_id FROM pending_channel_approvals')
.get() as { messaging_group_id: string };
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: 'approve',
userId: 'random-bystander',
channelType: 'telegram',
platformId: 'dm-random',
threadId: null,
});
if (claimed) break;
}
// No wiring created, pending row preserved so a real approver can act on it.
const mgaCount = (
getDb()
.prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(pending.messaging_group_id) as { c: number }
).c;
expect(mgaCount).toBe(0);
const stillPending = (
getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }
).c;
expect(stillPending).toBe(1);
});
});
describe('no-owner / no-agent failure modes', () => {
it('no owner → no card, no pending row (fresh-install bootstrap path)', async () => {
// Wipe the owner grant set up in the outer beforeEach.
const { getDb } = await import('../../db/connection.js');
getDb().prepare('DELETE FROM user_roles').run();
const { routeInbound } = await import('../../router.js');
await routeInbound(groupMention('chat-noowner'));
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).not.toHaveBeenCalled();
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c;
expect(count).toBe(0);
});
it('no agent groups → no card, no pending row', async () => {
const { getDb } = await import('../../db/connection.js');
// Drop foreign-key-dependent rows first, then the agent group itself.
getDb().prepare('DELETE FROM user_roles').run();
getDb().prepare('DELETE FROM agent_groups').run();
const { routeInbound } = await import('../../router.js');
await routeInbound(groupMention('chat-noagent'));
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).not.toHaveBeenCalled();
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c;
expect(count).toBe(0);
});
});
+159
View File
@@ -0,0 +1,159 @@
/**
* Unknown-channel registration flow.
*
* When the router hits an unwired messaging group AND the message was
* addressed to the bot (SDK-confirmed mention or DM), it calls
* `requestChannelApproval` instead of silently dropping. The flow:
*
* 1. Pick the target agent group we'd wire to (MVP: first by name).
* Multi-agent picker is a follow-up — see ACTION-ITEMS.
* 2. Pick an eligible approver (owner / admin) and a reachable DM for
* them, reusing the same primitives the sender-approval flow uses.
* 3. Deliver an Approve / Ignore card that names the target agent
* explicitly so the owner knows what they're wiring to.
* 4. Record a `pending_channel_approvals` row holding the original event
* so it can be re-routed on approve.
*
* On approve (handler in index.ts):
* - Create `messaging_group_agents` with MVP defaults
* (mention-sticky for groups / pattern='.' for DMs,
* sender_scope='known', ignored_message_policy='accumulate')
* - Add the triggering sender to `agent_group_members` so sender_scope
* doesn't bounce the replayed message into a sender-approval cascade
* - Delete the pending row, replay the original event
*
* On ignore:
* - Set `messaging_groups.denied_at = now()` so the router stops
* escalating on this channel until an admin explicitly re-wires
* - Delete the pending row
*
* Dedup: `pending_channel_approvals` PK on messaging_group_id. Second
* mention while pending silently dropped.
*
* Failure modes (log + no row, so a future attempt can try again):
* - No agent groups exist (install never set up a first agent).
* - No eligible approver in user_roles (no owner yet).
* - Approver has no reachable DM.
* - Delivery adapter missing.
*/
import { normalizeOptions, type RawOption } from '../../channels/ask-question.js';
import { getAllAgentGroups } from '../../db/agent-groups.js';
import { getMessagingGroup } from '../../db/messaging-groups.js';
import { getDeliveryAdapter } from '../../delivery.js';
import { log } from '../../log.js';
import type { InboundEvent } from '../../router.js';
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js';
const APPROVAL_OPTIONS: RawOption[] = [
{ label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' },
{ label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' },
];
export interface RequestChannelApprovalInput {
messagingGroupId: string;
event: InboundEvent;
}
export async function requestChannelApproval(input: RequestChannelApprovalInput): Promise<void> {
const { messagingGroupId, event } = input;
// In-flight dedup: don't spam the owner if the same unwired channel
// gets more mentions / DMs while a card is already pending.
if (hasInFlightChannelApproval(messagingGroupId)) {
log.debug('Channel registration already in flight — dropping retry', {
messagingGroupId,
});
return;
}
// MVP: pick the first agent group by name. Multi-agent systems will get
// a richer card later (user picks the target from a list).
const agentGroups = getAllAgentGroups();
if (agentGroups.length === 0) {
log.warn('Channel registration skipped — no agent groups configured. Run /init-first-agent.', {
messagingGroupId,
});
return;
}
const target = agentGroups[0];
// pickApprover takes the target agent group's id — gets scoped admins +
// global admins + owners. For fresh installs with only an owner, the
// owner is returned.
const approvers = pickApprover(target.id);
if (approvers.length === 0) {
log.warn('Channel registration skipped — no owner or admin configured', {
messagingGroupId,
targetAgentGroupId: target.id,
});
return;
}
const originMg = getMessagingGroup(messagingGroupId);
const originChannelType = originMg?.channel_type ?? '';
const delivery = await pickApprovalDelivery(approvers, originChannelType);
if (!delivery) {
log.warn('Channel registration skipped — no DM channel for any approver', {
messagingGroupId,
targetAgentGroupId: target.id,
});
return;
}
const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat';
const isGroup = originMg?.is_group === 1;
const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message';
const question = isGroup
? `Your agent was mentioned in ${originName} on ${originChannelType}. Wire it to ${target.name} and let it engage?`
: `Someone DM'd your agent on ${originChannelType} (${originName}). Wire it to ${target.name} and let it respond?`;
createPendingChannelApproval({
messaging_group_id: messagingGroupId,
agent_group_id: target.id,
original_message: JSON.stringify(event),
approver_user_id: delivery.userId,
created_at: new Date().toISOString(),
});
const adapter = getDeliveryAdapter();
if (!adapter) {
log.error('Channel registration row created but no delivery adapter is wired', {
messagingGroupId,
});
return;
}
try {
await adapter.deliver(
delivery.messagingGroup.channel_type,
delivery.messagingGroup.platform_id,
null,
'chat-sdk',
JSON.stringify({
type: 'ask_question',
// Use messaging_group_id as the questionId — it's unique per card
// (PK on pending table dedups) and lets the response handler look
// up the pending row directly without another index.
questionId: messagingGroupId,
title,
question,
options: normalizeOptions(APPROVAL_OPTIONS),
}),
);
log.info('Channel registration card delivered', {
messagingGroupId,
targetAgentGroupId: target.id,
approver: delivery.userId,
});
} catch (err) {
log.error('Channel registration card delivery failed', {
messagingGroupId,
err,
});
}
}
export const APPROVE_VALUE = 'approve';
export const REJECT_VALUE = 'reject';
@@ -0,0 +1,52 @@
/**
* CRUD for pending_channel_approvals — the in-flight state for the
* unknown-channel registration flow. A row exists while an owner-approval
* card is outstanding; it's deleted on approve (after wiring is created)
* or deny (after denied_at is set on the messaging_group).
*
* PRIMARY KEY on messaging_group_id gives free in-flight dedup. A second
* mention/DM while a card is pending resolves via
* `hasInFlightChannelApproval` in the request flow and drops silently
* instead of spamming the owner.
*/
import { getDb } from '../../../db/connection.js';
export interface PendingChannelApproval {
messaging_group_id: string;
agent_group_id: string;
original_message: string;
approver_user_id: string;
created_at: string;
}
export function createPendingChannelApproval(row: PendingChannelApproval): void {
getDb()
.prepare(
`INSERT INTO pending_channel_approvals (
messaging_group_id, agent_group_id, original_message,
approver_user_id, created_at
)
VALUES (
@messaging_group_id, @agent_group_id, @original_message,
@approver_user_id, @created_at
)`,
)
.run(row);
}
export function getPendingChannelApproval(messagingGroupId: string): PendingChannelApproval | undefined {
return getDb()
.prepare('SELECT * FROM pending_channel_approvals WHERE messaging_group_id = ?')
.get(messagingGroupId) as PendingChannelApproval | undefined;
}
export function hasInFlightChannelApproval(messagingGroupId: string): boolean {
const row = getDb()
.prepare('SELECT 1 AS x FROM pending_channel_approvals WHERE messaging_group_id = ?')
.get(messagingGroupId) as { x: number } | undefined;
return row !== undefined;
}
export function deletePendingChannelApproval(messagingGroupId: string): void {
getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId);
}
+144
View File
@@ -16,9 +16,14 @@
* access gate is not registered and core defaults to allow-all.
*/
import { recordDroppedMessage } from '../../db/dropped-messages.js';
import {
createMessagingGroupAgent,
setMessagingGroupDeniedAt,
} from '../../db/messaging-groups.js';
import {
routeInbound,
setAccessGate,
setChannelRequestGate,
setSenderResolver,
setSenderScopeGate,
type AccessGateResult,
@@ -28,7 +33,12 @@ import { registerResponseHandler, type ResponsePayload } from '../../response-re
import { log } from '../../log.js';
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
import { canAccessAgentGroup } from './access.js';
import { requestChannelApproval } from './channel-approval.js';
import { addMember } from './db/agent-group-members.js';
import {
deletePendingChannelApproval,
getPendingChannelApproval,
} from './db/pending-channel-approvals.js';
import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js';
import { hasAdminPrivilege } from './db/user-roles.js';
import { getUser, upsertUser } from './db/users.js';
@@ -253,3 +263,137 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<b
}
registerResponseHandler(handleSenderApprovalResponse);
// ── Unknown-channel registration flow ──
setChannelRequestGate(async (mg, event) => {
await requestChannelApproval({ messagingGroupId: mg.id, event });
});
/**
* Response handler for the unknown-channel registration card.
*
* Claim rule: questionId matches a pending_channel_approvals row (keyed
* by messaging_group_id). If no such row, return false so downstream
* handlers get a shot.
*
* Approve: create the wiring with MVP defaults (mention-sticky for
* groups / pattern='.' for DMs; sender_scope='known';
* ignored_message_policy='accumulate'), add the triggering sender as a
* member so sender_scope doesn't immediately bounce them into a
* sender-approval card, then replay the original event.
*
* Deny: set `messaging_groups.denied_at = now()` so future mentions on
* this channel drop silently until an admin explicitly wires it.
*/
async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<boolean> {
const row = getPendingChannelApproval(payload.questionId);
if (!row) return false;
// Click-auth: same pattern as sender-approval (see commit 68058cb).
// Raw platform userId → namespace with channelType → must match the
// designated approver OR have admin privilege over the target agent.
const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null;
const isAuthorized =
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
if (!isAuthorized) {
log.warn('Channel registration click rejected — unauthorized clicker', {
messagingGroupId: row.messaging_group_id,
clickerId,
expectedApprover: row.approver_user_id,
});
return true; // claim but take no action
}
const approverId = clickerId;
const approved = payload.value === 'approve';
if (!approved) {
setMessagingGroupDeniedAt(row.messaging_group_id, new Date().toISOString());
deletePendingChannelApproval(row.messaging_group_id);
log.info('Channel registration denied', {
messagingGroupId: row.messaging_group_id,
agentGroupId: row.agent_group_id,
approverId,
});
return true;
}
// Rehydrate the original event to know (a) whether it was a DM or group
// (chooses engage_mode default), and (b) who the triggering sender was
// (auto-member-add so sender_scope='known' doesn't bounce the replay).
let event: InboundEvent;
try {
event = JSON.parse(row.original_message) as InboundEvent;
} catch (err) {
log.error('Channel registration: failed to parse stored event', {
messagingGroupId: row.messaging_group_id,
err,
});
deletePendingChannelApproval(row.messaging_group_id);
return true;
}
// Decide engage_mode from the original event. DMs (`isMention=true` &
// not in a group) get `pattern='.'` (always respond). Group mentions
// get `mention-sticky` (respond now + follow the thread).
//
// We can't read `mg.is_group` reliably here because we only auto-create
// the mg with `is_group=0` on first sight — the adapter hasn't told us
// yet whether it's actually a group. Fall back to the InboundEvent's
// `threadId`: a non-null threadId implies a threaded platform (Slack
// channel thread, Discord thread), which we treat as a group.
const isGroup = event.threadId !== null;
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
const engagePattern = isGroup ? null : '.';
const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
createMessagingGroupAgent({
id: mgaId,
messaging_group_id: row.messaging_group_id,
agent_group_id: row.agent_group_id,
engage_mode: engageMode,
engage_pattern: engagePattern,
sender_scope: 'known',
ignored_message_policy: 'accumulate',
session_mode: 'shared',
priority: 0,
created_at: new Date().toISOString(),
});
log.info('Channel registration approved — wiring created', {
messagingGroupId: row.messaging_group_id,
agentGroupId: row.agent_group_id,
mgaId,
engageMode,
approverId,
});
// Auto-admit the triggering sender. Without this, the replay below
// would bounce through sender-approval (sender_scope='known' +
// sender-is-not-a-member).
const senderUserId = extractAndUpsertUser(event);
if (senderUserId) {
addMember({
user_id: senderUserId,
agent_group_id: row.agent_group_id,
added_by: approverId,
added_at: new Date().toISOString(),
});
}
// Clear the pending row BEFORE replay so the gate check on the second
// attempt sees a wired channel (agentCount > 0) and takes the fan-out
// path normally.
deletePendingChannelApproval(row.messaging_group_id);
try {
await routeInbound(event);
} catch (err) {
log.error('Failed to replay message after channel approval', {
messagingGroupId: row.messaging_group_id,
err,
});
}
return true;
}
registerResponseHandler(handleChannelApprovalResponse);
+61 -18
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
+10
View File
@@ -17,6 +17,16 @@ export interface MessagingGroup {
name: string | null;
is_group: number; // 0 | 1
unknown_sender_policy: UnknownSenderPolicy;
/**
* When set, the owner explicitly denied registering this channel — the
* router drops silently and does not re-escalate. Cleared by any explicit
* wiring mutation (admin command). See migration 012.
*
* Optional on the TS type so pre-migration-012 callers that build
* MessagingGroup objects in code (fixtures, etc.) don't need to update;
* the column itself defaults to NULL in SQLite.
*/
denied_at?: string | null;
created_at: string;
}