fix(slack extension): preserve thread IDs for read + outbound delivery (#23836)

* Slack Extension: preserve thread IDs in reads and outbound sends

* Slack extension: fix threadTs typing and action test context

* Update CHANGELOG.md
This commit is contained in:
Vincent Koc
2026-02-22 14:34:32 -05:00
committed by GitHub
parent 078e1a7fc9
commit 9f7c1686b4
3 changed files with 114 additions and 4 deletions

View File

@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Slack/Extension: forward `message read` `threadId` to `readMessages` and use delivery-context `threadId` as outbound `thread_ts` fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan.
- Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake.
- Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting.
- Feishu/Commands: in group chats, command authorization now falls back to top-level `channels.feishu.allowFrom` when per-group `allowFrom` is not set, so `/command` no longer gets blocked by an unintended empty allowlist. (#23756)

View File

@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from "vitest";
const handleSlackActionMock = vi.fn();
vi.mock("./runtime.js", () => ({
getSlackRuntime: () => ({
channel: {
slack: {
handleSlackAction: handleSlackActionMock,
},
},
}),
}));
import { slackPlugin } from "./channel.js";
describe("slackPlugin actions", () => {
it("forwards read threadId to Slack action handler", async () => {
handleSlackActionMock.mockResolvedValueOnce({ messages: [], hasMore: false });
const handleAction = slackPlugin.actions?.handleAction;
expect(handleAction).toBeDefined();
await handleAction!({
action: "read",
channel: "slack",
accountId: "default",
cfg: {},
params: {
channelId: "C123",
threadId: "1712345678.123456",
},
});
expect(handleSlackActionMock).toHaveBeenCalledWith(
expect.objectContaining({
action: "readMessages",
channelId: "C123",
threadId: "1712345678.123456",
}),
{},
undefined,
);
});
});
describe("slackPlugin outbound", () => {
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
};
it("uses threadId as threadTs fallback for sendText", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-text" });
const sendText = slackPlugin.outbound?.sendText;
expect(sendText).toBeDefined();
const result = await sendText!({
cfg,
to: "C123",
text: "hello",
accountId: "default",
threadId: "1712345678.123456",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenCalledWith(
"C123",
"hello",
expect.objectContaining({
threadTs: "1712345678.123456",
}),
);
expect(result).toEqual({ channel: "slack", messageId: "m-text" });
});
it("prefers replyToId over threadId for sendMedia", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-media" });
const sendMedia = slackPlugin.outbound?.sendMedia;
expect(sendMedia).toBeDefined();
const result = await sendMedia!({
cfg,
to: "C999",
text: "caption",
mediaUrl: "https://example.com/image.png",
accountId: "default",
replyToId: "1712000000.000001",
threadId: "1712345678.123456",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenCalledWith(
"C999",
"caption",
expect.objectContaining({
mediaUrl: "https://example.com/image.png",
threadTs: "1712000000.000001",
}),
);
expect(result).toEqual({ channel: "slack", messageId: "m-media" });
});
});

View File

@@ -245,6 +245,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
await handleSlackMessageAction({
providerId: meta.id,
ctx,
includeReadThreadId: true,
invoke: async (action, cfg, toolContext) =>
await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext),
}),
@@ -324,28 +325,30 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => {
sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => {
const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId });
const token = getTokenForOperation(account, "write");
const botToken = account.botToken?.trim();
const tokenOverride = token && token !== botToken ? token : undefined;
const threadTsValue = replyToId ?? threadId;
const result = await send(to, text, {
threadTs: replyToId ?? undefined,
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});
return { channel: "slack", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => {
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId, cfg }) => {
const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId });
const token = getTokenForOperation(account, "write");
const botToken = account.botToken?.trim();
const tokenOverride = token && token !== botToken ? token : undefined;
const threadTsValue = replyToId ?? threadId;
const result = await send(to, text, {
mediaUrl,
threadTs: replyToId ?? undefined,
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});