From 6cd165f391a6d4ecc733ce511df504cb962ff83b Mon Sep 17 00:00:00 2001 From: jmr Date: Sun, 8 Feb 2026 15:48:47 -0800 Subject: [PATCH] feat: Add /add-telegram skill for Telegram channel support (#83) Add skill to guide users through adding Telegram as a messaging channel. - Replace WhatsApp or run alongside it - Support for private chats and groups - Built-in /chatid command for easy registration - Flexible triggering: main chat, respondToAll, @mentions, or trigger pattern - Grammy library for modern TypeScript-first Telegram integration No source code changes - skill provides step-by-step implementation guide. --- .claude/skills/add-telegram/SKILL.md | 574 +++++++++++++++++++++++++++ 1 file changed, 574 insertions(+) create mode 100644 .claude/skills/add-telegram/SKILL.md diff --git a/.claude/skills/add-telegram/SKILL.md b/.claude/skills/add-telegram/SKILL.md new file mode 100644 index 0000000..b7b7909 --- /dev/null +++ b/.claude/skills/add-telegram/SKILL.md @@ -0,0 +1,574 @@ +--- +name: add-telegram +description: Add Telegram as a channel. Can replace WhatsApp entirely or run alongside it. Also configurable as a control-only channel (triggers actions) or passive channel (receives notifications only). +--- + +# Add Telegram Channel + +This skill adds Telegram support to NanoClaw. Users can choose to: + +1. **Replace WhatsApp** - Use Telegram as the only messaging channel +2. **Add alongside WhatsApp** - Both channels active +3. **Control channel** - Telegram triggers agent but doesn't receive all outputs +4. **Notification channel** - Receives outputs but limited triggering + +## Prerequisites + +### 1. Install Grammy + +```bash +npm install grammy +``` + +Grammy is a modern, TypeScript-first Telegram bot framework. + +### 2. Create Telegram Bot + +Tell the user: + +> I need you to create a Telegram bot: +> +> 1. Open Telegram and search for `@BotFather` +> 2. Send `/newbot` and follow prompts: +> - Bot name: Something friendly (e.g., "Andy Assistant") +> - Bot username: Must end with "bot" (e.g., "andy_ai_bot") +> 3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`) + +Wait for user to provide the token. + +### 3. Get Chat ID + +Tell the user: + +> To register a chat, you need its Chat ID. Here's how: +> +> **For Private Chat (DM with bot):** +> 1. Search for your bot in Telegram +> 2. Start a chat and send any message +> 3. I'll add a `/chatid` command to help you get the ID +> +> **For Group Chat:** +> 1. Add your bot to the group +> 2. Send any message +> 3. Use the `/chatid` command in the group + +## Questions to Ask + +Before making changes, ask: + +1. **Mode**: Replace WhatsApp or add alongside it? + - If replace: Set `TELEGRAM_ONLY=true` + - If alongside: Both will run + +2. **Chat behavior**: Should this chat respond to all messages or only when @mentioned? + - Main chat: Responds to all + - Other chats: Can configure `respondToAll: true` in registered_groups.json + +## Implementation + +### Step 1: Update Configuration + +Read `src/config.ts` and add Telegram config exports: + +```typescript +export const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || ""; +export const TELEGRAM_ONLY = process.env.TELEGRAM_ONLY === "true"; +``` + +These should be added near the top with other configuration exports. + +### Step 2: Add storeMessageDirect to Database + +Read `src/db.ts` and add this function (place it near the `storeMessage` function): + +```typescript +/** + * Store a message directly (for non-WhatsApp channels that don't use Baileys proto). + */ +export function storeMessageDirect(msg: { + id: string; + chat_jid: string; + sender: string; + sender_name: string; + content: string; + timestamp: string; + is_from_me: boolean; +}): void { + db.prepare( + `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run( + msg.id, + msg.chat_jid, + msg.sender, + msg.sender_name, + msg.content, + msg.timestamp, + msg.is_from_me ? 1 : 0, + ); +} +``` + +Also update the db.ts exports to include `storeMessageDirect`. + +### Step 3: Create Telegram Module + +Create `src/telegram.ts` with this content: + +```typescript +import { Bot } from "grammy"; +import pino from "pino"; +import { + ASSISTANT_NAME, + TRIGGER_PATTERN, + MAIN_GROUP_FOLDER, +} from "./config.js"; +import { RegisteredGroup, NewMessage } from "./types.js"; +import { storeChatMetadata, storeMessageDirect } from "./db.js"; + +const logger = pino({ + level: process.env.LOG_LEVEL || "info", + transport: { target: "pino-pretty", options: { colorize: true } }, +}); + +export interface TelegramCallbacks { + onMessage: ( + msg: NewMessage, + group: RegisteredGroup, + ) => Promise; + getRegisteredGroups: () => Record; +} + +let bot: Bot | null = null; +let callbacks: TelegramCallbacks | null = null; + +export async function connectTelegram( + botToken: string, + cbs: TelegramCallbacks, +): Promise { + callbacks = cbs; + bot = new Bot(botToken); + + // Command to get chat ID (useful for registration) + bot.command("chatid", (ctx) => { + const chatId = ctx.chat.id; + const chatType = ctx.chat.type; + const chatName = + chatType === "private" + ? ctx.from?.first_name || "Private" + : (ctx.chat as any).title || "Unknown"; + + ctx.reply( + `Chat ID: \`tg:${chatId}\`\nName: ${chatName}\nType: ${chatType}`, + { parse_mode: "Markdown" }, + ); + }); + + // Command to check bot status + bot.command("ping", (ctx) => { + ctx.reply(`${ASSISTANT_NAME} is online.`); + }); + + bot.on("message:text", async (ctx) => { + // Skip commands + if (ctx.message.text.startsWith("/")) return; + + const chatId = `tg:${ctx.chat.id}`; + const content = ctx.message.text; + const timestamp = new Date(ctx.message.date * 1000).toISOString(); + const senderName = + ctx.from?.first_name || + ctx.from?.username || + ctx.from?.id.toString() || + "Unknown"; + const sender = ctx.from?.id.toString() || ""; + const msgId = ctx.message.message_id.toString(); + + // Determine chat name + const chatName = + ctx.chat.type === "private" + ? senderName + : (ctx.chat as any).title || chatId; + + // Store chat metadata for discovery + storeChatMetadata(chatId, timestamp, chatName); + + // Check if this chat is registered + const registeredGroups = callbacks!.getRegisteredGroups(); + const group = registeredGroups[chatId]; + + if (!group) { + logger.debug( + { chatId, chatName }, + "Message from unregistered Telegram chat", + ); + return; + } + + // Store message for registered chats + storeMessageDirect({ + id: msgId, + chat_jid: chatId, + sender, + sender_name: senderName, + content, + timestamp, + is_from_me: false, + }); + + const isMain = group.folder === MAIN_GROUP_FOLDER; + const respondToAll = (group as any).respondToAll === true; + + // Check if bot is @mentioned in the message (Telegram native mention) + const botUsername = ctx.me?.username?.toLowerCase(); + const entities = ctx.message.entities || []; + const isBotMentioned = entities.some((entity) => { + if (entity.type === "mention") { + const mentionText = content + .substring(entity.offset, entity.offset + entity.length) + .toLowerCase(); + return mentionText === `@${botUsername}`; + } + return false; + }); + + // Respond if: main group, respondToAll group, bot is @mentioned, or trigger pattern matches + if ( + !isMain && + !respondToAll && + !isBotMentioned && + !TRIGGER_PATTERN.test(content) + ) { + return; + } + + logger.info( + { chatId, chatName, sender: senderName }, + "Processing Telegram message", + ); + + // Send typing indicator + await ctx.replyWithChatAction("typing"); + + const msg: NewMessage = { + id: msgId, + chat_jid: chatId, + sender, + sender_name: senderName, + content, + timestamp, + }; + + try { + const response = await callbacks!.onMessage(msg, group); + if (response) { + await ctx.reply(`${ASSISTANT_NAME}: ${response}`); + } + } catch (err) { + logger.error({ err, chatId }, "Error processing Telegram message"); + } + }); + + // Handle errors gracefully + bot.catch((err) => { + logger.error({ err: err.message }, "Telegram bot error"); + }); + + // Start polling + bot.start({ + onStart: (botInfo) => { + logger.info( + { username: botInfo.username, id: botInfo.id }, + "Telegram bot connected", + ); + console.log(`\n Telegram bot: @${botInfo.username}`); + console.log( + ` Send /chatid to the bot to get a chat's registration ID\n`, + ); + }, + }); +} + +export async function sendTelegramMessage( + chatId: string, + text: string, +): Promise { + if (!bot) { + logger.warn("Telegram bot not initialized"); + return; + } + + try { + // Remove tg: prefix if present + const numericId = chatId.replace(/^tg:/, ""); + await bot.api.sendMessage(numericId, text); + logger.info({ chatId, length: text.length }, "Telegram message sent"); + } catch (err) { + logger.error({ chatId, err }, "Failed to send Telegram message"); + } +} + +export function isTelegramConnected(): boolean { + return bot !== null; +} + +export function stopTelegram(): void { + if (bot) { + bot.stop(); + bot = null; + callbacks = null; + logger.info("Telegram bot stopped"); + } +} +``` + +### Step 4: Update Main Application + +Modify `src/index.ts`: + +1. Add imports at the top: + +```typescript +import { + connectTelegram, + sendTelegramMessage, + isTelegramConnected, +} from "./telegram.js"; +import { TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY } from "./config.js"; +``` + +2. Update `sendMessage` function to route by channel. Find the `sendMessage` function and replace it with: + +```typescript +async function sendMessage(jid: string, text: string): Promise { + if (jid.startsWith("tg:")) { + await sendTelegramMessage(jid, text); + } else { + try { + await sock.sendMessage(jid, { text }); + logger.info({ jid, length: text.length }, "Message sent"); + } catch (err) { + logger.error({ jid, err }, "Failed to send message"); + } + } +} +``` + +3. Update `main()` function. Find the `main()` function and update it to support Telegram. Add this before the `connectWhatsApp()` call: + +```typescript +const hasTelegram = !!TELEGRAM_BOT_TOKEN; + +if (hasTelegram) { + await connectTelegram(TELEGRAM_BOT_TOKEN, { + onMessage: async (msg, group) => { + // Get messages since last agent interaction for context + const sinceTimestamp = lastAgentTimestamp[msg.chat_jid] || ""; + const missedMessages = getMessagesSince( + msg.chat_jid, + sinceTimestamp, + ASSISTANT_NAME, + ); + + const lines = missedMessages.map((m) => { + const escapeXml = (s: string) => + s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + return `${escapeXml(m.content)}`; + }); + const prompt = `\n${lines.join("\n")}\n`; + + const group = registeredGroups[msg.chat_jid]; + const isMain = group.folder === MAIN_GROUP_FOLDER; + + const output = await runContainerAgent(group, { + prompt, + sessionId: sessions[group.folder], + groupFolder: group.folder, + chatJid: msg.chat_jid, + isMain, + isScheduledTask: false, + }); + + if (output.newSessionId) { + sessions[group.folder] = output.newSessionId; + saveJson(path.join(DATA_DIR, "sessions.json"), sessions); + } + + lastAgentTimestamp[msg.chat_jid] = msg.timestamp; + saveState(); + + return output.status === "success" ? output.result : null; + }, + getRegisteredGroups: () => registeredGroups, + }); +} +``` + +4. Wrap the `connectWhatsApp()` call to support Telegram-only mode. Replace: + +```typescript +await connectWhatsApp(); +``` + +With: + +```typescript +if (!TELEGRAM_ONLY) { + await connectWhatsApp(); +} else { + // Telegram-only mode: start scheduler and IPC without WhatsApp + startSchedulerLoop({ + sendMessage, + registeredGroups: () => registeredGroups, + getSessions: () => sessions, + }); + startIpcWatcher(); + logger.info( + `NanoClaw running (Telegram-only, trigger: @${ASSISTANT_NAME})`, + ); +} +``` + +### Step 5: Update Environment + +Add to `.env`: + +```bash +TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN_HERE + +# Optional: Set to "true" to disable WhatsApp entirely +# TELEGRAM_ONLY=true +``` + +### Step 6: Register a Telegram Chat + +After installing and starting the bot, tell the user: + +> 1. Send `/chatid` to your bot (in private chat or in a group) +> 2. Copy the chat ID (e.g., `tg:123456789` or `tg:-1001234567890`) +> 3. I'll add it to registered_groups.json + +Then update `data/registered_groups.json`: + +For private chat: + +```json +{ + "tg:123456789": { + "name": "Personal", + "folder": "main", + "trigger": "@Andy", + "added_at": "2026-02-05T12:00:00.000Z" + } +} +``` + +For group chat (note the negative ID for groups): + +```json +{ + "tg:-1001234567890": { + "name": "My Telegram Group", + "folder": "telegram-group", + "trigger": "@Andy", + "added_at": "2026-02-05T12:00:00.000Z", + "respondToAll": false + } +} +``` + +Set `respondToAll: true` if you want the bot to respond to all messages in that chat (not just when @mentioned or triggered). + +### Step 7: Build and Restart + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw +``` + +Or for systemd: + +```bash +npm run build +systemctl --user restart nanoclaw +``` + +### Step 8: Test + +Tell the user: + +> Send a message to your registered Telegram chat: +> - For main chat: Any message works +> - For non-main: `@Andy hello` or @mention the bot +> +> Check logs: `tail -f logs/nanoclaw.log` + +## Replace WhatsApp Entirely + +If user wants Telegram-only: + +1. Set `TELEGRAM_ONLY=true` in `.env` +2. The WhatsApp connection code is automatically skipped +3. Optionally remove `@whiskeysockets/baileys` dependency (but it's harmless to keep) + +## Features + +### Chat ID Formats + +- **WhatsApp**: `120363336345536173@g.us` (groups) or `1234567890@s.whatsapp.net` (DM) +- **Telegram**: `tg:123456789` (positive for private) or `tg:-1001234567890` (negative for groups) + +### Trigger Options + +The bot responds when: +1. Message is in the main chat (folder: "main") +2. Chat has `respondToAll: true` in registered_groups.json +3. Bot is @mentioned using native Telegram mention (e.g., @your_bot_username) +4. Message matches TRIGGER_PATTERN (e.g., starts with @Andy) + +### Commands + +- `/chatid` - Get chat ID for registration +- `/ping` - Check if bot is online + +## Troubleshooting + +### Bot not responding + +Check: +1. `TELEGRAM_BOT_TOKEN` is set in `.env` +2. Chat is registered in `data/registered_groups.json` with `tg:` prefix +3. For non-main chats: message includes trigger or @mention +4. Service is running: `launchctl list | grep nanoclaw` + +### Getting chat ID + +If `/chatid` doesn't work: +- Verify bot token is valid: `curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe"` +- Check bot is started: `tail -f logs/nanoclaw.log` + +### Service conflicts + +If running `npm run dev` while launchd service is active: +```bash +launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist +npm run dev +# When done testing: +launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist +``` + +## Removal + +To remove Telegram integration: + +1. Delete `src/telegram.ts` +2. Remove Telegram imports from `src/index.ts` +3. Remove `sendTelegramMessage` logic from `sendMessage()` function +4. Remove `connectTelegram()` call from `main()` +5. Remove `storeMessageDirect` from `src/db.ts` +6. Remove Telegram config from `src/config.ts` +7. Uninstall: `npm uninstall grammy` +8. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw`