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 {