mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 10:27:38 +00:00
feat(date-time): standardize time context and tool timestamps
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user