diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index cfa7378..7bc3199 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -190,6 +190,127 @@ describe('createMessageRuntime', () => { }); }); + it('ignores untriggered bot messages in paired rooms', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const channel = makeChannel(chatJid); + const saveState = vi.fn(); + const lastAgentTimestamps: Record = {}; + + vi.mocked(db.isPairedRoomJid).mockReturnValue(true); + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-1', + chat_jid: chatJid, + sender: 'other-bot@test', + sender_name: 'Other Bot', + content: '요청을 완료하지 못했습니다. 다시 시도해 주세요.', + timestamp: '2026-03-18T09:00:00.000Z', + is_bot_message: true, + }, + ]); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => lastAgentTimestamps, + saveState, + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-ignore-bot-loop', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(agentRunner.runAgentProcess).not.toHaveBeenCalled(); + expect(lastAgentTimestamps[chatJid]).toBe('0'); + expect(saveState).toHaveBeenCalled(); + }); + + it('processes triggered bot messages in paired rooms', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const channel = makeChannel(chatJid); + const saveState = vi.fn(); + const lastAgentTimestamps: Record = {}; + + vi.mocked(db.isPairedRoomJid).mockReturnValue(true); + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-1', + chat_jid: chatJid, + sender: 'other-bot@test', + sender_name: 'Other Bot', + content: '@Andy 확인 부탁', + timestamp: '2026-03-18T09:00:00.000Z', + is_bot_message: true, + }, + ]); + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + result: '확인했습니다.', + newSessionId: 'session-triggered-bot', + }); + return { + status: 'success', + result: '확인했습니다.', + newSessionId: 'session-triggered-bot', + }; + }, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => lastAgentTimestamps, + saveState, + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-triggered-bot-message', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1); + expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, '확인했습니다.'); + expect(lastAgentTimestamps[chatJid]).toBe('1'); + expect(saveState).toHaveBeenCalled(); + }); + it('clears Claude sessions and closes stdin immediately on poisoned output', async () => { const chatJid = 'group@test'; const group = makeGroup('claude-code'); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 54d7f72..f0c64a7 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -126,6 +126,21 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { channel?.isOwnMessage?.bind(channel), ); + const filterTriggeredPairedBotMessages = ( + chatJid: string, + messages: Parameters[0], + ) => { + if (!isPairedRoomJid(chatJid)) return messages; + + // In paired rooms, require an explicit trigger before reacting to another + // bot's message. This keeps the room usable without reintroducing bot loops. + return messages.filter( + (message) => + !message.is_bot_message || + deps.triggerPattern.test(message.content.trim()), + ); + }; + const deliverOpenWorkItem = async ( channel: Channel, item: WorkItem, @@ -575,10 +590,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { sinceSeqCursor, deps.assistantName, ); - const missedMessages = getProcessableMessages( + const missedMessages = filterTriggeredPairedBotMessages( chatJid, - rawMissedMessages, - channel, + getProcessableMessages(chatJid, rawMissedMessages, channel), ); if (missedMessages.length === 0) {