From ee599b9f0c19e0355f834982a322da273b298fcc Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 2 Apr 2026 17:05:24 +0000 Subject: [PATCH] feat: add reply/quoted message context support Add generic reply context fields to NewMessage (reply_to_message_id, reply_to_message_content, reply_to_sender_name) so any channel can pass quoted message context to the agent. - Add thread_id and reply_to_* fields to NewMessage interface - Add DB migration for reply context columns on messages table - Update storeMessage/getMessagesSince/getNewMessages to persist and retrieve reply fields - Render reply context as XML in formatMessages - Add DB and formatting tests Co-Authored-By: Alfred-the-buttler Co-Authored-By: moktamd Co-Authored-By: gurixs-carson Co-Authored-By: Claude Opus 4.6 (1M context) --- src/db.test.ts | 80 ++++++++++++++++++++++++++++++++++++++++++ src/db.ts | 26 ++++++++++++-- src/formatting.test.ts | 56 +++++++++++++++++++++++++++++ src/router.ts | 9 ++++- src/types.ts | 4 +++ 5 files changed, 171 insertions(+), 4 deletions(-) diff --git a/src/db.test.ts b/src/db.test.ts index ff4872a..e10db20 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -142,6 +142,86 @@ describe('storeMessage', () => { }); }); +// --- reply context persistence --- + +describe('reply context', () => { + it('stores and retrieves reply_to fields', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + + storeMessage({ + id: 'reply-1', + chat_jid: 'group@g.us', + sender: '123', + sender_name: 'Alice', + content: 'Yes, on my way!', + timestamp: '2024-01-01T00:00:01.000Z', + reply_to_message_id: '42', + reply_to_message_content: 'Are you coming tonight?', + reply_to_sender_name: 'Bob', + }); + + const messages = getMessagesSince( + 'group@g.us', + '2024-01-01T00:00:00.000Z', + 'Andy', + ); + expect(messages).toHaveLength(1); + expect(messages[0].reply_to_message_id).toBe('42'); + expect(messages[0].reply_to_message_content).toBe( + 'Are you coming tonight?', + ); + expect(messages[0].reply_to_sender_name).toBe('Bob'); + }); + + it('returns null for messages without reply context', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + + store({ + id: 'no-reply', + chat_jid: 'group@g.us', + sender: '123', + sender_name: 'Alice', + content: 'Just a normal message', + timestamp: '2024-01-01T00:00:01.000Z', + }); + + const messages = getMessagesSince( + 'group@g.us', + '2024-01-01T00:00:00.000Z', + 'Andy', + ); + expect(messages).toHaveLength(1); + expect(messages[0].reply_to_message_id).toBeNull(); + expect(messages[0].reply_to_message_content).toBeNull(); + expect(messages[0].reply_to_sender_name).toBeNull(); + }); + + it('retrieves reply context via getNewMessages', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + + storeMessage({ + id: 'reply-2', + chat_jid: 'group@g.us', + sender: '456', + sender_name: 'Carol', + content: 'Agreed', + timestamp: '2024-01-01T00:00:01.000Z', + reply_to_message_id: '99', + reply_to_message_content: 'We should meet', + reply_to_sender_name: 'Dave', + }); + + const { messages } = getNewMessages( + ['group@g.us'], + '2024-01-01T00:00:00.000Z', + 'Andy', + ); + expect(messages).toHaveLength(1); + expect(messages[0].reply_to_message_id).toBe('99'); + expect(messages[0].reply_to_sender_name).toBe('Dave'); + }); +}); + // --- getMessagesSince --- describe('getMessagesSince', () => { diff --git a/src/db.ts b/src/db.ts index 5aaf0b1..f12e5b6 100644 --- a/src/db.ts +++ b/src/db.ts @@ -146,6 +146,21 @@ function createSchema(database: Database.Database): void { } catch { /* columns already exist */ } + + // Add reply context columns if they don't exist (migration for existing DBs) + try { + database.exec( + `ALTER TABLE messages ADD COLUMN reply_to_message_id TEXT`, + ); + database.exec( + `ALTER TABLE messages ADD COLUMN reply_to_message_content TEXT`, + ); + database.exec( + `ALTER TABLE messages ADD COLUMN reply_to_sender_name TEXT`, + ); + } catch { + /* columns already exist */ + } } export function initDatabase(): void { @@ -274,7 +289,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, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, reply_to_message_id, reply_to_message_content, reply_to_sender_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( msg.id, msg.chat_jid, @@ -284,6 +299,9 @@ export function storeMessage(msg: NewMessage): void { msg.timestamp, msg.is_from_me ? 1 : 0, msg.is_bot_message ? 1 : 0, + msg.reply_to_message_id ?? null, + msg.reply_to_message_content ?? null, + msg.reply_to_sender_name ?? null, ); } @@ -328,7 +346,8 @@ export function getNewMessages( // Subquery takes the N most recent, outer query re-sorts chronologically. const sql = ` SELECT * FROM ( - SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, + reply_to_message_id, reply_to_message_content, reply_to_sender_name FROM messages WHERE timestamp > ? AND chat_jid IN (${placeholders}) AND is_bot_message = 0 AND content NOT LIKE ? @@ -361,7 +380,8 @@ export function getMessagesSince( // Subquery takes the N most recent, outer query re-sorts chronologically. const sql = ` SELECT * FROM ( - SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, + reply_to_message_id, reply_to_message_content, reply_to_sender_name FROM messages WHERE chat_jid = ? AND timestamp > ? AND is_bot_message = 0 AND content NOT LIKE ? diff --git a/src/formatting.test.ts b/src/formatting.test.ts index a630f20..2563576 100644 --- a/src/formatting.test.ts +++ b/src/formatting.test.ts @@ -115,6 +115,62 @@ describe('formatMessages', () => { expect(result).toContain('\n\n'); }); + it('renders reply context as quoted_message element', () => { + const result = formatMessages( + [ + makeMsg({ + content: 'Yes, on my way!', + reply_to_message_id: '42', + reply_to_message_content: 'Are you coming tonight?', + reply_to_sender_name: 'Bob', + }), + ], + TZ, + ); + expect(result).toContain('reply_to="42"'); + expect(result).toContain( + 'Are you coming tonight?', + ); + expect(result).toContain('Yes, on my way!'); + }); + + it('omits reply attributes when no reply context', () => { + const result = formatMessages([makeMsg()], TZ); + expect(result).not.toContain('reply_to'); + expect(result).not.toContain('quoted_message'); + }); + + it('omits quoted_message when content is missing but id is present', () => { + const result = formatMessages( + [ + makeMsg({ + reply_to_message_id: '42', + reply_to_sender_name: 'Bob', + }), + ], + TZ, + ); + expect(result).toContain('reply_to="42"'); + expect(result).not.toContain('quoted_message'); + }); + + it('escapes special characters in reply context', () => { + const result = formatMessages( + [ + makeMsg({ + reply_to_message_id: '1', + reply_to_message_content: '', + reply_to_sender_name: 'A & B', + }), + ], + TZ, + ); + expect(result).toContain('from="A & B"'); + expect(result).toContain( + '<script>alert("xss")</script>', + ); + }); + 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( diff --git a/src/router.ts b/src/router.ts index c14ca89..d6f88ad 100644 --- a/src/router.ts +++ b/src/router.ts @@ -16,7 +16,14 @@ export function formatMessages( ): string { const lines = messages.map((m) => { const displayTime = formatLocalTime(m.timestamp, timezone); - return `${escapeXml(m.content)}`; + const replyAttr = m.reply_to_message_id + ? ` reply_to="${escapeXml(m.reply_to_message_id)}"` + : ''; + const replySnippet = + m.reply_to_message_content && m.reply_to_sender_name + ? `\n ${escapeXml(m.reply_to_message_content)}` + : ''; + return `${replySnippet}${escapeXml(m.content)}`; }); const header = `\n`; diff --git a/src/types.ts b/src/types.ts index bcef463..717aff6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -51,6 +51,10 @@ export interface NewMessage { timestamp: string; is_from_me?: boolean; is_bot_message?: boolean; + thread_id?: string; + reply_to_message_id?: string; + reply_to_message_content?: string; + reply_to_sender_name?: string; } export interface ScheduledTask {