feat: complete DM pairing codes with channel adapters, gateway handlers, and TUI command (Tier 4 feature 4)

This commit is contained in:
William Valentin
2026-02-09 18:28:10 -08:00
parent 9d4d440ecf
commit 1e29da4da2
11 changed files with 270 additions and 7 deletions
+17
View File
@@ -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)
+29 -1
View File
@@ -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
+27 -4
View File
@@ -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) ──
+23 -1
View File
@@ -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;
}
}
}
+2
View File
@@ -371,6 +371,7 @@ export const configSchema = z.object({
agent_configs: agentConfigsSchema,
routing: routingSchema,
sessions: sessionsSchema,
pairing: pairingSchema,
});
export type Config = z.infer<typeof configSchema>;
@@ -404,3 +405,4 @@ export type HeartbeatConfig = z.infer<typeof heartbeatSchema>;
export type HeartbeatCheck = z.infer<typeof heartbeatCheckSchema>;
export type EmbeddingConfig = z.infer<typeof embeddingSchema>;
export type EmbeddingProvider = z.infer<typeof embeddingProviderSchema>;
export type PairingCodeConfig = z.infer<typeof pairingSchema>;
+23 -1
View File
@@ -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<DaemonContext> {
// 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<string, { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector }> | null = null;
@@ -815,6 +832,7 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
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<DaemonContext> {
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<DaemonContext> {
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<DaemonContext> {
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<DaemonContext> {
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);
}
+19
View File
@@ -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 <tier> <p/m> 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 <ch> <id> 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<string, string> = {
'/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',
+74
View File
@@ -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<typeof createPairingHandlers>;
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');
});
});
+2
View File
@@ -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';
+42
View File
@@ -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<OutboundMessage> => {
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<OutboundMessage> => {
return makeResponse(request.id, {
pending: deps.pairingManager.listPendingCodes(),
approved: deps.pairingManager.listApproved(),
});
},
'pairing.revoke': async (request: GatewayRequest): Promise<OutboundMessage> => {
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 });
},
};
}
+12
View File
@@ -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);