fix(slack): send proactive dm text directly

This commit is contained in:
Peter Steinberger
2026-05-02 04:56:55 +01:00
parent c89da2a606
commit 096b91cb3b
5 changed files with 57 additions and 11 deletions

View File

@@ -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=<user id>)` while keeping conversation resolution for uploads and threaded sends. Fixes #62042. Thanks @MarkMolina.
- Slack/mentions: resolve `<!subteam^...>` 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.

View File

@@ -714,7 +714,7 @@ Notes:
- `user:<id>` for DMs
- `channel:<id>` 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.
</Accordion>
</AccordionGroup>

View File

@@ -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", "", {

View File

@@ -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<SlackSendResult> {
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");

View File

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