diff --git a/CHANGELOG.md b/CHANGELOG.md index ed995426a84..9b18dd882f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,7 @@ Docs: https://docs.openclaw.ai - Memory/LanceDB: make auto-capture recognize short CJK memory phrases and configurable literal triggers, so Chinese, Japanese, and Korean users can capture memories without regex or LLM intent detection. Fixes #75680. Thanks @vyctorbrzezowski and @guokewuming. - Plugins doctor: report stale plugin config warnings and avoid claiming full plugin health when config warnings remain. (#81515) Thanks @BKF-Gitty. - Sessions: display `model: "-acp"` / `modelProvider: "acpx"` (ACP-runtime sentinel) for ACP control-plane sessions in `openclaw sessions` output, instead of the agent's configured model which was misleading. Catalog finding 20. (#79543) +- Slack: normalize message read `before` and `after` timestamp bounds before calling Slack history or thread reply APIs. Fixes #80835. (#81338) Thanks @honor2030. ### Changes diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 7066c07842d..3f27983b3b4 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -9,7 +9,8 @@ "@slack/types": "2.21.1", "@slack/web-api": "7.15.2", "https-proxy-agent": "9.0.0", - "typebox": "1.1.38" + "typebox": "1.1.38", + "zod": "4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/slack/src/actions.read.test.ts b/extensions/slack/src/actions.read.test.ts index 2a68833c0e0..53cc631ada4 100644 --- a/extensions/slack/src/actions.read.test.ts +++ b/extensions/slack/src/actions.read.test.ts @@ -118,4 +118,97 @@ describe("readSlackMessages", () => { hasMore: false, }); }); + + it("passes Slack timestamp strings through to history bounds", async () => { + const client = createClient(); + + await readSlackMessages("C1", { + client, + before: "1712345678.654321", + after: "1712340000.000001", + token: "xoxb-test", + }); + + expect(client.conversations.history).toHaveBeenCalledWith({ + channel: "C1", + limit: undefined, + latest: "1712345678.654321", + oldest: "1712340000.000001", + }); + }); + + it("converts ISO date strings to epoch seconds for history bounds", async () => { + const client = createClient(); + + await readSlackMessages("C1", { + client, + before: "2024-04-05T12:34:56.000Z", + after: "2024-04-05T00:00:00.000Z", + token: "xoxb-test", + }); + + expect(client.conversations.history).toHaveBeenCalledWith({ + channel: "C1", + limit: undefined, + latest: "1712320496", + oldest: "1712275200", + }); + }); + + it("converts ISO date strings with offsets to epoch seconds for history bounds", async () => { + const client = createClient(); + + await readSlackMessages("C1", { + client, + before: "2024-04-05T12:34:56+03:00", + after: "2024-04-05T12:34:56.789+03:00", + token: "xoxb-test", + }); + + expect(client.conversations.history).toHaveBeenCalledWith({ + channel: "C1", + limit: undefined, + latest: "1712309696", + oldest: "1712309696.789", + }); + }); + + it.each(["not-a-timestamp", "2024-02-30T00:00:00.000Z", "04/05/2024", "2024-04-05T12:34:56"])( + "rejects invalid history bound %s with a clear timestamp error", + async (before) => { + const client = createClient(); + + await expect( + readSlackMessages("C1", { + client, + before, + token: "xoxb-test", + }), + ).rejects.toThrow( + `Invalid Slack read before timestamp "${before}": expected a Slack timestamp or ISO-8601 date string`, + ); + expect(client.conversations.history).not.toHaveBeenCalled(); + }, + ); + + it("normalizes ISO date strings and Slack timestamp strings for thread reply bounds", async () => { + const client = createClient(); + + await readSlackMessages("C1", { + client, + threadId: "1712345678.000001", + before: "2024-04-05T12:34:56.000Z", + after: "1712340000.000001", + token: "xoxb-test", + }); + + expect(client.conversations.replies).toHaveBeenCalledWith({ + channel: "C1", + ts: "1712345678.000001", + limit: undefined, + latest: "1712320496", + oldest: "1712340000.000001", + }); + expect(client.conversations.history).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/slack/src/actions.ts b/extensions/slack/src/actions.ts index 8b9b39d8476..b17972f0dfa 100644 --- a/extensions/slack/src/actions.ts +++ b/extensions/slack/src/actions.ts @@ -2,6 +2,7 @@ import type { Block, KnownBlock, WebClient } from "@slack/web-api"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { z } from "zod"; import { resolveSlackAccount } from "./accounts.js"; import { validateSlackBlocksArray } from "./blocks-input.js"; import { createSlackWebClient, getSlackWriteClient } from "./client.js"; @@ -77,6 +78,42 @@ function normalizeEmoji(raw: string) { return trimmed.replace(/^:+|:+$/g, ""); } +const SLACK_TIMESTAMP_RE = /^\d+(?:\.\d+)?$/; +const ISO_8601_TIMESTAMP_SCHEMA = z.iso.datetime({ offset: true }); + +function formatEpochSeconds(milliseconds: number): string { + const seconds = milliseconds / 1000; + if (Number.isInteger(seconds)) { + return String(seconds); + } + return seconds.toFixed(3).replace(/0+$/, "").replace(/\.$/, ""); +} + +function normalizeSlackReadTimestamp( + raw: string | undefined, + field: "before" | "after", +): string | undefined { + const trimmed = raw?.trim(); + if (!trimmed) { + return undefined; + } + if (SLACK_TIMESTAMP_RE.test(trimmed)) { + return trimmed; + } + if (!ISO_8601_TIMESTAMP_SCHEMA.safeParse(trimmed).success) { + throw new Error( + `Invalid Slack read ${field} timestamp "${trimmed}": expected a Slack timestamp or ISO-8601 date string`, + ); + } + const parsed = Date.parse(trimmed); + if (!Number.isFinite(parsed)) { + throw new Error( + `Invalid Slack read ${field} timestamp "${trimmed}": expected a Slack timestamp or ISO-8601 date string`, + ); + } + return formatEpochSeconds(parsed); +} + function hasSlackPlatformError(err: unknown, code: string): boolean { if (!err || typeof err !== "object") { return false; @@ -268,7 +305,6 @@ export async function readSlackMessages( messageId?: string; } = {}, ): Promise<{ messages: SlackMessageSummary[]; hasMore: boolean }> { - const client = await getClient(opts); const exactMessageId = opts.messageId?.trim(); const readLimit = exactMessageId ? 1 : opts.limit; const exactBounds = exactMessageId @@ -278,9 +314,10 @@ export async function readSlackMessages( oldest: undefined, } : { - latest: opts.before, - oldest: opts.after, + latest: normalizeSlackReadTimestamp(opts.before, "before"), + oldest: normalizeSlackReadTimestamp(opts.after, "after"), }; + const client = await getClient(opts); // Use conversations.replies for thread messages, conversations.history for channel messages. if (opts.threadId) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7aa8488a4d3..605fad318f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1427,6 +1427,9 @@ importers: typebox: specifier: 1.1.38 version: 1.1.38 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:*