diff --git a/src/agent-impl.ts b/src/agent-impl.ts index e8ab2527ab5..dfb8084e763 100644 --- a/src/agent-impl.ts +++ b/src/agent-impl.ts @@ -367,6 +367,29 @@ export class AgentImpl return [...this._channels.values()]; } + private async sendOutboundMessage( + jid: string, + rawText: string, + channel?: Channel, + ): Promise { + const targetChannel = channel ?? findChannel(this.channelArray, jid); + if (!targetChannel) { + logger.warn({ jid }, 'No channel owns JID, cannot send'); + return false; + } + + const text = formatOutbound(rawText); + if (!text) return false; + + await targetChannel.sendMessage(jid, text); + this.emit('message.out', { + jid, + text, + timestamp: new Date().toISOString(), + }); + return true; + } + // ─── Group management ──────────────────────────────────────────── /** Register a group for message processing. Only after start(). */ @@ -524,19 +547,24 @@ export class AgentImpl this.config.dataDir, ); if (result.ok) { - await channel.sendMessage(chatJid, result.url); + await this.sendOutboundMessage(chatJid, result.url, channel); } else { - await channel.sendMessage( + await this.sendOutboundMessage( chatJid, `Remote Control failed: ${result.error}`, + channel, ); } } else { const result = stopRemoteControl(this.config.dataDir); if (result.ok) { - await channel.sendMessage(chatJid, 'Remote Control session ended.'); + await this.sendOutboundMessage( + chatJid, + 'Remote Control session ended.', + channel, + ); } else { - await channel.sendMessage(chatJid, result.error); + await this.sendOutboundMessage(chatJid, result.error, channel); } } } @@ -623,8 +651,11 @@ export class AgentImpl `Agent output: ${raw.length} chars`, ); if (text) { - await channel.sendMessage(chatJid, text); - outputSentToUser = true; + outputSentToUser = await this.sendOutboundMessage( + chatJid, + raw, + channel, + ); } resetIdleTimer(); } @@ -904,13 +935,7 @@ export class AgentImpl onProcess: (groupJid, boxName, _containerName, groupFolder) => this.queue.registerBox(groupJid, boxName, groupFolder), sendMessage: async (jid, rawText) => { - const channel = findChannel(this.channelArray, jid); - if (!channel) { - logger.warn({ jid }, 'No channel owns JID, cannot send'); - return; - } - const text = formatOutbound(rawText); - if (text) await channel.sendMessage(jid, text); + await this.sendOutboundMessage(jid, rawText); }, }); @@ -919,10 +944,9 @@ export class AgentImpl ipcPollInterval: this.runtimeConfig.ipcPollInterval, timezone: this.runtimeConfig.timezone, db: this.db, - sendMessage: (jid, text) => { - const channel = findChannel(this.channelArray, jid); - if (!channel) throw new Error(`No channel for JID: ${jid}`); - return channel.sendMessage(jid, text); + sendMessage: async (jid, text) => { + const sent = await this.sendOutboundMessage(jid, text); + if (!sent) throw new Error(`No channel for JID: ${jid}`); }, registeredGroups: () => this._registeredGroups, registerGroup: (jid, group) => this.registerGroup(jid, group), diff --git a/src/message-events.test.ts b/src/message-events.test.ts index 96c324b7966..6de934e3800 100644 --- a/src/message-events.test.ts +++ b/src/message-events.test.ts @@ -14,6 +14,7 @@ import { } from './agent-config.js'; import { buildRuntimeConfig } from './runtime-config.js'; import { _initTestDatabase, AgentDb } from './db.js'; +import type { Channel } from './types.js'; let tmpDir: string; const rtConfig = buildRuntimeConfig({}, '/tmp/agentlite-test-pkg'); @@ -34,6 +35,26 @@ function createAgent(name: string): AgentImpl { let db: AgentDb; +function createMockChannel(): Channel & { + sendCalls: Array<{ jid: string; text: string }>; +} { + return { + name: 'mock', + sendCalls: [], + async connect(): Promise {}, + async disconnect(): Promise {}, + async sendMessage(jid: string, text: string): Promise { + this.sendCalls.push({ jid, text }); + }, + isConnected(): boolean { + return true; + }, + ownsJid(jid: string): boolean { + return jid.startsWith('mock:'); + }, + }; +} + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentlite-msg-')); db = _initTestDatabase(); @@ -205,3 +226,72 @@ describe('message.in event', () => { }); }); }); + +describe('message.out event', () => { + it('emits message.out when sending outbound text', async () => { + const agent = createAgent('test'); + const channel = createMockChannel(); + const events: unknown[] = []; + agent.on('message.out', (evt) => events.push(evt)); + + ( + agent as unknown as { + _channels: Map; + sendOutboundMessage: (jid: string, text: string) => Promise; + } + )._channels.set('mock', channel); + + const sent = await ( + agent as unknown as { + sendOutboundMessage: (jid: string, text: string) => Promise; + } + ).sendOutboundMessage('mock:123', 'partial update'); + + expect(sent).toBe(true); + expect(channel.sendCalls).toEqual([ + { jid: 'mock:123', text: 'partial update' }, + ]); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + jid: 'mock:123', + text: 'partial update', + }); + expect( + Number.isNaN(Date.parse((events[0] as { timestamp: string }).timestamp)), + ).toBe(false); + }); + + it('strips internal tags and suppresses empty outbound chunks', async () => { + const agent = createAgent('test'); + const channel = createMockChannel(); + const events: unknown[] = []; + agent.on('message.out', (evt) => events.push(evt)); + + ( + agent as unknown as { + _channels: Map; + sendOutboundMessage: (jid: string, text: string) => Promise; + } + )._channels.set('mock', channel); + + const sentVisible = await ( + agent as unknown as { + sendOutboundMessage: (jid: string, text: string) => Promise; + } + ).sendOutboundMessage('mock:123', 'hidden\nhello'); + const sentHidden = await ( + agent as unknown as { + sendOutboundMessage: (jid: string, text: string) => Promise; + } + ).sendOutboundMessage('mock:123', 'hidden only'); + + expect(sentVisible).toBe(true); + expect(sentHidden).toBe(false); + expect(channel.sendCalls).toEqual([{ jid: 'mock:123', text: 'hello' }]); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + jid: 'mock:123', + text: 'hello', + }); + }); +});