feat(date-time): standardize time context and tool timestamps

This commit is contained in:
Peter Steinberger
2026-01-15 22:26:31 +00:00
parent 634a429c50
commit 8b89980a89
23 changed files with 534 additions and 165 deletions

View File

@@ -27,32 +27,7 @@ import {
readStringArrayParam,
readStringParam,
} from "./common.js";
function formatDiscordTimestamp(ts?: string | null): string | undefined {
if (!ts) return undefined;
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return undefined;
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}`;
}
import { withNormalizedTimestamp } from "../date-time.js";
function parseDiscordMessageLink(link: string) {
const normalized = link.trim();
@@ -76,6 +51,13 @@ export async function handleDiscordMessagingAction(
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
): Promise<AgentToolResult<unknown>> {
const normalizeMessage = (message: unknown) => {
if (!message || typeof message !== "object") return message;
return withNormalizedTimestamp(
message as Record<string, unknown>,
(message as { timestamp?: unknown }).timestamp,
);
};
switch (action) {
case "react": {
if (!isActionEnabled("reactions")) {
@@ -189,7 +171,13 @@ export async function handleDiscordMessagingAction(
);
}
const message = await fetchMessageDiscord(channelId, messageId);
return jsonResult({ ok: true, message, guildId, channelId, messageId });
return jsonResult({
ok: true,
message: normalizeMessage(message),
guildId,
channelId,
messageId,
});
}
case "readMessages": {
if (!isActionEnabled("messages")) {
@@ -207,11 +195,10 @@ export async function handleDiscordMessagingAction(
after: readStringParam(params, "after"),
around: readStringParam(params, "around"),
});
const formattedMessages = messages.map((message) => ({
...message,
timestamp: formatDiscordTimestamp(message.timestamp) ?? message.timestamp,
}));
return jsonResult({ ok: true, messages: formattedMessages });
return jsonResult({
ok: true,
messages: messages.map((message) => normalizeMessage(message)),
});
}
case "sendMessage": {
if (!isActionEnabled("messages")) {
@@ -357,7 +344,7 @@ export async function handleDiscordMessagingAction(
required: true,
});
const pins = await listPinsDiscord(channelId);
return jsonResult({ ok: true, pins });
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
}
case "searchMessages": {
if (!isActionEnabled("search")) {
@@ -386,7 +373,23 @@ export async function handleDiscordMessagingAction(
authorIds: authorIdList.length ? authorIdList : undefined,
limit,
});
return jsonResult({ ok: true, results });
if (!results || typeof results !== "object") {
return jsonResult({ ok: true, results });
}
const resultsRecord = results as Record<string, unknown>;
const messages = resultsRecord.messages;
const normalizedMessages = Array.isArray(messages)
? messages.map((group) =>
Array.isArray(group) ? group.map((msg) => normalizeMessage(msg)) : group,
)
: messages;
return jsonResult({
ok: true,
results: {
...resultsRecord,
messages: normalizedMessages,
},
});
}
default:
throw new Error(`Unknown action: ${action}`);

View File

@@ -17,6 +17,7 @@ const editChannelDiscord = vi.fn(async () => ({
name: "edited",
}));
const editMessageDiscord = vi.fn(async () => ({}));
const fetchMessageDiscord = vi.fn(async () => ({}));
const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
const fetchReactionsDiscord = vi.fn(async () => ({}));
const listPinsDiscord = vi.fn(async () => ({}));
@@ -42,6 +43,7 @@ vi.mock("../../discord/send.js", () => ({
deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args),
editChannelDiscord: (...args: unknown[]) => editChannelDiscord(...args),
editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args),
fetchMessageDiscord: (...args: unknown[]) => fetchMessageDiscord(...args),
fetchChannelPermissionsDiscord: (...args: unknown[]) => fetchChannelPermissionsDiscord(...args),
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
@@ -134,6 +136,80 @@ describe("handleDiscordMessagingAction", () => {
),
).rejects.toThrow(/Discord reactions are disabled/);
});
it("adds normalized timestamps to readMessages payloads", async () => {
readMessagesDiscord.mockResolvedValueOnce([
{ id: "1", timestamp: "2026-01-15T10:00:00.000Z" },
]);
const result = await handleDiscordMessagingAction(
"readMessages",
{ channelId: "C1" },
enableAllActions,
);
const payload = result.details as { messages: Array<{ timestampMs?: number; timestampUtc?: string }> };
const expectedMs = Date.parse("2026-01-15T10:00:00.000Z");
expect(payload.messages[0].timestampMs).toBe(expectedMs);
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("adds normalized timestamps to fetchMessage payloads", async () => {
fetchMessageDiscord.mockResolvedValueOnce({
id: "1",
timestamp: "2026-01-15T11:00:00.000Z",
});
const result = await handleDiscordMessagingAction(
"fetchMessage",
{ guildId: "G1", channelId: "C1", messageId: "M1" },
enableAllActions,
);
const payload = result.details as { message?: { timestampMs?: number; timestampUtc?: string } };
const expectedMs = Date.parse("2026-01-15T11:00:00.000Z");
expect(payload.message?.timestampMs).toBe(expectedMs);
expect(payload.message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("adds normalized timestamps to listPins payloads", async () => {
listPinsDiscord.mockResolvedValueOnce([
{ id: "1", timestamp: "2026-01-15T12:00:00.000Z" },
]);
const result = await handleDiscordMessagingAction(
"listPins",
{ channelId: "C1" },
enableAllActions,
);
const payload = result.details as { pins: Array<{ timestampMs?: number; timestampUtc?: string }> };
const expectedMs = Date.parse("2026-01-15T12:00:00.000Z");
expect(payload.pins[0].timestampMs).toBe(expectedMs);
expect(payload.pins[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("adds normalized timestamps to searchMessages payloads", async () => {
searchMessagesDiscord.mockResolvedValueOnce({
total_results: 1,
messages: [[{ id: "1", timestamp: "2026-01-15T13:00:00.000Z" }]],
});
const result = await handleDiscordMessagingAction(
"searchMessages",
{ guildId: "G1", content: "hi" },
enableAllActions,
);
const payload = result.details as {
results?: { messages?: Array<Array<{ timestampMs?: number; timestampUtc?: string }>> };
};
const expectedMs = Date.parse("2026-01-15T13:00:00.000Z");
expect(payload.results?.messages?.[0]?.[0]?.timestampMs).toBe(expectedMs);
expect(payload.results?.messages?.[0]?.[0]?.timestampUtc).toBe(
new Date(expectedMs).toISOString(),
);
});
});
const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels";

View File

@@ -325,4 +325,38 @@ describe("handleSlackAction", () => {
threadTs: "1111111111.111111",
});
});
it("adds normalized timestamps to readMessages payloads", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
readSlackMessages.mockResolvedValueOnce({
messages: [{ ts: "1735689600.456", text: "hi" }],
hasMore: false,
});
const result = await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
const payload = result.details as { messages: Array<{ timestampMs?: number; timestampUtc?: string }> };
const expectedMs = Math.round(1735689600.456 * 1000);
expect(payload.messages[0].timestampMs).toBe(expectedMs);
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("adds normalized timestamps to pin payloads", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
listSlackPins.mockResolvedValueOnce([
{
type: "message",
message: { ts: "1735689600.789", text: "pinned" },
},
]);
const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, cfg);
const payload = result.details as {
pins: Array<{ message?: { timestampMs?: number; timestampUtc?: string } }>;
};
const expectedMs = Math.round(1735689600.789 * 1000);
expect(payload.pins[0].message?.timestampMs).toBe(expectedMs);
expect(payload.pins[0].message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
});
});

View File

@@ -17,6 +17,7 @@ import {
sendSlackMessage,
unpinSlackMessage,
} from "../../slack/actions.js";
import { withNormalizedTimestamp } from "../date-time.js";
import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js";
const messagingActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
@@ -197,7 +198,13 @@ export async function handleSlackAction(
before: before ?? undefined,
after: after ?? undefined,
});
return jsonResult({ ok: true, ...result });
const messages = result.messages.map((message) =>
withNormalizedTimestamp(
message as Record<string, unknown>,
(message as { ts?: unknown }).ts,
),
);
return jsonResult({ ok: true, messages, hasMore: result.hasMore });
}
default:
break;
@@ -234,7 +241,16 @@ export async function handleSlackAction(
const pins = accountOpts
? await listSlackPins(channelId, accountOpts)
: await listSlackPins(channelId);
return jsonResult({ ok: true, pins });
const normalizedPins = pins.map((pin) => {
const message = pin.message
? withNormalizedTimestamp(
pin.message as Record<string, unknown>,
(pin.message as { ts?: unknown }).ts,
)
: pin.message;
return message ? { ...pin, message } : pin;
});
return jsonResult({ ok: true, pins: normalizedPins });
}
if (action === "memberInfo") {