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 <quoted_message> XML in formatMessages - Add DB and formatting tests Co-Authored-By: Alfred-the-buttler <leon.alfred.bot@gmail.com> Co-Authored-By: moktamd <moktamd@users.noreply.github.com> Co-Authored-By: gurixs-carson <gurixs-carson@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
26
src/db.ts
26
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 ?
|
||||
|
||||
@@ -115,6 +115,62 @@ describe('formatMessages', () => {
|
||||
expect(result).toContain('<messages>\n\n</messages>');
|
||||
});
|
||||
|
||||
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(
|
||||
'<quoted_message from="Bob">Are you coming tonight?</quoted_message>',
|
||||
);
|
||||
expect(result).toContain('Yes, on my way!</message>');
|
||||
});
|
||||
|
||||
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: '<script>alert("xss")</script>',
|
||||
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(
|
||||
|
||||
@@ -16,7 +16,14 @@ export function formatMessages(
|
||||
): string {
|
||||
const lines = messages.map((m) => {
|
||||
const displayTime = formatLocalTime(m.timestamp, timezone);
|
||||
return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}">${escapeXml(m.content)}</message>`;
|
||||
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 <quoted_message from="${escapeXml(m.reply_to_sender_name)}">${escapeXml(m.reply_to_message_content)}</quoted_message>`
|
||||
: '';
|
||||
return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}"${replyAttr}>${replySnippet}${escapeXml(m.content)}</message>`;
|
||||
});
|
||||
|
||||
const header = `<context timezone="${escapeXml(timezone)}" />\n`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user