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 { checkForNewEmails, sendEmailReply, getContextKey } from './email-channel.js';
import { EMAIL_CHANNEL } from './config.js'; import { EMAIL_CHANNEL } from './config.js';
import { isEmailProcessed, markEmailProcessed, markEmailResponded } from './db.js'; import { isEmailProcessed, markEmailProcessed, markEmailResponded } from './db.js';
```
Then add the `startEmailLoop` function:
```typescript
async function startEmailLoop(): Promise<void> { async function startEmailLoop(): Promise<void> {
if (!EMAIL_CHANNEL.enabled) { if (!EMAIL_CHANNEL.enabled) {
logger.info('Email channel disabled'); 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)); 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 ```typescript
// In the connection === 'open' block, after startMessageLoop(): // In main(), after startMessageLoop():
startEmailLoop(); startEmailLoop();
``` ```
@@ -574,7 +579,7 @@ async function runEmailAgent(
if (output.newSessionId) { if (output.newSessionId) {
sessions[groupFolder] = 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; 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 ### Step 8: Create Email Group Memory

View File

@@ -204,20 +204,11 @@ async (args) => {
### Step 4: Update Host IPC Routing ### 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: 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
await sendMessage(
data.chatJid,
`${ASSISTANT_NAME}: ${data.text}`,
);
```
Replace with:
```typescript ```typescript
if (data.sender && data.chatJid.startsWith('tg:')) { if (data.sender && data.chatJid.startsWith('tg:')) {
@@ -228,16 +219,13 @@ if (data.sender && data.chatJid.startsWith('tg:')) {
sourceGroup, sourceGroup,
); );
} else { } else {
// Telegram bots already show their name — skip prefix for tg: chats await deps.sendMessage(data.chatJid, data.text);
const prefix = data.chatJid.startsWith('tg:') ? '' : `${ASSISTANT_NAME}: `;
await sendMessage(
data.chatJid,
`${prefix}${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 ```typescript
if (TELEGRAM_BOT_POOL.length > 0) { if (TELEGRAM_BOT_POOL.length > 0) {

View File

@@ -79,6 +79,23 @@ Before making changes, ask:
- Main chat: Responds to all (set `requiresTrigger: false`) - Main chat: Responds to all (set `requiresTrigger: false`)
- Other chats: Default requires trigger (`requiresTrigger: true`) - 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 ## Implementation
### Step 1: Update Configuration ### Step 1: Update Configuration
@@ -92,86 +109,44 @@ export const TELEGRAM_ONLY = process.env.TELEGRAM_ONLY === "true";
These should be added near the top with other configuration exports. 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): Create `src/channels/telegram.ts` implementing the `Channel` interface. Use `src/channels/whatsapp.ts` as a reference for the pattern.
```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.
```typescript ```typescript
import { Bot } from "grammy"; import { Bot } from "grammy";
import { import {
ASSISTANT_NAME, ASSISTANT_NAME,
TRIGGER_PATTERN, TRIGGER_PATTERN,
} from "./config.js"; } from "../config.js";
import { import { logger } from "../logger.js";
getAllRegisteredGroups, import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from "../types.js";
storeChatMetadata,
storeMessageDirect,
} from "./db.js";
import { logger } from "./logger.js";
let bot: Bot | null = null; export interface TelegramChannelOpts {
onMessage: OnInboundMessage;
/** Store a placeholder message for non-text content (photos, voice, etc.) */ onChatMetadata: OnChatMetadata;
function storeNonTextMessage(ctx: any, placeholder: string): void { registeredGroups: () => Record<string, RegisteredGroup>;
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 async function connectTelegram(botToken: string): Promise<void> { export class TelegramChannel implements Channel {
bot = new Bot(botToken); 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) // Command to get chat ID (useful for registration)
bot.command("chatid", (ctx) => { this.bot.command("chatid", (ctx) => {
const chatId = ctx.chat.id; const chatId = ctx.chat.id;
const chatType = ctx.chat.type; const chatType = ctx.chat.type;
const chatName = const chatName =
@@ -186,15 +161,15 @@ export async function connectTelegram(botToken: string): Promise<void> {
}); });
// Command to check bot status // Command to check bot status
bot.command("ping", (ctx) => { this.bot.command("ping", (ctx) => {
ctx.reply(`${ASSISTANT_NAME} is online.`); ctx.reply(`${ASSISTANT_NAME} is online.`);
}); });
bot.on("message:text", async (ctx) => { this.bot.on("message:text", async (ctx) => {
// Skip commands // Skip commands
if (ctx.message.text.startsWith("/")) return; if (ctx.message.text.startsWith("/")) return;
const chatId = `tg:${ctx.chat.id}`; const chatJid = `tg:${ctx.chat.id}`;
let content = ctx.message.text; let content = ctx.message.text;
const timestamp = new Date(ctx.message.date * 1000).toISOString(); const timestamp = new Date(ctx.message.date * 1000).toISOString();
const senderName = const senderName =
@@ -209,7 +184,7 @@ export async function connectTelegram(botToken: string): Promise<void> {
const chatName = const chatName =
ctx.chat.type === "private" ctx.chat.type === "private"
? senderName ? senderName
: (ctx.chat as any).title || chatId; : (ctx.chat as any).title || chatJid;
// Translate Telegram @bot_username mentions into TRIGGER_PATTERN format. // Translate Telegram @bot_username mentions into TRIGGER_PATTERN format.
// Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN // Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN
@@ -232,24 +207,22 @@ export async function connectTelegram(botToken: string): Promise<void> {
} }
// Store chat metadata for discovery // Store chat metadata for discovery
storeChatMetadata(chatId, timestamp, chatName); this.opts.onChatMetadata(chatJid, timestamp, chatName);
// Check if this chat is registered
const registeredGroups = getAllRegisteredGroups();
const group = registeredGroups[chatId];
// Only deliver full message for registered groups
const group = this.opts.registeredGroups()[chatJid];
if (!group) { if (!group) {
logger.debug( logger.debug(
{ chatId, chatName }, { chatJid, chatName },
"Message from unregistered Telegram chat", "Message from unregistered Telegram chat",
); );
return; return;
} }
// Store message — startMessageLoop() will pick it up // Deliver message — startMessageLoop() will pick it up
storeMessageDirect({ this.opts.onMessage(chatJid, {
id: msgId, id: msgId,
chat_jid: chatId, chat_jid: chatJid,
sender, sender,
sender_name: senderName, sender_name: senderName,
content, content,
@@ -258,34 +231,57 @@ export async function connectTelegram(botToken: string): Promise<void> {
}); });
logger.info( logger.info(
{ chatId, chatName, sender: senderName }, { chatJid, chatName, sender: senderName },
"Telegram message stored", "Telegram message stored",
); );
}); });
// Handle non-text messages with placeholders so the agent knows something was sent // Handle non-text messages with placeholders so the agent knows something was sent
bot.on("message:photo", (ctx) => storeNonTextMessage(ctx, "[Photo]")); const storeNonText = (ctx: any, placeholder: string) => {
bot.on("message:video", (ctx) => storeNonTextMessage(ctx, "[Video]")); const chatJid = `tg:${ctx.chat.id}`;
bot.on("message:voice", (ctx) => storeNonTextMessage(ctx, "[Voice message]")); const group = this.opts.registeredGroups()[chatJid];
bot.on("message:audio", (ctx) => storeNonTextMessage(ctx, "[Audio]")); if (!group) return;
bot.on("message:document", (ctx) => {
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"; const name = ctx.message.document?.file_name || "file";
storeNonTextMessage(ctx, `[Document: ${name}]`); storeNonText(ctx, `[Document: ${name}]`);
}); });
bot.on("message:sticker", (ctx) => { this.bot.on("message:sticker", (ctx) => {
const emoji = ctx.message.sticker?.emoji || ""; const emoji = ctx.message.sticker?.emoji || "";
storeNonTextMessage(ctx, `[Sticker ${emoji}]`); storeNonText(ctx, `[Sticker ${emoji}]`);
}); });
bot.on("message:location", (ctx) => storeNonTextMessage(ctx, "[Location]")); this.bot.on("message:location", (ctx) => storeNonText(ctx, "[Location]"));
bot.on("message:contact", (ctx) => storeNonTextMessage(ctx, "[Contact]")); this.bot.on("message:contact", (ctx) => storeNonText(ctx, "[Contact]"));
// Handle errors gracefully // Handle errors gracefully
bot.catch((err) => { this.bot.catch((err) => {
logger.error({ err: err.message }, "Telegram bot error"); logger.error({ err: err.message }, "Telegram bot error");
}); });
// Start polling // Start polling — returns a Promise that resolves when started
bot.start({ return new Promise<void>((resolve) => {
this.bot!.start({
onStart: (botInfo) => { onStart: (botInfo) => {
logger.info( logger.info(
{ username: botInfo.username, id: botInfo.id }, { username: botInfo.username, id: botInfo.id },
@@ -295,124 +291,117 @@ export async function connectTelegram(botToken: string): Promise<void> {
console.log( console.log(
` Send /chatid to the bot to get a chat's registration ID\n`, ` Send /chatid to the bot to get a chat's registration ID\n`,
); );
resolve();
}, },
}); });
});
} }
export async function sendTelegramMessage( async sendMessage(jid: string, text: string): Promise<void> {
chatId: string, if (!this.bot) {
text: string,
): Promise<void> {
if (!bot) {
logger.warn("Telegram bot not initialized"); logger.warn("Telegram bot not initialized");
return; return;
} }
try { try {
const numericId = chatId.replace(/^tg:/, ""); const numericId = jid.replace(/^tg:/, "");
// Telegram has a 4096 character limit per message — split if needed // Telegram has a 4096 character limit per message — split if needed
const MAX_LENGTH = 4096; const MAX_LENGTH = 4096;
if (text.length <= MAX_LENGTH) { if (text.length <= MAX_LENGTH) {
await bot.api.sendMessage(numericId, text); await this.bot.api.sendMessage(numericId, text);
} else { } else {
for (let i = 0; i < text.length; i += MAX_LENGTH) { for (let i = 0; i < text.length; i += MAX_LENGTH) {
await bot.api.sendMessage(numericId, text.slice(i, i + MAX_LENGTH)); await this.bot.api.sendMessage(numericId, text.slice(i, i + MAX_LENGTH));
} }
} }
logger.info({ chatId, length: text.length }, "Telegram message sent"); logger.info({ jid, length: text.length }, "Telegram message sent");
} catch (err) { } catch (err) {
logger.error({ chatId, err }, "Failed to send Telegram message"); logger.error({ jid, err }, "Failed to send Telegram message");
} }
} }
export async function setTelegramTyping(chatId: string): Promise<void> { isConnected(): boolean {
if (!bot) return; return this.bot !== null;
try {
const numericId = chatId.replace(/^tg:/, "");
await bot.api.sendChatAction(numericId, "typing");
} catch (err) {
logger.debug({ chatId, err }, "Failed to send Telegram typing indicator");
}
} }
export function isTelegramConnected(): boolean { ownsJid(jid: string): boolean {
return bot !== null; return jid.startsWith("tg:");
} }
export function stopTelegram(): void { async disconnect(): Promise<void> {
if (bot) { if (this.bot) {
bot.stop(); this.bot.stop();
bot = null; this.bot = null;
logger.info("Telegram bot stopped"); 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: Key differences from the old standalone `src/telegram.ts`:
- No `onMessage` callbackmessages are stored to DB and the existing message loop picks them up - Implements `Channel` interfacesame pattern as `WhatsAppChannel`
- Registration check uses `getAllRegisteredGroups()` from `db.ts` directly - Uses `onMessage` / `onChatMetadata` callbacks instead of importing DB functions directly
- Trigger matching is handled by `startMessageLoop()` / `processGroupMessages()`, not the Telegram module - 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: 1. **Add imports** at the top:
```typescript ```typescript
import { import { TelegramChannel } from "./channels/telegram.js";
connectTelegram,
sendTelegramMessage,
setTelegramTyping,
stopTelegram,
} from "./telegram.js";
import { TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY } from "./config.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 ```typescript
async function sendMessage(jid: string, text: string): Promise<void> { let whatsapp: WhatsAppChannel;
// Route Telegram messages directly (no outgoing queue needed) const channels: Channel[] = [];
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');
}
}
``` ```
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 ```typescript
async function setTyping(jid: string, isTyping: boolean): Promise<void> { // Find the channel that owns this JID
if (jid.startsWith("tg:")) { const channel = findChannel(channels, chatJid);
if (isTyping) await setTelegramTyping(jid); if (!channel) return true; // No channel for this JID
return;
} // ... (existing code for message fetching, trigger check, formatting)
try {
await sock.sendPresenceUpdate(isTyping ? 'composing' : 'paused', jid); await channel.setTyping?.(chatJid, true);
} catch (err) { // ... (existing agent invocation, replacing whatsapp.sendMessage with channel.sendMessage)
logger.debug({ jid, err }, 'Failed to update typing status'); 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 ```typescript
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -424,49 +413,70 @@ async function main(): Promise<void> {
// Graceful shutdown handlers // Graceful shutdown handlers
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received'); logger.info({ signal }, 'Shutdown signal received');
stopTelegram();
await queue.shutdown(10000); await queue.shutdown(10000);
for (const ch of channels) await ch.disconnect();
process.exit(0); process.exit(0);
}; };
process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGINT', () => shutdown('SIGINT'));
// Start Telegram bot if configured (independent of WhatsApp) // Channel callbacks (shared by all channels)
const hasTelegram = !!TELEGRAM_BOT_TOKEN; const channelOpts = {
if (hasTelegram) { onMessage: (chatJid: string, msg: NewMessage) => storeMessage(msg),
await connectTelegram(TELEGRAM_BOT_TOKEN); 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) { if (TELEGRAM_BOT_TOKEN) {
await connectWhatsApp(); const telegram = new TelegramChannel(TELEGRAM_BOT_TOKEN, channelOpts);
} else { channels.push(telegram);
// Telegram-only mode: start all services that WhatsApp's connection.open normally starts await telegram.connect();
}
// Start subsystems
startSchedulerLoop({ startSchedulerLoop({
registeredGroups: () => registeredGroups, registeredGroups: () => registeredGroups,
getSessions: () => sessions, getSessions: () => sessions,
queue, queue,
onProcess: (groupJid, proc, containerName, groupFolder) => onProcess: (groupJid, proc, containerName, groupFolder) =>
queue.registerProcess(groupJid, proc, containerName, groupFolder), queue.registerProcess(groupJid, proc, containerName, groupFolder),
sendMessage, sendMessage: async (jid, rawText) => {
assistantName: ASSISTANT_NAME, 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),
}); });
startIpcWatcher();
queue.setProcessMessagesFn(processGroupMessages); queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages(); recoverPendingMessages();
startMessageLoop(); startMessageLoop();
logger.info(
`NanoClaw running (Telegram-only, trigger: @${ASSISTANT_NAME})`,
);
}
} }
``` ```
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`** to include Telegram chats:
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:
```typescript ```typescript
function getAvailableGroups(): AvailableGroup[] { export function getAvailableGroups(): AvailableGroup[] {
const chats = getAllChats(); const chats = getAllChats();
const registeredJids = new Set(Object.keys(registeredGroups)); 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`: Add to `.env`:
@@ -500,7 +510,7 @@ cp .env data/env/env
The container reads environment from `data/env/env`, not `.env` directly. 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: 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. 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 ```bash
npm run build npm run build
@@ -548,7 +558,7 @@ npm run build
systemctl --user restart nanoclaw systemctl --user restart nanoclaw
``` ```
### Step 8: Test ### Step 7: Test
Tell the user: Tell the user:
@@ -564,8 +574,8 @@ If user wants Telegram-only:
1. Set `TELEGRAM_ONLY=true` in `.env` 1. Set `TELEGRAM_ONLY=true` in `.env`
2. Run `cp .env data/env/env` to sync to container 2. Run `cp .env data/env/env` to sync to container
3. The WhatsApp connection code is automatically skipped 3. The WhatsApp channel is not created — only Telegram
4. All services (scheduler, IPC watcher, queue, message loop) start independently 4. All services (scheduler, IPC watcher, queue, message loop) start normally
5. Optionally remove `@whiskeysockets/baileys` dependency (but it's harmless to keep) 5. Optionally remove `@whiskeysockets/baileys` dependency (but it's harmless to keep)
## Features ## Features
@@ -636,14 +646,11 @@ If they say yes, invoke the `/add-telegram-swarm` skill.
To remove Telegram integration: To remove Telegram integration:
1. Delete `src/telegram.ts` 1. Delete `src/channels/telegram.ts`
2. Remove Telegram imports from `src/index.ts` 2. Remove `TelegramChannel` import and creation from `src/index.ts`
3. Remove `sendTelegramMessage` / `setTelegramTyping` routing from `sendMessage()` and `setTyping()` functions 3. Remove `channels` array and revert to using `whatsapp` directly in `processGroupMessages`, scheduler deps, and IPC deps
4. Remove `connectTelegram()` / `stopTelegram()` calls from `main()` 4. Revert `getAvailableGroups()` filter to only include `@g.us` chats
5. Remove `TELEGRAM_ONLY` conditional in `main()` 5. Remove Telegram config (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_ONLY`) from `src/config.ts`
6. Revert `getAvailableGroups()` filter to only include `@g.us` chats 6. Remove Telegram registrations from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"`
7. Remove `storeMessageDirect` from `src/db.ts` 7. Uninstall: `npm uninstall grammy`
8. Remove Telegram config (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_ONLY`) from `src/config.ts` 8. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
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`

View File

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

View File

@@ -276,8 +276,8 @@ rm -rf data/sessions/
# Clear sessions for a specific group # Clear sessions for a specific group
rm -rf data/sessions/{groupFolder}/.claude/ rm -rf data/sessions/{groupFolder}/.claude/
# Also clear the session ID from NanoClaw's tracking # Also clear the session ID from NanoClaw's tracking (stored in SQLite)
echo '{}' > data/sessions.json 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: 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) │ │ Host (macOS) │
│ └── src/index.ts → processTaskIpc() │ │ └── src/ipc.ts → processTaskIpc()
│ └── host.ts → handleXIpc() │ │ └── host.ts → handleXIpc() │
│ └── spawn subprocess → scripts/*.ts │ │ └── spawn subprocess → scripts/*.ts │
│ └── Playwright → Chrome → X Website │ │ └── 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 ```typescript
import { handleXIpc } from '../.claude/skills/x-integration/host.js'; import { handleXIpc } from '../.claude/skills/x-integration/host.js';
``` ```

17
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Test
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx vitest run

View File

@@ -10,7 +10,10 @@ Single Node.js process that connects to WhatsApp, routes messages to Claude Agen
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `src/index.ts` | Main app: WhatsApp connection, message routing, IPC | | `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/config.ts` | Trigger pattern, paths, intervals | | `src/config.ts` | Trigger pattern, paths, intervals |
| `src/container-runner.ts` | Spawns agent containers with mounts | | `src/container-runner.ts` | Spawns agent containers with mounts |
| `src/task-scheduler.ts` | Runs scheduled tasks | | `src/task-scheduler.ts` | Runs scheduled tasks |

View File

@@ -124,7 +124,10 @@ WhatsApp (baileys) --> SQLite --> Polling loop --> Container (Claude Agent SDK)
Single Node.js process. Agents execute in isolated Linux containers with mounted directories. Per-group message queue with concurrency control. IPC via filesystem. Single Node.js process. Agents execute in isolated Linux containers with mounted directories. Per-group message queue with concurrency control. IPC via filesystem.
Key files: Key files:
- `src/index.ts` - Main app: WhatsApp connection, message loop, IPC - `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/group-queue.ts` - Per-group queue with global concurrency limit - `src/group-queue.ts` - Per-group queue with global concurrency limit
- `src/container-runner.ts` - Spawns streaming agent containers - `src/container-runner.ts` - Spawns streaming agent containers
- `src/task-scheduler.ts` - Runs scheduled tasks - `src/task-scheduler.ts` - Runs scheduled tasks

View File

@@ -42,12 +42,16 @@ const server = new McpServer({
server.tool( server.tool(
'send_message', 'send_message',
"Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times. Note: when running as a scheduled task, your final output is NOT sent to the user — use this tool if you need to communicate with the user or group.", "Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times. Note: when running as a scheduled task, your final output is NOT sent to the user — use this tool if you need to communicate with the user or group.",
{ text: z.string().describe('The message text to send') }, {
text: z.string().describe('The message text to send'),
sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'),
},
async (args) => { async (args) => {
const data = { const data: Record<string, string | undefined> = {
type: 'message', type: 'message',
chatJid, chatJid,
text: args.text, text: args.text,
sender: args.sender || undefined,
groupFolder, groupFolder,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };

View File

@@ -98,9 +98,13 @@ nanoclaw/
├── .gitignore ├── .gitignore
├── src/ ├── src/
│ ├── index.ts # Main application (WhatsApp + routing + message loop) │ ├── index.ts # Orchestrator: state, message loop, agent invocation
│ ├── channels/
│ │ └── whatsapp.ts # WhatsApp connection, auth, send/receive
│ ├── ipc.ts # IPC watcher and task processing
│ ├── router.ts # Message formatting and outbound routing
│ ├── config.ts # Configuration constants │ ├── config.ts # Configuration constants
│ ├── types.ts # TypeScript interfaces │ ├── types.ts # TypeScript interfaces (includes Channel)
│ ├── logger.ts # Pino logger setup │ ├── logger.ts # Pino logger setup
│ ├── db.ts # SQLite database initialization and queries │ ├── db.ts # SQLite database initialization and queries
│ ├── group-queue.ts # Per-group queue with global concurrency limit │ ├── group-queue.ts # Per-group queue with global concurrency limit

1183
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,9 @@
"auth": "tsx src/whatsapp-auth.ts", "auth": "tsx src/whatsapp-auth.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\"" "format:check": "prettier --check \"src/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@whiskeysockets/baileys": "^7.0.0-rc.9", "@whiskeysockets/baileys": "^7.0.0-rc.9",
@@ -26,9 +28,11 @@
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/qrcode-terminal": "^0.12.2", "@types/qrcode-terminal": "^0.12.2",
"@vitest/coverage-v8": "^4.0.18",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.7.0" "typescript": "^5.7.0",
"vitest": "^4.0.18"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"

283
src/channels/whatsapp.ts Normal file
View File

@@ -0,0 +1,283 @@
import { exec } from 'child_process';
import fs from 'fs';
import path from 'path';
import makeWASocket, {
DisconnectReason,
WASocket,
makeCacheableSignalKeyStore,
useMultiFileAuthState,
} from '@whiskeysockets/baileys';
import { STORE_DIR } from '../config.js';
import {
getLastGroupSync,
setLastGroupSync,
updateChatName,
} from '../db.js';
import { logger } from '../logger.js';
import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from '../types.js';
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
export interface WhatsAppChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
export class WhatsAppChannel implements Channel {
name = 'whatsapp';
prefixAssistantName = true;
private sock!: WASocket;
private connected = false;
private lidToPhoneMap: Record<string, string> = {};
private outgoingQueue: Array<{ jid: string; text: string }> = [];
private flushing = false;
private groupSyncTimerStarted = false;
private opts: WhatsAppChannelOpts;
constructor(opts: WhatsAppChannelOpts) {
this.opts = opts;
}
async connect(): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.connectInternal(resolve).catch(reject);
});
}
private async connectInternal(onFirstOpen?: () => void): Promise<void> {
const authDir = path.join(STORE_DIR, 'auth');
fs.mkdirSync(authDir, { recursive: true });
const { state, saveCreds } = await useMultiFileAuthState(authDir);
this.sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
printQRInTerminal: false,
logger,
browser: ['NanoClaw', 'Chrome', '1.0.0'],
});
this.sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
const msg =
'WhatsApp authentication required. Run /setup in Claude Code.';
logger.error(msg);
exec(
`osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`,
);
setTimeout(() => process.exit(1), 1000);
}
if (connection === 'close') {
this.connected = false;
const reason = (lastDisconnect?.error as any)?.output?.statusCode;
const shouldReconnect = reason !== DisconnectReason.loggedOut;
logger.info({ reason, shouldReconnect, queuedMessages: this.outgoingQueue.length }, 'Connection closed');
if (shouldReconnect) {
logger.info('Reconnecting...');
this.connectInternal().catch((err) => {
logger.error({ err }, 'Failed to reconnect, retrying in 5s');
setTimeout(() => {
this.connectInternal().catch((err2) => {
logger.error({ err: err2 }, 'Reconnection retry failed');
});
}, 5000);
});
} else {
logger.info('Logged out. Run /setup to re-authenticate.');
process.exit(0);
}
} else if (connection === 'open') {
this.connected = true;
logger.info('Connected to WhatsApp');
// Build LID to phone mapping from auth state for self-chat translation
if (this.sock.user) {
const phoneUser = this.sock.user.id.split(':')[0];
const lidUser = this.sock.user.lid?.split(':')[0];
if (lidUser && phoneUser) {
this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`;
logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set');
}
}
// Flush any messages queued while disconnected
this.flushOutgoingQueue().catch((err) =>
logger.error({ err }, 'Failed to flush outgoing queue'),
);
// Sync group metadata on startup (respects 24h cache)
this.syncGroupMetadata().catch((err) =>
logger.error({ err }, 'Initial group sync failed'),
);
// Set up daily sync timer (only once)
if (!this.groupSyncTimerStarted) {
this.groupSyncTimerStarted = true;
setInterval(() => {
this.syncGroupMetadata().catch((err) =>
logger.error({ err }, 'Periodic group sync failed'),
);
}, GROUP_SYNC_INTERVAL_MS);
}
// Signal first connection to caller
if (onFirstOpen) {
onFirstOpen();
onFirstOpen = undefined;
}
}
});
this.sock.ev.on('creds.update', saveCreds);
this.sock.ev.on('messages.upsert', ({ messages }) => {
for (const msg of messages) {
if (!msg.message) continue;
const rawJid = msg.key.remoteJid;
if (!rawJid || rawJid === 'status@broadcast') continue;
// Translate LID JID to phone JID if applicable
const chatJid = this.translateJid(rawJid);
const timestamp = new Date(
Number(msg.messageTimestamp) * 1000,
).toISOString();
// Always notify about chat metadata for group discovery
this.opts.onChatMetadata(chatJid, timestamp);
// Only deliver full message for registered groups
const groups = this.opts.registeredGroups();
if (groups[chatJid]) {
const content =
msg.message?.conversation ||
msg.message?.extendedTextMessage?.text ||
msg.message?.imageMessage?.caption ||
msg.message?.videoMessage?.caption ||
'';
const sender = msg.key.participant || msg.key.remoteJid || '';
const senderName = msg.pushName || sender.split('@')[0];
this.opts.onMessage(chatJid, {
id: msg.key.id || '',
chat_jid: chatJid,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: msg.key.fromMe || false,
});
}
}
});
}
async sendMessage(jid: string, text: string): Promise<void> {
if (!this.connected) {
this.outgoingQueue.push({ jid, text });
logger.info({ jid, length: text.length, queueSize: this.outgoingQueue.length }, 'WA disconnected, message queued');
return;
}
try {
await this.sock.sendMessage(jid, { text });
logger.info({ jid, length: text.length }, 'Message sent');
} catch (err) {
// If send fails, queue it for retry on reconnect
this.outgoingQueue.push({ jid, text });
logger.warn({ jid, err, queueSize: this.outgoingQueue.length }, 'Failed to send, message queued');
}
}
isConnected(): boolean {
return this.connected;
}
ownsJid(jid: string): boolean {
return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net');
}
async disconnect(): Promise<void> {
this.connected = false;
this.sock?.end(undefined);
}
async setTyping(jid: string, isTyping: boolean): Promise<void> {
try {
await this.sock.sendPresenceUpdate(isTyping ? 'composing' : 'paused', jid);
} catch (err) {
logger.debug({ jid, err }, 'Failed to update typing status');
}
}
/**
* Sync group metadata from WhatsApp.
* Fetches all participating groups and stores their names in the database.
* Called on startup, daily, and on-demand via IPC.
*/
async syncGroupMetadata(force = false): Promise<void> {
if (!force) {
const lastSync = getLastGroupSync();
if (lastSync) {
const lastSyncTime = new Date(lastSync).getTime();
if (Date.now() - lastSyncTime < GROUP_SYNC_INTERVAL_MS) {
logger.debug({ lastSync }, 'Skipping group sync - synced recently');
return;
}
}
}
try {
logger.info('Syncing group metadata from WhatsApp...');
const groups = await this.sock.groupFetchAllParticipating();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
updateChatName(jid, metadata.subject);
count++;
}
}
setLastGroupSync();
logger.info({ count }, 'Group metadata synced');
} catch (err) {
logger.error({ err }, 'Failed to sync group metadata');
}
}
private translateJid(jid: string): string {
if (!jid.endsWith('@lid')) return jid;
const lidUser = jid.split('@')[0].split(':')[0];
const phoneJid = this.lidToPhoneMap[lidUser];
if (phoneJid) {
logger.debug({ lidJid: jid, phoneJid }, 'Translated LID to phone JID');
return phoneJid;
}
return jid;
}
private async flushOutgoingQueue(): Promise<void> {
if (this.flushing || this.outgoingQueue.length === 0) return;
this.flushing = true;
try {
logger.info({ count: this.outgoingQueue.length }, 'Flushing outgoing message queue');
while (this.outgoingQueue.length > 0) {
const item = this.outgoingQueue.shift()!;
await this.sendMessage(item.jid, item.text);
}
} finally {
this.flushing = false;
}
}
}

View File

@@ -1,6 +1,12 @@
import path from 'path'; import path from 'path';
export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || 'Andy'; export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || 'Andy';
export const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || '';
export const TELEGRAM_ONLY = process.env.TELEGRAM_ONLY === 'true';
export const TELEGRAM_BOT_POOL = (process.env.TELEGRAM_BOT_POOL || '')
.split(',')
.map((t) => t.trim())
.filter(Boolean);
export const POLL_INTERVAL = 2000; export const POLL_INTERVAL = 2000;
export const SCHEDULER_POLL_INTERVAL = 60000; export const SCHEDULER_POLL_INTERVAL = 60000;

315
src/db.test.ts Normal file
View File

@@ -0,0 +1,315 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
_initTestDatabase,
createTask,
deleteTask,
getAllChats,
getMessagesSince,
getNewMessages,
getTaskById,
storeChatMetadata,
storeMessage,
updateTask,
} from './db.js';
beforeEach(() => {
_initTestDatabase();
});
// Helper to store a message using the normalized NewMessage interface
function store(overrides: {
id: string;
chat_jid: string;
sender: string;
sender_name: string;
content: string;
timestamp: string;
is_from_me?: boolean;
}) {
storeMessage({
id: overrides.id,
chat_jid: overrides.chat_jid,
sender: overrides.sender,
sender_name: overrides.sender_name,
content: overrides.content,
timestamp: overrides.timestamp,
is_from_me: overrides.is_from_me ?? false,
});
}
// --- storeMessage (NewMessage format) ---
describe('storeMessage', () => {
it('stores a message and retrieves it', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
store({
id: 'msg-1',
chat_jid: 'group@g.us',
sender: '123@s.whatsapp.net',
sender_name: 'Alice',
content: 'hello world',
timestamp: '2024-01-01T00:00:01.000Z',
});
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'BotName');
expect(messages).toHaveLength(1);
expect(messages[0].id).toBe('msg-1');
expect(messages[0].sender).toBe('123@s.whatsapp.net');
expect(messages[0].sender_name).toBe('Alice');
expect(messages[0].content).toBe('hello world');
});
it('stores empty content', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
store({
id: 'msg-2',
chat_jid: 'group@g.us',
sender: '111@s.whatsapp.net',
sender_name: 'Dave',
content: '',
timestamp: '2024-01-01T00:00:04.000Z',
});
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'BotName');
expect(messages).toHaveLength(1);
expect(messages[0].content).toBe('');
});
it('stores is_from_me flag', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
store({
id: 'msg-3',
chat_jid: 'group@g.us',
sender: 'me@s.whatsapp.net',
sender_name: 'Me',
content: 'my message',
timestamp: '2024-01-01T00:00:05.000Z',
is_from_me: true,
});
// Message is stored (we can retrieve it — is_from_me doesn't affect retrieval)
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'BotName');
expect(messages).toHaveLength(1);
});
it('upserts on duplicate id+chat_jid', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
store({
id: 'msg-dup',
chat_jid: 'group@g.us',
sender: '123@s.whatsapp.net',
sender_name: 'Alice',
content: 'original',
timestamp: '2024-01-01T00:00:01.000Z',
});
store({
id: 'msg-dup',
chat_jid: 'group@g.us',
sender: '123@s.whatsapp.net',
sender_name: 'Alice',
content: 'updated',
timestamp: '2024-01-01T00:00:01.000Z',
});
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'BotName');
expect(messages).toHaveLength(1);
expect(messages[0].content).toBe('updated');
});
});
// --- getMessagesSince ---
describe('getMessagesSince', () => {
beforeEach(() => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
const msgs = [
{ id: 'm1', content: 'first', ts: '2024-01-01T00:00:01.000Z', sender: 'Alice' },
{ id: 'm2', content: 'second', ts: '2024-01-01T00:00:02.000Z', sender: 'Bob' },
{ id: 'm3', content: 'Andy: bot reply', ts: '2024-01-01T00:00:03.000Z', sender: 'Bot' },
{ id: 'm4', content: 'third', ts: '2024-01-01T00:00:04.000Z', sender: 'Carol' },
];
for (const m of msgs) {
store({
id: m.id,
chat_jid: 'group@g.us',
sender: `${m.sender}@s.whatsapp.net`,
sender_name: m.sender,
content: m.content,
timestamp: m.ts,
});
}
});
it('returns messages after the given timestamp', () => {
const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:02.000Z', 'Andy');
// Should exclude m1, m2 (before/at timestamp), m3 (bot message)
expect(msgs).toHaveLength(1);
expect(msgs[0].content).toBe('third');
});
it('excludes messages from the assistant (content prefix)', () => {
const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy');
const botMsgs = msgs.filter((m) => m.content.startsWith('Andy:'));
expect(botMsgs).toHaveLength(0);
});
it('returns all messages when sinceTimestamp is empty', () => {
const msgs = getMessagesSince('group@g.us', '', 'Andy');
// 3 user messages (bot message excluded)
expect(msgs).toHaveLength(3);
});
});
// --- getNewMessages ---
describe('getNewMessages', () => {
beforeEach(() => {
storeChatMetadata('group1@g.us', '2024-01-01T00:00:00.000Z');
storeChatMetadata('group2@g.us', '2024-01-01T00:00:00.000Z');
const msgs = [
{ id: 'a1', chat: 'group1@g.us', content: 'g1 msg1', ts: '2024-01-01T00:00:01.000Z' },
{ id: 'a2', chat: 'group2@g.us', content: 'g2 msg1', ts: '2024-01-01T00:00:02.000Z' },
{ id: 'a3', chat: 'group1@g.us', content: 'Andy: reply', ts: '2024-01-01T00:00:03.000Z' },
{ id: 'a4', chat: 'group1@g.us', content: 'g1 msg2', ts: '2024-01-01T00:00:04.000Z' },
];
for (const m of msgs) {
store({
id: m.id,
chat_jid: m.chat,
sender: 'user@s.whatsapp.net',
sender_name: 'User',
content: m.content,
timestamp: m.ts,
});
}
});
it('returns new messages across multiple groups', () => {
const { messages, newTimestamp } = getNewMessages(
['group1@g.us', 'group2@g.us'],
'2024-01-01T00:00:00.000Z',
'Andy',
);
// Excludes 'Andy: reply', returns 3 messages
expect(messages).toHaveLength(3);
expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z');
});
it('filters by timestamp', () => {
const { messages } = getNewMessages(
['group1@g.us', 'group2@g.us'],
'2024-01-01T00:00:02.000Z',
'Andy',
);
// Only g1 msg2 (after ts, not bot)
expect(messages).toHaveLength(1);
expect(messages[0].content).toBe('g1 msg2');
});
it('returns empty for no registered groups', () => {
const { messages, newTimestamp } = getNewMessages([], '', 'Andy');
expect(messages).toHaveLength(0);
expect(newTimestamp).toBe('');
});
});
// --- storeChatMetadata ---
describe('storeChatMetadata', () => {
it('stores chat with JID as default name', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
const chats = getAllChats();
expect(chats).toHaveLength(1);
expect(chats[0].jid).toBe('group@g.us');
expect(chats[0].name).toBe('group@g.us');
});
it('stores chat with explicit name', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z', 'My Group');
const chats = getAllChats();
expect(chats[0].name).toBe('My Group');
});
it('updates name on subsequent call with name', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Updated Name');
const chats = getAllChats();
expect(chats).toHaveLength(1);
expect(chats[0].name).toBe('Updated Name');
});
it('preserves newer timestamp on conflict', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:05.000Z');
storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z');
const chats = getAllChats();
expect(chats[0].last_message_time).toBe('2024-01-01T00:00:05.000Z');
});
});
// --- Task CRUD ---
describe('task CRUD', () => {
it('creates and retrieves a task', () => {
createTask({
id: 'task-1',
group_folder: 'main',
chat_jid: 'group@g.us',
prompt: 'do something',
schedule_type: 'once',
schedule_value: '2024-06-01T00:00:00.000Z',
context_mode: 'isolated',
next_run: '2024-06-01T00:00:00.000Z',
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
const task = getTaskById('task-1');
expect(task).toBeDefined();
expect(task!.prompt).toBe('do something');
expect(task!.status).toBe('active');
});
it('updates task status', () => {
createTask({
id: 'task-2',
group_folder: 'main',
chat_jid: 'group@g.us',
prompt: 'test',
schedule_type: 'once',
schedule_value: '2024-06-01T00:00:00.000Z',
context_mode: 'isolated',
next_run: null,
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
updateTask('task-2', { status: 'paused' });
expect(getTaskById('task-2')!.status).toBe('paused');
});
it('deletes a task and its run logs', () => {
createTask({
id: 'task-3',
group_folder: 'main',
chat_jid: 'group@g.us',
prompt: 'delete me',
schedule_type: 'once',
schedule_value: '2024-06-01T00:00:00.000Z',
context_mode: 'isolated',
next_run: null,
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
deleteTask('task-3');
expect(getTaskById('task-3')).toBeUndefined();
});
});

121
src/db.ts
View File

@@ -2,19 +2,13 @@ import Database from 'better-sqlite3';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { proto } from '@whiskeysockets/baileys';
import { DATA_DIR, STORE_DIR } from './config.js'; import { DATA_DIR, STORE_DIR } from './config.js';
import { NewMessage, RegisteredGroup, ScheduledTask, TaskRunLog } from './types.js'; import { NewMessage, RegisteredGroup, ScheduledTask, TaskRunLog } from './types.js';
let db: Database.Database; let db: Database.Database;
export function initDatabase(): void { function createSchema(database: Database.Database): void {
const dbPath = path.join(STORE_DIR, 'messages.db'); database.exec(`
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS chats ( CREATE TABLE IF NOT EXISTS chats (
jid TEXT PRIMARY KEY, jid TEXT PRIMARY KEY,
name TEXT, name TEXT,
@@ -60,35 +54,7 @@ export function initDatabase(): void {
FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id) FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id)
); );
CREATE INDEX IF NOT EXISTS idx_task_run_logs ON task_run_logs(task_id, run_at); CREATE INDEX IF NOT EXISTS idx_task_run_logs ON task_run_logs(task_id, run_at);
`);
// Add sender_name column if it doesn't exist (migration for existing DBs)
try {
db.exec(`ALTER TABLE messages ADD COLUMN sender_name TEXT`);
} catch {
/* column already exists */
}
// Add context_mode column if it doesn't exist (migration for existing DBs)
try {
db.exec(
`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`,
);
} catch {
/* column already exists */
}
// Add requires_trigger column if it doesn't exist (migration for existing DBs)
try {
db.exec(
`ALTER TABLE registered_groups ADD COLUMN requires_trigger INTEGER DEFAULT 1`,
);
} catch {
/* column already exists */
}
// State tables (replacing JSON files)
db.exec(`
CREATE TABLE IF NOT EXISTS router_state ( CREATE TABLE IF NOT EXISTS router_state (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
@@ -108,10 +74,33 @@ export function initDatabase(): void {
); );
`); `);
// Add context_mode column if it doesn't exist (migration for existing DBs)
try {
database.exec(
`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`,
);
} catch {
/* column already exists */
}
}
export function initDatabase(): void {
const dbPath = path.join(STORE_DIR, 'messages.db');
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
db = new Database(dbPath);
createSchema(db);
// Migrate from JSON files if they exist // Migrate from JSON files if they exist
migrateJsonState(); migrateJsonState();
} }
/** @internal - for tests only. Creates a fresh in-memory database. */
export function _initTestDatabase(): void {
db = new Database(':memory:');
createSchema(db);
}
/** /**
* Store chat metadata only (no message content). * Store chat metadata only (no message content).
* Used for all chats to enable group discovery without storing sensitive content. * Used for all chats to enable group discovery without storing sensitive content.
@@ -203,36 +192,42 @@ export function setLastGroupSync(): void {
* Store a message with full content. * Store a message with full content.
* Only call this for registered groups where message history is needed. * Only call this for registered groups where message history is needed.
*/ */
export function storeMessage( export function storeMessage(msg: NewMessage): void {
msg: proto.IWebMessageInfo,
chatJid: string,
isFromMe: boolean,
pushName?: string,
): void {
if (!msg.key) return;
const content =
msg.message?.conversation ||
msg.message?.extendedTextMessage?.text ||
msg.message?.imageMessage?.caption ||
msg.message?.videoMessage?.caption ||
'';
const timestamp = new Date(Number(msg.messageTimestamp) * 1000).toISOString();
const sender = msg.key.participant || msg.key.remoteJid || '';
const senderName = pushName || sender.split('@')[0];
const msgId = msg.key.id || '';
db.prepare( db.prepare(
`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me) VALUES (?, ?, ?, ?, ?, ?, ?)`, `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me) VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run( ).run(
msgId, msg.id,
chatJid, msg.chat_jid,
sender, msg.sender,
senderName, msg.sender_name,
content, msg.content,
timestamp, msg.timestamp,
isFromMe ? 1 : 0, msg.is_from_me ? 1 : 0,
);
}
/**
* 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,
); );
} }

246
src/formatting.test.ts Normal file
View File

@@ -0,0 +1,246 @@
import { describe, it, expect } from 'vitest';
import { ASSISTANT_NAME, TRIGGER_PATTERN } from './config.js';
import {
escapeXml,
formatMessages,
formatOutbound,
stripInternalTags,
} from './router.js';
import { Channel, NewMessage } from './types.js';
function makeMsg(overrides: Partial<NewMessage> = {}): NewMessage {
return {
id: '1',
chat_jid: 'group@g.us',
sender: '123@s.whatsapp.net',
sender_name: 'Alice',
content: 'hello',
timestamp: '2024-01-01T00:00:00.000Z',
...overrides,
};
}
// --- escapeXml ---
describe('escapeXml', () => {
it('escapes ampersands', () => {
expect(escapeXml('a & b')).toBe('a &amp; b');
});
it('escapes less-than', () => {
expect(escapeXml('a < b')).toBe('a &lt; b');
});
it('escapes greater-than', () => {
expect(escapeXml('a > b')).toBe('a &gt; b');
});
it('escapes double quotes', () => {
expect(escapeXml('"hello"')).toBe('&quot;hello&quot;');
});
it('handles multiple special characters together', () => {
expect(escapeXml('a & b < c > d "e"')).toBe(
'a &amp; b &lt; c &gt; d &quot;e&quot;',
);
});
it('passes through strings with no special chars', () => {
expect(escapeXml('hello world')).toBe('hello world');
});
it('handles empty string', () => {
expect(escapeXml('')).toBe('');
});
});
// --- formatMessages ---
describe('formatMessages', () => {
it('formats a single message as XML', () => {
const result = formatMessages([makeMsg()]);
expect(result).toBe(
'<messages>\n' +
'<message sender="Alice" time="2024-01-01T00:00:00.000Z">hello</message>\n' +
'</messages>',
);
});
it('formats multiple messages', () => {
const msgs = [
makeMsg({ id: '1', sender_name: 'Alice', content: 'hi', timestamp: 't1' }),
makeMsg({ id: '2', sender_name: 'Bob', content: 'hey', timestamp: 't2' }),
];
const result = formatMessages(msgs);
expect(result).toContain('sender="Alice"');
expect(result).toContain('sender="Bob"');
expect(result).toContain('>hi</message>');
expect(result).toContain('>hey</message>');
});
it('escapes special characters in sender names', () => {
const result = formatMessages([makeMsg({ sender_name: 'A & B <Co>' })]);
expect(result).toContain('sender="A &amp; B &lt;Co&gt;"');
});
it('escapes special characters in content', () => {
const result = formatMessages([
makeMsg({ content: '<script>alert("xss")</script>' }),
]);
expect(result).toContain(
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;',
);
});
it('handles empty array', () => {
const result = formatMessages([]);
expect(result).toBe('<messages>\n\n</messages>');
});
});
// --- TRIGGER_PATTERN ---
describe('TRIGGER_PATTERN', () => {
it('matches @Andy at start of message', () => {
expect(TRIGGER_PATTERN.test('@Andy hello')).toBe(true);
});
it('matches case-insensitively', () => {
expect(TRIGGER_PATTERN.test('@andy hello')).toBe(true);
expect(TRIGGER_PATTERN.test('@ANDY hello')).toBe(true);
});
it('does not match when not at start of message', () => {
expect(TRIGGER_PATTERN.test('hello @Andy')).toBe(false);
});
it('does not match partial name like @Andrew (word boundary)', () => {
expect(TRIGGER_PATTERN.test('@Andrew hello')).toBe(false);
});
it('matches with word boundary before apostrophe', () => {
expect(TRIGGER_PATTERN.test("@Andy's thing")).toBe(true);
});
it('matches @Andy alone (end of string is a word boundary)', () => {
expect(TRIGGER_PATTERN.test('@Andy')).toBe(true);
});
it('matches with leading whitespace after trim', () => {
// The actual usage trims before testing: TRIGGER_PATTERN.test(m.content.trim())
expect(TRIGGER_PATTERN.test('@Andy hey'.trim())).toBe(true);
});
});
// --- Outbound formatting (internal tag stripping + prefix) ---
describe('stripInternalTags', () => {
it('strips single-line internal tags', () => {
expect(stripInternalTags('hello <internal>secret</internal> world')).toBe(
'hello world',
);
});
it('strips multi-line internal tags', () => {
expect(
stripInternalTags('hello <internal>\nsecret\nstuff\n</internal> world'),
).toBe('hello world');
});
it('strips multiple internal tag blocks', () => {
expect(
stripInternalTags(
'<internal>a</internal>hello<internal>b</internal>',
),
).toBe('hello');
});
it('returns empty string when text is only internal tags', () => {
expect(stripInternalTags('<internal>only this</internal>')).toBe('');
});
});
describe('formatOutbound', () => {
const waChannel = { prefixAssistantName: true } as Channel;
const noPrefixChannel = { prefixAssistantName: false } as Channel;
const defaultChannel = {} as Channel;
it('prefixes with assistant name when channel wants it', () => {
expect(formatOutbound(waChannel, 'hello world')).toBe(
`${ASSISTANT_NAME}: hello world`,
);
});
it('does not prefix when channel opts out', () => {
expect(formatOutbound(noPrefixChannel, 'hello world')).toBe('hello world');
});
it('defaults to prefixing when prefixAssistantName is undefined', () => {
expect(formatOutbound(defaultChannel, 'hello world')).toBe(
`${ASSISTANT_NAME}: hello world`,
);
});
it('returns empty string when all text is internal', () => {
expect(formatOutbound(waChannel, '<internal>hidden</internal>')).toBe('');
});
it('strips internal tags and prefixes remaining text', () => {
expect(
formatOutbound(waChannel, '<internal>thinking</internal>The answer is 42'),
).toBe(`${ASSISTANT_NAME}: The answer is 42`);
});
});
// --- Trigger gating with requiresTrigger flag ---
describe('trigger gating (requiresTrigger interaction)', () => {
// Replicates the exact logic from processGroupMessages and startMessageLoop:
// if (!isMainGroup && group.requiresTrigger !== false) { check trigger }
function shouldRequireTrigger(
isMainGroup: boolean,
requiresTrigger: boolean | undefined,
): boolean {
return !isMainGroup && requiresTrigger !== false;
}
function shouldProcess(
isMainGroup: boolean,
requiresTrigger: boolean | undefined,
messages: NewMessage[],
): boolean {
if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true;
return messages.some((m) => TRIGGER_PATTERN.test(m.content.trim()));
}
it('main group always processes (no trigger needed)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(true, undefined, msgs)).toBe(true);
});
it('main group processes even with requiresTrigger=true', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(true, true, msgs)).toBe(true);
});
it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, undefined, msgs)).toBe(false);
});
it('non-main group with requiresTrigger=true requires trigger', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, true, msgs)).toBe(false);
});
it('non-main group with requiresTrigger=true processes when trigger present', () => {
const msgs = [makeMsg({ content: '@Andy do something' })];
expect(shouldProcess(false, true, msgs)).toBe(true);
});
it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, false, msgs)).toBe(true);
});
});

245
src/group-queue.test.ts Normal file
View File

@@ -0,0 +1,245 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { GroupQueue } from './group-queue.js';
// Mock config to control concurrency limit
vi.mock('./config.js', () => ({
DATA_DIR: '/tmp/nanoclaw-test-data',
MAX_CONCURRENT_CONTAINERS: 2,
}));
// Mock fs operations used by sendMessage/closeStdin
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
default: {
...actual,
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
renameSync: vi.fn(),
},
};
});
describe('GroupQueue', () => {
let queue: GroupQueue;
beforeEach(() => {
vi.useFakeTimers();
queue = new GroupQueue();
});
afterEach(() => {
vi.useRealTimers();
});
// --- Single group at a time ---
it('only runs one container per group at a time', async () => {
let concurrentCount = 0;
let maxConcurrent = 0;
const processMessages = vi.fn(async (groupJid: string) => {
concurrentCount++;
maxConcurrent = Math.max(maxConcurrent, concurrentCount);
// Simulate async work
await new Promise((resolve) => setTimeout(resolve, 100));
concurrentCount--;
return true;
});
queue.setProcessMessagesFn(processMessages);
// Enqueue two messages for the same group
queue.enqueueMessageCheck('group1@g.us');
queue.enqueueMessageCheck('group1@g.us');
// Advance timers to let the first process complete
await vi.advanceTimersByTimeAsync(200);
// Second enqueue should have been queued, not concurrent
expect(maxConcurrent).toBe(1);
});
// --- Global concurrency limit ---
it('respects global concurrency limit', async () => {
let activeCount = 0;
let maxActive = 0;
const completionCallbacks: Array<() => void> = [];
const processMessages = vi.fn(async (groupJid: string) => {
activeCount++;
maxActive = Math.max(maxActive, activeCount);
await new Promise<void>((resolve) => completionCallbacks.push(resolve));
activeCount--;
return true;
});
queue.setProcessMessagesFn(processMessages);
// Enqueue 3 groups (limit is 2)
queue.enqueueMessageCheck('group1@g.us');
queue.enqueueMessageCheck('group2@g.us');
queue.enqueueMessageCheck('group3@g.us');
// Let promises settle
await vi.advanceTimersByTimeAsync(10);
// Only 2 should be active (MAX_CONCURRENT_CONTAINERS = 2)
expect(maxActive).toBe(2);
expect(activeCount).toBe(2);
// Complete one — third should start
completionCallbacks[0]();
await vi.advanceTimersByTimeAsync(10);
expect(processMessages).toHaveBeenCalledTimes(3);
});
// --- Tasks prioritized over messages ---
it('drains tasks before messages for same group', async () => {
const executionOrder: string[] = [];
let resolveFirst: () => void;
const processMessages = vi.fn(async (groupJid: string) => {
if (executionOrder.length === 0) {
// First call: block until we release it
await new Promise<void>((resolve) => {
resolveFirst = resolve;
});
}
executionOrder.push('messages');
return true;
});
queue.setProcessMessagesFn(processMessages);
// Start processing messages (takes the active slot)
queue.enqueueMessageCheck('group1@g.us');
await vi.advanceTimersByTimeAsync(10);
// While active, enqueue both a task and pending messages
const taskFn = vi.fn(async () => {
executionOrder.push('task');
});
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
queue.enqueueMessageCheck('group1@g.us');
// Release the first processing
resolveFirst!();
await vi.advanceTimersByTimeAsync(10);
// Task should have run before the second message check
expect(executionOrder[0]).toBe('messages'); // first call
expect(executionOrder[1]).toBe('task'); // task runs first in drain
// Messages would run after task completes
});
// --- Retry with backoff on failure ---
it('retries with exponential backoff on failure', async () => {
let callCount = 0;
const processMessages = vi.fn(async () => {
callCount++;
return false; // failure
});
queue.setProcessMessagesFn(processMessages);
queue.enqueueMessageCheck('group1@g.us');
// First call happens immediately
await vi.advanceTimersByTimeAsync(10);
expect(callCount).toBe(1);
// First retry after 5000ms (BASE_RETRY_MS * 2^0)
await vi.advanceTimersByTimeAsync(5000);
await vi.advanceTimersByTimeAsync(10);
expect(callCount).toBe(2);
// Second retry after 10000ms (BASE_RETRY_MS * 2^1)
await vi.advanceTimersByTimeAsync(10000);
await vi.advanceTimersByTimeAsync(10);
expect(callCount).toBe(3);
});
// --- Shutdown prevents new enqueues ---
it('prevents new enqueues after shutdown', async () => {
const processMessages = vi.fn(async () => true);
queue.setProcessMessagesFn(processMessages);
await queue.shutdown(1000);
queue.enqueueMessageCheck('group1@g.us');
await vi.advanceTimersByTimeAsync(100);
expect(processMessages).not.toHaveBeenCalled();
});
// --- Max retries exceeded ---
it('stops retrying after MAX_RETRIES and resets', async () => {
let callCount = 0;
const processMessages = vi.fn(async () => {
callCount++;
return false; // always fail
});
queue.setProcessMessagesFn(processMessages);
queue.enqueueMessageCheck('group1@g.us');
// Run through all 5 retries (MAX_RETRIES = 5)
// Initial call
await vi.advanceTimersByTimeAsync(10);
expect(callCount).toBe(1);
// Retry 1: 5000ms, Retry 2: 10000ms, Retry 3: 20000ms, Retry 4: 40000ms, Retry 5: 80000ms
const retryDelays = [5000, 10000, 20000, 40000, 80000];
for (let i = 0; i < retryDelays.length; i++) {
await vi.advanceTimersByTimeAsync(retryDelays[i] + 10);
expect(callCount).toBe(i + 2);
}
// After 5 retries (6 total calls), should stop — no more retries
const countAfterMaxRetries = callCount;
await vi.advanceTimersByTimeAsync(200000); // Wait a long time
expect(callCount).toBe(countAfterMaxRetries);
});
// --- Waiting groups get drained when slots free up ---
it('drains waiting groups when active slots free up', async () => {
const processed: string[] = [];
const completionCallbacks: Array<() => void> = [];
const processMessages = vi.fn(async (groupJid: string) => {
processed.push(groupJid);
await new Promise<void>((resolve) => completionCallbacks.push(resolve));
return true;
});
queue.setProcessMessagesFn(processMessages);
// Fill both slots
queue.enqueueMessageCheck('group1@g.us');
queue.enqueueMessageCheck('group2@g.us');
await vi.advanceTimersByTimeAsync(10);
// Queue a third
queue.enqueueMessageCheck('group3@g.us');
await vi.advanceTimersByTimeAsync(10);
expect(processed).toEqual(['group1@g.us', 'group2@g.us']);
// Free up a slot
completionCallbacks[0]();
await vi.advanceTimersByTimeAsync(10);
expect(processed).toContain('group3@g.us');
});
});

View File

@@ -1,104 +1,57 @@
import { exec, execSync } from 'child_process'; import { execSync } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import makeWASocket, {
DisconnectReason,
WASocket,
makeCacheableSignalKeyStore,
useMultiFileAuthState,
} from '@whiskeysockets/baileys';
import { CronExpressionParser } from 'cron-parser';
import { import {
ASSISTANT_NAME, ASSISTANT_NAME,
DATA_DIR, DATA_DIR,
IDLE_TIMEOUT, IDLE_TIMEOUT,
IPC_POLL_INTERVAL,
MAIN_GROUP_FOLDER, MAIN_GROUP_FOLDER,
POLL_INTERVAL, POLL_INTERVAL,
STORE_DIR,
TIMEZONE,
TRIGGER_PATTERN, TRIGGER_PATTERN,
} from './config.js'; } from './config.js';
import { WhatsAppChannel } from './channels/whatsapp.js';
import { import {
AvailableGroup,
ContainerOutput, ContainerOutput,
runContainerAgent, runContainerAgent,
writeGroupsSnapshot, writeGroupsSnapshot,
writeTasksSnapshot, writeTasksSnapshot,
} from './container-runner.js'; } from './container-runner.js';
import { import {
createTask,
deleteTask,
getAllChats, getAllChats,
getAllRegisteredGroups, getAllRegisteredGroups,
getAllSessions, getAllSessions,
getAllTasks, getAllTasks,
getLastGroupSync,
getMessagesSince, getMessagesSince,
getNewMessages, getNewMessages,
getRouterState, getRouterState,
getTaskById,
initDatabase, initDatabase,
setLastGroupSync,
setRegisteredGroup, setRegisteredGroup,
setRouterState, setRouterState,
setSession, setSession,
storeChatMetadata, storeChatMetadata,
storeMessage, storeMessage,
updateChatName,
updateTask,
} from './db.js'; } from './db.js';
import { GroupQueue } from './group-queue.js'; import { GroupQueue } from './group-queue.js';
import { startIpcWatcher } from './ipc.js';
import { formatMessages, formatOutbound } from './router.js';
import { startSchedulerLoop } from './task-scheduler.js'; import { startSchedulerLoop } from './task-scheduler.js';
import { NewMessage, RegisteredGroup } from './types.js'; import { NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours // Re-export for backwards compatibility during refactor
export { escapeXml, formatMessages } from './router.js';
let sock: WASocket;
let lastTimestamp = ''; let lastTimestamp = '';
let sessions: Record<string, string> = {}; let sessions: Record<string, string> = {};
let registeredGroups: Record<string, RegisteredGroup> = {}; let registeredGroups: Record<string, RegisteredGroup> = {};
let lastAgentTimestamp: Record<string, string> = {}; let lastAgentTimestamp: Record<string, string> = {};
// LID to phone number mapping (WhatsApp now sends LID JIDs for self-chats)
let lidToPhoneMap: Record<string, string> = {};
// Guards to prevent duplicate loops on WhatsApp reconnect
let messageLoopRunning = false; let messageLoopRunning = false;
let ipcWatcherRunning = false;
let groupSyncTimerStarted = false;
// WhatsApp connection state and outgoing message queue
let waConnected = false;
const outgoingQueue: Array<{ jid: string; text: string }> = [];
let whatsapp: WhatsAppChannel;
const queue = new GroupQueue(); const queue = new GroupQueue();
/**
* Translate a JID from LID format to phone format if we have a mapping.
* Returns the original JID if no mapping exists.
*/
function translateJid(jid: string): string {
if (!jid.endsWith('@lid')) return jid;
const lidUser = jid.split('@')[0].split(':')[0];
const phoneJid = lidToPhoneMap[lidUser];
if (phoneJid) {
logger.debug({ lidJid: jid, phoneJid }, 'Translated LID to phone JID');
return phoneJid;
}
return jid;
}
async function setTyping(jid: string, isTyping: boolean): Promise<void> {
try {
await sock.sendPresenceUpdate(isTyping ? 'composing' : 'paused', jid);
} catch (err) {
logger.debug({ jid, err }, 'Failed to update typing status');
}
}
function loadState(): void { function loadState(): void {
// Load from SQLite (migration from JSON happens in initDatabase)
lastTimestamp = getRouterState('last_timestamp') || ''; lastTimestamp = getRouterState('last_timestamp') || '';
const agentTs = getRouterState('last_agent_timestamp'); const agentTs = getRouterState('last_agent_timestamp');
try { try {
@@ -137,49 +90,11 @@ function registerGroup(jid: string, group: RegisteredGroup): void {
); );
} }
/**
* Sync group metadata from WhatsApp.
* Fetches all participating groups and stores their names in the database.
* Called on startup, daily, and on-demand via IPC.
*/
async function syncGroupMetadata(force = false): Promise<void> {
// Check if we need to sync (skip if synced recently, unless forced)
if (!force) {
const lastSync = getLastGroupSync();
if (lastSync) {
const lastSyncTime = new Date(lastSync).getTime();
const now = Date.now();
if (now - lastSyncTime < GROUP_SYNC_INTERVAL_MS) {
logger.debug({ lastSync }, 'Skipping group sync - synced recently');
return;
}
}
}
try {
logger.info('Syncing group metadata from WhatsApp...');
const groups = await sock.groupFetchAllParticipating();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
updateChatName(jid, metadata.subject);
count++;
}
}
setLastGroupSync();
logger.info({ count }, 'Group metadata synced');
} catch (err) {
logger.error({ err }, 'Failed to sync group metadata');
}
}
/** /**
* Get available groups list for the agent. * Get available groups list for the agent.
* Returns groups ordered by most recent activity. * Returns groups ordered by most recent activity.
*/ */
function getAvailableGroups(): AvailableGroup[] { export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
const chats = getAllChats(); const chats = getAllChats();
const registeredJids = new Set(Object.keys(registeredGroups)); const registeredJids = new Set(Object.keys(registeredGroups));
@@ -193,28 +108,14 @@ function getAvailableGroups(): AvailableGroup[] {
})); }));
} }
function escapeXml(s: string): string { /** @internal - exported for testing */
return s export function _setRegisteredGroups(groups: Record<string, RegisteredGroup>): void {
.replace(/&/g, '&amp;') registeredGroups = groups;
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function formatMessages(messages: NewMessage[]): string {
const lines = messages.map((m) =>
`<message sender="${escapeXml(m.sender_name)}" time="${m.timestamp}">${escapeXml(m.content)}</message>`,
);
return `<messages>\n${lines.join('\n')}\n</messages>`;
} }
/** /**
* Process all pending messages for a group. * Process all pending messages for a group.
* Called by the GroupQueue when it's this group's turn. * Called by the GroupQueue when it's this group's turn.
*
* Uses streaming output: agent results are sent to WhatsApp as they arrive.
* The container stays alive for IDLE_TIMEOUT after each result, allowing
* rapid-fire messages to be piped in without spawning a new container.
*/ */
async function processGroupMessages(chatJid: string): Promise<boolean> { async function processGroupMessages(chatJid: string): Promise<boolean> {
const group = registeredGroups[chatJid]; const group = registeredGroups[chatJid];
@@ -222,7 +123,6 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
const isMainGroup = group.folder === MAIN_GROUP_FOLDER; const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
// Get all messages since last agent interaction
const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
const missedMessages = getMessagesSince( const missedMessages = getMessagesSince(
chatJid, chatJid,
@@ -265,7 +165,7 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
}, IDLE_TIMEOUT); }, IDLE_TIMEOUT);
}; };
await setTyping(chatJid, true); await whatsapp.setTyping(chatJid, true);
let hadError = false; let hadError = false;
const output = await runAgent(group, prompt, chatJid, async (result) => { const output = await runAgent(group, prompt, chatJid, async (result) => {
@@ -276,7 +176,7 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
const text = raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim(); const text = raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`); logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`);
if (text) { if (text) {
await sendMessage(chatJid, `${ASSISTANT_NAME}: ${text}`); await whatsapp.sendMessage(chatJid, `${ASSISTANT_NAME}: ${text}`);
} }
// Only reset idle timer on actual results, not session-update markers (result: null) // Only reset idle timer on actual results, not session-update markers (result: null)
resetIdleTimer(); resetIdleTimer();
@@ -287,7 +187,7 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
} }
}); });
await setTyping(chatJid, false); await whatsapp.setTyping(chatJid, false);
if (idleTimer) clearTimeout(idleTimer); if (idleTimer) clearTimeout(idleTimer);
if (output === 'error' || hadError) { if (output === 'error' || hadError) {
@@ -380,511 +280,6 @@ async function runAgent(
} }
} }
async function sendMessage(jid: string, text: string): Promise<void> {
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) {
// If send fails, queue it for retry on reconnect
outgoingQueue.push({ jid, text });
logger.warn({ jid, err, queueSize: outgoingQueue.length }, 'Failed to send, message queued');
}
}
let flushing = false;
async function flushOutgoingQueue(): Promise<void> {
if (flushing || outgoingQueue.length === 0) return;
flushing = true;
try {
logger.info({ count: outgoingQueue.length }, 'Flushing outgoing message queue');
// Process one at a time — sendMessage re-queues on failure internally.
// Shift instead of splice so unattempted messages stay in the queue
// if an unexpected error occurs.
while (outgoingQueue.length > 0) {
const item = outgoingQueue.shift()!;
await sendMessage(item.jid, item.text);
}
} finally {
flushing = false;
}
}
function startIpcWatcher(): void {
if (ipcWatcherRunning) {
logger.debug('IPC watcher already running, skipping duplicate start');
return;
}
ipcWatcherRunning = true;
const ipcBaseDir = path.join(DATA_DIR, 'ipc');
fs.mkdirSync(ipcBaseDir, { recursive: true });
const processIpcFiles = async () => {
// Scan all group IPC directories (identity determined by directory)
let groupFolders: string[];
try {
groupFolders = fs.readdirSync(ipcBaseDir).filter((f) => {
const stat = fs.statSync(path.join(ipcBaseDir, f));
return stat.isDirectory() && f !== 'errors';
});
} catch (err) {
logger.error({ err }, 'Error reading IPC base directory');
setTimeout(processIpcFiles, IPC_POLL_INTERVAL);
return;
}
for (const sourceGroup of groupFolders) {
const isMain = sourceGroup === MAIN_GROUP_FOLDER;
const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages');
const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks');
// Process messages from this group's IPC directory
try {
if (fs.existsSync(messagesDir)) {
const messageFiles = fs
.readdirSync(messagesDir)
.filter((f) => f.endsWith('.json'));
for (const file of messageFiles) {
const filePath = path.join(messagesDir, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
if (data.type === 'message' && data.chatJid && data.text) {
// Authorization: verify this group can send to this chatJid
const targetGroup = registeredGroups[data.chatJid];
if (
isMain ||
(targetGroup && targetGroup.folder === sourceGroup)
) {
await sendMessage(
data.chatJid,
`${ASSISTANT_NAME}: ${data.text}`,
);
logger.info(
{ chatJid: data.chatJid, sourceGroup },
'IPC message sent',
);
} else {
logger.warn(
{ chatJid: data.chatJid, sourceGroup },
'Unauthorized IPC message attempt blocked',
);
}
}
fs.unlinkSync(filePath);
} catch (err) {
logger.error(
{ file, sourceGroup, err },
'Error processing IPC message',
);
const errorDir = path.join(ipcBaseDir, 'errors');
fs.mkdirSync(errorDir, { recursive: true });
fs.renameSync(
filePath,
path.join(errorDir, `${sourceGroup}-${file}`),
);
}
}
}
} catch (err) {
logger.error(
{ err, sourceGroup },
'Error reading IPC messages directory',
);
}
// Process tasks from this group's IPC directory
try {
if (fs.existsSync(tasksDir)) {
const taskFiles = fs
.readdirSync(tasksDir)
.filter((f) => f.endsWith('.json'));
for (const file of taskFiles) {
const filePath = path.join(tasksDir, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
// Pass source group identity to processTaskIpc for authorization
await processTaskIpc(data, sourceGroup, isMain);
fs.unlinkSync(filePath);
} catch (err) {
logger.error(
{ file, sourceGroup, err },
'Error processing IPC task',
);
const errorDir = path.join(ipcBaseDir, 'errors');
fs.mkdirSync(errorDir, { recursive: true });
fs.renameSync(
filePath,
path.join(errorDir, `${sourceGroup}-${file}`),
);
}
}
}
} catch (err) {
logger.error({ err, sourceGroup }, 'Error reading IPC tasks directory');
}
}
setTimeout(processIpcFiles, IPC_POLL_INTERVAL);
};
processIpcFiles();
logger.info('IPC watcher started (per-group namespaces)');
}
async function processTaskIpc(
data: {
type: string;
taskId?: string;
prompt?: string;
schedule_type?: string;
schedule_value?: string;
context_mode?: string;
groupFolder?: string;
chatJid?: string;
targetJid?: string;
// For register_group
jid?: string;
name?: string;
folder?: string;
trigger?: string;
containerConfig?: RegisteredGroup['containerConfig'];
},
sourceGroup: string, // Verified identity from IPC directory
isMain: boolean, // Verified from directory path
): Promise<void> {
switch (data.type) {
case 'schedule_task':
if (
data.prompt &&
data.schedule_type &&
data.schedule_value &&
data.targetJid
) {
// Resolve the target group from JID
const targetJid = data.targetJid as string;
const targetGroupEntry = registeredGroups[targetJid];
if (!targetGroupEntry) {
logger.warn(
{ targetJid },
'Cannot schedule task: target group not registered',
);
break;
}
const targetFolder = targetGroupEntry.folder;
// Authorization: non-main groups can only schedule for themselves
if (!isMain && targetFolder !== sourceGroup) {
logger.warn(
{ sourceGroup, targetFolder },
'Unauthorized schedule_task attempt blocked',
);
break;
}
const scheduleType = data.schedule_type as 'cron' | 'interval' | 'once';
let nextRun: string | null = null;
if (scheduleType === 'cron') {
try {
const interval = CronExpressionParser.parse(data.schedule_value, {
tz: TIMEZONE,
});
nextRun = interval.next().toISOString();
} catch {
logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid cron expression',
);
break;
}
} else if (scheduleType === 'interval') {
const ms = parseInt(data.schedule_value, 10);
if (isNaN(ms) || ms <= 0) {
logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid interval',
);
break;
}
nextRun = new Date(Date.now() + ms).toISOString();
} else if (scheduleType === 'once') {
const scheduled = new Date(data.schedule_value);
if (isNaN(scheduled.getTime())) {
logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid timestamp',
);
break;
}
nextRun = scheduled.toISOString();
}
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const contextMode =
data.context_mode === 'group' || data.context_mode === 'isolated'
? data.context_mode
: 'isolated';
createTask({
id: taskId,
group_folder: targetFolder,
chat_jid: targetJid,
prompt: data.prompt,
schedule_type: scheduleType,
schedule_value: data.schedule_value,
context_mode: contextMode,
next_run: nextRun,
status: 'active',
created_at: new Date().toISOString(),
});
logger.info(
{ taskId, sourceGroup, targetFolder, contextMode },
'Task created via IPC',
);
}
break;
case 'pause_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) {
updateTask(data.taskId, { status: 'paused' });
logger.info(
{ taskId: data.taskId, sourceGroup },
'Task paused via IPC',
);
} else {
logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task pause attempt',
);
}
}
break;
case 'resume_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) {
updateTask(data.taskId, { status: 'active' });
logger.info(
{ taskId: data.taskId, sourceGroup },
'Task resumed via IPC',
);
} else {
logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task resume attempt',
);
}
}
break;
case 'cancel_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) {
deleteTask(data.taskId);
logger.info(
{ taskId: data.taskId, sourceGroup },
'Task cancelled via IPC',
);
} else {
logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task cancel attempt',
);
}
}
break;
case 'refresh_groups':
// Only main group can request a refresh
if (isMain) {
logger.info(
{ sourceGroup },
'Group metadata refresh requested via IPC',
);
await syncGroupMetadata(true);
// Write updated snapshot immediately
const availableGroups = getAvailableGroups();
writeGroupsSnapshot(
sourceGroup,
true,
availableGroups,
new Set(Object.keys(registeredGroups)),
);
} else {
logger.warn(
{ sourceGroup },
'Unauthorized refresh_groups attempt blocked',
);
}
break;
case 'register_group':
// Only main group can register new groups
if (!isMain) {
logger.warn(
{ sourceGroup },
'Unauthorized register_group attempt blocked',
);
break;
}
if (data.jid && data.name && data.folder && data.trigger) {
registerGroup(data.jid, {
name: data.name,
folder: data.folder,
trigger: data.trigger,
added_at: new Date().toISOString(),
containerConfig: data.containerConfig,
});
} else {
logger.warn(
{ data },
'Invalid register_group request - missing required fields',
);
}
break;
default:
logger.warn({ type: data.type }, 'Unknown IPC task type');
}
}
async function connectWhatsApp(): Promise<void> {
const authDir = path.join(STORE_DIR, 'auth');
fs.mkdirSync(authDir, { recursive: true });
const { state, saveCreds } = await useMultiFileAuthState(authDir);
sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
printQRInTerminal: false,
logger,
browser: ['NanoClaw', 'Chrome', '1.0.0'],
});
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
const msg =
'WhatsApp authentication required. Run /setup in Claude Code.';
logger.error(msg);
exec(
`osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`,
);
setTimeout(() => process.exit(1), 1000);
}
if (connection === 'close') {
waConnected = false;
const reason = (lastDisconnect?.error as any)?.output?.statusCode;
const shouldReconnect = reason !== DisconnectReason.loggedOut;
logger.info({ reason, shouldReconnect, queuedMessages: outgoingQueue.length }, 'Connection closed');
if (shouldReconnect) {
logger.info('Reconnecting...');
connectWhatsApp().catch((err) => {
logger.error({ err }, 'Failed to reconnect, retrying in 5s');
setTimeout(() => {
connectWhatsApp().catch((err2) => {
logger.error({ err: err2 }, 'Reconnection retry failed');
});
}, 5000);
});
} else {
logger.info('Logged out. Run /setup to re-authenticate.');
process.exit(0);
}
} else if (connection === 'open') {
waConnected = true;
logger.info('Connected to WhatsApp');
// Build LID to phone mapping from auth state for self-chat translation
if (sock.user) {
const phoneUser = sock.user.id.split(':')[0];
const lidUser = sock.user.lid?.split(':')[0];
if (lidUser && phoneUser) {
lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`;
logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set');
}
}
// Flush any messages queued while disconnected
flushOutgoingQueue().catch((err) =>
logger.error({ err }, 'Failed to flush outgoing queue'),
);
// Sync group metadata on startup (respects 24h cache)
syncGroupMetadata().catch((err) =>
logger.error({ err }, 'Initial group sync failed'),
);
// Set up daily sync timer (only once)
if (!groupSyncTimerStarted) {
groupSyncTimerStarted = true;
setInterval(() => {
syncGroupMetadata().catch((err) =>
logger.error({ err }, 'Periodic group sync failed'),
);
}, GROUP_SYNC_INTERVAL_MS);
}
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();
}
});
sock.ev.on('creds.update', saveCreds);
sock.ev.on('messages.upsert', ({ messages }) => {
for (const msg of messages) {
if (!msg.message) continue;
const rawJid = msg.key.remoteJid;
if (!rawJid || rawJid === 'status@broadcast') continue;
// Translate LID JID to phone JID if applicable
const chatJid = translateJid(rawJid);
const timestamp = new Date(
Number(msg.messageTimestamp) * 1000,
).toISOString();
// Always store chat metadata for group discovery
storeChatMetadata(chatJid, timestamp);
// Only store full message content for registered groups
if (registeredGroups[chatJid]) {
storeMessage(
msg,
chatJid,
msg.key.fromMe || false,
msg.pushName || undefined,
);
}
}
});
}
async function startMessageLoop(): Promise<void> { async function startMessageLoop(): Promise<void> {
if (messageLoopRunning) { if (messageLoopRunning) {
logger.debug('Message loop already running, skipping duplicate start'); logger.debug('Message loop already running, skipping duplicate start');
@@ -1060,15 +455,54 @@ async function main(): Promise<void> {
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received'); logger.info({ signal }, 'Shutdown signal received');
await queue.shutdown(10000); await queue.shutdown(10000);
await whatsapp.disconnect();
process.exit(0); process.exit(0);
}; };
process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGINT', () => shutdown('SIGINT'));
await connectWhatsApp(); // Create WhatsApp channel
whatsapp = new WhatsAppChannel({
onMessage: (chatJid, msg) => storeMessage(msg),
onChatMetadata: (chatJid, timestamp) => storeChatMetadata(chatJid, timestamp),
registeredGroups: () => registeredGroups,
});
// Connect — resolves when first connected
await whatsapp.connect();
// Start subsystems (independently of connection handler)
startSchedulerLoop({
registeredGroups: () => registeredGroups,
getSessions: () => sessions,
queue,
onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder),
sendMessage: async (jid, rawText) => {
const text = formatOutbound(whatsapp, rawText);
if (text) await whatsapp.sendMessage(jid, text);
},
});
startIpcWatcher({
sendMessage: (jid, text) => whatsapp.sendMessage(jid, text),
registeredGroups: () => registeredGroups,
registerGroup,
syncGroupMetadata: (force) => whatsapp.syncGroupMetadata(force),
getAvailableGroups,
writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
});
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
startMessageLoop();
} }
// Guard: only run when executed directly, not when imported by tests
const isDirectRun =
process.argv[1] &&
new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname;
if (isDirectRun) {
main().catch((err) => { main().catch((err) => {
logger.error({ err }, 'Failed to start NanoClaw'); logger.error({ err }, 'Failed to start NanoClaw');
process.exit(1); process.exit(1);
}); });
}

594
src/ipc-auth.test.ts Normal file
View File

@@ -0,0 +1,594 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
_initTestDatabase,
createTask,
getAllTasks,
getRegisteredGroup,
getTaskById,
setRegisteredGroup,
} from './db.js';
import { processTaskIpc, IpcDeps } from './ipc.js';
import { RegisteredGroup } from './types.js';
// Set up registered groups used across tests
const MAIN_GROUP: RegisteredGroup = {
name: 'Main',
folder: 'main',
trigger: 'always',
added_at: '2024-01-01T00:00:00.000Z',
};
const OTHER_GROUP: RegisteredGroup = {
name: 'Other',
folder: 'other-group',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
};
const THIRD_GROUP: RegisteredGroup = {
name: 'Third',
folder: 'third-group',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
};
let groups: Record<string, RegisteredGroup>;
let deps: IpcDeps;
beforeEach(() => {
_initTestDatabase();
groups = {
'main@g.us': MAIN_GROUP,
'other@g.us': OTHER_GROUP,
'third@g.us': THIRD_GROUP,
};
// Populate DB as well
setRegisteredGroup('main@g.us', MAIN_GROUP);
setRegisteredGroup('other@g.us', OTHER_GROUP);
setRegisteredGroup('third@g.us', THIRD_GROUP);
deps = {
sendMessage: async () => {},
registeredGroups: () => groups,
registerGroup: (jid, group) => {
groups[jid] = group;
setRegisteredGroup(jid, group);
// Mock the fs.mkdirSync that registerGroup does
},
syncGroupMetadata: async () => {},
getAvailableGroups: () => [],
writeGroupsSnapshot: () => {},
};
});
// --- schedule_task authorization ---
describe('schedule_task authorization', () => {
it('main group can schedule for another group', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'do something',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z',
targetJid: 'other@g.us',
},
'main',
true,
deps,
);
// Verify task was created in DB for the other group
const allTasks = getAllTasks();
expect(allTasks.length).toBe(1);
expect(allTasks[0].group_folder).toBe('other-group');
});
it('non-main group can schedule for itself', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'self task',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z',
targetJid: 'other@g.us',
},
'other-group',
false,
deps,
);
const allTasks = getAllTasks();
expect(allTasks.length).toBe(1);
expect(allTasks[0].group_folder).toBe('other-group');
});
it('non-main group cannot schedule for another group', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'unauthorized',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z',
targetJid: 'main@g.us',
},
'other-group',
false,
deps,
);
const allTasks = getAllTasks();
expect(allTasks.length).toBe(0);
});
it('rejects schedule_task for unregistered target JID', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'no target',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z',
targetJid: 'unknown@g.us',
},
'main',
true,
deps,
);
const allTasks = getAllTasks();
expect(allTasks.length).toBe(0);
});
});
// --- pause_task authorization ---
describe('pause_task authorization', () => {
beforeEach(() => {
createTask({
id: 'task-main',
group_folder: 'main',
chat_jid: 'main@g.us',
prompt: 'main task',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z',
context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z',
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
createTask({
id: 'task-other',
group_folder: 'other-group',
chat_jid: 'other@g.us',
prompt: 'other task',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z',
context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z',
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
});
it('main group can pause any task', async () => {
await processTaskIpc({ type: 'pause_task', taskId: 'task-other' }, 'main', true, deps);
expect(getTaskById('task-other')!.status).toBe('paused');
});
it('non-main group can pause its own task', async () => {
await processTaskIpc({ type: 'pause_task', taskId: 'task-other' }, 'other-group', false, deps);
expect(getTaskById('task-other')!.status).toBe('paused');
});
it('non-main group cannot pause another groups task', async () => {
await processTaskIpc({ type: 'pause_task', taskId: 'task-main' }, 'other-group', false, deps);
expect(getTaskById('task-main')!.status).toBe('active');
});
});
// --- resume_task authorization ---
describe('resume_task authorization', () => {
beforeEach(() => {
createTask({
id: 'task-paused',
group_folder: 'other-group',
chat_jid: 'other@g.us',
prompt: 'paused task',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z',
context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z',
status: 'paused',
created_at: '2024-01-01T00:00:00.000Z',
});
});
it('main group can resume any task', async () => {
await processTaskIpc({ type: 'resume_task', taskId: 'task-paused' }, 'main', true, deps);
expect(getTaskById('task-paused')!.status).toBe('active');
});
it('non-main group can resume its own task', async () => {
await processTaskIpc({ type: 'resume_task', taskId: 'task-paused' }, 'other-group', false, deps);
expect(getTaskById('task-paused')!.status).toBe('active');
});
it('non-main group cannot resume another groups task', async () => {
await processTaskIpc({ type: 'resume_task', taskId: 'task-paused' }, 'third-group', false, deps);
expect(getTaskById('task-paused')!.status).toBe('paused');
});
});
// --- cancel_task authorization ---
describe('cancel_task authorization', () => {
it('main group can cancel any task', async () => {
createTask({
id: 'task-to-cancel',
group_folder: 'other-group',
chat_jid: 'other@g.us',
prompt: 'cancel me',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z',
context_mode: 'isolated',
next_run: null,
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
await processTaskIpc({ type: 'cancel_task', taskId: 'task-to-cancel' }, 'main', true, deps);
expect(getTaskById('task-to-cancel')).toBeUndefined();
});
it('non-main group can cancel its own task', async () => {
createTask({
id: 'task-own',
group_folder: 'other-group',
chat_jid: 'other@g.us',
prompt: 'my task',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z',
context_mode: 'isolated',
next_run: null,
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
await processTaskIpc({ type: 'cancel_task', taskId: 'task-own' }, 'other-group', false, deps);
expect(getTaskById('task-own')).toBeUndefined();
});
it('non-main group cannot cancel another groups task', async () => {
createTask({
id: 'task-foreign',
group_folder: 'main',
chat_jid: 'main@g.us',
prompt: 'not yours',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z',
context_mode: 'isolated',
next_run: null,
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
await processTaskIpc({ type: 'cancel_task', taskId: 'task-foreign' }, 'other-group', false, deps);
expect(getTaskById('task-foreign')).toBeDefined();
});
});
// --- register_group authorization ---
describe('register_group authorization', () => {
it('non-main group cannot register a group', async () => {
await processTaskIpc(
{
type: 'register_group',
jid: 'new@g.us',
name: 'New Group',
folder: 'new-group',
trigger: '@Andy',
},
'other-group',
false,
deps,
);
// registeredGroups should not have changed
expect(groups['new@g.us']).toBeUndefined();
});
});
// --- refresh_groups authorization ---
describe('refresh_groups authorization', () => {
it('non-main group cannot trigger refresh', async () => {
// This should be silently blocked (no crash, no effect)
await processTaskIpc({ type: 'refresh_groups' }, 'other-group', false, deps);
// If we got here without error, the auth gate worked
});
});
// --- IPC message authorization ---
// Tests the authorization pattern from startIpcWatcher (ipc.ts).
// The logic: isMain || (targetGroup && targetGroup.folder === sourceGroup)
describe('IPC message authorization', () => {
// Replicate the exact check from the IPC watcher
function isMessageAuthorized(
sourceGroup: string,
isMain: boolean,
targetChatJid: string,
registeredGroups: Record<string, RegisteredGroup>,
): boolean {
const targetGroup = registeredGroups[targetChatJid];
return isMain || (!!targetGroup && targetGroup.folder === sourceGroup);
}
it('main group can send to any group', () => {
expect(isMessageAuthorized('main', true, 'other@g.us', groups)).toBe(true);
expect(isMessageAuthorized('main', true, 'third@g.us', groups)).toBe(true);
});
it('non-main group can send to its own chat', () => {
expect(isMessageAuthorized('other-group', false, 'other@g.us', groups)).toBe(true);
});
it('non-main group cannot send to another groups chat', () => {
expect(isMessageAuthorized('other-group', false, 'main@g.us', groups)).toBe(false);
expect(isMessageAuthorized('other-group', false, 'third@g.us', groups)).toBe(false);
});
it('non-main group cannot send to unregistered JID', () => {
expect(isMessageAuthorized('other-group', false, 'unknown@g.us', groups)).toBe(false);
});
it('main group can send to unregistered JID', () => {
// Main is always authorized regardless of target
expect(isMessageAuthorized('main', true, 'unknown@g.us', groups)).toBe(true);
});
});
// --- schedule_task with cron and interval types ---
describe('schedule_task schedule types', () => {
it('creates task with cron schedule and computes next_run', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'cron task',
schedule_type: 'cron',
schedule_value: '0 9 * * *', // every day at 9am
targetJid: 'other@g.us',
},
'main',
true,
deps,
);
const tasks = getAllTasks();
expect(tasks).toHaveLength(1);
expect(tasks[0].schedule_type).toBe('cron');
expect(tasks[0].next_run).toBeTruthy();
// next_run should be a valid ISO date in the future
expect(new Date(tasks[0].next_run!).getTime()).toBeGreaterThan(Date.now() - 60000);
});
it('rejects invalid cron expression', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'bad cron',
schedule_type: 'cron',
schedule_value: 'not a cron',
targetJid: 'other@g.us',
},
'main',
true,
deps,
);
expect(getAllTasks()).toHaveLength(0);
});
it('creates task with interval schedule', async () => {
const before = Date.now();
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'interval task',
schedule_type: 'interval',
schedule_value: '3600000', // 1 hour
targetJid: 'other@g.us',
},
'main',
true,
deps,
);
const tasks = getAllTasks();
expect(tasks).toHaveLength(1);
expect(tasks[0].schedule_type).toBe('interval');
// next_run should be ~1 hour from now
const nextRun = new Date(tasks[0].next_run!).getTime();
expect(nextRun).toBeGreaterThanOrEqual(before + 3600000 - 1000);
expect(nextRun).toBeLessThanOrEqual(Date.now() + 3600000 + 1000);
});
it('rejects invalid interval (non-numeric)', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'bad interval',
schedule_type: 'interval',
schedule_value: 'abc',
targetJid: 'other@g.us',
},
'main',
true,
deps,
);
expect(getAllTasks()).toHaveLength(0);
});
it('rejects invalid interval (zero)', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'zero interval',
schedule_type: 'interval',
schedule_value: '0',
targetJid: 'other@g.us',
},
'main',
true,
deps,
);
expect(getAllTasks()).toHaveLength(0);
});
it('rejects invalid once timestamp', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'bad once',
schedule_type: 'once',
schedule_value: 'not-a-date',
targetJid: 'other@g.us',
},
'main',
true,
deps,
);
expect(getAllTasks()).toHaveLength(0);
});
});
// --- context_mode defaulting ---
describe('schedule_task context_mode', () => {
it('accepts context_mode=group', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'group context',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z',
context_mode: 'group',
targetJid: 'other@g.us',
},
'main',
true,
deps,
);
const tasks = getAllTasks();
expect(tasks[0].context_mode).toBe('group');
});
it('accepts context_mode=isolated', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'isolated context',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z',
context_mode: 'isolated',
targetJid: 'other@g.us',
},
'main',
true,
deps,
);
const tasks = getAllTasks();
expect(tasks[0].context_mode).toBe('isolated');
});
it('defaults invalid context_mode to isolated', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'bad context',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z',
context_mode: 'bogus' as any,
targetJid: 'other@g.us',
},
'main',
true,
deps,
);
const tasks = getAllTasks();
expect(tasks[0].context_mode).toBe('isolated');
});
it('defaults missing context_mode to isolated', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'no context mode',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z',
targetJid: 'other@g.us',
},
'main',
true,
deps,
);
const tasks = getAllTasks();
expect(tasks[0].context_mode).toBe('isolated');
});
});
// --- register_group success path ---
describe('register_group success', () => {
it('main group can register a new group', async () => {
await processTaskIpc(
{
type: 'register_group',
jid: 'new@g.us',
name: 'New Group',
folder: 'new-group',
trigger: '@Andy',
},
'main',
true,
deps,
);
// Verify group was registered in DB
const group = getRegisteredGroup('new@g.us');
expect(group).toBeDefined();
expect(group!.name).toBe('New Group');
expect(group!.folder).toBe('new-group');
expect(group!.trigger).toBe('@Andy');
});
it('register_group rejects request with missing fields', async () => {
await processTaskIpc(
{
type: 'register_group',
jid: 'partial@g.us',
name: 'Partial',
// missing folder and trigger
},
'main',
true,
deps,
);
expect(getRegisteredGroup('partial@g.us')).toBeUndefined();
});
});

381
src/ipc.ts Normal file
View File

@@ -0,0 +1,381 @@
import fs from 'fs';
import path from 'path';
import { CronExpressionParser } from 'cron-parser';
import {
ASSISTANT_NAME,
DATA_DIR,
IPC_POLL_INTERVAL,
MAIN_GROUP_FOLDER,
TIMEZONE,
} from './config.js';
import { AvailableGroup } from './container-runner.js';
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
import { logger } from './logger.js';
import { RegisteredGroup } from './types.js';
export interface IpcDeps {
sendMessage: (jid: string, text: string) => Promise<void>;
registeredGroups: () => Record<string, RegisteredGroup>;
registerGroup: (jid: string, group: RegisteredGroup) => void;
syncGroupMetadata: (force: boolean) => Promise<void>;
getAvailableGroups: () => AvailableGroup[];
writeGroupsSnapshot: (
groupFolder: string,
isMain: boolean,
availableGroups: AvailableGroup[],
registeredJids: Set<string>,
) => void;
}
let ipcWatcherRunning = false;
export function startIpcWatcher(deps: IpcDeps): void {
if (ipcWatcherRunning) {
logger.debug('IPC watcher already running, skipping duplicate start');
return;
}
ipcWatcherRunning = true;
const ipcBaseDir = path.join(DATA_DIR, 'ipc');
fs.mkdirSync(ipcBaseDir, { recursive: true });
const processIpcFiles = async () => {
// Scan all group IPC directories (identity determined by directory)
let groupFolders: string[];
try {
groupFolders = fs.readdirSync(ipcBaseDir).filter((f) => {
const stat = fs.statSync(path.join(ipcBaseDir, f));
return stat.isDirectory() && f !== 'errors';
});
} catch (err) {
logger.error({ err }, 'Error reading IPC base directory');
setTimeout(processIpcFiles, IPC_POLL_INTERVAL);
return;
}
const registeredGroups = deps.registeredGroups();
for (const sourceGroup of groupFolders) {
const isMain = sourceGroup === MAIN_GROUP_FOLDER;
const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages');
const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks');
// Process messages from this group's IPC directory
try {
if (fs.existsSync(messagesDir)) {
const messageFiles = fs
.readdirSync(messagesDir)
.filter((f) => f.endsWith('.json'));
for (const file of messageFiles) {
const filePath = path.join(messagesDir, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
if (data.type === 'message' && data.chatJid && data.text) {
// Authorization: verify this group can send to this chatJid
const targetGroup = registeredGroups[data.chatJid];
if (
isMain ||
(targetGroup && targetGroup.folder === sourceGroup)
) {
await deps.sendMessage(
data.chatJid,
`${ASSISTANT_NAME}: ${data.text}`,
);
logger.info(
{ chatJid: data.chatJid, sourceGroup },
'IPC message sent',
);
} else {
logger.warn(
{ chatJid: data.chatJid, sourceGroup },
'Unauthorized IPC message attempt blocked',
);
}
}
fs.unlinkSync(filePath);
} catch (err) {
logger.error(
{ file, sourceGroup, err },
'Error processing IPC message',
);
const errorDir = path.join(ipcBaseDir, 'errors');
fs.mkdirSync(errorDir, { recursive: true });
fs.renameSync(
filePath,
path.join(errorDir, `${sourceGroup}-${file}`),
);
}
}
}
} catch (err) {
logger.error(
{ err, sourceGroup },
'Error reading IPC messages directory',
);
}
// Process tasks from this group's IPC directory
try {
if (fs.existsSync(tasksDir)) {
const taskFiles = fs
.readdirSync(tasksDir)
.filter((f) => f.endsWith('.json'));
for (const file of taskFiles) {
const filePath = path.join(tasksDir, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
// Pass source group identity to processTaskIpc for authorization
await processTaskIpc(data, sourceGroup, isMain, deps);
fs.unlinkSync(filePath);
} catch (err) {
logger.error(
{ file, sourceGroup, err },
'Error processing IPC task',
);
const errorDir = path.join(ipcBaseDir, 'errors');
fs.mkdirSync(errorDir, { recursive: true });
fs.renameSync(
filePath,
path.join(errorDir, `${sourceGroup}-${file}`),
);
}
}
}
} catch (err) {
logger.error({ err, sourceGroup }, 'Error reading IPC tasks directory');
}
}
setTimeout(processIpcFiles, IPC_POLL_INTERVAL);
};
processIpcFiles();
logger.info('IPC watcher started (per-group namespaces)');
}
export async function processTaskIpc(
data: {
type: string;
taskId?: string;
prompt?: string;
schedule_type?: string;
schedule_value?: string;
context_mode?: string;
groupFolder?: string;
chatJid?: string;
targetJid?: string;
// For register_group
jid?: string;
name?: string;
folder?: string;
trigger?: string;
containerConfig?: RegisteredGroup['containerConfig'];
},
sourceGroup: string, // Verified identity from IPC directory
isMain: boolean, // Verified from directory path
deps: IpcDeps,
): Promise<void> {
const registeredGroups = deps.registeredGroups();
switch (data.type) {
case 'schedule_task':
if (
data.prompt &&
data.schedule_type &&
data.schedule_value &&
data.targetJid
) {
// Resolve the target group from JID
const targetJid = data.targetJid as string;
const targetGroupEntry = registeredGroups[targetJid];
if (!targetGroupEntry) {
logger.warn(
{ targetJid },
'Cannot schedule task: target group not registered',
);
break;
}
const targetFolder = targetGroupEntry.folder;
// Authorization: non-main groups can only schedule for themselves
if (!isMain && targetFolder !== sourceGroup) {
logger.warn(
{ sourceGroup, targetFolder },
'Unauthorized schedule_task attempt blocked',
);
break;
}
const scheduleType = data.schedule_type as 'cron' | 'interval' | 'once';
let nextRun: string | null = null;
if (scheduleType === 'cron') {
try {
const interval = CronExpressionParser.parse(data.schedule_value, {
tz: TIMEZONE,
});
nextRun = interval.next().toISOString();
} catch {
logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid cron expression',
);
break;
}
} else if (scheduleType === 'interval') {
const ms = parseInt(data.schedule_value, 10);
if (isNaN(ms) || ms <= 0) {
logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid interval',
);
break;
}
nextRun = new Date(Date.now() + ms).toISOString();
} else if (scheduleType === 'once') {
const scheduled = new Date(data.schedule_value);
if (isNaN(scheduled.getTime())) {
logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid timestamp',
);
break;
}
nextRun = scheduled.toISOString();
}
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const contextMode =
data.context_mode === 'group' || data.context_mode === 'isolated'
? data.context_mode
: 'isolated';
createTask({
id: taskId,
group_folder: targetFolder,
chat_jid: targetJid,
prompt: data.prompt,
schedule_type: scheduleType,
schedule_value: data.schedule_value,
context_mode: contextMode,
next_run: nextRun,
status: 'active',
created_at: new Date().toISOString(),
});
logger.info(
{ taskId, sourceGroup, targetFolder, contextMode },
'Task created via IPC',
);
}
break;
case 'pause_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) {
updateTask(data.taskId, { status: 'paused' });
logger.info(
{ taskId: data.taskId, sourceGroup },
'Task paused via IPC',
);
} else {
logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task pause attempt',
);
}
}
break;
case 'resume_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) {
updateTask(data.taskId, { status: 'active' });
logger.info(
{ taskId: data.taskId, sourceGroup },
'Task resumed via IPC',
);
} else {
logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task resume attempt',
);
}
}
break;
case 'cancel_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) {
deleteTask(data.taskId);
logger.info(
{ taskId: data.taskId, sourceGroup },
'Task cancelled via IPC',
);
} else {
logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task cancel attempt',
);
}
}
break;
case 'refresh_groups':
// Only main group can request a refresh
if (isMain) {
logger.info(
{ sourceGroup },
'Group metadata refresh requested via IPC',
);
await deps.syncGroupMetadata(true);
// Write updated snapshot immediately
const availableGroups = deps.getAvailableGroups();
deps.writeGroupsSnapshot(
sourceGroup,
true,
availableGroups,
new Set(Object.keys(registeredGroups)),
);
} else {
logger.warn(
{ sourceGroup },
'Unauthorized refresh_groups attempt blocked',
);
}
break;
case 'register_group':
// Only main group can register new groups
if (!isMain) {
logger.warn(
{ sourceGroup },
'Unauthorized register_group attempt blocked',
);
break;
}
if (data.jid && data.name && data.folder && data.trigger) {
deps.registerGroup(data.jid, {
name: data.name,
folder: data.folder,
trigger: data.trigger,
added_at: new Date().toISOString(),
containerConfig: data.containerConfig,
});
} else {
logger.warn(
{ data },
'Invalid register_group request - missing required fields',
);
}
break;
default:
logger.warn({ type: data.type }, 'Unknown IPC task type');
}
}

46
src/router.ts Normal file
View File

@@ -0,0 +1,46 @@
import { ASSISTANT_NAME } from './config.js';
import { Channel, NewMessage } from './types.js';
export function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function formatMessages(messages: NewMessage[]): string {
const lines = messages.map((m) =>
`<message sender="${escapeXml(m.sender_name)}" time="${m.timestamp}">${escapeXml(m.content)}</message>`,
);
return `<messages>\n${lines.join('\n')}\n</messages>`;
}
export function stripInternalTags(text: string): string {
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
}
export function formatOutbound(channel: Channel, rawText: string): string {
const text = stripInternalTags(rawText);
if (!text) return '';
const prefix =
channel.prefixAssistantName !== false ? `${ASSISTANT_NAME}: ` : '';
return `${prefix}${text}`;
}
export function routeOutbound(
channels: Channel[],
jid: string,
text: string,
): Promise<void> {
const channel = channels.find((c) => c.ownsJid(jid) && c.isConnected());
if (!channel) throw new Error(`No channel for JID: ${jid}`);
return channel.sendMessage(jid, text);
}
export function findChannel(
channels: Channel[],
jid: string,
): Channel | undefined {
return channels.find((c) => c.ownsJid(jid));
}

91
src/routing.test.ts Normal file
View File

@@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js';
import { getAvailableGroups, _setRegisteredGroups } from './index.js';
beforeEach(() => {
_initTestDatabase();
_setRegisteredGroups({});
});
// --- JID ownership patterns ---
describe('JID ownership patterns', () => {
// These test the patterns that will become ownsJid() on the Channel interface
it('WhatsApp group JID: ends with @g.us', () => {
const jid = '12345678@g.us';
expect(jid.endsWith('@g.us')).toBe(true);
});
it('WhatsApp DM JID: ends with @s.whatsapp.net', () => {
const jid = '12345678@s.whatsapp.net';
expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
});
it('unknown JID format: does not match WhatsApp patterns', () => {
const jid = 'unknown:12345';
expect(jid.endsWith('@g.us')).toBe(false);
expect(jid.endsWith('@s.whatsapp.net')).toBe(false);
});
});
// --- getAvailableGroups ---
describe('getAvailableGroups', () => {
it('returns only @g.us JIDs', () => {
storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1');
storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM');
storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2');
const groups = getAvailableGroups();
expect(groups).toHaveLength(2);
expect(groups.every((g) => g.jid.endsWith('@g.us'))).toBe(true);
});
it('excludes __group_sync__ sentinel', () => {
storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group');
const groups = getAvailableGroups();
expect(groups).toHaveLength(1);
expect(groups[0].jid).toBe('group@g.us');
});
it('marks registered groups correctly', () => {
storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered');
storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered');
_setRegisteredGroups({
'reg@g.us': {
name: 'Registered',
folder: 'registered',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
});
const groups = getAvailableGroups();
const reg = groups.find((g) => g.jid === 'reg@g.us');
const unreg = groups.find((g) => g.jid === 'unreg@g.us');
expect(reg?.isRegistered).toBe(true);
expect(unreg?.isRegistered).toBe(false);
});
it('returns groups ordered by most recent activity', () => {
storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old');
storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New');
storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid');
const groups = getAvailableGroups();
expect(groups[0].jid).toBe('new@g.us');
expect(groups[1].jid).toBe('mid@g.us');
expect(groups[2].jid).toBe('old@g.us');
});
it('returns empty array when no chats exist', () => {
const groups = getAvailableGroups();
expect(groups).toHaveLength(0);
});
});

View File

@@ -28,7 +28,6 @@ export interface SchedulerDependencies {
queue: GroupQueue; queue: GroupQueue;
onProcess: (groupJid: string, proc: ChildProcess, containerName: string, groupFolder: string) => void; onProcess: (groupJid: string, proc: ChildProcess, containerName: string, groupFolder: string) => void;
sendMessage: (jid: string, text: string) => Promise<void>; sendMessage: (jid: string, text: string) => Promise<void>;
assistantName: string;
} }
async function runTask( async function runTask(
@@ -117,11 +116,8 @@ async function runTask(
async (streamedOutput: ContainerOutput) => { async (streamedOutput: ContainerOutput) => {
if (streamedOutput.result) { if (streamedOutput.result) {
result = streamedOutput.result; result = streamedOutput.result;
// Forward result to user (strip <internal> tags) // Forward result to user (sendMessage handles formatting)
const text = streamedOutput.result.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim(); await deps.sendMessage(task.chat_jid, streamedOutput.result);
if (text) {
await deps.sendMessage(task.chat_jid, `${deps.assistantName}: ${text}`);
}
// Only reset idle timer on actual results, not session-update markers // Only reset idle timer on actual results, not session-update markers
resetIdleTimer(); resetIdleTimer();
} }

327
src/telegram.ts Normal file
View File

@@ -0,0 +1,327 @@
import { Api, Bot } from 'grammy';
import {
ASSISTANT_NAME,
TRIGGER_PATTERN,
} from './config.js';
import {
getAllRegisteredGroups,
storeChatMetadata,
storeMessageDirect,
} from './db.js';
import { logger } from './logger.js';
let bot: Bot | null = null;
// Bot pool for agent teams: send-only Api instances (no polling)
const poolApis: Api[] = [];
// Current display name for each pool bot (from getMe at startup)
const poolBotNames: string[] = [];
// Maps "{groupFolder}:{senderName}" → pool Api index for stable assignment
const senderBotMap = new Map<string, number>();
// Tracks which pool indices are already claimed this session
const assignedIndices = new Set<number>();
/** 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 async function connectTelegram(botToken: string): Promise<void> {
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}`;
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',
);
return;
}
// Store message — startMessageLoop() will pick it up
storeMessageDirect({
id: msgId,
chat_jid: chatId,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: false,
});
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));
}
}
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');
}
}
/**
* Initialize send-only Api instances for the bot pool.
* Each pool bot can send messages but doesn't poll for updates.
*/
export async function initBotPool(tokens: string[]): Promise<void> {
for (const token of tokens) {
try {
const api = new Api(token);
const me = await api.getMe();
poolApis.push(api);
poolBotNames.push(me.first_name);
logger.info(
{ username: me.username, name: me.first_name, id: me.id, poolSize: poolApis.length },
'Pool bot initialized',
);
} catch (err) {
logger.error({ err }, 'Failed to initialize pool bot');
}
}
if (poolApis.length > 0) {
logger.info({ count: poolApis.length, names: poolBotNames }, 'Telegram bot pool ready');
}
}
/**
* Send a message via a pool bot assigned to the given sender name.
* Assignment priority:
* 1. Already assigned to this sender this session → reuse
* 2. A pool bot whose current name matches the sender → claim it (no rename needed)
* 3. First unassigned pool bot → claim and rename
* 4. All claimed → wrap around (reuse + rename)
*/
export async function sendPoolMessage(
chatId: string,
text: string,
sender: string,
groupFolder: string,
): Promise<void> {
if (poolApis.length === 0) {
// No pool bots — fall back to main bot
await sendTelegramMessage(chatId, text);
return;
}
const key = `${groupFolder}:${sender}`;
let idx = senderBotMap.get(key);
if (idx === undefined) {
// 1. Check if any pool bot already has this name (from a previous session)
const nameMatch = poolBotNames.findIndex(
(name, i) => name === sender && !assignedIndices.has(i),
);
if (nameMatch !== -1) {
idx = nameMatch;
assignedIndices.add(idx);
senderBotMap.set(key, idx);
logger.info({ sender, groupFolder, poolIndex: idx }, 'Matched pool bot by name');
} else {
// 2. Pick first unassigned bot
let freeIdx = -1;
for (let i = 0; i < poolApis.length; i++) {
if (!assignedIndices.has(i)) {
freeIdx = i;
break;
}
}
// 3. All assigned — wrap around to least recently used
if (freeIdx === -1) freeIdx = assignedIndices.size % poolApis.length;
idx = freeIdx;
assignedIndices.add(idx);
senderBotMap.set(key, idx);
// Rename the bot, then wait for Telegram to propagate
try {
await poolApis[idx].setMyName(sender);
poolBotNames[idx] = sender;
await new Promise((r) => setTimeout(r, 2000));
logger.info({ sender, groupFolder, poolIndex: idx }, 'Assigned and renamed pool bot');
} catch (err) {
logger.warn({ sender, err }, 'Failed to rename pool bot (sending anyway)');
}
}
}
const api = poolApis[idx];
try {
const numericId = chatId.replace(/^tg:/, '');
const MAX_LENGTH = 4096;
if (text.length <= MAX_LENGTH) {
await api.sendMessage(numericId, text);
} else {
for (let i = 0; i < text.length; i += MAX_LENGTH) {
await api.sendMessage(numericId, text.slice(i, i + MAX_LENGTH));
}
}
logger.info({ chatId, sender, poolIndex: idx, length: text.length }, 'Pool message sent');
} catch (err) {
logger.error({ chatId, sender, err }, 'Failed to send pool message');
}
}
export function isTelegramConnected(): boolean {
return bot !== null;
}
export function stopTelegram(): void {
if (bot) {
bot.stop();
bot = null;
logger.info('Telegram bot stopped');
}
}

View File

@@ -48,6 +48,7 @@ export interface NewMessage {
sender_name: string; sender_name: string;
content: string; content: string;
timestamp: string; timestamp: string;
is_from_me?: boolean;
} }
export interface ScheduledTask { export interface ScheduledTask {
@@ -73,3 +74,28 @@ export interface TaskRunLog {
result: string | null; result: string | null;
error: string | null; error: string | null;
} }
// --- Channel abstraction ---
export interface Channel {
name: string;
connect(): Promise<void>;
sendMessage(jid: string, text: string): Promise<void>;
isConnected(): boolean;
ownsJid(jid: string): boolean;
disconnect(): Promise<void>;
// Optional: typing indicator. Channels that support it implement it.
setTyping?(jid: string, isTyping: boolean): Promise<void>;
// Whether to prefix outbound messages with the assistant name.
// Telegram bots already display their name, so they return false.
// WhatsApp returns true. Default true if not implemented.
prefixAssistantName?: boolean;
}
// Callback type that channels use to deliver inbound messages
export type OnInboundMessage = (chatJid: string, message: NewMessage) => void;
// Callback for chat metadata discovery.
// name is optional — channels that deliver names inline (Telegram) pass it here;
// channels that sync names separately (WhatsApp syncGroupMetadata) omit it.
export type OnChatMetadata = (chatJid: string, timestamp: string, name?: string) => void;

7
vitest.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});