From 096b91cb3b5a9a58c85e555ad0a20992f6bc883b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 04:56:55 +0100 Subject: [PATCH] fix(slack): send proactive dm text directly --- CHANGELOG.md | 1 + docs/channels/slack.md | 2 +- extensions/slack/src/send.blocks.test.ts | 21 ++++++++++++++ extensions/slack/src/send.ts | 35 +++++++++++++++++++----- extensions/slack/src/send.upload.test.ts | 9 ++++-- 5 files changed, 57 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a04b1c364e7..1532c55a206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai - Slack/DMs: honor `dmHistoryLimit` for fresh 1:1 Slack DM sessions by backfilling recent conversation history before the current reply. Fixes #64427. Thanks @brantley-creator. - Slack/DMs: keep top-level direct messages on the stable DM session even when `replyToMode` targets Slack thread replies, preserving context across DM turns. Fixes #58832. Thanks @daye-jjeong. - Slack/delivery: preserve Slack Web API missing-scope details in outbound delivery errors, so queued retry state identifies the OAuth scope to add. Fixes #62391. Thanks @alexey-pelykh. +- Slack/DMs: send text/block-only proactive DMs directly with `chat.postMessage(channel=)` while keeping conversation resolution for uploads and threaded sends. Fixes #62042. Thanks @MarkMolina. - Slack/mentions: resolve `` user-group mentions through Slack `usergroups.users.list` and treat them as explicit mentions only when the bot user is a member, so mention-gated agent channels wake for real user-group mentions without config-only allowlists. Fixes #73827. Thanks @CG-Intelligence-Agent-Jack. - Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars. - Web search: point missing-key errors to `web_fetch` for known URLs and the browser tool for interactive pages. Thanks @zhaoyang97. diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 0949fb95816..9ff573f4c64 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -714,7 +714,7 @@ Notes: - `user:` for DMs - `channel:` for channels - Slack DMs are opened via Slack conversation APIs when sending to user targets. + Text/block-only Slack DMs can post directly to user IDs; file uploads and threaded sends open the DM via Slack conversation APIs first because those paths require a concrete conversation ID. diff --git a/extensions/slack/src/send.blocks.test.ts b/extensions/slack/src/send.blocks.test.ts index 8e061d719c8..6af1ae07517 100644 --- a/extensions/slack/src/send.blocks.test.ts +++ b/extensions/slack/src/send.blocks.test.ts @@ -115,6 +115,27 @@ describe("sendMessageSlack blocks", () => { expect(result).toEqual({ messageId: "171234.567", channelId: "C123" }); }); + it("posts user-target block messages directly without conversations.open", async () => { + const client = createSlackSendTestClient(); + client.conversations.open.mockRejectedValueOnce(new Error("missing_scope")); + + const result = await sendMessageSlack("user:U123", "", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + blocks: [{ type: "divider" }], + }); + + expect(client.conversations.open).not.toHaveBeenCalled(); + expect(client.chat.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "U123", + text: "Shared a Block Kit message", + }), + ); + expect(result).toEqual({ messageId: "171234.567", channelId: "U123" }); + }); + it("derives fallback text from image blocks", async () => { const client = createSlackSendTestClient(); await sendMessageSlack("channel:C123", "", { diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index bec06cb5e06..aa7a58ddcfb 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -300,6 +300,21 @@ function setSlackDmChannelCache(key: string, channelId: string): void { slackDmChannelCache.set(key, channelId); } +function isSlackUserRecipient(recipient: SlackRecipient): boolean { + return recipient.kind === "user" || /^U[A-Z0-9]+$/i.test(recipient.id); +} + +function resolveDirectUserPostChannelId(params: { + recipient: SlackRecipient; + hasMedia: boolean; + threadTs?: string; +}): string | undefined { + if (!isSlackUserRecipient(params.recipient) || params.hasMedia || params.threadTs) { + return undefined; + } + return params.recipient.id; +} + async function resolveChannelId( client: WebClient, recipient: SlackRecipient, @@ -309,10 +324,9 @@ async function resolveChannelId( // target string had no explicit prefix (parseSlackTarget defaults bare IDs // to "channel"). chat.postMessage tolerates user IDs directly, but // files.uploadV2 → completeUploadExternal validates channel_id against - // ^[CGDZ][A-Z0-9]{8,}$ and rejects U-prefixed IDs. Always resolve user - // IDs via conversations.open to obtain the DM channel ID. - const isUserId = recipient.kind === "user" || /^U[A-Z0-9]+$/i.test(recipient.id); - if (!isUserId) { + // ^[CGDZ][A-Z0-9]{8,}$ and rejects U-prefixed IDs. Resolve user IDs via + // conversations.open only for paths that require the concrete DM channel ID. + if (!isSlackUserRecipient(recipient)) { return { channelId: recipient.id }; } const cacheKey = createSlackDmCacheKey({ @@ -484,10 +498,17 @@ async function sendMessageSlackQueuedInner(params: { }): Promise { const { opts, cfg, account, token, recipient, blocks, trimmedMessage } = params; const client = opts.client ?? getSlackWriteClient(token); - const { channelId } = await resolveChannelId(client, recipient, { - accountId: account.accountId, - token, + const directUserPostChannelId = resolveDirectUserPostChannelId({ + recipient, + hasMedia: Boolean(opts.mediaUrl), + ...(opts.threadTs ? { threadTs: opts.threadTs } : {}), }); + const { channelId } = directUserPostChannelId + ? { channelId: directUserPostChannelId } + : await resolveChannelId(client, recipient, { + accountId: account.accountId, + token, + }); if (blocks) { if (opts.mediaUrl) { throw new Error("Slack send does not support blocks with mediaUrl"); diff --git a/extensions/slack/src/send.upload.test.ts b/extensions/slack/src/send.upload.test.ts index 2bbde8ef2ab..db2502e1056 100644 --- a/extensions/slack/src/send.upload.test.ts +++ b/extensions/slack/src/send.upload.test.ts @@ -145,8 +145,9 @@ describe("sendMessageSlack file upload with user IDs", () => { ); }); - it("caches DM channel resolution per account", async () => { + it("posts text-only user-target DMs directly without conversations.open", async () => { const client = createUploadTestClient(); + client.conversations.open.mockRejectedValueOnce(new Error("missing_scope")); await sendMessageSlack("user:UABC123", "first", { token: "xoxb-test", @@ -159,12 +160,12 @@ describe("sendMessageSlack file upload with user IDs", () => { client, }); - expect(client.conversations.open).toHaveBeenCalledTimes(1); + expect(client.conversations.open).not.toHaveBeenCalled(); expect(client.chat.postMessage).toHaveBeenCalledTimes(2); expect(client.chat.postMessage).toHaveBeenNthCalledWith( 2, expect.objectContaining({ - channel: "D99RESOLVED", + channel: "UABC123", text: "second", }), ); @@ -215,11 +216,13 @@ describe("sendMessageSlack file upload with user IDs", () => { token: "xoxb-test-a", cfg: SLACK_TEST_CFG, client, + mediaUrl: "/tmp/first.png", }); await sendMessageSlack("user:UABC123", "second", { token: "xoxb-test-b", cfg: SLACK_TEST_CFG, client, + mediaUrl: "/tmp/second.png", }); expect(client.conversations.open).toHaveBeenCalledTimes(2);