mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 21:00:44 +00:00
fix(slack): preserve real thread anchors
This commit is contained in:
@@ -456,6 +456,127 @@ describe("slackPlugin outbound", () => {
|
||||
expect(result).toEqual({ channel: "slack", messageId: "m-media" });
|
||||
});
|
||||
|
||||
it("falls back to threadId when replyToId is not a Slack thread timestamp", async () => {
|
||||
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-text" });
|
||||
const sendText = requireSlackSendText();
|
||||
|
||||
const result = await sendText({
|
||||
cfg,
|
||||
to: "C123",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
replyToId: "msg-internal-1",
|
||||
threadId: "1712345678.123456",
|
||||
deps: { sendSlack },
|
||||
});
|
||||
|
||||
expect(sendSlack).toHaveBeenCalledWith(
|
||||
"C123",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
threadTs: "1712345678.123456",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ channel: "slack", messageId: "m-text" });
|
||||
});
|
||||
|
||||
it("does not stringify numeric Slack thread ids", async () => {
|
||||
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-text" });
|
||||
const sendText = requireSlackSendText();
|
||||
|
||||
await sendText({
|
||||
cfg,
|
||||
to: "C123",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
threadId: 1712345678.123456,
|
||||
deps: { sendSlack },
|
||||
});
|
||||
|
||||
expect(sendSlack).toHaveBeenCalledWith(
|
||||
"C123",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
threadTs: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to auto-thread lookup when replyToId is not a Slack thread timestamp", () => {
|
||||
const resolveAutoThreadId = slackPlugin.threading?.resolveAutoThreadId;
|
||||
if (!resolveAutoThreadId) {
|
||||
throw new Error("slack threading.resolveAutoThreadId unavailable");
|
||||
}
|
||||
|
||||
const threadId = resolveAutoThreadId({
|
||||
cfg,
|
||||
to: "channel:C123",
|
||||
replyToId: "msg-internal-1",
|
||||
toolContext: {
|
||||
currentChannelId: "C123",
|
||||
currentThreadTs: "1712345678.123456",
|
||||
replyToMode: "all",
|
||||
},
|
||||
});
|
||||
|
||||
expect(threadId).toBe("1712345678.123456");
|
||||
});
|
||||
|
||||
it("does not recover invalid Slack auto-thread anchors", () => {
|
||||
const resolveAutoThreadId = slackPlugin.threading?.resolveAutoThreadId;
|
||||
if (!resolveAutoThreadId) {
|
||||
throw new Error("slack threading.resolveAutoThreadId unavailable");
|
||||
}
|
||||
|
||||
const threadId = resolveAutoThreadId({
|
||||
cfg,
|
||||
to: "channel:C123",
|
||||
replyToId: "msg-internal-1",
|
||||
toolContext: {
|
||||
currentChannelId: "C123",
|
||||
currentThreadTs: "thread-root",
|
||||
replyToMode: "all",
|
||||
},
|
||||
});
|
||||
|
||||
expect(threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not stringify numeric thread ids in tool context", () => {
|
||||
const buildToolContext = slackPlugin.threading?.buildToolContext;
|
||||
if (!buildToolContext) {
|
||||
throw new Error("slack threading.buildToolContext unavailable");
|
||||
}
|
||||
|
||||
const context = buildToolContext({
|
||||
cfg,
|
||||
context: {
|
||||
To: "channel:C123",
|
||||
MessageThreadId: 1712345678.123456,
|
||||
},
|
||||
});
|
||||
|
||||
expect(context?.currentThreadTs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to threadId in reply transport when replyToId is not a Slack thread timestamp", () => {
|
||||
const resolveReplyTransport = slackPlugin.threading?.resolveReplyTransport;
|
||||
if (!resolveReplyTransport) {
|
||||
throw new Error("slack threading.resolveReplyTransport unavailable");
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveReplyTransport({
|
||||
cfg,
|
||||
replyToId: "msg-internal-1",
|
||||
threadId: "1712345678.123456",
|
||||
}),
|
||||
).toEqual({
|
||||
replyToId: "1712345678.123456",
|
||||
threadId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards mediaLocalRoots for sendMedia", async () => {
|
||||
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-media-local" });
|
||||
const sendMedia = requireSlackSendMedia();
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
slackConfigAdapter,
|
||||
} from "./shared.js";
|
||||
import { parseSlackTarget } from "./target-parsing.js";
|
||||
import { normalizeSlackThreadTsCandidate, resolveSlackThreadTsValue } from "./thread-ts.js";
|
||||
import { buildSlackThreadingToolContext } from "./threading-tool-context.js";
|
||||
|
||||
// Lazy SDK loaders. The dynamic import is hidden behind a string-literal
|
||||
@@ -218,7 +219,7 @@ async function resolveSlackSendContext(params: {
|
||||
const token = getTokenForOperation(account, "write");
|
||||
const botToken = account.botToken?.trim();
|
||||
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||
const threadTsValue = params.replyToId ?? params.threadId;
|
||||
const threadTsValue = resolveSlackThreadTsValue(params);
|
||||
return { send, threadTsValue, tokenOverride };
|
||||
}
|
||||
|
||||
@@ -608,14 +609,16 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
allowExplicitReplyTagsWhenOff: false,
|
||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||
resolveAutoThreadId: ({ to, toolContext, replyToId }) =>
|
||||
replyToId
|
||||
normalizeSlackThreadTsCandidate(replyToId)
|
||||
? undefined
|
||||
: resolveSlackAutoThreadId({
|
||||
to,
|
||||
toolContext,
|
||||
}),
|
||||
: normalizeSlackThreadTsCandidate(
|
||||
resolveSlackAutoThreadId({
|
||||
to,
|
||||
toolContext,
|
||||
}),
|
||||
),
|
||||
resolveReplyTransport: ({ threadId, replyToId }) => ({
|
||||
replyToId: replyToId ?? (threadId != null && threadId !== "" ? String(threadId) : undefined),
|
||||
replyToId: resolveSlackThreadTsValue({ replyToId, threadId }),
|
||||
threadId: null,
|
||||
}),
|
||||
},
|
||||
@@ -632,7 +635,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
payload,
|
||||
}),
|
||||
sendPayload: async (ctx) => {
|
||||
const { send, tokenOverride } = await resolveSlackSendContext({
|
||||
const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({
|
||||
cfg: ctx.cfg,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
deps: ctx.deps,
|
||||
@@ -642,6 +645,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
const { slackOutbound } = await loadSlackOutboundAdapterModule();
|
||||
return await slackOutbound.sendPayload!({
|
||||
...ctx,
|
||||
replyToId: threadTsValue,
|
||||
threadId: null,
|
||||
deps: {
|
||||
...ctx.deps,
|
||||
slack: async (
|
||||
@@ -669,7 +674,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
});
|
||||
return await send(to, text, {
|
||||
cfg,
|
||||
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
|
||||
threadTs: threadTsValue,
|
||||
accountId: accountId ?? undefined,
|
||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||
});
|
||||
@@ -696,7 +701,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
cfg,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
|
||||
threadTs: threadTsValue,
|
||||
accountId: accountId ?? undefined,
|
||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||
});
|
||||
|
||||
@@ -265,6 +265,9 @@ vi.mock("@slack/bolt", () => {
|
||||
command() {
|
||||
/* no-op */
|
||||
}
|
||||
use() {
|
||||
/* no-op */
|
||||
}
|
||||
start = vi.fn().mockResolvedValue(undefined);
|
||||
stop = vi.fn().mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
@@ -115,4 +115,62 @@ describe("slackOutbound", () => {
|
||||
);
|
||||
expect(result).toEqual({ channel: "slack", messageId: "m-blocks" });
|
||||
});
|
||||
|
||||
it("falls back to threadId when payload replyToId is not a Slack thread timestamp", async () => {
|
||||
sendMessageSlackMock.mockResolvedValueOnce({ messageId: "m-blocks" });
|
||||
|
||||
await slackOutbound.sendPayload!({
|
||||
cfg,
|
||||
to: "C123",
|
||||
text: "",
|
||||
replyToId: "msg-internal-1",
|
||||
threadId: "1712345678.123456",
|
||||
payload: {
|
||||
text: "fallback text",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: [{ type: "divider" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(sendMessageSlackMock).toHaveBeenCalledWith(
|
||||
"C123",
|
||||
"fallback text",
|
||||
expect.objectContaining({
|
||||
threadTs: "1712345678.123456",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not thread payloads without a valid Slack thread timestamp", async () => {
|
||||
sendMessageSlackMock.mockResolvedValueOnce({ messageId: "m-blocks" });
|
||||
|
||||
await slackOutbound.sendPayload!({
|
||||
cfg,
|
||||
to: "C123",
|
||||
text: "",
|
||||
replyToId: "msg-internal-1",
|
||||
threadId: "thread-root",
|
||||
payload: {
|
||||
text: "fallback text",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: [{ type: "divider" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(sendMessageSlackMock).toHaveBeenCalledWith(
|
||||
"C123",
|
||||
"fallback text",
|
||||
expect.objectContaining({
|
||||
threadTs: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { compileSlackInteractiveReplies } from "./interactive-replies.js";
|
||||
import { SLACK_TEXT_LIMIT } from "./limits.js";
|
||||
import type { SlackSendIdentity } from "./send.js";
|
||||
import { resolveSlackThreadTsValue } from "./thread-ts.js";
|
||||
|
||||
const SLACK_MAX_BLOCKS = 50;
|
||||
type SlackSendFn = typeof import("./send.runtime.js").sendMessageSlack;
|
||||
@@ -84,9 +85,13 @@ async function sendSlackOutboundMessage(params: {
|
||||
resolveOutboundSendDep<SlackSendFn>(params.deps, "slack") ??
|
||||
(await loadSlackSendRuntime()).sendMessageSlack;
|
||||
const slackIdentity = resolveSlackSendIdentity(params.identity);
|
||||
const threadTs = resolveSlackThreadTsValue({
|
||||
replyToId: params.replyToId,
|
||||
threadId: params.threadId,
|
||||
});
|
||||
const result = await send(params.to, params.text, {
|
||||
cfg: params.cfg,
|
||||
threadTs: params.replyToId ?? (params.threadId != null ? String(params.threadId) : undefined),
|
||||
threadTs,
|
||||
accountId: params.accountId ?? undefined,
|
||||
...(params.mediaUrl
|
||||
? {
|
||||
|
||||
34
extensions/slack/src/thread-ts.test.ts
Normal file
34
extensions/slack/src/thread-ts.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeSlackThreadTsCandidate, resolveSlackThreadTsValue } from "./thread-ts.js";
|
||||
|
||||
describe("Slack thread_ts resolution", () => {
|
||||
it("accepts trimmed Slack timestamp strings", () => {
|
||||
expect(normalizeSlackThreadTsCandidate(" 1712345678.123456 ")).toBe("1712345678.123456");
|
||||
});
|
||||
|
||||
it("rejects internal reply ids", () => {
|
||||
expect(normalizeSlackThreadTsCandidate("msg-internal-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects numeric thread ids instead of stringifying them", () => {
|
||||
expect(normalizeSlackThreadTsCandidate(1712345678.123456)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back from invalid replyToId to valid threadId", () => {
|
||||
expect(
|
||||
resolveSlackThreadTsValue({
|
||||
replyToId: "msg-internal-1",
|
||||
threadId: "1712345678.123456",
|
||||
}),
|
||||
).toBe("1712345678.123456");
|
||||
});
|
||||
|
||||
it("validates fallback threadId before using it", () => {
|
||||
expect(
|
||||
resolveSlackThreadTsValue({
|
||||
replyToId: "msg-internal-1",
|
||||
threadId: "thread-root",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
23
extensions/slack/src/thread-ts.ts
Normal file
23
extensions/slack/src/thread-ts.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const SLACK_THREAD_TS_PATTERN = /^\d+\.\d+$/;
|
||||
|
||||
export function normalizeSlackThreadTsCandidate(
|
||||
value?: string | number | null,
|
||||
): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeOptionalString(value);
|
||||
return normalized && SLACK_THREAD_TS_PATTERN.test(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function resolveSlackThreadTsValue(params: {
|
||||
replyToId?: string | number | null;
|
||||
threadId?: string | number | null;
|
||||
}): string | undefined {
|
||||
return (
|
||||
normalizeSlackThreadTsCandidate(params.replyToId) ??
|
||||
normalizeSlackThreadTsCandidate(params.threadId)
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveSlackAccount, resolveSlackReplyToMode } from "./accounts.js";
|
||||
import { normalizeSlackThreadTsCandidate } from "./thread-ts.js";
|
||||
|
||||
export function buildSlackThreadingToolContext(params: {
|
||||
cfg: OpenClawConfig;
|
||||
@@ -28,7 +29,7 @@ export function buildSlackThreadingToolContext(params: {
|
||||
: normalizeOptionalString(params.context.NativeChannelId);
|
||||
return {
|
||||
currentChannelId,
|
||||
currentThreadTs: threadId != null ? String(threadId) : undefined,
|
||||
currentThreadTs: normalizeSlackThreadTsCandidate(threadId),
|
||||
replyToMode: effectiveReplyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user