From 1e29da4da2f78e302fd556cf332d139a632dd3d3 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 9 Feb 2026 18:28:10 -0800 Subject: [PATCH] feat: complete DM pairing codes with channel adapters, gateway handlers, and TUI command (Tier 4 feature 4) --- src/channels/discord/adapter.ts | 17 ++++++ src/channels/slack/adapter.ts | 30 ++++++++++- src/channels/telegram/adapter.ts | 31 +++++++++-- src/channels/whatsapp/adapter.ts | 24 ++++++++- src/config/schema.ts | 2 + src/daemon/index.ts | 24 ++++++++- src/frontends/tui/commands.ts | 19 +++++++ src/gateway/handlers/handlers.test.ts | 74 +++++++++++++++++++++++++++ src/gateway/handlers/index.ts | 2 + src/gateway/handlers/pairing.ts | 42 +++++++++++++++ src/gateway/server.ts | 12 +++++ 11 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 src/gateway/handlers/pairing.ts diff --git a/src/channels/discord/adapter.ts b/src/channels/discord/adapter.ts index 0385e63..440def9 100644 --- a/src/channels/discord/adapter.ts +++ b/src/channels/discord/adapter.ts @@ -18,6 +18,7 @@ import type { ChannelStatus, } from '../types.js'; import { splitMessage } from '../utils.js'; +import type { PairingManager } from '../pairing.js'; /** Configuration for the Discord channel adapter. */ export interface DiscordAdapterConfig { @@ -28,6 +29,8 @@ export interface DiscordAdapterConfig { allowedChannelIds?: string[]; /** Whether to require mention to respond in guild channels (default: true). DMs always respond. */ requireMention?: boolean; + /** Optional pairing manager for DM pairing codes. */ + pairingManager?: PairingManager; } /** @@ -194,6 +197,20 @@ export class DiscordAdapter implements ChannelAdapter { return; } } + } else { + // DM pairing check — if pairing is enabled, require approval + const pm = this.config.pairingManager; + if (pm?.enabled && !pm.isApproved('discord', message.channelId)) { + const text = message.content.trim(); + if (text && pm.validateCode('discord', message.channelId, text)) { + try { + if ('send' in message.channel) { + (message.channel as any).send('Pairing successful! You can now chat with Flynn.'); + } + } catch { /* ignore send errors */ } + } + return; + } } // Send typing indicator (lasts 10 seconds, no need for interval) diff --git a/src/channels/slack/adapter.ts b/src/channels/slack/adapter.ts index c143a9f..752d6b9 100644 --- a/src/channels/slack/adapter.ts +++ b/src/channels/slack/adapter.ts @@ -16,6 +16,7 @@ import type { ChannelStatus, } from '../types.js'; import { splitMessage } from '../utils.js'; +import type { PairingManager } from '../pairing.js'; /** Configuration for the Slack channel adapter. */ export interface SlackAdapterConfig { @@ -26,6 +27,8 @@ export interface SlackAdapterConfig { allowedChannelIds?: string[]; /** Require bot mention to respond (default: false). */ requireMention?: boolean; + /** Optional pairing manager for DM pairing codes. */ + pairingManager?: PairingManager; } /** Minimal shape of a Slack message event from Bolt. */ @@ -275,7 +278,32 @@ export class SlackAdapter implements ChannelAdapter { this.config.allowedChannelIds.length > 0 && !this.config.allowedChannelIds.includes(channelId) ) { - return; + // Pairing fallback — check if the Slack user is approved or sending a valid code + const pm = this.config.pairingManager; + const userId = message.user; + if (pm?.enabled && userId) { + if (pm.isApproved('slack', userId)) { + // Approved — fall through to normal message handling + } else { + const text = (message.text ?? '').trim(); + if (text && pm.validateCode('slack', userId, text)) { + // Code validated — send confirmation via Slack + if (this.app) { + const threadTs = message.thread_ts ?? message.ts ?? ''; + try { + await this.app.client.chat.postMessage({ + channel: channelId, + text: 'Pairing successful! You can now chat with Flynn.', + thread_ts: threadTs || undefined, + }); + } catch { /* ignore send errors */ } + } + } + return; + } + } else { + return; + } } // Mention requirement diff --git a/src/channels/telegram/adapter.ts b/src/channels/telegram/adapter.ts index f0fed07..3e7cc3b 100644 --- a/src/channels/telegram/adapter.ts +++ b/src/channels/telegram/adapter.ts @@ -12,6 +12,7 @@ import type { import { isAllowedChat } from '../../frontends/telegram/handlers.js'; import { parseConfirmationCallback } from '../../frontends/telegram/confirmations.js'; import { splitMessage } from '../utils.js'; +import type { PairingManager } from '../pairing.js'; /** Configuration for the Telegram channel adapter. */ export interface TelegramAdapterConfig { @@ -20,6 +21,8 @@ export interface TelegramAdapterConfig { /** Require bot mention or reply-to-bot to respond in group chats (default: true). */ requireMention?: boolean; hookEngine?: HookEngine; + /** Optional pairing manager for DM pairing codes. */ + pairingManager?: PairingManager; } /** @@ -76,14 +79,34 @@ export class TelegramAdapter implements ChannelAdapter { this.bot = new Bot(this.config.botToken); this._status = 'connecting'; - // ── Auth middleware — reject messages from unknown chats ── + // ── Auth middleware — reject messages from unknown chats (with pairing fallback) ── this.bot.use(async (ctx, next) => { const chatId = ctx.chat?.id; - if (chatId === undefined || !isAllowedChat(chatId, this.config.allowedChatIds)) { - console.log(`Rejected message from unauthorized chat: ${chatId}`); + if (chatId === undefined) return; + + // Allowlist check + if (isAllowedChat(chatId, this.config.allowedChatIds)) { + await next(); return; } - await next(); + + // Pairing fallback — check if sender is already approved or sending a valid code + const pm = this.config.pairingManager; + if (pm?.enabled) { + const senderId = String(chatId); + if (pm.isApproved('telegram', senderId)) { + await next(); + return; + } + // Check if the message text is a valid pairing code + const text = ctx.message?.text?.trim(); + if (text && pm.validateCode('telegram', senderId, text)) { + await ctx.reply('Pairing successful! You can now chat with Flynn.'); + return; + } + } + + console.log(`Rejected message from unauthorized chat: ${chatId}`); }); // ── Confirmation callback handler (requires hookEngine) ── diff --git a/src/channels/whatsapp/adapter.ts b/src/channels/whatsapp/adapter.ts index e3f03e2..bc5753e 100644 --- a/src/channels/whatsapp/adapter.ts +++ b/src/channels/whatsapp/adapter.ts @@ -18,6 +18,7 @@ import type { ChannelStatus, } from '../types.js'; import { splitMessage } from '../utils.js'; +import type { PairingManager } from '../pairing.js'; /** Configuration for the WhatsApp channel adapter. */ export interface WhatsAppAdapterConfig { @@ -29,6 +30,8 @@ export interface WhatsAppAdapterConfig { requireMention?: boolean; /** Directory for session persistence (LocalAuth data path). */ dataDir?: string; + /** Optional pairing manager for DM pairing codes. */ + pairingManager?: PairingManager; } /** Minimal shape of a whatsapp-web.js message. */ @@ -232,7 +235,26 @@ export class WhatsAppAdapter implements ChannelAdapter { this.config.allowedNumbers.length > 0 && !this.config.allowedNumbers.includes(phoneNumber) ) { - return; + // Pairing fallback — check if the sender is approved or sending a valid code + const pm = this.config.pairingManager; + if (pm?.enabled) { + if (pm.isApproved('whatsapp', phoneNumber)) { + // Approved — fall through to normal message handling + } else { + const text = (message.body ?? '').trim(); + if (text && pm.validateCode('whatsapp', phoneNumber, text)) { + // Code validated — send confirmation via WhatsApp + if (this.client) { + try { + await this.client.sendMessage(from, 'Pairing successful! You can now chat with Flynn.'); + } catch { /* ignore send errors */ } + } + } + return; + } + } else { + return; + } } } diff --git a/src/config/schema.ts b/src/config/schema.ts index ac67d95..f3764dd 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -371,6 +371,7 @@ export const configSchema = z.object({ agent_configs: agentConfigsSchema, routing: routingSchema, sessions: sessionsSchema, + pairing: pairingSchema, }); export type Config = z.infer; @@ -404,3 +405,4 @@ export type HeartbeatConfig = z.infer; export type HeartbeatCheck = z.infer; export type EmbeddingConfig = z.infer; export type EmbeddingProvider = z.infer; +export type PairingCodeConfig = z.infer; diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 0b7564b..174a14b 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -16,7 +16,7 @@ import { VectorStore, HybridSearch, createEmbeddingProvider, chunkText, contentH import type { EmbeddingProvider as EmbeddingProviderInterface } from '../memory/index.js'; import { createMemoryTools } from '../tools/builtin/index.js'; import { GatewayServer } from '../gateway/index.js'; -import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter } from '../channels/index.js'; +import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, PairingManager } from '../channels/index.js'; import { CronScheduler, WebhookHandler, HeartbeatMonitor, GmailWatcher } from '../automation/index.js'; import type { InboundMessage, OutboundMessage } from '../channels/index.js'; import { McpManager } from '../mcp/index.js'; @@ -793,6 +793,23 @@ export async function startDaemon(config: Config): Promise { // Initialize channel registry (created early so the gateway can reference it) const channelRegistry = new ChannelRegistry(); + // Create PairingManager if pairing is enabled + let pairingManager: PairingManager | undefined; + if (config.pairing.enabled) { + // Parse code_ttl: supports '5m', '1h', '30s' → milliseconds + const ttlMatch = config.pairing.code_ttl.match(/^(\d+)(s|m|h)$/); + const codeTtlMs = ttlMatch + ? Number(ttlMatch[1]) * ({ s: 1000, m: 60_000, h: 3_600_000 }[ttlMatch[2] as 's' | 'm' | 'h']) + : 5 * 60_000; // default 5 minutes + + pairingManager = new PairingManager({ + enabled: true, + codeTtl: codeTtlMs, + codeLength: config.pairing.code_length, + }); + console.log(`Pairing codes enabled (TTL: ${config.pairing.code_ttl}, length: ${config.pairing.code_length})`); + } + // Mutable reference to channel agents map — set after createMessageRouter() below. // This allows the gateway's getTokenUsage callback to access channel agent usage data. let channelAgents: Map | null = null; @@ -815,6 +832,7 @@ export async function startDaemon(config: Config): Promise { uiDir: resolve(import.meta.dirname, '../gateway/ui'), config, channelRegistry, + pairingManager, restart: async () => { console.log('Restart requested via gateway'); await lifecycle.shutdown(); @@ -883,6 +901,7 @@ export async function startDaemon(config: Config): Promise { allowedChatIds: config.telegram.allowed_chat_ids, requireMention: config.telegram.require_mention, hookEngine, + pairingManager, }); channelRegistry.register(telegramAdapter); @@ -893,6 +912,7 @@ export async function startDaemon(config: Config): Promise { allowedGuildIds: config.discord.allowed_guild_ids.length > 0 ? config.discord.allowed_guild_ids : undefined, allowedChannelIds: config.discord.allowed_channel_ids.length > 0 ? config.discord.allowed_channel_ids : undefined, requireMention: config.discord.require_mention, + pairingManager, }); channelRegistry.register(discordAdapter); } @@ -905,6 +925,7 @@ export async function startDaemon(config: Config): Promise { signingSecret: config.slack.signing_secret, allowedChannelIds: config.slack.allowed_channel_ids.length > 0 ? config.slack.allowed_channel_ids : undefined, requireMention: config.slack.require_mention, + pairingManager, }); channelRegistry.register(slackAdapter); } @@ -916,6 +937,7 @@ export async function startDaemon(config: Config): Promise { allowedGroupIds: config.whatsapp.allowed_group_ids.length > 0 ? config.whatsapp.allowed_group_ids : undefined, requireMention: config.whatsapp.require_mention, dataDir: config.whatsapp.data_dir, + pairingManager, }); channelRegistry.register(whatsappAdapter); } diff --git a/src/frontends/tui/commands.ts b/src/frontends/tui/commands.ts index cd7f9d3..207061a 100644 --- a/src/frontends/tui/commands.ts +++ b/src/frontends/tui/commands.ts @@ -11,6 +11,7 @@ export type Command = | { type: 'backend'; provider?: string } | { type: 'login'; provider?: string } | { type: 'transfer'; target: string } + | { type: 'pair'; action?: 'generate' | 'list' | 'revoke'; args?: string } | { type: 'message'; content: string }; export function parseCommand(input: string): Command | null { @@ -99,6 +100,19 @@ export function parseCommand(input: string): Command | null { return { type: 'login', provider: provider || undefined }; } + // Pair + if (trimmed === '/pair' || trimmed === '/pair list') { + return { type: 'pair', action: 'list' }; + } + if (trimmed === '/pair generate' || trimmed.startsWith('/pair generate ')) { + const label = trimmed.slice('/pair generate'.length).trim() || undefined; + return { type: 'pair', action: 'generate', args: label }; + } + if (trimmed.startsWith('/pair revoke ')) { + const args = trimmed.slice('/pair revoke '.length).trim(); + return { type: 'pair', action: 'revoke', args }; + } + // Regular message return { type: 'message', content: trimmed }; } @@ -111,6 +125,9 @@ Commands: /model

Change tier's provider/model (e.g. /model default anthropic/claude-sonnet-4) /backend [provider] Show or switch local backend (ollama, llamacpp) /login [provider] Authenticate with GitHub + /pair List pending pairing codes and approved senders + /pair generate [label] Generate a new DM pairing code + /pair revoke Revoke an approved sender /reset, /clear, /new Clear conversation history /compact Compact conversation history /usage Show token usage and estimated cost @@ -139,6 +156,7 @@ export const SLASH_COMMANDS = [ '/fullscreen', '/fs', '/login', + '/pair', '/transfer', '/quit', '/exit', @@ -159,6 +177,7 @@ export const COMMAND_TOOLTIPS: Record = { '/fullscreen': 'Switch to fullscreen mode', '/fs': 'Switch to fullscreen mode', '/login': 'Authenticate with GitHub (OAuth device flow)', + '/pair': 'Generate/list/revoke DM pairing codes', '/transfer': 'Transfer session to another frontend', '/quit': 'Exit TUI', '/exit': 'Exit TUI', diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index 70289e2..92fc20a 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -5,6 +5,8 @@ import { createSessionHandlers } from './sessions.js'; import { createToolHandlers } from './tools.js'; import { createAgentHandlers } from './agent.js'; import { createConfigHandlers, redactConfig } from './config.js'; +import { createPairingHandlers } from './pairing.js'; +import { PairingManager } from '../../channels/pairing.js'; import { LaneQueue } from '../lane-queue.js'; import { ErrorCode } from '../protocol.js'; import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, OutboundMessage } from '../protocol.js'; @@ -747,3 +749,75 @@ describe('redactConfig – comprehensive credential redaction', () => { expect(config.server.token).toBe('bearer-secret'); }); }); + +describe('pairing handlers', () => { + let pm: PairingManager; + let handlers: ReturnType; + + beforeEach(() => { + pm = new PairingManager({ enabled: true, codeTtl: 300_000, codeLength: 6 }); + handlers = createPairingHandlers({ pairingManager: pm }); + }); + + it('pairing.generate returns a code and expiry', async () => { + const req: GatewayRequest = { id: 1, method: 'pairing.generate', params: { label: 'for alice' } }; + const result = await handlers['pairing.generate'](req) as GatewayResponse; + + const r = result.result as { code: string; expiresAt: number }; + expect(r.code).toHaveLength(6); + expect(r.expiresAt).toBeGreaterThan(Date.now()); + }); + + it('pairing.generate works without label', async () => { + const req: GatewayRequest = { id: 2, method: 'pairing.generate' }; + const result = await handlers['pairing.generate'](req) as GatewayResponse; + + const r = result.result as { code: string; expiresAt: number }; + expect(r.code).toHaveLength(6); + }); + + it('pairing.list returns pending codes and approved senders', async () => { + // Generate a code first + pm.generateCode('test'); + // Approve a sender + const code = pm.generateCode('for bob'); + pm.validateCode('telegram', '12345', code); + + const req: GatewayRequest = { id: 3, method: 'pairing.list' }; + const result = await handlers['pairing.list'](req) as GatewayResponse; + + const r = result.result as { pending: unknown[]; approved: unknown[] }; + expect(r.pending).toHaveLength(1); // one code remaining (the other was consumed) + expect(r.approved).toHaveLength(1); + }); + + it('pairing.revoke removes an approved sender', async () => { + // Approve a sender + const code = pm.generateCode(); + pm.validateCode('discord', 'chan-1', code); + expect(pm.isApproved('discord', 'chan-1')).toBe(true); + + const req: GatewayRequest = { id: 4, method: 'pairing.revoke', params: { channel: 'discord', senderId: 'chan-1' } }; + const result = await handlers['pairing.revoke'](req) as GatewayResponse; + + const r = result.result as { revoked: boolean }; + expect(r.revoked).toBe(true); + expect(pm.isApproved('discord', 'chan-1')).toBe(false); + }); + + it('pairing.revoke returns false for unknown sender', async () => { + const req: GatewayRequest = { id: 5, method: 'pairing.revoke', params: { channel: 'telegram', senderId: 'unknown' } }; + const result = await handlers['pairing.revoke'](req) as GatewayResponse; + + const r = result.result as { revoked: boolean }; + expect(r.revoked).toBe(false); + }); + + it('pairing.revoke requires channel and senderId', async () => { + const req: GatewayRequest = { id: 6, method: 'pairing.revoke', params: {} }; + const result = await handlers['pairing.revoke'](req) as GatewayError; + + expect(result.error.code).toBe(ErrorCode.InvalidRequest); + expect(result.error.message).toContain('channel'); + }); +}); diff --git a/src/gateway/handlers/index.ts b/src/gateway/handlers/index.ts index 82e6965..a4c2f93 100644 --- a/src/gateway/handlers/index.ts +++ b/src/gateway/handlers/index.ts @@ -8,3 +8,5 @@ export { createAgentHandlers } from './agent.js'; export type { AgentHandlerDeps } from './agent.js'; export { createConfigHandlers } from './config.js'; export type { ConfigHandlerDeps } from './config.js'; +export { createPairingHandlers } from './pairing.js'; +export type { PairingHandlerDeps } from './pairing.js'; diff --git a/src/gateway/handlers/pairing.ts b/src/gateway/handlers/pairing.ts new file mode 100644 index 0000000..9881bd3 --- /dev/null +++ b/src/gateway/handlers/pairing.ts @@ -0,0 +1,42 @@ +import type { GatewayRequest, OutboundMessage } from '../protocol.js'; +import { makeResponse, makeError, ErrorCode } from '../protocol.js'; +import type { PairingManager } from '../../channels/pairing.js'; + +export interface PairingHandlerDeps { + pairingManager: PairingManager; +} + +export function createPairingHandlers(deps: PairingHandlerDeps) { + return { + 'pairing.generate': async (request: GatewayRequest): Promise => { + const label = request.params?.label as string | undefined; + const code = deps.pairingManager.generateCode(label); + const pending = deps.pairingManager.listPendingCodes(); + const entry = pending.find(p => p.code === code); + + return makeResponse(request.id, { + code, + expiresAt: entry?.expiresAt ?? null, + }); + }, + + 'pairing.list': async (request: GatewayRequest): Promise => { + return makeResponse(request.id, { + pending: deps.pairingManager.listPendingCodes(), + approved: deps.pairingManager.listApproved(), + }); + }, + + 'pairing.revoke': async (request: GatewayRequest): Promise => { + const channel = request.params?.channel as string | undefined; + const senderId = request.params?.senderId as string | undefined; + + if (!channel || !senderId) { + return makeError(request.id, ErrorCode.InvalidRequest, 'Missing required params: channel, senderId'); + } + + const revoked = deps.pairingManager.revokeApproval(channel, senderId); + return makeResponse(request.id, { revoked }); + }, + }; +} diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 22fa23f..4c67ca1 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -21,6 +21,7 @@ import { createToolHandlers, createAgentHandlers, createConfigHandlers, + createPairingHandlers, } from './handlers/index.js'; import type { TokenUsageEntry } from './handlers/system.js'; import type { SessionManager } from '../session/manager.js'; @@ -29,6 +30,7 @@ import type { ToolRegistry } from '../tools/registry.js'; import type { ToolExecutor } from '../tools/executor.js'; import type { WebhookHandler } from '../automation/webhooks.js'; import type { GmailWatcher } from '../automation/gmail.js'; +import type { PairingManager } from '../channels/pairing.js'; export interface GatewayServerConfig { port: number; @@ -55,6 +57,8 @@ export interface GatewayServerConfig { gmailHandler?: GmailWatcher; /** Optional callback to retrieve per-session token usage data for the dashboard. */ getTokenUsage?: () => TokenUsageEntry[]; + /** Optional pairing manager for DM pairing code management via gateway. */ + pairingManager?: PairingManager; } export class GatewayServer { @@ -124,6 +128,14 @@ export class GatewayServer { } } + // Pairing handlers (only if pairing manager is provided) + if (this.config.pairingManager) { + const pairingHandlers = createPairingHandlers({ pairingManager: this.config.pairingManager }); + for (const [method, handler] of Object.entries(pairingHandlers)) { + this.router.register(method, handler); + } + } + // Register all methods for (const [method, handler] of Object.entries(systemHandlers)) { this.router.register(method, handler);