feat: make /transfer bidirectional across telegram and tui
This commit is contained in:
@@ -78,13 +78,14 @@ describe('TelegramAdapter', () => {
|
||||
|
||||
// .use() for auth middleware
|
||||
expect(mockUse).toHaveBeenCalledTimes(1);
|
||||
// .command() for /start, /reset, /model, /local, /cloud
|
||||
expect(mockCommand).toHaveBeenCalledTimes(5);
|
||||
// .command() for /start, /reset, /model, /local, /cloud, /transfer
|
||||
expect(mockCommand).toHaveBeenCalledTimes(6);
|
||||
expect(mockCommand.mock.calls[0][0]).toBe('start');
|
||||
expect(mockCommand.mock.calls[1][0]).toBe('reset');
|
||||
expect(mockCommand.mock.calls[2][0]).toBe('model');
|
||||
expect(mockCommand.mock.calls[3][0]).toBe('local');
|
||||
expect(mockCommand.mock.calls[4][0]).toBe('cloud');
|
||||
expect(mockCommand.mock.calls[5][0]).toBe('transfer');
|
||||
// .on('message:text', ...) for text handler
|
||||
expect(mockOn).toHaveBeenCalledWith('message:text', expect.any(Function));
|
||||
// .start() to begin long polling
|
||||
@@ -269,6 +270,34 @@ describe('TelegramAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('/transfer command strips @bot suffix in groups', async () => {
|
||||
const handler = vi.fn();
|
||||
adapter.onMessage(handler);
|
||||
|
||||
await adapter.connect();
|
||||
|
||||
const transferCall = mockCommand.mock.calls.find((call) => call[0] === 'transfer');
|
||||
expect(transferCall).toBeDefined();
|
||||
const transferHandler = getCommandHandler('transfer');
|
||||
|
||||
const ctx = {
|
||||
message: { message_id: 124, text: '/transfer@flynn_bot tui' },
|
||||
chat: { id: 100 },
|
||||
from: { first_name: 'Will' },
|
||||
};
|
||||
|
||||
await transferHandler(ctx);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
const msg: InboundMessage = handler.mock.calls[0][0];
|
||||
expect(msg.text).toBe('/transfer tui');
|
||||
expect(msg.metadata).toEqual({
|
||||
isCommand: true,
|
||||
command: 'transfer',
|
||||
commandArgs: 'tui',
|
||||
});
|
||||
});
|
||||
|
||||
// ── Auth middleware ───────────────────────────────────────────
|
||||
|
||||
it('auth middleware blocks unauthorized chat IDs', async () => {
|
||||
|
||||
@@ -210,6 +210,28 @@ export class TelegramAdapter implements ChannelAdapter {
|
||||
});
|
||||
});
|
||||
|
||||
this.bot.command('transfer', async (ctx) => {
|
||||
if (!this.messageHandler) {return;}
|
||||
|
||||
// Telegram can deliver group commands in the form: /transfer@bot_username ...
|
||||
// Strip optional @mention for consistent parsing across contexts.
|
||||
const args = ctx.message?.text?.replace(/^\/transfer(?:@\S+)?\s*/i, '').trim() ?? '';
|
||||
|
||||
this.messageHandler({
|
||||
id: String(ctx.message?.message_id ?? Date.now()),
|
||||
channel: 'telegram',
|
||||
senderId: String(ctx.chat.id),
|
||||
senderName: ctx.from?.first_name,
|
||||
text: `/transfer ${args}`.trim(),
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
isCommand: true,
|
||||
command: 'transfer',
|
||||
commandArgs: args || undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ── Text message handler ──
|
||||
|
||||
this.bot.on('message:text', async (ctx) => {
|
||||
|
||||
+17
-7
@@ -238,15 +238,25 @@ export function registerTuiCommand(program: Command): void {
|
||||
});
|
||||
|
||||
const transferSessionToTarget = (target: string): string => {
|
||||
if (target !== 'telegram') {
|
||||
return `Unknown transfer target: ${target}`;
|
||||
const normalizedTarget = target.trim().toLowerCase();
|
||||
if (!normalizedTarget) {
|
||||
return 'Usage: /transfer <tui|telegram>';
|
||||
}
|
||||
if (!config.telegram || config.telegram.allowed_chat_ids.length === 0) {
|
||||
return 'Telegram not configured';
|
||||
|
||||
if (normalizedTarget === 'tui') {
|
||||
return 'Session is already active on TUI (local)';
|
||||
}
|
||||
const telegramUserId = String(config.telegram.allowed_chat_ids[0]);
|
||||
sessionManager.transferSession('tui', 'local', 'telegram', telegramUserId);
|
||||
return `Session transferred to Telegram (${telegramUserId})`;
|
||||
|
||||
if (normalizedTarget === 'telegram') {
|
||||
if (!config.telegram || config.telegram.allowed_chat_ids.length === 0) {
|
||||
return 'Telegram not configured';
|
||||
}
|
||||
const telegramUserId = String(config.telegram.allowed_chat_ids[0]);
|
||||
sessionManager.transferSession('tui', 'local', 'telegram', telegramUserId);
|
||||
return `Session transferred to Telegram (${telegramUserId})`;
|
||||
}
|
||||
|
||||
return `Unknown transfer target: ${target}. Supported targets: tui, telegram`;
|
||||
};
|
||||
|
||||
if (opts.fullscreen) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { createContextCommand, createElevateCommand, createModelCommand, createQueueCommand, createResearchCommand } from './index.js';
|
||||
import { createContextCommand, createElevateCommand, createModelCommand, createQueueCommand, createResearchCommand, createTransferCommand } from './index.js';
|
||||
|
||||
describe('builtin /model command', () => {
|
||||
it('passes through the full argument string', async () => {
|
||||
@@ -169,3 +169,31 @@ describe('builtin /context command', () => {
|
||||
expect(result).toEqual({ handled: true, text: 'Context command is not available in this session.' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('builtin /transfer command', () => {
|
||||
it('passes through the full target argument string', async () => {
|
||||
const cmd = createTransferCommand();
|
||||
const transferSession = vi.fn(() => 'Session transferred');
|
||||
const result = await cmd.execute(['telegram'], {
|
||||
channel: 'test',
|
||||
senderId: 'user',
|
||||
sessionId: 's1',
|
||||
rawInput: '/transfer telegram',
|
||||
services: { transferSession },
|
||||
});
|
||||
expect(transferSession).toHaveBeenCalledWith('telegram');
|
||||
expect(result).toEqual({ handled: true, text: 'Session transferred' });
|
||||
});
|
||||
|
||||
it('returns not-available when service is missing', async () => {
|
||||
const cmd = createTransferCommand();
|
||||
const result = await cmd.execute(['tui'], {
|
||||
channel: 'test',
|
||||
senderId: 'user',
|
||||
sessionId: 's1',
|
||||
rawInput: '/transfer tui',
|
||||
services: {},
|
||||
});
|
||||
expect(result).toEqual({ handled: true, text: 'Transfer command is not available in this session.' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,6 +218,24 @@ export function createResearchCommand(): CommandDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
export function createTransferCommand(): CommandDefinition {
|
||||
return {
|
||||
name: 'transfer',
|
||||
description: 'Transfer session to another frontend',
|
||||
execute: async (args, ctx) => {
|
||||
if (!ctx.services?.transferSession) {
|
||||
return notAvailable('Transfer command');
|
||||
}
|
||||
|
||||
const target = args.join(' ').trim();
|
||||
return {
|
||||
handled: true,
|
||||
text: await ctx.services.transferSession(target),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerBuiltinCommands(registry: CommandRegistry): void {
|
||||
registry.register(createHelpCommand(registry));
|
||||
registry.register(createStatusCommand());
|
||||
@@ -229,4 +247,5 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
|
||||
registry.register(createResetCommand());
|
||||
registry.register(createElevateCommand());
|
||||
registry.register(createQueueCommand());
|
||||
registry.register(createTransferCommand());
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ export {
|
||||
createCompactCommand,
|
||||
createResetCommand,
|
||||
createQueueCommand,
|
||||
createTransferCommand,
|
||||
registerBuiltinCommands,
|
||||
} from './builtin/index.js';
|
||||
|
||||
@@ -34,4 +34,5 @@ export interface CommandServices {
|
||||
getQueue?: () => Promise<string> | string;
|
||||
setQueue?: (input: string) => Promise<string> | string;
|
||||
resetQueue?: () => Promise<string> | string;
|
||||
transferSession?: (target: string) => Promise<string> | string;
|
||||
}
|
||||
|
||||
@@ -213,6 +213,76 @@ describe('daemon command fast-path integration', () => {
|
||||
expect(session.deleteConfig).toHaveBeenCalledWith('modelTier');
|
||||
});
|
||||
|
||||
it('handles /transfer command via command fast-path and copies session to TUI', async () => {
|
||||
const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process');
|
||||
const transferSpy = vi.fn();
|
||||
const session = {
|
||||
id: 'telegram:user-1',
|
||||
addMessage: vi.fn(),
|
||||
getHistory: vi.fn(() => []),
|
||||
clear: vi.fn(),
|
||||
replaceHistory: vi.fn(),
|
||||
getConfig: vi.fn(() => undefined),
|
||||
setConfig: vi.fn(),
|
||||
deleteConfig: vi.fn(),
|
||||
};
|
||||
|
||||
const commandRegistry = new CommandRegistry();
|
||||
registerBuiltinCommands(commandRegistry);
|
||||
|
||||
const router = createMessageRouter({
|
||||
sessionManager: {
|
||||
getSession: vi.fn(() => session),
|
||||
transferSession: transferSpy,
|
||||
} as unknown as MessageRouterDeps['sessionManager'],
|
||||
modelRouter: {
|
||||
getAvailableTiers: () => ['fast', 'default', 'complex', 'local'],
|
||||
getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }),
|
||||
getLabel: (tier: string) => tier,
|
||||
} as unknown as MessageRouterDeps['modelRouter'],
|
||||
systemPrompt: 'test prompt',
|
||||
toolRegistry: {
|
||||
clone() { return this; },
|
||||
register: vi.fn(),
|
||||
} as unknown as MessageRouterDeps['toolRegistry'],
|
||||
toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'],
|
||||
config: {
|
||||
agents: {
|
||||
primary_tier: 'default',
|
||||
delegation: {
|
||||
compaction: 'fast',
|
||||
memory_extraction: 'fast',
|
||||
classification: 'fast',
|
||||
tool_summarisation: 'fast',
|
||||
complex_reasoning: 'complex',
|
||||
},
|
||||
max_delegation_depth: 3,
|
||||
max_iterations: 10,
|
||||
},
|
||||
compaction: { enabled: false },
|
||||
models: { default: { provider: 'anthropic', model: 'claude' } },
|
||||
} as unknown as MessageRouterDeps['config'],
|
||||
commandRegistry,
|
||||
});
|
||||
|
||||
const reply = vi.fn(async (_message: OutboundMessage) => {});
|
||||
await router.handler({
|
||||
id: 'm-transfer',
|
||||
channel: 'telegram',
|
||||
senderId: 'user-1',
|
||||
text: '/transfer tui',
|
||||
timestamp: Date.now(),
|
||||
metadata: { isCommand: true, command: 'transfer', commandArgs: 'tui' },
|
||||
} as MessageRouterInput, reply);
|
||||
|
||||
expect(processSpy).not.toHaveBeenCalled();
|
||||
expect(transferSpy).toHaveBeenCalledWith('telegram', 'user-1', 'tui', 'local');
|
||||
expect(reply).toHaveBeenCalledWith({
|
||||
text: 'Session transferred to TUI (local)',
|
||||
replyTo: 'm-transfer',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits user.action audit events for channel messages', async () => {
|
||||
const mockAuditLogger = {
|
||||
userAction: vi.fn(),
|
||||
|
||||
+40
-1
@@ -274,7 +274,7 @@ export function createMessageRouter(deps: {
|
||||
tier: effectiveTier,
|
||||
autonomyLevel: deps.config.agents.autonomy_level ?? 'standard',
|
||||
sensitiveMode: deps.config.agents.sensitive_mode,
|
||||
immutableDenylist: deps.config.agents.immutable_denylist.map((rule) => ({
|
||||
immutableDenylist: (deps.config.agents.immutable_denylist ?? []).map((rule) => ({
|
||||
tool: rule.tool,
|
||||
argsPattern: rule.args_pattern,
|
||||
reason: rule.reason,
|
||||
@@ -838,6 +838,45 @@ export function createMessageRouter(deps: {
|
||||
session.deleteConfig('queue.summarize_overflow');
|
||||
return 'Reset session queue overrides.';
|
||||
},
|
||||
|
||||
transferSession: (targetRaw: string) => {
|
||||
const target = targetRaw.trim().toLowerCase();
|
||||
if (!target) {
|
||||
return 'Usage: /transfer <tui|telegram>';
|
||||
}
|
||||
|
||||
let toFrontend: string;
|
||||
let toUserId: string;
|
||||
let destinationLabel: string;
|
||||
|
||||
if (target === 'tui') {
|
||||
toFrontend = 'tui';
|
||||
toUserId = 'local';
|
||||
destinationLabel = 'TUI (local)';
|
||||
} else if (target === 'telegram') {
|
||||
if (msg.channel === 'telegram') {
|
||||
toFrontend = 'telegram';
|
||||
toUserId = msg.senderId;
|
||||
} else {
|
||||
const chatId = deps.config.telegram?.allowed_chat_ids?.[0];
|
||||
if (chatId === undefined) {
|
||||
return 'Telegram not configured';
|
||||
}
|
||||
toFrontend = 'telegram';
|
||||
toUserId = String(chatId);
|
||||
}
|
||||
destinationLabel = `Telegram (${toUserId})`;
|
||||
} else {
|
||||
return `Unknown transfer target: ${target}. Supported targets: tui, telegram`;
|
||||
}
|
||||
|
||||
if (msg.channel === toFrontend && msg.senderId === toUserId) {
|
||||
return `Session is already active on ${destinationLabel}`;
|
||||
}
|
||||
|
||||
deps.sessionManager.transferSession(msg.channel, msg.senderId, toFrontend, toUserId);
|
||||
return `Session transferred to ${destinationLabel}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ describe('parseCommand', () => {
|
||||
|
||||
it('parses /transfer command', () => {
|
||||
expect(parseCommand('/transfer telegram')).toEqual({ type: 'transfer', target: 'telegram' });
|
||||
expect(parseCommand('/transfer')).toEqual({ type: 'transfer', target: '' });
|
||||
});
|
||||
|
||||
it('parses /queue commands', () => {
|
||||
|
||||
@@ -94,6 +94,9 @@ export function parseCommand(input: string): Command | null {
|
||||
}
|
||||
|
||||
// Transfer
|
||||
if (trimmed === '/transfer') {
|
||||
return { type: 'transfer', target: '' };
|
||||
}
|
||||
if (trimmed.startsWith('/transfer ')) {
|
||||
const target = trimmed.slice('/transfer '.length).trim();
|
||||
return { type: 'transfer', target };
|
||||
@@ -172,7 +175,7 @@ Commands:
|
||||
/verbose Toggle verbose mode (show raw streaming and tool output)
|
||||
/status Show session info and token usage
|
||||
/fullscreen, /fs Switch to fullscreen mode
|
||||
/transfer <dest> Transfer session to another frontend
|
||||
/transfer <dest> Transfer session to another frontend (telegram|tui)
|
||||
/quit, /exit Exit TUI
|
||||
`.trim();
|
||||
}
|
||||
@@ -224,7 +227,7 @@ export const COMMAND_TOOLTIPS: Record<string, string> = {
|
||||
'/pair': 'Generate/list/revoke DM pairing codes',
|
||||
'/queue': 'Show or update per-session queue policy',
|
||||
'/elevate': 'Show or manage elevated mode',
|
||||
'/transfer': 'Transfer session to another frontend',
|
||||
'/transfer': 'Transfer session to another frontend (telegram|tui)',
|
||||
'/quit': 'Exit TUI',
|
||||
'/exit': 'Exit TUI',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user