From 32bebbd64437b3f5c0108fd17fcaa62088c4dffe Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:00:13 +0200 Subject: [PATCH 01/35] MAESTRO: add grammy dependency for Telegram provider Installs grammy@^1.30.0 (resolved to 1.42.0) as the foundation for the new Telegram provider. Verified npm run build still passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 89 +++++++++++++++++++++++++++++++++++++++++++++-- package.json | 3 +- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1f2cf71..3b8177b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "better-sqlite3": "^12.8.0", "discord.js": "^14.0.0", - "dotenv": "^16.0.0" + "dotenv": "^16.0.0", + "grammy": "^1.42.0" }, "bin": { "maestro-bridge": "dist/cli/maestro-relay.js", @@ -676,6 +677,12 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@grammyjs/types": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.26.0.tgz", + "integrity": "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1106,6 +1113,18 @@ "npm": ">=7.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1306,7 +1325,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1646,6 +1664,15 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1803,6 +1830,21 @@ "node": ">=10.13.0" } }, + "node_modules/grammy": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.42.0.tgz", + "integrity": "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g==", + "license": "MIT", + "dependencies": { + "@grammyjs/types": "3.26.0", + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -2018,7 +2060,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/napi-build-utils": { @@ -2046,6 +2087,26 @@ "node": ">=10" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2434,6 +2495,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2622,6 +2689,22 @@ "dev": true, "license": "MIT" }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 3056a54..beb8f02 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "dependencies": { "better-sqlite3": "^12.8.0", "discord.js": "^14.0.0", - "dotenv": "^16.0.0" + "dotenv": "^16.0.0", + "grammy": "^1.42.0" }, "devDependencies": { "@eslint/js": "^10.0.1", From 052d9b574811926184a061ce668df54a6d3f137e Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:01:31 +0200 Subject: [PATCH 02/35] MAESTRO: add Telegram provider config loader Mirrors src/providers/discord/config.ts: lazy getters for TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_AGENT_ID, TELEGRAM_ALLOWED_USER_IDS, TELEGRAM_MENTION_USER_ID. Required fields throw on first access so a disabled Telegram provider does not fail bridge startup. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/providers/telegram/config.ts | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/providers/telegram/config.ts diff --git a/src/providers/telegram/config.ts b/src/providers/telegram/config.ts new file mode 100644 index 0000000..fa55b3c --- /dev/null +++ b/src/providers/telegram/config.ts @@ -0,0 +1,33 @@ +import { required } from '../../core/config'; + +function csv(key: string): string[] { + const val = process.env[key]; + if (!val) return []; + return val + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +/** + * Telegram adapter configuration. Loaded lazily so a deployment that + * disables Telegram (ENABLED_PROVIDERS=discord) does not fail at startup + * for missing TELEGRAM_BOT_TOKEN. + */ +export const telegramConfig = { + get token() { + return required('TELEGRAM_BOT_TOKEN'); + }, + get chatId() { + return required('TELEGRAM_CHAT_ID'); + }, + get agentId() { + return required('TELEGRAM_AGENT_ID'); + }, + get allowedUserIds() { + return csv('TELEGRAM_ALLOWED_USER_IDS'); + }, + get mentionUserId() { + return process.env.TELEGRAM_MENTION_USER_ID || ''; + }, +}; From 3da832d37e936469aacfe85b159bbedf58210687 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:03:11 +0200 Subject: [PATCH 03/35] MAESTRO: add Telegram topics DB wrapper Mirrors discord/threadsDb.ts: exports TelegramAgentTopic interface and topicDb with register/get/getByAgentId/updateSession/remove/listByChat backed by the shared core SQLite instance. Table creation lands in the next playbook task. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/providers/telegram/topicsDb.ts | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/providers/telegram/topicsDb.ts diff --git a/src/providers/telegram/topicsDb.ts b/src/providers/telegram/topicsDb.ts new file mode 100644 index 0000000..121797d --- /dev/null +++ b/src/providers/telegram/topicsDb.ts @@ -0,0 +1,49 @@ +import { db } from '../../core/db'; + +export interface TelegramAgentTopic { + topic_id: number; + chat_id: string; + agent_id: string; + session_id: string | null; + created_at: number; +} + +export const topicDb = { + register(topicId: number, chatId: string, agentId: string): void { + db.prepare( + `INSERT INTO telegram_agent_topics (topic_id, chat_id, agent_id, created_at) + VALUES (?, ?, ?, ?)`, + ).run(topicId, chatId, agentId, Date.now()); + }, + + get(chatId: string, topicId: number): TelegramAgentTopic | undefined { + return db + .prepare('SELECT * FROM telegram_agent_topics WHERE chat_id = ? AND topic_id = ?') + .get(chatId, topicId) as TelegramAgentTopic | undefined; + }, + + getByAgentId(agentId: string): TelegramAgentTopic[] { + return db + .prepare('SELECT * FROM telegram_agent_topics WHERE agent_id = ? ORDER BY created_at DESC') + .all(agentId) as TelegramAgentTopic[]; + }, + + updateSession(chatId: string, topicId: number, sessionId: string | null): void { + db.prepare( + 'UPDATE telegram_agent_topics SET session_id = ? WHERE chat_id = ? AND topic_id = ?', + ).run(sessionId, chatId, topicId); + }, + + remove(chatId: string, topicId: number): void { + db.prepare('DELETE FROM telegram_agent_topics WHERE chat_id = ? AND topic_id = ?').run( + chatId, + topicId, + ); + }, + + listByChat(chatId: string): TelegramAgentTopic[] { + return db + .prepare('SELECT * FROM telegram_agent_topics WHERE chat_id = ? ORDER BY created_at DESC') + .all(chatId) as TelegramAgentTopic[]; + }, +}; From 50a375f8ed3ca0ce20f630b3dbd1edc8b25f0da2 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:05:21 +0200 Subject: [PATCH 04/35] MAESTRO: register telegram_agent_topics migration Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/db/migrations.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/core/db/migrations.ts b/src/core/db/migrations.ts index ae9d26a..752c120 100644 --- a/src/core/db/migrations.ts +++ b/src/core/db/migrations.ts @@ -8,12 +8,14 @@ import type Database from 'better-sqlite3'; * 2. Add `owner_user_id` to agent_threads (legacy) * 3. Add `provider` column + composite PK (provider, channel_id) to agent_channels * 4. Rename `agent_threads` → `discord_agent_threads` + * 5. Create `telegram_agent_topics` for forum-topic-per-session tracking */ export function runMigrations(db: Database.Database): void { ensureReadOnlyColumn(db); ensureProviderColumn(db); renameAgentThreadsTable(db); ensureDiscordThreadsTable(db); + ensureTelegramTopicsTable(db); ensureOwnerUserIdColumn(db); } @@ -119,3 +121,16 @@ function ensureDiscordThreadsTable(database: Database.Database): void { ) `); } + +function ensureTelegramTopicsTable(database: Database.Database): void { + database.exec(` + CREATE TABLE IF NOT EXISTS telegram_agent_topics ( + topic_id INTEGER NOT NULL, + chat_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + session_id TEXT, + created_at INTEGER NOT NULL, + PRIMARY KEY (chat_id, topic_id) + ) + `); +} From 77119e5f7b56a77ebba41ace92b08e5f8f1152de Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:06:37 +0200 Subject: [PATCH 05/35] MAESTRO: document Telegram provider env vars in .env.example --- .env.example | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.env.example b/.env.example index abc1e86..c13b80a 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,11 @@ DISCORD_CLIENT_ID=your_application_client_id_here DISCORD_GUILD_ID=your_guild_id_here DISCORD_ALLOWED_USER_IDS=123,456 # comma-separated Discord user IDs allowed to use slash commands DISCORD_MENTION_USER_ID= # optional: Discord user ID to @mention when --mention is used + +# --- Telegram provider (loaded only if 'telegram' is in ENABLED_PROVIDERS) --- +# Setup walkthrough: see docs/telegram-setup.md +TELEGRAM_BOT_TOKEN= # from @BotFather (https://t.me/BotFather → /newbot) +TELEGRAM_CHAT_ID= # the supergroup ID (negative number, e.g. -1001234567890) or DM chat ID +TELEGRAM_AGENT_ID= # the Maestro agent this bot is bound to (one bot = one agent) +TELEGRAM_ALLOWED_USER_IDS= # comma-separated Telegram user IDs allowed to interact with the bot +TELEGRAM_MENTION_USER_ID= # optional: Telegram user ID to @mention when --mention is used From 89ce10164e3c297156dc8658db73dbdc73b4d65e Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:08:48 +0200 Subject: [PATCH 06/35] MAESTRO: add Telegram provider adapter with lifecycle stubs Implement TelegramProvider class with grammy-based start/stop/isReady, register the provider in the kernel loader. Inbound handler, send, resolveConversation, and findOrCreateAgentChannel are TG-02 stubs filled in by TG-03 through TG-05. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/providers.ts | 4 ++ src/providers/telegram/adapter.ts | 77 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/providers/telegram/adapter.ts diff --git a/src/core/providers.ts b/src/core/providers.ts index 7809119..7f026e8 100644 --- a/src/core/providers.ts +++ b/src/core/providers.ts @@ -22,6 +22,10 @@ async function loadProvider(name: string): Promise { const { DiscordProvider } = await import('../providers/discord/adapter'); return new DiscordProvider(); } + case 'telegram': { + const { TelegramProvider } = await import('../providers/telegram/adapter'); + return new TelegramProvider(); + } default: console.warn(`[providers] Unknown provider "${name}" — ignoring.`); return null; diff --git a/src/providers/telegram/adapter.ts b/src/providers/telegram/adapter.ts new file mode 100644 index 0000000..d2fb913 --- /dev/null +++ b/src/providers/telegram/adapter.ts @@ -0,0 +1,77 @@ +import { Bot } from 'grammy'; +import type { + AgentChannelInfo, + BridgeProvider, + ChannelTarget, + ConversationRecord, + IncomingMessage, + KernelContext, + MessageTarget, + OutgoingMessage, + ReactionHandle, +} from '../../core/types'; +import { telegramConfig } from './config'; + +export class TelegramProvider implements BridgeProvider { + readonly name = 'telegram'; + private bot: Bot | null = null; + private ctx: KernelContext | null = null; + private ready = false; + + async start(ctx: KernelContext): Promise { + this.ctx = ctx; + const token = telegramConfig.token; + const chatId = telegramConfig.chatId; + const agentId = telegramConfig.agentId; + + const bot = new Bot(token); + this.bot = bot; + + await bot.init(); + console.log( + `[telegram] connected as @${bot.botInfo.username} (bound to agent ${agentId}, chat ${chatId})`, + ); + + bot.on('message', async () => { + // TG-03 fills this in. + }); + + // Long-polling runs forever; do not await. + void bot.start({ + onStart: () => { + this.ready = true; + }, + }); + } + + async stop(): Promise { + if (this.bot) { + await this.bot.stop(); + } + this.ready = false; + } + + isReady(): boolean { + return this.ready; + } + + resolveConversation(_message: IncomingMessage): ConversationRecord | null { + throw new Error('not implemented in TG-02'); + } + + async send(_target: ChannelTarget, _msg: OutgoingMessage): Promise { + throw new Error('not implemented in TG-02'); + } + + async findOrCreateAgentChannel(_agentId: string): Promise { + throw new Error('not implemented in TG-02'); + } + + async react(_target: MessageTarget, _emoji: string): Promise { + return { remove: async () => {} }; + } + + async sendTyping(_target: ChannelTarget): Promise { + return; + } +} From a32776056608e3a47ba2733393267245ed91ff03 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:12:19 +0200 Subject: [PATCH 07/35] MAESTRO: add Telegram voice helpers (isVoiceMessage, downloadVoice, attachmentsFromMessage) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/providers/telegram/voice.ts | 84 +++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/providers/telegram/voice.ts diff --git a/src/providers/telegram/voice.ts b/src/providers/telegram/voice.ts new file mode 100644 index 0000000..0a6629c --- /dev/null +++ b/src/providers/telegram/voice.ts @@ -0,0 +1,84 @@ +import type { Bot } from 'grammy'; +import type { Message as TelegramMessage } from 'grammy/types'; +import type { IncomingAttachment } from '../../core/types'; + +/** Telegram-specific: a message is a voice message when the `voice` field is present. */ +export function isVoiceMessage(msg: Pick): boolean { + return !!msg.voice; +} + +async function fileUrl(bot: Bot, fileId: string): Promise { + const file = await bot.api.getFile(fileId); + if (!file.file_path) { + throw new Error(`Telegram file ${fileId} returned no file_path`); + } + return `https://api.telegram.org/file/bot${bot.token}/${file.file_path}`; +} + +export async function downloadVoice( + bot: Bot, + msg: TelegramMessage, +): Promise { + if (!msg.voice) { + throw new Error('downloadVoice called on a message without a voice payload'); + } + const url = await fileUrl(bot, msg.voice.file_id); + return { + url, + name: `voice-${msg.message_id}.ogg`, + size: msg.voice.file_size ?? 0, + contentType: msg.voice.mime_type ?? 'audio/ogg', + }; +} + +export async function attachmentsFromMessage( + bot: Bot, + msg: TelegramMessage, +): Promise { + const attachments: IncomingAttachment[] = []; + + if (msg.document) { + const url = await fileUrl(bot, msg.document.file_id); + attachments.push({ + url, + name: msg.document.file_name ?? `document-${msg.message_id}`, + size: msg.document.file_size ?? 0, + contentType: msg.document.mime_type, + }); + } + + if (msg.photo && msg.photo.length > 0) { + const largest = msg.photo.reduce((a, b) => + (a.file_size ?? a.width * a.height) >= (b.file_size ?? b.width * b.height) ? a : b, + ); + const url = await fileUrl(bot, largest.file_id); + attachments.push({ + url, + name: `photo-${msg.message_id}.jpg`, + size: largest.file_size ?? 0, + contentType: 'image/jpeg', + }); + } + + if (msg.audio) { + const url = await fileUrl(bot, msg.audio.file_id); + attachments.push({ + url, + name: msg.audio.file_name ?? `audio-${msg.message_id}`, + size: msg.audio.file_size ?? 0, + contentType: msg.audio.mime_type ?? 'audio/mpeg', + }); + } + + if (msg.video) { + const url = await fileUrl(bot, msg.video.file_id); + attachments.push({ + url, + name: msg.video.file_name ?? `video-${msg.message_id}.mp4`, + size: msg.video.file_size ?? 0, + contentType: msg.video.mime_type ?? 'video/mp4', + }); + } + + return attachments; +} From 96bef35282a92569cd17ba0416bba0fe124c261c Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:14:25 +0200 Subject: [PATCH 08/35] MAESTRO: add Telegram messageHandler with allowlist + voice transcription Factory createMessageHandler(deps) translates grammy Context messages into kernel IncomingMessage and enqueues them. Filters: bot author, bound chat, optional per-user allowlist. Builds channelId as chat.id or chat.id:thread_id for forum topics. Routes voice through transcribeVoiceAttachment when the transcriber is available, falling back to forwarding the OGG attachment on transcription failure. Errors are caught so polling cannot die. --- src/providers/telegram/messageHandler.ts | 86 ++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/providers/telegram/messageHandler.ts diff --git a/src/providers/telegram/messageHandler.ts b/src/providers/telegram/messageHandler.ts new file mode 100644 index 0000000..80554f3 --- /dev/null +++ b/src/providers/telegram/messageHandler.ts @@ -0,0 +1,86 @@ +import type { Bot, Context as GrammyContext } from 'grammy'; +import type { EnqueueOptions, IncomingAttachment, IncomingMessage } from '../../core/types'; +import { + isTranscriberAvailable, + transcribeVoiceAttachment, +} from '../../core/transcription'; +import { attachmentsFromMessage, downloadVoice, isVoiceMessage } from './voice'; + +type Enqueue = (msg: IncomingMessage, options?: EnqueueOptions) => void; + +export type MessageHandlerDeps = { + bot: Bot; + boundChatId: string; + boundAgentId: string; + allowedUserIds: string[]; + enqueue: Enqueue; + isVoiceMessage: typeof isVoiceMessage; + downloadVoice: typeof downloadVoice; + attachmentsFromMessage: typeof attachmentsFromMessage; + transcribeVoiceAttachment: typeof transcribeVoiceAttachment; + isTranscriberAvailable: typeof isTranscriberAvailable; + logger?: Pick; +}; + +export function createMessageHandler(deps: MessageHandlerDeps) { + return async function handleMessage(ctx: GrammyContext): Promise { + try { + const message = ctx.message; + const from = ctx.from; + const chat = ctx.chat; + if (!message || !from || !chat) return; + + if (from.is_bot) return; + + if (String(chat.id) !== deps.boundChatId) return; + + if ( + deps.allowedUserIds.length > 0 && + !deps.allowedUserIds.includes(String(from.id)) + ) { + return; + } + + const threadId = message.message_thread_id; + const isThread = !!message.is_topic_message && typeof threadId === 'number'; + const channelId = isThread ? `${chat.id}:${threadId}` : `${chat.id}`; + + let content = message.text ?? message.caption ?? ''; + let attachments: IncomingAttachment[] = []; + + if (deps.isVoiceMessage(message) && deps.isTranscriberAvailable()) { + const voiceAttachment = await deps.downloadVoice(deps.bot, message); + try { + const transcription = await deps.transcribeVoiceAttachment(voiceAttachment); + content = transcription; + } catch (err) { + const log = deps.logger?.error ?? console.error; + log('[telegram] voice transcription failed:', err); + attachments = [voiceAttachment]; + } + } else { + attachments = await deps.attachmentsFromMessage(deps.bot, message); + } + + const authorName = + from.username ?? from.first_name ?? String(from.id); + + const incoming: IncomingMessage = { + provider: 'telegram', + messageId: String(message.message_id), + channelId, + authorId: String(from.id), + authorName, + content, + attachments, + isThread, + raw: ctx, + }; + + deps.enqueue(incoming); + } catch (err) { + const log = deps.logger?.error ?? console.error; + log('[telegram] messageHandler', err); + } + }; +} From 55650c2781200975f6916e70df11d843df87c9d7 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:15:52 +0200 Subject: [PATCH 09/35] MAESTRO: wire Telegram messageHandler into adapter Replaces the TG-02 placeholder bot.on('message') stub with the real createMessageHandler factory wired with config + transcription helpers so inbound updates flow into the kernel queue. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/providers/telegram/adapter.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/providers/telegram/adapter.ts b/src/providers/telegram/adapter.ts index d2fb913..1b92bb3 100644 --- a/src/providers/telegram/adapter.ts +++ b/src/providers/telegram/adapter.ts @@ -10,7 +10,17 @@ import type { OutgoingMessage, ReactionHandle, } from '../../core/types'; +import { + isTranscriberAvailable, + transcribeVoiceAttachment, +} from '../../core/transcription'; import { telegramConfig } from './config'; +import { createMessageHandler } from './messageHandler'; +import { + attachmentsFromMessage, + downloadVoice, + isVoiceMessage, +} from './voice'; export class TelegramProvider implements BridgeProvider { readonly name = 'telegram'; @@ -32,9 +42,20 @@ export class TelegramProvider implements BridgeProvider { `[telegram] connected as @${bot.botInfo.username} (bound to agent ${agentId}, chat ${chatId})`, ); - bot.on('message', async () => { - // TG-03 fills this in. + const handler = createMessageHandler({ + bot, + boundChatId: chatId, + boundAgentId: agentId, + allowedUserIds: telegramConfig.allowedUserIds, + enqueue: (msg) => ctx.enqueue(msg), + isVoiceMessage, + downloadVoice, + attachmentsFromMessage, + transcribeVoiceAttachment, + isTranscriberAvailable, + logger: console, }); + bot.on('message', handler); // Long-polling runs forever; do not await. void bot.start({ From c1aacc3fa3eff96842e99c37bf4ff1f79754944d Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:21:18 +0200 Subject: [PATCH 10/35] MAESTRO: implement Telegram outbound (send/react/typing) for TG-04 - parseChannelId helper splits ${chat}:${topic} composite ids back into chatId + threadId - send: chunks via splitMessage(text, 4096) and posts each chunk, threading into forum topics when applicable; mention requests fall back to a textual cue (Telegram plain-text @mentions only work with usernames) - react: setMessageReaction with emoji reaction; warn-and-continue on unsupported emoji; remove() clears the reaction - sendTyping: best-effort sendChatAction('typing') with optional message_thread_id Co-Authored-By: Claude Opus 4.7 (1M context) --- src/providers/telegram/adapter.ts | 62 ++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/src/providers/telegram/adapter.ts b/src/providers/telegram/adapter.ts index 1b92bb3..416d7ed 100644 --- a/src/providers/telegram/adapter.ts +++ b/src/providers/telegram/adapter.ts @@ -1,4 +1,5 @@ import { Bot } from 'grammy'; +import type { ReactionTypeEmoji } from 'grammy/types'; import type { AgentChannelInfo, BridgeProvider, @@ -14,6 +15,7 @@ import { isTranscriberAvailable, transcribeVoiceAttachment, } from '../../core/transcription'; +import { splitMessage } from '../../core/splitMessage'; import { telegramConfig } from './config'; import { createMessageHandler } from './messageHandler'; import { @@ -22,6 +24,11 @@ import { isVoiceMessage, } from './voice'; +function parseChannelId(channelId: string): { chatId: string; threadId?: number } { + const [chatId, topicStr] = channelId.split(':'); + return topicStr ? { chatId, threadId: Number(topicStr) } : { chatId }; +} + export class TelegramProvider implements BridgeProvider { readonly name = 'telegram'; private bot: Bot | null = null; @@ -80,19 +87,62 @@ export class TelegramProvider implements BridgeProvider { throw new Error('not implemented in TG-02'); } - async send(_target: ChannelTarget, _msg: OutgoingMessage): Promise { - throw new Error('not implemented in TG-02'); + async send(target: ChannelTarget, msg: OutgoingMessage): Promise { + if (!this.bot) throw new Error('telegram bot not started'); + const { chatId, threadId } = parseChannelId(target.channelId); + + let text = msg.text; + if (msg.mention && telegramConfig.mentionUserId) { + text = `[mention requested for user ${telegramConfig.mentionUserId}]\n${text}`; + } + + const parts = splitMessage(text, 4096); + for (const part of parts) { + await this.bot.api.sendMessage( + chatId, + part, + threadId ? { message_thread_id: threadId } : {}, + ); + } } async findOrCreateAgentChannel(_agentId: string): Promise { throw new Error('not implemented in TG-02'); } - async react(_target: MessageTarget, _emoji: string): Promise { - return { remove: async () => {} }; + async react(target: MessageTarget, emoji: string): Promise { + if (!this.bot) throw new Error('telegram bot not started'); + const { chatId } = parseChannelId(target.channelId); + const messageId = Number(target.messageId); + try { + await this.bot.api.setMessageReaction(chatId, messageId, [ + { type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] }, + ]); + } catch (err) { + console.warn(`[telegram] setMessageReaction(${emoji}) failed:`, err); + } + return { + remove: async () => { + try { + await this.bot!.api.setMessageReaction(chatId, messageId, []); + } catch { + /* gentle degradation */ + } + }, + }; } - async sendTyping(_target: ChannelTarget): Promise { - return; + async sendTyping(target: ChannelTarget): Promise { + if (!this.bot) return; + const { chatId, threadId } = parseChannelId(target.channelId); + try { + await this.bot.api.sendChatAction( + chatId, + 'typing', + threadId ? { message_thread_id: threadId } : {}, + ); + } catch { + // best-effort; typing indicator is non-critical + } } } From ad2a8a3c83062067484888109d29d5817959c0ec Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:23:56 +0200 Subject: [PATCH 11/35] MAESTRO: detect Telegram chat mode (forum vs dm) at startup Calls bot.api.getChat after init to set chatMode based on type='supergroup' && is_forum, with a hint log when running in DM/non-forum mode. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/providers/telegram/adapter.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/providers/telegram/adapter.ts b/src/providers/telegram/adapter.ts index 416d7ed..9acdebb 100644 --- a/src/providers/telegram/adapter.ts +++ b/src/providers/telegram/adapter.ts @@ -34,6 +34,7 @@ export class TelegramProvider implements BridgeProvider { private bot: Bot | null = null; private ctx: KernelContext | null = null; private ready = false; + private chatMode: 'forum' | 'dm' = 'dm'; async start(ctx: KernelContext): Promise { this.ctx = ctx; @@ -49,6 +50,16 @@ export class TelegramProvider implements BridgeProvider { `[telegram] connected as @${bot.botInfo.username} (bound to agent ${agentId}, chat ${chatId})`, ); + const chat = await bot.api.getChat(chatId); + this.chatMode = + chat.type === 'supergroup' && chat.is_forum ? 'forum' : 'dm'; + console.log(`[telegram] chat mode: ${this.chatMode}`); + if (this.chatMode === 'dm') { + console.log( + '[telegram] tip: enable forum topics on a supergroup for topic-per-session UX', + ); + } + const handler = createMessageHandler({ bot, boundChatId: chatId, From c42ba7c1247e60070616baf3ec721e242d5e7ee1 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:25:21 +0200 Subject: [PATCH 12/35] MAESTRO: register Telegram bound channel in core agent_channels at startup Looks up the agent name via maestro.listAgents() (falls back to agent id if maestro-cli is unreachable) and inserts a row in agent_channels keyed on (telegram, chat_id) so /api/health and listByAgentId see the binding. Idempotent: skipped if the row already exists. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/providers/telegram/adapter.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/providers/telegram/adapter.ts b/src/providers/telegram/adapter.ts index 9acdebb..50f333a 100644 --- a/src/providers/telegram/adapter.ts +++ b/src/providers/telegram/adapter.ts @@ -16,6 +16,8 @@ import { transcribeVoiceAttachment, } from '../../core/transcription'; import { splitMessage } from '../../core/splitMessage'; +import { channelDb as coreChannelDb } from '../../core/db'; +import { maestro } from '../../core/maestro'; import { telegramConfig } from './config'; import { createMessageHandler } from './messageHandler'; import { @@ -60,6 +62,23 @@ export class TelegramProvider implements BridgeProvider { ); } + if (!coreChannelDb.get('telegram', chatId)) { + let agentName = agentId; + try { + const agents = await maestro.listAgents(); + const match = agents.find((a) => a.id === agentId); + if (match?.name) agentName = match.name; + } catch (err) { + console.warn( + `[telegram] could not resolve agent name from maestro-cli; falling back to agent id (${(err as Error).message})`, + ); + } + coreChannelDb.register('telegram', chatId, agentId, agentName); + console.log( + `[telegram] registered bound channel ${chatId} → agent ${agentName} (${agentId})`, + ); + } + const handler = createMessageHandler({ bot, boundChatId: chatId, From 9b6f484508ec28e9e26d3b45b4c901c45e80b913 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:27:11 +0200 Subject: [PATCH 13/35] MAESTRO: implement Telegram resolveConversation for forum + DM modes Co-Authored-By: Claude Opus 4.7 (1M context) --- src/providers/telegram/adapter.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/providers/telegram/adapter.ts b/src/providers/telegram/adapter.ts index 50f333a..519c6f3 100644 --- a/src/providers/telegram/adapter.ts +++ b/src/providers/telegram/adapter.ts @@ -20,6 +20,7 @@ import { channelDb as coreChannelDb } from '../../core/db'; import { maestro } from '../../core/maestro'; import { telegramConfig } from './config'; import { createMessageHandler } from './messageHandler'; +import { topicDb } from './topicsDb'; import { attachmentsFromMessage, downloadVoice, @@ -113,8 +114,30 @@ export class TelegramProvider implements BridgeProvider { return this.ready; } - resolveConversation(_message: IncomingMessage): ConversationRecord | null { - throw new Error('not implemented in TG-02'); + resolveConversation(message: IncomingMessage): ConversationRecord | null { + const { chatId, threadId } = parseChannelId(message.channelId); + if (chatId !== telegramConfig.chatId) return null; + + if (this.chatMode === 'forum') { + if (threadId === undefined) return null; + const row = topicDb.get(chatId, threadId); + if (!row) return null; + return { + agentId: row.agent_id, + sessionId: row.session_id, + readOnly: false, + persistSession: (sid) => topicDb.updateSession(chatId, threadId, sid), + }; + } + + const row = coreChannelDb.get('telegram', chatId); + if (!row) return null; + return { + agentId: row.agent_id, + sessionId: row.session_id, + readOnly: row.read_only === 1, + persistSession: (sid) => coreChannelDb.updateSession('telegram', chatId, sid), + }; } async send(target: ChannelTarget, msg: OutgoingMessage): Promise { From 78ce85ebe5ebb2ada7b8a50641318a16f5075c4a Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:29:21 +0200 Subject: [PATCH 14/35] MAESTRO: implement Telegram findOrCreateAgentChannel with agent-name cache Replaces the TG-02 stub. Refuses agents other than the bound TELEGRAM_AGENT_ID. In forum mode, returns the most recent existing topic for the agent or creates '${agentName} (default)' via bot.api.createForumTopic and registers it in topicDb. In DM/group mode, returns the bound chatId directly. Adds a private resolveAgentName(agentId) helper that consults maestro.listAgents() and caches the result for the process lifetime, falling back to null on cli failure. --- src/providers/telegram/adapter.ts | 58 +++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/providers/telegram/adapter.ts b/src/providers/telegram/adapter.ts index 519c6f3..a791e26 100644 --- a/src/providers/telegram/adapter.ts +++ b/src/providers/telegram/adapter.ts @@ -38,6 +38,7 @@ export class TelegramProvider implements BridgeProvider { private ctx: KernelContext | null = null; private ready = false; private chatMode: 'forum' | 'dm' = 'dm'; + private agentNameCache = new Map(); async start(ctx: KernelContext): Promise { this.ctx = ctx; @@ -159,8 +160,61 @@ export class TelegramProvider implements BridgeProvider { } } - async findOrCreateAgentChannel(_agentId: string): Promise { - throw new Error('not implemented in TG-02'); + async findOrCreateAgentChannel(agentId: string): Promise { + if (!this.bot) throw new Error('telegram bot not started'); + if (agentId !== telegramConfig.agentId) { + throw new Error( + `Telegram bot is bound to agent ${telegramConfig.agentId}; cannot serve agent ${agentId}. ` + + `Run a separate bridge instance for that agent.`, + ); + } + + const agentName = (await this.resolveAgentName(agentId)) ?? agentId; + + if (this.chatMode === 'forum') { + const topics = topicDb.getByAgentId(agentId); + let topicId: number; + if (topics.length === 0) { + const created = await this.bot.api.createForumTopic( + telegramConfig.chatId, + `${agentName} (default)`, + ); + topicId = created.message_thread_id; + topicDb.register(topicId, telegramConfig.chatId, agentId); + } else { + topicId = topics[0].topic_id; + } + return { + channelId: `${telegramConfig.chatId}:${topicId}`, + agentId, + agentName, + }; + } + + return { + channelId: telegramConfig.chatId, + agentId, + agentName, + }; + } + + private async resolveAgentName(agentId: string): Promise { + const cached = this.agentNameCache.get(agentId); + if (cached) return cached; + try { + const agents = await maestro.listAgents(); + const match = agents.find((a) => a.id === agentId); + if (match?.name) { + this.agentNameCache.set(agentId, match.name); + return match.name; + } + return null; + } catch (err) { + console.warn( + `[telegram] resolveAgentName: maestro-cli unavailable (${(err as Error).message})`, + ); + return null; + } } async react(target: MessageTarget, emoji: string): Promise { From 761a1aad4c09c01d177c6e489370f5696a6b1618 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:33:43 +0200 Subject: [PATCH 15/35] MAESTRO: add Telegram /session new (and /new) command path Forum mode creates a fresh topic, registers it in telegram_agent_topics, and replies in the new topic. DM/group mode clears the bound channel's session_id in core agent_channels and replies in the chat. Both paths short-circuit so the slash command is never forwarded to maestro. Extended MessageHandlerDeps with chatMode and resolveAgentName (needed for the topic title); the adapter forwards this.chatMode and a thunk over the existing agent-name cache. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/telegramSessionNew.test.ts | 165 +++++++++++++++++++++++ src/providers/telegram/adapter.ts | 2 + src/providers/telegram/messageHandler.ts | 33 +++++ 3 files changed, 200 insertions(+) create mode 100644 src/__tests__/telegramSessionNew.test.ts diff --git a/src/__tests__/telegramSessionNew.test.ts b/src/__tests__/telegramSessionNew.test.ts new file mode 100644 index 0000000..2266334 --- /dev/null +++ b/src/__tests__/telegramSessionNew.test.ts @@ -0,0 +1,165 @@ +import test, { afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { createMessageHandler } from '../providers/telegram/messageHandler'; +import { topicDb } from '../providers/telegram/topicsDb'; +import { channelDb as coreChannelDb } from '../core/db'; + +const BOUND_CHAT = 'tg-chat-session-new'; +const BOUND_AGENT = 'agent-session-new'; + +afterEach(() => { + try { + coreChannelDb.remove('telegram', BOUND_CHAT); + } catch { + /* ignore */ + } + for (const row of topicDb.listByChat(BOUND_CHAT)) { + try { + topicDb.remove(BOUND_CHAT, row.topic_id); + } catch { + /* ignore */ + } + } +}); + +type FakeBotApiCall = { method: string; args: unknown[] }; + +function makeFakeBot(opts: { newTopicId?: number } = {}) { + const calls: FakeBotApiCall[] = []; + const api = { + createForumTopic: async (chatId: string, name: string) => { + calls.push({ method: 'createForumTopic', args: [chatId, name] }); + return { message_thread_id: opts.newTopicId ?? 555, name }; + }, + sendMessage: async (chatId: string, text: string, options?: unknown) => { + calls.push({ method: 'sendMessage', args: [chatId, text, options] }); + return { message_id: 1 }; + }, + }; + return { bot: { api } as any, calls }; +} + +function makeCtx(text: string, threadId?: number) { + const message: Record = { + message_id: 42, + text, + }; + if (threadId !== undefined) { + message.message_thread_id = threadId; + message.is_topic_message = true; + } + return { + message, + from: { id: 7, is_bot: false, username: 'tester' }, + chat: { id: BOUND_CHAT, type: 'supergroup' }, + } as any; +} + +function baseDeps(overrides: Partial[0]>) { + return { + boundChatId: BOUND_CHAT, + boundAgentId: BOUND_AGENT, + chatMode: 'forum' as const, + resolveAgentName: async () => 'My Agent', + allowedUserIds: [], + enqueue: () => undefined, + isVoiceMessage: () => false, + downloadVoice: async () => { + throw new Error('not used'); + }, + attachmentsFromMessage: async () => [], + transcribeVoiceAttachment: async () => '', + isTranscriberAvailable: () => false, + logger: { warn: () => undefined, error: () => undefined }, + ...overrides, + } as Parameters[0]; +} + +test('forum mode: /new creates a topic, registers it, replies in topic, and skips enqueue', async () => { + const { bot, calls } = makeFakeBot({ newTopicId: 901 }); + let enqueued = 0; + const handler = createMessageHandler( + baseDeps({ bot, enqueue: () => (enqueued += 1) }), + ); + + await handler(makeCtx('/new')); + + assert.equal(enqueued, 0, 'should not enqueue the slash command'); + const created = calls.find((c) => c.method === 'createForumTopic'); + assert.ok(created, 'should call createForumTopic'); + assert.equal((created!.args[0] as string), BOUND_CHAT); + assert.match(created!.args[1] as string, /^My Agent session /); + + const sent = calls.find((c) => c.method === 'sendMessage'); + assert.ok(sent, 'should send a confirmation message'); + assert.equal(sent!.args[1], 'Started a new session in this topic. Send a message to begin.'); + assert.deepEqual(sent!.args[2], { message_thread_id: 901 }); + + const row = topicDb.get(BOUND_CHAT, 901); + assert.ok(row, 'created topic should be persisted'); + assert.equal(row!.agent_id, BOUND_AGENT); +}); + +test('forum mode: /session new also matches', async () => { + const { bot, calls } = makeFakeBot({ newTopicId: 902 }); + const handler = createMessageHandler(baseDeps({ bot })); + + await handler(makeCtx('/session new')); + + const created = calls.find((c) => c.method === 'createForumTopic'); + assert.ok(created, '/session new should also trigger topic creation'); +}); + +test('dm mode: /new clears the bound channel session and replies', async () => { + coreChannelDb.register('telegram', BOUND_CHAT, BOUND_AGENT, 'My Agent'); + coreChannelDb.updateSession('telegram', BOUND_CHAT, 'old-session-123'); + assert.equal(coreChannelDb.get('telegram', BOUND_CHAT)!.session_id, 'old-session-123'); + + const { bot, calls } = makeFakeBot(); + let enqueued = 0; + const handler = createMessageHandler( + baseDeps({ bot, chatMode: 'dm', enqueue: () => (enqueued += 1) }), + ); + + await handler(makeCtx('/new')); + + assert.equal(enqueued, 0, 'should not enqueue the slash command'); + const created = calls.find((c) => c.method === 'createForumTopic'); + assert.equal(created, undefined, 'should not create a forum topic in dm mode'); + + const sent = calls.find((c) => c.method === 'sendMessage'); + assert.ok(sent); + assert.equal(sent!.args[1], 'Started a new session. Send a message to begin.'); + + assert.equal(coreChannelDb.get('telegram', BOUND_CHAT)!.session_id, null); +}); + +test('non-/new messages bypass session-new path', async () => { + coreChannelDb.register('telegram', BOUND_CHAT, BOUND_AGENT, 'My Agent'); + + const { bot, calls } = makeFakeBot(); + let enqueued = 0; + const handler = createMessageHandler( + baseDeps({ bot, chatMode: 'dm', enqueue: () => (enqueued += 1) }), + ); + + await handler(makeCtx('hello there')); + + assert.equal(enqueued, 1, 'normal message should be enqueued'); + assert.equal(calls.length, 0, 'no telegram api calls for normal message'); +}); + +test('/news (similar prefix) is not treated as /new', async () => { + coreChannelDb.register('telegram', BOUND_CHAT, BOUND_AGENT, 'My Agent'); + + const { bot, calls } = makeFakeBot(); + let enqueued = 0; + const handler = createMessageHandler( + baseDeps({ bot, chatMode: 'dm', enqueue: () => (enqueued += 1) }), + ); + + await handler(makeCtx('/news today')); + + assert.equal(enqueued, 1, 'word-boundary on /new should let /news through'); + assert.equal(calls.length, 0); +}); diff --git a/src/providers/telegram/adapter.ts b/src/providers/telegram/adapter.ts index a791e26..f5ea7bf 100644 --- a/src/providers/telegram/adapter.ts +++ b/src/providers/telegram/adapter.ts @@ -85,6 +85,8 @@ export class TelegramProvider implements BridgeProvider { bot, boundChatId: chatId, boundAgentId: agentId, + chatMode: this.chatMode, + resolveAgentName: async () => (await this.resolveAgentName(agentId)) ?? agentId, allowedUserIds: telegramConfig.allowedUserIds, enqueue: (msg) => ctx.enqueue(msg), isVoiceMessage, diff --git a/src/providers/telegram/messageHandler.ts b/src/providers/telegram/messageHandler.ts index 80554f3..2fd5cc1 100644 --- a/src/providers/telegram/messageHandler.ts +++ b/src/providers/telegram/messageHandler.ts @@ -4,6 +4,8 @@ import { isTranscriberAvailable, transcribeVoiceAttachment, } from '../../core/transcription'; +import { channelDb as coreChannelDb } from '../../core/db'; +import { topicDb } from './topicsDb'; import { attachmentsFromMessage, downloadVoice, isVoiceMessage } from './voice'; type Enqueue = (msg: IncomingMessage, options?: EnqueueOptions) => void; @@ -12,6 +14,8 @@ export type MessageHandlerDeps = { bot: Bot; boundChatId: string; boundAgentId: string; + chatMode: 'forum' | 'dm'; + resolveAgentName: () => Promise; allowedUserIds: string[]; enqueue: Enqueue; isVoiceMessage: typeof isVoiceMessage; @@ -22,6 +26,8 @@ export type MessageHandlerDeps = { logger?: Pick; }; +const SESSION_NEW_PATTERN = /^\/(session\s+new|new)\b/; + export function createMessageHandler(deps: MessageHandlerDeps) { return async function handleMessage(ctx: GrammyContext): Promise { try { @@ -41,6 +47,12 @@ export function createMessageHandler(deps: MessageHandlerDeps) { return; } + const text = message.text ?? ''; + if (SESSION_NEW_PATTERN.test(text)) { + await handleSessionNew(deps); + return; + } + const threadId = message.message_thread_id; const isThread = !!message.is_topic_message && typeof threadId === 'number'; const channelId = isThread ? `${chat.id}:${threadId}` : `${chat.id}`; @@ -84,3 +96,24 @@ export function createMessageHandler(deps: MessageHandlerDeps) { } }; } + +async function handleSessionNew(deps: MessageHandlerDeps): Promise { + if (deps.chatMode === 'forum') { + const agentName = await deps.resolveAgentName(); + const topicName = `${agentName} session ${new Date().toISOString().slice(0, 16)}`; + const created = await deps.bot.api.createForumTopic(deps.boundChatId, topicName); + topicDb.register(created.message_thread_id, deps.boundChatId, deps.boundAgentId); + await deps.bot.api.sendMessage( + deps.boundChatId, + 'Started a new session in this topic. Send a message to begin.', + { message_thread_id: created.message_thread_id }, + ); + return; + } + + coreChannelDb.updateSession('telegram', deps.boundChatId, null); + await deps.bot.api.sendMessage( + deps.boundChatId, + 'Started a new session. Send a message to begin.', + ); +} From 526d7c71f41f65439616f9808df17ed7d7be59e2 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:41:09 +0200 Subject: [PATCH 16/35] MAESTRO: port Discord slash commands to Telegram command modules Adds src/providers/telegram/commands/ with TelegramCommandContext type and per-command modules for /health, /agents, /session, /gist, /playbook, /notes, and /auto-run. Each module mirrors the Discord business logic but uses plain-text formatting instead of embeds. Telegram-specific quirks: /agents new|disconnect|readonly are informational (bot is bound at install time); /playbook list emits a numbered list and stashes a per-(chat,thread,user) selection map so follow-up `/playbook run ` resolves to the chosen id. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/providers/telegram/commands/agents.ts | 118 ++++++++++++ src/providers/telegram/commands/auto-run.ts | 82 ++++++++ src/providers/telegram/commands/gist.ts | 37 ++++ src/providers/telegram/commands/health.ts | 33 ++++ src/providers/telegram/commands/notes.ts | 91 +++++++++ src/providers/telegram/commands/playbook.ts | 195 ++++++++++++++++++++ src/providers/telegram/commands/session.ts | 97 ++++++++++ src/providers/telegram/commands/types.ts | 20 ++ 8 files changed, 673 insertions(+) create mode 100644 src/providers/telegram/commands/agents.ts create mode 100644 src/providers/telegram/commands/auto-run.ts create mode 100644 src/providers/telegram/commands/gist.ts create mode 100644 src/providers/telegram/commands/health.ts create mode 100644 src/providers/telegram/commands/notes.ts create mode 100644 src/providers/telegram/commands/playbook.ts create mode 100644 src/providers/telegram/commands/session.ts create mode 100644 src/providers/telegram/commands/types.ts diff --git a/src/providers/telegram/commands/agents.ts b/src/providers/telegram/commands/agents.ts new file mode 100644 index 0000000..b6f3968 --- /dev/null +++ b/src/providers/telegram/commands/agents.ts @@ -0,0 +1,118 @@ +import { maestro } from '../../../core/maestro'; +import type { TelegramCommandContext } from './types'; + +export const command = 'agents'; +export const description = 'Show the bound agent and its details'; + +const BOUND_NOTICE = + 'This Telegram bot is bound to a single agent at install time. ' + + 'To bind a different agent, run a separate bridge instance.'; + +export async function execute(ctx: TelegramCommandContext): Promise { + const sub = ctx.args[0] ?? 'list'; + + if (sub === 'list') { + await handleList(ctx); + return; + } + if (sub === 'show') { + await handleShow(ctx); + return; + } + if (sub === 'new' || sub === 'disconnect' || sub === 'readonly') { + await ctx.reply( + `⚠️ /agents ${sub} is not supported on Telegram.\n${BOUND_NOTICE}`, + ); + return; + } + + await ctx.reply( + 'Usage: /agents [list|show]\n' + + '• list — show the bound agent\n' + + '• show — show details, stats, recent activity', + ); +} + +async function handleList(ctx: TelegramCommandContext): Promise { + let agents; + try { + agents = await maestro.listAgents(); + } catch (err) { + await ctx.reply(`❌ Could not list agents: ${(err as Error).message}`); + return; + } + + const bound = agents.find((a) => a.id === ctx.boundAgentId); + if (!bound) { + await ctx.reply( + `Bound agent ${ctx.boundAgentName} (${ctx.boundAgentId}) ` + + `was not returned by maestro-cli list agents.\n${BOUND_NOTICE}`, + ); + return; + } + + await ctx.reply( + `Bound agent: ${bound.name}\n` + + `id: ${bound.id}\n` + + `tool: ${bound.toolType}\n` + + `cwd: ${bound.cwd}\n\n` + + BOUND_NOTICE, + ); +} + +async function handleShow(ctx: TelegramCommandContext): Promise { + let detail; + try { + detail = await maestro.showAgent(ctx.boundAgentId); + } catch (err) { + await ctx.reply(`❌ Could not load agent: ${(err as Error).message}`); + return; + } + + const lines: string[] = [ + `Agent: ${detail.name}`, + `id: ${detail.id}`, + `tool: ${detail.toolType}`, + `cwd: ${detail.cwd}`, + ]; + if (detail.groupName) lines.push(`group: ${detail.groupName}`); + + const stats = detail.stats; + if (stats) { + const statLines: string[] = []; + if (typeof stats.historyEntries === 'number') { + const ok = stats.successCount ?? 0; + const fail = stats.failureCount ?? 0; + statLines.push(`History: ${stats.historyEntries} entries (${ok} ok · ${fail} failed)`); + } + if ( + typeof stats.totalInputTokens === 'number' || + typeof stats.totalOutputTokens === 'number' + ) { + statLines.push( + `Tokens: ${stats.totalInputTokens ?? 0}↓ ${stats.totalOutputTokens ?? 0}↑`, + ); + } + if (typeof stats.totalCost === 'number' && stats.totalCost > 0) { + statLines.push(`Cost: $${stats.totalCost.toFixed(4)}`); + } + if (typeof stats.totalElapsedMs === 'number' && stats.totalElapsedMs > 0) { + statLines.push(`Total elapsed: ${(stats.totalElapsedMs / 1000).toFixed(1)}s`); + } + if (statLines.length) { + lines.push('', 'Stats:', ...statLines); + } + } + + if (detail.recentHistory && detail.recentHistory.length > 0) { + lines.push('', 'Recent activity:'); + for (const h of detail.recentHistory.slice(0, 5)) { + const when = new Date(h.timestamp).toLocaleString(); + const status = h.success === false ? '⚠️' : '•'; + const summary = (h.summary ?? '').slice(0, 90); + lines.push(`${status} ${when} — ${summary}`); + } + } + + await ctx.reply(lines.join('\n')); +} diff --git a/src/providers/telegram/commands/auto-run.ts b/src/providers/telegram/commands/auto-run.ts new file mode 100644 index 0000000..5c615ab --- /dev/null +++ b/src/providers/telegram/commands/auto-run.ts @@ -0,0 +1,82 @@ +import path from 'path'; +import { maestro } from '../../../core/maestro'; +import type { TelegramCommandContext } from './types'; + +export const command = 'auto-run'; +export const description = "Launch one of this agent's Auto Run documents"; + +/** + * Resolve `doc` (a user-supplied filename, relative path, or absolute path) + * to a normalized path strictly contained within `folder`. Returns null when + * the resolved path escapes the folder. + */ +export function resolveContainedDocPath(folder: string, doc: string): string | null { + const folderResolved = path.resolve(folder); + const candidate = path.isAbsolute(doc) ? doc : path.join(folderResolved, doc); + const resolved = path.resolve(candidate); + if (resolved === folderResolved) return null; + const prefix = folderResolved.endsWith(path.sep) ? folderResolved : folderResolved + path.sep; + if (!resolved.startsWith(prefix)) return null; + return resolved; +} + +async function getAgentFolder(agentId: string): Promise { + try { + const agent = await maestro.showAgent(agentId); + return typeof agent.autoRunFolderPath === 'string' ? agent.autoRunFolderPath : null; + } catch { + return null; + } +} + +export async function execute(ctx: TelegramCommandContext): Promise { + const sub = ctx.args[0]; + if (sub !== 'start') { + await ctx.reply( + 'Usage: /auto-run start \n' + + 'doc-path is a filename or relative path inside the bound agent\'s Auto Run folder.', + ); + return; + } + + const doc = ctx.args[1]; + if (!doc) { + await ctx.reply('Missing document. Usage: /auto-run start '); + return; + } + + const folder = await getAgentFolder(ctx.boundAgentId); + if (!folder) { + await ctx.reply( + "❌ Could not determine this agent's Auto Run folder. " + + 'Open the agent in Maestro and configure one, then try again.', + ); + return; + } + + const docPath = resolveContainedDocPath(folder, doc); + if (!docPath) { + await ctx.reply( + "❌ Document must live inside this agent's Auto Run folder. " + + 'Use a filename or relative subpath (no `..` traversal or absolute paths outside the folder).', + ); + return; + } + + try { + await maestro.startAutoRun({ + agentId: ctx.boundAgentId, + docs: [docPath], + }); + } catch (err) { + await ctx.reply( + `❌ Auto Run failed to launch: ${(err as Error).message.slice(0, 1500)}`, + ); + return; + } + + await ctx.reply( + `▶️ Launched Auto Run for ${ctx.boundAgentName} with ${path.basename(docPath)}.\n` + + 'Watch this chat for progress.', + ); +} diff --git a/src/providers/telegram/commands/gist.ts b/src/providers/telegram/commands/gist.ts new file mode 100644 index 0000000..0ef1721 --- /dev/null +++ b/src/providers/telegram/commands/gist.ts @@ -0,0 +1,37 @@ +import { maestro } from '../../../core/maestro'; +import type { TelegramCommandContext } from './types'; + +export const command = 'gist'; +export const description = "Publish this agent's session transcript as a GitHub gist"; + +export async function execute(ctx: TelegramCommandContext): Promise { + let isPublic = false; + const descriptionParts: string[] = []; + for (const arg of ctx.args) { + if (arg === '--public' || arg === '-p') { + isPublic = true; + } else { + descriptionParts.push(arg); + } + } + const gistDescription = descriptionParts.length ? descriptionParts.join(' ') : undefined; + + let result; + try { + result = await maestro.createGist(ctx.boundAgentId, { + description: gistDescription, + isPublic, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await ctx.reply(`❌ Could not publish gist: ${message.slice(0, 1500)}`); + return; + } + + const visibility = isPublic ? 'public' : 'private'; + await ctx.reply( + `📎 Gist published — ${ctx.boundAgentName}\n` + + `${result.url}\n` + + `Visibility: ${visibility}`, + ); +} diff --git a/src/providers/telegram/commands/health.ts b/src/providers/telegram/commands/health.ts new file mode 100644 index 0000000..0f5f511 --- /dev/null +++ b/src/providers/telegram/commands/health.ts @@ -0,0 +1,33 @@ +import { maestro } from '../../../core/maestro'; +import type { TelegramCommandContext } from './types'; + +export const command = 'health'; +export const description = 'Verify maestro-cli is reachable'; + +export async function execute(ctx: TelegramCommandContext): Promise { + const installed = await maestro.isInstalled(); + if (!installed) { + await ctx.reply( + '❌ maestro-cli not found. Install Maestro and ensure it is on PATH.\n' + + 'See https://maestro.sh for instructions.', + ); + return; + } + + let agentCount: number; + try { + agentCount = (await maestro.listAgents()).length; + } catch (err) { + await ctx.reply( + `⚠️ maestro-cli is installed but failed to list agents. ` + + `Make sure Maestro is running.\n${(err as Error).message}`, + ); + return; + } + + await ctx.reply( + `✅ maestro-cli is healthy.\n` + + `Found ${agentCount} agent${agentCount !== 1 ? 's' : ''}. ` + + `Bound to ${ctx.boundAgentName} (${ctx.boundAgentId}).`, + ); +} diff --git a/src/providers/telegram/commands/notes.ts b/src/providers/telegram/commands/notes.ts new file mode 100644 index 0000000..39bd0d8 --- /dev/null +++ b/src/providers/telegram/commands/notes.ts @@ -0,0 +1,91 @@ +import { maestro } from '../../../core/maestro'; +import type { TelegramCommandContext } from './types'; + +export const command = 'notes'; +export const description = "Director's Notes: AI synopsis or unified history"; + +export async function execute(ctx: TelegramCommandContext): Promise { + const sub = ctx.args[0]; + + if (sub === 'synopsis') { + await handleSynopsis(ctx); + return; + } + if (sub === 'history') { + await handleHistory(ctx); + return; + } + + await ctx.reply( + 'Usage: /notes [synopsis|history]\n' + + '• synopsis [days] — AI synopsis of recent activity (slow)\n' + + '• history [days] [limit] [filter] — recent unified history (filter: auto|user|cue)', + ); +} + +function parseInteger(value: string | undefined, min: number, max: number): number | undefined { + if (!value) return undefined; + if (!/^\d+$/.test(value)) return undefined; + const num = Number(value); + if (num < min || num > max) return undefined; + return num; +} + +async function handleSynopsis(ctx: TelegramCommandContext): Promise { + const days = parseInteger(ctx.args[1], 1, 30); + + let result; + try { + result = await maestro.directorSynopsis({ days }); + } catch (err) { + await ctx.reply(`❌ Synopsis failed: ${(err as Error).message.slice(0, 1500)}`); + return; + } + + const text = result.markdown ?? result.synopsis ?? result.text ?? '(empty synopsis)'; + const truncated = text.length > 3500 ? text.slice(0, 3500) + '\n\n…truncated' : text; + const header = `🎬 Director's synopsis${days ? ` — last ${days}d` : ''}`; + const footer = + typeof result.entriesAnalyzed === 'number' + ? `\n\nAnalyzed ${result.entriesAnalyzed} entries${ + typeof result.daysCovered === 'number' ? ` over ${result.daysCovered}d` : '' + }` + : ''; + await ctx.reply(`${header}\n\n${truncated}${footer}`); +} + +async function handleHistory(ctx: TelegramCommandContext): Promise { + const days = parseInteger(ctx.args[1], 1, 30); + const limit = parseInteger(ctx.args[2], 1, 50) ?? 20; + const filterArg = ctx.args[3]; + const filter = + filterArg === 'auto' || filterArg === 'user' || filterArg === 'cue' ? filterArg : undefined; + + let entries; + try { + entries = await maestro.directorHistory({ days, limit, filter }); + } catch (err) { + await ctx.reply( + `❌ History fetch failed: ${(err as Error).message.slice(0, 1500)}`, + ); + return; + } + + if (entries.length === 0) { + await ctx.reply('No history entries in the requested window.'); + return; + } + + const header = `📜 Director history${days ? ` — last ${days}d` : ''}`; + const lines = entries.map((e) => { + const when = e.timestamp ? new Date(e.timestamp).toLocaleString() : '—'; + const type = e.type ?? '?'; + const agent = e.agentName ? ` · ${e.agentName}` : ''; + const status = e.success === false ? '⚠️' : '•'; + const summary = (e.summary ?? '').slice(0, 100); + return `${status} [${type}] ${when}${agent}\n${summary}`; + }); + const body = lines.join('\n\n'); + const truncated = body.length > 3500 ? body.slice(0, 3500) + '\n\n…truncated' : body; + await ctx.reply(`${header}\n\n${truncated}`); +} diff --git a/src/providers/telegram/commands/playbook.ts b/src/providers/telegram/commands/playbook.ts new file mode 100644 index 0000000..81273af --- /dev/null +++ b/src/providers/telegram/commands/playbook.ts @@ -0,0 +1,195 @@ +import { maestro, MaestroPlaybook } from '../../../core/maestro'; +import type { TelegramCommandContext } from './types'; + +export const command = 'playbook'; +export const description = 'Run and inspect Maestro playbooks'; + +const pendingSelections = new Map(); + +function selectionKey(ctx: TelegramCommandContext): string { + return `${ctx.chatId}:${ctx.threadId ?? 0}:${ctx.fromUserId}`; +} + +export async function execute(ctx: TelegramCommandContext): Promise { + const sub = ctx.args[0]; + + if (sub === 'list') { + await handleList(ctx); + return; + } + if (sub === 'show') { + await handleShow(ctx); + return; + } + if (sub === 'run') { + await handleRun(ctx); + return; + } + + // No subcommand: try resolving as a numeric reply to a previous list + if (sub && /^\d+$/.test(sub)) { + await handleNumberReply(ctx, Number(sub)); + return; + } + + await ctx.reply( + 'Usage: /playbook [list|show |run ]\n' + + '• list — list available playbooks\n' + + '• show — show playbook details\n' + + '• run — run a playbook and post the result', + ); +} + +async function handleList(ctx: TelegramCommandContext): Promise { + let playbooks: MaestroPlaybook[]; + try { + playbooks = await maestro.listPlaybooks(); + } catch (err) { + await ctx.reply(`❌ Could not list playbooks: ${(err as Error).message}`); + return; + } + + if (playbooks.length === 0) { + await ctx.reply('No playbooks found. Create one in the Maestro app first.'); + return; + } + + const lines = playbooks.map((p, idx) => { + const owner = p.agentName ? ` · ${p.agentName}` : ''; + return `${idx + 1}. ${p.name}${owner}\n ${p.id} · ${p.documentCount} docs · ${p.taskCount} tasks`; + }); + await ctx.reply( + `Playbooks:\n${lines.join('\n')}\n\n` + + 'Reply with /playbook show or /playbook run .', + ); + pendingSelections.set(selectionKey(ctx), { playbooks, action: 'run' }); +} + +async function handleShow(ctx: TelegramCommandContext): Promise { + const playbookId = await resolvePlaybookId(ctx, ctx.args.slice(1)); + if (!playbookId) return; + + let detail; + try { + detail = await maestro.showPlaybook(playbookId); + } catch (err) { + await ctx.reply(`❌ Could not load playbook: ${(err as Error).message}`); + return; + } + + const lines: string[] = [ + `Playbook: ${detail.name}`, + `id: ${detail.id}`, + `description: ${detail.description || '(none)'}`, + `tasks: ${detail.taskCount} (${detail.documentCount} docs)`, + ]; + if (detail.agentName) lines.push(`agent: ${detail.agentName}`); + + if (detail.documents.length) { + lines.push('', 'Documents:'); + for (const d of detail.documents.slice(0, 15)) { + lines.push(`• ${d.path} — ${d.completedCount}/${d.taskCount} tasks`); + } + if (detail.documents.length > 15) { + lines.push(`… and ${detail.documents.length - 15} more`); + } + } + await ctx.reply(lines.join('\n')); +} + +async function handleRun(ctx: TelegramCommandContext): Promise { + const playbookId = await resolvePlaybookId(ctx, ctx.args.slice(1)); + if (!playbookId) return; + + let detail; + try { + detail = await maestro.showPlaybook(playbookId); + } catch { + detail = null; + } + const label = detail?.name ?? playbookId; + await ctx.reply(`▶️ Running playbook ${label}…`); + + let event; + try { + event = await maestro.runPlaybook(playbookId); + } catch (err) { + await ctx.reply( + `❌ Playbook ${label} failed: ${(err as Error).message.slice(0, 1500)}`, + ); + return; + } + + const lines: string[] = [ + event.success === false + ? `⚠️ Playbook ${label} finished with errors.` + : `✅ Playbook ${label} complete.`, + ]; + if (typeof event.totalTasksCompleted === 'number') { + lines.push(`Tasks completed: ${event.totalTasksCompleted}`); + } + if (typeof event.totalElapsedMs === 'number') { + lines.push(`Elapsed: ${(event.totalElapsedMs / 1000).toFixed(1)}s`); + } + if (typeof event.totalCost === 'number' && event.totalCost > 0) { + lines.push(`Cost: $${event.totalCost.toFixed(4)}`); + } + if (event.summary) { + lines.push('', String(event.summary).slice(0, 1500)); + } + await ctx.reply(lines.join('\n')); +} + +async function resolvePlaybookId( + ctx: TelegramCommandContext, + args: string[], +): Promise { + const arg = args[0]; + if (!arg) { + await ctx.reply( + 'Missing playbook id. Use /playbook list first, then run /playbook run .', + ); + return null; + } + + if (/^\d+$/.test(arg)) { + const pending = pendingSelections.get(selectionKey(ctx)); + if (!pending) { + await ctx.reply( + 'No recent /playbook list to pick from. Run /playbook list first.', + ); + return null; + } + const idx = Number(arg) - 1; + if (idx < 0 || idx >= pending.playbooks.length) { + await ctx.reply(`Number out of range. Pick 1-${pending.playbooks.length}.`); + return null; + } + return pending.playbooks[idx].id; + } + return arg; +} + +async function handleNumberReply( + ctx: TelegramCommandContext, + num: number, +): Promise { + const pending = pendingSelections.get(selectionKey(ctx)); + if (!pending) { + await ctx.reply( + 'No recent /playbook list to pick from. Run /playbook list first.', + ); + return; + } + const idx = num - 1; + if (idx < 0 || idx >= pending.playbooks.length) { + await ctx.reply(`Number out of range. Pick 1-${pending.playbooks.length}.`); + return; + } + // Re-dispatch as a run with the selected id + const newCtx: TelegramCommandContext = { + ...ctx, + args: ['run', pending.playbooks[idx].id], + }; + await handleRun(newCtx); +} diff --git a/src/providers/telegram/commands/session.ts b/src/providers/telegram/commands/session.ts new file mode 100644 index 0000000..5e07456 --- /dev/null +++ b/src/providers/telegram/commands/session.ts @@ -0,0 +1,97 @@ +import { maestro, MaestroSession } from '../../../core/maestro'; +import { channelDb as coreChannelDb } from '../../../core/db'; +import { topicDb } from '../topicsDb'; +import type { TelegramCommandContext } from './types'; + +export const command = 'session'; +export const description = 'Manage agent sessions (new, list)'; + +export async function execute(ctx: TelegramCommandContext): Promise { + const sub = ctx.args[0] ?? 'list'; + + if (sub === 'new') { + await handleNew(ctx); + return; + } + if (sub === 'list') { + await handleList(ctx); + return; + } + + await ctx.reply( + 'Usage: /session [new|list]\n' + + '• new — start a fresh session (forum: new topic; dm: clears current session)\n' + + '• list — list known sessions for the bound agent', + ); +} + +async function handleNew(ctx: TelegramCommandContext): Promise { + if (ctx.chatMode === 'forum') { + const topicName = `${ctx.boundAgentName} session ${new Date() + .toISOString() + .slice(0, 16)}`; + const created = await ctx.bot.api.createForumTopic(ctx.chatId, topicName); + topicDb.register(created.message_thread_id, ctx.chatId, ctx.boundAgentId); + await ctx.bot.api.sendMessage( + ctx.chatId, + 'Started a new session in this topic. Send a message to begin.', + { message_thread_id: created.message_thread_id }, + ); + return; + } + + coreChannelDb.updateSession('telegram', ctx.chatId, null); + await ctx.reply('Started a new session. Send a message to begin.'); +} + +async function handleList(ctx: TelegramCommandContext): Promise { + let maestroSessions: MaestroSession[] = []; + try { + maestroSessions = await maestro.listSessions(ctx.boundAgentId); + } catch { + // fall through with empty list + } + const sessionMap = new Map( + maestroSessions.map((s) => [s.sessionId, s]), + ); + + if (ctx.chatMode === 'forum') { + const topics = topicDb.listByChat(ctx.chatId); + if (topics.length === 0) { + await ctx.reply('No session topics yet. Use /session new to create one.'); + return; + } + const lines = topics.map((t) => { + const info = sessionMap.get(t.session_id ?? ''); + const shortId = t.session_id ? t.session_id.slice(0, 8) : 'no session yet'; + const stats = info + ? `${info.messageCount} msgs · $${info.costUsd.toFixed(4)} · ${new Date( + info.modifiedAt, + ).toLocaleDateString()}` + : 'No messages yet'; + return `topic ${t.topic_id} — ${shortId} · ${stats}`; + }); + await ctx.reply(`Sessions — ${ctx.boundAgentName}\n${lines.join('\n')}`); + return; + } + + // DM mode: single shared session + const row = coreChannelDb.get('telegram', ctx.chatId); + if (!row?.session_id) { + await ctx.reply( + `Single shared session for ${ctx.boundAgentName}. ` + + `No session yet — send a message to start one.`, + ); + return; + } + const info = sessionMap.get(row.session_id); + const shortId = row.session_id.slice(0, 8); + const stats = info + ? `${info.messageCount} msgs · $${info.costUsd.toFixed(4)} · ${new Date( + info.modifiedAt, + ).toLocaleDateString()}` + : 'No stats available'; + await ctx.reply( + `Single shared session for ${ctx.boundAgentName}.\n${shortId} · ${stats}`, + ); +} diff --git a/src/providers/telegram/commands/types.ts b/src/providers/telegram/commands/types.ts new file mode 100644 index 0000000..306cb5c --- /dev/null +++ b/src/providers/telegram/commands/types.ts @@ -0,0 +1,20 @@ +import type { Bot } from 'grammy'; + +export interface TelegramCommandContext { + bot: Bot; + chatId: string; + threadId?: number; + fromUserId: string; + args: string[]; + rawText: string; + boundAgentId: string; + boundAgentName: string; + chatMode: 'forum' | 'dm'; + reply: (text: string) => Promise; +} + +export interface TelegramCommandModule { + command: string; + description: string; + execute: (ctx: TelegramCommandContext) => Promise; +} From 13f08dccf124e98f2ebbb10527f658e55e965829 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:44:59 +0200 Subject: [PATCH 17/35] MAESTRO: add Telegram command dispatcher with @botname stripping Introduces src/providers/telegram/commands/index.ts. Builds the COMMANDS registry from the seven per-command modules and exposes dispatchCommand(rawText, ctx) which: - accepts /command, /command arg1 arg2, and /command@BotName forms - lowercases the command word for case-insensitive matching - returns false for non-slash text or unknown commands so the message handler can fall through to normal enqueue - splits positional args on whitespace and forwards a fully built TelegramCommandContext to the matching module's execute Adds telegramDispatchCommand.test.ts covering args parsing, @botname stripping, case-insensitivity, the hyphenated auto-run name, unknown commands, and rawText preservation. 183/183 tests pass. --- src/__tests__/telegramDispatchCommand.test.ts | 156 ++++++++++++++++++ src/providers/telegram/commands/index.ts | 61 +++++++ 2 files changed, 217 insertions(+) create mode 100644 src/__tests__/telegramDispatchCommand.test.ts create mode 100644 src/providers/telegram/commands/index.ts diff --git a/src/__tests__/telegramDispatchCommand.test.ts b/src/__tests__/telegramDispatchCommand.test.ts new file mode 100644 index 0000000..2262b9d --- /dev/null +++ b/src/__tests__/telegramDispatchCommand.test.ts @@ -0,0 +1,156 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + COMMANDS, + dispatchCommand, + type DispatchCommandContext, +} from '../providers/telegram/commands'; +import type { TelegramCommandContext } from '../providers/telegram/commands/types'; + +function makeCtx(overrides: Partial = {}): { + ctx: DispatchCommandContext; + replies: string[]; +} { + const replies: string[] = []; + const ctx: DispatchCommandContext = { + bot: {} as DispatchCommandContext['bot'], + chatId: 'chat-1', + threadId: undefined, + fromUserId: 'user-1', + boundAgentId: 'agent-1', + boundAgentName: 'My Agent', + chatMode: 'dm', + reply: async (text: string) => { + replies.push(text); + }, + ...overrides, + }; + return { ctx, replies }; +} + +test('COMMANDS exposes all seven Telegram commands with descriptions', () => { + const expected = ['health', 'agents', 'session', 'gist', 'playbook', 'notes', 'auto-run']; + for (const name of expected) { + const entry = COMMANDS[name]; + assert.ok(entry, `missing command ${name}`); + assert.equal(typeof entry.description, 'string'); + assert.ok(entry.description.length > 0, `${name} description should be non-empty`); + assert.equal(typeof entry.execute, 'function'); + } +}); + +test('dispatchCommand returns false for non-slash text', async () => { + const { ctx } = makeCtx(); + assert.equal(await dispatchCommand('hello there', ctx), false); + assert.equal(await dispatchCommand('', ctx), false); + assert.equal(await dispatchCommand('/', ctx), false); +}); + +test('dispatchCommand returns false for unknown commands', async () => { + const { ctx, replies } = makeCtx(); + assert.equal(await dispatchCommand('/notarealcommand', ctx), false); + assert.deepEqual(replies, [], 'unknown command should not produce a reply'); +}); + +test('dispatchCommand parses bare command with no args', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS.health.execute; + COMMANDS.health.execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const handled = await dispatchCommand('/health', ctx); + assert.equal(handled, true); + assert.equal(seen.length, 1); + assert.deepEqual(seen[0].args, []); + assert.equal(seen[0].rawText, '/health'); + assert.equal(seen[0].chatId, 'chat-1'); + assert.equal(seen[0].boundAgentName, 'My Agent'); + } finally { + COMMANDS.health.execute = original; + } +}); + +test('dispatchCommand splits positional args on whitespace', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS.agents.execute; + COMMANDS.agents.execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const handled = await dispatchCommand('/agents list one two', ctx); + assert.equal(handled, true); + assert.deepEqual(seen[0].args, ['list', 'one', 'two']); + } finally { + COMMANDS.agents.execute = original; + } +}); + +test('dispatchCommand strips @ suffix from the command word', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS.agents.execute; + COMMANDS.agents.execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const handled = await dispatchCommand('/agents@MyBot list', ctx); + assert.equal(handled, true); + assert.equal(seen.length, 1); + assert.deepEqual(seen[0].args, ['list']); + } finally { + COMMANDS.agents.execute = original; + } +}); + +test('dispatchCommand is case-insensitive for command name', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS.health.execute; + COMMANDS.health.execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const handled = await dispatchCommand('/HEALTH', ctx); + assert.equal(handled, true); + assert.equal(seen.length, 1); + } finally { + COMMANDS.health.execute = original; + } +}); + +test('dispatchCommand handles hyphenated command name auto-run', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS['auto-run'].execute; + COMMANDS['auto-run'].execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const handled = await dispatchCommand('/auto-run start docs/foo.md', ctx); + assert.equal(handled, true); + assert.deepEqual(seen[0].args, ['start', 'docs/foo.md']); + } finally { + COMMANDS['auto-run'].execute = original; + } +}); + +test('dispatchCommand preserves rawText including args', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS.gist.execute; + COMMANDS.gist.execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const text = '/gist --public a custom description'; + const handled = await dispatchCommand(text, ctx); + assert.equal(handled, true); + assert.equal(seen[0].rawText, text); + assert.deepEqual(seen[0].args, ['--public', 'a', 'custom', 'description']); + } finally { + COMMANDS.gist.execute = original; + } +}); diff --git a/src/providers/telegram/commands/index.ts b/src/providers/telegram/commands/index.ts new file mode 100644 index 0000000..2876602 --- /dev/null +++ b/src/providers/telegram/commands/index.ts @@ -0,0 +1,61 @@ +import * as health from './health'; +import * as agents from './agents'; +import * as session from './session'; +import * as gist from './gist'; +import * as playbook from './playbook'; +import * as notes from './notes'; +import * as autoRun from './auto-run'; +import type { TelegramCommandContext, TelegramCommandModule } from './types'; + +type CommandEntry = { + description: string; + execute: (ctx: TelegramCommandContext) => Promise; +}; + +const modules: TelegramCommandModule[] = [ + health, + agents, + session, + gist, + playbook, + notes, + autoRun, +]; + +export const COMMANDS: Record = Object.fromEntries( + modules.map((m) => [m.command, { description: m.description, execute: m.execute }]), +); + +export type DispatchCommandContext = Omit< + TelegramCommandContext, + 'args' | 'rawText' | 'reply' +> & { + reply: TelegramCommandContext['reply']; +}; + +const COMMAND_PATTERN = /^\/(\S+)\s*([\s\S]*)$/; + +export async function dispatchCommand( + rawText: string, + ctx: DispatchCommandContext, +): Promise { + const trimmed = rawText.trimStart(); + const match = trimmed.match(COMMAND_PATTERN); + if (!match) return false; + + const [, head, rest] = match; + const command = head.split('@', 1)[0].toLowerCase(); + + const entry = COMMANDS[command]; + if (!entry) return false; + + const args = rest.length ? rest.split(/\s+/).filter(Boolean) : []; + + const fullCtx: TelegramCommandContext = { + ...ctx, + args, + rawText, + }; + await entry.execute(fullCtx); + return true; +} From 2d536cb429d72273fc4e07098dcd89cced2400fa Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:49:47 +0200 Subject: [PATCH 18/35] MAESTRO: wire Telegram command dispatcher into messageHandler Replace the TG-05 /session-new regex shortcut with a single dispatchCommand call. Any text that starts with "/" goes through the dispatcher; if no command matches, the message falls through to the normal enqueue path. The handler now resolves the bound agent name and builds a thread-aware reply closure before invoking the dispatcher, so command modules can post back into the originating topic in forum mode. The local handleSessionNew helper and the SESSION_NEW_PATTERN regex are removed -- /session new lives in commands/session.ts now. The bare /new shortcut is dropped; only the seven documented commands are honored. Tests updated to exercise /session new through the dispatcher plus a fall-through assertion for unknown slash text. Full suite: 182/182. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/telegramSessionNew.test.ts | 24 +++------- src/providers/telegram/messageHandler.ts | 56 +++++++++++------------- 2 files changed, 33 insertions(+), 47 deletions(-) diff --git a/src/__tests__/telegramSessionNew.test.ts b/src/__tests__/telegramSessionNew.test.ts index 2266334..5bc8b53 100644 --- a/src/__tests__/telegramSessionNew.test.ts +++ b/src/__tests__/telegramSessionNew.test.ts @@ -75,14 +75,14 @@ function baseDeps(overrides: Partial[0]> } as Parameters[0]; } -test('forum mode: /new creates a topic, registers it, replies in topic, and skips enqueue', async () => { +test('forum mode: /session new creates a topic, registers it, replies in topic, and skips enqueue', async () => { const { bot, calls } = makeFakeBot({ newTopicId: 901 }); let enqueued = 0; const handler = createMessageHandler( baseDeps({ bot, enqueue: () => (enqueued += 1) }), ); - await handler(makeCtx('/new')); + await handler(makeCtx('/session new')); assert.equal(enqueued, 0, 'should not enqueue the slash command'); const created = calls.find((c) => c.method === 'createForumTopic'); @@ -100,17 +100,7 @@ test('forum mode: /new creates a topic, registers it, replies in topic, and skip assert.equal(row!.agent_id, BOUND_AGENT); }); -test('forum mode: /session new also matches', async () => { - const { bot, calls } = makeFakeBot({ newTopicId: 902 }); - const handler = createMessageHandler(baseDeps({ bot })); - - await handler(makeCtx('/session new')); - - const created = calls.find((c) => c.method === 'createForumTopic'); - assert.ok(created, '/session new should also trigger topic creation'); -}); - -test('dm mode: /new clears the bound channel session and replies', async () => { +test('dm mode: /session new clears the bound channel session and replies', async () => { coreChannelDb.register('telegram', BOUND_CHAT, BOUND_AGENT, 'My Agent'); coreChannelDb.updateSession('telegram', BOUND_CHAT, 'old-session-123'); assert.equal(coreChannelDb.get('telegram', BOUND_CHAT)!.session_id, 'old-session-123'); @@ -121,7 +111,7 @@ test('dm mode: /new clears the bound channel session and replies', async () => { baseDeps({ bot, chatMode: 'dm', enqueue: () => (enqueued += 1) }), ); - await handler(makeCtx('/new')); + await handler(makeCtx('/session new')); assert.equal(enqueued, 0, 'should not enqueue the slash command'); const created = calls.find((c) => c.method === 'createForumTopic'); @@ -134,7 +124,7 @@ test('dm mode: /new clears the bound channel session and replies', async () => { assert.equal(coreChannelDb.get('telegram', BOUND_CHAT)!.session_id, null); }); -test('non-/new messages bypass session-new path', async () => { +test('non-/ messages bypass dispatcher and are enqueued', async () => { coreChannelDb.register('telegram', BOUND_CHAT, BOUND_AGENT, 'My Agent'); const { bot, calls } = makeFakeBot(); @@ -149,7 +139,7 @@ test('non-/new messages bypass session-new path', async () => { assert.equal(calls.length, 0, 'no telegram api calls for normal message'); }); -test('/news (similar prefix) is not treated as /new', async () => { +test('unknown slash command (/news today) falls through to enqueue', async () => { coreChannelDb.register('telegram', BOUND_CHAT, BOUND_AGENT, 'My Agent'); const { bot, calls } = makeFakeBot(); @@ -160,6 +150,6 @@ test('/news (similar prefix) is not treated as /new', async () => { await handler(makeCtx('/news today')); - assert.equal(enqueued, 1, 'word-boundary on /new should let /news through'); + assert.equal(enqueued, 1, 'unknown command should fall through to enqueue'); assert.equal(calls.length, 0); }); diff --git a/src/providers/telegram/messageHandler.ts b/src/providers/telegram/messageHandler.ts index 2fd5cc1..aa85281 100644 --- a/src/providers/telegram/messageHandler.ts +++ b/src/providers/telegram/messageHandler.ts @@ -4,8 +4,7 @@ import { isTranscriberAvailable, transcribeVoiceAttachment, } from '../../core/transcription'; -import { channelDb as coreChannelDb } from '../../core/db'; -import { topicDb } from './topicsDb'; +import { dispatchCommand } from './commands'; import { attachmentsFromMessage, downloadVoice, isVoiceMessage } from './voice'; type Enqueue = (msg: IncomingMessage, options?: EnqueueOptions) => void; @@ -26,8 +25,6 @@ export type MessageHandlerDeps = { logger?: Pick; }; -const SESSION_NEW_PATTERN = /^\/(session\s+new|new)\b/; - export function createMessageHandler(deps: MessageHandlerDeps) { return async function handleMessage(ctx: GrammyContext): Promise { try { @@ -48,13 +45,33 @@ export function createMessageHandler(deps: MessageHandlerDeps) { } const text = message.text ?? ''; - if (SESSION_NEW_PATTERN.test(text)) { - await handleSessionNew(deps); - return; - } - const threadId = message.message_thread_id; const isThread = !!message.is_topic_message && typeof threadId === 'number'; + + if (text.trimStart().startsWith('/')) { + const boundAgentName = await deps.resolveAgentName(); + const reply = async (replyText: string) => { + await deps.bot.api.sendMessage( + deps.boundChatId, + replyText, + isThread && threadId !== undefined + ? { message_thread_id: threadId } + : {}, + ); + }; + const handled = await dispatchCommand(text, { + bot: deps.bot, + chatId: deps.boundChatId, + threadId: isThread ? threadId : undefined, + fromUserId: String(from.id), + boundAgentId: deps.boundAgentId, + boundAgentName, + chatMode: deps.chatMode, + reply, + }); + if (handled) return; + } + const channelId = isThread ? `${chat.id}:${threadId}` : `${chat.id}`; let content = message.text ?? message.caption ?? ''; @@ -96,24 +113,3 @@ export function createMessageHandler(deps: MessageHandlerDeps) { } }; } - -async function handleSessionNew(deps: MessageHandlerDeps): Promise { - if (deps.chatMode === 'forum') { - const agentName = await deps.resolveAgentName(); - const topicName = `${agentName} session ${new Date().toISOString().slice(0, 16)}`; - const created = await deps.bot.api.createForumTopic(deps.boundChatId, topicName); - topicDb.register(created.message_thread_id, deps.boundChatId, deps.boundAgentId); - await deps.bot.api.sendMessage( - deps.boundChatId, - 'Started a new session in this topic. Send a message to begin.', - { message_thread_id: created.message_thread_id }, - ); - return; - } - - coreChannelDb.updateSession('telegram', deps.boundChatId, null); - await deps.bot.api.sendMessage( - deps.boundChatId, - 'Started a new session. Send a message to begin.', - ); -} From 1164be962002ef4099e88710d6794010dea9d4e8 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:51:05 +0200 Subject: [PATCH 19/35] MAESTRO: add Telegram setMyCommands deploy script Mirrors src/providers/discord/deploy.ts. Builds the {command, description} list from COMMANDS and calls bot.api.setMyCommands so the slash-menu autocomplete shows registered commands when users type "/". --- src/providers/telegram/deploy.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/providers/telegram/deploy.ts diff --git a/src/providers/telegram/deploy.ts b/src/providers/telegram/deploy.ts new file mode 100644 index 0000000..e276035 --- /dev/null +++ b/src/providers/telegram/deploy.ts @@ -0,0 +1,19 @@ +import { Bot } from 'grammy'; +import { telegramConfig } from './config'; +import { COMMANDS } from './commands'; + +async function main() { + const bot = new Bot(telegramConfig.token); + const list = Object.entries(COMMANDS).map(([command, { description }]) => ({ + command, + description, + })); + console.log(`Registering ${list.length} Telegram commands…`); + await bot.api.setMyCommands(list); + console.log('Done.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 9c1093c4188c4b3ee79314899a5773fee5490e0a Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:55:23 +0200 Subject: [PATCH 20/35] MAESTRO: route deploy-commands by ENABLED_PROVIDERS Add src/scripts/deploy-commands.ts as the single entry point for slash-command deployment. Reads ENABLED_PROVIDERS (defaulting to discord), spawns each provider's compiled deploy script in its own node process, and prints a summary so a failure in one provider never poisons another. Update the npm script accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/scripts/deploy-commands.ts | 70 ++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/scripts/deploy-commands.ts diff --git a/package.json b/package.json index beb8f02..788532d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dev": "tsx src/index.ts", "build": "rm -rf dist && tsc", "start": "node dist/index.js", - "deploy-commands": "tsx src/providers/discord/deploy.ts", + "deploy-commands": "node dist/scripts/deploy-commands.js", "test": "npm run build && node --test dist/__tests__/**/*.test.js", "maestro-relay": "tsx src/cli/maestro-relay.ts", "maestro-bridge": "tsx src/cli/maestro-relay.ts", diff --git a/src/scripts/deploy-commands.ts b/src/scripts/deploy-commands.ts new file mode 100644 index 0000000..b679406 --- /dev/null +++ b/src/scripts/deploy-commands.ts @@ -0,0 +1,70 @@ +import 'dotenv/config'; +import { spawn } from 'child_process'; +import path from 'path'; + +const PROVIDER_DEPLOY_SCRIPTS: Record = { + discord: path.resolve(__dirname, '..', 'providers', 'discord', 'deploy.js'), + telegram: path.resolve(__dirname, '..', 'providers', 'telegram', 'deploy.js'), +}; + +function parseEnabledProviders(): string[] { + const raw = process.env.ENABLED_PROVIDERS; + if (!raw) return ['discord']; + return raw + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +function runDeploy(provider: string, scriptPath: string): Promise { + return new Promise((resolve) => { + const child = spawn(process.execPath, [scriptPath], { + stdio: 'inherit', + env: process.env, + }); + child.on('close', (code) => resolve(code === 0)); + child.on('error', (err) => { + console.error(`[deploy-commands] Failed to spawn ${provider} deploy:`, err); + resolve(false); + }); + }); +} + +type Outcome = 'ok' | 'failed' | 'skipped'; + +async function main() { + const enabled = parseEnabledProviders(); + if (enabled.length === 0) { + console.error('[deploy-commands] ENABLED_PROVIDERS is empty — nothing to deploy.'); + process.exitCode = 1; + return; + } + + console.log(`[deploy-commands] Enabled providers: ${enabled.join(', ')}`); + const results: Array<{ provider: string; status: Outcome }> = []; + + for (const provider of enabled) { + const script = PROVIDER_DEPLOY_SCRIPTS[provider]; + if (!script) { + console.log( + `[deploy-commands] Provider "${provider}" has no deploy script — skipping.`, + ); + results.push({ provider, status: 'skipped' }); + continue; + } + console.log(`\n[deploy-commands] Running ${provider} deploy (${script})...`); + const ok = await runDeploy(provider, script); + results.push({ provider, status: ok ? 'ok' : 'failed' }); + } + + console.log('\n[deploy-commands] Summary:'); + for (const r of results) { + console.log(` ${r.provider}: ${r.status}`); + } + + if (results.some((r) => r.status === 'failed')) { + process.exitCode = 1; + } +} + +main(); From f1ae0a5abbdfa2c48ec6b0d254bbfab308d2e433 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 19:57:34 +0200 Subject: [PATCH 21/35] MAESTRO: accept telegram in install.sh normalize_module Co-Authored-By: Claude Opus 4.7 (1M context) --- install.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 13e5b59..2e56fc1 100755 --- a/install.sh +++ b/install.sh @@ -88,7 +88,8 @@ normalize_module() { raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" case "$raw" in discord|'') echo "discord" ;; - *) die "Unsupported module/provider: $raw (supported today: discord)" ;; + telegram) echo "telegram" ;; + *) die "Unsupported module/provider: $raw (supported: discord, telegram)" ;; esac } From d3ca8fa96d72238fdb523af235766bedf58405ec Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 20:00:50 +0200 Subject: [PATCH 22/35] MAESTRO: dispatch write_config by MODULE (discord|telegram) Extract Discord-specific prompts and env writer into write_config_discord as a near-pure refactor (behavior identical), and add a write_config_telegram stub that falls back to the .env.example template until the interactive walkthrough lands in the next TG-07 task. Co-Authored-By: Claude Opus 4.7 (1M context) --- install.sh | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 2e56fc1..4bd2330 100755 --- a/install.sh +++ b/install.sh @@ -217,6 +217,16 @@ write_config() { return fi + MODULE="$(normalize_module "$MODULE")" + case "$MODULE" in + discord) write_config_discord "$env_file" ;; + telegram) write_config_telegram "$env_file" ;; + esac +} + +write_config_discord() { + local env_file="$1" + local interactive=0 [ -r /dev/tty ] && interactive=1 local have_required=0 @@ -241,7 +251,6 @@ write_config() { info "Writing config from environment to $env_file" fi local token client_id guild_id allowed - MODULE="$(normalize_module "$MODULE")" token="$(prompt_var DISCORD_BOT_TOKEN 'Discord bot token')" client_id="$(prompt_var DISCORD_CLIENT_ID 'Discord application (client) ID')" guild_id="$(prompt_var DISCORD_GUILD_ID 'Discord guild (server) ID')" @@ -268,6 +277,15 @@ write_config() { ok "Wrote $env_file" } +write_config_telegram() { + local env_file="$1" + # Full interactive walkthrough lands in the next TG-07 task. + warn "Telegram interactive config not yet implemented — writing template to $env_file (edit before starting)" + cp "$INSTALL_DIR/.env.example" "$env_file" + chmod 600 "$env_file" + ln -sf "$env_file" "$INSTALL_DIR/.env" +} + config_complete() { local file="$1" key value [ -f "$file" ] || return 1 From 762f8042fc8d07e27fc8748e3c1a1de4458ea425 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 20:03:34 +0200 Subject: [PATCH 23/35] MAESTRO: implement write_config_telegram() walkthrough in install.sh Replaces the placeholder with a four-step interactive flow (BotFather, chat-ID retrieval via @userinfobot, agent binding, optional allowlist). Preserves the non-interactive fallback: when stdin isn't a TTY and TELEGRAM_BOT_TOKEN/CHAT_ID/AGENT_ID aren't all preset, drops the .env template instead of prompting. Calls pick_telegram_agent (next TG-07 task) for agent selection. --- install.sh | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index 4bd2330..7b481a1 100755 --- a/install.sh +++ b/install.sh @@ -279,11 +279,92 @@ write_config_discord() { write_config_telegram() { local env_file="$1" - # Full interactive walkthrough lands in the next TG-07 task. - warn "Telegram interactive config not yet implemented — writing template to $env_file (edit before starting)" - cp "$INSTALL_DIR/.env.example" "$env_file" - chmod 600 "$env_file" + + local interactive=0 + [ -r /dev/tty ] && interactive=1 + local have_required=0 + if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] \ + && [ -n "${TELEGRAM_CHAT_ID:-}" ] \ + && [ -n "${TELEGRAM_AGENT_ID:-}" ]; then + have_required=1 + fi + + if [ "$interactive" -eq 0 ] && [ "$have_required" -eq 0 ]; then + info "Non-interactive shell — writing template to $env_file (edit before starting)" + cp "$INSTALL_DIR/.env.example" "$env_file" + chmod 600 "$env_file" + ln -sf "$env_file" "$INSTALL_DIR/.env" + return + fi + + if [ "$interactive" -eq 1 ]; then + info "Configuring Telegram provider" + echo + echo " $(c_bold 'Step 1 — create your bot via @BotFather')" + echo " 1. Open Telegram and search for @BotFather (https://t.me/BotFather)" + echo " 2. Send /newbot and follow the prompts (give it a display name and username)" + echo " 3. Copy the bot token BotFather returns (looks like 1234567890:ABC-XYZ...)" + echo " Full walkthrough with screenshots: docs/telegram-setup.md" + echo + else + info "Writing Telegram config from environment to $env_file" + fi + local token + token="$(prompt_var TELEGRAM_BOT_TOKEN 'Telegram bot token')" + + if [ "$interactive" -eq 1 ]; then + echo + echo " $(c_bold 'Step 2 — pick a chat for your bot')" + echo " Recommended: a $(c_bold 'forum supergroup') (each Maestro session becomes its own topic)." + echo " a. Create a new Telegram group, then convert it to a supergroup (Group Settings → Group Type → Public/Private supergroup)" + echo " b. Enable Topics: Group Settings → Topics → toggle on" + echo " c. Add your bot as a member, promote to admin with 'Manage Topics' permission" + echo " d. Find the chat ID: forward any message from the group to @userinfobot — it replies with the supergroup ID (a negative number like -1001234567890)" + echo + echo " Alternative: a private DM with your bot (simpler; no topics, sessions reset in place via /new)." + echo " a. In Telegram, search your bot's @username and send /start" + echo " b. Forward your /start message to @userinfobot to get your DM chat ID (positive number)" + echo + fi + local chat_id + chat_id="$(prompt_var TELEGRAM_CHAT_ID 'Telegram chat ID (supergroup or DM)')" + + if [ "$interactive" -eq 1 ]; then + echo + echo " $(c_bold 'Step 3 — bind this bot to a Maestro agent')" + fi + local agent_id + agent_id="$(pick_telegram_agent)" + + if [ "$interactive" -eq 1 ]; then + echo + echo " $(c_bold 'Step 4 — restrict access (recommended)')" + echo " Comma-separated list of Telegram user IDs allowed to interact with the bot." + echo " Find your own user ID by sending any message to @userinfobot." + echo " Leave empty for no allowlist (anyone in the chat can use the bot)." + fi + local allowed + allowed="$(prompt_var TELEGRAM_ALLOWED_USER_IDS 'Allowed user IDs (optional)')" + + local tmp_env + tmp_env="$(mktemp "${env_file}.XXXXXX")" + chmod 600 "$tmp_env" + { + printf '# Generated by install.sh on %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'ENABLED_PROVIDERS=telegram\n' + printf 'API_PORT=3457\n' + printf 'TELEGRAM_BOT_TOKEN=%s\n' "$token" + printf 'TELEGRAM_CHAT_ID=%s\n' "$chat_id" + printf 'TELEGRAM_AGENT_ID=%s\n' "$agent_id" + printf 'TELEGRAM_ALLOWED_USER_IDS=%s\n' "$allowed" + printf 'TELEGRAM_MENTION_USER_ID=\n' + printf 'FFMPEG_PATH=%s\n' "${VOICE_FFMPEG:-ffmpeg}" + printf 'WHISPER_CLI_PATH=%s\n' "${VOICE_WHISPER:-whisper-cli}" + printf 'WHISPER_MODEL_PATH=%s\n' "${VOICE_MODEL:-models/${DEFAULT_MODEL_NAME}}" + } > "$tmp_env" + mv "$tmp_env" "$env_file" ln -sf "$env_file" "$INSTALL_DIR/.env" + ok "Wrote $env_file" } config_complete() { From d37eddc148a5f7efc4fd06bb73c3f5ba47f6ec28 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 20:07:37 +0200 Subject: [PATCH 24/35] MAESTRO: implement pick_telegram_agent() with numbered agent menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lists Maestro agents from `maestro-cli list agents --json` and lets the user pick by number. Honors TELEGRAM_AGENT_ID env-var preset, falls back to manual entry when maestro-cli is missing or returns no agents, and parses JSON via jq when available with a node -e fallback (Node is already a hard install requirement). Menu output goes to stderr / reads from /dev/tty so only the chosen ID lands on stdout — the function is consumed via $(pick_telegram_agent) inside write_config_telegram(). Co-Authored-By: Claude Opus 4.7 (1M context) --- install.sh | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/install.sh b/install.sh index 7b481a1..80045ff 100755 --- a/install.sh +++ b/install.sh @@ -367,6 +367,88 @@ write_config_telegram() { ok "Wrote $env_file" } +pick_telegram_agent() { + if [ -n "${TELEGRAM_AGENT_ID:-}" ]; then + echo "$TELEGRAM_AGENT_ID" + return + fi + if ! command -v maestro-cli >/dev/null 2>&1; then + warn "maestro-cli not found on PATH — type the Maestro agent ID manually." + prompt_var TELEGRAM_AGENT_ID 'Maestro agent ID' + return + fi + + local json + json="$(maestro-cli list agents --json 2>/dev/null || echo '[]')" + + local parsed="" + if command -v jq >/dev/null 2>&1; then + parsed="$(printf '%s' "$json" | jq -r '.[] | (.id + "\t" + (.name // "(unnamed)"))' 2>/dev/null || true)" + else + parsed="$(printf '%s' "$json" | node -e ' +let s = ""; +process.stdin.on("data", d => s += d); +process.stdin.on("end", () => { + try { + const a = JSON.parse(s); + if (!Array.isArray(a)) return; + for (const x of a) { + process.stdout.write((x.id || "") + "\t" + (x.name || "(unnamed)") + "\n"); + } + } catch (e) {} +}); +' 2>/dev/null || true)" + fi + + if [ -z "$parsed" ]; then + warn "No Maestro agents available — type the agent ID manually." + prompt_var TELEGRAM_AGENT_ID 'Maestro agent ID' + return + fi + + local i=0 + local -a ids=() + printf ' Available Maestro agents:\n' >&2 + while IFS=$'\t' read -r id name; do + [ -z "$id" ] && continue + i=$((i + 1)) + ids+=("$id") + printf ' %d) %s (id: %s)\n' "$i" "$name" "$id" >&2 + done <<< "$parsed" + + if [ "$i" -eq 0 ]; then + warn "No Maestro agents parsed — type the agent ID manually." + prompt_var TELEGRAM_AGENT_ID 'Maestro agent ID' + return + fi + + local choice="" + if [ -r /dev/tty ]; then + read -r -p " Pick an agent by number (or paste an agent ID): " choice Date: Sun, 10 May 2026 20:09:29 +0200 Subject: [PATCH 25/35] MAESTRO: make config_complete() module-aware (discord/telegram) Reads ENABLED_PROVIDERS from the env file and validates the matching provider's required keys. Defaults to discord when ENABLED_PROVIDERS is absent so legacy installs keep working. Co-Authored-By: Claude Opus 4.7 (1M context) --- install.sh | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 80045ff..85b19d2 100755 --- a/install.sh +++ b/install.sh @@ -450,9 +450,20 @@ process.stdin.on("end", () => { } config_complete() { - local file="$1" key value + local file="$1" key value enabled [ -f "$file" ] || return 1 - for key in DISCORD_BOT_TOKEN DISCORD_CLIENT_ID DISCORD_GUILD_ID; do + enabled="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*$/\1/p' "$file" | head -n1)" + [ -n "$enabled" ] || enabled="discord" + local -a required_keys=() + case ",$enabled," in + *,telegram,*) + required_keys=(TELEGRAM_BOT_TOKEN TELEGRAM_CHAT_ID TELEGRAM_AGENT_ID) + ;; + *) + required_keys=(DISCORD_BOT_TOKEN DISCORD_CLIENT_ID DISCORD_GUILD_ID) + ;; + esac + for key in "${required_keys[@]}"; do value="$(sed -nE "s/^${key}=([^#[:space:]]+).*/\1/p" "$file" | head -n1)" [ -n "$value" ] || return 1 case "$value" in From 4cdc360d4eeab1b2ea86a969bd87ccaccab5261d Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 20:11:13 +0200 Subject: [PATCH 26/35] MAESTRO: route deploy_commands through provider-aware npm script Both install.sh deploy_commands() and maestro-relay-ctl cmd_deploy were calling Discord's deploy script directly. Switch them to `npm run deploy-commands`, which dispatches per provider via src/scripts/deploy-commands.ts so Telegram (and future providers) deploy the same way. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/maestro-relay-ctl.sh | 10 ++-------- install.sh | 22 +++++----------------- 2 files changed, 7 insertions(+), 25 deletions(-) diff --git a/bin/maestro-relay-ctl.sh b/bin/maestro-relay-ctl.sh index e8b9f52..dfafa8c 100755 --- a/bin/maestro-relay-ctl.sh +++ b/bin/maestro-relay-ctl.sh @@ -68,7 +68,7 @@ Commands: restart Restart the relay service status Show service status logs Tail service logs (Ctrl+C to stop) - deploy Deploy slash commands to Discord + deploy Deploy chat commands for enabled providers (Discord slash commands, Telegram bot commands) update Reinstall the latest release (preserves config) uninstall Remove the relay, service files, and CLI symlinks version Print installed version @@ -146,13 +146,7 @@ cmd_deploy() { require_install local env_file="$INSTALL_DIR/.env" [ -f "$env_file" ] || die "Config missing: $env_file" - local enabled_providers - enabled_providers="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*$/\1/p' "$env_file" | head -n1)" - [ -z "$enabled_providers" ] && enabled_providers="discord" - case ",$enabled_providers," in - *,discord,*) (cd "$INSTALL_DIR" && node dist/providers/discord/deploy.js) ;; - *) die "Discord is not enabled in ENABLED_PROVIDERS=$enabled_providers" ;; - esac + (cd "$INSTALL_DIR" && npm run deploy-commands --silent) } cmd_update() { diff --git a/install.sh b/install.sh index 85b19d2..51d2510 100755 --- a/install.sh +++ b/install.sh @@ -476,27 +476,15 @@ config_complete() { deploy_commands() { local env_file="$CONFIG_DIR/.env" if ! config_complete "$env_file"; then - warn "Skipping slash command deployment — config at $env_file is incomplete or contains template values." + warn "Skipping command deployment — config at $env_file is incomplete or contains template values." warn "Edit it and run 'maestro-relay-ctl deploy' when ready." return fi - local enabled_providers - enabled_providers="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*$/\1/p' "$env_file" | head -n1)" - if [ -z "$enabled_providers" ]; then - enabled_providers="discord" - fi - case ",$enabled_providers," in - *,discord,*) ;; - *) - info "Skipping Discord slash-command deployment (ENABLED_PROVIDERS=$enabled_providers)" - return - ;; - esac - info "Deploying slash commands to Discord" - if (cd "$INSTALL_DIR" && node dist/providers/discord/deploy.js); then - ok "Slash commands deployed" + info "Deploying chat commands" + if (cd "$INSTALL_DIR" && npm run deploy-commands --silent); then + ok "Commands deployed" else - warn "Slash command deployment failed. Edit $env_file and re-run 'maestro-relay-ctl deploy'." + warn "Command deployment failed. Edit $env_file and re-run 'maestro-relay-ctl deploy'." fi } From 82d81592560c1a0cb67699aaa88588c7bfd3649b Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 20:12:00 +0200 Subject: [PATCH 27/35] MAESTRO: document telegram option in install.sh header Co-Authored-By: Claude Opus 4.7 (1M context) --- install.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 51d2510..600d0fa 100755 --- a/install.sh +++ b/install.sh @@ -3,7 +3,8 @@ # Usage: # curl -fsSL https://raw.githubusercontent.com/RunMaestro/Maestro-Relay/main/install.sh | bash # Re-run to upgrade to the latest release. Existing config is preserved. -# Optional: MAESTRO_RELAY_MODULE=discord (currently the only supported module). +# Optional: MAESTRO_RELAY_MODULE=discord|telegram (default: discord). +# Run `MAESTRO_RELAY_MODULE=telegram bash install.sh` to install the Telegram bridge. # # Legacy MAESTRO_BRIDGE_* / MAESTRO_DISCORD_* env vars are accepted as fallback so v0.0.x # installs upgrading via `maestro-discord-ctl update` keep working. From 98e26328c381aa3774ac9d5d52b2bdd58093e4e0 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 20:15:25 +0200 Subject: [PATCH 28/35] MAESTRO: add newcomer-friendly Telegram setup walkthrough Canonical zero-to-running Telegram bot guide referenced from install.sh and .env.example. Covers BotFather bot creation, supergroup vs DM chat selection, agent binding, multi-agent setup, and troubleshooting. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/telegram-setup.md | 164 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/telegram-setup.md diff --git a/docs/telegram-setup.md b/docs/telegram-setup.md new file mode 100644 index 0000000..628aa1f --- /dev/null +++ b/docs/telegram-setup.md @@ -0,0 +1,164 @@ +# Telegram bot setup + +This guide walks you through creating a Telegram bot, picking a chat for it, +and binding it to a Maestro agent. No prior Telegram-bot experience required — +if you've used Telegram as a chat app, you have everything you need. + +## What you'll end up with + +- A Telegram bot (your own, with a name and avatar you choose) that represents + one Maestro agent. +- A chat where you talk to that agent — either: + - A **forum supergroup** where each Maestro session becomes its own topic + (recommended; mirrors how Discord channels + threads feel), OR + - A **private DM** with the bot (simpler; one running session at a time, + use `/new` to reset). + +One bot is bound to one agent for its lifetime. To bridge multiple agents, +create one bot per agent (BotFather makes this cheap — see "Multiple agents" +at the bottom). + +## Step 1 — create the bot via @BotFather + +@BotFather is Telegram's official bot for creating other bots. + +1. Open Telegram and search for `@BotFather`, or click + [https://t.me/BotFather](https://t.me/BotFather). +2. Tap **Start** (or send `/start`) to open the conversation. +3. Send `/newbot`. +4. BotFather asks for a **display name** — this is what users see in chats. + Example: `Alice's Coding Agent`. +5. BotFather asks for a **username** — must end in `bot` and be globally + unique. Example: `alice_coding_bot`. +6. BotFather replies with a **bot token** that looks like + `1234567890:ABCdefGHI-jklMNO_pqrSTU_vwxYZ`. + + **Save this token somewhere safe.** You'll paste it into the installer in + Step 4. Treat it like a password — anyone with the token can control your + bot. +7. _(Optional)_ Send `/setuserpic` in the BotFather chat to give the bot an + avatar, or `/setdescription` to give it a description that appears on its + profile. + +## Step 2 — pick a chat for the bot + +Choose **Option A** (forum supergroup) for the smoothest experience, or +**Option B** (private DM) if you just want to start fast. + +### Option A: forum supergroup (recommended) + +Forum supergroups let each Maestro session live in its own _topic_, similar to +how Discord uses channels + threads. You'll be able to start fresh sessions +with `/new` without losing previous conversations. + +1. In Telegram, tap the menu and choose **New Group**. Add at least one other + contact (you can remove them after creating the group) — Telegram requires + at least one other member to create a group. +2. Once the group exists, open its **Info** panel (tap the group name at the + top), then tap **Edit** (pencil icon) → **Group Type** and pick either + **Public Group** or **Private Group**. Either choice converts it to a + _supergroup_ under the hood, which is required for topics. +3. Open the Info panel again → **Topics** → toggle **on**. The group's main + feed is now the "General" topic, and you can create more. +4. Add your bot to the group: Info → **Add Members** → search for your bot's + `@username` (the one you set in Step 1) → tap to add. +5. Promote the bot to admin with the **Manage Topics** permission: + Info → **Administrators** → **Add Admin** → pick your bot → grant + **Manage Topics**. Leave every other admin permission off — the bot + doesn't need them. +6. Get the supergroup's **chat ID**: + - Forward any message from the supergroup to + [@userinfobot](https://t.me/userinfobot). + - It replies with `Forwarded from chat: ... id: -1001234567890`. Copy that + negative number — that's your `TELEGRAM_CHAT_ID`. + +### Option B: private DM with the bot + +Simpler, single-session at a time, no topic management. Good for solo use. + +1. In Telegram, search for your bot's `@username` and tap **Start** (or send + `/start`). +2. Forward your `/start` message (or any other message you sent the bot) to + [@userinfobot](https://t.me/userinfobot). +3. It replies with your DM chat ID — a positive number. That's your + `TELEGRAM_CHAT_ID`. + +## Step 3 — note your own Telegram user ID + +This goes into the access allowlist so only you (and people you list) can use +the bot. Skipping the allowlist is fine for a private bot, but recommended for +anything in a shared supergroup. + +- Send any message to [@userinfobot](https://t.me/userinfobot). It replies + with your numeric user ID (e.g. `987654321`). +- Repeat for any other allowed users — collect their IDs into a comma- + separated list (e.g. `987654321,123456789`). + +## Step 4 — run the installer + +The installer prompts for each of the values you collected. Run it with the +`MAESTRO_RELAY_MODULE=telegram` flag so it picks the Telegram walkthrough: + +```sh +MAESTRO_RELAY_MODULE=telegram bash -c "$(curl -fsSL https://raw.githubusercontent.com/RunMaestro/Maestro-Relay/main/install.sh)" +``` + +You'll be asked for: + +- The **bot token** from Step 1. +- The **chat ID** from Step 2. +- A **Maestro agent** to bind the bot to. The installer lists your local + agents and lets you pick by number. +- _(Optional)_ The **allowed user IDs** from Step 3. + +The bot is bound to the chosen agent for its lifetime. To bridge a different +agent, run a separate bridge instance with its own `.env` (and a separate bot +from BotFather — see "Multiple agents" below). + +## Step 5 — start the bridge + +```sh +maestro-relay-ctl start +maestro-relay-ctl logs +``` + +In your Telegram chat: + +- **Forum mode**: send `/new` in the supergroup's main feed — a new topic + appears. Send messages inside that topic to talk to the agent. Run `/new` + again to start another session in a fresh topic. +- **DM mode**: just send a message. `/new` resets the session in place. + +Try `/health` to confirm the bridge is reaching `maestro-cli`. + +## Multiple agents + +One Telegram bot = one Maestro agent. To bridge multiple agents: + +1. Create a separate bot in BotFather for each agent (Step 1, repeated). +2. Run a separate bridge instance per bot, each with its own: + - `.env` file (different `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, and + `TELEGRAM_AGENT_ID`). + - `API_PORT` (so they don't conflict with each other on the same machine). + - systemd unit (override the unit name when installing). + +This is intentional — Telegram bots take ~30 seconds to create in BotFather, +and giving each agent its own bot identity makes for a much cleaner UX than +multiplexing many agents through a single bot. + +## Troubleshooting + +- **Bot doesn't reply in a forum supergroup**: confirm the bot is an admin + with the **Manage Topics** permission. Without it, the bridge cannot create + forum topics and the call fails silently. +- **"Telegram bot is bound to agent X; cannot serve agent Y" in logs**: + `maestro-relay send --agent Y --provider telegram` was called against a bot + bound to a different agent. Use the bridge for agent Y instead, or run a + second bridge instance for agent Y with its own bot. +- **No reactions appear (⏳)**: Telegram only allows a curated set of emoji + reactions on messages. The bridge already falls back gracefully — typing + indicators continue to work even if the reaction emoji isn't accepted in + your chat type. +- **Voice messages aren't transcribed**: confirm `ffmpeg` and `whisper-cli` + are on your `PATH` and `WHISPER_MODEL_PATH` points at a real model file. + The setup is identical to the Discord provider's voice support. From 669a1d35255e4c8058e8ee163865f4194e2c396e Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 20:17:06 +0200 Subject: [PATCH 29/35] MAESTRO: add Telegram section and provider details to README Adds a one-paragraph pitch and quick-start curl invocation for the Telegram provider, links the docs/telegram-setup.md walkthrough, mentions Telegram in Features and Prerequisites, and adds commented Telegram env vars to the dev-from-source .env example block. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0475fbe..1dcc1d3 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ ## Features - Provider-pluggable kernel — Discord today, Slack/Teams next +- Telegram support (forum topics or DM) - Creates dedicated channels for Maestro agents - Per-user session threads (`/session new` or by mentioning the bot) - Per-conversation FIFO queue with typing/reaction indicators @@ -18,7 +19,7 @@ ## Prerequisites - Node.js 22+ -- A Discord application + bot token (if running the Discord provider) +- A Discord application + bot token (if running the Discord provider), or a Telegram bot token via [@BotFather](https://t.me/BotFather) (if running the Telegram provider) - [Maestro CLI](https://docs.runmaestro.ai/cli) on your `PATH` ## Install (production one-liner) @@ -49,7 +50,7 @@ The legacy aliases `maestro-bridge-ctl` and `maestro-discord-ctl` still work for | systemd user / launchd agent | Auto-start unit | Override any of these with `MAESTRO_RELAY_HOME`, `XDG_CONFIG_HOME`, or `MAESTRO_RELAY_BIN_DIR`. Pin a specific version with `MAESTRO_RELAY_VERSION=v1.0.0`. -Choose a provider module at install time via `MAESTRO_RELAY_MODULE` (currently only `discord` is supported). +Choose a provider module at install time via `MAESTRO_RELAY_MODULE=discord` or `MAESTRO_RELAY_MODULE=telegram`. ## Install (development from source) @@ -71,7 +72,7 @@ Set these values in `.env`: ``` # Core -ENABLED_PROVIDERS=discord # comma-separated; default 'discord' +ENABLED_PROVIDERS=discord # comma-separated; default 'discord' (e.g. 'telegram' or 'discord,telegram') API_PORT=3457 # optional, default 3457 # Discord provider @@ -81,6 +82,14 @@ DISCORD_GUILD_ID= # Your server's ID (right-click server → Copy ID) DISCORD_ALLOWED_USER_IDS=123,456 # Optional: comma-separated allowed user IDs DISCORD_MENTION_USER_ID= # Optional: user ID to @mention when --mention is used +# Telegram provider (only loaded if 'telegram' is in ENABLED_PROVIDERS) +# Setup walkthrough: see docs/telegram-setup.md +# TELEGRAM_BOT_TOKEN= # from @BotFather (https://t.me/BotFather → /newbot) +# TELEGRAM_CHAT_ID= # supergroup ID (negative, e.g. -1001234567890) or DM chat ID +# TELEGRAM_AGENT_ID= # the Maestro agent this bot is bound to (one bot = one agent) +# TELEGRAM_ALLOWED_USER_IDS= # comma-separated Telegram user IDs allowed to interact with the bot +# TELEGRAM_MENTION_USER_ID= # optional: Telegram user ID to @mention when --mention is used + # Voice transcription (optional) FFMPEG_PATH=/opt/homebrew/bin/ffmpeg WHISPER_CLI_PATH=/opt/homebrew/bin/whisper-cli @@ -193,6 +202,18 @@ npm run build && node --test --experimental-test-coverage dist/__tests__/**/*.te | `/notes synopsis` | Post an AI-generated synopsis of recent activity | | `/notes history` | Post a unified history feed across agents | +## Telegram + +Bot-per-agent model: each Telegram bot represents one Maestro agent. Recommended setup is a forum supergroup where each session becomes its own topic; DM mode is supported for single-session use. + +### Quick start + +```bash +MAESTRO_RELAY_MODULE=telegram bash -c "$(curl -fsSL https://raw.githubusercontent.com/RunMaestro/Maestro-Relay/main/install.sh)" +``` + +The full newcomer walkthrough — creating a bot via @BotFather, picking a chat, collecting the IDs the installer asks for — lives in [docs/telegram-setup.md](docs/telegram-setup.md). + ## How it works Mention the bot or run `/session new` in an agent channel to create a thread, then chat — messages are queued and forwarded to the agent via `maestro-cli`. See [docs/architecture.md](docs/architecture.md) for the full message flow, the kernel/provider split, and how to add a new provider. From 081e133cf1645f586b630f3383247e0edd429563 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 20:18:46 +0200 Subject: [PATCH 30/35] MAESTRO: document Telegram provider in architecture.md Adds a Telegram message-flow section (forum + DM modes), a row for the telegram_agent_topics table, project-layout entries for src/providers/telegram/*, and a single-agent-binding note to the "Adding a new provider" checklist. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/architecture.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index eb49fd0..aaeeb69 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,6 +34,17 @@ The kernel speaks only in `IncomingMessage` / `OutgoingMessage` / `ChannelTarget - Persists the maestro session id on the first response via `conv.persistSession` 5. Errors are logged to `logs/errors.log` and surfaced as a `⚠️` reply in the channel. +## Message flow (Telegram) + +Telegram uses a **bot-per-agent** model: at install time the bot is bound to one Maestro agent (`TELEGRAM_AGENT_ID`) and one chat (`TELEGRAM_CHAT_ID`). One bot serves exactly one agent for its lifetime. + +1. **Forum mode**: user sends `/new` in the supergroup main feed → adapter calls `bot.api.createForumTopic`, registers the new topic in `telegram_agent_topics`, and treats that topic as one Maestro session. Subsequent messages in the topic are routed to that session. +2. **DM mode**: the bound chat is a single shared session. `/new` clears the stored session id so the next message starts a fresh maestro session. +3. Each message becomes an `IncomingMessage` with `channelId = chatId` (DM) or `chatId:topicId` (forum) and is passed to `ctx.enqueue`. +4. The kernel queue serializes per `(provider, channelId)` exactly as for Discord — reactions/typing, `resolveConversation`, attachment download, `maestro.send`, response splitting, usage footer, session persistence. +5. Outbound: `provider.send` posts via `bot.api.sendMessage`, attaching `message_thread_id` when the target is a forum topic. Long responses are split at the 4096-char Telegram message limit. +6. `findOrCreateAgentChannel(agentId)` enforces the single-agent binding by throwing if `agentId !== TELEGRAM_AGENT_ID` — agent-initiated messages from `/api/send` for any other agent are rejected. + ## Thread ownership (Discord) Each thread is bound to the user who created it (via mention or `/session new`). @@ -52,6 +63,7 @@ Each thread is bound to the user who created it (via mention or `/session new`). | ------------------------- | --------------------- | --------------------------------------------------- | | `agent_channels` | core | `(provider, channel_id)` → agent + session + flags | | `discord_agent_threads` | discord provider | Thread → channel + agent + owner + session | +| `telegram_agent_topics` | telegram provider | `(chat_id, topic_id)` → agent + session | The schema upgrades on first start: legacy `agent_channels` (single-PK `channel_id`) is rebuilt with composite PK `(provider, channel_id)` and existing rows defaulted to `discord`; legacy `agent_threads` is renamed to `discord_agent_threads`. @@ -73,6 +85,13 @@ The schema upgrades on first start: legacy `agent_channels` (single-PK `channel_ | `src/providers/discord/voice.ts` | Discord voice-message detection | | `src/providers/discord/commands/` | Slash command handlers | | `src/providers/discord/deploy.ts` | Registers slash commands with Discord API | +| `src/providers/telegram/adapter.ts` | TelegramProvider implementing BridgeProvider | +| `src/providers/telegram/messageHandler.ts` | Telegram update → IncomingMessage | +| `src/providers/telegram/voice.ts` | Telegram voice-message detection + download | +| `src/providers/telegram/topicsDb.ts` | `telegram_agent_topics` accessor | +| `src/providers/telegram/commands/` | Slash command handlers (dispatched by messageHandler) | +| `src/providers/telegram/deploy.ts` | Registers commands via `bot.api.setMyCommands` | +| `src/providers/telegram/config.ts` | `TELEGRAM_*` env loading | | `src/cli/maestro-relay.ts` | CLI tool for agent → chat messaging | | `src/index.ts` | Kernel orchestrator (entry point) | @@ -82,3 +101,4 @@ The schema upgrades on first start: legacy `agent_channels` (single-PK `channel_ 2. Add a `case ''` branch to `loadProvider` in `src/core/providers.ts`. 3. Document the provider's env vars in `.env.example`. 4. Users opt in by setting `ENABLED_PROVIDERS=discord,`. +5. If your provider is bound to a single agent (like Telegram), enforce that in `findOrCreateAgentChannel` by throwing when the requested `agentId` doesn't match the bound one — this keeps `/api/send` from leaking cross-agent traffic into the wrong chat. From 119c3de1a0c1959b946b611610864bac63165fdc Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 20:19:54 +0200 Subject: [PATCH 31/35] MAESTRO: add Telegram troubleshooting section to README Mirrors the four troubleshooting bullets from docs/telegram-setup.md (forum admin permission, bot/agent binding mismatch, reaction emoji fallback, voice transcription dependencies) and splits the existing flat Troubleshooting list into Discord and Telegram subsections. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 1dcc1d3..19d46ae 100644 --- a/README.md +++ b/README.md @@ -270,5 +270,16 @@ Without this the bot will fail to connect with a "Used disallowed intents" error ## Troubleshooting +### Discord + - If `/health` fails, ensure `maestro-cli` is on your `PATH`. - If commands don’t appear, re-run `npm run deploy-commands` after updating your bot or application settings. + +### Telegram + +See [docs/telegram-setup.md](docs/telegram-setup.md) for the full walkthrough. Common pitfalls: + +- **Bot doesn't reply in a forum supergroup**: confirm the bot is an admin with the **Manage Topics** permission. Without it, the bridge cannot create forum topics and the call fails silently. +- **"Telegram bot is bound to agent X; cannot serve agent Y" in logs**: `maestro-relay send --agent Y --provider telegram` was called against a bot bound to a different agent. Use the bridge for agent Y instead, or run a second bridge instance for agent Y with its own bot. +- **No reactions appear (⏳)**: Telegram only allows a curated set of emoji reactions on messages. The bridge falls back gracefully — typing indicators continue to work even if the reaction emoji isn't accepted in your chat type. +- **Voice messages aren't transcribed**: confirm `ffmpeg` and `whisper-cli` are on your `PATH` and `WHISPER_MODEL_PATH` points at a real model file. Same setup as the Discord provider's voice support. From 7141da4d3676a2a6bd3793d874a0b4a5e003baa4 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 20:22:25 +0200 Subject: [PATCH 32/35] MAESTRO: correct /new to /session new in Telegram docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final-pass review caught that the walkthrough and architecture doc referenced a /new command that doesn't exist — the dispatcher only registers top-level commands (session, health, agents, ...). The real session-reset command is /session new. Updated both docs to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/architecture.md | 4 ++-- docs/telegram-setup.md | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index aaeeb69..c3e3699 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,8 +38,8 @@ The kernel speaks only in `IncomingMessage` / `OutgoingMessage` / `ChannelTarget Telegram uses a **bot-per-agent** model: at install time the bot is bound to one Maestro agent (`TELEGRAM_AGENT_ID`) and one chat (`TELEGRAM_CHAT_ID`). One bot serves exactly one agent for its lifetime. -1. **Forum mode**: user sends `/new` in the supergroup main feed → adapter calls `bot.api.createForumTopic`, registers the new topic in `telegram_agent_topics`, and treats that topic as one Maestro session. Subsequent messages in the topic are routed to that session. -2. **DM mode**: the bound chat is a single shared session. `/new` clears the stored session id so the next message starts a fresh maestro session. +1. **Forum mode**: user sends `/session new` in the supergroup main feed → adapter calls `bot.api.createForumTopic`, registers the new topic in `telegram_agent_topics`, and treats that topic as one Maestro session. Subsequent messages in the topic are routed to that session. +2. **DM mode**: the bound chat is a single shared session. `/session new` clears the stored session id so the next message starts a fresh maestro session. 3. Each message becomes an `IncomingMessage` with `channelId = chatId` (DM) or `chatId:topicId` (forum) and is passed to `ctx.enqueue`. 4. The kernel queue serializes per `(provider, channelId)` exactly as for Discord — reactions/typing, `resolveConversation`, attachment download, `maestro.send`, response splitting, usage footer, session persistence. 5. Outbound: `provider.send` posts via `bot.api.sendMessage`, attaching `message_thread_id` when the target is a forum topic. Long responses are split at the 4096-char Telegram message limit. diff --git a/docs/telegram-setup.md b/docs/telegram-setup.md index 628aa1f..a3860cc 100644 --- a/docs/telegram-setup.md +++ b/docs/telegram-setup.md @@ -12,7 +12,7 @@ if you've used Telegram as a chat app, you have everything you need. - A **forum supergroup** where each Maestro session becomes its own topic (recommended; mirrors how Discord channels + threads feel), OR - A **private DM** with the bot (simpler; one running session at a time, - use `/new` to reset). + use `/session new` to reset). One bot is bound to one agent for its lifetime. To bridge multiple agents, create one bot per agent (BotFather makes this cheap — see "Multiple agents" @@ -49,7 +49,7 @@ Choose **Option A** (forum supergroup) for the smoothest experience, or Forum supergroups let each Maestro session live in its own _topic_, similar to how Discord uses channels + threads. You'll be able to start fresh sessions -with `/new` without losing previous conversations. +with `/session new` without losing previous conversations. 1. In Telegram, tap the menu and choose **New Group**. Add at least one other contact (you can remove them after creating the group) — Telegram requires @@ -124,10 +124,11 @@ maestro-relay-ctl logs In your Telegram chat: -- **Forum mode**: send `/new` in the supergroup's main feed — a new topic - appears. Send messages inside that topic to talk to the agent. Run `/new` - again to start another session in a fresh topic. -- **DM mode**: just send a message. `/new` resets the session in place. +- **Forum mode**: send `/session new` in the supergroup's main feed — a new + topic appears. Send messages inside that topic to talk to the agent. Run + `/session new` again to start another session in a fresh topic. +- **DM mode**: just send a message. `/session new` resets the session in + place. Try `/health` to confirm the bridge is reaching `maestro-cli`. From 9c38f0971c73a633ef2f0d564e7dabec2d1dfcee Mon Sep 17 00:00:00 2001 From: chr1syy Date: Fri, 15 May 2026 11:55:41 +0200 Subject: [PATCH 33/35] fix(telegram): address PR #41 CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Actionable items from the review: - adapter.ts: parseChannelId now rejects non-numeric topic segments so malformed channelIds like 'chatId:abc' return { chatId } instead of { chatId, threadId: NaN } and bypassing the undefined guard in resolveConversation. - adapter.ts: bot.start() now attaches a .catch handler that logs the failure and clears this.ready, so a polling crash doesn't leave the provider stuck reporting itself as ready while unhandled-rejecting. - adapter.ts: bound-channel registration in start() now reconciles when TELEGRAM_AGENT_ID changes between restarts. Detects the mismatch against agent_channels and re-registers under the new agent with a warning so operators see the drift; previous forum topics are left in place (their topicDb rows still point at the old agent and will no longer resolveConversation). - adapter.ts: findOrCreateAgentChannel now picks the OLDEST topic for the agent (matches the new topicDb.getByAgentId ordering) so the 'default' topic stays stable across new-session creation. - commands/index.ts: /cmd@OtherBot no longer dispatches when the suffix targets a different bot — we now compare it case-insensitively against ctx.bot.botInfo.username and return false on mismatch. Added two new dispatcher tests (case-insensitive match, mismatch rejected) and updated the existing makeCtx fixture to provide a botInfo.username. - topicsDb.ts: register() switched to INSERT OR IGNORE so re-registering the same (chat_id, topic_id) is a no-op instead of throwing on the PK conflict; created_at stays from the first insert. - topicsDb.ts: getByAgentId() now orders by created_at ASC so the adapter's topics[0] selection returns the agent's original/default topic, not the most recently created one. - messageHandler.ts: forum-General-feed messages (no message_thread_id or is_topic_message=false) now log a one-line warn explaining they're ignored and pointing the user at /session new, rather than being dropped silently downstream in resolveConversation. Empty messages still drop without logging. - commands/playbook.ts: pendingSelections map now has a 10-minute TTL and a 256-entry cap with oldest-first eviction. New helpers getActiveSelection and setSelection handle expiry/pruning; callers routed through them. - scripts/deploy-commands.ts: parseEnabledProviders now validates names against a known set (discord/slack/telegram) and exits non-zero with a clear error on unknown entries, so typos like 'telegarm' fail fast instead of being silently 'skipped'. - install.sh: pick_telegram_agent re-prompts the user on out-of-range numeric selections instead of silently treating them as raw agent IDs; falls back to prompt_var on empty re-input or non-interactive shells. Skipped: - voice.ts deferred URL resolution: Telegram getFile URLs are valid for ~1h and the queue dispatches in sub-seconds, so storing pre-resolved URLs is fine in practice. Revisit if we ever queue across that window. - agentNameCache TTL: process lifetimes are short, agent renames are rare, the impact is purely cosmetic (topic title + reply formatting). - notes.ts usage/arg-parse: the current handleHistory parses args[1]=days / args[2]=limit / args[3]=filter, and the usage string 'history [days] [limit] [filter]' matches that exactly. CodeRabbit's comment was contradictory. - session.ts toFixed(4) cost formatting: low-value; $0.0000 already conveys 'cost rounded to zero'. - install.sh printf %s format-string hardening: every value is passed as an argument, not the format. No exploitable path. Build clean; 226 tests pass (was 224, +2 dispatcher tests for the @OtherBot suffix change). --- install.sh | 43 +++++++++++----- src/__tests__/telegramDispatchCommand.test.ts | 36 +++++++++++++- src/providers/telegram/adapter.ts | 36 +++++++++++--- src/providers/telegram/commands/index.ts | 11 ++++- src/providers/telegram/commands/playbook.ts | 49 +++++++++++++++++-- src/providers/telegram/messageHandler.ts | 20 ++++++++ src/providers/telegram/topicsDb.ts | 10 +++- src/scripts/deploy-commands.ts | 13 ++++- 8 files changed, 187 insertions(+), 31 deletions(-) diff --git a/install.sh b/install.sh index 93fb719..dbbb8dd 100755 --- a/install.sh +++ b/install.sh @@ -505,20 +505,37 @@ process.stdin.on("end", () => { return fi - case "$choice" in - ''|*[!0-9]*) - # Non-numeric input — treat as a raw agent ID - echo "$choice" - ;; - *) - if [ "$choice" -ge 1 ] && [ "$choice" -le "$i" ]; then - echo "${ids[$((choice - 1))]}" - else - warn "Selection out of range — using the entered value as a raw agent ID." + # If choice is numeric, validate against the list size and re-prompt on + # out-of-range. Out-of-range integers almost never resolve to a real Maestro + # agent ID, so silently accepting them used to fail later at start time. + while :; do + case "$choice" in + ''|*[!0-9]*) + # Non-numeric input — treat as a raw agent ID and stop prompting. echo "$choice" - fi - ;; - esac + return + ;; + *) + if [ "$choice" -ge 1 ] && [ "$choice" -le "$i" ]; then + echo "${ids[$((choice - 1))]}" + return + fi + warn "Selection out of range (pick 1-$i, or paste an agent ID)." + if [ -r /dev/tty ]; then + read -r -p " Re-pick: " choice = {}): { } { const replies: string[] = []; const ctx: DispatchCommandContext = { - bot: {} as DispatchCommandContext['bot'], + bot: { botInfo: { username: 'MyBot' } } as DispatchCommandContext['bot'], chatId: 'chat-1', threadId: undefined, fromUserId: 'user-1', @@ -88,7 +88,7 @@ test('dispatchCommand splits positional args on whitespace', async () => { } }); -test('dispatchCommand strips @ suffix from the command word', async () => { +test('dispatchCommand accepts @ suffix matching our bot username', async () => { const seen: TelegramCommandContext[] = []; const original = COMMANDS.agents.execute; COMMANDS.agents.execute = async (cmdCtx) => { @@ -105,6 +105,38 @@ test('dispatchCommand strips @ suffix from the command word', async () } }); +test('dispatchCommand is case-insensitive for the @ suffix', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS.agents.execute; + COMMANDS.agents.execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const handled = await dispatchCommand('/agents@mybot list', ctx); + assert.equal(handled, true); + assert.deepEqual(seen[0].args, ['list']); + } finally { + COMMANDS.agents.execute = original; + } +}); + +test('dispatchCommand returns false when @ suffix targets a different bot', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS.agents.execute; + COMMANDS.agents.execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const handled = await dispatchCommand('/agents@OtherBot list', ctx); + assert.equal(handled, false, 'command targeting another bot must not dispatch'); + assert.equal(seen.length, 0); + } finally { + COMMANDS.agents.execute = original; + } +}); + test('dispatchCommand is case-insensitive for command name', async () => { const seen: TelegramCommandContext[] = []; const original = COMMANDS.health.execute; diff --git a/src/providers/telegram/adapter.ts b/src/providers/telegram/adapter.ts index f5ea7bf..cd7edde 100644 --- a/src/providers/telegram/adapter.ts +++ b/src/providers/telegram/adapter.ts @@ -29,7 +29,10 @@ import { function parseChannelId(channelId: string): { chatId: string; threadId?: number } { const [chatId, topicStr] = channelId.split(':'); - return topicStr ? { chatId, threadId: Number(topicStr) } : { chatId }; + if (!topicStr || !/^-?\d+$/.test(topicStr)) return { chatId }; + const threadId = Number(topicStr); + if (!Number.isFinite(threadId)) return { chatId }; + return { chatId, threadId }; } export class TelegramProvider implements BridgeProvider { @@ -64,7 +67,8 @@ export class TelegramProvider implements BridgeProvider { ); } - if (!coreChannelDb.get('telegram', chatId)) { + const existing = coreChannelDb.get('telegram', chatId); + if (!existing || existing.agent_id !== agentId) { let agentName = agentId; try { const agents = await maestro.listAgents(); @@ -75,6 +79,13 @@ export class TelegramProvider implements BridgeProvider { `[telegram] could not resolve agent name from maestro-cli; falling back to agent id (${(err as Error).message})`, ); } + if (existing && existing.agent_id !== agentId) { + console.warn( + `[telegram] bound channel ${chatId} was registered to agent ${existing.agent_id}; ` + + `reconciling to ${agentId} (TELEGRAM_AGENT_ID changed). Forum topics from the previous binding are left in place but will no longer resolve.`, + ); + coreChannelDb.remove('telegram', chatId); + } coreChannelDb.register('telegram', chatId, agentId, agentName); console.log( `[telegram] registered bound channel ${chatId} → agent ${agentName} (${agentId})`, @@ -98,12 +109,17 @@ export class TelegramProvider implements BridgeProvider { }); bot.on('message', handler); - // Long-polling runs forever; do not await. - void bot.start({ - onStart: () => { - this.ready = true; - }, - }); + // Long-polling runs forever; don't await, but surface failures. + bot + .start({ + onStart: () => { + this.ready = true; + }, + }) + .catch((err) => { + this.ready = false; + console.error('[telegram] long-polling stopped with error:', err); + }); } async stop(): Promise { @@ -174,6 +190,10 @@ export class TelegramProvider implements BridgeProvider { const agentName = (await this.resolveAgentName(agentId)) ?? agentId; if (this.chatMode === 'forum') { + // Use the oldest topic for this agent as the stable "default", or create + // one if none exist. `topicDb.getByAgentId` returns rows ordered by + // created_at ascending so topics[0] is the original/default topic — not + // the most recently created. const topics = topicDb.getByAgentId(agentId); let topicId: number; if (topics.length === 0) { diff --git a/src/providers/telegram/commands/index.ts b/src/providers/telegram/commands/index.ts index 2876602..4c79c27 100644 --- a/src/providers/telegram/commands/index.ts +++ b/src/providers/telegram/commands/index.ts @@ -44,7 +44,16 @@ export async function dispatchCommand( if (!match) return false; const [, head, rest] = match; - const command = head.split('@', 1)[0].toLowerCase(); + // Telegram group convention: `/cmd@BotUsername` disambiguates among multiple + // bots in a chat. Only handle the command when the suffix targets *this* + // bot — otherwise return false so the message handler can ignore it. + const atIdx = head.indexOf('@'); + if (atIdx !== -1) { + const targetBot = head.slice(atIdx + 1).toLowerCase(); + const ourBot = ctx.bot.botInfo?.username?.toLowerCase(); + if (!ourBot || targetBot !== ourBot) return false; + } + const command = head.slice(0, atIdx === -1 ? head.length : atIdx).toLowerCase(); const entry = COMMANDS[command]; if (!entry) return false; diff --git a/src/providers/telegram/commands/playbook.ts b/src/providers/telegram/commands/playbook.ts index 81273af..9e111c5 100644 --- a/src/providers/telegram/commands/playbook.ts +++ b/src/providers/telegram/commands/playbook.ts @@ -4,12 +4,53 @@ import type { TelegramCommandContext } from './types'; export const command = 'playbook'; export const description = 'Run and inspect Maestro playbooks'; -const pendingSelections = new Map(); +type PendingSelection = { + playbooks: MaestroPlaybook[]; + action: 'show' | 'run'; + ts: number; +}; + +const PENDING_TTL_MS = 10 * 60 * 1000; // 10 minutes +const PENDING_MAX_ENTRIES = 256; +const pendingSelections = new Map(); function selectionKey(ctx: TelegramCommandContext): string { return `${ctx.chatId}:${ctx.threadId ?? 0}:${ctx.fromUserId}`; } +function pruneExpiredSelections(now: number = Date.now()): void { + for (const [key, value] of pendingSelections) { + if (now - value.ts > PENDING_TTL_MS) pendingSelections.delete(key); + } +} + +function getActiveSelection(key: string): PendingSelection | undefined { + const value = pendingSelections.get(key); + if (!value) return undefined; + if (Date.now() - value.ts > PENDING_TTL_MS) { + pendingSelections.delete(key); + return undefined; + } + return value; +} + +function setSelection(key: string, value: Omit): void { + pruneExpiredSelections(); + if (pendingSelections.size >= PENDING_MAX_ENTRIES) { + // Evict the oldest entry. + let oldestKey: string | undefined; + let oldestTs = Infinity; + for (const [k, v] of pendingSelections) { + if (v.ts < oldestTs) { + oldestTs = v.ts; + oldestKey = k; + } + } + if (oldestKey) pendingSelections.delete(oldestKey); + } + pendingSelections.set(key, { ...value, ts: Date.now() }); +} + export async function execute(ctx: TelegramCommandContext): Promise { const sub = ctx.args[0]; @@ -62,7 +103,7 @@ async function handleList(ctx: TelegramCommandContext): Promise { `Playbooks:\n${lines.join('\n')}\n\n` + 'Reply with /playbook show or /playbook run .', ); - pendingSelections.set(selectionKey(ctx), { playbooks, action: 'run' }); + setSelection(selectionKey(ctx), { playbooks, action: 'run' }); } async function handleShow(ctx: TelegramCommandContext): Promise { @@ -153,7 +194,7 @@ async function resolvePlaybookId( } if (/^\d+$/.test(arg)) { - const pending = pendingSelections.get(selectionKey(ctx)); + const pending = getActiveSelection(selectionKey(ctx)); if (!pending) { await ctx.reply( 'No recent /playbook list to pick from. Run /playbook list first.', @@ -174,7 +215,7 @@ async function handleNumberReply( ctx: TelegramCommandContext, num: number, ): Promise { - const pending = pendingSelections.get(selectionKey(ctx)); + const pending = getActiveSelection(selectionKey(ctx)); if (!pending) { await ctx.reply( 'No recent /playbook list to pick from. Run /playbook list first.', diff --git a/src/providers/telegram/messageHandler.ts b/src/providers/telegram/messageHandler.ts index aa85281..2835d5c 100644 --- a/src/providers/telegram/messageHandler.ts +++ b/src/providers/telegram/messageHandler.ts @@ -47,6 +47,12 @@ export function createMessageHandler(deps: MessageHandlerDeps) { const text = message.text ?? ''; const threadId = message.message_thread_id; const isThread = !!message.is_topic_message && typeof threadId === 'number'; + // In forum supergroups the "General" topic has no `message_thread_id` + // and is_topic_message is false. Topic-scoped messages have both set. + // We only route topic-scoped messages to maestro; General-feed messages + // are ignored (use `/session new` from anywhere to spawn a topic). + const isForumGeneralFeed = + deps.chatMode === 'forum' && !isThread; if (text.trimStart().startsWith('/')) { const boundAgentName = await deps.resolveAgentName(); @@ -72,6 +78,20 @@ export function createMessageHandler(deps: MessageHandlerDeps) { if (handled) return; } + // Drop non-command General-feed messages with a visible warning rather + // than failing silently downstream in resolveConversation. Users can + // start a topic via `/session new` (handled above before this point). + if (isForumGeneralFeed) { + const trimmed = (text || message.caption || '').trim(); + if (trimmed.length > 0) { + const log = deps.logger?.warn ?? console.warn; + log( + `[telegram] ignoring message in supergroup General feed (chat=${chat.id}) — start a topic with /session new to chat with the agent`, + ); + } + return; + } + const channelId = isThread ? `${chat.id}:${threadId}` : `${chat.id}`; let content = message.text ?? message.caption ?? ''; diff --git a/src/providers/telegram/topicsDb.ts b/src/providers/telegram/topicsDb.ts index 121797d..77d5ee1 100644 --- a/src/providers/telegram/topicsDb.ts +++ b/src/providers/telegram/topicsDb.ts @@ -10,8 +10,11 @@ export interface TelegramAgentTopic { export const topicDb = { register(topicId: number, chatId: string, agentId: string): void { + // Idempotent: ignore conflicts on (chat_id, topic_id) so reprocessing the + // same forum topic doesn't throw. created_at is preserved from the first + // insert via INSERT OR IGNORE. db.prepare( - `INSERT INTO telegram_agent_topics (topic_id, chat_id, agent_id, created_at) + `INSERT OR IGNORE INTO telegram_agent_topics (topic_id, chat_id, agent_id, created_at) VALUES (?, ?, ?, ?)`, ).run(topicId, chatId, agentId, Date.now()); }, @@ -23,8 +26,11 @@ export const topicDb = { }, getByAgentId(agentId: string): TelegramAgentTopic[] { + // ORDER BY created_at ASC so topics[0] is the original/default topic for + // the agent. findOrCreateAgentChannel relies on this ordering to return a + // stable channelId for /api/send. return db - .prepare('SELECT * FROM telegram_agent_topics WHERE agent_id = ? ORDER BY created_at DESC') + .prepare('SELECT * FROM telegram_agent_topics WHERE agent_id = ? ORDER BY created_at ASC') .all(agentId) as TelegramAgentTopic[]; }, diff --git a/src/scripts/deploy-commands.ts b/src/scripts/deploy-commands.ts index b679406..5100dd5 100644 --- a/src/scripts/deploy-commands.ts +++ b/src/scripts/deploy-commands.ts @@ -7,13 +7,24 @@ const PROVIDER_DEPLOY_SCRIPTS: Record = { telegram: path.resolve(__dirname, '..', 'providers', 'telegram', 'deploy.js'), }; +const KNOWN_PROVIDERS = new Set(['discord', 'slack', 'telegram']); + function parseEnabledProviders(): string[] { const raw = process.env.ENABLED_PROVIDERS; if (!raw) return ['discord']; - return raw + const names = raw .split(',') .map((s) => s.trim()) .filter((s) => s.length > 0); + const unknown = names.filter((n) => !KNOWN_PROVIDERS.has(n)); + if (unknown.length > 0) { + console.error( + `[deploy-commands] Unknown provider name(s) in ENABLED_PROVIDERS: ${unknown.join(', ')}. ` + + `Allowed: ${[...KNOWN_PROVIDERS].join(', ')}.`, + ); + process.exit(1); + } + return names; } function runDeploy(provider: string, scriptPath: string): Promise { From 32d46f03a9836a8244ea00c2780fdb2d11fc7810 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Fri, 15 May 2026 12:42:03 +0200 Subject: [PATCH 34/35] fix(installer): address second-round PR #41 CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three actionable items from the re-review: - bin/maestro-relay-ctl.sh:config_complete now validates *every* provider in ENABLED_PROVIDERS, not just the first match. Splits the CSV and appends required keys per provider. So 'ENABLED_PROVIDERS=discord,telegram' requires BOTH credential sets (was: only validated whichever case branch matched first, letting missing Discord keys slip past at deploy time). Returns 1 on unknown provider names. - install.sh:config_complete: same fix — loop over enabled providers and accumulate required keys, validate the union. Returns 1 on unknown provider names (parallels deploy-commands.ts hardening). - install.sh: replaced the three remaining '[ -r /dev/tty ]' tests in write_config_telegram and pick_telegram_agent with calls to the existing can_read_tty() helper. The helper exists specifically because [ -r /dev/tty ] returns true in some non-interactive contexts where the subsequent { fi local choice="" - if [ -r /dev/tty ]; then + if can_read_tty; then read -r -p " Pick an agent by number (or paste an agent ID): " choice { return fi warn "Selection out of range (pick 1-$i, or paste an agent ID)." - if [ -r /dev/tty ]; then + if can_read_tty; then read -r -p " Re-pick: " choice { } config_complete() { - local file="$1" key value enabled + local file="$1" key value enabled provider [ -f "$file" ] || return 1 enabled="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*$/\1/p' "$file" | head -n1)" enabled="${enabled#\"}"; enabled="${enabled%\"}" enabled="${enabled#\'}"; enabled="${enabled%\'}" [ -n "$enabled" ] || enabled="discord" + # Validate EVERY enabled provider's required env vars, not just the first + # match — multi-provider deployments like ENABLED_PROVIDERS=discord,telegram + # must have credentials for both. + local IFS=',' local -a required_keys=() - case ",$enabled," in - *,telegram,*) - required_keys=(TELEGRAM_BOT_TOKEN TELEGRAM_CHAT_ID TELEGRAM_AGENT_ID) - ;; - *,slack,*) - required_keys=(SLACK_BOT_TOKEN SLACK_SIGNING_SECRET SLACK_TEAM_ID SLACK_APP_ID) - ;; - *) - required_keys=(DISCORD_BOT_TOKEN DISCORD_CLIENT_ID DISCORD_GUILD_ID) - ;; - esac + for provider in $enabled; do + provider="${provider// /}" + case "$provider" in + telegram) + required_keys+=(TELEGRAM_BOT_TOKEN TELEGRAM_CHAT_ID TELEGRAM_AGENT_ID) + ;; + slack) + required_keys+=(SLACK_BOT_TOKEN SLACK_SIGNING_SECRET SLACK_TEAM_ID SLACK_APP_ID) + ;; + discord|'') + required_keys+=(DISCORD_BOT_TOKEN DISCORD_CLIENT_ID DISCORD_GUILD_ID) + ;; + *) return 1 ;; + esac + done + unset IFS for key in "${required_keys[@]}"; do value="$(sed -nE "s/^${key}=([^#[:space:]]+).*/\1/p" "$file" | head -n1)" [ -n "$value" ] || return 1 From 20b108efa73ee50e34eb1d503d0bca251277c012 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sat, 16 May 2026 11:28:26 +0200 Subject: [PATCH 35/35] fix(telegram): address Codex review findings on PR #41 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two major issues flagged by Codex review: 1. Forum-mode default-topic lookup was not scoped to the bound chat. topicDb.getByAgentId() returned rows from every chat the agent had ever had topics in. findOrCreateAgentChannel then picked topics[0] and combined it with the CURRENT telegramConfig.chatId — yielding a (currentChatId, oldTopicId) pair that doesn't exist in the new chat after a TELEGRAM_CHAT_ID change or reinstall. The bound-channel reconciliation already invalidates this for resolveConversation (via chatId check), but findOrCreateAgentChannel was missed. Fix: added topicDb.getByAgentIdInChat(chatId, agentId) and switched findOrCreateAgentChannel to use it. getByAgentId is kept as the cross-chat variant with a doc comment pointing routing callers to the chat-scoped form. 2. Telegram non-voice attachments pre-resolved their getFile URLs at intake time and stored them on the IncomingAttachment. Those URLs are only valid for ~1 hour, so a backlogged per-conversation queue (long agent runs stacking up) could hit expired URLs by the time downloadAttachments ran. Previously rationalized as safe because of typical sub-second dispatch, but Codex correctly points out the worst case under sustained load. Fix: extended IncomingAttachment with an optional resolveUrl?: () => Promise callback. downloadAttachments now calls it just-in-time and reports the attachment as failed if it throws. attachmentsFromMessage refactored to store the file_id closure instead of pre-resolving; voice attachments are unchanged because they're consumed in the messageHandler before enqueue (so the URL is fresh when transcription runs). Tests: - 2 new attachments tests covering resolveUrl just-in-time and resolveUrl-throws-reports-failed. - 2 new topicsDb tests covering chat scoping and oldest-first ordering for getByAgentIdInChat. Build clean; 230 tests pass (was 226, +4). --- src/__tests__/attachments.test.ts | 58 +++++++++++++++ src/__tests__/telegramSessionNew.test.ts | 2 +- src/__tests__/telegramTopicsDb.test.ts | 50 +++++++++++++ src/core/attachments.ts | 19 ++++- src/core/types.ts | 11 +++ src/providers/telegram/adapter.ts | 12 +-- src/providers/telegram/messageHandler.ts | 2 +- src/providers/telegram/topicsDb.ts | 17 ++++- src/providers/telegram/voice.ts | 93 ++++++++++++++++-------- 9 files changed, 224 insertions(+), 40 deletions(-) create mode 100644 src/__tests__/telegramTopicsDb.test.ts diff --git a/src/__tests__/attachments.test.ts b/src/__tests__/attachments.test.ts index 1d3c429..5b527e8 100644 --- a/src/__tests__/attachments.test.ts +++ b/src/__tests__/attachments.test.ts @@ -182,6 +182,64 @@ test('downloadAttachments reports all files as failed when mkdir fails', async ( assert.deepEqual(failed, ['a.txt', 'b.txt']); }); +test('downloadAttachments calls resolveUrl just-in-time and downloads from its result', async () => { + let resolveCalls = 0; + let fetchedUrl = ''; + globalThis.fetch = (url: string | URL | Request) => { + fetchedUrl = String(url); + return Promise.resolve(okResponse('lazy content')); + }; + + const { downloaded, failed } = await downloadAttachments( + [ + makeAttachment({ + name: 'lazy.bin', + url: '', + size: 100, + resolveUrl: async () => { + resolveCalls++; + return 'https://api.example.com/files/short-lived-token-abc'; + }, + }), + ], + tmpDir, + ); + + assert.equal(resolveCalls, 1, 'resolveUrl must be called once per attachment'); + assert.equal( + fetchedUrl, + 'https://api.example.com/files/short-lived-token-abc', + 'fetch must receive the just-in-time URL, not the empty placeholder', + ); + assert.equal(downloaded.length, 1); + assert.deepEqual(failed, []); + const content = await readFile(downloaded[0].savedPath, 'utf-8'); + assert.equal(content, 'lazy content'); +}); + +test('downloadAttachments reports the file as failed when resolveUrl throws', async () => { + globalThis.fetch = () => { + throw new Error('fetch must not be called if resolveUrl fails'); + }; + + const { downloaded, failed } = await downloadAttachments( + [ + makeAttachment({ + name: 'expired.bin', + url: '', + size: 100, + resolveUrl: async () => { + throw new Error('upstream getFile failed'); + }, + }), + ], + tmpDir, + ); + + assert.equal(downloaded.length, 0); + assert.deepEqual(failed, ['expired.bin']); +}); + test('downloadAttachments handles partial failures', async () => { let callCount = 0; globalThis.fetch = () => { diff --git a/src/__tests__/telegramSessionNew.test.ts b/src/__tests__/telegramSessionNew.test.ts index 5bc8b53..faea7d9 100644 --- a/src/__tests__/telegramSessionNew.test.ts +++ b/src/__tests__/telegramSessionNew.test.ts @@ -67,7 +67,7 @@ function baseDeps(overrides: Partial[0]> downloadVoice: async () => { throw new Error('not used'); }, - attachmentsFromMessage: async () => [], + attachmentsFromMessage: () => [], transcribeVoiceAttachment: async () => '', isTranscriberAvailable: () => false, logger: { warn: () => undefined, error: () => undefined }, diff --git a/src/__tests__/telegramTopicsDb.test.ts b/src/__tests__/telegramTopicsDb.test.ts new file mode 100644 index 0000000..fafcb63 --- /dev/null +++ b/src/__tests__/telegramTopicsDb.test.ts @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { topicDb } from '../providers/telegram/topicsDb'; +import { db } from '../core/db'; + +test('topicDb.getByAgentIdInChat returns only rows for the given chat', () => { + db.prepare('DELETE FROM telegram_agent_topics WHERE agent_id = ?').run('test-scoped-agent'); + topicDb.register(101, 'chat-A', 'test-scoped-agent'); + topicDb.register(102, 'chat-A', 'test-scoped-agent'); + topicDb.register(201, 'chat-B', 'test-scoped-agent'); + + const aOnly = topicDb.getByAgentIdInChat('chat-A', 'test-scoped-agent'); + assert.equal(aOnly.length, 2); + assert.deepEqual( + aOnly.map((r) => r.topic_id).sort(), + [101, 102], + 'must include only chat-A rows', + ); + + const bOnly = topicDb.getByAgentIdInChat('chat-B', 'test-scoped-agent'); + assert.equal(bOnly.length, 1); + assert.equal(bOnly[0].topic_id, 201); + + const cOnly = topicDb.getByAgentIdInChat('chat-C', 'test-scoped-agent'); + assert.equal(cOnly.length, 0, 'unknown chat returns empty'); + + // getByAgentId still returns everything across chats + const all = topicDb.getByAgentId('test-scoped-agent'); + assert.equal(all.length, 3); + + db.prepare('DELETE FROM telegram_agent_topics WHERE agent_id = ?').run('test-scoped-agent'); +}); + +test('topicDb.getByAgentIdInChat orders ascending by created_at (default topic is oldest)', () => { + db.prepare('DELETE FROM telegram_agent_topics WHERE agent_id = ?').run('test-order-agent'); + topicDb.register(700, 'chat-X', 'test-order-agent'); + // Wait a tick so created_at differs (better-sqlite3 + Date.now in ms) + const start = Date.now(); + while (Date.now() === start) { + /* tight spin */ + } + topicDb.register(800, 'chat-X', 'test-order-agent'); + + const topics = topicDb.getByAgentIdInChat('chat-X', 'test-order-agent'); + assert.equal(topics.length, 2); + assert.equal(topics[0].topic_id, 700, 'oldest topic must come first'); + assert.equal(topics[1].topic_id, 800); + + db.prepare('DELETE FROM telegram_agent_topics WHERE agent_id = ?').run('test-order-agent'); +}); diff --git a/src/core/attachments.ts b/src/core/attachments.ts index e2b0a33..5a03b13 100644 --- a/src/core/attachments.ts +++ b/src/core/attachments.ts @@ -51,7 +51,24 @@ export async function downloadAttachments( const savedPath = path.join(targetDir, filename); try { - const response = await fetch(attachment.url); + // Resolve the URL lazily if the provider supplied a resolver. This is + // how Telegram avoids stale getFile URLs (~1h expiry) when the queue + // backlog stretches across long agent runs. + let downloadUrl: string; + try { + downloadUrl = attachment.resolveUrl + ? await attachment.resolveUrl() + : attachment.url; + } catch (err) { + console.warn( + `[attachments] Failed to resolve URL for "${attachment.name}":`, + err, + ); + failed.push(attachment.name); + continue; + } + + const response = await fetch(downloadUrl); if (!response.ok) { console.warn( `[attachments] Failed to download "${attachment.name}": HTTP ${response.status}`, diff --git a/src/core/types.ts b/src/core/types.ts index ca67730..f8e2810 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -20,10 +20,21 @@ export interface MessageTarget extends ChannelTarget { } export interface IncomingAttachment { + /** + * Direct download URL. For platforms that mint short-lived URLs (e.g. + * Telegram's getFile, which expires after ~1 hour), prefer `resolveUrl` + * so the URL is fetched just-in-time at download. + */ url: string; name: string; size: number; contentType?: string; + /** + * Optional lazy URL resolver. When present, `downloadAttachments` calls + * this just before fetching to avoid using a stale pre-resolved URL. Use + * this for providers whose download URLs are time-limited. + */ + resolveUrl?: () => Promise; } export interface IncomingMessage { diff --git a/src/providers/telegram/adapter.ts b/src/providers/telegram/adapter.ts index cd7edde..28dfe15 100644 --- a/src/providers/telegram/adapter.ts +++ b/src/providers/telegram/adapter.ts @@ -190,11 +190,13 @@ export class TelegramProvider implements BridgeProvider { const agentName = (await this.resolveAgentName(agentId)) ?? agentId; if (this.chatMode === 'forum') { - // Use the oldest topic for this agent as the stable "default", or create - // one if none exist. `topicDb.getByAgentId` returns rows ordered by - // created_at ascending so topics[0] is the original/default topic — not - // the most recently created. - const topics = topicDb.getByAgentId(agentId); + // Use the oldest topic for this agent *in the currently bound chat* as + // the stable "default", or create one if none exist. Scoping to + // telegramConfig.chatId prevents a stale row from a previous + // TELEGRAM_CHAT_ID binding from being combined with the new chat id, + // which would yield a (currentChat, oldTopicId) pair that doesn't + // exist on Telegram. + const topics = topicDb.getByAgentIdInChat(telegramConfig.chatId, agentId); let topicId: number; if (topics.length === 0) { const created = await this.bot.api.createForumTopic( diff --git a/src/providers/telegram/messageHandler.ts b/src/providers/telegram/messageHandler.ts index 2835d5c..bed4f3a 100644 --- a/src/providers/telegram/messageHandler.ts +++ b/src/providers/telegram/messageHandler.ts @@ -108,7 +108,7 @@ export function createMessageHandler(deps: MessageHandlerDeps) { attachments = [voiceAttachment]; } } else { - attachments = await deps.attachmentsFromMessage(deps.bot, message); + attachments = deps.attachmentsFromMessage(deps.bot, message); } const authorName = diff --git a/src/providers/telegram/topicsDb.ts b/src/providers/telegram/topicsDb.ts index 77d5ee1..16dc880 100644 --- a/src/providers/telegram/topicsDb.ts +++ b/src/providers/telegram/topicsDb.ts @@ -27,13 +27,26 @@ export const topicDb = { getByAgentId(agentId: string): TelegramAgentTopic[] { // ORDER BY created_at ASC so topics[0] is the original/default topic for - // the agent. findOrCreateAgentChannel relies on this ordering to return a - // stable channelId for /api/send. + // the agent. NOTE: returns rows from *every* chat the agent has ever had + // topics in. Use `getByAgentIdInChat` when you need to scope to the + // current bound chat (e.g. `findOrCreateAgentChannel`). return db .prepare('SELECT * FROM telegram_agent_topics WHERE agent_id = ? ORDER BY created_at ASC') .all(agentId) as TelegramAgentTopic[]; }, + getByAgentIdInChat(chatId: string, agentId: string): TelegramAgentTopic[] { + // Chat-scoped variant of getByAgentId. Always prefer this in routing / + // outbound paths so a stale row from a previous TELEGRAM_CHAT_ID can't + // produce a (currentChatId, oldTopicId) pair that doesn't exist on + // Telegram. + return db + .prepare( + 'SELECT * FROM telegram_agent_topics WHERE chat_id = ? AND agent_id = ? ORDER BY created_at ASC', + ) + .all(chatId, agentId) as TelegramAgentTopic[]; + }, + updateSession(chatId: string, topicId: number, sessionId: string | null): void { db.prepare( 'UPDATE telegram_agent_topics SET session_id = ? WHERE chat_id = ? AND topic_id = ?', diff --git a/src/providers/telegram/voice.ts b/src/providers/telegram/voice.ts index 0a6629c..db84a04 100644 --- a/src/providers/telegram/voice.ts +++ b/src/providers/telegram/voice.ts @@ -22,6 +22,8 @@ export async function downloadVoice( if (!msg.voice) { throw new Error('downloadVoice called on a message without a voice payload'); } + // Voice is consumed immediately by transcribeVoiceAttachment in the + // messageHandler (before enqueue), so pre-resolving the URL is fine. const url = await fileUrl(bot, msg.voice.file_id); return { url, @@ -31,53 +33,84 @@ export async function downloadVoice( }; } -export async function attachmentsFromMessage( +/** + * Build a deferred-URL `IncomingAttachment` for a Telegram file. The actual + * `getFile` call is delayed until the kernel's `downloadAttachments` runs + * via the `resolveUrl` callback. This avoids using a stale URL when the + * per-conversation queue is backlogged behind long agent runs (Telegram + * getFile URLs are only valid for ~1h). + */ +function lazyTelegramAttachment( + bot: Bot, + fileId: string, + name: string, + size: number, + contentType: string | undefined, +): IncomingAttachment { + return { + url: '', + name, + size, + contentType, + resolveUrl: () => fileUrl(bot, fileId), + }; +} + +export function attachmentsFromMessage( bot: Bot, msg: TelegramMessage, -): Promise { +): IncomingAttachment[] { const attachments: IncomingAttachment[] = []; if (msg.document) { - const url = await fileUrl(bot, msg.document.file_id); - attachments.push({ - url, - name: msg.document.file_name ?? `document-${msg.message_id}`, - size: msg.document.file_size ?? 0, - contentType: msg.document.mime_type, - }); + attachments.push( + lazyTelegramAttachment( + bot, + msg.document.file_id, + msg.document.file_name ?? `document-${msg.message_id}`, + msg.document.file_size ?? 0, + msg.document.mime_type, + ), + ); } if (msg.photo && msg.photo.length > 0) { const largest = msg.photo.reduce((a, b) => (a.file_size ?? a.width * a.height) >= (b.file_size ?? b.width * b.height) ? a : b, ); - const url = await fileUrl(bot, largest.file_id); - attachments.push({ - url, - name: `photo-${msg.message_id}.jpg`, - size: largest.file_size ?? 0, - contentType: 'image/jpeg', - }); + attachments.push( + lazyTelegramAttachment( + bot, + largest.file_id, + `photo-${msg.message_id}.jpg`, + largest.file_size ?? 0, + 'image/jpeg', + ), + ); } if (msg.audio) { - const url = await fileUrl(bot, msg.audio.file_id); - attachments.push({ - url, - name: msg.audio.file_name ?? `audio-${msg.message_id}`, - size: msg.audio.file_size ?? 0, - contentType: msg.audio.mime_type ?? 'audio/mpeg', - }); + attachments.push( + lazyTelegramAttachment( + bot, + msg.audio.file_id, + msg.audio.file_name ?? `audio-${msg.message_id}`, + msg.audio.file_size ?? 0, + msg.audio.mime_type ?? 'audio/mpeg', + ), + ); } if (msg.video) { - const url = await fileUrl(bot, msg.video.file_id); - attachments.push({ - url, - name: msg.video.file_name ?? `video-${msg.message_id}.mp4`, - size: msg.video.file_size ?? 0, - contentType: msg.video.mime_type ?? 'video/mp4', - }); + attachments.push( + lazyTelegramAttachment( + bot, + msg.video.file_id, + msg.video.file_name ?? `video-${msg.message_id}.mp4`, + msg.video.file_size ?? 0, + msg.video.mime_type ?? 'video/mp4', + ), + ); } return attachments;