Files
nanoclaw/src/types.ts
gavrielc 719f97e483 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>
2026-04-20 14:34:00 +03:00

190 lines
5.1 KiB
TypeScript

// ── Central DB entities ──
export interface AgentGroup {
id: string;
name: string;
folder: string;
agent_provider: string | null;
created_at: string;
}
export type UnknownSenderPolicy = 'strict' | 'request_approval' | 'public';
export interface MessagingGroup {
id: string;
channel_type: string;
platform_id: string;
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;
}
// ── Identity & privilege ──
/**
* User = a messaging-platform identifier. Namespaced so distinct channels
* with numeric IDs don't collide: "phone:+1555...", "tg:123", "discord:456",
* "email:a@x.com". A single human with a phone AND a telegram handle has
* two separate users — no cross-channel linking (yet).
*/
export interface User {
id: string;
kind: string; // 'phone' | 'email' | 'discord' | 'telegram' | 'matrix' | ...
display_name: string | null;
created_at: string;
}
export type UserRoleKind = 'owner' | 'admin';
/**
* Role grant. Owner is always global. Admin is either global
* (agent_group_id = null) or scoped to a specific agent group.
* Admin @ A implicitly makes the user a member of A — we do not require
* a separate agent_group_members row for admins.
*/
export interface UserRole {
user_id: string;
role: UserRoleKind;
agent_group_id: string | null;
granted_by: string | null;
granted_at: string;
}
/** "Known" membership in an agent group — required for unprivileged users. */
export interface AgentGroupMember {
user_id: string;
agent_group_id: string;
added_by: string | null;
added_at: string;
}
/** Cached DM channel for a user on a specific channel_type. */
export interface UserDm {
user_id: string;
channel_type: string;
messaging_group_id: string;
resolved_at: string;
}
export type EngageMode = 'pattern' | 'mention' | 'mention-sticky';
export type SenderScope = 'all' | 'known';
export type IgnoredMessagePolicy = 'drop' | 'accumulate';
export interface MessagingGroupAgent {
id: string;
messaging_group_id: string;
agent_group_id: string;
engage_mode: EngageMode;
/**
* Regex source string used when engage_mode='pattern'. `'.'` is the sentinel
* for "match every message" (the "always" flavor). Ignored for 'mention' /
* 'mention-sticky' modes.
*/
engage_pattern: string | null;
sender_scope: SenderScope;
ignored_message_policy: IgnoredMessagePolicy;
session_mode: 'shared' | 'per-thread' | 'agent-shared';
priority: number;
created_at: string;
}
export interface Session {
id: string;
agent_group_id: string;
messaging_group_id: string | null;
thread_id: string | null;
agent_provider: string | null;
status: 'active' | 'closed';
container_status: 'running' | 'idle' | 'stopped';
last_active: string | null;
created_at: string;
}
// ── Session DB entities ──
export type MessageInKind = 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system';
export type MessageInStatus = 'pending' | 'processing' | 'completed' | 'failed';
export interface MessageIn {
id: string;
kind: MessageInKind;
timestamp: string;
status: MessageInStatus;
status_changed: string | null;
process_after: string | null;
recurrence: string | null;
tries: number;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
content: string; // JSON blob
}
export interface MessageOut {
id: string;
in_reply_to: string | null;
timestamp: string;
delivered: number; // 0 | 1
deliver_after: string | null;
recurrence: string | null;
kind: string;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
content: string; // JSON blob
}
// ── Pending questions (central DB) ──
export interface PendingQuestion {
question_id: string;
session_id: string;
message_out_id: string;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
title: string;
options: import('./channels/ask-question.js').NormalizedOption[];
created_at: string;
}
// ── Pending approvals (central DB) ──
export interface PendingApproval {
approval_id: string;
session_id: string | null;
request_id: string;
action: string;
payload: string; // JSON
created_at: string;
agent_group_id: string | null;
channel_type: string | null;
platform_id: string | null;
platform_message_id: string | null;
expires_at: string | null;
status: 'pending' | 'approved' | 'rejected' | 'expired';
title: string;
options_json: string;
}
// ── Agent destinations (central DB) ──
export interface AgentDestination {
agent_group_id: string;
local_name: string;
target_type: 'channel' | 'agent';
target_id: string;
created_at: string;
}