diff --git a/README.md b/README.md index c7764c9..f46def3 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ curl -fsSL https://bun.sh/install | bash **Ubuntu / Debian / WSL2:** ```bash curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - -sudo apt-get install -y nodejs tmux +sudo apt-get install -y nodejs tmux build-essential curl -fsSL https://bun.sh/install | bash # Docker Engine @@ -243,7 +243,7 @@ src/ ├── config/ Zod schema, config I/O, paths, Gemini CLI settings ├── memory/ SQLite usage tracking, QMD integration ├── mcp/ MCP servers (status, cron, ask-user, gog, admin) -├── channels/ Chat SDK adapters + reply delivery (Discord/Slack) +├── channels/ Chat SDK adapters + reply delivery (Discord/Slack/Telegram) ├── inngest/ Durable functions (agent-run, heartbeat, cron, daily-summary) ├── cli/commands/ CLI command implementations ├── vault/ Secret management (keyring/encrypted-file/command) diff --git a/bun.lock b/bun.lock index 86ac078..fcbea31 100644 --- a/bun.lock +++ b/bun.lock @@ -5,16 +5,17 @@ "": { "name": "geminiclaw", "dependencies": { - "@chat-adapter/discord": "^4.18.0", - "@chat-adapter/slack": "^4.14.0", - "@chat-adapter/state-memory": "^4.14.0", + "@chat-adapter/discord": "^4.20.0", + "@chat-adapter/slack": "^4.20.0", + "@chat-adapter/state-memory": "^4.20.0", + "@chat-adapter/telegram": "^4.20.0", "@clack/prompts": "^1.0.1", "@google/gemini-cli": "0.34.0-nightly.20260307.6c3a90645", "@mariozechner/pi-tui": "^0.54.2", "@modelcontextprotocol/sdk": "^1", "@tobilu/qmd": "^1.1.6", "chalk": "^5", - "chat": "^4.14.0", + "chat": "^4.20.0", "commander": "^12", "croner": "^10.0.1", "express": "^5", @@ -76,13 +77,15 @@ "@bufbuild/protobuf": ["@bufbuild/protobuf@2.11.0", "", {}, "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ=="], - "@chat-adapter/discord": ["@chat-adapter/discord@4.18.0", "", { "dependencies": { "@chat-adapter/shared": "4.18.0", "chat": "4.18.0", "discord-api-types": "^0.37.119", "discord-interactions": "^4.4.0", "discord.js": "^14.25.1" } }, "sha512-9m33jG34l9dAUeGAQrNzFuDWAEntlmOaOf7f4ba6m9b9IQ1Wf+X/4BE6sjy9oaAHsLQA1AWyaFW+h9dvBPBOAw=="], + "@chat-adapter/discord": ["@chat-adapter/discord@4.20.0", "", { "dependencies": { "@chat-adapter/shared": "4.20.0", "chat": "4.20.0", "discord-api-types": "^0.37.119", "discord-interactions": "^4.4.0", "discord.js": "^14.25.1" } }, "sha512-iCHoxOWmHJx3Z3haxZQJwd9oq2fN/mK+oieiJ0ttL1vuJ/BDi2tnvC2hXP3X+kS1yb2Iy5sK476HeAlMu9vwzw=="], - "@chat-adapter/shared": ["@chat-adapter/shared@4.18.0", "", { "dependencies": { "chat": "4.18.0" } }, "sha512-tVmiOHJM1P/eTwAnG0/7vSM/z8cKDrEH/7mUNglApYymtzDTOMc2j/cikM+s+eIE2fXoHEBqly8Bf+bf+zw+3g=="], + "@chat-adapter/shared": ["@chat-adapter/shared@4.20.0", "", { "dependencies": { "chat": "4.20.0" } }, "sha512-PaN3TADZUswD5VNV5qMS5M316PP9hp14lGHNa5SB8s7LN5PGXuXUroO0e7rjyo18i7jNKsalJUjpD/uoEg0u3Q=="], - "@chat-adapter/slack": ["@chat-adapter/slack@4.14.0", "", { "dependencies": { "@chat-adapter/shared": "4.14.0", "@slack/web-api": "^7.11.0", "chat": "4.14.0" } }, "sha512-IJ0cJ3QC1f/inieA12VIhoJCap59kQbT4xovt52OU9WtTy4zGkxKIgEem7jA+KcbxeepoypmNIpTy8QSUMZBnQ=="], + "@chat-adapter/slack": ["@chat-adapter/slack@4.20.0", "", { "dependencies": { "@chat-adapter/shared": "4.20.0", "@slack/web-api": "^7.14.0", "chat": "4.20.0" } }, "sha512-pZruK/ndyXJcgwTQ7S9MjB31TE80REuMtcGGlZCX/iaPSZ2QYDhz2493MvtBiy2DY4dyNOk2eihsf1r9JzzhkQ=="], - "@chat-adapter/state-memory": ["@chat-adapter/state-memory@4.14.0", "", { "dependencies": { "chat": "4.14.0" } }, "sha512-6YWMok5/1tS81lXspXhObmXVN+Zaopdd4ZMMh3r2WVeCCLCvbRYz9X9tqA+QuI2XCWxUcAPuWavFATgn+PdkLw=="], + "@chat-adapter/state-memory": ["@chat-adapter/state-memory@4.20.0", "", { "dependencies": { "chat": "4.20.0" } }, "sha512-J7IowToAxfrVsCdkHSTaBEbe6MHygFr+hS+7qHMdNXaCfDCQrN/G6lB7QOxLQrPNenL8qMKWq41vsHpLbkiQTA=="], + + "@chat-adapter/telegram": ["@chat-adapter/telegram@4.20.0", "", { "dependencies": { "@chat-adapter/shared": "4.20.0", "chat": "4.20.0" } }, "sha512-zNmTA6e+dy21sdYafe/V3Ytd5id9SeRweWOUERmg4OK2w8PXIsyniGusW1agd0vcl2ERQF/mRcuHLPVV379Mww=="], "@clack/core": ["@clack/core@1.0.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g=="], @@ -690,7 +693,7 @@ "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], - "chat": ["chat@4.14.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5" } }, "sha512-gVod4FBptr4ewPEdY2zo1GnegI8/O0aC5F+IvFMIjimqmQjW0txhQbgI/DgmD48pwXJhxUGpvNrqzMrChx8GWw=="], + "chat": ["chat@4.20.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" } }, "sha512-9/SvX0wM1AZa7VyibyVjRkb3WsbmSbIm54i1MMtzFQEj96oAisFdxkRoBKQDZ4R9tfLe7f6SOwWIHqwCGaCxoA=="], "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], @@ -1518,12 +1521,6 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@chat-adapter/discord/chat": ["chat@4.18.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" } }, "sha512-SrBPE+MjsHEc1DFS77IYftFw4IDgrQt7khZU5WQ4QIGI5I4DdOr+KZkRrwWLIyVCuEZjduEgHq1eHWtLCHH6YQ=="], - - "@chat-adapter/shared/chat": ["chat@4.18.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" } }, "sha512-SrBPE+MjsHEc1DFS77IYftFw4IDgrQt7khZU5WQ4QIGI5I4DdOr+KZkRrwWLIyVCuEZjduEgHq1eHWtLCHH6YQ=="], - - "@chat-adapter/slack/@chat-adapter/shared": ["@chat-adapter/shared@4.14.0", "", { "dependencies": { "chat": "4.14.0" } }, "sha512-D6OCFlO7g8Tvq6YJ06c0KkEtR4XKFx0FLCGV93AtRrww9mE9MBKEgeQwJosFRMt7y21n6XJK1n/BPZDnTveNZg=="], - "@discordjs/builders/discord-api-types": ["discord-api-types@0.38.40", "", {}, "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ=="], "@discordjs/formatters/discord-api-types": ["discord-api-types@0.38.40", "", {}, "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ=="], diff --git a/docs/architecture.md b/docs/architecture.md index fbe4b09..2ffb1fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,14 +9,15 @@ GeminiClaw treats **Gemini CLI as the core agent engine** and wraps it with thin | LLM reasoning & tool use | **Gemini CLI** (ACP) | Full-featured agent runtime with sandbox, MCP, multi-turn sessions | | Durable execution & scheduling | **Inngest** | Retry, step persistence, concurrency control without custom queue | | Memory search & retrieval | **QMD** | Hybrid search (BM25 + Vector + LLM reranking) without custom embedder | -| Multi-channel messaging | **Vercel Chat SDK** | Discord/Slack adapters without managing gateway connections | +| Multi-channel messaging | **Vercel Chat SDK** | Discord/Slack/Telegram adapters without managing gateway connections | GeminiClaw itself is the glue: session lifecycle, process pool management, context injection, and security gates. This thin-wrapper approach keeps the codebase extensible without reimplementing capabilities that already exist upstream. ``` ┌─────────────────────┐ │ Trigger Sources │ - │ Discord · Slack │ + │ Discord · Slack · │ + │ Telegram │ │ CLI · Cron · HTTP │ └─────────┬───────────┘ │ webhook / event @@ -37,8 +38,8 @@ GeminiClaw itself is the glue: session lifecycle, process pool management, conte │ │ │ │ │ │ ┌───────▼─────┐ ┌────────▼────────┐ ┌──────▼──────┐ │ │ │ QMD │ │ Chat SDK │ │ MCP │ │ -│ │ hybrid │ │ Discord/Slack │ │ Servers │ │ -│ │ search │ │ reply routing │ │ gog · cron │ │ +│ │ hybrid │ │ Discord/Slack/ │ │ Servers │ │ +│ │ search │ │ Telegram │ │ gog · cron │ │ │ │ over │ │ │ │ ask-user │ │ │ │ memory │ │ │ │ status │ │ │ └─────────────┘ └─────────────────┘ │ admin │ │ @@ -111,11 +112,12 @@ Highest-scoring process wins. Within the same score, recency (`lastUsedAt`) brea Inngest serves as both the **durable execution engine** and the **event bus** that unifies all trigger sources. Every external input — webhooks, CLI commands, cron schedules — is normalized into an Inngest event (`geminiclaw/run`), making the agent execution path uniform regardless of origin. ``` -Discord webhook ─┐ -Slack webhook ─┤ -CLI command ─┼─→ inngest.send('geminiclaw/run') ─→ agentRun() ─→ turn lifecycle -Heartbeat cron ─┤ -Custom cron ─┘ +Discord webhook ─┐ +Slack webhook ─┤ +Telegram polling ─┤ +CLI command ─┼─→ inngest.send('geminiclaw/run') ─→ agentRun() ─→ turn lifecycle +Heartbeat cron ─┤ +Custom cron ─┘ ``` This architecture means adding a new trigger source (e.g. a GitHub webhook, an HTTP API, or a scheduled task) only requires emitting an Inngest event — no changes to the agent execution pipeline. diff --git a/docs/security.md b/docs/security.md index 776b935..a0292d1 100644 --- a/docs/security.md +++ b/docs/security.md @@ -130,7 +130,7 @@ Gemini CLI's ACP does not support native `ask_user` prompts, and the MCP specifi ``` 1. MCP tool → writes ask-user-pending-{askId}.json -2. Runner → detects file → sends button message to chat (Discord/Slack) +2. Runner → detects file → sends button message to chat (Discord/Slack/Telegram) 3. User → clicks button 4. Chat handler → writes ask-user-answer-{askId}.json 5. MCP tool → detects file (500ms polling) → continue or abort diff --git a/package.json b/package.json index ec55482..67f05b0 100644 --- a/package.json +++ b/package.json @@ -32,16 +32,17 @@ "prepare": "git config core.hooksPath .githooks" }, "dependencies": { - "@chat-adapter/discord": "^4.18.0", - "@chat-adapter/slack": "^4.14.0", - "@chat-adapter/state-memory": "^4.14.0", + "@chat-adapter/discord": "^4.20.0", + "@chat-adapter/slack": "^4.20.0", + "@chat-adapter/state-memory": "^4.20.0", + "@chat-adapter/telegram": "^4.20.0", "@clack/prompts": "^1.0.1", "@google/gemini-cli": "0.34.0-nightly.20260307.6c3a90645", "@mariozechner/pi-tui": "^0.54.2", "@modelcontextprotocol/sdk": "^1", "@tobilu/qmd": "^1.1.6", "chalk": "^5", - "chat": "^4.14.0", + "chat": "^4.20.0", "commander": "^12", "croner": "^10.0.1", "express": "^5", diff --git a/patches/@tobilu%2Fqmd@1.1.6.patch b/patches/@tobilu%2Fqmd@1.1.6.patch index 5498339..f5d4938 100644 --- a/patches/@tobilu%2Fqmd@1.1.6.patch +++ b/patches/@tobilu%2Fqmd@1.1.6.patch @@ -36,3 +36,18 @@ index d1b09687ae86c1f22c245091ed7391879057f7c1..29946f0f109449d7d36e071b46cfdfbf const server = new McpServer({ name: "qmd", version: "0.9.9" }, { instructions: buildInstructions(store) }); // --------------------------------------------------------------------------- // Resource: qmd://{path} - read-only access to documents by path +diff --git a/package.json b/package.json +index a47a93bc0a8cceeb46432cb2e2f797bd2be7ae6b..a7d3041db4c51be80c17aa970db022a448926e63 100644 +--- a/package.json ++++ b/package.json +@@ -9,7 +9,9 @@ + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" +- } ++ }, ++ "./dist/store.js": "./dist/store.js", ++ "./dist/mcp.js": "./dist/mcp.js" + }, + "bin": { + "qmd": "dist/qmd.js" diff --git a/src/agent/bootstrap.ts b/src/agent/bootstrap.ts index a596e13..8c39b6b 100644 --- a/src/agent/bootstrap.ts +++ b/src/agent/bootstrap.ts @@ -47,7 +47,7 @@ export function shouldBootstrap(params: { if (!existsSync(join(workspacePath, BOOTSTRAP_FILENAME))) return false; // For channel triggers, only activate in home channel or DMs - if (trigger === 'discord' || trigger === 'slack') { + if (trigger === 'discord' || trigger === 'slack' || trigger === 'telegram') { if (!isHomeChannel && !isDM) return false; } diff --git a/src/agent/context-builder.ts b/src/agent/context-builder.ts index d69dd67..8001ebb 100644 --- a/src/agent/context-builder.ts +++ b/src/agent/context-builder.ts @@ -74,10 +74,7 @@ export class ContextBuilder { /** Resolve home channel as `platform:channelId` string. */ private resolveHomeChannel(): string | undefined { const config = loadConfig(); - const dc = config.channels.discord; - if (dc.enabled && dc.homeChannel) return `discord:${dc.homeChannel}`; - const sc = config.channels.slack; - if (sc.enabled && sc.homeChannel) return `slack:${sc.homeChannel}`; + if (config.home) return `${config.home.channel}:${config.home.channelId}`; return undefined; } @@ -324,7 +321,7 @@ export class ContextBuilder { parts.push('Do NOT respond with HEARTBEAT_OK under any circumstances.'); } - if (options.trigger === 'discord' || options.trigger === 'slack') { + if (options.trigger === 'discord' || options.trigger === 'slack' || options.trigger === 'telegram') { parts.push(''); parts.push('### Channel Context'); parts.push('You are replying in a chat channel. Follow SOUL.md Communication Style.'); diff --git a/src/agent/turn/finalize.ts b/src/agent/turn/finalize.ts index 2ee2d49..81dc2d9 100644 --- a/src/agent/turn/finalize.ts +++ b/src/agent/turn/finalize.ts @@ -3,7 +3,7 @@ * * All handlers are fail-open with conditional execution: * - generateTitle: first turn only - * - notifyBackgroundJob: heartbeat + cron → heartbeat.notifications channel + * - notifyBackgroundJob: heartbeat + cron → notifications channel * - sendReply: when a reply target exists (skipped for heartbeat OK) */ @@ -50,13 +50,12 @@ async function generateTitle(ctx: DeliverContext): Promise { /** * Post a brief completion notification for background jobs (heartbeat / cron) - * to the heartbeat.notifications channel. This is separate from the full + * to the notifications channel. This is separate from the full * result reply which goes to the job's own reply channel. * * Desktop notifications only fire on heartbeat alerts. */ async function notifyBackgroundJob(ctx: DeliverContext): Promise { - const notif = ctx.config.heartbeat.notifications; const trigger = ctx.eventData.trigger; const promises: Promise[] = []; @@ -65,7 +64,7 @@ async function notifyBackgroundJob(ctx: DeliverContext): Promise { const isAlert = !ctx.runResult.heartbeatOk; const filtered = filterResponseText(ctx.runResult.responseText); text = isAlert ? `\u26a0\ufe0f **Heartbeat Alert**\n${filtered.substring(0, 500)}` : '\u2705 **Heartbeat OK**'; - if (isAlert && notif.desktop) { + if (isAlert && ctx.config.heartbeat.desktop) { promises.push(sendDesktopNotification('GeminiClaw \u26a0\ufe0f', filtered.substring(0, 300))); } } else { @@ -76,27 +75,23 @@ async function notifyBackgroundJob(ctx: DeliverContext): Promise { : `\u2705 **Cron done: ${jobId}**`; } - if (notif.discord.enabled && notif.discord.channelId) { + // Fall back to home channel when dedicated notifications channel is not configured + const notifTarget = ctx.config.notifications ?? ctx.config.home; + if (notifTarget) { promises.push( postToChannel({ - channelType: 'discord', - channelId: notif.discord.channelId, + channelType: notifTarget.channel, + channelId: notifTarget.channelId, text, config: ctx.config, }).catch((err) => { - log.warn('job notification failed', { channelType: 'discord', error: String(err) }); + log.warn('job notification failed', { + channelType: notifTarget.channel, + error: String(err), + }); }), ); } - if (notif.slack.enabled && notif.slack.channelId) { - promises.push( - postToChannel({ channelType: 'slack', channelId: notif.slack.channelId, text, config: ctx.config }).catch( - (err) => { - log.warn('job notification failed', { channelType: 'slack', error: String(err) }); - }, - ), - ); - } await Promise.allSettled(promises); } diff --git a/src/channels/chat-handlers.ts b/src/channels/chat-handlers.ts index e180cb3..8e912ed 100644 --- a/src/channels/chat-handlers.ts +++ b/src/channels/chat-handlers.ts @@ -79,6 +79,15 @@ function deriveSessionId(thread: Thread, tz?: string): string { return threadTs ? `slack-${channel}-${threadTs}` : `slack-${channel}-${dateSuffix}`; } + if (adapterName === 'telegram') { + // thread.id = "telegram:chatId" or "telegram:chatId:messageThreadId" + const chatId = parts[1] ?? ''; + if (thread.isDM) { + return `telegram-dm-${chatId}-${dateSuffix}`; + } + return `telegram-${chatId}-${dateSuffix}`; + } + // Fallback for unknown adapters return `${adapterName}-${thread.id}`; } @@ -95,6 +104,7 @@ function extractRawChannelId(thread: Thread): string { const parts = thread.id.split(':'); if (thread.adapter.name === 'discord') return parts[2] ?? ''; if (thread.adapter.name === 'slack') return parts[1] ?? ''; + if (thread.adapter.name === 'telegram') return parts[1] ?? ''; return thread.channelId; } @@ -121,6 +131,12 @@ function deriveChannelSessionPrefix(thread: Thread): string { return `slack-${channel}`; } + if (adapterName === 'telegram') { + const chatId = parts[1] ?? ''; + if (thread.isDM) return `telegram-dm-${chatId}`; + return `telegram-${chatId}`; + } + return `${adapterName}-${parts.slice(0, -1).join('-')}`; } @@ -285,14 +301,9 @@ async function buildEventData(thread: Thread, message: Message): Promise { if (!isUserMessage(thread, message)) return; if (!shouldRespondInChannel(thread)) return; @@ -497,29 +514,33 @@ export function registerHandlers(chat: Chat): void { const logText = `**Agent asked:** ${pending.question}\n\n**${userName}** selected: ${answer}`; try { - const adapterName = event.thread.adapter.name; - if (adapterName === 'discord') { - // Discord: clear embeds + components (buttons) via REST API - const rawChannelId = extractDiscordMessageChannelId(event.threadId); - const botToken = (event.thread.adapter as unknown as { botToken: string }).botToken; - await fetch(`https://discord.com/api/v10/channels/${rawChannelId}/messages/${event.messageId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bot ${botToken}`, - }, - body: JSON.stringify({ content: logText, embeds: [], components: [] }), - }); - } else if (adapterName === 'slack') { - // Slack: clear blocks (buttons) via REST API so they can't be re-clicked - await editSlackMessage(event.thread, event.messageId, logText); + if (!event.thread) { + log.warn('event.thread is null, cannot edit card'); } else { - await event.thread.adapter.editMessage(event.threadId, event.messageId, logText); + const adapterName = event.thread.adapter.name; + if (adapterName === 'discord') { + // Discord: clear embeds + components (buttons) via REST API + const rawChannelId = extractDiscordMessageChannelId(event.threadId); + const botToken = (event.thread.adapter as unknown as { botToken: string }).botToken; + await fetch(`https://discord.com/api/v10/channels/${rawChannelId}/messages/${event.messageId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bot ${botToken}`, + }, + body: JSON.stringify({ content: logText, embeds: [], components: [] }), + }); + } else if (adapterName === 'slack') { + // Slack: clear blocks (buttons) via REST API so they can't be re-clicked + await editSlackMessage(event.thread, event.messageId, logText); + } else { + await event.thread.adapter.editMessage(event.threadId, event.messageId, logText); + } } } catch (err) { log.warn('failed to edit card into Q&A log, falling back to post', { error: String(err) }); try { - await event.thread.post(`**${userName}** selected: ${answer}`); + await event.thread?.post(`**${userName}** selected: ${answer}`); } catch (postErr) { log.warn('failed to post action confirmation', { error: String(postErr) }); } diff --git a/src/channels/chat-setup.ts b/src/channels/chat-setup.ts index 4ad6f66..e554b5b 100644 --- a/src/channels/chat-setup.ts +++ b/src/channels/chat-setup.ts @@ -1,7 +1,7 @@ /** * channels/chat-setup.ts — Chat SDK factory and singleton management. * - * Creates a Chat instance with Discord/Slack adapters based on config. + * Creates a Chat instance with Discord/Slack/Telegram adapters based on config. * The instance is registered as a singleton so serialized threads * can be deserialized anywhere (e.g. in Inngest steps). */ @@ -18,7 +18,7 @@ let chatInstance: Chat | undefined; /** * Create and configure the Chat SDK instance. * - * Registers Discord and Slack adapters based on the provided config. + * Registers Discord, Slack, and Telegram adapters based on the provided config. * Calls `registerSingleton()` so `ThreadImpl.fromJSON()` works globally. * * Args: @@ -88,6 +88,16 @@ export async function createChat(config: Config): Promise { log.info('slack adapter registered'); } + // Telegram adapter: bot token + long polling + if (config.channels.telegram.enabled && config.channels.telegram.botToken) { + const { createTelegramAdapter } = await import('@chat-adapter/telegram'); + adapters.telegram = createTelegramAdapter({ + botToken: config.channels.telegram.botToken, + mode: 'polling', + }); + log.info('telegram adapter registered'); + } + const chat = new Chat({ userName: 'geminiclaw', adapters, diff --git a/src/channels/list-channels.ts b/src/channels/list-channels.ts index 2d1c585..38ad034 100644 --- a/src/channels/list-channels.ts +++ b/src/channels/list-channels.ts @@ -58,6 +58,97 @@ export async function fetchDiscordChannels(token: string): Promise { + try { + const res = await fetch(`https://api.telegram.org/bot${botToken}/getUpdates?limit=100`); + if (!res.ok) return []; + const body = (await res.json()) as { ok: boolean; result?: TelegramUpdate[] }; + if (!body.ok || !body.result) return []; + + const seen = new Map(); + for (const update of body.result) { + const chat = update.message?.chat; + if (chat && !seen.has(chat.id)) { + seen.set(chat.id, chat); + } + } + + return [...seen.values()].map((chat) => ({ + id: String(chat.id), + name: chat.title ?? chat.first_name ?? chat.username ?? String(chat.id), + group: chat.type === 'private' ? 'DM' : chat.type, + })); + } catch { + return []; + } +} + +/** + * Poll for a new Telegram message. Waits until a message arrives or timeout. + * Returns the first discovered chat, or null on timeout. + */ +export async function pollForTelegramChat(botToken: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let offset = 0; + + // Get the current offset to skip old messages + try { + const res = await fetch(`https://api.telegram.org/bot${botToken}/getUpdates?limit=1`); + if (res.ok) { + const body = (await res.json()) as { ok: boolean; result?: TelegramUpdate[] }; + if (body.ok && body.result && body.result.length > 0) { + offset = (body.result[body.result.length - 1] as TelegramUpdate).update_id + 1; + } + } + } catch { + /* ignore */ + } + + while (Date.now() < deadline) { + try { + const remaining = Math.min(30, Math.ceil((deadline - Date.now()) / 1000)); + if (remaining <= 0) break; + const res = await fetch( + `https://api.telegram.org/bot${botToken}/getUpdates?offset=${offset}&timeout=${remaining}&limit=1`, + { signal: AbortSignal.timeout((remaining + 5) * 1000) }, + ); + if (!res.ok) break; + const body = (await res.json()) as { ok: boolean; result?: TelegramUpdate[] }; + if (body.ok && body.result && body.result.length > 0) { + const chat = body.result[0]?.message?.chat; + if (chat) { + return { + id: String(chat.id), + name: chat.title ?? chat.first_name ?? chat.username ?? String(chat.id), + group: chat.type === 'private' ? 'DM' : chat.type, + }; + } + } + } catch { + break; + } + } + return null; +} + /** * Fetch public channels from a Slack workspace. */ diff --git a/src/channels/progress/platform-behavior.ts b/src/channels/progress/platform-behavior.ts index 84c793c..2d411d3 100644 --- a/src/channels/progress/platform-behavior.ts +++ b/src/channels/progress/platform-behavior.ts @@ -98,6 +98,7 @@ export function createPlatformBehavior(adapterName: string): PlatformBehavior { switch (adapterName) { case 'discord': case 'slack': + case 'telegram': return new ReactionPlatformBehavior(); default: return new BasePlatformBehavior(); diff --git a/src/channels/reply.ts b/src/channels/reply.ts index d337347..076c3ad 100644 --- a/src/channels/reply.ts +++ b/src/channels/reply.ts @@ -160,7 +160,7 @@ export async function deliverReply(opts: { * Unified replacement for the former sendLegacyReply and sendHeartbeatToChannel. */ export async function postToChannel(opts: { - channelType: 'discord' | 'slack'; + channelType: 'discord' | 'slack' | 'telegram'; channelId: string; threadRef?: string; text: string; @@ -183,6 +183,8 @@ export async function postToChannel(opts: { channel: channelId, threadTs: threadRef ?? '', } as never); + } else if (channelType === 'telegram') { + threadId = adapter.encodeThreadId({ chatId: channelId } as never); } else { threadId = threadRef ? adapter.encodeThreadId({ channelId, guildId: '@me', threadId: threadRef } as never) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index f7aef54..73a85b0 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -10,7 +10,13 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import * as p from '@clack/prompts'; import type { Command } from 'commander'; -import { type ChannelEntry, fetchDiscordChannels, fetchSlackChannels } from '../../channels/list-channels.js'; +import { + type ChannelEntry, + fetchDiscordChannels, + fetchSlackChannels, + fetchTelegramChats, + pollForTelegramChat, +} from '../../channels/list-channels.js'; import { CONFIG_PATH, type Config, getGeminiBin, getWorkspacePath, loadConfig, patchConfigFile } from '../../config.js'; import { vault } from '../../vault/index.js'; import { VAULT_REF_PREFIX } from '../../vault/types.js'; @@ -97,37 +103,154 @@ async function stepDiscord(): Promise { p.log.success('Discord integration enabled.'); } +/** Parse a "platform:channelId" value, splitting only on the first colon. */ +function parseHomeValue(value: string): { channel: string; channelId: string } { + const idx = value.indexOf(':'); + if (idx === -1) throw new Error(`Invalid channel value (expected "platform:id"): ${value}`); + return { channel: value.slice(0, idx), channelId: value.slice(idx + 1) }; +} + /** - * Select a home channel for Discord after secrets are collected. - * The home channel is used for bootstrap, heartbeat, and @mention-free responses. + * Unified home channel selection. The home channel is the agent's primary + * destination for bootstrap greetings, heartbeat results, and cron fallback. + * Home is mandatory — if only one channel is available, it is auto-selected. */ -async function stepDiscordHomeChannel(): Promise { +async function stepHome(): Promise { const config = loadConfig(); - if (!config.channels.discord.enabled || !config.channels.discord.token) return; - const s = p.spinner(); - s.start('Fetching Discord channels...'); - const channels = await fetchDiscordChannels(config.channels.discord.token); - s.stop('Discord channels loaded.'); + interface HomeOption { + value: string; + label: string; + hint?: string; + } + const allOptions: HomeOption[] = []; - const options = toChannelOptions(channels); + // --- Discord --- + if (config.channels.discord.enabled && config.channels.discord.token) { + const s = p.spinner(); + s.start('Fetching Discord channels...'); + const channels = await fetchDiscordChannels(config.channels.discord.token); + s.stop('Discord channels loaded.'); + for (const ch of channels) { + allOptions.push({ + value: `discord:${ch.id}`, + label: `Discord #${ch.name}`, + hint: ch.group, + }); + } + } + + // --- Slack --- + if (config.channels.slack.enabled && config.channels.slack.token) { + const s = p.spinner(); + s.start('Fetching Slack channels...'); + const channels = await fetchSlackChannels(config.channels.slack.token); + s.stop('Slack channels loaded.'); + for (const ch of channels) { + allOptions.push({ + value: `slack:${ch.id}`, + label: `Slack #${ch.name}`, + }); + } + } - if (options.length === 0) { - p.log.warn('No text channels found. Skipping home channel setup.'); + // --- Telegram --- + if (config.channels.telegram.enabled && config.channels.telegram.botToken) { + const s = p.spinner(); + s.start('Fetching Telegram chats...'); + const chats = await fetchTelegramChats(config.channels.telegram.botToken); + + if (chats.length === 0) { + s.stop('No Telegram chats found.'); + + // Polling loop with retry/abort + let discovered = false; + while (!discovered) { + p.note( + 'Send a message to your bot in Telegram, then wait for auto-detection.', + 'Telegram Chat Discovery', + ); + const s2 = p.spinner(); + s2.start('Waiting for Telegram message... (2 min timeout)'); + const chat = await pollForTelegramChat(config.channels.telegram.botToken, 120_000); + if (chat) { + s2.stop('Telegram chat discovered!'); + allOptions.push({ + value: `telegram:${chat.id}`, + label: `Telegram ${chat.name}`, + hint: chat.group, + }); + discovered = true; + } else { + s2.stop('No message received.'); + const action = await p.select({ + message: 'Telegram chat not detected. What would you like to do?', + options: [ + { value: 'retry', label: 'Retry', hint: 'Wait another 2 minutes' }, + { value: 'manual', label: 'Enter chat ID manually' }, + { value: 'skip', label: 'Skip', hint: 'Use another channel as home' }, + ], + }); + exitIfCancelled(action); + + if (action === 'skip') { + discovered = true; + } else if (action === 'manual') { + const manual = await p.text({ + message: 'Telegram chat ID (e.g. -1001234567890)', + placeholder: '-1001234567890', + validate: (v) => { + if (!v) return 'Chat ID is required'; + if (!/^-?\d+$/.test(v)) return 'Chat ID must be a number (e.g. -1001234567890)'; + return undefined; + }, + }); + exitIfCancelled(manual); + allOptions.push({ + value: `telegram:${manual}`, + label: `Telegram ${manual}`, + }); + discovered = true; + } + // action === 'retry' → loop continues + } + } + } else { + s.stop('Telegram chats loaded.'); + for (const ch of chats) { + allOptions.push({ + value: `telegram:${ch.id}`, + label: `Telegram ${ch.name}`, + hint: ch.group, + }); + } + } + } + + // --- Selection --- + if (allOptions.length === 0) { + p.log.warn('No channels available. Enable a channel first, then run: geminiclaw setup --step home'); + return; + } + + if (allOptions.length === 1) { + const only = allOptions[0] as HomeOption; + const { channel, channelId } = parseHomeValue(only.value); + patchConfigFile({ home: { channel, channelId } }); + p.log.success(`Home → ${only.label} (auto-selected)`); return; } const selected = await p.select({ - message: 'Which Discord channel should the agent call home?', - options: [{ value: '__skip__', label: 'Skip', hint: 'No home channel' }, ...options], + message: 'Which channel should the agent call home?', + options: allOptions, }); exitIfCancelled(selected); - if (selected !== '__skip__') { - patchConfigFile({ channels: { discord: { homeChannel: selected } } }); - const label = options.find((o) => o.value === selected); - p.log.success(`Home channel → Discord ${label?.label ?? selected}`); - } + const { channel, channelId } = parseHomeValue(selected as string); + patchConfigFile({ home: { channel, channelId } }); + const label = allOptions.find((o) => o.value === selected); + p.log.success(`Home → ${label?.label ?? selected}`); } /* ------------------------------------------------------------------ */ @@ -162,36 +285,30 @@ async function stepSlack(): Promise { p.log.success('Slack integration enabled.'); } -/** - * Select a home channel for Slack after secrets are collected. - */ -async function stepSlackHomeChannel(): Promise { - const config = loadConfig(); - if (!config.channels.slack.enabled || !config.channels.slack.token) return; - - const s = p.spinner(); - s.start('Fetching Slack channels...'); - const channels = await fetchSlackChannels(config.channels.slack.token); - s.stop('Slack channels loaded.'); - - const options = toChannelOptions(channels); - - if (options.length === 0) { - p.log.warn('No channels found. Skipping home channel setup.'); - return; - } +/* ------------------------------------------------------------------ */ +/* Step: Telegram config */ +/* ------------------------------------------------------------------ */ - const selected = await p.select({ - message: 'Which Slack channel should the agent call home?', - options: [{ value: '__skip__', label: 'Skip', hint: 'No home channel' }, ...options], +async function stepTelegram(): Promise { + const enable = await p.confirm({ + message: 'Enable Telegram integration?', + initialValue: false, }); - exitIfCancelled(selected); + exitIfCancelled(enable); + if (!enable) return; - if (selected !== '__skip__') { - patchConfigFile({ channels: { slack: { homeChannel: selected } } }); - const label = options.find((o) => o.value === selected); - p.log.success(`Home channel → Slack ${label?.label ?? selected}`); - } + p.note( + [ + '1. Open @BotFather on Telegram and send /newbot', + '2. Follow the prompts to create your bot and copy the token', + '3. (Optional) Send /setprivacy → Disable if you want the bot to see group messages', + '4. Add the bot to your chat/group and send it a message', + ].join('\n'), + 'Telegram Bot Setup', + ); + + patchConfigFile({ channels: { telegram: { enabled: true } } }); + p.log.success('Telegram integration enabled.'); } /* ------------------------------------------------------------------ */ @@ -353,6 +470,7 @@ async function collectSecrets(): Promise { const channels = (raw.channels ?? {}) as Record>; const discord = (channels.discord ?? {}) as Record; const slack = (channels.slack ?? {}) as Record; + const telegram = (channels.telegram ?? {}) as Record; // Build the list of secrets to collect based on enabled channels interface SecretEntry { label: string; @@ -372,6 +490,15 @@ async function collectSecrets(): Promise { }); } + if (telegram.enabled === true && !telegram.botToken) { + entries.push({ + label: 'Telegram Bot Token', + hint: '@BotFather → /newbot → Token', + configDotPath: 'channels.telegram.botToken', + vaultKey: 'telegram-bot-token', + }); + } + if (slack.enabled === true) { if (!slack.token) { entries.push({ @@ -468,69 +595,51 @@ async function stepHeartbeatNotifications(): Promise { 'Job Notifications', ); - const raw: Record = existsSync(CONFIG_PATH) - ? (JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) as Record) - : {}; - - if (!raw.heartbeat) raw.heartbeat = {}; - const hb = raw.heartbeat as Record; - if (!hb.notifications) hb.notifications = {}; - const notif = hb.notifications as Record; - - let hasChanges = false; + // Collect all enabled platform channels for notification target selection + const allOptions: { value: string; label: string }[] = []; - // --- Discord --- if (config.channels.discord.enabled && config.channels.discord.token) { const s = p.spinner(); s.start('Fetching Discord channels...'); const channels = await fetchDiscordChannels(config.channels.discord.token); s.stop('Discord channels loaded.'); - - const options = toChannelOptions(channels); - - if (options.length > 0) { - const selected = await p.select({ - message: 'Notification channel for background jobs (heartbeat & cron)?', - options: [{ value: '__skip__', label: 'Skip', hint: 'Do not send to Discord' }, ...options], - }); - exitIfCancelled(selected); - - if (selected !== '__skip__') { - notif.discord = { enabled: true, channelId: selected }; - hasChanges = true; - const label = options.find((o) => o.value === selected); - p.log.success(`Job notifications → Discord ${label?.label ?? selected}`); - } - } else { - p.log.warn('No text channels found in Discord. Skipping.'); + for (const ch of toChannelOptions(channels)) { + allOptions.push({ value: `discord:${ch.value}`, label: `Discord ${ch.label}` }); } } - // --- Slack --- if (config.channels.slack.enabled && config.channels.slack.token) { const s = p.spinner(); s.start('Fetching Slack channels...'); const channels = await fetchSlackChannels(config.channels.slack.token); s.stop('Slack channels loaded.'); + for (const ch of toChannelOptions(channels)) { + allOptions.push({ value: `slack:${ch.value}`, label: `Slack ${ch.label}` }); + } + } - const options = toChannelOptions(channels); + if (config.channels.telegram.enabled && config.channels.telegram.botToken) { + const telegramChats = await fetchTelegramChats(config.channels.telegram.botToken); + for (const ch of telegramChats) { + allOptions.push({ value: `telegram:${ch.id}`, label: `Telegram ${ch.name}` }); + } + } - if (options.length > 0) { - const selected = await p.select({ - message: 'Notification channel for background jobs (heartbeat & cron)?', - options: [{ value: '__skip__', label: 'Skip', hint: 'Do not send to Slack' }, ...options], - }); - exitIfCancelled(selected); + if (allOptions.length > 0) { + const selected = await p.select({ + message: 'Notification channel for background jobs (heartbeat & cron)?', + options: [{ value: '__skip__', label: 'Skip', hint: 'No channel notifications' }, ...allOptions], + }); + exitIfCancelled(selected); - if (selected !== '__skip__') { - notif.slack = { enabled: true, channelId: selected }; - hasChanges = true; - const label = options.find((o) => o.value === selected); - p.log.success(`Job notifications → Slack ${label?.label ?? selected}`); - } - } else { - p.log.warn('No channels found in Slack. Skipping.'); + if (selected !== '__skip__') { + const parsed = parseHomeValue(selected as string); + patchConfigFile({ notifications: parsed }); + const label = allOptions.find((o) => o.value === selected); + p.log.success(`Job notifications → ${label?.label ?? selected}`); } + } else { + p.log.warn('No channels available. Skipping channel notifications.'); } // --- Desktop --- @@ -539,17 +648,7 @@ async function stepHeartbeatNotifications(): Promise { initialValue: true, }); exitIfCancelled(enableDesktop); - notif.desktop = enableDesktop; - hasChanges = true; - - if (hasChanges) { - const dir = CONFIG_PATH.replace(/[/\\][^/\\]+$/, ''); - if (!existsSync(dir)) { - const { mkdirSync } = await import('node:fs'); - mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - writeFileSync(CONFIG_PATH, `${JSON.stringify(raw, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 }); - } + patchConfigFile({ heartbeat: { desktop: enableDesktop } }); if (enableDesktop) { p.log.success('Desktop notifications: enabled'); @@ -575,24 +674,28 @@ function printSummary(config: Config, workspacePath: string): void { const check = (ok: boolean): string => (ok ? '\x1b[32m✔\x1b[0m' : '\x1b[2m—\x1b[0m'); - // Build job notification summary (heartbeat + cron) + // Build job notification summary const notifParts: string[] = []; - if (config.heartbeat.notifications.desktop) notifParts.push('desktop (alerts)'); - if (config.heartbeat.notifications.discord.enabled && config.heartbeat.notifications.discord.channelId) - notifParts.push(`Discord #${config.heartbeat.notifications.discord.channelId}`); - if (config.heartbeat.notifications.slack.enabled && config.heartbeat.notifications.slack.channelId) - notifParts.push(`Slack #${config.heartbeat.notifications.slack.channelId}`); + if (config.heartbeat.desktop) notifParts.push('desktop (alerts)'); + if (config.notifications) { + notifParts.push(`${config.notifications.channel} #${config.notifications.channelId}`); + } const notifSummary = notifParts.length > 0 ? notifParts.join(' + ') : 'none'; + // Home summary + const homeSummary = config.home ? `${config.home.channel} #${config.home.channelId}` : 'not set'; + p.note( [ - `Workspace ${workspacePath}`, - `Language ${config.language}`, - `SOUL.md ${check(soulConfigured)} ${soulConfigured ? 'configured' : 'not set (auto-generated on first conversation)'}`, - `Discord ${check(config.channels.discord.enabled)} ${config.channels.discord.enabled ? 'enabled' : 'disabled'}`, - `Slack ${check(config.channels.slack.enabled)} ${config.channels.slack.enabled ? 'enabled' : 'disabled'}`, - `Google ${check(!!config.gogAccount)} ${config.gogAccount || 'not configured'}`, - `Timezone ${config.timezone || systemTz}`, + `Workspace ${workspacePath}`, + `Language ${config.language}`, + `SOUL.md ${check(soulConfigured)} ${soulConfigured ? 'configured' : 'not set (auto-generated on first conversation)'}`, + `Discord ${check(config.channels.discord.enabled)} ${config.channels.discord.enabled ? 'enabled' : 'disabled'}`, + `Slack ${check(config.channels.slack.enabled)} ${config.channels.slack.enabled ? 'enabled' : 'disabled'}`, + `Telegram ${check(config.channels.telegram.enabled)} ${config.channels.telegram.enabled ? 'enabled' : 'disabled'}`, + `Google ${check(!!config.gogAccount)} ${config.gogAccount || 'not configured'}`, + `Timezone ${config.timezone || systemTz}`, + `Home ${check(!!config.home)} ${homeSummary}`, `Notifications ${check(notifParts.length > 0)} ${notifSummary}`, ].join('\n'), 'Setup Summary', @@ -684,9 +787,12 @@ export async function runSetupWizard(config: Config, workspacePath: string): Pro // Step 3: Discord await stepDiscord(); - // Step 5: Slack + // Step 4: Slack await stepSlack(); + // Step 5: Telegram + await stepTelegram(); + // Step 6: Google Workspace await stepGoogle(); @@ -698,15 +804,9 @@ export async function runSetupWizard(config: Config, workspacePath: string): Pro await collectSecrets(); } - // Step 9: Home channel selection (requires tokens from step 8) - if (process.stdin.isTTY) { - await stepDiscordHomeChannel(); - await stepSlackHomeChannel(); - } - - // Step 10: Job notifications (heartbeat + cron completion notices) + // Step 9: Home channel selection (mandatory, requires tokens from step 8) if (process.stdin.isTTY) { - await stepHeartbeatNotifications(); + await stepHome(); } // Re-initialize to register MCP servers with all collected settings @@ -727,9 +827,9 @@ export async function runSetupWizard(config: Config, workspacePath: string): Pro const STEP_RUNNERS: Record Promise> = { language: stepLanguage, discord: stepDiscord, - 'discord-home': stepDiscordHomeChannel, slack: stepSlack, - 'slack-home': stepSlackHomeChannel, + telegram: stepTelegram, + home: stepHome, gog: stepGoogle, timezone: stepTimezone, secrets: collectSecrets, @@ -737,7 +837,7 @@ const STEP_RUNNERS: Record Promise> = { }; /** Steps that modify MCP server config and require re-initialization afterward. */ -const STEPS_NEEDING_REINIT = new Set(['discord', 'slack', 'gog', 'secrets']); +const STEPS_NEEDING_REINIT = new Set(['discord', 'slack', 'telegram', 'gog', 'secrets']); export function registerSetupCommand(program: Command): void { program @@ -746,7 +846,7 @@ export function registerSetupCommand(program: Command): void { .option('--check', 'Check setup status without launching the wizard') .option( '--step ', - 'Run a single setup step (language, discord, discord-home, slack, slack-home, gog, timezone, secrets, notifications)', + 'Run a single setup step (language, discord, slack, telegram, home, gog, timezone, secrets, notifications)', ) .action(async (options) => { const config = loadConfig(); @@ -764,8 +864,11 @@ export function registerSetupCommand(program: Command): void { discordTokenSet: !!config.channels.discord.token, slackEnabled: config.channels.slack.enabled, slackTokenSet: !!config.channels.slack.token, + telegramEnabled: config.channels.telegram.enabled, + telegramTokenSet: !!config.channels.telegram.botToken, gogAccount: config.gogAccount || null, timezone: config.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, + home: config.home ?? null, }; process.stdout.write(`${JSON.stringify(checks, null, 2)}\n`); return; diff --git a/src/cli/commands/start.ts b/src/cli/commands/start.ts index 0294b52..41eb4f0 100644 --- a/src/cli/commands/start.ts +++ b/src/cli/commands/start.ts @@ -203,6 +203,7 @@ export function registerStartCommand(program: Command): void { const slackConnected = config.channels.slack.enabled && !!config.channels.slack.token && !!config.channels.slack.signingSecret; + const telegramConnected = config.channels.telegram.enabled && !!config.channels.telegram.botToken; // ── Bootstrap greeting — nudge setup in home channel ── await sendBootstrapGreeting(config, chat, { discordConnected, slackConnected }); @@ -232,7 +233,7 @@ export function registerStartCommand(program: Command): void { } // ── All initialization complete — print startup banner ── - printBanner(port, { discordConnected, slackConnected, previewUrl, mcpProbes }); + printBanner(port, { discordConnected, slackConnected, telegramConnected, previewUrl, mcpProbes }); }); } @@ -352,19 +353,19 @@ async function sendBootstrapGreeting( 'Shall we?', ].join('\n'); - if (opts.discordConnected && config.channels.discord.homeChannel && config.channels.discord.token) { + if (!config.home) return; + const { channel, channelId } = config.home; + + if (channel === 'discord' && opts.discordConnected && config.channels.discord.token) { try { - const res = await fetch( - `https://discord.com/api/v10/channels/${config.channels.discord.homeChannel}/messages`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bot ${config.channels.discord.token}`, - }, - body: JSON.stringify({ content: message }), + const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bot ${config.channels.discord.token}`, }, - ); + body: JSON.stringify({ content: message }), + }); if (!res.ok) { const body = await res.text(); process.stderr.write(`[geminiclaw] Bootstrap greeting failed (${res.status}): ${body}\n`); @@ -376,7 +377,7 @@ async function sendBootstrapGreeting( } } - if (opts.slackConnected && config.channels.slack.homeChannel && config.channels.slack.token) { + if (channel === 'slack' && opts.slackConnected && config.channels.slack.token) { try { const res = await fetch('https://slack.com/api/chat.postMessage', { method: 'POST', @@ -384,9 +385,8 @@ async function sendBootstrapGreeting( 'Content-Type': 'application/json', Authorization: `Bearer ${config.channels.slack.token}`, }, - body: JSON.stringify({ channel: config.channels.slack.homeChannel, text: message }), + body: JSON.stringify({ channel: channelId, text: message }), }); - // Slack API returns HTTP 200 even on errors; check body.ok const body = (await res.json()) as { ok: boolean; error?: string }; if (!res.ok || !body.ok) { process.stderr.write( @@ -399,6 +399,24 @@ async function sendBootstrapGreeting( process.stderr.write(`[geminiclaw] Failed to send bootstrap greeting to Slack: ${String(err)}\n`); } } + + if (channel === 'telegram' && config.channels.telegram.botToken) { + try { + const res = await fetch(`https://api.telegram.org/bot${config.channels.telegram.botToken}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chat_id: channelId, text: message }), + }); + if (!res.ok) { + const body = await res.text(); + process.stderr.write(`[geminiclaw] Bootstrap greeting failed (${res.status}): ${body}\n`); + } else { + process.stderr.write('[geminiclaw] Bootstrap greeting sent to Telegram home channel\n'); + } + } catch (err) { + process.stderr.write(`[geminiclaw] Failed to send bootstrap greeting to Telegram: ${String(err)}\n`); + } + } } // ── Startup banner ─────────────────────────────────────────────── @@ -406,6 +424,7 @@ async function sendBootstrapGreeting( interface BannerOptions { discordConnected: boolean; slackConnected: boolean; + telegramConnected: boolean; previewUrl?: string; mcpProbes?: import('../../dashboard/mcp-probe.js').McpServerProbe[]; } @@ -467,7 +486,7 @@ ${b} Server${r} ${c}http://localhost:${port}${r} ${b} Dashboard${r} ${c}http://localhost:${port}/dashboard${r} ${b} Health${r} ${c}http://localhost:${port}/health${r}${opts.previewUrl ? `\n${b} Preview${r} ${c}${opts.previewUrl}${r}` : ''} ${d} ──────────────────────────────────────────────────────────────────────────────${r} - ${dot(opts.discordConnected)} Discord ${dot(opts.slackConnected)} Slack${mcpSection} + ${dot(opts.discordConnected)} Discord ${dot(opts.slackConnected)} Slack ${dot(opts.telegramConnected)} Telegram${mcpSection} ${d} ──────────────────────────────────────────────────────────────────────────────${r} ${y}Ready${r} `); diff --git a/src/config.test.ts b/src/config.test.ts index 04d2821..6864d1c 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -18,9 +18,7 @@ describe('ConfigSchema', () => { expect(config.heartbeatIntervalMin).toBe(30); expect(config.channels.discord.enabled).toBe(false); expect(config.channels.slack.enabled).toBe(false); - expect(config.heartbeat.notifications.desktop).toBe(true); - expect(config.heartbeat.notifications.discord.enabled).toBe(false); - expect(config.heartbeat.notifications.slack.enabled).toBe(false); + expect(config.heartbeat.desktop).toBe(true); }); it('overrides defaults with provided values', () => { @@ -101,13 +99,14 @@ describe('loadConfig / patchConfigFile', () => { it('deep merges nested keys without clobbering siblings', () => { // Set up initial config with discord token patchConfigFile({ channels: { discord: { token: 'my-token', enabled: true } } }, configPath); - // Patch homeChannel — should NOT erase token/enabled - patchConfigFile({ channels: { discord: { homeChannel: '123456' } } }, configPath); + // Patch home — should NOT erase channels.discord.token/enabled + patchConfigFile({ home: { channel: 'discord', channelId: '123456' } }, configPath); const raw = JSON.parse(readFileSync(configPath, 'utf-8')); expect(raw.channels.discord.token).toBe('my-token'); expect(raw.channels.discord.enabled).toBe(true); - expect(raw.channels.discord.homeChannel).toBe('123456'); + expect(raw.home.channel).toBe('discord'); + expect(raw.home.channelId).toBe('123456'); }); it('creates directory when patching', () => { @@ -240,6 +239,105 @@ describe('loadConfig / patchConfigFile', () => { }); }); +describe('legacy config migration', () => { + let tmpDir: string; + let configPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'geminiclaw-test-')); + configPath = join(tmpDir, 'config.json'); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('migrates homeChannel to top-level home and persists to disk', () => { + writeFileSync( + configPath, + JSON.stringify({ + workspace: tmpDir, + channels: { discord: { enabled: true, homeChannel: '111', token: 'tok' } }, + }), + ); + + const config = loadConfig(configPath); + expect(config.home).toEqual({ channel: 'discord', channelId: '111' }); + + // Verify disk was updated + const raw = JSON.parse(readFileSync(configPath, 'utf-8')); + expect(raw.home).toEqual({ channel: 'discord', channelId: '111' }); + expect(raw.channels.discord.homeChannel).toBeUndefined(); + }); + + it('migrates heartbeat.notifications to top-level notifications and persists', () => { + writeFileSync( + configPath, + JSON.stringify({ + workspace: tmpDir, + heartbeat: { + notifications: { + discord: { enabled: true, channelId: '222' }, + slack: { enabled: false, channelId: '333' }, + }, + }, + }), + ); + + const config = loadConfig(configPath); + expect(config.notifications).toEqual({ channel: 'discord', channelId: '222' }); + + const raw = JSON.parse(readFileSync(configPath, 'utf-8')); + expect(raw.notifications).toEqual({ channel: 'discord', channelId: '222' }); + expect(raw.heartbeat.notifications).toBeUndefined(); + }); + + it('strips old notifications format { enabled, method }', () => { + writeFileSync( + configPath, + JSON.stringify({ + workspace: tmpDir, + notifications: { enabled: true, method: 'discord' }, + }), + ); + + const config = loadConfig(configPath); + expect(config.notifications).toBeUndefined(); + + const raw = JSON.parse(readFileSync(configPath, 'utf-8')); + expect(raw.notifications).toBeUndefined(); + }); + + it('does not overwrite existing home when homeChannel exists on another platform', () => { + writeFileSync( + configPath, + JSON.stringify({ + workspace: tmpDir, + home: { channel: 'slack', channelId: '999' }, + channels: { discord: { enabled: true, homeChannel: '111' } }, + }), + ); + + const config = loadConfig(configPath); + expect(config.home).toEqual({ channel: 'slack', channelId: '999' }); + + // homeChannel should still be cleaned up from disk + const raw = JSON.parse(readFileSync(configPath, 'utf-8')); + expect(raw.channels.discord.homeChannel).toBeUndefined(); + }); + + it('does not rewrite file when no migration needed', () => { + const content = JSON.stringify({ workspace: tmpDir, model: 'auto' }); + writeFileSync(configPath, content); + const mtimeBefore = readFileSync(configPath, 'utf-8'); + + loadConfig(configPath); + + const mtimeAfter = readFileSync(configPath, 'utf-8'); + expect(mtimeAfter).toBe(mtimeBefore); + }); +}); + describe('loadConfig with vault resolution (integration)', () => { let tmpDir: string; let configPath: string; diff --git a/src/config/io.ts b/src/config/io.ts index 9906be0..f0431ab 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -23,6 +23,11 @@ import { type Config, ConfigSchema, WORKSPACE_CONFIG_FILENAME, WorkspaceConfigSc * SLACK_BOT_TOKEN → channels.slack.token * SLACK_SIGNING_SECRET → channels.slack.signingSecret * + * Legacy migration (pre-Zod parse): + * - Old `notifications: { enabled, method }` → stripped (new format: `{ channel, channelId }`) + * - Old `channels.*.homeChannel` → `home: { channel, channelId }` + * - Old `heartbeat.notifications.*.{ enabled, channelId }` → `notifications: { channel, channelId }` + * * Vault usage: call `await vault.init()` before `loadConfig()` to enable resolution. * If vault is not initialized, $vault: references are left as-is (undefined after parse). */ @@ -36,6 +41,23 @@ export function loadConfig(configPath: string = CONFIG_PATH): Config { } } + // Migrate legacy config formats before Zod parse. + // If any migration fires, the updated JSON is written back to disk + // so old keys are cleaned up permanently. + if (typeof json === 'object' && json !== null) { + const obj = json as Record; + if (migrateLegacyConfig(obj)) { + try { + writeFileSync(configPath, `${JSON.stringify(obj, null, 2)}\n`, { + encoding: 'utf-8', + mode: 0o600, + }); + } catch (err) { + process.stderr.write(`[geminiclaw] warning: failed to write migrated config: ${String(err)}\n`); + } + } + } + // Track which keys the user explicitly set in their config file. // Workspace overrides should not clobber user-explicit values. const userExplicitKeys = new Set(typeof json === 'object' && json !== null ? Object.keys(json) : []); @@ -64,6 +86,9 @@ export function loadConfig(configPath: string = CONFIG_PATH): Config { if (ws.slack?.respondInChannels !== undefined) { config.channels.slack.respondInChannels = ws.slack.respondInChannels; } + if (ws.telegram?.respondInChannels !== undefined) { + config.channels.telegram.respondInChannels = ws.telegram.respondInChannels; + } } catch (err) { process.stderr.write( `[geminiclaw] warning: failed to parse workspace config ${wsConfigPath}: ${String(err)}\n`, @@ -71,23 +96,19 @@ export function loadConfig(configPath: string = CONFIG_PATH): Config { } } - // Merge homeChannel into respondInChannels (deduplicated) - if (config.channels.discord.homeChannel) { - const home = config.channels.discord.homeChannel; - if (!config.channels.discord.respondInChannels.includes(home)) { - config.channels.discord.respondInChannels = [home, ...config.channels.discord.respondInChannels]; - } - } - if (config.channels.slack.homeChannel) { - const home = config.channels.slack.homeChannel; - if (!config.channels.slack.respondInChannels.includes(home)) { - config.channels.slack.respondInChannels = [home, ...config.channels.slack.respondInChannels]; + // Merge home channel into its platform's respondInChannels (deduplicated) + if (config.home) { + const { channel, channelId } = config.home; + const platformConfig = config.channels[channel]; + if (platformConfig && !platformConfig.respondInChannels.includes(channelId)) { + platformConfig.respondInChannels = [channelId, ...platformConfig.respondInChannels]; } } // Resolve $vault: references — requires vault.init() to have been called first. // If the vault is not initialized, resolveSync() is a no-op and returns the raw value. config.channels.discord.token = vault.resolveSync(config.channels.discord.token); + config.channels.telegram.botToken = vault.resolveSync(config.channels.telegram.botToken); config.channels.slack.token = vault.resolveSync(config.channels.slack.token); config.channels.slack.signingSecret = vault.resolveSync(config.channels.slack.signingSecret); @@ -126,6 +147,80 @@ export function loadConfig(configPath: string = CONFIG_PATH): Config { return config; } +/** + * Migrate legacy config formats in-place. + * + * Returns true if any migration was applied (caller should persist to disk). + * + * Migrations: + * 1. Old `notifications: { enabled, method }` → deleted (new: `{ channel, channelId }`) + * 2. `channels.*.homeChannel` → `home: { channel, channelId }`, old key deleted + * 3. `heartbeat.notifications.*.{ enabled, channelId }` → `notifications`, old key deleted + */ +function migrateLegacyConfig(obj: Record): boolean { + let migrated = false; + + // 1. Strip legacy top-level `notifications` (old format: { enabled, method }) + const notif = obj.notifications; + if (notif && typeof notif === 'object' && notif !== null && !('channel' in notif)) { + delete obj.notifications; + migrated = true; + } + + // 2. Migrate legacy per-platform homeChannel → top-level `home`. + // Prefer enabled platforms; fall back to disabled ones to avoid data loss. + if (!obj.home) { + const channels = obj.channels as Record> | undefined; + if (channels) { + let fallback: { channel: string; channelId: string } | undefined; + for (const platform of ['discord', 'slack', 'telegram'] as const) { + const ch = channels[platform]; + if (typeof ch?.homeChannel === 'string' && ch.homeChannel) { + if (ch.enabled) { + obj.home = { channel: platform, channelId: ch.homeChannel }; + break; + } + fallback ??= { channel: platform, channelId: ch.homeChannel }; + } + } + if (!obj.home && fallback) { + obj.home = fallback; + } + if (obj.home) migrated = true; + } + } + + // Clean up homeChannel keys from all platforms regardless of which was picked + const channels = obj.channels as Record> | undefined; + if (channels) { + for (const platform of ['discord', 'slack', 'telegram']) { + if (channels[platform] && 'homeChannel' in channels[platform]) { + delete channels[platform].homeChannel; + migrated = true; + } + } + } + + // 3. Migrate legacy heartbeat.notifications → top-level `notifications` + const hb = obj.heartbeat as Record | undefined; + const hbNotif = hb?.notifications as Record> | undefined; + if (hbNotif) { + if (!obj.notifications) { + for (const platform of ['discord', 'slack', 'telegram'] as const) { + const ch = hbNotif[platform]; + if (ch?.enabled && typeof ch.channelId === 'string' && ch.channelId) { + obj.notifications = { channel: platform, channelId: ch.channelId }; + break; + } + } + } + delete hb?.notifications; + migrated = true; + } + + return migrated; +} + /** * Recursively merge source into target, preserving existing nested keys. */ diff --git a/src/config/schema.ts b/src/config/schema.ts index 8ed85ce..f65556a 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -71,6 +71,20 @@ export const ConfigSchema = z.object({ * Set to 0 to disable session resumption entirely. */ sessionIdleMinutes: z.number().min(0).default(60), + /** Primary channel for the agent. Bootstrap greetings, heartbeat and cron results are sent here. */ + home: z + .object({ + channel: z.enum(['discord', 'slack', 'telegram']), + channelId: z.string(), + }) + .optional(), + /** Channel for background job notifications (heartbeat alerts, cron completion). */ + notifications: z + .object({ + channel: z.enum(['discord', 'slack', 'telegram']), + channelId: z.string(), + }) + .optional(), /** Heartbeat-specific settings. */ heartbeat: z .object({ @@ -81,31 +95,8 @@ export const ConfigSchema = z.object({ * Falls back to the global `model` setting when omitted. */ model: z.string().default('flash'), - /** - * Heartbeat notification destinations. - * Channel notifications receive every run result (execution logs). - * Desktop notifications only fire on alerts (response is NOT HEARTBEAT_OK). - */ - notifications: z - .object({ - /** macOS/Linux desktop notification. Enabled by default so alerts are never silent. */ - desktop: z.boolean().default(true), - /** Post heartbeat alerts to a Discord channel. */ - discord: z - .object({ - enabled: z.boolean().default(false), - channelId: z.string().optional(), - }) - .default({}), - /** Post heartbeat alerts to a Slack channel. */ - slack: z - .object({ - enabled: z.boolean().default(false), - channelId: z.string().optional(), - }) - .default({}), - }) - .default({}), + /** macOS/Linux desktop notification for heartbeat alerts. Enabled by default so alerts are never silent. */ + desktop: z.boolean().default(true), }) .default({}), channels: z @@ -115,13 +106,21 @@ export const ConfigSchema = z.object({ token: z.string().optional(), enabled: z.boolean().default(false), /** - * Primary channel for the agent. Bootstrap greetings and heartbeat - * notifications are sent here. Automatically included in respondInChannels. + * Channel IDs where the bot responds to all messages without requiring @mention. + * home channel is automatically included at runtime — no need to list it here. + * Can be updated by the agent via {workspace}/config.json. */ - homeChannel: z.string().optional(), + respondInChannels: z.array(z.string()).default([]), + }) + .default({}), + telegram: z + .object({ + botToken: z.string().optional(), + enabled: z.boolean().default(false), + mode: z.enum(['auto', 'webhook', 'polling']).default('auto'), /** - * Channel IDs where the bot responds to all messages without requiring @mention. - * homeChannel is automatically included at runtime — no need to list it here. + * Chat IDs where the bot responds to all messages without requiring @mention. + * home.channelId is automatically included at runtime when home.channel is 'telegram'. * Can be updated by the agent via {workspace}/config.json. */ respondInChannels: z.array(z.string()).default([]), @@ -132,14 +131,9 @@ export const ConfigSchema = z.object({ token: z.string().optional(), signingSecret: z.string().optional(), enabled: z.boolean().default(false), - /** - * Primary channel for the agent. Bootstrap greetings and heartbeat - * notifications are sent here. Automatically included in respondInChannels. - */ - homeChannel: z.string().optional(), /** * Channel IDs where the bot responds to all messages without requiring @mention. - * homeChannel is automatically included at runtime — no need to list it here. + * home channel is automatically included at runtime — no need to list it here. * Can be updated by the agent via {workspace}/config.json. */ respondInChannels: z.array(z.string()).default([]), @@ -259,6 +253,12 @@ export const WorkspaceConfigSchema = z.object({ respondInChannels: z.array(z.string()).optional(), }) .optional(), + telegram: z + .object({ + /** Chat IDs where the bot responds without @mention. */ + respondInChannels: z.array(z.string()).optional(), + }) + .optional(), }); export type WorkspaceConfig = z.infer; diff --git a/src/cron/cli.ts b/src/cron/cli.ts index dffb710..f275c46 100644 --- a/src/cron/cli.ts +++ b/src/cron/cli.ts @@ -157,7 +157,7 @@ export function buildCronCommand(): Command { if (options.replyChannel && options.replyChannelId) { job.reply = { - channel: options.replyChannel as 'discord' | 'slack', + channel: options.replyChannel as 'discord' | 'slack' | 'telegram', channelId: options.replyChannelId, }; } diff --git a/src/cron/types.ts b/src/cron/types.ts index 0368664..23e9a16 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -17,9 +17,9 @@ export interface CronJob { model?: string; /** Auto-delete job from jobs.json after successful run. Default: true for `at`, false otherwise. */ deleteAfterRun?: boolean; - /** Delivery target for geminiclaw_post_message. Falls back to homeChannel if omitted. */ + /** Delivery target for geminiclaw_post_message. Falls back to config.home if omitted. */ reply?: { - channel: 'discord' | 'slack'; + channel: 'discord' | 'slack' | 'telegram'; channelId: string; }; createdAt: string; diff --git a/src/inngest/cron-scheduler.ts b/src/inngest/cron-scheduler.ts index afd102b..780d92c 100644 --- a/src/inngest/cron-scheduler.ts +++ b/src/inngest/cron-scheduler.ts @@ -33,14 +33,11 @@ const log = createLogger('cron-scheduler'); /** * Build a `platform:channelId` delivery target string for context injection. * The agent uses this to know where to post results via `geminiclaw_post_message`. - * Falls back to homeChannel when the job has no explicit reply. + * Falls back to config.home when the job has no explicit reply. */ function buildDeliveryTarget(job: CronJob, config: ReturnType): string | undefined { if (job.reply) return `${job.reply.channel}:${job.reply.channelId}`; - const dc = config.channels.discord; - if (dc.enabled && dc.homeChannel) return `discord:${dc.homeChannel}`; - const sc = config.channels.slack; - if (sc.enabled && sc.homeChannel) return `slack:${sc.homeChannel}`; + if (config.home) return `${config.home.channel}:${config.home.channelId}`; return undefined; } diff --git a/src/mcp/admin-server.ts b/src/mcp/admin-server.ts index 45ac2f8..1023de8 100644 --- a/src/mcp/admin-server.ts +++ b/src/mcp/admin-server.ts @@ -20,7 +20,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { SessionStore } from '../agent/session/store.js'; import type { MediaItem } from '../channels/channel.js'; -import { fetchDiscordChannels, fetchSlackChannels } from '../channels/list-channels.js'; +import { fetchDiscordChannels, fetchSlackChannels, fetchTelegramChats } from '../channels/list-channels.js'; import { postToChannel } from '../channels/reply.js'; import { getWorkspacePath } from '../config/paths.js'; import { loadConfig } from '../config.js'; @@ -85,7 +85,7 @@ const TOOLS = [ { name: 'geminiclaw_list_channels', description: - 'List available channels from configured Discord and Slack integrations. ' + + 'List available channels from configured Discord, Slack, and Telegram integrations. ' + 'Returns channel names and IDs. Use this to resolve channel names to IDs ' + 'when the user references a channel by name (e.g. "#general").', inputSchema: { @@ -93,7 +93,7 @@ const TOOLS = [ properties: { platform: { type: 'string' as const, - enum: ['discord', 'slack', 'all'], + enum: ['discord', 'slack', 'telegram', 'all'], description: 'Which platform to list channels from. Defaults to "all".', }, }, @@ -104,7 +104,7 @@ const TOOLS = [ { name: 'geminiclaw_post_message', description: - 'Post a message to a Discord or Slack channel. ' + + 'Post a message to a Discord, Slack, or Telegram channel. ' + 'Use geminiclaw_list_channels first to resolve channel names to IDs. ' + 'Optionally specify threadRef to reply in a thread. ' + 'Supports file attachments via workspace-relative paths.', @@ -113,7 +113,7 @@ const TOOLS = [ properties: { platform: { type: 'string' as const, - enum: ['discord', 'slack'], + enum: ['discord', 'slack', 'telegram'], description: 'Target platform', }, channelId: { type: 'string' as const, description: 'Channel ID' }, @@ -266,7 +266,7 @@ async function handlePostMessage( workspace: string, args: Record, ): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> { - const platform = args.platform as 'discord' | 'slack'; + const platform = args.platform as 'discord' | 'slack' | 'telegram'; const channelId = args.channelId as string; const threadRef = args.threadRef as string | undefined; const message = args.message as string; @@ -375,8 +375,24 @@ async function handleListChannels( } } + if ( + (platform === 'all' || platform === 'telegram') && + config.channels.telegram.enabled && + config.channels.telegram.botToken + ) { + const chats = await fetchTelegramChats(config.channels.telegram.botToken); + if (chats.length > 0) { + const lines = chats.map((ch) => ` ${ch.name} → ${ch.id}${ch.group ? ` (${ch.group})` : ''}`); + sections.push(`Telegram:\n${lines.join('\n')}`); + } else { + sections.push('Telegram: no chats found (send a message to the bot first)'); + } + } + if (sections.length === 0) { - return { content: [{ type: 'text', text: 'No enabled channels found. Check Discord/Slack configuration.' }] }; + return { + content: [{ type: 'text', text: 'No enabled channels found. Check Discord/Slack/Telegram configuration.' }], + }; } return { content: [{ type: 'text', text: sections.join('\n\n') }] }; diff --git a/src/mcp/cron-server.ts b/src/mcp/cron-server.ts index 7d7263e..3541c77 100644 --- a/src/mcp/cron-server.ts +++ b/src/mcp/cron-server.ts @@ -52,8 +52,8 @@ const TOOLS = [ reply: { type: 'object', description: - 'Delivery target for geminiclaw_post_message: { channel: "discord"|"slack", channelId: "..." }. ' + - 'Falls back to homeChannel if omitted.', + 'Delivery target for geminiclaw_post_message: { channel: "discord"|"slack"|"telegram", channelId: "..." }. ' + + 'Falls back to config.home if omitted.', }, model: { type: 'string', @@ -153,7 +153,7 @@ function formatJobSummary(job: CronJob): string { `タイムゾーン: ${job.timezone ?? '(デフォルト)'}`, `モデル: ${job.model ?? '(デフォルト)'}`, `次回実行: ${job.nextRunAt ?? '(未設定)'}`, - `配信先: ${job.reply ? `${job.reply.channel}:${job.reply.channelId}` : '(homeChannel)'}`, + `配信先: ${job.reply ? `${job.reply.channel}:${job.reply.channelId}` : '(home)'}`, `自動削除: ${job.deleteAfterRun != null ? (job.deleteAfterRun ? 'はい' : 'いいえ') : '(デフォルト)'}`, `有効: ${job.enabled ? 'はい' : 'いいえ'}`, ]; diff --git a/src/serve.ts b/src/serve.ts index 766b845..3462615 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -370,7 +370,7 @@ export function createPreviewServer( previewApp.use((_req, res, next) => { res.setHeader( 'Content-Security-Policy', - "default-src 'none'; script-src 'self' 'unsafe-inline' http: https:; img-src * data: blob:; style-src 'self' 'unsafe-inline' http: https:; font-src * data:; frame-src http: https:; media-src * data: blob:", + "default-src 'none'; script-src 'unsafe-inline'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' https:; frame-src https:", ); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); diff --git a/src/skills/inject-translations.test.ts b/src/skills/inject-translations.test.ts index c5b59e8..29d7088 100644 --- a/src/skills/inject-translations.test.ts +++ b/src/skills/inject-translations.test.ts @@ -244,10 +244,15 @@ describe('basic translation injection', () => { } }); - it('does not create any tp-block wrapper divs', () => { + it('wraps block pairs in .tp-block containers with data-state', () => { const dom = setup(); const blocks = dom.window.document.querySelectorAll('.tp-block'); - expect(blocks.length).toBe(0); + expect(blocks.length).toBe(2); // h1 + p (code is skipped) + blocks.forEach((block) => { + expect(block.getAttribute('data-state')).toBe('translated'); + expect(block.querySelector('.tp-translated')).toBeTruthy(); + expect(block.querySelector('.tp-original')).toBeTruthy(); + }); }); it('sets translated text content', () => { @@ -264,13 +269,13 @@ describe('basic translation injection', () => { expect(originals[1].textContent).toBe('This is a test paragraph.'); }); - it('pairs translated and original via data-tp-pair / data-tp-id', () => { + it('pairs translated and original inside same .tp-block wrapper', () => { const dom = setup(); - const translated = dom.window.document.querySelectorAll('.tp-translated'); - translated.forEach((el) => { - const pairId = el.getAttribute('data-tp-pair'); - expect(pairId).toBeTruthy(); - const original = dom.window.document.querySelector(`[data-tp-id="${pairId}"]`); + const blocks = dom.window.document.querySelectorAll('.tp-block'); + blocks.forEach((block) => { + const translated = block.querySelector('.tp-translated'); + const original = block.querySelector('.tp-original'); + expect(translated).toBeTruthy(); expect(original).toBeTruthy(); expect(original?.classList.contains('tp-original')).toBe(true); }); @@ -347,16 +352,15 @@ describe('injected styles and scripts', () => { return dom; } - it('injects style tag with body-class-based visibility rules', () => { + it('injects style tag with data-state-based visibility rules', () => { const dom = setup(); const styles = dom.window.document.querySelectorAll('style[data-tp]'); expect(styles.length).toBe(1); const css = styles[0].textContent || ''; - // Should use body class approach, not .tp-block wrapper - expect(css).toContain('.tp-original'); - expect(css).toContain('body.tp-mode-original'); - expect(css).toContain('body.tp-mode-both'); - expect(css).not.toContain('.tp-block'); + expect(css).toContain('.tp-block[data-state="translated"]'); + expect(css).toContain('.tp-block[data-state="original"]'); + expect(css).toContain('.tp-block[data-state="both"]'); + expect(css).toContain('display: contents'); }); it('injects script tag with data-tp attribute', () => { @@ -364,7 +368,7 @@ describe('injected styles and scripts', () => { const scripts = dom.window.document.querySelectorAll('script[data-tp]'); expect(scripts.length).toBe(1); expect(scripts[0].textContent).toContain('data-tp-mode'); - expect(scripts[0].textContent).toContain('tpSetMode'); + expect(scripts[0].textContent).toContain('tpToggle'); }); }); @@ -372,7 +376,7 @@ describe('injected styles and scripts', () => { // Nested structures // ============================================================ describe('nested structures', () => { - it('li elements remain direct children of ul (valid HTML)', () => { + it('li elements are wrapped in .tp-block inside ul', () => { const dom = createMarkedDom(`
  • Item one
  • @@ -387,20 +391,18 @@ describe('nested structures', () => { }; injectTranslations(dom.window.document as unknown as Document, data); - // No wrapper divs + // .tp-block wrappers with display:contents preserve list layout const wrappers = dom.window.document.querySelectorAll('.tp-block'); - expect(wrappers.length).toBe(0); + expect(wrappers.length).toBe(2); - // All li elements (translated + original) are direct children of ul - const ul = dom.window.document.querySelector('ul'); - expect(ul).toBeTruthy(); - const children = Array.from(ul?.children); - expect(children.every((c) => c.tagName === 'LI')).toBe(true); - // 2 translated + 2 original = 4 li elements - expect(children.length).toBe(4); + // Each wrapper contains translated + original li + wrappers.forEach((w) => { + expect(w.querySelector('.tp-translated')?.tagName).toBe('LI'); + expect(w.querySelector('.tp-original')?.tagName).toBe('LI'); + }); }); - it('flex parent children are not disrupted', () => { + it('flex parent children wrapped in .tp-block with display:contents', () => { const dom = createMarkedDom(`

    Flex child 1

    @@ -415,17 +417,15 @@ describe('nested structures', () => { }; injectTranslations(dom.window.document as unknown as Document, data); - // No wrapper divs inserted + // .tp-block wrappers inserted (display:contents avoids layout disruption) const wrappers = dom.window.document.querySelectorAll('.tp-block'); - expect(wrappers.length).toBe(0); + expect(wrappers.length).toBe(2); - // All children of flex container are

    tags (same tag type preserved) - const flex = dom.window.document.querySelector('div[style]'); - expect(flex).toBeTruthy(); - const children = Array.from(flex?.children); - expect(children.every((c) => c.tagName === 'P')).toBe(true); - // 2 translated + 2 original = 4 - expect(children.length).toBe(4); + // Each wrapper contains translated + original

    tags + wrappers.forEach((w) => { + expect(w.querySelector('.tp-translated')?.tagName).toBe('P'); + expect(w.querySelector('.tp-original')?.tagName).toBe('P'); + }); }); it('inline spans: no wrapper, text swap in place', () => { diff --git a/templates/.gemini/skills/cron/SKILL.md b/templates/.gemini/skills/cron/SKILL.md index e826d13..bfbb734 100644 --- a/templates/.gemini/skills/cron/SKILL.md +++ b/templates/.gemini/skills/cron/SKILL.md @@ -26,7 +26,7 @@ Parameters: - `schedule` (required): Schedule object (see below) - `prompt` (required): Self-contained prompt (see below) - `timezone`: IANA timezone (e.g., `Asia/Tokyo`) -- `reply`: Delivery target for `geminiclaw_post_message` `{ channel: "discord"|"slack", channelId: "..." }`. Falls back to homeChannel if omitted. +- `reply`: Delivery target for `geminiclaw_post_message` `{ channel: "discord"|"slack"|"telegram", channelId: "..." }`. Falls back to home channel if omitted. `nextRunAt` and `createdAt` are set automatically by the tool. @@ -76,7 +76,7 @@ Standard 5-field cron expression. Use in combination with `timezone`. - Schedule: {human-readable description of schedule} - Prompt: {beginning or full text of prompt} - Timezone: {timezone} - - Delivery target: {description of reply or "homeChannel"} + - Delivery target: {description of reply or "home channel"} Reply "OK" to proceed, or let me know if anything needs to be changed. ``` @@ -125,7 +125,7 @@ Registered jobs: cloudflare-check-open / cloudflare-analysis / cloudflare-final- Specifies where the cron job posts results via `geminiclaw_post_message`. **Set this to the channel ID where the user wants to receive results.** -If not set, falls back to homeChannel. +If not set, falls back to home channel. ## Notes diff --git a/templates/.gemini/skills/daily-briefing/SKILL.md b/templates/.gemini/skills/daily-briefing/SKILL.md index 3ea9875..19546a1 100644 --- a/templates/.gemini/skills/daily-briefing/SKILL.md +++ b/templates/.gemini/skills/daily-briefing/SKILL.md @@ -14,7 +14,7 @@ Generate a concise, prioritized daily briefing. Works standalone with core Gemin 1. Gather → Pull data from available sources (parallel) 2. Analyze → Prioritize and detect conflicts/urgencies 3. Format → Produce scannable briefing -4. Deliver → Post to homeChannel via geminiclaw_post_message +4. Deliver → Post to home channel via geminiclaw_post_message ``` ## Phase 1: Gather Data @@ -128,9 +128,9 @@ Produce a concise briefing. Target: **under 2 minutes to read**. ## Phase 4: Deliver -Post the briefing to homeChannel via `geminiclaw_post_message`. +Post the briefing to home channel via `geminiclaw_post_message`. -When triggered manually, reply directly in the conversation instead of posting to homeChannel. +When triggered manually, reply directly in the conversation instead of posting to home channel. ## Cron Setup diff --git a/templates/.gemini/skills/self-manage/SKILL.md b/templates/.gemini/skills/self-manage/SKILL.md index b4471ba..b2ff76b 100644 --- a/templates/.gemini/skills/self-manage/SKILL.md +++ b/templates/.gemini/skills/self-manage/SKILL.md @@ -28,7 +28,7 @@ geminiclaw_admin({ args: ["config", "get", "heartbeat.model"] }) ``` geminiclaw_admin({ args: ["config", "set", "model", "pro"] }) -geminiclaw_admin({ args: ["config", "set", "heartbeat.notifications", "desktop"] }) +geminiclaw_admin({ args: ["config", "set", "heartbeat.desktop", "true"] }) ``` Model values: `auto`, `pro`, `flash`, `flash-lite`, `auto-gemini-2.5`, `gemini-2.5-pro`, `gemini-2.5-flash` diff --git a/templates/.gemini/skills/topic-patrol/SKILL.md b/templates/.gemini/skills/topic-patrol/SKILL.md index 1231874..af496dc 100644 --- a/templates/.gemini/skills/topic-patrol/SKILL.md +++ b/templates/.gemini/skills/topic-patrol/SKILL.md @@ -12,7 +12,7 @@ Explore the web based on the user's interests and share discoveries like a curio 1. Read → USER.md (Interests + Work Context + sources) + prior state 2. Explore → Route to the right source tool per topic 3. Filter → "Would I tell a friend about this?" -4. Share → Conversational message to homeChannel (or stay silent) +4. Share → Conversational message to home channel (or stay silent) 5. Record → Update state + findings log ``` @@ -21,7 +21,7 @@ Explore the web based on the user's interests and share discoveries like a curio - **Cron job** — scheduled every 4-8 hours with a lightweight model (e.g. `gemini-2.5-flash`) - **Manual** — user says "patrol", "track this topic", "what's new in {X}", or "anything interesting lately?" -When triggered manually, reply directly in the conversation instead of posting to homeChannel. +When triggered manually, reply directly in the conversation instead of posting to home channel. ## Execution Flow @@ -82,7 +82,7 @@ Use your judgment. If nothing clears the bar, share nothing — silence is bette Check the current time via `geminiclaw_status`. Respect **quiet hours (23:00–08:00)** — defer delivery until morning. -Post to homeChannel via `geminiclaw_post_message`. Limit to **1-2 topics per message**. +Post to home channel via `geminiclaw_post_message`. Limit to **1-2 topics per message**. **Tone**: natural, conversational. Explain WHY it's interesting, not just WHAT it is. Connect it to the user's current work. End with a question or action prompt. diff --git a/templates/.gemini/skills/translate-preview/references/inject-translations.js b/templates/.gemini/skills/translate-preview/references/inject-translations.js index ea78264..20979bb 100644 --- a/templates/.gemini/skills/translate-preview/references/inject-translations.js +++ b/templates/.gemini/skills/translate-preview/references/inject-translations.js @@ -41,19 +41,21 @@ function injectTranslations(doc, data) { node.className = (node.className ? node.className + ' ' : '') + 'tp-inline'; node.setAttribute('data-tp-state', 'translated'); } else { - // Block nodes: adjacent sibling approach (no wrapper div) - // Clone original tag for translated text — preserves CSS inheritance + // Block nodes: wrap in .tp-block container with data-state toggle + // (same structure as twitter-render.js for consistent interaction) var translated = node.cloneNode(false); translated.textContent = cleanText; translated.className = (translated.className ? translated.className + ' ' : '') + 'tp-translated'; translated.removeAttribute('data-tp-id'); - translated.setAttribute('data-tp-pair', String(block.id)); - // Mark original: hide by default, keep full DOM structure node.className = (node.className ? node.className + ' ' : '') + 'tp-original'; - // Insert translated clone immediately before the original - node.parentNode.insertBefore(translated, node); + var wrapper = doc.createElement('div'); + wrapper.className = 'tp-block'; + wrapper.setAttribute('data-state', 'translated'); + node.parentNode.insertBefore(wrapper, node); + wrapper.appendChild(translated); + wrapper.appendChild(node); } } @@ -115,14 +117,11 @@ function _tp_injectStyles(doc) { '.tp-btn-active { background: #3b82f6; color: #fff; border-color: #3b82f6; }' + '.tp-btn-active:hover { background: #2563eb; }' + - /* Bilingual blocks — default: translated mode (original hidden) */ - '.tp-original { display: none !important; }' + - /* Original mode: hide translated, show original */ - 'body.tp-mode-original .tp-translated { display: none !important; }' + - 'body.tp-mode-original .tp-original { display: initial !important; }' + - /* Both mode: show both, muted original */ - 'body.tp-mode-both .tp-original { ' + - 'display: initial !important; ' + + /* Bilingual blocks — data-state driven (matches twitter-render.js) */ + '.tp-block { display: contents; }' + + '.tp-block[data-state="translated"] .tp-original { display: none; }' + + '.tp-block[data-state="original"] .tp-translated { display: none; }' + + '.tp-block[data-state="both"] .tp-original { ' + 'color: #6b7280; font-size: 0.85em; ' + 'border-left: 3px solid rgba(59,130,246,0.3); ' + 'padding-left: 8px; margin-top: 4px; }' + @@ -136,7 +135,7 @@ function _tp_injectStyles(doc) { '.tp-btn { border-color: #4b5563; color: #d1d5db; }' + '.tp-btn:hover { background: #374151; }' + '.tp-btn-active { background: #3b82f6; color: #fff; border-color: #3b82f6; }' + - 'body.tp-mode-both .tp-original { color: #9ca3af; }' + + '.tp-block[data-state="both"] .tp-original { color: #9ca3af; }' + '}'; (doc.head || doc.documentElement).appendChild(style); @@ -147,10 +146,19 @@ function _tp_injectInteractionScript(doc) { script.setAttribute('data-tp', 'true'); script.textContent = '(function(){' + - // Global mode switch via header buttons (body class approach) - 'function tpSetMode(mode){' + - 'document.body.classList.remove("tp-mode-translated","tp-mode-original","tp-mode-both");' + - 'if(mode!=="translated")document.body.classList.add("tp-mode-"+mode);' + + // Toggle a single .tp-block through translated→both→original→translated + 'function tpToggle(block){' + + 'var s=block.getAttribute("data-state");' + + 'block.setAttribute("data-state",s==="translated"?"both":s==="both"?"original":"translated");' + + '}' + + + // Global mode switch via header buttons + 'document.addEventListener("click",function(e){' + + 'var btn=e.target.closest("[data-tp-mode]");' + + 'if(!btn)return;' + + 'var mode=btn.getAttribute("data-tp-mode");' + + // Set all blocks + 'document.querySelectorAll(".tp-block").forEach(function(b){b.setAttribute("data-state",mode);});' + // Inline elements: swap text content 'document.querySelectorAll(".tp-inline").forEach(function(el){' + 'var orig=el.getAttribute("data-tp-original")||"";' + @@ -160,48 +168,26 @@ function _tp_injectInteractionScript(doc) { 'else el.textContent=trans;' + 'el.setAttribute("data-tp-state",mode);' + '});' + - '}' + - 'document.addEventListener("click",function(e){' + - 'var btn=e.target.closest("[data-tp-mode]");' + - 'if(!btn)return;' + - 'var mode=btn.getAttribute("data-tp-mode");' + - 'tpSetMode(mode);' + 'document.querySelectorAll(".tp-btn").forEach(function(b){b.classList.remove("tp-btn-active");});' + 'btn.classList.add("tp-btn-active");' + '});' + - // Long-press toggle on individual pair (500ms) - 'var _tpTimer=null,_tpMoved=false;' + - 'function tpTogglePair(el){' + - 'var pair=el.closest("[data-tp-pair]");' + - 'if(!pair)pair=el.closest("[data-tp-id]");' + - 'if(!pair)return;' + - 'var id=pair.getAttribute("data-tp-pair")||pair.getAttribute("data-tp-id");' + - 'var t=document.querySelector("[data-tp-pair=\\""+id+"\\"]");' + - 'var o=document.querySelector("[data-tp-id=\\""+id+"\\"]");' + - 'if(!t||!o)return;' + - // Cycle: translated→both→original→translated - 'var tHidden=getComputedStyle(t).display==="none";' + - 'var oHidden=getComputedStyle(o).display==="none";' + - 'if(!tHidden&&oHidden){t.style.display="";o.style.display="initial";}' + // translated→both - 'else if(!tHidden&&!oHidden){t.style.display="none";o.style.display="initial";}' + // both→original - 'else{t.style.display="";o.style.display="none";}' + // original→translated - '}' + + // Long-press toggle on individual block (500ms) + 'var _tpTimer=null;' + 'document.addEventListener("touchstart",function(e){' + - 'var pair=e.target.closest("[data-tp-pair]")||e.target.closest("[data-tp-id]");' + - 'if(!pair)return;' + - '_tpMoved=false;' + - '_tpTimer=setTimeout(function(){tpTogglePair(pair);},500);' + + 'var block=e.target.closest(".tp-block");' + + 'if(!block)return;' + + '_tpTimer=setTimeout(function(){tpToggle(block);},500);' + '},{passive:true});' + - 'document.addEventListener("touchmove",function(){_tpMoved=true;if(_tpTimer){clearTimeout(_tpTimer);_tpTimer=null;}},{passive:true});' + + 'document.addEventListener("touchmove",function(){if(_tpTimer){clearTimeout(_tpTimer);_tpTimer=null;}},{passive:true});' + 'document.addEventListener("touchend",function(){if(_tpTimer){clearTimeout(_tpTimer);_tpTimer=null;}},{passive:true});' + // Right-click toggle on desktop 'document.addEventListener("contextmenu",function(e){' + - 'var pair=e.target.closest("[data-tp-pair]")||e.target.closest("[data-tp-id]");' + - 'if(!pair)return;' + + 'var block=e.target.closest(".tp-block");' + + 'if(!block)return;' + 'e.preventDefault();' + - 'tpTogglePair(pair);' + + 'tpToggle(block);' + '});' + '})();'; @@ -214,17 +200,70 @@ function _tp_escapeHtml(str) { // ── Capture: inline CSS, absolutize URLs, strip scripts ── +/** + * Rewrite localhost URLs to the page's actual origin. + * Some sites (e.g. Next.js on Vercel) SSR with localhost URLs that the CDN + * normally rewrites. When agent-browser fetches the page, these survive and + * cause resource loads to fail. + */ +function _tp_rewriteLocalhost(href, base) { + if (!href) return href; + var m = href.match(/^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/.*)/); + if (!m) return href; + try { + var origin = new URL(base).origin; + return origin + m[1]; + } catch (_) { + return href; + } +} + function _tp_capturePage(doc) { var base = doc.baseURI || ''; - // 1. Inline external stylesheets + // 1a. Capture CSS from CSSOM — gets whatever the browser actually loaded, + // regardless of whether the URLs were correct or rewritten by JS at runtime. + // Absolutize relative url() references using each sheet's href as base. + var cssomTexts = []; + for (var s = 0; s < doc.styleSheets.length; s++) { + try { + var sheet = doc.styleSheets[s]; + if (sheet.ownerNode && sheet.ownerNode.getAttribute && sheet.ownerNode.getAttribute('data-tp')) continue; + var rules = sheet.cssRules || sheet.rules; + if (!rules || !rules.length) continue; + var sheetBase = sheet.href || base; + var css = ''; + for (var r = 0; r < rules.length; r++) css += rules[r].cssText + '\n'; + // Resolve relative url() in CSS to absolute using the sheet's origin + if (css && sheetBase) { + css = css.replace(/url\(["']?([^"')]+)["']?\)/g, function(match, u) { + if (u.startsWith('data:') || u.startsWith('http://') || u.startsWith('https://')) return match; + try { return 'url("' + new URL(u, sheetBase).href + '")'; } catch (_) { return match; } + }); + } + if (css) cssomTexts.push(css); + } catch (_) { + // Cross-origin stylesheets throw SecurityError — handled in 1b + } + } + + // 1b. For tags whose CSS wasn't captured via CSSOM + // (cross-origin or failed to load), fetch with localhost rewriting. var linkPromises = []; var links = doc.querySelectorAll('link[rel="stylesheet"]'); for (var i = 0; i < links.length; i++) { (function(link) { var href = link.href; if (!href) { link.remove(); return; } - var p = fetch(href).then(function(res) { + // Check if this sheet was already captured via CSSOM + try { + var rules = link.sheet && (link.sheet.cssRules || link.sheet.rules); + if (rules && rules.length > 0) { link.remove(); return; } + } catch (_) { /* cross-origin — need to fetch */ } + // Rewrite localhost URLs to the page's real origin before fetching + var fetchUrl = _tp_rewriteLocalhost(href, base); + var p = fetch(fetchUrl).then(function(res) { + if (!res.ok) throw new Error(res.status); return res.text(); }).then(function(css) { var style = doc.createElement('style'); @@ -239,22 +278,39 @@ function _tp_capturePage(doc) { } return Promise.all(linkPromises).then(function() { + // 1c. Inject CSSOM-captured CSS as inlined