Refactor index (#156)

* feat: add Telegram channel with agent swarm support

Add Telegram as a messaging channel that can run alongside WhatsApp
or standalone (TELEGRAM_ONLY mode). Includes bot pool support for
agent swarms where each subagent appears as a different bot identity
in the group.

- Add grammy dependency for Telegram Bot API
- Route messages through tg: JID prefix convention
- Add storeMessageDirect for non-Baileys channels
- Add sender field to IPC send_message for swarm identity
- Support TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY, TELEGRAM_BOT_POOL config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add index.ts refactor plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract channel abstraction, IPC, and router from index.ts

Break the 1088-line monolith into focused modules:
- src/channels/whatsapp.ts: WhatsAppChannel class implementing Channel interface
- src/ipc.ts: IPC watcher and task processing with dependency injection
- src/router.ts: message formatting, outbound routing, channel lookup
- src/types.ts: Channel interface, OnInboundMessage, OnChatMetadata types

Also adds regression test suite (98 tests), updates all documentation
and skill files to reflect the new architecture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: add test workflow for PRs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove accidentally committed pool-bot assets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): remove grammy from base dependencies

Grammy is installed by the /add-telegram skill, not a base dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-02-11 00:36:37 +02:00
committed by GitHub
parent 196abf67cf
commit 2b56fecfdc
28 changed files with 4273 additions and 1066 deletions

View File

@@ -476,7 +476,11 @@ Read `src/index.ts` and add the email polling infrastructure. First, add these i
import { checkForNewEmails, sendEmailReply, getContextKey } from './email-channel.js';
import { EMAIL_CHANNEL } from './config.js';
import { isEmailProcessed, markEmailProcessed, markEmailResponded } from './db.js';
```
Then add the `startEmailLoop` function:
```typescript
async function startEmailLoop(): Promise<void> {
if (!EMAIL_CHANNEL.enabled) {
logger.info('Email channel disabled');
@@ -524,11 +528,12 @@ Respond to this email. Your response will be sent as an email reply.`;
await new Promise(resolve => setTimeout(resolve, EMAIL_CHANNEL.pollIntervalMs));
}
}
```
Then find the `connectWhatsApp` function and add `startEmailLoop()` call after `startMessageLoop()`:
Then add `startEmailLoop()` in the `main()` function, after `startMessageLoop()`:
```typescript
// In the connection === 'open' block, after startMessageLoop():
// In main(), after startMessageLoop():
startEmailLoop();
```
@@ -574,7 +579,7 @@ async function runEmailAgent(
if (output.newSessionId) {
sessions[groupFolder] = output.newSessionId;
saveJson(path.join(DATA_DIR, 'sessions.json'), sessions);
setSession(groupFolder, output.newSessionId);
}
return output.status === 'success' ? output.result : null;
@@ -600,7 +605,7 @@ If you want the agent to be able to send emails proactively from within a sessio
}
```
Then add handling in `src/index.ts` in the `processTaskIpc` function or create a new IPC handler for email actions.
Then add handling in `src/ipc.ts` in the `processTaskIpc` function or create a new IPC handler for email actions.
### Step 8: Create Email Group Memory

View File

@@ -204,20 +204,11 @@ async (args) => {
### Step 4: Update Host IPC Routing
Read `src/index.ts` and make these changes:
Read `src/ipc.ts` and make these changes:
1. **Add imports** — add `sendPoolMessage` and `initBotPool` to the Telegram imports, and `TELEGRAM_BOT_POOL` to the config imports.
1. **Add imports** — add `sendPoolMessage` and `initBotPool` from the Telegram swarm module, and `TELEGRAM_BOT_POOL` from config.
2. **Update IPC message routing** — in the `startIpcWatcher` / `processIpcFiles` function, find where IPC messages are sent:
```typescript
await sendMessage(
data.chatJid,
`${ASSISTANT_NAME}: ${data.text}`,
);
```
Replace with:
2. **Update IPC message routing** — in `src/ipc.ts`, find where the `sendMessage` dependency is called to deliver IPC messages (inside `processIpcFiles`). The `sendMessage` is passed in via the `IpcDeps` parameter. Wrap it to route Telegram swarm messages through the bot pool:
```typescript
if (data.sender && data.chatJid.startsWith('tg:')) {
@@ -228,16 +219,13 @@ if (data.sender && data.chatJid.startsWith('tg:')) {
sourceGroup,
);
} else {
// Telegram bots already show their name — skip prefix for tg: chats
const prefix = data.chatJid.startsWith('tg:') ? '' : `${ASSISTANT_NAME}: `;
await sendMessage(
data.chatJid,
`${prefix}${data.text}`,
);
await deps.sendMessage(data.chatJid, data.text);
}
```
3. **Initialize pool in `main()`** — after the `connectTelegram()` call, add:
Note: The assistant name prefix is handled by `formatOutbound()` in the router — Telegram channels have `prefixAssistantName = false` so no prefix is added for `tg:` JIDs.
3. **Initialize pool in `main()` in `src/index.ts`** — after creating the Telegram channel, add:
```typescript
if (TELEGRAM_BOT_POOL.length > 0) {

View File

@@ -79,6 +79,23 @@ Before making changes, ask:
- Main chat: Responds to all (set `requiresTrigger: false`)
- Other chats: Default requires trigger (`requiresTrigger: true`)
## 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 `Channel` interface (`connect`, `sendMessage`, `ownsJid`, `disconnect`, `setTyping`)
- Delivers inbound messages via `onMessage` / `onChatMetadata` callbacks
- The existing message loop in `src/index.ts` picks up stored messages automatically
## Implementation
### Step 1: Update Configuration
@@ -92,327 +109,299 @@ 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
### Step 2: Create Telegram Channel
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,
);
}
```
This uses the existing `db` instance from `db.ts`. No additional imports needed.
### Step 3: Create Telegram Module
Create `src/telegram.ts`. The Telegram module is a thin layer that stores incoming messages to the database. It does NOT call the agent directly — the existing `startMessageLoop()` in `src/index.ts` polls all registered group JIDs and picks up Telegram messages automatically.
Create `src/channels/telegram.ts` implementing the `Channel` interface. Use `src/channels/whatsapp.ts` as a reference for the pattern.
```typescript
import { Bot } from "grammy";
import {
ASSISTANT_NAME,
TRIGGER_PATTERN,
} from "./config.js";
import {
getAllRegisteredGroups,
storeChatMetadata,
storeMessageDirect,
} from "./db.js";
import { logger } from "./logger.js";
} from "../config.js";
import { logger } from "../logger.js";
import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from "../types.js";
let bot: Bot | null = null;
/** Store a placeholder message for non-text content (photos, voice, etc.) */
function storeNonTextMessage(ctx: any, placeholder: string): void {
const chatId = `tg:${ctx.chat.id}`;
const registeredGroups = getAllRegisteredGroups();
if (!registeredGroups[chatId]) 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}` : "";
storeChatMetadata(chatId, timestamp);
storeMessageDirect({
id: ctx.message.message_id.toString(),
chat_jid: chatId,
sender: ctx.from?.id?.toString() || "",
sender_name: senderName,
content: `${placeholder}${caption}`,
timestamp,
is_from_me: false,
});
export interface TelegramChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
export async function connectTelegram(botToken: string): Promise<void> {
bot = new Bot(botToken);
export class TelegramChannel implements Channel {
name = "telegram";
prefixAssistantName = false; // Telegram bots already display their name
// 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";
private bot: Bot | null = null;
private opts: TelegramChannelOpts;
private botToken: string;
ctx.reply(
`Chat ID: \`tg:${chatId}\`\nName: ${chatName}\nType: ${chatType}`,
{ parse_mode: "Markdown" },
);
});
constructor(botToken: string, opts: TelegramChannelOpts) {
this.botToken = botToken;
this.opts = opts;
}
// Command to check bot status
bot.command("ping", (ctx) => {
ctx.reply(`${ASSISTANT_NAME} is online.`);
});
async connect(): Promise<void> {
this.bot = new Bot(this.botToken);
bot.on("message:text", async (ctx) => {
// Skip commands
if (ctx.message.text.startsWith("/")) return;
// 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";
const chatId = `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 || chatId;
// 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
storeChatMetadata(chatId, timestamp, chatName);
// Check if this chat is registered
const registeredGroups = getAllRegisteredGroups();
const group = registeredGroups[chatId];
if (!group) {
logger.debug(
{ chatId, chatName },
"Message from unregistered Telegram chat",
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;
}
// Store message — startMessageLoop() will pick it up
storeMessageDirect({
id: msgId,
chat_jid: chatId,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: false,
});
try {
const numericId = jid.replace(/^tg:/, "");
logger.info(
{ chatId, chatName, sender: senderName },
"Telegram message stored",
);
});
// Handle non-text messages with placeholders so the agent knows something was sent
bot.on("message:photo", (ctx) => storeNonTextMessage(ctx, "[Photo]"));
bot.on("message:video", (ctx) => storeNonTextMessage(ctx, "[Video]"));
bot.on("message:voice", (ctx) => storeNonTextMessage(ctx, "[Voice message]"));
bot.on("message:audio", (ctx) => storeNonTextMessage(ctx, "[Audio]"));
bot.on("message:document", (ctx) => {
const name = ctx.message.document?.file_name || "file";
storeNonTextMessage(ctx, `[Document: ${name}]`);
});
bot.on("message:sticker", (ctx) => {
const emoji = ctx.message.sticker?.emoji || "";
storeNonTextMessage(ctx, `[Sticker ${emoji}]`);
});
bot.on("message:location", (ctx) => storeNonTextMessage(ctx, "[Location]"));
bot.on("message:contact", (ctx) => storeNonTextMessage(ctx, "[Contact]"));
// 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 {
const numericId = chatId.replace(/^tg:/, "");
// Telegram has a 4096 character limit per message — split if needed
const MAX_LENGTH = 4096;
if (text.length <= MAX_LENGTH) {
await bot.api.sendMessage(numericId, text);
} else {
for (let i = 0; i < text.length; i += MAX_LENGTH) {
await bot.api.sendMessage(numericId, text.slice(i, i + MAX_LENGTH));
// 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");
}
logger.info({ chatId, length: text.length }, "Telegram message sent");
} catch (err) {
logger.error({ chatId, err }, "Failed to send Telegram message");
}
}
export async function setTelegramTyping(chatId: string): Promise<void> {
if (!bot) return;
try {
const numericId = chatId.replace(/^tg:/, "");
await bot.api.sendChatAction(numericId, "typing");
} catch (err) {
logger.debug({ chatId, err }, "Failed to send Telegram typing indicator");
isConnected(): boolean {
return this.bot !== null;
}
}
export function isTelegramConnected(): boolean {
return bot !== null;
}
ownsJid(jid: string): boolean {
return jid.startsWith("tg:");
}
export function stopTelegram(): void {
if (bot) {
bot.stop();
bot = null;
logger.info("Telegram bot stopped");
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 WhatsApp message handling:
- No `onMessage` callbackmessages are stored to DB and the existing message loop picks them up
- Registration check uses `getAllRegisteredGroups()` from `db.ts` directly
- Trigger matching is handled by `startMessageLoop()` / `processGroupMessages()`, not the Telegram module
Key differences from the old standalone `src/telegram.ts`:
- Implements `Channel` interfacesame pattern as `WhatsAppChannel`
- Uses `onMessage` / `onChatMetadata` callbacks instead of importing DB functions directly
- Registration check via `registeredGroups()` callback, not `getAllRegisteredGroups()`
- `prefixAssistantName = false` — Telegram bots already show their name, so `formatOutbound()` skips the prefix
- No `storeMessageDirect` needed — `storeMessage()` in db.ts already accepts `NewMessage` directly
### Step 4: Update Main Application
### Step 3: Update Main Application
Modify `src/index.ts`:
Modify `src/index.ts` to support multiple channels. Read the file first to understand the current structure.
1. **Add imports** at the top:
```typescript
import {
connectTelegram,
sendTelegramMessage,
setTelegramTyping,
stopTelegram,
} from "./telegram.js";
import { TelegramChannel } from "./channels/telegram.js";
import { TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY } from "./config.js";
import { findChannel } from "./router.js";
```
2. **Update `sendMessage` function** to route Telegram messages. Find the `sendMessage` function and add a `tg:` prefix check before the WhatsApp path:
2. **Add a channels array** alongside the existing `whatsapp` variable:
```typescript
async function sendMessage(jid: string, text: string): Promise<void> {
// Route Telegram messages directly (no outgoing queue needed)
if (jid.startsWith("tg:")) {
await sendTelegramMessage(jid, text);
return;
}
// WhatsApp path (with outgoing queue for reconnection)
if (!waConnected) {
outgoingQueue.push({ jid, text });
logger.info({ jid, length: text.length, queueSize: outgoingQueue.length }, 'WA disconnected, message queued');
return;
}
try {
await sock.sendMessage(jid, { text });
logger.info({ jid, length: text.length }, 'Message sent');
} catch (err) {
outgoingQueue.push({ jid, text });
logger.warn({ jid, err, queueSize: outgoingQueue.length }, 'Failed to send, message queued');
}
}
let whatsapp: WhatsAppChannel;
const channels: Channel[] = [];
```
3. **Update `setTyping` function** to route Telegram typing indicators:
Import `Channel` from `./types.js` if not already imported.
3. **Update `processGroupMessages`** to find the correct channel for the JID instead of using `whatsapp` directly. Replace the direct `whatsapp.setTyping()` and `whatsapp.sendMessage()` calls:
```typescript
async function setTyping(jid: string, isTyping: boolean): Promise<void> {
if (jid.startsWith("tg:")) {
if (isTyping) await setTelegramTyping(jid);
return;
}
try {
await sock.sendPresenceUpdate(isTyping ? 'composing' : 'paused', jid);
} catch (err) {
logger.debug({ jid, err }, 'Failed to update typing status');
}
}
// 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);
```
4. **Update `main()` function**. Add Telegram startup before `connectWhatsApp()` and wrap WhatsApp in a `TELEGRAM_ONLY` check:
In the `onOutput` callback inside `processGroupMessages`, replace:
```typescript
await whatsapp.sendMessage(chatJid, `${ASSISTANT_NAME}: ${text}`);
```
with:
```typescript
const formatted = formatOutbound(channel, text);
if (formatted) await channel.sendMessage(chatJid, formatted);
```
4. **Update `main()` function** to create channels conditionally and use them for deps:
```typescript
async function main(): Promise<void> {
@@ -424,49 +413,70 @@ async function main(): Promise<void> {
// Graceful shutdown handlers
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
stopTelegram();
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'));
// Start Telegram bot if configured (independent of WhatsApp)
const hasTelegram = !!TELEGRAM_BOT_TOKEN;
if (hasTelegram) {
await connectTelegram(TELEGRAM_BOT_TOKEN);
// 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_ONLY) {
await connectWhatsApp();
} else {
// Telegram-only mode: start all services that WhatsApp's connection.open normally starts
startSchedulerLoop({
registeredGroups: () => registeredGroups,
getSessions: () => sessions,
queue,
onProcess: (groupJid, proc, containerName, groupFolder) =>
queue.registerProcess(groupJid, proc, containerName, groupFolder),
sendMessage,
assistantName: ASSISTANT_NAME,
});
startIpcWatcher();
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
startMessageLoop();
logger.info(
`NanoClaw running (Telegram-only, trigger: @${ASSISTANT_NAME})`,
);
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();
}
```
Note: When running alongside WhatsApp, the `connection.open` handler in `connectWhatsApp()` already starts the scheduler, IPC watcher, queue, and message loop — no duplication needed.
5. **Update `getAvailableGroups` function** to include Telegram chats. The current filter only shows WhatsApp groups (`@g.us`). Update it to also include `tg:` chats so the agent can discover and register Telegram chats via IPC:
5. **Update `getAvailableGroups`** to include Telegram chats:
```typescript
function getAvailableGroups(): AvailableGroup[] {
export function getAvailableGroups(): AvailableGroup[] {
const chats = getAllChats();
const registeredJids = new Set(Object.keys(registeredGroups));
@@ -481,7 +491,7 @@ function getAvailableGroups(): AvailableGroup[] {
}
```
### Step 5: Update Environment
### Step 4: Update Environment
Add to `.env`:
@@ -500,7 +510,7 @@ cp .env data/env/env
The container reads environment from `data/env/env`, not `.env` directly.
### Step 6: Register a Telegram Chat
### Step 5: Register a Telegram Chat
After installing and starting the bot, tell the user:
@@ -534,7 +544,7 @@ The `RegisteredGroup` type requires a `trigger` string field and has an optional
Alternatively, if the agent is already running in the main group, it can register new groups via IPC using the `register_group` task type.
### Step 7: Build and Restart
### Step 6: Build and Restart
```bash
npm run build
@@ -548,7 +558,7 @@ npm run build
systemctl --user restart nanoclaw
```
### Step 8: Test
### Step 7: Test
Tell the user:
@@ -564,8 +574,8 @@ If user wants Telegram-only:
1. Set `TELEGRAM_ONLY=true` in `.env`
2. Run `cp .env data/env/env` to sync to container
3. The WhatsApp connection code is automatically skipped
4. All services (scheduler, IPC watcher, queue, message loop) start independently
3. The WhatsApp channel is not created — only Telegram
4. All services (scheduler, IPC watcher, queue, message loop) start normally
5. Optionally remove `@whiskeysockets/baileys` dependency (but it's harmless to keep)
## Features
@@ -636,14 +646,11 @@ If they say yes, invoke the `/add-telegram-swarm` skill.
To remove Telegram integration:
1. Delete `src/telegram.ts`
2. Remove Telegram imports from `src/index.ts`
3. Remove `sendTelegramMessage` / `setTelegramTyping` routing from `sendMessage()` and `setTyping()` functions
4. Remove `connectTelegram()` / `stopTelegram()` calls from `main()`
5. Remove `TELEGRAM_ONLY` conditional in `main()`
6. Revert `getAvailableGroups()` filter to only include `@g.us` chats
7. Remove `storeMessageDirect` from `src/db.ts`
8. Remove Telegram config (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_ONLY`) from `src/config.ts`
9. Remove Telegram registrations from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"`
10. Uninstall: `npm uninstall grammy`
11. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
1. Delete `src/channels/telegram.ts`
2. Remove `TelegramChannel` import and creation from `src/index.ts`
3. Remove `channels` array and revert to using `whatsapp` directly in `processGroupMessages`, scheduler deps, and IPC deps
4. Revert `getAvailableGroups()` filter to only include `@g.us` chats
5. Remove Telegram config (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_ONLY`) from `src/config.ts`
6. Remove Telegram registrations from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"`
7. Uninstall: `npm uninstall grammy`
8. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw`

View File

@@ -18,12 +18,14 @@ This skill helps users add capabilities or modify behavior. Use AskUserQuestion
| File | Purpose |
|------|---------|
| `src/index.ts` | Orchestrator: state, message loop, agent invocation |
| `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive |
| `src/ipc.ts` | IPC watcher and task processing |
| `src/router.ts` | Message formatting and outbound routing |
| `src/types.ts` | TypeScript interfaces (includes Channel) |
| `src/config.ts` | Assistant name, trigger pattern, directories |
| `src/index.ts` | Message routing, WhatsApp connection, agent invocation |
| `src/db.ts` | Database initialization and queries |
| `src/types.ts` | TypeScript interfaces |
| `src/whatsapp-auth.ts` | Standalone WhatsApp authentication script |
| `.mcp.json` | MCP server configuration (reference) |
| `groups/CLAUDE.md` | Global memory/persona |
## Common Customization Patterns
@@ -37,10 +39,9 @@ Questions to ask:
- Should messages from this channel go to existing groups or new ones?
Implementation pattern:
1. Find/add MCP server for the channel
2. Add connection and message handling in `src/index.ts`
3. Store messages in the database (update `src/db.ts` if needed)
4. Ensure responses route back to correct channel
1. Create `src/channels/{name}.ts` implementing the `Channel` interface from `src/types.ts` (see `src/channels/whatsapp.ts` for reference)
2. Add the channel instance to `main()` in `src/index.ts` and wire callbacks (`onMessage`, `onChatMetadata`)
3. Messages are stored via the `onMessage` callback; routing is automatic via `ownsJid()`
### Adding a New MCP Integration
@@ -50,9 +51,8 @@ Questions to ask:
- Which groups should have access?
Implementation:
1. Add MCP server to the `mcpServers` config in `src/index.ts`
2. Add tools to `allowedTools` array
3. Document in `groups/CLAUDE.md`
1. Add MCP server config to the container settings (see `src/container-runner.ts` for how MCP servers are mounted)
2. Document available tools in `groups/CLAUDE.md`
### Changing Assistant Behavior
@@ -72,8 +72,8 @@ Questions to ask:
- Does it need new MCP tools?
Implementation:
1. Add command handling in `processMessage()` in `src/index.ts`
2. Check for the command before the trigger pattern check
1. Commands are handled by the agent naturally — add instructions to `groups/CLAUDE.md` or the group's `CLAUDE.md`
2. For trigger-level routing changes, modify `processGroupMessages()` in `src/index.ts`
### Changing Deployment
@@ -102,7 +102,6 @@ User: "Add Telegram as an input channel"
1. Ask: "Should Telegram use the same @Andy trigger, or a different one?"
2. Ask: "Should Telegram messages create separate conversation contexts, or share with WhatsApp groups?"
3. Find Telegram MCP or library
4. Add connection handling in index.ts
5. Update message storage in db.ts
6. Tell user how to authenticate and test
3. Create `src/channels/telegram.ts` implementing the `Channel` interface (see `src/channels/whatsapp.ts`)
4. Add the channel to `main()` in `src/index.ts`
5. Tell user how to authenticate and test

View File

@@ -276,8 +276,8 @@ rm -rf data/sessions/
# Clear sessions for a specific group
rm -rf data/sessions/{groupFolder}/.claude/
# Also clear the session ID from NanoClaw's tracking
echo '{}' > data/sessions.json
# Also clear the session ID from NanoClaw's tracking (stored in SQLite)
sqlite3 store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'"
```
To verify session resumption is working, check the logs for the same session ID across messages:

View File

@@ -118,7 +118,7 @@ Paths relative to project root:
┌─────────────────────────────────────────────────────────────┐
│ Host (macOS) │
│ └── src/index.ts → processTaskIpc() │
│ └── src/ipc.ts → processTaskIpc()
│ └── host.ts → handleXIpc() │
│ └── spawn subprocess → scripts/*.ts │
│ └── Playwright → Chrome → X Website │
@@ -157,9 +157,9 @@ To integrate this skill into NanoClaw, make the following modifications:
---
**1. Host side: `src/index.ts`**
**1. Host side: `src/ipc.ts`**
Add import after other local imports (look for `import { loadJson, saveJson, acquirePidLock } from './utils.js';`):
Add import after other local imports:
```typescript
import { handleXIpc } from '../.claude/skills/x-integration/host.js';
```