diff --git a/src/auto-reply/envelope.test.ts b/src/auto-reply/envelope.test.ts index 256353186..90f8b9ef0 100644 --- a/src/auto-reply/envelope.test.ts +++ b/src/auto-reply/envelope.test.ts @@ -4,6 +4,9 @@ import { formatAgentEnvelope } from "./envelope.js"; describe("formatAgentEnvelope", () => { it("includes surface, from, ip, host, and timestamp", () => { + const originalTz = process.env.TZ; + process.env.TZ = "UTC"; + const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z const body = formatAgentEnvelope({ surface: "WebChat", @@ -13,8 +16,29 @@ describe("formatAgentEnvelope", () => { timestamp: ts, body: "hello", }); + + process.env.TZ = originalTz; + + expect(body).toMatch( + /^\[WebChat user1 mac-mini 10\.0\.0\.5 2025-01-02T03:04\+00:00\{.+\}\] hello$/, + ); + }); + + it("formats timestamps in local time (not UTC)", () => { + const originalTz = process.env.TZ; + process.env.TZ = "America/Los_Angeles"; + + const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z + const body = formatAgentEnvelope({ + surface: "WebChat", + timestamp: ts, + body: "hello", + }); + + process.env.TZ = originalTz; + expect(body).toBe( - "[WebChat user1 mac-mini 10.0.0.5 2025-01-02 03:04] hello", + "[WebChat 2025-01-01T19:04-08:00{America/Los_Angeles}] hello", ); }); diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index d34022b53..fc5ad21fe 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -11,8 +11,26 @@ function formatTimestamp(ts?: number | Date): string | undefined { if (!ts) return undefined; const date = ts instanceof Date ? ts : new Date(ts); if (Number.isNaN(date.getTime())) return undefined; - // Compact ISO-like format with minutes precision. - return date.toISOString().slice(0, 16).replace("T", " "); + + const yyyy = String(date.getFullYear()).padStart(4, "0"); + const mm = String(date.getMonth() + 1).padStart(2, "0"); + const dd = String(date.getDate()).padStart(2, "0"); + const hh = String(date.getHours()).padStart(2, "0"); + const min = String(date.getMinutes()).padStart(2, "0"); + + // getTimezoneOffset() is minutes *behind* UTC. Flip sign to get ISO offset. + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absOffsetMinutes = Math.abs(offsetMinutes); + const offsetH = String(Math.floor(absOffsetMinutes / 60)).padStart(2, "0"); + const offsetM = String(absOffsetMinutes % 60).padStart(2, "0"); + + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + const tzSuffix = tz ? `{${tz}}` : ""; + + // Compact ISO-like *local* timestamp with minutes precision. + // Example: 2025-01-02T03:04-08:00{America/Los_Angeles} + return `${yyyy}-${mm}-${dd}T${hh}:${min}${sign}${offsetH}:${offsetM}${tzSuffix}`; } export function formatAgentEnvelope(params: AgentEnvelopeParams): string { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index aeafdae3c..b012ff565 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -48,40 +48,47 @@ describe("createTelegramBot", () => { }); it("wraps inbound message with Telegram envelope", async () => { - onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; - replySpy.mockReset(); + const originalTz = process.env.TZ; + process.env.TZ = "Europe/Vienna"; - createTelegramBot({ token: "tok" }); - expect(onSpy).toHaveBeenCalledWith("message", expect.any(Function)); - const handler = onSpy.mock.calls[0][1] as ( - ctx: Record, - ) => Promise; + try { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); - const message = { - chat: { id: 1234, type: "private" }, - text: "hello world", - date: 1736380800, // 2025-01-09T00:00:00Z - from: { - first_name: "Ada", - last_name: "Lovelace", - username: "ada_bot", - }, - }; - await handler({ - message, - me: { username: "clawdis_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); + createTelegramBot({ token: "tok" }); + expect(onSpy).toHaveBeenCalledWith("message", expect.any(Function)); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.Body).toMatch( - /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09 00:00]/, - ); - expect(payload.Body).toContain("hello world"); + const message = { + chat: { id: 1234, type: "private" }, + text: "hello world", + date: 1736380800, // 2025-01-09T00:00:00Z + from: { + first_name: "Ada", + last_name: "Lovelace", + username: "ada_bot", + }, + }; + await handler({ + message, + me: { username: "clawdis_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toMatch( + /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T01:00\+01:00\{Europe\/Vienna\}\]/, + ); + expect(payload.Body).toContain("hello world"); + } finally { + process.env.TZ = originalTz; + } }); it("triggers typing cue via onReplyStart", async () => { diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index a1bdc5d0c..cf44185be 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -868,6 +868,9 @@ describe("web auto-reply", () => { }); it("processes inbound messages without batching and preserves timestamps", async () => { + const originalTz = process.env.TZ; + process.env.TZ = "Europe/Vienna"; + const originalMax = process.getMaxListeners(); process.setMaxListeners?.(1); // force low to confirm bump @@ -875,72 +878,75 @@ describe("web auto-reply", () => { main: { sessionId: "sid", updatedAt: Date.now() }, }); - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "ok" }); + try { + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "ok" }); - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: ( - msg: import("./inbound.js").WebInboundMessage, - ) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; - setLoadConfigMock(() => ({ - inbound: { - timestampPrefix: "UTC", - session: { store: store.storePath }, - }, - })); + setLoadConfigMock(() => ({ + inbound: { + timestampPrefix: "UTC", + session: { store: store.storePath }, + }, + })); - await monitorWebProvider(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); - // Two messages from the same sender with fixed timestamps - await capturedOnMessage?.({ - body: "first", - from: "+1", - to: "+2", - id: "m1", - timestamp: 1735689600000, // Jan 1 2025 00:00:00 UTC - sendComposing, - reply, - sendMedia, - }); - await capturedOnMessage?.({ - body: "second", - from: "+1", - to: "+2", - id: "m2", - timestamp: 1735693200000, // Jan 1 2025 01:00:00 UTC - sendComposing, - reply, - sendMedia, - }); + // Two messages from the same sender with fixed timestamps + await capturedOnMessage?.({ + body: "first", + from: "+1", + to: "+2", + id: "m1", + timestamp: 1735689600000, // Jan 1 2025 00:00:00 UTC + sendComposing, + reply, + sendMedia, + }); + await capturedOnMessage?.({ + body: "second", + from: "+1", + to: "+2", + id: "m2", + timestamp: 1735693200000, // Jan 1 2025 01:00:00 UTC + sendComposing, + reply, + sendMedia, + }); - expect(resolver).toHaveBeenCalledTimes(2); - const firstArgs = resolver.mock.calls[0][0]; - const secondArgs = resolver.mock.calls[1][0]; - expect(firstArgs.Body).toContain( - "[WhatsApp +1 2025-01-01 00:00] [clawdis] first", - ); - expect(firstArgs.Body).not.toContain("second"); - expect(secondArgs.Body).toContain( - "[WhatsApp +1 2025-01-01 01:00] [clawdis] second", - ); - expect(secondArgs.Body).not.toContain("first"); + expect(resolver).toHaveBeenCalledTimes(2); + const firstArgs = resolver.mock.calls[0][0]; + const secondArgs = resolver.mock.calls[1][0]; + expect(firstArgs.Body).toContain( + "[WhatsApp +1 2025-01-01T01:00+01:00{Europe/Vienna}] [clawdis] first", + ); + expect(firstArgs.Body).not.toContain("second"); + expect(secondArgs.Body).toContain( + "[WhatsApp +1 2025-01-01T02:00+01:00{Europe/Vienna}] [clawdis] second", + ); + expect(secondArgs.Body).not.toContain("first"); - // Max listeners bumped to avoid warnings in multi-instance test runs - expect(process.getMaxListeners?.()).toBeGreaterThanOrEqual(50); - - process.setMaxListeners?.(originalMax); - await store.cleanup(); + // Max listeners bumped to avoid warnings in multi-instance test runs + expect(process.getMaxListeners?.()).toBeGreaterThanOrEqual(50); + } finally { + process.setMaxListeners?.(originalMax); + process.env.TZ = originalTz; + await store.cleanup(); + } }); it("falls back to text when media send fails", async () => {