diff --git a/package.json b/package.json index ed5009a..7d19e9c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@whiskeysockets/baileys": "^7.0.0-rc.9", "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", -"pino": "^9.6.0", + "pino": "^9.6.0", "pino-pretty": "^13.0.0", "qrcode-terminal": "^0.12.0", "zod": "^4.3.6" diff --git a/src/channels/whatsapp.test.ts b/src/channels/whatsapp.test.ts index 1ed11ea..02d8c70 100644 --- a/src/channels/whatsapp.test.ts +++ b/src/channels/whatsapp.test.ts @@ -6,6 +6,8 @@ import { EventEmitter } from 'events'; // Mock config vi.mock('../config.js', () => ({ STORE_DIR: '/tmp/nanoclaw-test-store', + ASSISTANT_NAME: 'Andy', + ASSISTANT_HAS_OWN_NUMBER: false, })); // Mock logger @@ -197,9 +199,10 @@ describe('WhatsAppChannel', () => { (channel as any).connected = true; await (channel as any).flushOutgoingQueue(); + // Group messages get prefixed when flushed expect(fakeSocket.sendMessage).toHaveBeenCalledWith( 'test@g.us', - { text: 'Queued message' }, + { text: 'Andy: Queued message' }, ); }); @@ -642,7 +645,19 @@ describe('WhatsAppChannel', () => { await connectChannel(channel); await channel.sendMessage('test@g.us', 'Hello'); - expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { text: 'Hello' }); + // Group messages get prefixed with assistant name + expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { text: 'Andy: Hello' }); + }); + + it('prefixes direct chat messages on shared number', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await channel.sendMessage('123@s.whatsapp.net', 'Hello'); + // Shared number: DMs also get prefixed (needed for self-chat distinction) + expect(fakeSocket.sendMessage).toHaveBeenCalledWith('123@s.whatsapp.net', { text: 'Andy: Hello' }); }); it('queues message when disconnected', async () => { @@ -685,9 +700,10 @@ describe('WhatsAppChannel', () => { await new Promise((r) => setTimeout(r, 50)); expect(fakeSocket.sendMessage).toHaveBeenCalledTimes(3); - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', { text: 'First' }); - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', { text: 'Second' }); - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', { text: 'Third' }); + // Group messages get prefixed + expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', { text: 'Andy: First' }); + expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', { text: 'Andy: Second' }); + expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', { text: 'Andy: Third' }); }); }); @@ -854,9 +870,9 @@ describe('WhatsAppChannel', () => { expect(channel.name).toBe('whatsapp'); }); - it('prefixes assistant name', () => { + it('does not expose prefixAssistantName (prefix handled internally)', () => { const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.prefixAssistantName).toBe(true); + expect('prefixAssistantName' in channel).toBe(false); }); }); }); diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index 1269d88..0f78bcf 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -10,7 +10,7 @@ import makeWASocket, { useMultiFileAuthState, } from '@whiskeysockets/baileys'; -import { STORE_DIR } from '../config.js'; +import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, STORE_DIR } from '../config.js'; import { getLastGroupSync, setLastGroupSync, @@ -29,7 +29,6 @@ export interface WhatsAppChannelOpts { export class WhatsAppChannel implements Channel { name = 'whatsapp'; - prefixAssistantName = true; private sock!: WASocket; private connected = false; @@ -173,6 +172,15 @@ export class WhatsAppChannel implements Channel { 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}:`); + this.opts.onMessage(chatJid, { id: msg.key.id || '', chat_jid: chatJid, @@ -180,7 +188,8 @@ export class WhatsAppChannel implements Channel { sender_name: senderName, content, timestamp, - is_from_me: msg.key.fromMe || false, + is_from_me: fromMe, + is_bot_message: isBotMessage, }); } } @@ -188,17 +197,25 @@ export class WhatsAppChannel implements Channel { } async sendMessage(jid: string, text: string): Promise { + // 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 }); - logger.info({ jid, length: text.length, queueSize: this.outgoingQueue.length }, 'WA disconnected, message queued'); + 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 }); - logger.info({ jid, length: text.length }, 'Message sent'); + 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 }); + this.outgoingQueue.push({ jid, text: prefixed }); logger.warn({ jid, err, queueSize: this.outgoingQueue.length }, 'Failed to send, message queued'); } } @@ -296,7 +313,9 @@ export class WhatsAppChannel implements Channel { logger.info({ count: this.outgoingQueue.length }, 'Flushing outgoing message queue'); while (this.outgoingQueue.length > 0) { const item = this.outgoingQueue.shift()!; - await this.sendMessage(item.jid, item.text); + // 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; diff --git a/src/config.ts b/src/config.ts index 721bcb6..0764fc0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,16 @@ import path from 'path'; -export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || 'Andy'; +import { readEnvFile } from './env.js'; + +// Read config values from .env (falls back to process.env). +// Secrets are NOT read here — they stay on disk and are loaded only +// where needed (container-runner.ts) to avoid leaking to child processes. +const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']); + +export const ASSISTANT_NAME = + process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; +export const ASSISTANT_HAS_OWN_NUMBER = + (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true'; export const POLL_INTERVAL = 2000; export const SCHEDULER_POLL_INTERVAL = 60000; diff --git a/src/container-runner.ts b/src/container-runner.ts index 6080314..5e20ced 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -15,6 +15,7 @@ import { GROUPS_DIR, IDLE_TIMEOUT, } from './config.js'; +import { readEnvFile } from './env.js'; import { logger } from './logger.js'; import { validateAdditionalMounts } from './mount-security.js'; import { RegisteredGroup } from './types.js'; @@ -185,31 +186,7 @@ function buildVolumeMounts( * Secrets are never written to disk or mounted as files. */ function readSecrets(): Record { - const envFile = path.join(process.cwd(), '.env'); - if (!fs.existsSync(envFile)) return {}; - - const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']; - const secrets: Record = {}; - const content = fs.readFileSync(envFile, 'utf-8'); - - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eqIdx = trimmed.indexOf('='); - if (eqIdx === -1) continue; - const key = trimmed.slice(0, eqIdx).trim(); - if (!allowedVars.includes(key)) continue; - let value = trimmed.slice(eqIdx + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - if (value) secrets[key] = value; - } - - return secrets; + return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']); } function buildContainerArgs(mounts: VolumeMount[], containerName: string): string[] { diff --git a/src/db.test.ts b/src/db.test.ts index a7a2755..32cde1e 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -53,7 +53,7 @@ describe('storeMessage', () => { timestamp: '2024-01-01T00:00:01.000Z', }); - const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'BotName'); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].id).toBe('msg-1'); expect(messages[0].sender).toBe('123@s.whatsapp.net'); @@ -73,7 +73,7 @@ describe('storeMessage', () => { timestamp: '2024-01-01T00:00:04.000Z', }); - const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'BotName'); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].content).toBe(''); }); @@ -92,7 +92,7 @@ describe('storeMessage', () => { }); // Message is stored (we can retrieve it — is_from_me doesn't affect retrieval) - const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'BotName'); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); }); @@ -117,7 +117,7 @@ describe('storeMessage', () => { timestamp: '2024-01-01T00:00:01.000Z', }); - const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'BotName'); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].content).toBe('updated'); }); @@ -129,22 +129,23 @@ describe('getMessagesSince', () => { beforeEach(() => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); - const msgs = [ - { id: 'm1', content: 'first', ts: '2024-01-01T00:00:01.000Z', sender: 'Alice' }, - { id: 'm2', content: 'second', ts: '2024-01-01T00:00:02.000Z', sender: 'Bob' }, - { id: 'm3', content: 'Andy: bot reply', ts: '2024-01-01T00:00:03.000Z', sender: 'Bot' }, - { id: 'm4', content: 'third', ts: '2024-01-01T00:00:04.000Z', sender: 'Carol' }, - ]; - for (const m of msgs) { - store({ - id: m.id, - chat_jid: 'group@g.us', - sender: `${m.sender}@s.whatsapp.net`, - sender_name: m.sender, - content: m.content, - timestamp: m.ts, - }); - } + store({ + id: 'm1', chat_jid: 'group@g.us', sender: 'Alice@s.whatsapp.net', + sender_name: 'Alice', content: 'first', timestamp: '2024-01-01T00:00:01.000Z', + }); + store({ + id: 'm2', chat_jid: 'group@g.us', sender: 'Bob@s.whatsapp.net', + sender_name: 'Bob', content: 'second', timestamp: '2024-01-01T00:00:02.000Z', + }); + storeMessage({ + id: 'm3', chat_jid: 'group@g.us', sender: 'Bot@s.whatsapp.net', + sender_name: 'Bot', content: 'bot reply', timestamp: '2024-01-01T00:00:03.000Z', + is_bot_message: true, + }); + store({ + id: 'm4', chat_jid: 'group@g.us', sender: 'Carol@s.whatsapp.net', + sender_name: 'Carol', content: 'third', timestamp: '2024-01-01T00:00:04.000Z', + }); }); it('returns messages after the given timestamp', () => { @@ -154,17 +155,28 @@ describe('getMessagesSince', () => { expect(msgs[0].content).toBe('third'); }); - it('excludes messages from the assistant (content prefix)', () => { + it('excludes bot messages via is_bot_message flag', () => { const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); - const botMsgs = msgs.filter((m) => m.content.startsWith('Andy:')); + const botMsgs = msgs.filter((m) => m.content === 'bot reply'); expect(botMsgs).toHaveLength(0); }); - it('returns all messages when sinceTimestamp is empty', () => { + it('returns all non-bot messages when sinceTimestamp is empty', () => { const msgs = getMessagesSince('group@g.us', '', 'Andy'); // 3 user messages (bot message excluded) expect(msgs).toHaveLength(3); }); + + it('filters pre-migration bot messages via content prefix backstop', () => { + // Simulate a message written before migration: has prefix but is_bot_message = 0 + store({ + id: 'm5', chat_jid: 'group@g.us', sender: 'Bot@s.whatsapp.net', + sender_name: 'Bot', content: 'Andy: old bot reply', + timestamp: '2024-01-01T00:00:05.000Z', + }); + const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:04.000Z', 'Andy'); + expect(msgs).toHaveLength(0); + }); }); // --- getNewMessages --- @@ -174,22 +186,23 @@ describe('getNewMessages', () => { storeChatMetadata('group1@g.us', '2024-01-01T00:00:00.000Z'); storeChatMetadata('group2@g.us', '2024-01-01T00:00:00.000Z'); - const msgs = [ - { id: 'a1', chat: 'group1@g.us', content: 'g1 msg1', ts: '2024-01-01T00:00:01.000Z' }, - { id: 'a2', chat: 'group2@g.us', content: 'g2 msg1', ts: '2024-01-01T00:00:02.000Z' }, - { id: 'a3', chat: 'group1@g.us', content: 'Andy: reply', ts: '2024-01-01T00:00:03.000Z' }, - { id: 'a4', chat: 'group1@g.us', content: 'g1 msg2', ts: '2024-01-01T00:00:04.000Z' }, - ]; - for (const m of msgs) { - store({ - id: m.id, - chat_jid: m.chat, - sender: 'user@s.whatsapp.net', - sender_name: 'User', - content: m.content, - timestamp: m.ts, - }); - } + store({ + id: 'a1', chat_jid: 'group1@g.us', sender: 'user@s.whatsapp.net', + sender_name: 'User', content: 'g1 msg1', timestamp: '2024-01-01T00:00:01.000Z', + }); + store({ + id: 'a2', chat_jid: 'group2@g.us', sender: 'user@s.whatsapp.net', + sender_name: 'User', content: 'g2 msg1', timestamp: '2024-01-01T00:00:02.000Z', + }); + storeMessage({ + id: 'a3', chat_jid: 'group1@g.us', sender: 'user@s.whatsapp.net', + sender_name: 'User', content: 'bot reply', timestamp: '2024-01-01T00:00:03.000Z', + is_bot_message: true, + }); + store({ + id: 'a4', chat_jid: 'group1@g.us', sender: 'user@s.whatsapp.net', + sender_name: 'User', content: 'g1 msg2', timestamp: '2024-01-01T00:00:04.000Z', + }); }); it('returns new messages across multiple groups', () => { @@ -198,7 +211,7 @@ describe('getNewMessages', () => { '2024-01-01T00:00:00.000Z', 'Andy', ); - // Excludes 'Andy: reply', returns 3 messages + // Excludes bot message, returns 3 user messages expect(messages).toHaveLength(3); expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z'); }); diff --git a/src/db.ts b/src/db.ts index c1daa5b..cf704f8 100644 --- a/src/db.ts +++ b/src/db.ts @@ -2,7 +2,7 @@ import Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; -import { DATA_DIR, STORE_DIR } from './config.js'; +import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js'; import { NewMessage, RegisteredGroup, ScheduledTask, TaskRunLog } from './types.js'; let db: Database.Database; @@ -22,6 +22,7 @@ function createSchema(database: Database.Database): void { content TEXT, timestamp TEXT, is_from_me INTEGER, + is_bot_message INTEGER DEFAULT 0, PRIMARY KEY (id, chat_jid), FOREIGN KEY (chat_jid) REFERENCES chats(jid) ); @@ -82,6 +83,19 @@ function createSchema(database: Database.Database): void { } catch { /* column already exists */ } + + // Add is_bot_message column if it doesn't exist (migration for existing DBs) + try { + database.exec( + `ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`, + ); + // Backfill: mark existing bot messages that used the content prefix pattern + database.prepare( + `UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`, + ).run(`${ASSISTANT_NAME}:%`); + } catch { + /* column already exists */ + } } export function initDatabase(): void { @@ -194,7 +208,7 @@ export function setLastGroupSync(): void { */ export function storeMessage(msg: NewMessage): void { db.prepare( - `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me) VALUES (?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ).run( msg.id, msg.chat_jid, @@ -203,6 +217,7 @@ export function storeMessage(msg: NewMessage): void { msg.content, msg.timestamp, msg.is_from_me ? 1 : 0, + msg.is_bot_message ? 1 : 0, ); } @@ -217,9 +232,10 @@ export function storeMessageDirect(msg: { content: string; timestamp: string; is_from_me: boolean; + is_bot_message?: boolean; }): void { db.prepare( - `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me) VALUES (?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ).run( msg.id, msg.chat_jid, @@ -228,6 +244,7 @@ export function storeMessageDirect(msg: { msg.content, msg.timestamp, msg.is_from_me ? 1 : 0, + msg.is_bot_message ? 1 : 0, ); } @@ -239,11 +256,13 @@ export function getNewMessages( if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp }; const placeholders = jids.map(() => '?').join(','); - // Filter out bot's own messages by checking content prefix (not is_from_me, since user shares the account) + // Filter bot messages using both the is_bot_message flag AND the content + // prefix as a backstop for messages written before the migration ran. const sql = ` SELECT id, chat_jid, sender, sender_name, content, timestamp FROM messages - WHERE timestamp > ? AND chat_jid IN (${placeholders}) AND content NOT LIKE ? + WHERE timestamp > ? AND chat_jid IN (${placeholders}) + AND is_bot_message = 0 AND content NOT LIKE ? ORDER BY timestamp `; @@ -264,11 +283,13 @@ export function getMessagesSince( sinceTimestamp: string, botPrefix: string, ): NewMessage[] { - // Filter out bot's own messages by checking content prefix + // Filter bot messages using both the is_bot_message flag AND the content + // prefix as a backstop for messages written before the migration ran. const sql = ` SELECT id, chat_jid, sender, sender_name, content, timestamp FROM messages - WHERE chat_jid = ? AND timestamp > ? AND content NOT LIKE ? + WHERE chat_jid = ? AND timestamp > ? + AND is_bot_message = 0 AND content NOT LIKE ? ORDER BY timestamp `; return db diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..2bd86f0 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,40 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * Parse the .env file and return values for the requested keys. + * Does NOT load anything into process.env — callers decide what to + * do with the values. This keeps secrets out of the process environment + * so they don't leak to child processes. + */ +export function readEnvFile(keys: string[]): Record { + const envFile = path.join(process.cwd(), '.env'); + let content: string; + try { + content = fs.readFileSync(envFile, 'utf-8'); + } catch { + return {}; + } + + const result: Record = {}; + const wanted = new Set(keys); + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + if (!wanted.has(key)) continue; + let value = trimmed.slice(eqIdx + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (value) result[key] = value; + } + + return result; +} diff --git a/src/formatting.test.ts b/src/formatting.test.ts index 49e0c57..0ca1185 100644 --- a/src/formatting.test.ts +++ b/src/formatting.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { ASSISTANT_NAME, TRIGGER_PATTERN } from './config.js'; +import { TRIGGER_PATTERN } from './config.js'; import { escapeXml, formatMessages, formatOutbound, stripInternalTags, } from './router.js'; -import { Channel, NewMessage } from './types.js'; +import { NewMessage } from './types.js'; function makeMsg(overrides: Partial = {}): NewMessage { return { @@ -162,34 +162,18 @@ describe('stripInternalTags', () => { }); describe('formatOutbound', () => { - const waChannel = { prefixAssistantName: true } as Channel; - const noPrefixChannel = { prefixAssistantName: false } as Channel; - const defaultChannel = {} as Channel; - - it('prefixes with assistant name when channel wants it', () => { - expect(formatOutbound(waChannel, 'hello world')).toBe( - `${ASSISTANT_NAME}: hello world`, - ); - }); - - it('does not prefix when channel opts out', () => { - expect(formatOutbound(noPrefixChannel, 'hello world')).toBe('hello world'); - }); - - it('defaults to prefixing when prefixAssistantName is undefined', () => { - expect(formatOutbound(defaultChannel, 'hello world')).toBe( - `${ASSISTANT_NAME}: hello world`, - ); + it('returns text with internal tags stripped', () => { + expect(formatOutbound('hello world')).toBe('hello world'); }); it('returns empty string when all text is internal', () => { - expect(formatOutbound(waChannel, 'hidden')).toBe(''); + expect(formatOutbound('hidden')).toBe(''); }); - it('strips internal tags and prefixes remaining text', () => { + it('strips internal tags from remaining text', () => { expect( - formatOutbound(waChannel, 'thinkingThe answer is 42'), - ).toBe(`${ASSISTANT_NAME}: The answer is 42`); + formatOutbound('thinkingThe answer is 42'), + ).toBe('The answer is 42'); }); }); diff --git a/src/index.ts b/src/index.ts index a385bb0..4dc98f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -124,11 +124,7 @@ async function processGroupMessages(chatJid: string): Promise { const isMainGroup = group.folder === MAIN_GROUP_FOLDER; const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; - const missedMessages = getMessagesSince( - chatJid, - sinceTimestamp, - ASSISTANT_NAME, - ); + const missedMessages = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME); if (missedMessages.length === 0) return true; @@ -177,7 +173,7 @@ async function processGroupMessages(chatJid: string): Promise { const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`); if (text) { - await whatsapp.sendMessage(chatJid, `${ASSISTANT_NAME}: ${text}`); + await whatsapp.sendMessage(chatJid, text); outputSentToUser = true; } // Only reset idle timer on actual results, not session-update markers (result: null) @@ -300,11 +296,7 @@ async function startMessageLoop(): Promise { while (true) { try { const jids = Object.keys(registeredGroups); - const { messages, newTimestamp } = getNewMessages( - jids, - lastTimestamp, - ASSISTANT_NAME, - ); + const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME); if (messages.length > 0) { logger.info({ count: messages.length }, 'New messages'); @@ -488,7 +480,7 @@ async function main(): Promise { queue, onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder), sendMessage: async (jid, rawText) => { - const text = formatOutbound(whatsapp, rawText); + const text = formatOutbound(rawText); if (text) await whatsapp.sendMessage(jid, text); }, }); diff --git a/src/ipc.ts b/src/ipc.ts index 53556c9..9327d36 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -4,7 +4,6 @@ import path from 'path'; import { CronExpressionParser } from 'cron-parser'; import { - ASSISTANT_NAME, DATA_DIR, IPC_POLL_INTERVAL, MAIN_GROUP_FOLDER, @@ -79,10 +78,7 @@ export function startIpcWatcher(deps: IpcDeps): void { isMain || (targetGroup && targetGroup.folder === sourceGroup) ) { - await deps.sendMessage( - data.chatJid, - `${ASSISTANT_NAME}: ${data.text}`, - ); + await deps.sendMessage(data.chatJid, data.text); logger.info( { chatJid: data.chatJid, sourceGroup }, 'IPC message sent', diff --git a/src/router.ts b/src/router.ts index 798e7b5..76948dc 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,4 +1,3 @@ -import { ASSISTANT_NAME } from './config.js'; import { Channel, NewMessage } from './types.js'; export function escapeXml(s: string): string { @@ -20,12 +19,10 @@ export function stripInternalTags(text: string): string { return text.replace(/[\s\S]*?<\/internal>/g, '').trim(); } -export function formatOutbound(channel: Channel, rawText: string): string { +export function formatOutbound(rawText: string): string { const text = stripInternalTags(rawText); if (!text) return ''; - const prefix = - channel.prefixAssistantName !== false ? `${ASSISTANT_NAME}: ` : ''; - return `${prefix}${text}`; + return text; } export function routeOutbound( diff --git a/src/types.ts b/src/types.ts index 2d655a6..57d2a01 100644 --- a/src/types.ts +++ b/src/types.ts @@ -49,6 +49,7 @@ export interface NewMessage { content: string; timestamp: string; is_from_me?: boolean; + is_bot_message?: boolean; } export interface ScheduledTask { @@ -86,10 +87,6 @@ export interface Channel { disconnect(): Promise; // Optional: typing indicator. Channels that support it implement it. setTyping?(jid: string, isTyping: boolean): Promise; - // Whether to prefix outbound messages with the assistant name. - // Telegram bots already display their name, so they return false. - // WhatsApp returns true. Default true if not implemented. - prefixAssistantName?: boolean; } // Callback type that channels use to deliver inbound messages