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.
15 KiB
name, description
| name | description |
|---|---|
| add-telegram | 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:
- 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
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
- Other chats: Can configure
respondToAll: truein registered_groups.json
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: Add storeMessageDirect to Database
Read src/db.ts and add this function (place it near the storeMessage function):
/**
* 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:
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:
- Add imports at the top:
import {
connectTelegram,
sendTelegramMessage,
isTelegramConnected,
} from "./telegram.js";
import { TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY } from "./config.js";
- Update
sendMessagefunction to route by channel. Find thesendMessagefunction and replace it with:
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");
}
}
}
- Update
main()function. Find themain()function and update it to support Telegram. Add this before theconnectWhatsApp()call:
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, ">")
.replace(/"/g, """);
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,
});
}
- Wrap the
connectWhatsApp()call to support Telegram-only mode. Replace:
await connectWhatsApp();
With:
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:
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:
- Send
/chatidto your bot (in private chat or in a group)- Copy the chat ID (e.g.,
tg:123456789ortg:-1001234567890)- I'll add it to registered_groups.json
Then update data/registered_groups.json:
For private chat:
{
"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):
{
"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
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
Or for systemd:
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 helloor @mention the botCheck logs:
tail -f logs/nanoclaw.log
Replace WhatsApp Entirely
If user wants Telegram-only:
- Set
TELEGRAM_ONLY=truein.env - The WhatsApp connection code is automatically skipped
- 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:
- Message is in the main chat (folder: "main")
- Chat has
respondToAll: truein registered_groups.json - Bot is @mentioned using native Telegram mention (e.g., @your_bot_username)
- 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:
TELEGRAM_BOT_TOKENis set in.env- Chat is registered in
data/registered_groups.jsonwithtg:prefix - For non-main chats: message includes trigger or @mention
- 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:
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:
- Delete
src/telegram.ts - Remove Telegram imports from
src/index.ts - Remove
sendTelegramMessagelogic fromsendMessage()function - Remove
connectTelegram()call frommain() - Remove
storeMessageDirectfromsrc/db.ts - Remove Telegram config from
src/config.ts - Uninstall:
npm uninstall grammy - Rebuild:
npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw