add-telegram
npx machina-cli add skill crypdick/pynchy/add-telegram --openclawAdd Telegram Channel
This skill adds Telegram support to NanoClaw. Users can choose to:
- Replace WhatsApp - Use Telegram as the only messaging channel
- Add alongside WhatsApp - Both channels active
- Control channel - Telegram triggers agent but doesn't receive all outputs
- Notification channel - Receives outputs but limited triggering
Prerequisites
1. Install Grammy
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:
- Open Telegram and search for
@BotFather- Send
/newbotand follow prompts:
- Bot name: Something friendly (e.g., "Andy Assistant")
- Bot username: Must end with "bot" (e.g., "andy_ai_bot")
- 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):
- Search for your bot in Telegram
- Start a chat and send any message
- I'll add a
/chatidcommand to help you get the IDFor Group Chat:
- Add your bot to the group
- Send any message
- Use the
/chatidcommand in the group
4. Disable Group Privacy (for group chats)
Tell the user:
Important for group chats: By default, Telegram bots in groups only receive messages that @mention the bot or are commands. To let the bot see all messages (needed for
requiresTrigger: falseor trigger-word detection):
- Open Telegram and search for
@BotFather- Send
/mybotsand select your bot- Go to Bot Settings > Group Privacy
- Select Turn off
Without this, the bot will only see messages that directly @mention it.
This step is optional if the user only wants trigger-based responses via @mentioning the bot.
Questions to Ask
Before making changes, ask:
-
Mode: Replace WhatsApp or add alongside it?
- If replace: Set
TELEGRAM_ONLY=true - If alongside: Both will run
- If replace: Set
-
Chat behavior: Should this chat respond to all messages or only when @mentioned?
- Main chat: Responds to all (set
requiresTrigger: false) - Other chats: Default requires trigger (
requiresTrigger: true)
- Main chat: Responds to all (set
Architecture
NanoClaw uses a Channel abstraction (Channel interface in src/types.ts). Each messaging platform implements this interface. Key files:
| File | Purpose |
|---|---|
src/types.ts | Channel interface definition |
src/channels/whatsapp.ts | WhatsAppChannel class (reference implementation) |
src/router.ts | findChannel(), routeOutbound(), formatOutbound() |
src/index.ts | Orchestrator: creates channels, wires callbacks, starts subsystems |
src/ipc.ts | IPC watcher (uses sendMessage dep for outbound) |
The Telegram channel follows the same pattern as WhatsApp:
- Implements
Channelinterface (connect,sendMessage,ownsJid,disconnect,setTyping) - Delivers inbound messages via
onMessage/onChatMetadatacallbacks - The existing message loop in
src/index.tspicks up stored messages automatically
Implementation
Step 1: Update Configuration
Read src/config.ts and add Telegram config exports:
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: Create Telegram Channel
Create src/channels/telegram.ts implementing the Channel interface. Use src/channels/whatsapp.ts as a reference for the pattern.
import { Bot } from "grammy";
import {
ASSISTANT_NAME,
TRIGGER_PATTERN,
} from "../config.js";
import { logger } from "../logger.js";
import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from "../types.js";
export interface TelegramChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
export class TelegramChannel implements Channel {
name = "telegram";
prefixAssistantName = false; // Telegram bots already display their name
private bot: Bot | null = null;
private opts: TelegramChannelOpts;
private botToken: string;
constructor(botToken: string, opts: TelegramChannelOpts) {
this.botToken = botToken;
this.opts = opts;
}
async connect(): Promise<void> {
this.bot = new Bot(this.botToken);
// Command to get chat ID (useful for registration)
this.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
this.bot.command("ping", (ctx) => {
ctx.reply(`${ASSISTANT_NAME} is online.`);
});
this.bot.on("message:text", async (ctx) => {
// Skip commands
if (ctx.message.text.startsWith("/")) return;
const chatJid = `tg:${ctx.chat.id}`;
let 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 || chatJid;
// Translate Telegram @bot_username mentions into TRIGGER_PATTERN format.
// Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN
// (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned.
const botUsername = ctx.me?.username?.toLowerCase();
if (botUsername) {
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;
});
if (isBotMentioned && !TRIGGER_PATTERN.test(content)) {
content = `@${ASSISTANT_NAME} ${content}`;
}
}
// Store chat metadata for discovery
this.opts.onChatMetadata(chatJid, timestamp, chatName);
// Only deliver full message for registered groups
const group = this.opts.registeredGroups()[chatJid];
if (!group) {
logger.debug(
{ chatJid, chatName },
"Message from unregistered Telegram chat",
);
return;
}
// Deliver message — startMessageLoop() will pick it up
this.opts.onMessage(chatJid, {
id: msgId,
chat_jid: chatJid,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: false,
});
logger.info(
{ chatJid, chatName, sender: senderName },
"Telegram message stored",
);
});
// Handle non-text messages with placeholders so the agent knows something was sent
const storeNonText = (ctx: any, placeholder: string) => {
const chatJid = `tg:${ctx.chat.id}`;
const group = this.opts.registeredGroups()[chatJid];
if (!group) return;
const timestamp = new Date(ctx.message.date * 1000).toISOString();
const senderName =
ctx.from?.first_name || ctx.from?.username || ctx.from?.id?.toString() || "Unknown";
const caption = ctx.message.caption ? ` ${ctx.message.caption}` : "";
this.opts.onChatMetadata(chatJid, timestamp);
this.opts.onMessage(chatJid, {
id: ctx.message.message_id.toString(),
chat_jid: chatJid,
sender: ctx.from?.id?.toString() || "",
sender_name: senderName,
content: `${placeholder}${caption}`,
timestamp,
is_from_me: false,
});
};
this.bot.on("message:photo", (ctx) => storeNonText(ctx, "[Photo]"));
this.bot.on("message:video", (ctx) => storeNonText(ctx, "[Video]"));
this.bot.on("message:voice", (ctx) => storeNonText(ctx, "[Voice message]"));
this.bot.on("message:audio", (ctx) => storeNonText(ctx, "[Audio]"));
this.bot.on("message:document", (ctx) => {
const name = ctx.message.document?.file_name || "file";
storeNonText(ctx, `[Document: ${name}]`);
});
this.bot.on("message:sticker", (ctx) => {
const emoji = ctx.message.sticker?.emoji || "";
storeNonText(ctx, `[Sticker ${emoji}]`);
});
this.bot.on("message:location", (ctx) => storeNonText(ctx, "[Location]"));
this.bot.on("message:contact", (ctx) => storeNonText(ctx, "[Contact]"));
// Handle errors gracefully
this.bot.catch((err) => {
logger.error({ err: err.message }, "Telegram bot error");
});
// Start polling — returns a Promise that resolves when started
return new Promise<void>((resolve) => {
this.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`,
);
resolve();
},
});
});
}
async sendMessage(jid: string, text: string): Promise<void> {
if (!this.bot) {
logger.warn("Telegram bot not initialized");
return;
}
try {
const numericId = jid.replace(/^tg:/, "");
// Telegram has a 4096 character limit per message — split if needed
const MAX_LENGTH = 4096;
if (text.length <= MAX_LENGTH) {
await this.bot.api.sendMessage(numericId, text);
} else {
for (let i = 0; i < text.length; i += MAX_LENGTH) {
await this.bot.api.sendMessage(numericId, text.slice(i, i + MAX_LENGTH));
}
}
logger.info({ jid, length: text.length }, "Telegram message sent");
} catch (err) {
logger.error({ jid, err }, "Failed to send Telegram message");
}
}
isConnected(): boolean {
return this.bot !== null;
}
ownsJid(jid: string): boolean {
return jid.startsWith("tg:");
}
async disconnect(): Promise<void> {
if (this.bot) {
this.bot.stop();
this.bot = null;
logger.info("Telegram bot stopped");
}
}
async setTyping(jid: string, isTyping: boolean): Promise<void> {
if (!this.bot || !isTyping) return;
try {
const numericId = jid.replace(/^tg:/, "");
await this.bot.api.sendChatAction(numericId, "typing");
} catch (err) {
logger.debug({ jid, err }, "Failed to send Telegram typing indicator");
}
}
}
Key differences from the old standalone src/telegram.ts:
- Implements
Channelinterface — same pattern asWhatsAppChannel - Uses
onMessage/onChatMetadatacallbacks instead of importing DB functions directly - Registration check via
registeredGroups()callback, notgetAllRegisteredGroups() prefixAssistantName = false— Telegram bots already show their name, soformatOutbound()skips the prefix- No
storeMessageDirectneeded —storeMessage()in db.ts already acceptsNewMessagedirectly
Step 3: Update Main Application
Modify src/index.ts to support multiple channels. Read the file first to understand the current structure.
- Add imports at the top:
import { TelegramChannel } from "./channels/telegram.js";
import { TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY } from "./config.js";
import { findChannel } from "./router.js";
- Add a channels array alongside the existing
whatsappvariable:
let whatsapp: WhatsAppChannel;
const channels: Channel[] = [];
Import Channel from ./types.js if not already imported.
- Update
processGroupMessagesto find the correct channel for the JID instead of usingwhatsappdirectly. Replace the directwhatsapp.setTyping()andwhatsapp.sendMessage()calls:
// Find the channel that owns this JID
const channel = findChannel(channels, chatJid);
if (!channel) return true; // No channel for this JID
// ... (existing code for message fetching, trigger check, formatting)
await channel.setTyping?.(chatJid, true);
// ... (existing agent invocation, replacing whatsapp.sendMessage with channel.sendMessage)
await channel.setTyping?.(chatJid, false);
In the onOutput callback inside processGroupMessages, replace:
await whatsapp.sendMessage(chatJid, `${ASSISTANT_NAME}: ${text}`);
with:
const formatted = formatOutbound(channel, text);
if (formatted) await channel.sendMessage(chatJid, formatted);
- Update
main()function to create channels conditionally and use them for deps:
async function main(): Promise<void> {
ensureContainerSystemRunning();
initDatabase();
logger.info('Database initialized');
loadState();
// Graceful shutdown handlers
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
await queue.shutdown(10000);
for (const ch of channels) await ch.disconnect();
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Channel callbacks (shared by all channels)
const channelOpts = {
onMessage: (chatJid: string, msg: NewMessage) => storeMessage(msg),
onChatMetadata: (chatJid: string, timestamp: string, name?: string) =>
storeChatMetadata(chatJid, timestamp, name),
registeredGroups: () => registeredGroups,
};
// Create and connect channels
if (!TELEGRAM_ONLY) {
whatsapp = new WhatsAppChannel(channelOpts);
channels.push(whatsapp);
await whatsapp.connect();
}
if (TELEGRAM_BOT_TOKEN) {
const telegram = new TelegramChannel(TELEGRAM_BOT_TOKEN, channelOpts);
channels.push(telegram);
await telegram.connect();
}
// Start subsystems
startSchedulerLoop({
registeredGroups: () => registeredGroups,
getSessions: () => sessions,
queue,
onProcess: (groupJid, proc, containerName, groupFolder) =>
queue.registerProcess(groupJid, proc, containerName, groupFolder),
sendMessage: async (jid, rawText) => {
const channel = findChannel(channels, jid);
if (!channel) return;
const text = formatOutbound(channel, rawText);
if (text) await channel.sendMessage(jid, text);
},
});
startIpcWatcher({
sendMessage: (jid, text) => {
const channel = findChannel(channels, jid);
if (!channel) throw new Error(`No channel for JID: ${jid}`);
return channel.sendMessage(jid, text);
},
registeredGroups: () => registeredGroups,
registerGroup,
syncGroupMetadata: (force) => whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
getAvailableGroups,
writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
});
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
startMessageLoop();
}
- Update
getAvailableGroupsto include Telegram chats:
export function getAvailableGroups(): AvailableGroup[] {
const chats = getAllChats();
const registeredJids = new Set(Object.keys(registeredGroups));
return chats
.filter((c) => c.jid !== '__group_sync__' && (c.jid.endsWith('@g.us') || c.jid.startsWith('tg:')))
.map((c) => ({
jid: c.jid,
name: c.name,
lastActivity: c.last_message_time,
isRegistered: registeredJids.has(c.jid),
}));
}
Step 4: Update Environment
Add to .env:
TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN_HERE
# Optional: Set to "true" to disable WhatsApp entirely
# TELEGRAM_ONLY=true
Important: After modifying .env, sync to the container environment:
cp .env data/env/env
The container reads environment from data/env/env, not .env directly.
Step 5: Register a Telegram Chat
After installing and starting the bot, tell the user:
- Send
/chatidto your bot (in private chat or in a group)- Copy the chat ID (e.g.,
tg:123456789ortg:-1001234567890)- I'll register it for you
Registration uses the registerGroup() function in src/index.ts, which writes to SQLite and creates the group folder structure. Call it like this (or add a one-time script):
// For private chat (god group):
registerGroup("tg:123456789", {
name: "Personal",
folder: "main",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: false, // god group responds to all messages
});
// For group chat (note negative ID for Telegram groups):
registerGroup("tg:-1001234567890", {
name: "My Telegram Group",
folder: "telegram-group",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: true, // only respond when triggered
});
The RegisteredGroup type requires a trigger string field and has an optional requiresTrigger boolean (defaults to true). Set requiresTrigger: false for chats that should respond to all messages.
Alternatively, if the agent is already running in the god group, it can register new groups via IPC using the register_group task type.
Step 6: Build and Restart
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
Or for systemd:
npm run build
systemctl --user restart nanoclaw
Step 7: Test
Tell the user:
Send a message to your registered Telegram chat:
- For main chat: Any message works
- For non-god:
@Andy helloor @mention the botCheck logs:
tail -f logs/nanoclaw.log
Replace WhatsApp Entirely
If user wants Telegram-only:
- Set
TELEGRAM_ONLY=truein.env - Run
cp .env data/env/envto sync to container - The WhatsApp channel is not created — only Telegram
- All services (scheduler, IPC watcher, queue, message loop) start normally
- Optionally remove
@whiskeysockets/baileysdependency (but it's harmless to keep)
Features
Chat ID Formats
- WhatsApp:
120363336345536173@g.us(groups) or1234567890@s.whatsapp.net(DM) - Telegram:
tg:123456789(positive for private) ortg:-1001234567890(negative for groups)
Trigger Options
The bot responds when:
- Chat has
requiresTrigger: falsein its registration (e.g., god group) - Bot is @mentioned in Telegram (translated to TRIGGER_PATTERN automatically)
- Message matches TRIGGER_PATTERN directly (e.g., starts with @Andy)
Telegram @mentions (e.g., @andy_ai_bot) are automatically translated: if the bot is @mentioned and the message doesn't already match TRIGGER_PATTERN, the trigger prefix is prepended before storing. This ensures @mentioning the bot always triggers a response.
Group Privacy: The bot must have Group Privacy disabled in BotFather to see non-mention messages in groups. See Prerequisites step 4.
Commands
/chatid- Get chat ID for registration/ping- Check if bot is online
Troubleshooting
Bot not responding
Check:
TELEGRAM_BOT_TOKENis set in.envAND synced todata/env/env- Chat is registered in SQLite (check with:
sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'") - For non-god chats: message includes trigger pattern
- Service is running:
launchctl list | grep nanoclaw
Bot only responds to @mentions in groups
The bot has Group Privacy enabled (default). It can only see messages that @mention it or are commands. To fix:
- Open
@BotFatherin Telegram /mybots> select bot > Bot Settings > Group Privacy > Turn off- Remove and re-add the bot to the group (required for the change to take effect)
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:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
npm run dev
# When done testing:
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
Agent Swarms (Teams)
After completing the Telegram setup, ask the user:
Would you like to add Agent Swarm support? Without it, Agent Teams still work — they just operate behind the scenes. With Swarm support, each subagent appears as a different bot in the Telegram group so you can see who's saying what and have interactive team sessions.
If they say yes, invoke the /add-telegram-swarm skill.
Removal
To remove Telegram integration:
- Delete
src/channels/telegram.ts - Remove
TelegramChannelimport and creation fromsrc/index.ts - Remove
channelsarray and revert to usingwhatsappdirectly inprocessGroupMessages, scheduler deps, and IPC deps - Revert
getAvailableGroups()filter to only include@g.uschats - Remove Telegram config (
TELEGRAM_BOT_TOKEN,TELEGRAM_ONLY) fromsrc/config.ts - Remove Telegram registrations from SQLite:
sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'" - Uninstall:
npm uninstall grammy - Rebuild:
npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw
Source
git clone https://github.com/crypdick/pynchy/blob/main/docs/_archive/old-nanoclaw-skills/add-telegram/SKILL.mdView on GitHub Overview
This skill adds Telegram support to NanoClaw, allowing you to replace WhatsApp, run Telegram alongside it, or use Telegram as a control-only (triggers actions) or passive notification channel. It follows the same Channel abstraction as WhatsApp, wiring Telegram through the existing router and index logic to handle inbound and outbound messages.
How This Skill Works
Telegram is implemented as a Channel that adheres to the Channel interface (connect, sendMessage, ownsJid, disconnect, setTyping). It uses the grammy library to interact with the Telegram Bot API, delivering inbound messages via onMessage and onChatMetadata, while the central index.ts loop handles stored messages and outbound formatting just like the WhatsApp path.
When to Use It
- Replace WhatsApp with Telegram as the sole messaging channel
- Add Telegram alongside WhatsApp for multi-channel coverage
- Configure Telegram as a control-only channel that triggers actions without surfacing all outputs
- Configure Telegram as a notification-only channel that receives outputs with limited triggering
- Troubleshoot and adjust group privacy and chat IDs to ensure full message visibility
Quick Start
- Step 1: Install Grammy and create a Telegram bot with BotFather to obtain your token
- Step 2: Get the Chat ID for the private chat or group where the bot will operate
- Step 3: Configure environment: set TELEGRAM_BOT_TOKEN and TELEGRAM_ONLY in config, wire the Telegram channel into the system, and optionally disable group privacy for full message visibility
Best Practices
- Use TELEGRAM_ONLY to clearly switch between replacement and multi-channel modes
- Store and protect the Telegram bot token securely; avoid hard-coding
- Collect and verify Chat IDs for each chat (private and group) before going live
- Align requiresTrigger settings per chat (main chat vs. other chats) to control triggering
- Test end-to-end with both private and group chats; monitor inbound/outbound flows and logs
Example Use Cases
- Replace WhatsApp entirely with Telegram for a customer-support bot
- Run Telegram and WhatsApp in parallel to extend reach and redundancy
- Use Telegram as a control channel to trigger automations without full outputs
- Use Telegram as a notification channel to receive outputs with limited triggering
- Test group chats by temporarily disabling Group Privacy to allow full message visibility