feat: complete DM pairing codes with channel adapters, gateway handlers, and TUI command (Tier 4 feature 4)
This commit is contained in:
@@ -18,6 +18,7 @@ import type {
|
|||||||
ChannelStatus,
|
ChannelStatus,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { splitMessage } from '../utils.js';
|
import { splitMessage } from '../utils.js';
|
||||||
|
import type { PairingManager } from '../pairing.js';
|
||||||
|
|
||||||
/** Configuration for the Discord channel adapter. */
|
/** Configuration for the Discord channel adapter. */
|
||||||
export interface DiscordAdapterConfig {
|
export interface DiscordAdapterConfig {
|
||||||
@@ -28,6 +29,8 @@ export interface DiscordAdapterConfig {
|
|||||||
allowedChannelIds?: string[];
|
allowedChannelIds?: string[];
|
||||||
/** Whether to require mention to respond in guild channels (default: true). DMs always respond. */
|
/** Whether to require mention to respond in guild channels (default: true). DMs always respond. */
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
|
/** Optional pairing manager for DM pairing codes. */
|
||||||
|
pairingManager?: PairingManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,6 +197,20 @@ export class DiscordAdapter implements ChannelAdapter {
|
|||||||
return;
|
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)
|
// Send typing indicator (lasts 10 seconds, no need for interval)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
ChannelStatus,
|
ChannelStatus,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { splitMessage } from '../utils.js';
|
import { splitMessage } from '../utils.js';
|
||||||
|
import type { PairingManager } from '../pairing.js';
|
||||||
|
|
||||||
/** Configuration for the Slack channel adapter. */
|
/** Configuration for the Slack channel adapter. */
|
||||||
export interface SlackAdapterConfig {
|
export interface SlackAdapterConfig {
|
||||||
@@ -26,6 +27,8 @@ export interface SlackAdapterConfig {
|
|||||||
allowedChannelIds?: string[];
|
allowedChannelIds?: string[];
|
||||||
/** Require bot mention to respond (default: false). */
|
/** Require bot mention to respond (default: false). */
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
|
/** Optional pairing manager for DM pairing codes. */
|
||||||
|
pairingManager?: PairingManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal shape of a Slack message event from Bolt. */
|
/** Minimal shape of a Slack message event from Bolt. */
|
||||||
@@ -275,8 +278,33 @@ export class SlackAdapter implements ChannelAdapter {
|
|||||||
this.config.allowedChannelIds.length > 0 &&
|
this.config.allowedChannelIds.length > 0 &&
|
||||||
!this.config.allowedChannelIds.includes(channelId)
|
!this.config.allowedChannelIds.includes(channelId)
|
||||||
) {
|
) {
|
||||||
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mention requirement
|
// Mention requirement
|
||||||
const requireMention = this.config.requireMention ?? false;
|
const requireMention = this.config.requireMention ?? false;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
import { isAllowedChat } from '../../frontends/telegram/handlers.js';
|
import { isAllowedChat } from '../../frontends/telegram/handlers.js';
|
||||||
import { parseConfirmationCallback } from '../../frontends/telegram/confirmations.js';
|
import { parseConfirmationCallback } from '../../frontends/telegram/confirmations.js';
|
||||||
import { splitMessage } from '../utils.js';
|
import { splitMessage } from '../utils.js';
|
||||||
|
import type { PairingManager } from '../pairing.js';
|
||||||
|
|
||||||
/** Configuration for the Telegram channel adapter. */
|
/** Configuration for the Telegram channel adapter. */
|
||||||
export interface TelegramAdapterConfig {
|
export interface TelegramAdapterConfig {
|
||||||
@@ -20,6 +21,8 @@ export interface TelegramAdapterConfig {
|
|||||||
/** Require bot mention or reply-to-bot to respond in group chats (default: true). */
|
/** Require bot mention or reply-to-bot to respond in group chats (default: true). */
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
hookEngine?: HookEngine;
|
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.bot = new Bot(this.config.botToken);
|
||||||
this._status = 'connecting';
|
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) => {
|
this.bot.use(async (ctx, next) => {
|
||||||
const chatId = ctx.chat?.id;
|
const chatId = ctx.chat?.id;
|
||||||
if (chatId === undefined || !isAllowedChat(chatId, this.config.allowedChatIds)) {
|
if (chatId === undefined) return;
|
||||||
console.log(`Rejected message from unauthorized chat: ${chatId}`);
|
|
||||||
|
// Allowlist check
|
||||||
|
if (isAllowedChat(chatId, this.config.allowedChatIds)) {
|
||||||
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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();
|
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) ──
|
// ── Confirmation callback handler (requires hookEngine) ──
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
ChannelStatus,
|
ChannelStatus,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { splitMessage } from '../utils.js';
|
import { splitMessage } from '../utils.js';
|
||||||
|
import type { PairingManager } from '../pairing.js';
|
||||||
|
|
||||||
/** Configuration for the WhatsApp channel adapter. */
|
/** Configuration for the WhatsApp channel adapter. */
|
||||||
export interface WhatsAppAdapterConfig {
|
export interface WhatsAppAdapterConfig {
|
||||||
@@ -29,6 +30,8 @@ export interface WhatsAppAdapterConfig {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Directory for session persistence (LocalAuth data path). */
|
/** Directory for session persistence (LocalAuth data path). */
|
||||||
dataDir?: string;
|
dataDir?: string;
|
||||||
|
/** Optional pairing manager for DM pairing codes. */
|
||||||
|
pairingManager?: PairingManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal shape of a whatsapp-web.js message. */
|
/** Minimal shape of a whatsapp-web.js message. */
|
||||||
@@ -232,8 +235,27 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
|||||||
this.config.allowedNumbers.length > 0 &&
|
this.config.allowedNumbers.length > 0 &&
|
||||||
!this.config.allowedNumbers.includes(phoneNumber)
|
!this.config.allowedNumbers.includes(phoneNumber)
|
||||||
) {
|
) {
|
||||||
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send typing indicator
|
// Send typing indicator
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ export const configSchema = z.object({
|
|||||||
agent_configs: agentConfigsSchema,
|
agent_configs: agentConfigsSchema,
|
||||||
routing: routingSchema,
|
routing: routingSchema,
|
||||||
sessions: sessionsSchema,
|
sessions: sessionsSchema,
|
||||||
|
pairing: pairingSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = z.infer<typeof configSchema>;
|
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 HeartbeatCheck = z.infer<typeof heartbeatCheckSchema>;
|
||||||
export type EmbeddingConfig = z.infer<typeof embeddingSchema>;
|
export type EmbeddingConfig = z.infer<typeof embeddingSchema>;
|
||||||
export type EmbeddingProvider = z.infer<typeof embeddingProviderSchema>;
|
export type EmbeddingProvider = z.infer<typeof embeddingProviderSchema>;
|
||||||
|
export type PairingCodeConfig = z.infer<typeof pairingSchema>;
|
||||||
|
|||||||
+23
-1
@@ -16,7 +16,7 @@ import { VectorStore, HybridSearch, createEmbeddingProvider, chunkText, contentH
|
|||||||
import type { EmbeddingProvider as EmbeddingProviderInterface } from '../memory/index.js';
|
import type { EmbeddingProvider as EmbeddingProviderInterface } from '../memory/index.js';
|
||||||
import { createMemoryTools } from '../tools/builtin/index.js';
|
import { createMemoryTools } from '../tools/builtin/index.js';
|
||||||
import { GatewayServer } from '../gateway/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 { CronScheduler, WebhookHandler, HeartbeatMonitor, GmailWatcher } from '../automation/index.js';
|
||||||
import type { InboundMessage, OutboundMessage } from '../channels/index.js';
|
import type { InboundMessage, OutboundMessage } from '../channels/index.js';
|
||||||
import { McpManager } from '../mcp/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)
|
// Initialize channel registry (created early so the gateway can reference it)
|
||||||
const channelRegistry = new ChannelRegistry();
|
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.
|
// Mutable reference to channel agents map — set after createMessageRouter() below.
|
||||||
// This allows the gateway's getTokenUsage callback to access channel agent usage data.
|
// This allows the gateway's getTokenUsage callback to access channel agent usage data.
|
||||||
let channelAgents: Map<string, { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector }> | null = null;
|
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'),
|
uiDir: resolve(import.meta.dirname, '../gateway/ui'),
|
||||||
config,
|
config,
|
||||||
channelRegistry,
|
channelRegistry,
|
||||||
|
pairingManager,
|
||||||
restart: async () => {
|
restart: async () => {
|
||||||
console.log('Restart requested via gateway');
|
console.log('Restart requested via gateway');
|
||||||
await lifecycle.shutdown();
|
await lifecycle.shutdown();
|
||||||
@@ -883,6 +901,7 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
|||||||
allowedChatIds: config.telegram.allowed_chat_ids,
|
allowedChatIds: config.telegram.allowed_chat_ids,
|
||||||
requireMention: config.telegram.require_mention,
|
requireMention: config.telegram.require_mention,
|
||||||
hookEngine,
|
hookEngine,
|
||||||
|
pairingManager,
|
||||||
});
|
});
|
||||||
channelRegistry.register(telegramAdapter);
|
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,
|
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,
|
allowedChannelIds: config.discord.allowed_channel_ids.length > 0 ? config.discord.allowed_channel_ids : undefined,
|
||||||
requireMention: config.discord.require_mention,
|
requireMention: config.discord.require_mention,
|
||||||
|
pairingManager,
|
||||||
});
|
});
|
||||||
channelRegistry.register(discordAdapter);
|
channelRegistry.register(discordAdapter);
|
||||||
}
|
}
|
||||||
@@ -905,6 +925,7 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
|||||||
signingSecret: config.slack.signing_secret,
|
signingSecret: config.slack.signing_secret,
|
||||||
allowedChannelIds: config.slack.allowed_channel_ids.length > 0 ? config.slack.allowed_channel_ids : undefined,
|
allowedChannelIds: config.slack.allowed_channel_ids.length > 0 ? config.slack.allowed_channel_ids : undefined,
|
||||||
requireMention: config.slack.require_mention,
|
requireMention: config.slack.require_mention,
|
||||||
|
pairingManager,
|
||||||
});
|
});
|
||||||
channelRegistry.register(slackAdapter);
|
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,
|
allowedGroupIds: config.whatsapp.allowed_group_ids.length > 0 ? config.whatsapp.allowed_group_ids : undefined,
|
||||||
requireMention: config.whatsapp.require_mention,
|
requireMention: config.whatsapp.require_mention,
|
||||||
dataDir: config.whatsapp.data_dir,
|
dataDir: config.whatsapp.data_dir,
|
||||||
|
pairingManager,
|
||||||
});
|
});
|
||||||
channelRegistry.register(whatsappAdapter);
|
channelRegistry.register(whatsappAdapter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type Command =
|
|||||||
| { type: 'backend'; provider?: string }
|
| { type: 'backend'; provider?: string }
|
||||||
| { type: 'login'; provider?: string }
|
| { type: 'login'; provider?: string }
|
||||||
| { type: 'transfer'; target: string }
|
| { type: 'transfer'; target: string }
|
||||||
|
| { type: 'pair'; action?: 'generate' | 'list' | 'revoke'; args?: string }
|
||||||
| { type: 'message'; content: string };
|
| { type: 'message'; content: string };
|
||||||
|
|
||||||
export function parseCommand(input: string): Command | null {
|
export function parseCommand(input: string): Command | null {
|
||||||
@@ -99,6 +100,19 @@ export function parseCommand(input: string): Command | null {
|
|||||||
return { type: 'login', provider: provider || undefined };
|
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
|
// Regular message
|
||||||
return { type: 'message', content: trimmed };
|
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)
|
/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)
|
/backend [provider] Show or switch local backend (ollama, llamacpp)
|
||||||
/login [provider] Authenticate with GitHub
|
/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
|
/reset, /clear, /new Clear conversation history
|
||||||
/compact Compact conversation history
|
/compact Compact conversation history
|
||||||
/usage Show token usage and estimated cost
|
/usage Show token usage and estimated cost
|
||||||
@@ -139,6 +156,7 @@ export const SLASH_COMMANDS = [
|
|||||||
'/fullscreen',
|
'/fullscreen',
|
||||||
'/fs',
|
'/fs',
|
||||||
'/login',
|
'/login',
|
||||||
|
'/pair',
|
||||||
'/transfer',
|
'/transfer',
|
||||||
'/quit',
|
'/quit',
|
||||||
'/exit',
|
'/exit',
|
||||||
@@ -159,6 +177,7 @@ export const COMMAND_TOOLTIPS: Record<string, string> = {
|
|||||||
'/fullscreen': 'Switch to fullscreen mode',
|
'/fullscreen': 'Switch to fullscreen mode',
|
||||||
'/fs': 'Switch to fullscreen mode',
|
'/fs': 'Switch to fullscreen mode',
|
||||||
'/login': 'Authenticate with GitHub (OAuth device flow)',
|
'/login': 'Authenticate with GitHub (OAuth device flow)',
|
||||||
|
'/pair': 'Generate/list/revoke DM pairing codes',
|
||||||
'/transfer': 'Transfer session to another frontend',
|
'/transfer': 'Transfer session to another frontend',
|
||||||
'/quit': 'Exit TUI',
|
'/quit': 'Exit TUI',
|
||||||
'/exit': 'Exit TUI',
|
'/exit': 'Exit TUI',
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { createSessionHandlers } from './sessions.js';
|
|||||||
import { createToolHandlers } from './tools.js';
|
import { createToolHandlers } from './tools.js';
|
||||||
import { createAgentHandlers } from './agent.js';
|
import { createAgentHandlers } from './agent.js';
|
||||||
import { createConfigHandlers, redactConfig } from './config.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 { LaneQueue } from '../lane-queue.js';
|
||||||
import { ErrorCode } from '../protocol.js';
|
import { ErrorCode } from '../protocol.js';
|
||||||
import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, OutboundMessage } 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');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,3 +8,5 @@ export { createAgentHandlers } from './agent.js';
|
|||||||
export type { AgentHandlerDeps } from './agent.js';
|
export type { AgentHandlerDeps } from './agent.js';
|
||||||
export { createConfigHandlers } from './config.js';
|
export { createConfigHandlers } from './config.js';
|
||||||
export type { ConfigHandlerDeps } from './config.js';
|
export type { ConfigHandlerDeps } from './config.js';
|
||||||
|
export { createPairingHandlers } from './pairing.js';
|
||||||
|
export type { PairingHandlerDeps } from './pairing.js';
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
createToolHandlers,
|
createToolHandlers,
|
||||||
createAgentHandlers,
|
createAgentHandlers,
|
||||||
createConfigHandlers,
|
createConfigHandlers,
|
||||||
|
createPairingHandlers,
|
||||||
} from './handlers/index.js';
|
} from './handlers/index.js';
|
||||||
import type { TokenUsageEntry } from './handlers/system.js';
|
import type { TokenUsageEntry } from './handlers/system.js';
|
||||||
import type { SessionManager } from '../session/manager.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 { ToolExecutor } from '../tools/executor.js';
|
||||||
import type { WebhookHandler } from '../automation/webhooks.js';
|
import type { WebhookHandler } from '../automation/webhooks.js';
|
||||||
import type { GmailWatcher } from '../automation/gmail.js';
|
import type { GmailWatcher } from '../automation/gmail.js';
|
||||||
|
import type { PairingManager } from '../channels/pairing.js';
|
||||||
|
|
||||||
export interface GatewayServerConfig {
|
export interface GatewayServerConfig {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -55,6 +57,8 @@ export interface GatewayServerConfig {
|
|||||||
gmailHandler?: GmailWatcher;
|
gmailHandler?: GmailWatcher;
|
||||||
/** Optional callback to retrieve per-session token usage data for the dashboard. */
|
/** Optional callback to retrieve per-session token usage data for the dashboard. */
|
||||||
getTokenUsage?: () => TokenUsageEntry[];
|
getTokenUsage?: () => TokenUsageEntry[];
|
||||||
|
/** Optional pairing manager for DM pairing code management via gateway. */
|
||||||
|
pairingManager?: PairingManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GatewayServer {
|
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
|
// Register all methods
|
||||||
for (const [method, handler] of Object.entries(systemHandlers)) {
|
for (const [method, handler] of Object.entries(systemHandlers)) {
|
||||||
this.router.register(method, handler);
|
this.router.register(method, handler);
|
||||||
|
|||||||
Reference in New Issue
Block a user