fix(slack): normalize read timestamp bounds (#81338)

* fix(slack): normalize read timestamp bounds

* fix(slack): document read timestamp bounds fix

* fix(slack): simplify timestamp bounds validation

---------

Co-authored-by: honor2030 <19909783+honor2030@users.noreply.github.com>
Co-authored-by: Altay <altay@hey.com>
This commit is contained in:
이민재
2026-05-14 07:52:55 +09:00
committed by GitHub
parent d08f68dee7
commit 72f50dd127
5 changed files with 139 additions and 4 deletions

View File

@@ -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: "<agentId>-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

View File

@@ -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:*"

View File

@@ -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();
});
});

View File

@@ -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) {

3
pnpm-lock.yaml generated
View File

@@ -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:*