feat: add Telegram Topics (forum mode) support
- buildJid() constructs tg:{chatId}:{threadId} for topic messages
- parseJid() extracts chatId + threadId from JID for outbound routing
- /chatid command shows thread ID in forum topics
- sendMessage and setTyping pass message_thread_id when present
- All message handlers (text, photo, voice, media) use thread-aware JIDs
Allows each forum topic to be registered as an independent Nanoclaw group.
This commit is contained in:
1536
package-lock.json
generated
1536
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "1.2.12",
|
"version": "1.1.3",
|
||||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -8,27 +8,33 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
|
"auth": "tsx src/whatsapp-auth.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format": "prettier --write \"src/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\"",
|
||||||
"format:fix": "prettier --write \"src/**/*.ts\"",
|
"format:fix": "prettier --write \"src/**/*.ts\"",
|
||||||
"format:check": "prettier --check \"src/**/*.ts\"",
|
"format:check": "prettier --check \"src/**/*.ts\"",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"setup": "tsx setup/index.ts",
|
"setup": "tsx setup/index.ts",
|
||||||
"auth": "tsx src/whatsapp-auth.ts",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@whiskeysockets/baileys": "^7.0.0-rc.9",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
|
"grammy": "^1.39.3",
|
||||||
|
"openai": "^6.25.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"qrcode-terminal": "^0.12.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@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",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
|||||||
1024
src/channels/telegram.test.ts
Normal file
1024
src/channels/telegram.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
366
src/channels/telegram.ts
Normal file
366
src/channels/telegram.ts
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { Bot } from 'grammy';
|
||||||
|
|
||||||
|
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
|
||||||
|
import { resolveGroupIpcPath } from '../group-folder.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { transcribeBuffer } from '../transcription.js';
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
OnChatMetadata,
|
||||||
|
OnInboundMessage,
|
||||||
|
RegisteredGroup,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
export interface TelegramChannelOpts {
|
||||||
|
onMessage: OnInboundMessage;
|
||||||
|
onChatMetadata: OnChatMetadata;
|
||||||
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||||
|
clearSession: (chatJid: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TelegramChannel implements Channel {
|
||||||
|
name = 'telegram';
|
||||||
|
|
||||||
|
private bot: Bot | null = null;
|
||||||
|
private opts: TelegramChannelOpts;
|
||||||
|
private botToken: string;
|
||||||
|
|
||||||
|
constructor(botToken: string, opts: TelegramChannelOpts) {
|
||||||
|
this.botToken = botToken;
|
||||||
|
this.opts = opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a JID from a Telegram chat ID and optional topic thread ID.
|
||||||
|
* Format: "tg:{chatId}" or "tg:{chatId}:{threadId}" for forum topics.
|
||||||
|
*/
|
||||||
|
private buildJid(chatId: number, threadId?: number): string {
|
||||||
|
return threadId ? `tg:${chatId}:${threadId}` : `tg:${chatId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a JID back into chatId (string) and optional threadId (number).
|
||||||
|
* Handles negative chat IDs (groups/supergroups start with -100...).
|
||||||
|
*/
|
||||||
|
private parseJid(jid: string): { chatId: string; threadId?: number } {
|
||||||
|
const withoutPrefix = jid.replace(/^tg:/, '');
|
||||||
|
const colonIdx = withoutPrefix.indexOf(':');
|
||||||
|
if (colonIdx === -1) {
|
||||||
|
return { chatId: withoutPrefix };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
chatId: withoutPrefix.slice(0, colonIdx),
|
||||||
|
threadId: parseInt(withoutPrefix.slice(colonIdx + 1), 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
this.bot = new Bot(this.botToken);
|
||||||
|
|
||||||
|
// Command to get chat ID (useful for registration)
|
||||||
|
// In forum topics, also shows the thread ID so the full JID can be used.
|
||||||
|
this.bot.command('chatid', (ctx) => {
|
||||||
|
const chatId = ctx.chat.id;
|
||||||
|
const chatType = ctx.chat.type;
|
||||||
|
const threadId = (ctx.message as any)?.message_thread_id as
|
||||||
|
| number
|
||||||
|
| undefined;
|
||||||
|
const chatJid = this.buildJid(chatId, threadId);
|
||||||
|
const chatName =
|
||||||
|
chatType === 'private'
|
||||||
|
? ctx.from?.first_name || 'Private'
|
||||||
|
: (ctx.chat as any).title || 'Unknown';
|
||||||
|
|
||||||
|
const topicLine = threadId ? `\nThread ID: \`${threadId}\`` : '';
|
||||||
|
ctx.reply(
|
||||||
|
`Chat ID: \`${chatJid}\`${topicLine}\nName: ${chatName}\nType: ${chatType}`,
|
||||||
|
{ parse_mode: 'Markdown' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command to check bot status
|
||||||
|
this.bot.command('ping', (ctx) => {
|
||||||
|
ctx.reply(`${ASSISTANT_NAME} is online.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command to clear conversation context (start a new session)
|
||||||
|
this.bot.command('reset', (ctx) => {
|
||||||
|
const threadId = (ctx.message as any)?.message_thread_id as
|
||||||
|
| number
|
||||||
|
| undefined;
|
||||||
|
const chatJid = this.buildJid(ctx.chat.id, threadId);
|
||||||
|
this.opts.clearSession(chatJid);
|
||||||
|
ctx.reply('Session cleared. Next message starts a fresh conversation.');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.bot.on('message:text', async (ctx) => {
|
||||||
|
// Skip commands
|
||||||
|
if (ctx.message.text.startsWith('/')) return;
|
||||||
|
|
||||||
|
const threadId = ctx.message.message_thread_id;
|
||||||
|
const chatJid = this.buildJid(ctx.chat.id, threadId);
|
||||||
|
let content = ctx.message.text;
|
||||||
|
const timestamp = new Date(ctx.message.date * 1000).toISOString();
|
||||||
|
const senderName =
|
||||||
|
ctx.from?.first_name ||
|
||||||
|
ctx.from?.username ||
|
||||||
|
ctx.from?.id.toString() ||
|
||||||
|
'Unknown';
|
||||||
|
const sender = ctx.from?.id.toString() || '';
|
||||||
|
const msgId = ctx.message.message_id.toString();
|
||||||
|
|
||||||
|
// Determine chat name
|
||||||
|
const chatName =
|
||||||
|
ctx.chat.type === 'private'
|
||||||
|
? senderName
|
||||||
|
: (ctx.chat as any).title || chatJid;
|
||||||
|
|
||||||
|
// In private DMs every message is implicitly addressed to the bot —
|
||||||
|
// no @mention entity exists. Prepend the trigger so TRIGGER_PATTERN matches.
|
||||||
|
if (ctx.chat.type === 'private' && !TRIGGER_PATTERN.test(content)) {
|
||||||
|
content = `@${ASSISTANT_NAME} ${content}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate Telegram @bot_username mentions into TRIGGER_PATTERN format.
|
||||||
|
// Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN
|
||||||
|
// (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned.
|
||||||
|
const botUsername = ctx.me?.username?.toLowerCase();
|
||||||
|
if (botUsername) {
|
||||||
|
const entities = ctx.message.entities || [];
|
||||||
|
const isBotMentioned = entities.some((entity) => {
|
||||||
|
if (entity.type === 'mention') {
|
||||||
|
const mentionText = content
|
||||||
|
.substring(entity.offset, entity.offset + entity.length)
|
||||||
|
.toLowerCase();
|
||||||
|
return mentionText === `@${botUsername}`;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (isBotMentioned && !TRIGGER_PATTERN.test(content)) {
|
||||||
|
content = `@${ASSISTANT_NAME} ${content}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store chat metadata for discovery
|
||||||
|
this.opts.onChatMetadata(chatJid, timestamp, chatName);
|
||||||
|
|
||||||
|
// Only deliver full message for registered groups
|
||||||
|
const group = this.opts.registeredGroups()[chatJid];
|
||||||
|
if (!group) {
|
||||||
|
logger.debug(
|
||||||
|
{ chatJid, chatName },
|
||||||
|
'Message from unregistered Telegram chat',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver message — startMessageLoop() will pick it up
|
||||||
|
this.opts.onMessage(chatJid, {
|
||||||
|
id: msgId,
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender,
|
||||||
|
sender_name: senderName,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
is_from_me: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, chatName, sender: senderName },
|
||||||
|
'Telegram message stored',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle non-text messages with placeholders so the agent knows something was sent
|
||||||
|
const storeNonText = (ctx: any, placeholder: string) => {
|
||||||
|
const threadId = ctx.message?.message_thread_id as number | undefined;
|
||||||
|
const chatJid = this.buildJid(ctx.chat.id, threadId);
|
||||||
|
const group = this.opts.registeredGroups()[chatJid];
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const timestamp = new Date(ctx.message.date * 1000).toISOString();
|
||||||
|
const senderName =
|
||||||
|
ctx.from?.first_name ||
|
||||||
|
ctx.from?.username ||
|
||||||
|
ctx.from?.id?.toString() ||
|
||||||
|
'Unknown';
|
||||||
|
const caption = ctx.message.caption ? ` ${ctx.message.caption}` : '';
|
||||||
|
|
||||||
|
this.opts.onChatMetadata(chatJid, timestamp);
|
||||||
|
this.opts.onMessage(chatJid, {
|
||||||
|
id: ctx.message.message_id.toString(),
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender: ctx.from?.id?.toString() || '',
|
||||||
|
sender_name: senderName,
|
||||||
|
content: `${placeholder}${caption}`,
|
||||||
|
timestamp,
|
||||||
|
is_from_me: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.bot.on('message:photo', async (ctx) => {
|
||||||
|
const threadId = ctx.message.message_thread_id;
|
||||||
|
const chatJid = this.buildJid(ctx.chat.id, threadId);
|
||||||
|
const group = this.opts.registeredGroups()[chatJid];
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
let placeholder = '[Photo]';
|
||||||
|
try {
|
||||||
|
// Highest resolution is last in the array
|
||||||
|
const photos = ctx.message.photo;
|
||||||
|
const largest = photos[photos.length - 1];
|
||||||
|
const file = await ctx.api.getFile(largest.file_id);
|
||||||
|
const url = `https://api.telegram.org/file/bot${this.botToken}/${file.file_path}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.ok) {
|
||||||
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
const ext = path.extname(file.file_path || '') || '.jpg';
|
||||||
|
const filename = `photo_${ctx.message.message_id}${ext}`;
|
||||||
|
const inputDir = path.join(
|
||||||
|
resolveGroupIpcPath(group.folder),
|
||||||
|
'input',
|
||||||
|
);
|
||||||
|
fs.mkdirSync(inputDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(inputDir, filename), buffer);
|
||||||
|
placeholder = `[Photo: /workspace/ipc/input/${filename} — use the Read tool to view this image]`;
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, bytes: buffer.length },
|
||||||
|
'Downloaded Telegram photo',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
{ chatJid, status: res.status },
|
||||||
|
'Failed to download Telegram photo',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Telegram photo download error');
|
||||||
|
}
|
||||||
|
storeNonText(ctx, placeholder);
|
||||||
|
});
|
||||||
|
this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]'));
|
||||||
|
this.bot.on('message:voice', async (ctx) => {
|
||||||
|
const threadId = ctx.message.message_thread_id;
|
||||||
|
const chatJid = this.buildJid(ctx.chat.id, threadId);
|
||||||
|
const group = this.opts.registeredGroups()[chatJid];
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
let placeholder = '[Voice Message - transcription unavailable]';
|
||||||
|
try {
|
||||||
|
const file = await ctx.getFile();
|
||||||
|
const url = `https://api.telegram.org/file/bot${this.botToken}/${file.file_path}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.ok) {
|
||||||
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, bytes: buffer.length },
|
||||||
|
'Downloaded Telegram voice file',
|
||||||
|
);
|
||||||
|
const transcript = await transcribeBuffer(buffer);
|
||||||
|
if (transcript) {
|
||||||
|
placeholder = `[Voice: ${transcript}]`;
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, length: transcript.length },
|
||||||
|
'Transcribed voice message',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.warn({ chatJid }, 'Voice transcription returned null');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
{ chatJid, status: res.status },
|
||||||
|
'Failed to download Telegram voice file',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Telegram voice transcription error');
|
||||||
|
placeholder = '[Voice Message - transcription failed]';
|
||||||
|
}
|
||||||
|
storeNonText(ctx, placeholder);
|
||||||
|
});
|
||||||
|
this.bot.on('message:audio', (ctx) => storeNonText(ctx, '[Audio]'));
|
||||||
|
this.bot.on('message:document', (ctx) => {
|
||||||
|
const name = ctx.message.document?.file_name || 'file';
|
||||||
|
storeNonText(ctx, `[Document: ${name}]`);
|
||||||
|
});
|
||||||
|
this.bot.on('message:sticker', (ctx) => {
|
||||||
|
const emoji = ctx.message.sticker?.emoji || '';
|
||||||
|
storeNonText(ctx, `[Sticker ${emoji}]`);
|
||||||
|
});
|
||||||
|
this.bot.on('message:location', (ctx) => storeNonText(ctx, '[Location]'));
|
||||||
|
this.bot.on('message:contact', (ctx) => storeNonText(ctx, '[Contact]'));
|
||||||
|
|
||||||
|
// Handle errors gracefully
|
||||||
|
this.bot.catch((err) => {
|
||||||
|
logger.error({ err: err.message }, 'Telegram bot error');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start polling — returns a Promise that resolves when started
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
this.bot!.start({
|
||||||
|
onStart: (botInfo) => {
|
||||||
|
logger.info(
|
||||||
|
{ username: botInfo.username, id: botInfo.id },
|
||||||
|
'Telegram bot connected',
|
||||||
|
);
|
||||||
|
console.log(`\n Telegram bot: @${botInfo.username}`);
|
||||||
|
console.log(
|
||||||
|
` Send /chatid to the bot to get a chat's registration ID\n`,
|
||||||
|
);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(jid: string, text: string): Promise<void> {
|
||||||
|
if (!this.bot) {
|
||||||
|
logger.warn('Telegram bot not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { chatId, threadId } = this.parseJid(jid);
|
||||||
|
|
||||||
|
// Telegram has a 4096 character limit per message — split if needed
|
||||||
|
const MAX_LENGTH = 4096;
|
||||||
|
const opts = threadId ? { message_thread_id: threadId } : {};
|
||||||
|
for (let i = 0; i < text.length; i += MAX_LENGTH) {
|
||||||
|
await this.bot.api.sendMessage(chatId, text.slice(i, i + MAX_LENGTH), opts);
|
||||||
|
}
|
||||||
|
logger.info({ jid, length: text.length }, 'Telegram message sent');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ jid, err }, 'Failed to send Telegram message');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.bot !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ownsJid(jid: string): boolean {
|
||||||
|
return jid.startsWith('tg:');
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.bot) {
|
||||||
|
this.bot.stop();
|
||||||
|
this.bot = null;
|
||||||
|
logger.info('Telegram bot stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setTyping(jid: string, isTyping: boolean): Promise<void> {
|
||||||
|
if (!this.bot || !isTyping) return;
|
||||||
|
try {
|
||||||
|
const { chatId, threadId } = this.parseJid(jid);
|
||||||
|
const opts = threadId ? { message_thread_id: threadId } : {};
|
||||||
|
await this.bot.api.sendChatAction(chatId, 'typing', opts);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug({ jid, err }, 'Failed to send Telegram typing indicator');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1032
src/channels/whatsapp.test.ts
Normal file
1032
src/channels/whatsapp.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
400
src/channels/whatsapp.ts
Normal file
400
src/channels/whatsapp.ts
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
import { exec } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import makeWASocket, {
|
||||||
|
Browsers,
|
||||||
|
DisconnectReason,
|
||||||
|
WASocket,
|
||||||
|
fetchLatestWaWebVersion,
|
||||||
|
makeCacheableSignalKeyStore,
|
||||||
|
useMultiFileAuthState,
|
||||||
|
} from '@whiskeysockets/baileys';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ASSISTANT_HAS_OWN_NUMBER,
|
||||||
|
ASSISTANT_NAME,
|
||||||
|
STORE_DIR,
|
||||||
|
} from '../config.js';
|
||||||
|
import { getLastGroupSync, setLastGroupSync, updateChatName } from '../db.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { isVoiceMessage, transcribeAudioMessage } from '../transcription.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';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
|
||||||
|
logger.warn(
|
||||||
|
{ err },
|
||||||
|
'Failed to fetch latest WA Web version, using default',
|
||||||
|
);
|
||||||
|
return { version: undefined };
|
||||||
|
});
|
||||||
|
this.sock = makeWASocket({
|
||||||
|
version,
|
||||||
|
auth: {
|
||||||
|
creds: state.creds,
|
||||||
|
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
||||||
|
},
|
||||||
|
printQRInTerminal: false,
|
||||||
|
logger,
|
||||||
|
browser: Browsers.macOS('Chrome'),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 { output?: { statusCode?: number } }
|
||||||
|
)?.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');
|
||||||
|
|
||||||
|
// Announce availability so WhatsApp relays subsequent presence updates (typing indicators)
|
||||||
|
this.sock.sendPresenceUpdate('available').catch((err) => {
|
||||||
|
logger.warn({ err }, 'Failed to send presence update');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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', async ({ 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 = await this.translateJid(rawJid);
|
||||||
|
|
||||||
|
const timestamp = new Date(
|
||||||
|
Number(msg.messageTimestamp) * 1000,
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
// Always notify about chat metadata for group discovery
|
||||||
|
const isGroup = chatJid.endsWith('@g.us');
|
||||||
|
this.opts.onChatMetadata(
|
||||||
|
chatJid,
|
||||||
|
timestamp,
|
||||||
|
undefined,
|
||||||
|
'whatsapp',
|
||||||
|
isGroup,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
// Skip protocol messages with no text content (encryption keys, read receipts, etc.)
|
||||||
|
// but allow voice messages through for transcription
|
||||||
|
if (!content && !isVoiceMessage(msg)) continue;
|
||||||
|
|
||||||
|
const sender = msg.key.participant || msg.key.remoteJid || '';
|
||||||
|
const senderName = msg.pushName || sender.split('@')[0];
|
||||||
|
|
||||||
|
const fromMe = msg.key.fromMe || false;
|
||||||
|
// Detect bot messages: with own number, fromMe is reliable
|
||||||
|
// since only the bot sends from that number.
|
||||||
|
// With shared number, bot messages carry the assistant name prefix
|
||||||
|
// (even in DMs/self-chat) so we check for that.
|
||||||
|
const isBotMessage = ASSISTANT_HAS_OWN_NUMBER
|
||||||
|
? fromMe
|
||||||
|
: content.startsWith(`${ASSISTANT_NAME}:`);
|
||||||
|
|
||||||
|
// Transcribe voice messages before storing
|
||||||
|
let finalContent = content;
|
||||||
|
if (isVoiceMessage(msg)) {
|
||||||
|
try {
|
||||||
|
const transcript = await transcribeAudioMessage(msg, this.sock);
|
||||||
|
if (transcript) {
|
||||||
|
finalContent = `[Voice: ${transcript}]`;
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, length: transcript.length },
|
||||||
|
'Transcribed voice message',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
finalContent = '[Voice Message - transcription unavailable]';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Voice transcription error');
|
||||||
|
finalContent = '[Voice Message - transcription failed]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.opts.onMessage(chatJid, {
|
||||||
|
id: msg.key.id || '',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender,
|
||||||
|
sender_name: senderName,
|
||||||
|
content: finalContent,
|
||||||
|
timestamp,
|
||||||
|
is_from_me: fromMe,
|
||||||
|
is_bot_message: isBotMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(jid: string, text: string): Promise<void> {
|
||||||
|
// Prefix bot messages with assistant name so users know who's speaking.
|
||||||
|
// On a shared number, prefix is also needed in DMs (including self-chat)
|
||||||
|
// to distinguish bot output from user messages.
|
||||||
|
// Skip only when the assistant has its own dedicated phone number.
|
||||||
|
const prefixed = ASSISTANT_HAS_OWN_NUMBER
|
||||||
|
? text
|
||||||
|
: `${ASSISTANT_NAME}: ${text}`;
|
||||||
|
|
||||||
|
if (!this.connected) {
|
||||||
|
this.outgoingQueue.push({ jid, text: prefixed });
|
||||||
|
logger.info(
|
||||||
|
{ jid, length: prefixed.length, queueSize: this.outgoingQueue.length },
|
||||||
|
'WA disconnected, message queued',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.sock.sendMessage(jid, { text: prefixed });
|
||||||
|
logger.info({ jid, length: prefixed.length }, 'Message sent');
|
||||||
|
} catch (err) {
|
||||||
|
// If send fails, queue it for retry on reconnect
|
||||||
|
this.outgoingQueue.push({ jid, text: prefixed });
|
||||||
|
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 {
|
||||||
|
const status = isTyping ? 'composing' : 'paused';
|
||||||
|
logger.debug({ jid, status }, 'Sending presence update');
|
||||||
|
await this.sock.sendPresenceUpdate(status, 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 async translateJid(jid: string): Promise<string> {
|
||||||
|
if (!jid.endsWith('@lid')) return jid;
|
||||||
|
const lidUser = jid.split('@')[0].split(':')[0];
|
||||||
|
|
||||||
|
// Check local cache first
|
||||||
|
const cached = this.lidToPhoneMap[lidUser];
|
||||||
|
if (cached) {
|
||||||
|
logger.debug(
|
||||||
|
{ lidJid: jid, phoneJid: cached },
|
||||||
|
'Translated LID to phone JID (cached)',
|
||||||
|
);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query Baileys' signal repository for the mapping
|
||||||
|
try {
|
||||||
|
const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid);
|
||||||
|
if (pn) {
|
||||||
|
const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
|
||||||
|
this.lidToPhoneMap[lidUser] = phoneJid;
|
||||||
|
logger.info(
|
||||||
|
{ lidJid: jid, phoneJid },
|
||||||
|
'Translated LID to phone JID (signalRepository)',
|
||||||
|
);
|
||||||
|
return phoneJid;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository');
|
||||||
|
}
|
||||||
|
|
||||||
|
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()!;
|
||||||
|
// Send directly — queued items are already prefixed by sendMessage
|
||||||
|
await this.sock.sendMessage(item.jid, { text: item.text });
|
||||||
|
logger.info(
|
||||||
|
{ jid: item.jid, length: item.text.length },
|
||||||
|
'Queued message sent',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.flushing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { readEnvFile } from './env.js';
|
import { readEnvFile } from './env.js';
|
||||||
|
|
||||||
// Read config values from .env (falls back to process.env).
|
// Read config values from .env (falls back to process.env).
|
||||||
// Secrets (API keys, tokens) are NOT read here — they are loaded only
|
// Secrets are NOT read here — they stay on disk and are loaded only
|
||||||
// by the credential proxy (credential-proxy.ts), never exposed to containers.
|
// where needed (container-runner.ts) to avoid leaking to child processes.
|
||||||
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']);
|
const envConfig = readEnvFile([
|
||||||
|
'ASSISTANT_NAME',
|
||||||
|
'ASSISTANT_HAS_OWN_NUMBER',
|
||||||
|
'TELEGRAM_BOT_TOKEN',
|
||||||
|
'TELEGRAM_ONLY',
|
||||||
|
]);
|
||||||
|
|
||||||
export const ASSISTANT_NAME =
|
export const ASSISTANT_NAME =
|
||||||
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||||
@@ -18,7 +22,7 @@ export const SCHEDULER_POLL_INTERVAL = 60000;
|
|||||||
|
|
||||||
// Absolute paths needed for container mounts
|
// Absolute paths needed for container mounts
|
||||||
const PROJECT_ROOT = process.cwd();
|
const PROJECT_ROOT = process.cwd();
|
||||||
const HOME_DIR = process.env.HOME || os.homedir();
|
const HOME_DIR = process.env.HOME || '/Users/user';
|
||||||
|
|
||||||
// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
|
// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
|
||||||
export const MOUNT_ALLOWLIST_PATH = path.join(
|
export const MOUNT_ALLOWLIST_PATH = path.join(
|
||||||
@@ -27,15 +31,10 @@ export const MOUNT_ALLOWLIST_PATH = path.join(
|
|||||||
'nanoclaw',
|
'nanoclaw',
|
||||||
'mount-allowlist.json',
|
'mount-allowlist.json',
|
||||||
);
|
);
|
||||||
export const SENDER_ALLOWLIST_PATH = path.join(
|
|
||||||
HOME_DIR,
|
|
||||||
'.config',
|
|
||||||
'nanoclaw',
|
|
||||||
'sender-allowlist.json',
|
|
||||||
);
|
|
||||||
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
|
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
|
||||||
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
|
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
|
||||||
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
|
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
|
||||||
|
export const MAIN_GROUP_FOLDER = 'main';
|
||||||
|
|
||||||
export const CONTAINER_IMAGE =
|
export const CONTAINER_IMAGE =
|
||||||
process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
|
process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
|
||||||
@@ -47,10 +46,6 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
|
|||||||
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
|
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
|
||||||
10,
|
10,
|
||||||
); // 10MB default
|
); // 10MB default
|
||||||
export const CREDENTIAL_PROXY_PORT = parseInt(
|
|
||||||
process.env.CREDENTIAL_PROXY_PORT || '3001',
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
export const IPC_POLL_INTERVAL = 1000;
|
export const IPC_POLL_INTERVAL = 1000;
|
||||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
|
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
|
||||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(
|
export const MAX_CONCURRENT_CONTAINERS = Math.max(
|
||||||
@@ -71,3 +66,9 @@ export const TRIGGER_PATTERN = new RegExp(
|
|||||||
// Uses system timezone by default
|
// Uses system timezone by default
|
||||||
export const TIMEZONE =
|
export const TIMEZONE =
|
||||||
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
// Telegram configuration
|
||||||
|
export const TELEGRAM_BOT_TOKEN =
|
||||||
|
process.env.TELEGRAM_BOT_TOKEN || envConfig.TELEGRAM_BOT_TOKEN || '';
|
||||||
|
export const TELEGRAM_ONLY =
|
||||||
|
(process.env.TELEGRAM_ONLY || envConfig.TELEGRAM_ONLY) === 'true';
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ vi.mock('./config.js', () => ({
|
|||||||
CONTAINER_IMAGE: 'nanoclaw-agent:latest',
|
CONTAINER_IMAGE: 'nanoclaw-agent:latest',
|
||||||
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
|
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
|
||||||
CONTAINER_TIMEOUT: 1800000, // 30min
|
CONTAINER_TIMEOUT: 1800000, // 30min
|
||||||
CREDENTIAL_PROXY_PORT: 3001,
|
|
||||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
DATA_DIR: '/tmp/nanoclaw-test-data',
|
||||||
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
||||||
IDLE_TIMEOUT: 1800000, // 30min
|
IDLE_TIMEOUT: 1800000, // 30min
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Container Runner for NanoClaw
|
* Container Runner for NanoClaw
|
||||||
* Spawns agent execution in containers and handles IPC
|
* Spawns agent execution in containers and handles IPC
|
||||||
*/
|
*/
|
||||||
import { ChildProcess, exec, spawn } from 'child_process';
|
import { ChildProcess, exec, execFileSync, spawn } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -10,22 +10,20 @@ import {
|
|||||||
CONTAINER_IMAGE,
|
CONTAINER_IMAGE,
|
||||||
CONTAINER_MAX_OUTPUT_SIZE,
|
CONTAINER_MAX_OUTPUT_SIZE,
|
||||||
CONTAINER_TIMEOUT,
|
CONTAINER_TIMEOUT,
|
||||||
CREDENTIAL_PROXY_PORT,
|
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
GROUPS_DIR,
|
GROUPS_DIR,
|
||||||
IDLE_TIMEOUT,
|
IDLE_TIMEOUT,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
|
import { readEnvFile } from './env.js';
|
||||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import {
|
import {
|
||||||
CONTAINER_HOST_GATEWAY,
|
|
||||||
CONTAINER_RUNTIME_BIN,
|
CONTAINER_RUNTIME_BIN,
|
||||||
hostGatewayArgs,
|
isRootlessDocker,
|
||||||
readonlyMountArgs,
|
readonlyMountArgs,
|
||||||
stopContainer,
|
stopContainer,
|
||||||
} from './container-runtime.js';
|
} from './container-runtime.js';
|
||||||
import { detectAuthMode } from './credential-proxy.js';
|
|
||||||
import { validateAdditionalMounts } from './mount-security.js';
|
import { validateAdditionalMounts } from './mount-security.js';
|
||||||
import { RegisteredGroup } from './types.js';
|
import { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
@@ -40,7 +38,9 @@ export interface ContainerInput {
|
|||||||
chatJid: string;
|
chatJid: string;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
isScheduledTask?: boolean;
|
isScheduledTask?: boolean;
|
||||||
|
ipcSuffix?: string;
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
|
secrets?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerOutput {
|
export interface ContainerOutput {
|
||||||
@@ -59,6 +59,7 @@ interface VolumeMount {
|
|||||||
function buildVolumeMounts(
|
function buildVolumeMounts(
|
||||||
group: RegisteredGroup,
|
group: RegisteredGroup,
|
||||||
isMain: boolean,
|
isMain: boolean,
|
||||||
|
ipcSuffix?: string,
|
||||||
): VolumeMount[] {
|
): VolumeMount[] {
|
||||||
const mounts: VolumeMount[] = [];
|
const mounts: VolumeMount[] = [];
|
||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
@@ -76,17 +77,6 @@ function buildVolumeMounts(
|
|||||||
readonly: true,
|
readonly: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Shadow .env so the agent cannot read secrets from the mounted project root.
|
|
||||||
// Credentials are injected by the credential proxy, never exposed to containers.
|
|
||||||
const envFile = path.join(projectRoot, '.env');
|
|
||||||
if (fs.existsSync(envFile)) {
|
|
||||||
mounts.push({
|
|
||||||
hostPath: '/dev/null',
|
|
||||||
containerPath: '/workspace/project/.env',
|
|
||||||
readonly: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main also gets its group folder as the working directory
|
// Main also gets its group folder as the working directory
|
||||||
mounts.push({
|
mounts.push({
|
||||||
hostPath: groupDir,
|
hostPath: groupDir,
|
||||||
@@ -163,9 +153,13 @@ function buildVolumeMounts(
|
|||||||
readonly: false,
|
readonly: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Per-group IPC namespace: each group gets its own IPC directory
|
// Per-group IPC namespace: each group gets its own IPC directory.
|
||||||
// This prevents cross-group privilege escalation via IPC
|
// Task containers use a separate IPC dir (ipcSuffix='-task') to avoid
|
||||||
const groupIpcDir = resolveGroupIpcPath(group.folder);
|
// cross-contamination with concurrent user message containers.
|
||||||
|
const ipcFolderName = ipcSuffix
|
||||||
|
? `${group.folder}${ipcSuffix}`
|
||||||
|
: group.folder;
|
||||||
|
const groupIpcDir = path.join(DATA_DIR, 'ipc', ipcFolderName);
|
||||||
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
|
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
|
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
|
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
|
||||||
@@ -212,6 +206,25 @@ function buildVolumeMounts(
|
|||||||
return mounts;
|
return mounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read allowed secrets from .env for passing to the container via stdin.
|
||||||
|
* Secrets are never written to disk or mounted as files.
|
||||||
|
*/
|
||||||
|
function readSecrets(): Record<string, string> {
|
||||||
|
return readEnvFile([
|
||||||
|
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||||
|
'ANTHROPIC_API_KEY',
|
||||||
|
'PLANE_API_KEY',
|
||||||
|
'PLANE_BASE_URL',
|
||||||
|
'PLANE_WORKSPACE',
|
||||||
|
'EMAIL_ADDRESS',
|
||||||
|
'EMAIL_IMAP_PASSWORD',
|
||||||
|
'EMAIL_CALDAV_PASSWORD',
|
||||||
|
'CALDAV_URL',
|
||||||
|
'COOLIFY_API_KEY',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
function buildContainerArgs(
|
function buildContainerArgs(
|
||||||
mounts: VolumeMount[],
|
mounts: VolumeMount[],
|
||||||
containerName: string,
|
containerName: string,
|
||||||
@@ -221,26 +234,6 @@ function buildContainerArgs(
|
|||||||
// Pass host timezone so container's local time matches the user's
|
// Pass host timezone so container's local time matches the user's
|
||||||
args.push('-e', `TZ=${TIMEZONE}`);
|
args.push('-e', `TZ=${TIMEZONE}`);
|
||||||
|
|
||||||
// Route API traffic through the credential proxy (containers never see real secrets)
|
|
||||||
args.push(
|
|
||||||
'-e',
|
|
||||||
`ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mirror the host's auth method with a placeholder value.
|
|
||||||
// API key mode: SDK sends x-api-key, proxy replaces with real key.
|
|
||||||
// OAuth mode: SDK exchanges placeholder token for temp API key,
|
|
||||||
// proxy injects real OAuth token on that exchange request.
|
|
||||||
const authMode = detectAuthMode();
|
|
||||||
if (authMode === 'api-key') {
|
|
||||||
args.push('-e', 'ANTHROPIC_API_KEY=placeholder');
|
|
||||||
} else {
|
|
||||||
args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runtime-specific args for host gateway resolution
|
|
||||||
args.push(...hostGatewayArgs());
|
|
||||||
|
|
||||||
// Run as host user so bind-mounted files are accessible.
|
// Run as host user so bind-mounted files are accessible.
|
||||||
// Skip when running as root (uid 0), as the container's node user (uid 1000),
|
// Skip when running as root (uid 0), as the container's node user (uid 1000),
|
||||||
// or when getuid is unavailable (native Windows without WSL).
|
// or when getuid is unavailable (native Windows without WSL).
|
||||||
@@ -275,7 +268,25 @@ export async function runContainerAgent(
|
|||||||
const groupDir = resolveGroupFolderPath(group.folder);
|
const groupDir = resolveGroupFolderPath(group.folder);
|
||||||
fs.mkdirSync(groupDir, { recursive: true });
|
fs.mkdirSync(groupDir, { recursive: true });
|
||||||
|
|
||||||
const mounts = buildVolumeMounts(group, input.isMain);
|
const mounts = buildVolumeMounts(group, input.isMain, input.ipcSuffix);
|
||||||
|
|
||||||
|
// In rootless Docker, the container's node user (uid 1000) maps to a
|
||||||
|
// subordinate uid that can't write to host-owned directories.
|
||||||
|
// Make writable mounts world-accessible so the container user can write.
|
||||||
|
if (isRootlessDocker()) {
|
||||||
|
for (const mount of mounts) {
|
||||||
|
if (!mount.readonly) {
|
||||||
|
try {
|
||||||
|
execFileSync('chmod', ['-R', 'a+rwX', mount.hostPath], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||||
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
|
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
|
||||||
const containerArgs = buildContainerArgs(mounts, containerName);
|
const containerArgs = buildContainerArgs(mounts, containerName);
|
||||||
@@ -318,8 +329,12 @@ export async function runContainerAgent(
|
|||||||
let stdoutTruncated = false;
|
let stdoutTruncated = false;
|
||||||
let stderrTruncated = false;
|
let stderrTruncated = false;
|
||||||
|
|
||||||
|
// Pass secrets via stdin (never written to disk or mounted as files)
|
||||||
|
input.secrets = readSecrets();
|
||||||
container.stdin.write(JSON.stringify(input));
|
container.stdin.write(JSON.stringify(input));
|
||||||
container.stdin.end();
|
container.stdin.end();
|
||||||
|
// Remove secrets from input so they don't appear in logs
|
||||||
|
delete input.secrets;
|
||||||
|
|
||||||
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
|
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
|
||||||
let parseBuffer = '';
|
let parseBuffer = '';
|
||||||
@@ -369,10 +384,18 @@ export async function runContainerAgent(
|
|||||||
// so idle timers start even for "silent" query completions.
|
// so idle timers start even for "silent" query completions.
|
||||||
outputChain = outputChain.then(() => onOutput(parsed));
|
outputChain = outputChain.then(() => onOutput(parsed));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
parseErrorCount++;
|
||||||
|
if (parseErrorCount <= 3) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ group: group.name, error: err },
|
{ group: group.name, error: err },
|
||||||
'Failed to parse streamed output chunk',
|
'Failed to parse streamed output chunk',
|
||||||
);
|
);
|
||||||
|
} else if (parseErrorCount === 4) {
|
||||||
|
logger.warn(
|
||||||
|
{ group: group.name },
|
||||||
|
'Suppressing further parse error warnings for this container run',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,6 +425,7 @@ export async function runContainerAgent(
|
|||||||
|
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
let hadStreamingOutput = false;
|
let hadStreamingOutput = false;
|
||||||
|
let parseErrorCount = 0;
|
||||||
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
|
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
|
||||||
// Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
|
// Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
|
||||||
// graceful _close sentinel has time to trigger before the hard kill fires.
|
// graceful _close sentinel has time to trigger before the hard kill fires.
|
||||||
|
|||||||
@@ -2,51 +2,33 @@
|
|||||||
* Container runtime abstraction for NanoClaw.
|
* Container runtime abstraction for NanoClaw.
|
||||||
* All runtime-specific logic lives here so swapping runtimes means changing one file.
|
* All runtime-specific logic lives here so swapping runtimes means changing one file.
|
||||||
*/
|
*/
|
||||||
import { execSync } from 'child_process';
|
import { execFileSync, execSync } from 'child_process';
|
||||||
import fs from 'fs';
|
|
||||||
import os from 'os';
|
|
||||||
|
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
/** The container runtime binary name. */
|
/** The container runtime binary name. */
|
||||||
export const CONTAINER_RUNTIME_BIN = 'docker';
|
export const CONTAINER_RUNTIME_BIN = 'docker';
|
||||||
|
|
||||||
/** Hostname containers use to reach the host machine. */
|
/** Detect rootless Docker (container root maps to host user). */
|
||||||
export const CONTAINER_HOST_GATEWAY = 'host.docker.internal';
|
let _rootlessDocker: boolean | undefined;
|
||||||
|
export function isRootlessDocker(): boolean {
|
||||||
/**
|
if (_rootlessDocker === undefined) {
|
||||||
* Address the credential proxy binds to.
|
try {
|
||||||
* Docker Desktop (macOS): 127.0.0.1 — the VM routes host.docker.internal to loopback.
|
const info = execFileSync(
|
||||||
* Docker (Linux): bind to the docker0 bridge IP so only containers can reach it,
|
CONTAINER_RUNTIME_BIN,
|
||||||
* falling back to 0.0.0.0 if the interface isn't found.
|
['info', '--format', '{{.SecurityOptions}}'],
|
||||||
*/
|
{
|
||||||
export const PROXY_BIND_HOST =
|
stdio: 'pipe',
|
||||||
process.env.CREDENTIAL_PROXY_HOST || detectProxyBindHost();
|
encoding: 'utf-8',
|
||||||
|
timeout: 5000,
|
||||||
function detectProxyBindHost(): string {
|
},
|
||||||
if (os.platform() === 'darwin') return '127.0.0.1';
|
);
|
||||||
|
_rootlessDocker = info.includes('rootless');
|
||||||
// WSL uses Docker Desktop (same VM routing as macOS) — loopback is correct.
|
} catch {
|
||||||
// Check /proc filesystem, not env vars — WSL_DISTRO_NAME isn't set under systemd.
|
_rootlessDocker = false;
|
||||||
if (fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) return '127.0.0.1';
|
|
||||||
|
|
||||||
// Bare-metal Linux: bind to the docker0 bridge IP instead of 0.0.0.0
|
|
||||||
const ifaces = os.networkInterfaces();
|
|
||||||
const docker0 = ifaces['docker0'];
|
|
||||||
if (docker0) {
|
|
||||||
const ipv4 = docker0.find((a) => a.family === 'IPv4');
|
|
||||||
if (ipv4) return ipv4.address;
|
|
||||||
}
|
}
|
||||||
return '0.0.0.0';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** CLI args needed for the container to resolve the host gateway. */
|
|
||||||
export function hostGatewayArgs(): string[] {
|
|
||||||
// On Linux, host.docker.internal isn't built-in — add it explicitly
|
|
||||||
if (os.platform() === 'linux') {
|
|
||||||
return ['--add-host=host.docker.internal:host-gateway'];
|
|
||||||
}
|
}
|
||||||
return [];
|
return _rootlessDocker;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns CLI args for a readonly bind mount. */
|
/** Returns CLI args for a readonly bind mount. */
|
||||||
|
|||||||
113
src/db.test.ts
113
src/db.test.ts
@@ -2,14 +2,14 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
_initTestDatabase,
|
_initTestDatabase,
|
||||||
|
closeDatabase,
|
||||||
createTask,
|
createTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
getAllChats,
|
getAllChats,
|
||||||
getAllRegisteredGroups,
|
|
||||||
getMessagesSince,
|
getMessagesSince,
|
||||||
getNewMessages,
|
getNewMessages,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
setRegisteredGroup,
|
logTaskRun,
|
||||||
storeChatMetadata,
|
storeChatMetadata,
|
||||||
storeMessage,
|
storeMessage,
|
||||||
updateTask,
|
updateTask,
|
||||||
@@ -391,94 +391,37 @@ describe('task CRUD', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- LIMIT behavior ---
|
describe('closeDatabase', () => {
|
||||||
|
it('can be called without throwing', () => {
|
||||||
describe('message query LIMIT', () => {
|
expect(() => closeDatabase()).not.toThrow();
|
||||||
beforeEach(() => {
|
|
||||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
|
||||||
|
|
||||||
for (let i = 1; i <= 10; i++) {
|
|
||||||
store({
|
|
||||||
id: `lim-${i}`,
|
|
||||||
chat_jid: 'group@g.us',
|
|
||||||
sender: 'user@s.whatsapp.net',
|
|
||||||
sender_name: 'User',
|
|
||||||
content: `message ${i}`,
|
|
||||||
timestamp: `2024-01-01T00:00:${String(i).padStart(2, '0')}.000Z`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getNewMessages caps to limit and returns most recent in chronological order', () => {
|
|
||||||
const { messages, newTimestamp } = getNewMessages(
|
|
||||||
['group@g.us'],
|
|
||||||
'2024-01-01T00:00:00.000Z',
|
|
||||||
'Andy',
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
expect(messages).toHaveLength(3);
|
|
||||||
expect(messages[0].content).toBe('message 8');
|
|
||||||
expect(messages[2].content).toBe('message 10');
|
|
||||||
// Chronological order preserved
|
|
||||||
expect(messages[1].timestamp > messages[0].timestamp).toBe(true);
|
|
||||||
// newTimestamp reflects latest returned row
|
|
||||||
expect(newTimestamp).toBe('2024-01-01T00:00:10.000Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getMessagesSince caps to limit and returns most recent in chronological order', () => {
|
|
||||||
const messages = getMessagesSince(
|
|
||||||
'group@g.us',
|
|
||||||
'2024-01-01T00:00:00.000Z',
|
|
||||||
'Andy',
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
expect(messages).toHaveLength(3);
|
|
||||||
expect(messages[0].content).toBe('message 8');
|
|
||||||
expect(messages[2].content).toBe('message 10');
|
|
||||||
expect(messages[1].timestamp > messages[0].timestamp).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns all messages when count is under the limit', () => {
|
|
||||||
const { messages } = getNewMessages(
|
|
||||||
['group@g.us'],
|
|
||||||
'2024-01-01T00:00:00.000Z',
|
|
||||||
'Andy',
|
|
||||||
50,
|
|
||||||
);
|
|
||||||
expect(messages).toHaveLength(10);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- RegisteredGroup isMain round-trip ---
|
describe('deleteTask atomicity', () => {
|
||||||
|
it('deletes task and its logs', () => {
|
||||||
describe('registered group isMain', () => {
|
createTask({
|
||||||
it('persists isMain=true through set/get round-trip', () => {
|
id: 'task-del-1',
|
||||||
setRegisteredGroup('main@s.whatsapp.net', {
|
group_folder: 'main',
|
||||||
name: 'Main Chat',
|
chat_jid: 'jid@g.us',
|
||||||
folder: 'whatsapp_main',
|
prompt: 'test',
|
||||||
trigger: '@Andy',
|
schedule_type: 'once',
|
||||||
added_at: '2024-01-01T00:00:00.000Z',
|
schedule_value: '2026-01-01T00:00:00Z',
|
||||||
isMain: true,
|
context_mode: 'isolated',
|
||||||
|
next_run: '2026-01-01T00:00:00Z',
|
||||||
|
status: 'active',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
logTaskRun({
|
||||||
|
task_id: 'task-del-1',
|
||||||
|
run_at: new Date().toISOString(),
|
||||||
|
duration_ms: 100,
|
||||||
|
status: 'success',
|
||||||
|
result: 'ok',
|
||||||
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups = getAllRegisteredGroups();
|
deleteTask('task-del-1');
|
||||||
const group = groups['main@s.whatsapp.net'];
|
|
||||||
expect(group).toBeDefined();
|
|
||||||
expect(group.isMain).toBe(true);
|
|
||||||
expect(group.folder).toBe('whatsapp_main');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits isMain for non-main groups', () => {
|
expect(getTaskById('task-del-1')).toBeUndefined();
|
||||||
setRegisteredGroup('group@g.us', {
|
|
||||||
name: 'Family Chat',
|
|
||||||
folder: 'whatsapp_family-chat',
|
|
||||||
trigger: '@Andy',
|
|
||||||
added_at: '2024-01-01T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
const groups = getAllRegisteredGroups();
|
|
||||||
const group = groups['group@g.us'];
|
|
||||||
expect(group).toBeDefined();
|
|
||||||
expect(group.isMain).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
57
src/db.ts
57
src/db.ts
@@ -106,19 +106,6 @@ function createSchema(database: Database.Database): void {
|
|||||||
/* column already exists */
|
/* column already exists */
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add is_main column if it doesn't exist (migration for existing DBs)
|
|
||||||
try {
|
|
||||||
database.exec(
|
|
||||||
`ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`,
|
|
||||||
);
|
|
||||||
// Backfill: existing rows with folder = 'main' are the main group
|
|
||||||
database.exec(
|
|
||||||
`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
/* column already exists */
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add channel and is_group columns if they don't exist (migration for existing DBs)
|
// Add channel and is_group columns if they don't exist (migration for existing DBs)
|
||||||
try {
|
try {
|
||||||
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
|
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
|
||||||
@@ -158,6 +145,10 @@ export function _initTestDatabase(): void {
|
|||||||
createSchema(db);
|
createSchema(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function closeDatabase(): void {
|
||||||
|
db?.close();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
@@ -276,7 +267,7 @@ export function storeMessage(msg: NewMessage): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a message directly.
|
* Store a message directly (for non-WhatsApp channels that don't use Baileys proto).
|
||||||
*/
|
*/
|
||||||
export function storeMessageDirect(msg: {
|
export function storeMessageDirect(msg: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -306,29 +297,24 @@ export function getNewMessages(
|
|||||||
jids: string[],
|
jids: string[],
|
||||||
lastTimestamp: string,
|
lastTimestamp: string,
|
||||||
botPrefix: string,
|
botPrefix: string,
|
||||||
limit: number = 200,
|
|
||||||
): { messages: NewMessage[]; newTimestamp: string } {
|
): { messages: NewMessage[]; newTimestamp: string } {
|
||||||
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
|
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
|
||||||
|
|
||||||
const placeholders = jids.map(() => '?').join(',');
|
const placeholders = jids.map(() => '?').join(',');
|
||||||
// Filter bot messages using both the is_bot_message flag AND the content
|
// Filter bot messages using both the is_bot_message flag AND the content
|
||||||
// prefix as a backstop for messages written before the migration ran.
|
// prefix as a backstop for messages written before the migration ran.
|
||||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT * FROM (
|
SELECT id, chat_jid, sender, sender_name, content, timestamp
|
||||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
|
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
||||||
AND is_bot_message = 0 AND content NOT LIKE ?
|
AND is_bot_message = 0 AND content NOT LIKE ?
|
||||||
AND content != '' AND content IS NOT NULL
|
AND content != '' AND content IS NOT NULL
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp
|
||||||
LIMIT ?
|
|
||||||
) ORDER BY timestamp
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(sql)
|
.prepare(sql)
|
||||||
.all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[];
|
.all(lastTimestamp, ...jids, `${botPrefix}:%`) as NewMessage[];
|
||||||
|
|
||||||
let newTimestamp = lastTimestamp;
|
let newTimestamp = lastTimestamp;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -342,25 +328,20 @@ export function getMessagesSince(
|
|||||||
chatJid: string,
|
chatJid: string,
|
||||||
sinceTimestamp: string,
|
sinceTimestamp: string,
|
||||||
botPrefix: string,
|
botPrefix: string,
|
||||||
limit: number = 200,
|
|
||||||
): NewMessage[] {
|
): NewMessage[] {
|
||||||
// Filter bot messages using both the is_bot_message flag AND the content
|
// Filter bot messages using both the is_bot_message flag AND the content
|
||||||
// prefix as a backstop for messages written before the migration ran.
|
// prefix as a backstop for messages written before the migration ran.
|
||||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT * FROM (
|
SELECT id, chat_jid, sender, sender_name, content, timestamp
|
||||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
|
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE chat_jid = ? AND timestamp > ?
|
WHERE chat_jid = ? AND timestamp > ?
|
||||||
AND is_bot_message = 0 AND content NOT LIKE ?
|
AND is_bot_message = 0 AND content NOT LIKE ?
|
||||||
AND content != '' AND content IS NOT NULL
|
AND content != '' AND content IS NOT NULL
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp
|
||||||
LIMIT ?
|
|
||||||
) ORDER BY timestamp
|
|
||||||
`;
|
`;
|
||||||
return db
|
return db
|
||||||
.prepare(sql)
|
.prepare(sql)
|
||||||
.all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[];
|
.all(chatJid, sinceTimestamp, `${botPrefix}:%`) as NewMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTask(
|
export function createTask(
|
||||||
@@ -447,9 +428,10 @@ export function updateTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function deleteTask(id: string): void {
|
export function deleteTask(id: string): void {
|
||||||
// Delete child records first (FK constraint)
|
db.transaction(() => {
|
||||||
db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
|
db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
|
||||||
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
|
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDueTasks(): ScheduledTask[] {
|
export function getDueTasks(): ScheduledTask[] {
|
||||||
@@ -526,6 +508,10 @@ export function setSession(groupFolder: string, sessionId: string): void {
|
|||||||
).run(groupFolder, sessionId);
|
).run(groupFolder, sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteSession(groupFolder: string): void {
|
||||||
|
db.prepare('DELETE FROM sessions WHERE group_folder = ?').run(groupFolder);
|
||||||
|
}
|
||||||
|
|
||||||
export function getAllSessions(): Record<string, string> {
|
export function getAllSessions(): Record<string, string> {
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare('SELECT group_folder, session_id FROM sessions')
|
.prepare('SELECT group_folder, session_id FROM sessions')
|
||||||
@@ -553,7 +539,6 @@ export function getRegisteredGroup(
|
|||||||
added_at: string;
|
added_at: string;
|
||||||
container_config: string | null;
|
container_config: string | null;
|
||||||
requires_trigger: number | null;
|
requires_trigger: number | null;
|
||||||
is_main: number | null;
|
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!row) return undefined;
|
if (!row) return undefined;
|
||||||
@@ -575,7 +560,6 @@ export function getRegisteredGroup(
|
|||||||
: undefined,
|
: undefined,
|
||||||
requiresTrigger:
|
requiresTrigger:
|
||||||
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
||||||
isMain: row.is_main === 1 ? true : undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,8 +568,8 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
|||||||
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
|
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
|
||||||
}
|
}
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
|
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
jid,
|
jid,
|
||||||
group.name,
|
group.name,
|
||||||
@@ -594,7 +578,6 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
|||||||
group.added_at,
|
group.added_at,
|
||||||
group.containerConfig ? JSON.stringify(group.containerConfig) : null,
|
group.containerConfig ? JSON.stringify(group.containerConfig) : null,
|
||||||
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0,
|
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0,
|
||||||
group.isMain ? 1 : 0,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,7 +590,6 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
|||||||
added_at: string;
|
added_at: string;
|
||||||
container_config: string | null;
|
container_config: string | null;
|
||||||
requires_trigger: number | null;
|
requires_trigger: number | null;
|
||||||
is_main: number | null;
|
|
||||||
}>;
|
}>;
|
||||||
const result: Record<string, RegisteredGroup> = {};
|
const result: Record<string, RegisteredGroup> = {};
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -628,7 +610,6 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
|||||||
: undefined,
|
: undefined,
|
||||||
requiresTrigger:
|
requiresTrigger:
|
||||||
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
||||||
isMain: row.is_main === 1 ? true : undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -58,14 +58,13 @@ describe('escapeXml', () => {
|
|||||||
// --- formatMessages ---
|
// --- formatMessages ---
|
||||||
|
|
||||||
describe('formatMessages', () => {
|
describe('formatMessages', () => {
|
||||||
const TZ = 'UTC';
|
it('formats a single message as XML', () => {
|
||||||
|
const result = formatMessages([makeMsg()]);
|
||||||
it('formats a single message as XML with context header', () => {
|
expect(result).toBe(
|
||||||
const result = formatMessages([makeMsg()], TZ);
|
'<messages>\n' +
|
||||||
expect(result).toContain('<context timezone="UTC" />');
|
'<message sender="Alice" time="2024-01-01T00:00:00.000Z">hello</message>\n' +
|
||||||
expect(result).toContain('<message sender="Alice"');
|
'</messages>',
|
||||||
expect(result).toContain('>hello</message>');
|
);
|
||||||
expect(result).toContain('Jan 1, 2024');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats multiple messages', () => {
|
it('formats multiple messages', () => {
|
||||||
@@ -74,16 +73,11 @@ describe('formatMessages', () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
sender_name: 'Alice',
|
sender_name: 'Alice',
|
||||||
content: 'hi',
|
content: 'hi',
|
||||||
timestamp: '2024-01-01T00:00:00.000Z',
|
timestamp: 't1',
|
||||||
}),
|
|
||||||
makeMsg({
|
|
||||||
id: '2',
|
|
||||||
sender_name: 'Bob',
|
|
||||||
content: 'hey',
|
|
||||||
timestamp: '2024-01-01T01:00:00.000Z',
|
|
||||||
}),
|
}),
|
||||||
|
makeMsg({ id: '2', sender_name: 'Bob', content: 'hey', timestamp: 't2' }),
|
||||||
];
|
];
|
||||||
const result = formatMessages(msgs, TZ);
|
const result = formatMessages(msgs);
|
||||||
expect(result).toContain('sender="Alice"');
|
expect(result).toContain('sender="Alice"');
|
||||||
expect(result).toContain('sender="Bob"');
|
expect(result).toContain('sender="Bob"');
|
||||||
expect(result).toContain('>hi</message>');
|
expect(result).toContain('>hi</message>');
|
||||||
@@ -91,35 +85,22 @@ describe('formatMessages', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('escapes special characters in sender names', () => {
|
it('escapes special characters in sender names', () => {
|
||||||
const result = formatMessages([makeMsg({ sender_name: 'A & B <Co>' })], TZ);
|
const result = formatMessages([makeMsg({ sender_name: 'A & B <Co>' })]);
|
||||||
expect(result).toContain('sender="A & B <Co>"');
|
expect(result).toContain('sender="A & B <Co>"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('escapes special characters in content', () => {
|
it('escapes special characters in content', () => {
|
||||||
const result = formatMessages(
|
const result = formatMessages([
|
||||||
[makeMsg({ content: '<script>alert("xss")</script>' })],
|
makeMsg({ content: '<script>alert("xss")</script>' }),
|
||||||
TZ,
|
]);
|
||||||
);
|
|
||||||
expect(result).toContain(
|
expect(result).toContain(
|
||||||
'<script>alert("xss")</script>',
|
'<script>alert("xss")</script>',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty array', () => {
|
it('handles empty array', () => {
|
||||||
const result = formatMessages([], TZ);
|
const result = formatMessages([]);
|
||||||
expect(result).toContain('<context timezone="UTC" />');
|
expect(result).toBe('<messages>\n\n</messages>');
|
||||||
expect(result).toContain('<messages>\n\n</messages>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('converts timestamps to local time for given timezone', () => {
|
|
||||||
// 2024-01-01T18:30:00Z in America/New_York (EST) = 1:30 PM
|
|
||||||
const result = formatMessages(
|
|
||||||
[makeMsg({ timestamp: '2024-01-01T18:30:00.000Z' })],
|
|
||||||
'America/New_York',
|
|
||||||
);
|
|
||||||
expect(result).toContain('1:30');
|
|
||||||
expect(result).toContain('PM');
|
|
||||||
expect(result).toContain('<context timezone="America/New_York" />');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -243,41 +243,6 @@ describe('GroupQueue', () => {
|
|||||||
expect(processed).toContain('group3@g.us');
|
expect(processed).toContain('group3@g.us');
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Running task dedup (Issue #138) ---
|
|
||||||
|
|
||||||
it('rejects duplicate enqueue of a currently-running task', async () => {
|
|
||||||
let resolveTask: () => void;
|
|
||||||
let taskCallCount = 0;
|
|
||||||
|
|
||||||
const taskFn = vi.fn(async () => {
|
|
||||||
taskCallCount++;
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
resolveTask = resolve;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the task (runs immediately — slot available)
|
|
||||||
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
|
|
||||||
await vi.advanceTimersByTimeAsync(10);
|
|
||||||
expect(taskCallCount).toBe(1);
|
|
||||||
|
|
||||||
// Scheduler poll re-discovers the same task while it's running —
|
|
||||||
// this must be silently dropped
|
|
||||||
const dupFn = vi.fn(async () => {});
|
|
||||||
queue.enqueueTask('group1@g.us', 'task-1', dupFn);
|
|
||||||
await vi.advanceTimersByTimeAsync(10);
|
|
||||||
|
|
||||||
// Duplicate was NOT queued
|
|
||||||
expect(dupFn).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Complete the original task
|
|
||||||
resolveTask!();
|
|
||||||
await vi.advanceTimersByTimeAsync(10);
|
|
||||||
|
|
||||||
// Only one execution total
|
|
||||||
expect(taskCallCount).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Idle preemption ---
|
// --- Idle preemption ---
|
||||||
|
|
||||||
it('does NOT preempt active container when not idle', async () => {
|
it('does NOT preempt active container when not idle', async () => {
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ interface GroupState {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
idleWaiting: boolean;
|
idleWaiting: boolean;
|
||||||
isTaskContainer: boolean;
|
isTaskContainer: boolean;
|
||||||
runningTaskId: string | null;
|
|
||||||
pendingMessages: boolean;
|
pendingMessages: boolean;
|
||||||
pendingTasks: QueuedTask[];
|
pendingTasks: QueuedTask[];
|
||||||
process: ChildProcess | null;
|
process: ChildProcess | null;
|
||||||
containerName: string | null;
|
containerName: string | null;
|
||||||
groupFolder: string | null;
|
groupFolder: string | null;
|
||||||
|
/** IPC dir name — may differ from groupFolder for task containers (e.g. 'main-task'). */
|
||||||
|
ipcFolder: string | null;
|
||||||
retryCount: number;
|
retryCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,12 +43,12 @@ export class GroupQueue {
|
|||||||
active: false,
|
active: false,
|
||||||
idleWaiting: false,
|
idleWaiting: false,
|
||||||
isTaskContainer: false,
|
isTaskContainer: false,
|
||||||
runningTaskId: null,
|
|
||||||
pendingMessages: false,
|
pendingMessages: false,
|
||||||
pendingTasks: [],
|
pendingTasks: [],
|
||||||
process: null,
|
process: null,
|
||||||
containerName: null,
|
containerName: null,
|
||||||
groupFolder: null,
|
groupFolder: null,
|
||||||
|
ipcFolder: null,
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
};
|
};
|
||||||
this.groups.set(groupJid, state);
|
this.groups.set(groupJid, state);
|
||||||
@@ -92,11 +93,7 @@ export class GroupQueue {
|
|||||||
|
|
||||||
const state = this.getGroup(groupJid);
|
const state = this.getGroup(groupJid);
|
||||||
|
|
||||||
// Prevent double-queuing: check both pending and currently-running task
|
// Prevent double-queuing of the same task
|
||||||
if (state.runningTaskId === taskId) {
|
|
||||||
logger.debug({ groupJid, taskId }, 'Task already running, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.pendingTasks.some((t) => t.id === taskId)) {
|
if (state.pendingTasks.some((t) => t.id === taskId)) {
|
||||||
logger.debug({ groupJid, taskId }, 'Task already queued, skipping');
|
logger.debug({ groupJid, taskId }, 'Task already queued, skipping');
|
||||||
return;
|
return;
|
||||||
@@ -134,11 +131,13 @@ export class GroupQueue {
|
|||||||
proc: ChildProcess,
|
proc: ChildProcess,
|
||||||
containerName: string,
|
containerName: string,
|
||||||
groupFolder?: string,
|
groupFolder?: string,
|
||||||
|
ipcFolder?: string,
|
||||||
): void {
|
): void {
|
||||||
const state = this.getGroup(groupJid);
|
const state = this.getGroup(groupJid);
|
||||||
state.process = proc;
|
state.process = proc;
|
||||||
state.containerName = containerName;
|
state.containerName = containerName;
|
||||||
if (groupFolder) state.groupFolder = groupFolder;
|
if (groupFolder) state.groupFolder = groupFolder;
|
||||||
|
state.ipcFolder = ipcFolder ?? groupFolder ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,11 +158,11 @@ export class GroupQueue {
|
|||||||
*/
|
*/
|
||||||
sendMessage(groupJid: string, text: string): boolean {
|
sendMessage(groupJid: string, text: string): boolean {
|
||||||
const state = this.getGroup(groupJid);
|
const state = this.getGroup(groupJid);
|
||||||
if (!state.active || !state.groupFolder || state.isTaskContainer)
|
if (!state.active || !state.ipcFolder || state.isTaskContainer)
|
||||||
return false;
|
return false;
|
||||||
state.idleWaiting = false; // Agent is about to receive work, no longer idle
|
state.idleWaiting = false; // Agent is about to receive work, no longer idle
|
||||||
|
|
||||||
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
const inputDir = path.join(DATA_DIR, 'ipc', state.ipcFolder, 'input');
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(inputDir, { recursive: true });
|
fs.mkdirSync(inputDir, { recursive: true });
|
||||||
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}.json`;
|
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}.json`;
|
||||||
@@ -182,9 +181,9 @@ export class GroupQueue {
|
|||||||
*/
|
*/
|
||||||
closeStdin(groupJid: string): void {
|
closeStdin(groupJid: string): void {
|
||||||
const state = this.getGroup(groupJid);
|
const state = this.getGroup(groupJid);
|
||||||
if (!state.active || !state.groupFolder) return;
|
if (!state.active || !state.ipcFolder) return;
|
||||||
|
|
||||||
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
const inputDir = path.join(DATA_DIR, 'ipc', state.ipcFolder, 'input');
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(inputDir, { recursive: true });
|
fs.mkdirSync(inputDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(inputDir, '_close'), '');
|
fs.writeFileSync(path.join(inputDir, '_close'), '');
|
||||||
@@ -226,6 +225,7 @@ export class GroupQueue {
|
|||||||
state.process = null;
|
state.process = null;
|
||||||
state.containerName = null;
|
state.containerName = null;
|
||||||
state.groupFolder = null;
|
state.groupFolder = null;
|
||||||
|
state.ipcFolder = null;
|
||||||
this.activeCount--;
|
this.activeCount--;
|
||||||
this.drainGroup(groupJid);
|
this.drainGroup(groupJid);
|
||||||
}
|
}
|
||||||
@@ -236,7 +236,6 @@ export class GroupQueue {
|
|||||||
state.active = true;
|
state.active = true;
|
||||||
state.idleWaiting = false;
|
state.idleWaiting = false;
|
||||||
state.isTaskContainer = true;
|
state.isTaskContainer = true;
|
||||||
state.runningTaskId = task.id;
|
|
||||||
this.activeCount++;
|
this.activeCount++;
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -251,10 +250,10 @@ export class GroupQueue {
|
|||||||
} finally {
|
} finally {
|
||||||
state.active = false;
|
state.active = false;
|
||||||
state.isTaskContainer = false;
|
state.isTaskContainer = false;
|
||||||
state.runningTaskId = null;
|
|
||||||
state.process = null;
|
state.process = null;
|
||||||
state.containerName = null;
|
state.containerName = null;
|
||||||
state.groupFolder = null;
|
state.groupFolder = null;
|
||||||
|
state.ipcFolder = null;
|
||||||
this.activeCount--;
|
this.activeCount--;
|
||||||
this.drainGroup(groupJid);
|
this.drainGroup(groupJid);
|
||||||
}
|
}
|
||||||
|
|||||||
200
src/index.ts
200
src/index.ts
@@ -3,18 +3,16 @@ import path from 'path';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ASSISTANT_NAME,
|
ASSISTANT_NAME,
|
||||||
CREDENTIAL_PROXY_PORT,
|
DATA_DIR,
|
||||||
IDLE_TIMEOUT,
|
IDLE_TIMEOUT,
|
||||||
|
MAIN_GROUP_FOLDER,
|
||||||
POLL_INTERVAL,
|
POLL_INTERVAL,
|
||||||
TIMEZONE,
|
TELEGRAM_BOT_TOKEN,
|
||||||
|
TELEGRAM_ONLY,
|
||||||
TRIGGER_PATTERN,
|
TRIGGER_PATTERN,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { startCredentialProxy } from './credential-proxy.js';
|
import { WhatsAppChannel } from './channels/whatsapp.js';
|
||||||
import './channels/index.js';
|
import { TelegramChannel } from './channels/telegram.js';
|
||||||
import {
|
|
||||||
getChannelFactory,
|
|
||||||
getRegisteredChannelNames,
|
|
||||||
} from './channels/registry.js';
|
|
||||||
import {
|
import {
|
||||||
ContainerOutput,
|
ContainerOutput,
|
||||||
runContainerAgent,
|
runContainerAgent,
|
||||||
@@ -22,18 +20,14 @@ import {
|
|||||||
writeTasksSnapshot,
|
writeTasksSnapshot,
|
||||||
} from './container-runner.js';
|
} from './container-runner.js';
|
||||||
import {
|
import {
|
||||||
cleanupOrphans,
|
closeDatabase,
|
||||||
ensureContainerRuntimeRunning,
|
deleteSession,
|
||||||
PROXY_BIND_HOST,
|
|
||||||
} from './container-runtime.js';
|
|
||||||
import {
|
|
||||||
getAllChats,
|
getAllChats,
|
||||||
getAllRegisteredGroups,
|
getAllRegisteredGroups,
|
||||||
getAllSessions,
|
getAllSessions,
|
||||||
getAllTasks,
|
getAllTasks,
|
||||||
getMessagesSince,
|
getMessagesSince,
|
||||||
getNewMessages,
|
getNewMessages,
|
||||||
getRegisteredGroup,
|
|
||||||
getRouterState,
|
getRouterState,
|
||||||
initDatabase,
|
initDatabase,
|
||||||
setRegisteredGroup,
|
setRegisteredGroup,
|
||||||
@@ -43,18 +37,16 @@ import {
|
|||||||
storeMessage,
|
storeMessage,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { GroupQueue } from './group-queue.js';
|
import { GroupQueue } from './group-queue.js';
|
||||||
import { resolveGroupFolderPath } from './group-folder.js';
|
|
||||||
import { startIpcWatcher } from './ipc.js';
|
import { startIpcWatcher } from './ipc.js';
|
||||||
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
||||||
import {
|
|
||||||
isSenderAllowed,
|
|
||||||
isTriggerAllowed,
|
|
||||||
loadSenderAllowlist,
|
|
||||||
shouldDropMessage,
|
|
||||||
} from './sender-allowlist.js';
|
|
||||||
import { startSchedulerLoop } from './task-scheduler.js';
|
import { startSchedulerLoop } from './task-scheduler.js';
|
||||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import {
|
||||||
|
ensureContainerRuntimeRunning,
|
||||||
|
cleanupOrphans,
|
||||||
|
} from './container-runtime.js';
|
||||||
|
import { resolveGroupFolderPath } from './group-folder.js';
|
||||||
|
|
||||||
// Re-export for backwards compatibility during refactor
|
// Re-export for backwards compatibility during refactor
|
||||||
export { escapeXml, formatMessages } from './router.js';
|
export { escapeXml, formatMessages } from './router.js';
|
||||||
@@ -65,6 +57,7 @@ let registeredGroups: Record<string, RegisteredGroup> = {};
|
|||||||
let lastAgentTimestamp: Record<string, string> = {};
|
let lastAgentTimestamp: Record<string, string> = {};
|
||||||
let messageLoopRunning = false;
|
let messageLoopRunning = false;
|
||||||
|
|
||||||
|
let whatsapp: WhatsAppChannel;
|
||||||
const channels: Channel[] = [];
|
const channels: Channel[] = [];
|
||||||
const queue = new GroupQueue();
|
const queue = new GroupQueue();
|
||||||
|
|
||||||
@@ -91,22 +84,24 @@ function saveState(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function registerGroup(jid: string, group: RegisteredGroup): void {
|
function registerGroup(jid: string, group: RegisteredGroup): void {
|
||||||
let groupDir: string;
|
// Validate + persist first (setRegisteredGroup throws on invalid folder)
|
||||||
try {
|
|
||||||
groupDir = resolveGroupFolderPath(group.folder);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(
|
|
||||||
{ jid, folder: group.folder, err },
|
|
||||||
'Rejecting group registration with invalid folder',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
registeredGroups[jid] = group;
|
|
||||||
setRegisteredGroup(jid, group);
|
setRegisteredGroup(jid, group);
|
||||||
|
|
||||||
// Create group folder
|
// Create group folder after DB write so a mkdir failure doesn't leave
|
||||||
|
// a DB record with no matching directory
|
||||||
|
const groupDir = resolveGroupFolderPath(group.folder);
|
||||||
|
try {
|
||||||
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
{ jid, folder: group.folder, err },
|
||||||
|
'Failed to create group directory',
|
||||||
|
);
|
||||||
|
// Don't throw — DB record is written; directory can be created on next restart
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in-memory state after I/O succeeds
|
||||||
|
registeredGroups[jid] = group;
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ jid, name: group.name, folder: group.folder },
|
{ jid, name: group.name, folder: group.folder },
|
||||||
@@ -153,7 +148,7 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMainGroup = group.isMain === true;
|
const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
|
||||||
|
|
||||||
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
|
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
|
||||||
const missedMessages = getMessagesSince(
|
const missedMessages = getMessagesSince(
|
||||||
@@ -166,16 +161,13 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
|||||||
|
|
||||||
// For non-main groups, check if trigger is required and present
|
// For non-main groups, check if trigger is required and present
|
||||||
if (!isMainGroup && group.requiresTrigger !== false) {
|
if (!isMainGroup && group.requiresTrigger !== false) {
|
||||||
const allowlistCfg = loadSenderAllowlist();
|
const hasTrigger = missedMessages.some((m) =>
|
||||||
const hasTrigger = missedMessages.some(
|
TRIGGER_PATTERN.test(m.content.trim()),
|
||||||
(m) =>
|
|
||||||
TRIGGER_PATTERN.test(m.content.trim()) &&
|
|
||||||
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
|
|
||||||
);
|
);
|
||||||
if (!hasTrigger) return true;
|
if (!hasTrigger) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = formatMessages(missedMessages, TIMEZONE);
|
const prompt = formatMessages(missedMessages);
|
||||||
|
|
||||||
// Advance cursor so the piping path in startMessageLoop won't re-fetch
|
// Advance cursor so the piping path in startMessageLoop won't re-fetch
|
||||||
// these messages. Save the old cursor so we can roll back on error.
|
// these messages. Save the old cursor so we can roll back on error.
|
||||||
@@ -225,10 +217,6 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
|||||||
resetIdleTimer();
|
resetIdleTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status === 'success') {
|
|
||||||
queue.notifyIdle(chatJid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status === 'error') {
|
if (result.status === 'error') {
|
||||||
hadError = true;
|
hadError = true;
|
||||||
}
|
}
|
||||||
@@ -266,7 +254,7 @@ async function runAgent(
|
|||||||
chatJid: string,
|
chatJid: string,
|
||||||
onOutput?: (output: ContainerOutput) => Promise<void>,
|
onOutput?: (output: ContainerOutput) => Promise<void>,
|
||||||
): Promise<'success' | 'error'> {
|
): Promise<'success' | 'error'> {
|
||||||
const isMain = group.isMain === true;
|
const isMain = group.folder === MAIN_GROUP_FOLDER;
|
||||||
const sessionId = sessions[group.folder];
|
const sessionId = sessions[group.folder];
|
||||||
|
|
||||||
// Update tasks snapshot for container to read (filtered by group)
|
// Update tasks snapshot for container to read (filtered by group)
|
||||||
@@ -314,7 +302,6 @@ async function runAgent(
|
|||||||
groupFolder: group.folder,
|
groupFolder: group.folder,
|
||||||
chatJid,
|
chatJid,
|
||||||
isMain,
|
isMain,
|
||||||
assistantName: ASSISTANT_NAME,
|
|
||||||
},
|
},
|
||||||
(proc, containerName) =>
|
(proc, containerName) =>
|
||||||
queue.registerProcess(chatJid, proc, containerName, group.folder),
|
queue.registerProcess(chatJid, proc, containerName, group.folder),
|
||||||
@@ -350,6 +337,7 @@ async function startMessageLoop(): Promise<void> {
|
|||||||
|
|
||||||
logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
|
logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
|
||||||
|
|
||||||
|
let consecutiveErrors = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
const jids = Object.keys(registeredGroups);
|
const jids = Object.keys(registeredGroups);
|
||||||
@@ -387,19 +375,15 @@ async function startMessageLoop(): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMainGroup = group.isMain === true;
|
const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
|
||||||
const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
|
const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
|
||||||
|
|
||||||
// For non-main groups, only act on trigger messages.
|
// For non-main groups, only act on trigger messages.
|
||||||
// Non-trigger messages accumulate in DB and get pulled as
|
// Non-trigger messages accumulate in DB and get pulled as
|
||||||
// context when a trigger eventually arrives.
|
// context when a trigger eventually arrives.
|
||||||
if (needsTrigger) {
|
if (needsTrigger) {
|
||||||
const allowlistCfg = loadSenderAllowlist();
|
const hasTrigger = groupMessages.some((m) =>
|
||||||
const hasTrigger = groupMessages.some(
|
TRIGGER_PATTERN.test(m.content.trim()),
|
||||||
(m) =>
|
|
||||||
TRIGGER_PATTERN.test(m.content.trim()) &&
|
|
||||||
(m.is_from_me ||
|
|
||||||
isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
|
|
||||||
);
|
);
|
||||||
if (!hasTrigger) continue;
|
if (!hasTrigger) continue;
|
||||||
}
|
}
|
||||||
@@ -413,7 +397,7 @@ async function startMessageLoop(): Promise<void> {
|
|||||||
);
|
);
|
||||||
const messagesToSend =
|
const messagesToSend =
|
||||||
allPending.length > 0 ? allPending : groupMessages;
|
allPending.length > 0 ? allPending : groupMessages;
|
||||||
const formatted = formatMessages(messagesToSend, TIMEZONE);
|
const formatted = formatMessages(messagesToSend);
|
||||||
|
|
||||||
if (queue.sendMessage(chatJid, formatted)) {
|
if (queue.sendMessage(chatJid, formatted)) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -435,8 +419,17 @@ async function startMessageLoop(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
consecutiveErrors = 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
consecutiveErrors++;
|
||||||
|
if (consecutiveErrors === 1) {
|
||||||
logger.error({ err }, 'Error in message loop');
|
logger.error({ err }, 'Error in message loop');
|
||||||
|
} else if (consecutiveErrors % 10 === 0) {
|
||||||
|
logger.error(
|
||||||
|
{ err, consecutiveErrors },
|
||||||
|
'Message loop error persisting — check DB and disk',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
|
||||||
}
|
}
|
||||||
@@ -460,29 +453,19 @@ function recoverPendingMessages(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureContainerSystemRunning(): void {
|
async function main(): Promise<void> {
|
||||||
ensureContainerRuntimeRunning();
|
ensureContainerRuntimeRunning();
|
||||||
cleanupOrphans();
|
cleanupOrphans();
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
ensureContainerSystemRunning();
|
|
||||||
initDatabase();
|
initDatabase();
|
||||||
logger.info('Database initialized');
|
logger.info('Database initialized');
|
||||||
loadState();
|
loadState();
|
||||||
|
|
||||||
// Start credential proxy (containers route API calls through this)
|
|
||||||
const proxyServer = await startCredentialProxy(
|
|
||||||
CREDENTIAL_PROXY_PORT,
|
|
||||||
PROXY_BIND_HOST,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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');
|
||||||
proxyServer.close();
|
|
||||||
await queue.shutdown(10000);
|
await queue.shutdown(10000);
|
||||||
for (const ch of channels) await ch.disconnect();
|
for (const ch of channels) await ch.disconnect();
|
||||||
|
closeDatabase();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
@@ -490,25 +473,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// Channel callbacks (shared by all channels)
|
// Channel callbacks (shared by all channels)
|
||||||
const channelOpts = {
|
const channelOpts = {
|
||||||
onMessage: (chatJid: string, msg: NewMessage) => {
|
onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
|
||||||
// Sender allowlist drop mode: discard messages from denied senders before storing
|
|
||||||
if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
|
|
||||||
const cfg = loadSenderAllowlist();
|
|
||||||
if (
|
|
||||||
shouldDropMessage(chatJid, cfg) &&
|
|
||||||
!isSenderAllowed(chatJid, msg.sender, cfg)
|
|
||||||
) {
|
|
||||||
if (cfg.logDenied) {
|
|
||||||
logger.debug(
|
|
||||||
{ chatJid, sender: msg.sender },
|
|
||||||
'sender-allowlist: dropping message (drop mode)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
storeMessage(msg);
|
|
||||||
},
|
|
||||||
onChatMetadata: (
|
onChatMetadata: (
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
timestamp: string,
|
timestamp: string,
|
||||||
@@ -517,27 +482,30 @@ async function main(): Promise<void> {
|
|||||||
isGroup?: boolean,
|
isGroup?: boolean,
|
||||||
) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
|
) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
|
||||||
registeredGroups: () => registeredGroups,
|
registeredGroups: () => registeredGroups,
|
||||||
|
clearSession: (chatJid: string) => {
|
||||||
|
const group = registeredGroups[chatJid];
|
||||||
|
if (group) {
|
||||||
|
delete sessions[group.folder];
|
||||||
|
deleteSession(group.folder);
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, folder: group.folder },
|
||||||
|
'Session cleared via /reset',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create and connect all registered channels.
|
// Create and connect channels
|
||||||
// Each channel self-registers via the barrel import above.
|
if (!TELEGRAM_ONLY) {
|
||||||
// Factories return null when credentials are missing, so unconfigured channels are skipped.
|
whatsapp = new WhatsAppChannel(channelOpts);
|
||||||
for (const channelName of getRegisteredChannelNames()) {
|
channels.push(whatsapp);
|
||||||
const factory = getChannelFactory(channelName)!;
|
await whatsapp.connect();
|
||||||
const channel = factory(channelOpts);
|
|
||||||
if (!channel) {
|
|
||||||
logger.warn(
|
|
||||||
{ channel: channelName },
|
|
||||||
'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.',
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
channels.push(channel);
|
|
||||||
await channel.connect();
|
if (TELEGRAM_BOT_TOKEN) {
|
||||||
}
|
const telegram = new TelegramChannel(TELEGRAM_BOT_TOKEN, channelOpts);
|
||||||
if (channels.length === 0) {
|
channels.push(telegram);
|
||||||
logger.fatal('No channels connected');
|
await telegram.connect();
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start subsystems (independently of connection handler)
|
// Start subsystems (independently of connection handler)
|
||||||
@@ -545,8 +513,14 @@ async function main(): Promise<void> {
|
|||||||
registeredGroups: () => registeredGroups,
|
registeredGroups: () => registeredGroups,
|
||||||
getSessions: () => sessions,
|
getSessions: () => sessions,
|
||||||
queue,
|
queue,
|
||||||
onProcess: (groupJid, proc, containerName, groupFolder) =>
|
onProcess: (queueKey, proc, containerName, groupFolder, ipcFolder) =>
|
||||||
queue.registerProcess(groupJid, proc, containerName, groupFolder),
|
queue.registerProcess(
|
||||||
|
queueKey,
|
||||||
|
proc,
|
||||||
|
containerName,
|
||||||
|
groupFolder,
|
||||||
|
ipcFolder,
|
||||||
|
),
|
||||||
sendMessage: async (jid, rawText) => {
|
sendMessage: async (jid, rawText) => {
|
||||||
const channel = findChannel(channels, jid);
|
const channel = findChannel(channels, jid);
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
@@ -565,23 +539,15 @@ async function main(): Promise<void> {
|
|||||||
},
|
},
|
||||||
registeredGroups: () => registeredGroups,
|
registeredGroups: () => registeredGroups,
|
||||||
registerGroup,
|
registerGroup,
|
||||||
syncGroups: async (force: boolean) => {
|
syncGroupMetadata: (force) =>
|
||||||
await Promise.all(
|
whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
|
||||||
channels
|
|
||||||
.filter((ch) => ch.syncGroups)
|
|
||||||
.map((ch) => ch.syncGroups!(force)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getAvailableGroups,
|
getAvailableGroups,
|
||||||
writeGroupsSnapshot: (gf, im, ag, rj) =>
|
writeGroupsSnapshot: (gf, im, ag, rj) =>
|
||||||
writeGroupsSnapshot(gf, im, ag, rj),
|
writeGroupsSnapshot(gf, im, ag, rj),
|
||||||
});
|
});
|
||||||
queue.setProcessMessagesFn(processGroupMessages);
|
queue.setProcessMessagesFn(processGroupMessages);
|
||||||
recoverPendingMessages();
|
recoverPendingMessages();
|
||||||
startMessageLoop().catch((err) => {
|
startMessageLoop();
|
||||||
logger.fatal({ err }, 'Message loop crashed unexpectedly');
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard: only run when executed directly, not when imported by tests
|
// Guard: only run when executed directly, not when imported by tests
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ import { RegisteredGroup } from './types.js';
|
|||||||
// Set up registered groups used across tests
|
// Set up registered groups used across tests
|
||||||
const MAIN_GROUP: RegisteredGroup = {
|
const MAIN_GROUP: RegisteredGroup = {
|
||||||
name: 'Main',
|
name: 'Main',
|
||||||
folder: 'whatsapp_main',
|
folder: 'main',
|
||||||
trigger: 'always',
|
trigger: 'always',
|
||||||
added_at: '2024-01-01T00:00:00.000Z',
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
isMain: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const OTHER_GROUP: RegisteredGroup = {
|
const OTHER_GROUP: RegisteredGroup = {
|
||||||
@@ -59,7 +58,7 @@ beforeEach(() => {
|
|||||||
setRegisteredGroup(jid, group);
|
setRegisteredGroup(jid, group);
|
||||||
// Mock the fs.mkdirSync that registerGroup does
|
// Mock the fs.mkdirSync that registerGroup does
|
||||||
},
|
},
|
||||||
syncGroups: async () => {},
|
syncGroupMetadata: async () => {},
|
||||||
getAvailableGroups: () => [],
|
getAvailableGroups: () => [],
|
||||||
writeGroupsSnapshot: () => {},
|
writeGroupsSnapshot: () => {},
|
||||||
};
|
};
|
||||||
@@ -74,10 +73,10 @@ describe('schedule_task authorization', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'do something',
|
prompt: 'do something',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -94,7 +93,7 @@ describe('schedule_task authorization', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'self task',
|
prompt: 'self task',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'other-group',
|
'other-group',
|
||||||
@@ -113,7 +112,7 @@ describe('schedule_task authorization', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'unauthorized',
|
prompt: 'unauthorized',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
targetJid: 'main@g.us',
|
targetJid: 'main@g.us',
|
||||||
},
|
},
|
||||||
'other-group',
|
'other-group',
|
||||||
@@ -131,10 +130,10 @@ describe('schedule_task authorization', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'no target',
|
prompt: 'no target',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
targetJid: 'unknown@g.us',
|
targetJid: 'unknown@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -150,11 +149,11 @@ describe('pause_task authorization', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createTask({
|
createTask({
|
||||||
id: 'task-main',
|
id: 'task-main',
|
||||||
group_folder: 'whatsapp_main',
|
group_folder: 'main',
|
||||||
chat_jid: 'main@g.us',
|
chat_jid: 'main@g.us',
|
||||||
prompt: 'main task',
|
prompt: 'main task',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'isolated',
|
context_mode: 'isolated',
|
||||||
next_run: '2025-06-01T00:00:00.000Z',
|
next_run: '2025-06-01T00:00:00.000Z',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -166,7 +165,7 @@ describe('pause_task authorization', () => {
|
|||||||
chat_jid: 'other@g.us',
|
chat_jid: 'other@g.us',
|
||||||
prompt: 'other task',
|
prompt: 'other task',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'isolated',
|
context_mode: 'isolated',
|
||||||
next_run: '2025-06-01T00:00:00.000Z',
|
next_run: '2025-06-01T00:00:00.000Z',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -177,7 +176,7 @@ describe('pause_task authorization', () => {
|
|||||||
it('main group can pause any task', async () => {
|
it('main group can pause any task', async () => {
|
||||||
await processTaskIpc(
|
await processTaskIpc(
|
||||||
{ type: 'pause_task', taskId: 'task-other' },
|
{ type: 'pause_task', taskId: 'task-other' },
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -215,7 +214,7 @@ describe('resume_task authorization', () => {
|
|||||||
chat_jid: 'other@g.us',
|
chat_jid: 'other@g.us',
|
||||||
prompt: 'paused task',
|
prompt: 'paused task',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'isolated',
|
context_mode: 'isolated',
|
||||||
next_run: '2025-06-01T00:00:00.000Z',
|
next_run: '2025-06-01T00:00:00.000Z',
|
||||||
status: 'paused',
|
status: 'paused',
|
||||||
@@ -226,7 +225,7 @@ describe('resume_task authorization', () => {
|
|||||||
it('main group can resume any task', async () => {
|
it('main group can resume any task', async () => {
|
||||||
await processTaskIpc(
|
await processTaskIpc(
|
||||||
{ type: 'resume_task', taskId: 'task-paused' },
|
{ type: 'resume_task', taskId: 'task-paused' },
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -264,7 +263,7 @@ describe('cancel_task authorization', () => {
|
|||||||
chat_jid: 'other@g.us',
|
chat_jid: 'other@g.us',
|
||||||
prompt: 'cancel me',
|
prompt: 'cancel me',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'isolated',
|
context_mode: 'isolated',
|
||||||
next_run: null,
|
next_run: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -273,7 +272,7 @@ describe('cancel_task authorization', () => {
|
|||||||
|
|
||||||
await processTaskIpc(
|
await processTaskIpc(
|
||||||
{ type: 'cancel_task', taskId: 'task-to-cancel' },
|
{ type: 'cancel_task', taskId: 'task-to-cancel' },
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -287,7 +286,7 @@ describe('cancel_task authorization', () => {
|
|||||||
chat_jid: 'other@g.us',
|
chat_jid: 'other@g.us',
|
||||||
prompt: 'my task',
|
prompt: 'my task',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'isolated',
|
context_mode: 'isolated',
|
||||||
next_run: null,
|
next_run: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -306,11 +305,11 @@ describe('cancel_task authorization', () => {
|
|||||||
it('non-main group cannot cancel another groups task', async () => {
|
it('non-main group cannot cancel another groups task', async () => {
|
||||||
createTask({
|
createTask({
|
||||||
id: 'task-foreign',
|
id: 'task-foreign',
|
||||||
group_folder: 'whatsapp_main',
|
group_folder: 'main',
|
||||||
chat_jid: 'main@g.us',
|
chat_jid: 'main@g.us',
|
||||||
prompt: 'not yours',
|
prompt: 'not yours',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'isolated',
|
context_mode: 'isolated',
|
||||||
next_run: null,
|
next_run: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -357,7 +356,7 @@ describe('register_group authorization', () => {
|
|||||||
folder: '../../outside',
|
folder: '../../outside',
|
||||||
trigger: '@Andy',
|
trigger: '@Andy',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -398,12 +397,8 @@ describe('IPC message authorization', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('main group can send to any group', () => {
|
it('main group can send to any group', () => {
|
||||||
expect(
|
expect(isMessageAuthorized('main', true, 'other@g.us', groups)).toBe(true);
|
||||||
isMessageAuthorized('whatsapp_main', true, 'other@g.us', groups),
|
expect(isMessageAuthorized('main', true, 'third@g.us', groups)).toBe(true);
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
isMessageAuthorized('whatsapp_main', true, 'third@g.us', groups),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('non-main group can send to its own chat', () => {
|
it('non-main group can send to its own chat', () => {
|
||||||
@@ -429,9 +424,9 @@ describe('IPC message authorization', () => {
|
|||||||
|
|
||||||
it('main group can send to unregistered JID', () => {
|
it('main group can send to unregistered JID', () => {
|
||||||
// Main is always authorized regardless of target
|
// Main is always authorized regardless of target
|
||||||
expect(
|
expect(isMessageAuthorized('main', true, 'unknown@g.us', groups)).toBe(
|
||||||
isMessageAuthorized('whatsapp_main', true, 'unknown@g.us', groups),
|
true,
|
||||||
).toBe(true);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -447,7 +442,7 @@ describe('schedule_task schedule types', () => {
|
|||||||
schedule_value: '0 9 * * *', // every day at 9am
|
schedule_value: '0 9 * * *', // every day at 9am
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -471,7 +466,7 @@ describe('schedule_task schedule types', () => {
|
|||||||
schedule_value: 'not a cron',
|
schedule_value: 'not a cron',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -490,7 +485,7 @@ describe('schedule_task schedule types', () => {
|
|||||||
schedule_value: '3600000', // 1 hour
|
schedule_value: '3600000', // 1 hour
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -513,7 +508,7 @@ describe('schedule_task schedule types', () => {
|
|||||||
schedule_value: 'abc',
|
schedule_value: 'abc',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -530,7 +525,7 @@ describe('schedule_task schedule types', () => {
|
|||||||
schedule_value: '0',
|
schedule_value: '0',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -547,7 +542,7 @@ describe('schedule_task schedule types', () => {
|
|||||||
schedule_value: 'not-a-date',
|
schedule_value: 'not-a-date',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -565,11 +560,11 @@ describe('schedule_task context_mode', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'group context',
|
prompt: 'group context',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'group',
|
context_mode: 'group',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -584,11 +579,11 @@ describe('schedule_task context_mode', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'isolated context',
|
prompt: 'isolated context',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'isolated',
|
context_mode: 'isolated',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -603,11 +598,11 @@ describe('schedule_task context_mode', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'bad context',
|
prompt: 'bad context',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'bogus' as any,
|
context_mode: 'bogus' as any,
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -622,10 +617,10 @@ describe('schedule_task context_mode', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'no context mode',
|
prompt: 'no context mode',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -647,7 +642,7 @@ describe('register_group success', () => {
|
|||||||
folder: 'new-group',
|
folder: 'new-group',
|
||||||
trigger: '@Andy',
|
trigger: '@Andy',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -668,7 +663,7 @@ describe('register_group success', () => {
|
|||||||
name: 'Partial',
|
name: 'Partial',
|
||||||
// missing folder and trigger
|
// missing folder and trigger
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
|
|||||||
133
src/ipc.ts
133
src/ipc.ts
@@ -3,7 +3,12 @@ import path from 'path';
|
|||||||
|
|
||||||
import { CronExpressionParser } from 'cron-parser';
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
|
|
||||||
import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js';
|
import {
|
||||||
|
DATA_DIR,
|
||||||
|
IPC_POLL_INTERVAL,
|
||||||
|
MAIN_GROUP_FOLDER,
|
||||||
|
TIMEZONE,
|
||||||
|
} from './config.js';
|
||||||
import { AvailableGroup } from './container-runner.js';
|
import { AvailableGroup } from './container-runner.js';
|
||||||
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
|
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
|
||||||
import { isValidGroupFolder } from './group-folder.js';
|
import { isValidGroupFolder } from './group-folder.js';
|
||||||
@@ -14,7 +19,7 @@ export interface IpcDeps {
|
|||||||
sendMessage: (jid: string, text: string) => Promise<void>;
|
sendMessage: (jid: string, text: string) => Promise<void>;
|
||||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||||
registerGroup: (jid: string, group: RegisteredGroup) => void;
|
registerGroup: (jid: string, group: RegisteredGroup) => void;
|
||||||
syncGroups: (force: boolean) => Promise<void>;
|
syncGroupMetadata: (force: boolean) => Promise<void>;
|
||||||
getAvailableGroups: () => AvailableGroup[];
|
getAvailableGroups: () => AvailableGroup[];
|
||||||
writeGroupsSnapshot: (
|
writeGroupsSnapshot: (
|
||||||
groupFolder: string,
|
groupFolder: string,
|
||||||
@@ -52,14 +57,13 @@ export function startIpcWatcher(deps: IpcDeps): void {
|
|||||||
|
|
||||||
const registeredGroups = deps.registeredGroups();
|
const registeredGroups = deps.registeredGroups();
|
||||||
|
|
||||||
// Build folder→isMain lookup from registered groups
|
|
||||||
const folderIsMain = new Map<string, boolean>();
|
|
||||||
for (const group of Object.values(registeredGroups)) {
|
|
||||||
if (group.isMain) folderIsMain.set(group.folder, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const sourceGroup of groupFolders) {
|
for (const sourceGroup of groupFolders) {
|
||||||
const isMain = folderIsMain.get(sourceGroup) === true;
|
// Strip '-task' suffix so task containers (e.g. 'main-task') are authorized
|
||||||
|
// as their base group ('main') for all IPC operations.
|
||||||
|
const baseFolder = sourceGroup.endsWith('-task')
|
||||||
|
? sourceGroup.slice(0, -5)
|
||||||
|
: sourceGroup;
|
||||||
|
const isMain = baseFolder === MAIN_GROUP_FOLDER;
|
||||||
const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages');
|
const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages');
|
||||||
const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks');
|
const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks');
|
||||||
|
|
||||||
@@ -78,7 +82,9 @@ export function startIpcWatcher(deps: IpcDeps): void {
|
|||||||
const targetGroup = registeredGroups[data.chatJid];
|
const targetGroup = registeredGroups[data.chatJid];
|
||||||
if (
|
if (
|
||||||
isMain ||
|
isMain ||
|
||||||
(targetGroup && targetGroup.folder === sourceGroup)
|
(targetGroup &&
|
||||||
|
(targetGroup.folder === sourceGroup ||
|
||||||
|
targetGroup.folder === baseFolder))
|
||||||
) {
|
) {
|
||||||
await deps.sendMessage(data.chatJid, data.text);
|
await deps.sendMessage(data.chatJid, data.text);
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -177,6 +183,9 @@ export async function processTaskIpc(
|
|||||||
deps: IpcDeps,
|
deps: IpcDeps,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const registeredGroups = deps.registeredGroups();
|
const registeredGroups = deps.registeredGroups();
|
||||||
|
const baseFolder = sourceGroup.endsWith('-task')
|
||||||
|
? sourceGroup.slice(0, -5)
|
||||||
|
: sourceGroup;
|
||||||
|
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'schedule_task':
|
case 'schedule_task':
|
||||||
@@ -201,7 +210,11 @@ export async function processTaskIpc(
|
|||||||
const targetFolder = targetGroupEntry.folder;
|
const targetFolder = targetGroupEntry.folder;
|
||||||
|
|
||||||
// Authorization: non-main groups can only schedule for themselves
|
// Authorization: non-main groups can only schedule for themselves
|
||||||
if (!isMain && targetFolder !== sourceGroup) {
|
if (
|
||||||
|
!isMain &&
|
||||||
|
targetFolder !== sourceGroup &&
|
||||||
|
targetFolder !== baseFolder
|
||||||
|
) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ sourceGroup, targetFolder },
|
{ sourceGroup, targetFolder },
|
||||||
'Unauthorized schedule_task attempt blocked',
|
'Unauthorized schedule_task attempt blocked',
|
||||||
@@ -236,20 +249,18 @@ export async function processTaskIpc(
|
|||||||
}
|
}
|
||||||
nextRun = new Date(Date.now() + ms).toISOString();
|
nextRun = new Date(Date.now() + ms).toISOString();
|
||||||
} else if (scheduleType === 'once') {
|
} else if (scheduleType === 'once') {
|
||||||
const date = new Date(data.schedule_value);
|
const scheduled = new Date(data.schedule_value);
|
||||||
if (isNaN(date.getTime())) {
|
if (isNaN(scheduled.getTime())) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ scheduleValue: data.schedule_value },
|
{ scheduleValue: data.schedule_value },
|
||||||
'Invalid timestamp',
|
'Invalid timestamp',
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
nextRun = date.toISOString();
|
nextRun = scheduled.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskId =
|
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
data.taskId ||
|
|
||||||
`task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
const contextMode =
|
const contextMode =
|
||||||
data.context_mode === 'group' || data.context_mode === 'isolated'
|
data.context_mode === 'group' || data.context_mode === 'isolated'
|
||||||
? data.context_mode
|
? data.context_mode
|
||||||
@@ -276,7 +287,12 @@ export async function processTaskIpc(
|
|||||||
case 'pause_task':
|
case 'pause_task':
|
||||||
if (data.taskId) {
|
if (data.taskId) {
|
||||||
const task = getTaskById(data.taskId);
|
const task = getTaskById(data.taskId);
|
||||||
if (task && (isMain || task.group_folder === sourceGroup)) {
|
if (
|
||||||
|
task &&
|
||||||
|
(isMain ||
|
||||||
|
task.group_folder === sourceGroup ||
|
||||||
|
task.group_folder === baseFolder)
|
||||||
|
) {
|
||||||
updateTask(data.taskId, { status: 'paused' });
|
updateTask(data.taskId, { status: 'paused' });
|
||||||
logger.info(
|
logger.info(
|
||||||
{ taskId: data.taskId, sourceGroup },
|
{ taskId: data.taskId, sourceGroup },
|
||||||
@@ -294,7 +310,12 @@ export async function processTaskIpc(
|
|||||||
case 'resume_task':
|
case 'resume_task':
|
||||||
if (data.taskId) {
|
if (data.taskId) {
|
||||||
const task = getTaskById(data.taskId);
|
const task = getTaskById(data.taskId);
|
||||||
if (task && (isMain || task.group_folder === sourceGroup)) {
|
if (
|
||||||
|
task &&
|
||||||
|
(isMain ||
|
||||||
|
task.group_folder === sourceGroup ||
|
||||||
|
task.group_folder === baseFolder)
|
||||||
|
) {
|
||||||
updateTask(data.taskId, { status: 'active' });
|
updateTask(data.taskId, { status: 'active' });
|
||||||
logger.info(
|
logger.info(
|
||||||
{ taskId: data.taskId, sourceGroup },
|
{ taskId: data.taskId, sourceGroup },
|
||||||
@@ -312,7 +333,12 @@ export async function processTaskIpc(
|
|||||||
case 'cancel_task':
|
case 'cancel_task':
|
||||||
if (data.taskId) {
|
if (data.taskId) {
|
||||||
const task = getTaskById(data.taskId);
|
const task = getTaskById(data.taskId);
|
||||||
if (task && (isMain || task.group_folder === sourceGroup)) {
|
if (
|
||||||
|
task &&
|
||||||
|
(isMain ||
|
||||||
|
task.group_folder === sourceGroup ||
|
||||||
|
task.group_folder === baseFolder)
|
||||||
|
) {
|
||||||
deleteTask(data.taskId);
|
deleteTask(data.taskId);
|
||||||
logger.info(
|
logger.info(
|
||||||
{ taskId: data.taskId, sourceGroup },
|
{ taskId: data.taskId, sourceGroup },
|
||||||
@@ -327,70 +353,6 @@ export async function processTaskIpc(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'update_task':
|
|
||||||
if (data.taskId) {
|
|
||||||
const task = getTaskById(data.taskId);
|
|
||||||
if (!task) {
|
|
||||||
logger.warn(
|
|
||||||
{ taskId: data.taskId, sourceGroup },
|
|
||||||
'Task not found for update',
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!isMain && task.group_folder !== sourceGroup) {
|
|
||||||
logger.warn(
|
|
||||||
{ taskId: data.taskId, sourceGroup },
|
|
||||||
'Unauthorized task update attempt',
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updates: Parameters<typeof updateTask>[1] = {};
|
|
||||||
if (data.prompt !== undefined) updates.prompt = data.prompt;
|
|
||||||
if (data.schedule_type !== undefined)
|
|
||||||
updates.schedule_type = data.schedule_type as
|
|
||||||
| 'cron'
|
|
||||||
| 'interval'
|
|
||||||
| 'once';
|
|
||||||
if (data.schedule_value !== undefined)
|
|
||||||
updates.schedule_value = data.schedule_value;
|
|
||||||
|
|
||||||
// Recompute next_run if schedule changed
|
|
||||||
if (data.schedule_type || data.schedule_value) {
|
|
||||||
const updatedTask = {
|
|
||||||
...task,
|
|
||||||
...updates,
|
|
||||||
};
|
|
||||||
if (updatedTask.schedule_type === 'cron') {
|
|
||||||
try {
|
|
||||||
const interval = CronExpressionParser.parse(
|
|
||||||
updatedTask.schedule_value,
|
|
||||||
{ tz: TIMEZONE },
|
|
||||||
);
|
|
||||||
updates.next_run = interval.next().toISOString();
|
|
||||||
} catch {
|
|
||||||
logger.warn(
|
|
||||||
{ taskId: data.taskId, value: updatedTask.schedule_value },
|
|
||||||
'Invalid cron in task update',
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else if (updatedTask.schedule_type === 'interval') {
|
|
||||||
const ms = parseInt(updatedTask.schedule_value, 10);
|
|
||||||
if (!isNaN(ms) && ms > 0) {
|
|
||||||
updates.next_run = new Date(Date.now() + ms).toISOString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTask(data.taskId, updates);
|
|
||||||
logger.info(
|
|
||||||
{ taskId: data.taskId, sourceGroup, updates },
|
|
||||||
'Task updated via IPC',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'refresh_groups':
|
case 'refresh_groups':
|
||||||
// Only main group can request a refresh
|
// Only main group can request a refresh
|
||||||
if (isMain) {
|
if (isMain) {
|
||||||
@@ -398,7 +360,7 @@ export async function processTaskIpc(
|
|||||||
{ sourceGroup },
|
{ sourceGroup },
|
||||||
'Group metadata refresh requested via IPC',
|
'Group metadata refresh requested via IPC',
|
||||||
);
|
);
|
||||||
await deps.syncGroups(true);
|
await deps.syncGroupMetadata(true);
|
||||||
// Write updated snapshot immediately
|
// Write updated snapshot immediately
|
||||||
const availableGroups = deps.getAvailableGroups();
|
const availableGroups = deps.getAvailableGroups();
|
||||||
deps.writeGroupsSnapshot(
|
deps.writeGroupsSnapshot(
|
||||||
@@ -432,7 +394,6 @@ export async function processTaskIpc(
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Defense in depth: agent cannot set isMain via IPC
|
|
||||||
deps.registerGroup(data.jid, {
|
deps.registerGroup(data.jid, {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
folder: data.folder,
|
folder: data.folder,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Channel, NewMessage } from './types.js';
|
import { Channel, NewMessage } from './types.js';
|
||||||
import { formatLocalTime } from './timezone.js';
|
|
||||||
|
|
||||||
export function escapeXml(s: string): string {
|
export function escapeXml(s: string): string {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
@@ -10,18 +9,12 @@ export function escapeXml(s: string): string {
|
|||||||
.replace(/"/g, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMessages(
|
export function formatMessages(messages: NewMessage[]): string {
|
||||||
messages: NewMessage[],
|
const lines = messages.map(
|
||||||
timezone: string,
|
(m) =>
|
||||||
): string {
|
`<message sender="${escapeXml(m.sender_name)}" time="${m.timestamp}">${escapeXml(m.content)}</message>`,
|
||||||
const lines = messages.map((m) => {
|
);
|
||||||
const displayTime = formatLocalTime(m.timestamp, timezone);
|
return `<messages>\n${lines.join('\n')}\n</messages>`;
|
||||||
return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}">${escapeXml(m.content)}</message>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const header = `<context timezone="${escapeXml(timezone)}" />\n`;
|
|
||||||
|
|
||||||
return `${header}<messages>\n${lines.join('\n')}\n</messages>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripInternalTags(text: string): string {
|
export function stripInternalTags(text: string): string {
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ describe('JID ownership patterns', () => {
|
|||||||
const jid = '12345678@s.whatsapp.net';
|
const jid = '12345678@s.whatsapp.net';
|
||||||
expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
|
expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Telegram JID: starts with tg:', () => {
|
||||||
|
const jid = 'tg:123456789';
|
||||||
|
expect(jid.startsWith('tg:')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Telegram group JID: starts with tg: and has negative ID', () => {
|
||||||
|
const jid = 'tg:-1001234567890';
|
||||||
|
expect(jid.startsWith('tg:')).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- getAvailableGroups ---
|
// --- getAvailableGroups ---
|
||||||
@@ -167,4 +177,103 @@ describe('getAvailableGroups', () => {
|
|||||||
const groups = getAvailableGroups();
|
const groups = getAvailableGroups();
|
||||||
expect(groups).toHaveLength(0);
|
expect(groups).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes Telegram chat JIDs', () => {
|
||||||
|
storeChatMetadata(
|
||||||
|
'tg:100200300',
|
||||||
|
'2024-01-01T00:00:01.000Z',
|
||||||
|
'Telegram Chat',
|
||||||
|
'telegram',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
storeChatMetadata(
|
||||||
|
'user@s.whatsapp.net',
|
||||||
|
'2024-01-01T00:00:02.000Z',
|
||||||
|
'User DM',
|
||||||
|
'whatsapp',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = getAvailableGroups();
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].jid).toBe('tg:100200300');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Telegram group JIDs with negative IDs', () => {
|
||||||
|
storeChatMetadata(
|
||||||
|
'tg:-1001234567890',
|
||||||
|
'2024-01-01T00:00:01.000Z',
|
||||||
|
'TG Group',
|
||||||
|
'telegram',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = getAvailableGroups();
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].jid).toBe('tg:-1001234567890');
|
||||||
|
expect(groups[0].name).toBe('TG Group');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks registered Telegram chats correctly', () => {
|
||||||
|
storeChatMetadata(
|
||||||
|
'tg:100200300',
|
||||||
|
'2024-01-01T00:00:01.000Z',
|
||||||
|
'TG Registered',
|
||||||
|
'telegram',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
storeChatMetadata(
|
||||||
|
'tg:999999',
|
||||||
|
'2024-01-01T00:00:02.000Z',
|
||||||
|
'TG Unregistered',
|
||||||
|
'telegram',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
_setRegisteredGroups({
|
||||||
|
'tg:100200300': {
|
||||||
|
name: 'TG Registered',
|
||||||
|
folder: 'tg-registered',
|
||||||
|
trigger: '@Andy',
|
||||||
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = getAvailableGroups();
|
||||||
|
const tgReg = groups.find((g) => g.jid === 'tg:100200300');
|
||||||
|
const tgUnreg = groups.find((g) => g.jid === 'tg:999999');
|
||||||
|
|
||||||
|
expect(tgReg?.isRegistered).toBe(true);
|
||||||
|
expect(tgUnreg?.isRegistered).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixes WhatsApp and Telegram chats ordered by activity', () => {
|
||||||
|
storeChatMetadata(
|
||||||
|
'wa@g.us',
|
||||||
|
'2024-01-01T00:00:01.000Z',
|
||||||
|
'WhatsApp',
|
||||||
|
'whatsapp',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
storeChatMetadata(
|
||||||
|
'tg:100',
|
||||||
|
'2024-01-01T00:00:03.000Z',
|
||||||
|
'Telegram',
|
||||||
|
'telegram',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
storeChatMetadata(
|
||||||
|
'wa2@g.us',
|
||||||
|
'2024-01-01T00:00:02.000Z',
|
||||||
|
'WhatsApp 2',
|
||||||
|
'whatsapp',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = getAvailableGroups();
|
||||||
|
expect(groups).toHaveLength(3);
|
||||||
|
expect(groups[0].jid).toBe('tg:100');
|
||||||
|
expect(groups[1].jid).toBe('wa2@g.us');
|
||||||
|
expect(groups[2].jid).toBe('wa@g.us');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { _initTestDatabase, createTask, getTaskById } from './db.js';
|
import { _initTestDatabase, createTask, getTaskById } from './db.js';
|
||||||
import {
|
import {
|
||||||
_resetSchedulerLoopForTests,
|
_resetSchedulerLoopForTests,
|
||||||
computeNextRun,
|
|
||||||
startSchedulerLoop,
|
startSchedulerLoop,
|
||||||
} from './task-scheduler.js';
|
} from './task-scheduler.js';
|
||||||
|
|
||||||
@@ -51,79 +50,4 @@ describe('task scheduler', () => {
|
|||||||
const task = getTaskById('task-invalid-folder');
|
const task = getTaskById('task-invalid-folder');
|
||||||
expect(task?.status).toBe('paused');
|
expect(task?.status).toBe('paused');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => {
|
|
||||||
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
|
|
||||||
const task = {
|
|
||||||
id: 'drift-test',
|
|
||||||
group_folder: 'test',
|
|
||||||
chat_jid: 'test@g.us',
|
|
||||||
prompt: 'test',
|
|
||||||
schedule_type: 'interval' as const,
|
|
||||||
schedule_value: '60000', // 1 minute
|
|
||||||
context_mode: 'isolated' as const,
|
|
||||||
next_run: scheduledTime,
|
|
||||||
last_run: null,
|
|
||||||
last_result: null,
|
|
||||||
status: 'active' as const,
|
|
||||||
created_at: '2026-01-01T00:00:00.000Z',
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextRun = computeNextRun(task);
|
|
||||||
expect(nextRun).not.toBeNull();
|
|
||||||
|
|
||||||
// Should be anchored to scheduledTime + 60s, NOT Date.now() + 60s
|
|
||||||
const expected = new Date(scheduledTime).getTime() + 60000;
|
|
||||||
expect(new Date(nextRun!).getTime()).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('computeNextRun returns null for once-tasks', () => {
|
|
||||||
const task = {
|
|
||||||
id: 'once-test',
|
|
||||||
group_folder: 'test',
|
|
||||||
chat_jid: 'test@g.us',
|
|
||||||
prompt: 'test',
|
|
||||||
schedule_type: 'once' as const,
|
|
||||||
schedule_value: '2026-01-01T00:00:00.000Z',
|
|
||||||
context_mode: 'isolated' as const,
|
|
||||||
next_run: new Date(Date.now() - 1000).toISOString(),
|
|
||||||
last_run: null,
|
|
||||||
last_result: null,
|
|
||||||
status: 'active' as const,
|
|
||||||
created_at: '2026-01-01T00:00:00.000Z',
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(computeNextRun(task)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('computeNextRun skips missed intervals without infinite loop', () => {
|
|
||||||
// Task was due 10 intervals ago (missed)
|
|
||||||
const ms = 60000;
|
|
||||||
const missedBy = ms * 10;
|
|
||||||
const scheduledTime = new Date(Date.now() - missedBy).toISOString();
|
|
||||||
|
|
||||||
const task = {
|
|
||||||
id: 'skip-test',
|
|
||||||
group_folder: 'test',
|
|
||||||
chat_jid: 'test@g.us',
|
|
||||||
prompt: 'test',
|
|
||||||
schedule_type: 'interval' as const,
|
|
||||||
schedule_value: String(ms),
|
|
||||||
context_mode: 'isolated' as const,
|
|
||||||
next_run: scheduledTime,
|
|
||||||
last_run: null,
|
|
||||||
last_result: null,
|
|
||||||
status: 'active' as const,
|
|
||||||
created_at: '2026-01-01T00:00:00.000Z',
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextRun = computeNextRun(task);
|
|
||||||
expect(nextRun).not.toBeNull();
|
|
||||||
// Must be in the future
|
|
||||||
expect(new Date(nextRun!).getTime()).toBeGreaterThan(Date.now());
|
|
||||||
// Must be aligned to the original schedule grid
|
|
||||||
const offset =
|
|
||||||
(new Date(nextRun!).getTime() - new Date(scheduledTime).getTime()) % ms;
|
|
||||||
expect(offset).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { ChildProcess } from 'child_process';
|
|||||||
import { CronExpressionParser } from 'cron-parser';
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js';
|
import {
|
||||||
|
ASSISTANT_NAME,
|
||||||
|
MAIN_GROUP_FOLDER,
|
||||||
|
SCHEDULER_POLL_INTERVAL,
|
||||||
|
TIMEZONE,
|
||||||
|
} from './config.js';
|
||||||
import {
|
import {
|
||||||
ContainerOutput,
|
ContainerOutput,
|
||||||
runContainerAgent,
|
runContainerAgent,
|
||||||
@@ -21,56 +26,16 @@ import { resolveGroupFolderPath } from './group-folder.js';
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { RegisteredGroup, ScheduledTask } from './types.js';
|
import { RegisteredGroup, ScheduledTask } from './types.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the next run time for a recurring task, anchored to the
|
|
||||||
* task's scheduled time rather than Date.now() to prevent cumulative
|
|
||||||
* drift on interval-based tasks.
|
|
||||||
*
|
|
||||||
* Co-authored-by: @community-pr-601
|
|
||||||
*/
|
|
||||||
export function computeNextRun(task: ScheduledTask): string | null {
|
|
||||||
if (task.schedule_type === 'once') return null;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (task.schedule_type === 'cron') {
|
|
||||||
const interval = CronExpressionParser.parse(task.schedule_value, {
|
|
||||||
tz: TIMEZONE,
|
|
||||||
});
|
|
||||||
return interval.next().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.schedule_type === 'interval') {
|
|
||||||
const ms = parseInt(task.schedule_value, 10);
|
|
||||||
if (!ms || ms <= 0) {
|
|
||||||
// Guard against malformed interval that would cause an infinite loop
|
|
||||||
logger.warn(
|
|
||||||
{ taskId: task.id, value: task.schedule_value },
|
|
||||||
'Invalid interval value',
|
|
||||||
);
|
|
||||||
return new Date(now + 60_000).toISOString();
|
|
||||||
}
|
|
||||||
// Anchor to the scheduled time, not now, to prevent drift.
|
|
||||||
// Skip past any missed intervals so we always land in the future.
|
|
||||||
let next = new Date(task.next_run!).getTime() + ms;
|
|
||||||
while (next <= now) {
|
|
||||||
next += ms;
|
|
||||||
}
|
|
||||||
return new Date(next).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchedulerDependencies {
|
export interface SchedulerDependencies {
|
||||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||||
getSessions: () => Record<string, string>;
|
getSessions: () => Record<string, string>;
|
||||||
queue: GroupQueue;
|
queue: GroupQueue;
|
||||||
onProcess: (
|
onProcess: (
|
||||||
groupJid: string,
|
queueKey: string,
|
||||||
proc: ChildProcess,
|
proc: ChildProcess,
|
||||||
containerName: string,
|
containerName: string,
|
||||||
groupFolder: string,
|
groupFolder: string,
|
||||||
|
ipcFolder?: string,
|
||||||
) => void;
|
) => void;
|
||||||
sendMessage: (jid: string, text: string) => Promise<void>;
|
sendMessage: (jid: string, text: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -130,7 +95,7 @@ async function runTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update tasks snapshot for container to read (filtered by group)
|
// Update tasks snapshot for container to read (filtered by group)
|
||||||
const isMain = group.isMain === true;
|
const isMain = task.group_folder === MAIN_GROUP_FOLDER;
|
||||||
const tasks = getAllTasks();
|
const tasks = getAllTasks();
|
||||||
writeTasksSnapshot(
|
writeTasksSnapshot(
|
||||||
task.group_folder,
|
task.group_folder,
|
||||||
@@ -160,11 +125,15 @@ async function runTask(
|
|||||||
const TASK_CLOSE_DELAY_MS = 10000;
|
const TASK_CLOSE_DELAY_MS = 10000;
|
||||||
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// Tasks run in a separate queue slot (task:chatJid) and separate IPC dir
|
||||||
|
// (groupFolder-task/) so they don't block or contaminate user message containers.
|
||||||
|
const taskQueueKey = `task:${task.chat_jid}`;
|
||||||
|
|
||||||
const scheduleClose = () => {
|
const scheduleClose = () => {
|
||||||
if (closeTimer) return; // already scheduled
|
if (closeTimer) return; // already scheduled
|
||||||
closeTimer = setTimeout(() => {
|
closeTimer = setTimeout(() => {
|
||||||
logger.debug({ taskId: task.id }, 'Closing task container after result');
|
logger.debug({ taskId: task.id }, 'Closing task container after result');
|
||||||
deps.queue.closeStdin(task.chat_jid);
|
deps.queue.closeStdin(taskQueueKey);
|
||||||
}, TASK_CLOSE_DELAY_MS);
|
}, TASK_CLOSE_DELAY_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -178,20 +147,29 @@ async function runTask(
|
|||||||
chatJid: task.chat_jid,
|
chatJid: task.chat_jid,
|
||||||
isMain,
|
isMain,
|
||||||
isScheduledTask: true,
|
isScheduledTask: true,
|
||||||
|
ipcSuffix: '-task',
|
||||||
assistantName: ASSISTANT_NAME,
|
assistantName: ASSISTANT_NAME,
|
||||||
},
|
},
|
||||||
(proc, containerName) =>
|
(proc, containerName) =>
|
||||||
deps.onProcess(task.chat_jid, proc, containerName, task.group_folder),
|
deps.onProcess(
|
||||||
|
taskQueueKey,
|
||||||
|
proc,
|
||||||
|
containerName,
|
||||||
|
task.group_folder,
|
||||||
|
`${task.group_folder}-task`,
|
||||||
|
),
|
||||||
async (streamedOutput: ContainerOutput) => {
|
async (streamedOutput: ContainerOutput) => {
|
||||||
if (streamedOutput.result) {
|
if (streamedOutput.result) {
|
||||||
result = streamedOutput.result;
|
result = streamedOutput.result;
|
||||||
// Forward result to user (sendMessage handles formatting)
|
// Forward result to user (sendMessage handles formatting)
|
||||||
await deps.sendMessage(task.chat_jid, streamedOutput.result);
|
await deps.sendMessage(task.chat_jid, streamedOutput.result);
|
||||||
scheduleClose();
|
|
||||||
}
|
}
|
||||||
if (streamedOutput.status === 'success') {
|
if (streamedOutput.status === 'success') {
|
||||||
deps.queue.notifyIdle(task.chat_jid);
|
// Close task containers on any successful output, not just when
|
||||||
scheduleClose(); // Close promptly even when result is null (e.g. IPC-only tasks)
|
// result has text. Tasks that send output via MCP return result=null
|
||||||
|
// but are still done — without this they'd hang until hard timeout.
|
||||||
|
scheduleClose();
|
||||||
|
deps.queue.notifyIdle(taskQueueKey);
|
||||||
}
|
}
|
||||||
if (streamedOutput.status === 'error') {
|
if (streamedOutput.status === 'error') {
|
||||||
error = streamedOutput.error || 'Unknown error';
|
error = streamedOutput.error || 'Unknown error';
|
||||||
@@ -204,7 +182,7 @@ async function runTask(
|
|||||||
if (output.status === 'error') {
|
if (output.status === 'error') {
|
||||||
error = output.error || 'Unknown error';
|
error = output.error || 'Unknown error';
|
||||||
} else if (output.result) {
|
} else if (output.result) {
|
||||||
// Result was already forwarded to the user via the streaming callback above
|
// Messages are sent via MCP tool (IPC), result text is just logged
|
||||||
result = output.result;
|
result = output.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +207,18 @@ async function runTask(
|
|||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextRun = computeNextRun(task);
|
let nextRun: string | null = null;
|
||||||
|
if (task.schedule_type === 'cron') {
|
||||||
|
const interval = CronExpressionParser.parse(task.schedule_value, {
|
||||||
|
tz: TIMEZONE,
|
||||||
|
});
|
||||||
|
nextRun = interval.next().toISOString();
|
||||||
|
} else if (task.schedule_type === 'interval') {
|
||||||
|
const ms = parseInt(task.schedule_value, 10);
|
||||||
|
nextRun = new Date(Date.now() + ms).toISOString();
|
||||||
|
}
|
||||||
|
// 'once' tasks have no next run
|
||||||
|
|
||||||
const resultSummary = error
|
const resultSummary = error
|
||||||
? `Error: ${error}`
|
? `Error: ${error}`
|
||||||
: result
|
: result
|
||||||
@@ -262,7 +251,23 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.queue.enqueueTask(currentTask.chat_jid, currentTask.id, () =>
|
// Advance next_run immediately to prevent re-queuing the same task
|
||||||
|
// while it's still running (task takes longer than the poll interval).
|
||||||
|
if (currentTask.schedule_type === 'cron') {
|
||||||
|
const interval = CronExpressionParser.parse(
|
||||||
|
currentTask.schedule_value,
|
||||||
|
{ tz: TIMEZONE },
|
||||||
|
);
|
||||||
|
const nextRun = interval.next().toISOString();
|
||||||
|
updateTaskAfterRun(currentTask.id, nextRun, '');
|
||||||
|
logger.debug(
|
||||||
|
{ taskId: currentTask.id, nextRun },
|
||||||
|
'Advanced next_run before task start',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskQueueKey = `task:${currentTask.chat_jid}`;
|
||||||
|
deps.queue.enqueueTask(taskQueueKey, currentTask.id, () =>
|
||||||
runTask(currentTask, deps),
|
runTask(currentTask, deps),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
112
src/transcription.ts
Normal file
112
src/transcription.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { downloadMediaMessage } from '@whiskeysockets/baileys';
|
||||||
|
import { WAMessage, WASocket } from '@whiskeysockets/baileys';
|
||||||
|
|
||||||
|
import { readEnvFile } from './env.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
interface TranscriptionConfig {
|
||||||
|
model: string;
|
||||||
|
enabled: boolean;
|
||||||
|
fallbackMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: TranscriptionConfig = {
|
||||||
|
model: 'gpt-4o-mini-transcribe',
|
||||||
|
enabled: true,
|
||||||
|
fallbackMessage: '[Voice Message - transcription unavailable]',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function transcribeWithOpenAI(
|
||||||
|
audioBuffer: Buffer,
|
||||||
|
config: TranscriptionConfig,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const env = readEnvFile(['OPENAI_API_KEY']);
|
||||||
|
const apiKey = env.OPENAI_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
logger.warn('OPENAI_API_KEY not set in .env');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const openaiModule = await import('openai');
|
||||||
|
const OpenAI = openaiModule.default;
|
||||||
|
const toFile = openaiModule.toFile;
|
||||||
|
|
||||||
|
const openai = new OpenAI({ apiKey });
|
||||||
|
|
||||||
|
const file = await toFile(audioBuffer, 'voice.ogg', {
|
||||||
|
type: 'audio/ogg',
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcription = await openai.audio.transcriptions.create({
|
||||||
|
file: file,
|
||||||
|
model: config.model,
|
||||||
|
response_format: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
// When response_format is 'text', the API returns a plain string
|
||||||
|
return transcription as unknown as string;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'OpenAI transcription failed');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transcribeBuffer(
|
||||||
|
audioBuffer: Buffer,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const config = DEFAULT_CONFIG;
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcript = await transcribeWithOpenAI(audioBuffer, config);
|
||||||
|
return transcript ? transcript.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transcribeAudioMessage(
|
||||||
|
msg: WAMessage,
|
||||||
|
sock: WASocket,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const config = DEFAULT_CONFIG;
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
return config.fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = (await downloadMediaMessage(
|
||||||
|
msg,
|
||||||
|
'buffer',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
logger: console as any,
|
||||||
|
reuploadRequest: sock.updateMediaMessage,
|
||||||
|
},
|
||||||
|
)) as Buffer;
|
||||||
|
|
||||||
|
if (!buffer || buffer.length === 0) {
|
||||||
|
logger.error('Failed to download audio message');
|
||||||
|
return config.fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ bytes: buffer.length }, 'Downloaded audio message');
|
||||||
|
|
||||||
|
const transcript = await transcribeWithOpenAI(buffer, config);
|
||||||
|
|
||||||
|
if (!transcript) {
|
||||||
|
return config.fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return transcript.trim();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Transcription error');
|
||||||
|
return config.fallbackMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVoiceMessage(msg: WAMessage): boolean {
|
||||||
|
return msg.message?.audioMessage?.ptt === true;
|
||||||
|
}
|
||||||
@@ -39,7 +39,6 @@ export interface RegisteredGroup {
|
|||||||
added_at: string;
|
added_at: string;
|
||||||
containerConfig?: ContainerConfig;
|
containerConfig?: ContainerConfig;
|
||||||
requiresTrigger?: boolean; // Default: true for groups, false for solo chats
|
requiresTrigger?: boolean; // Default: true for groups, false for solo chats
|
||||||
isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NewMessage {
|
export interface NewMessage {
|
||||||
@@ -88,8 +87,6 @@ export interface Channel {
|
|||||||
disconnect(): Promise<void>;
|
disconnect(): Promise<void>;
|
||||||
// Optional: typing indicator. Channels that support it implement it.
|
// Optional: typing indicator. Channels that support it implement it.
|
||||||
setTyping?(jid: string, isTyping: boolean): Promise<void>;
|
setTyping?(jid: string, isTyping: boolean): Promise<void>;
|
||||||
// Optional: sync group/chat names from the platform.
|
|
||||||
syncGroups?(force: boolean): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Callback type that channels use to deliver inbound messages
|
// Callback type that channels use to deliver inbound messages
|
||||||
@@ -97,7 +94,7 @@ export type OnInboundMessage = (chatJid: string, message: NewMessage) => void;
|
|||||||
|
|
||||||
// Callback for chat metadata discovery.
|
// Callback for chat metadata discovery.
|
||||||
// name is optional — channels that deliver names inline (Telegram) pass it here;
|
// name is optional — channels that deliver names inline (Telegram) pass it here;
|
||||||
// channels that sync names separately (via syncGroups) omit it.
|
// channels that sync names separately (WhatsApp syncGroupMetadata) omit it.
|
||||||
export type OnChatMetadata = (
|
export type OnChatMetadata = (
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
timestamp: string,
|
timestamp: string,
|
||||||
|
|||||||
180
src/whatsapp-auth.ts
Normal file
180
src/whatsapp-auth.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* WhatsApp Authentication Script
|
||||||
|
*
|
||||||
|
* Run this during setup to authenticate with WhatsApp.
|
||||||
|
* Displays QR code, waits for scan, saves credentials, then exits.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx src/whatsapp-auth.ts
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import pino from 'pino';
|
||||||
|
import qrcode from 'qrcode-terminal';
|
||||||
|
import readline from 'readline';
|
||||||
|
|
||||||
|
import makeWASocket, {
|
||||||
|
Browsers,
|
||||||
|
DisconnectReason,
|
||||||
|
fetchLatestWaWebVersion,
|
||||||
|
makeCacheableSignalKeyStore,
|
||||||
|
useMultiFileAuthState,
|
||||||
|
} from '@whiskeysockets/baileys';
|
||||||
|
|
||||||
|
const AUTH_DIR = './store/auth';
|
||||||
|
const QR_FILE = './store/qr-data.txt';
|
||||||
|
const STATUS_FILE = './store/auth-status.txt';
|
||||||
|
|
||||||
|
const logger = pino({
|
||||||
|
level: 'warn', // Quiet logging - only show errors
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for --pairing-code flag and phone number
|
||||||
|
const usePairingCode = process.argv.includes('--pairing-code');
|
||||||
|
const phoneArg = process.argv.find((_, i, arr) => arr[i - 1] === '--phone');
|
||||||
|
|
||||||
|
function askQuestion(prompt: string): Promise<string> {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(prompt, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(answer.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectSocket(
|
||||||
|
phoneNumber?: string,
|
||||||
|
isReconnect = false,
|
||||||
|
): Promise<void> {
|
||||||
|
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
||||||
|
|
||||||
|
if (state.creds.registered && !isReconnect) {
|
||||||
|
fs.writeFileSync(STATUS_FILE, 'already_authenticated');
|
||||||
|
console.log('✓ Already authenticated with WhatsApp');
|
||||||
|
console.log(
|
||||||
|
' To re-authenticate, delete the store/auth folder and run again.',
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
|
||||||
|
logger.warn(
|
||||||
|
{ err },
|
||||||
|
'Failed to fetch latest WA Web version, using default',
|
||||||
|
);
|
||||||
|
return { version: undefined };
|
||||||
|
});
|
||||||
|
const sock = makeWASocket({
|
||||||
|
version,
|
||||||
|
auth: {
|
||||||
|
creds: state.creds,
|
||||||
|
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
||||||
|
},
|
||||||
|
printQRInTerminal: false,
|
||||||
|
logger,
|
||||||
|
browser: Browsers.macOS('Chrome'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (usePairingCode && phoneNumber && !state.creds.me) {
|
||||||
|
// Request pairing code after a short delay for connection to initialize
|
||||||
|
// Only on first connect (not reconnect after 515)
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const code = await sock.requestPairingCode(phoneNumber!);
|
||||||
|
console.log(`\n🔗 Your pairing code: ${code}\n`);
|
||||||
|
console.log(' 1. Open WhatsApp on your phone');
|
||||||
|
console.log(' 2. Tap Settings → Linked Devices → Link a Device');
|
||||||
|
console.log(' 3. Tap "Link with phone number instead"');
|
||||||
|
console.log(` 4. Enter this code: ${code}\n`);
|
||||||
|
fs.writeFileSync(STATUS_FILE, `pairing_code:${code}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to request pairing code:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
sock.ev.on('connection.update', (update) => {
|
||||||
|
const { connection, lastDisconnect, qr } = update;
|
||||||
|
|
||||||
|
if (qr) {
|
||||||
|
// Write raw QR data to file so the setup skill can render it
|
||||||
|
fs.writeFileSync(QR_FILE, qr);
|
||||||
|
console.log('Scan this QR code with WhatsApp:\n');
|
||||||
|
console.log(' 1. Open WhatsApp on your phone');
|
||||||
|
console.log(' 2. Tap Settings → Linked Devices → Link a Device');
|
||||||
|
console.log(' 3. Point your camera at the QR code below\n');
|
||||||
|
qrcode.generate(qr, { small: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection === 'close') {
|
||||||
|
const reason = (lastDisconnect?.error as any)?.output?.statusCode;
|
||||||
|
|
||||||
|
if (reason === DisconnectReason.loggedOut) {
|
||||||
|
fs.writeFileSync(STATUS_FILE, 'failed:logged_out');
|
||||||
|
console.log('\n✗ Logged out. Delete store/auth and try again.');
|
||||||
|
process.exit(1);
|
||||||
|
} else if (reason === DisconnectReason.timedOut) {
|
||||||
|
fs.writeFileSync(STATUS_FILE, 'failed:qr_timeout');
|
||||||
|
console.log('\n✗ QR code timed out. Please try again.');
|
||||||
|
process.exit(1);
|
||||||
|
} else if (reason === 515) {
|
||||||
|
// 515 = stream error, often happens after pairing succeeds but before
|
||||||
|
// registration completes. Reconnect to finish the handshake.
|
||||||
|
console.log('\n⟳ Stream error (515) after pairing — reconnecting...');
|
||||||
|
connectSocket(phoneNumber, true);
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(STATUS_FILE, `failed:${reason || 'unknown'}`);
|
||||||
|
console.log('\n✗ Connection failed. Please try again.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection === 'open') {
|
||||||
|
fs.writeFileSync(STATUS_FILE, 'authenticated');
|
||||||
|
// Clean up QR file now that we're connected
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(QR_FILE);
|
||||||
|
} catch {}
|
||||||
|
console.log('\n✓ Successfully authenticated with WhatsApp!');
|
||||||
|
console.log(' Credentials saved to store/auth/');
|
||||||
|
console.log(' You can now start the NanoClaw service.\n');
|
||||||
|
|
||||||
|
// Give it a moment to save credentials, then exit
|
||||||
|
setTimeout(() => process.exit(0), 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sock.ev.on('creds.update', saveCreds);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate(): Promise<void> {
|
||||||
|
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// Clean up any stale QR/status files from previous runs
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(QR_FILE);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(STATUS_FILE);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
let phoneNumber = phoneArg;
|
||||||
|
if (usePairingCode && !phoneNumber) {
|
||||||
|
phoneNumber = await askQuestion(
|
||||||
|
'Enter your phone number (with country code, no + or spaces, e.g. 14155551234): ',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Starting WhatsApp authentication...\n');
|
||||||
|
|
||||||
|
await connectSocket(phoneNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticate().catch((err) => {
|
||||||
|
console.error('Authentication failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
include: ['src/**/*.test.ts', 'setup/**/*.test.ts'],
|
include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'skills-engine/**/*.test.ts'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user