Files
Regolith/.claude/skills/add-telegram/SKILL.md
jmr 6cd165f391 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.
2026-02-09 01:48:47 +02:00

575 lines
15 KiB
Markdown

---
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<string | null>;
getRegisteredGroups: () => Record<string, RegisteredGroup>;
}
let bot: Bot | null = null;
let callbacks: TelegramCallbacks | null = null;
export async function connectTelegram(
botToken: string,
cbs: TelegramCallbacks,
): Promise<void> {
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<void> {
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<void> {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
return `<message sender="${escapeXml(m.sender_name)}" time="${m.timestamp}">${escapeXml(m.content)}</message>`;
});
const prompt = `<messages>\n${lines.join("\n")}\n</messages>`;
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`