diff --git a/src/formatting.test.ts b/src/formatting.test.ts
index ea85b9d..8a2160c 100644
--- a/src/formatting.test.ts
+++ b/src/formatting.test.ts
@@ -58,13 +58,14 @@ describe('escapeXml', () => {
// --- formatMessages ---
describe('formatMessages', () => {
- it('formats a single message as XML', () => {
- const result = formatMessages([makeMsg()]);
- expect(result).toBe(
- '\n' +
- 'hello\n' +
- '',
- );
+ const TZ = 'UTC';
+
+ it('formats a single message as XML with context header', () => {
+ const result = formatMessages([makeMsg()], TZ);
+ expect(result).toContain('');
+ expect(result).toContain('hello');
+ expect(result).toContain('Jan 1, 2024');
});
it('formats multiple messages', () => {
@@ -73,11 +74,16 @@ describe('formatMessages', () => {
id: '1',
sender_name: 'Alice',
content: 'hi',
- timestamp: 't1',
+ timestamp: '2024-01-01T00:00:00.000Z',
+ }),
+ makeMsg({
+ id: '2',
+ sender_name: 'Bob',
+ content: 'hey',
+ timestamp: '2024-01-01T01:00:00.000Z',
}),
- makeMsg({ id: '2', sender_name: 'Bob', content: 'hey', timestamp: 't2' }),
];
- const result = formatMessages(msgs);
+ const result = formatMessages(msgs, TZ);
expect(result).toContain('sender="Alice"');
expect(result).toContain('sender="Bob"');
expect(result).toContain('>hi');
@@ -85,22 +91,35 @@ describe('formatMessages', () => {
});
it('escapes special characters in sender names', () => {
- const result = formatMessages([makeMsg({ sender_name: 'A & B ' })]);
+ const result = formatMessages([makeMsg({ sender_name: 'A & B ' })], TZ);
expect(result).toContain('sender="A & B <Co>"');
});
it('escapes special characters in content', () => {
- const result = formatMessages([
- makeMsg({ content: '' }),
- ]);
+ const result = formatMessages(
+ [makeMsg({ content: '' })],
+ TZ,
+ );
expect(result).toContain(
'<script>alert("xss")</script>',
);
});
it('handles empty array', () => {
- const result = formatMessages([]);
- expect(result).toBe('\n\n');
+ const result = formatMessages([], TZ);
+ expect(result).toContain('');
+ expect(result).toContain('\n\n');
+ });
+
+ 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(
+ [makeMsg({ timestamp: '2024-01-01T18:30:00.000Z' })],
+ 'America/New_York',
+ );
+ expect(result).toContain('1:30');
+ expect(result).toContain('PM');
+ expect(result).toContain('');
});
});
diff --git a/src/index.ts b/src/index.ts
index 85aba50..c35261e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,6 +5,7 @@ import {
ASSISTANT_NAME,
IDLE_TIMEOUT,
POLL_INTERVAL,
+ TIMEZONE,
TRIGGER_PATTERN,
} from './config.js';
import './channels/index.js';
@@ -29,6 +30,7 @@ import {
getAllTasks,
getMessagesSince,
getNewMessages,
+ getRegisteredGroup,
getRouterState,
initDatabase,
setRegisteredGroup,
@@ -170,7 +172,7 @@ async function processGroupMessages(chatJid: string): Promise {
if (!hasTrigger) return true;
}
- const prompt = formatMessages(missedMessages);
+ const prompt = formatMessages(missedMessages, TIMEZONE);
// Advance cursor so the piping path in startMessageLoop won't re-fetch
// these messages. Save the old cursor so we can roll back on error.
@@ -408,7 +410,7 @@ async function startMessageLoop(): Promise {
);
const messagesToSend =
allPending.length > 0 ? allPending : groupMessages;
- const formatted = formatMessages(messagesToSend);
+ const formatted = formatMessages(messagesToSend, TIMEZONE);
if (queue.sendMessage(chatJid, formatted)) {
logger.debug(
diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts
index 7edc7db..1aa681e 100644
--- a/src/ipc-auth.test.ts
+++ b/src/ipc-auth.test.ts
@@ -74,7 +74,7 @@ describe('schedule_task authorization', () => {
type: 'schedule_task',
prompt: 'do something',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
targetJid: 'other@g.us',
},
'whatsapp_main',
@@ -94,7 +94,7 @@ describe('schedule_task authorization', () => {
type: 'schedule_task',
prompt: 'self task',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
targetJid: 'other@g.us',
},
'other-group',
@@ -113,7 +113,7 @@ describe('schedule_task authorization', () => {
type: 'schedule_task',
prompt: 'unauthorized',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
targetJid: 'main@g.us',
},
'other-group',
@@ -131,7 +131,7 @@ describe('schedule_task authorization', () => {
type: 'schedule_task',
prompt: 'no target',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
targetJid: 'unknown@g.us',
},
'whatsapp_main',
@@ -154,7 +154,7 @@ describe('pause_task authorization', () => {
chat_jid: 'main@g.us',
prompt: 'main task',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z',
status: 'active',
@@ -166,7 +166,7 @@ describe('pause_task authorization', () => {
chat_jid: 'other@g.us',
prompt: 'other task',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z',
status: 'active',
@@ -215,7 +215,7 @@ describe('resume_task authorization', () => {
chat_jid: 'other@g.us',
prompt: 'paused task',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z',
status: 'paused',
@@ -264,7 +264,7 @@ describe('cancel_task authorization', () => {
chat_jid: 'other@g.us',
prompt: 'cancel me',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: null,
status: 'active',
@@ -287,7 +287,7 @@ describe('cancel_task authorization', () => {
chat_jid: 'other@g.us',
prompt: 'my task',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: null,
status: 'active',
@@ -310,7 +310,7 @@ describe('cancel_task authorization', () => {
chat_jid: 'main@g.us',
prompt: 'not yours',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: null,
status: 'active',
@@ -565,7 +565,7 @@ describe('schedule_task context_mode', () => {
type: 'schedule_task',
prompt: 'group context',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'group',
targetJid: 'other@g.us',
},
@@ -584,7 +584,7 @@ describe('schedule_task context_mode', () => {
type: 'schedule_task',
prompt: 'isolated context',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
targetJid: 'other@g.us',
},
@@ -603,7 +603,7 @@ describe('schedule_task context_mode', () => {
type: 'schedule_task',
prompt: 'bad context',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'bogus' as any,
targetJid: 'other@g.us',
},
@@ -622,7 +622,7 @@ describe('schedule_task context_mode', () => {
type: 'schedule_task',
prompt: 'no context mode',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
targetJid: 'other@g.us',
},
'whatsapp_main',
diff --git a/src/ipc.ts b/src/ipc.ts
index e5614ce..7a972c0 100644
--- a/src/ipc.ts
+++ b/src/ipc.ts
@@ -236,15 +236,15 @@ export async function processTaskIpc(
}
nextRun = new Date(Date.now() + ms).toISOString();
} else if (scheduleType === 'once') {
- const scheduled = new Date(data.schedule_value);
- if (isNaN(scheduled.getTime())) {
+ const date = new Date(data.schedule_value);
+ if (isNaN(date.getTime())) {
logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid timestamp',
);
break;
}
- nextRun = scheduled.toISOString();
+ nextRun = date.toISOString();
}
const taskId =
diff --git a/src/router.ts b/src/router.ts
index 3c9fbc0..c14ca89 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -1,4 +1,5 @@
import { Channel, NewMessage } from './types.js';
+import { formatLocalTime } from './timezone.js';
export function escapeXml(s: string): string {
if (!s) return '';
@@ -9,12 +10,18 @@ export function escapeXml(s: string): string {
.replace(/"/g, '"');
}
-export function formatMessages(messages: NewMessage[]): string {
- const lines = messages.map(
- (m) =>
- `${escapeXml(m.content)}`,
- );
- return `\n${lines.join('\n')}\n`;
+export function formatMessages(
+ messages: NewMessage[],
+ timezone: string,
+): string {
+ const lines = messages.map((m) => {
+ const displayTime = formatLocalTime(m.timestamp, timezone);
+ return `${escapeXml(m.content)}`;
+ });
+
+ const header = `\n`;
+
+ return `${header}\n${lines.join('\n')}\n`;
}
export function stripInternalTags(text: string): string {
diff --git a/src/timezone.test.ts b/src/timezone.test.ts
new file mode 100644
index 0000000..df0525f
--- /dev/null
+++ b/src/timezone.test.ts
@@ -0,0 +1,29 @@
+import { describe, it, expect } from 'vitest';
+
+import { formatLocalTime } from './timezone.js';
+
+// --- formatLocalTime ---
+
+describe('formatLocalTime', () => {
+ it('converts UTC to local time display', () => {
+ // 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM
+ const result = formatLocalTime(
+ '2026-02-04T18:30:00.000Z',
+ 'America/New_York',
+ );
+ expect(result).toContain('1:30');
+ expect(result).toContain('PM');
+ expect(result).toContain('Feb');
+ expect(result).toContain('2026');
+ });
+
+ it('handles different timezones', () => {
+ // Same UTC time should produce different local times
+ const utc = '2026-06-15T12:00:00.000Z';
+ const ny = formatLocalTime(utc, 'America/New_York');
+ const tokyo = formatLocalTime(utc, 'Asia/Tokyo');
+ // NY is UTC-4 in summer (EDT), Tokyo is UTC+9
+ expect(ny).toContain('8:00');
+ expect(tokyo).toContain('9:00');
+ });
+});
diff --git a/src/timezone.ts b/src/timezone.ts
new file mode 100644
index 0000000..e7569f4
--- /dev/null
+++ b/src/timezone.ts
@@ -0,0 +1,16 @@
+/**
+ * Convert a UTC ISO timestamp to a localized display string.
+ * Uses the Intl API (no external dependencies).
+ */
+export function formatLocalTime(utcIso: string, timezone: string): string {
+ const date = new Date(utcIso);
+ return date.toLocaleString('en-US', {
+ timeZone: timezone,
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ });
+}