From 893a18ff5cdc2be9a2f1bfe75880fa2ae11f40cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 23:59:57 +0100 Subject: [PATCH] fix(slack): accept assistant dm message edits --- CHANGELOG.md | 1 + docs/channels/slack.md | 3 + .../slack/src/monitor/events/messages.test.ts | 60 +++++++ .../slack/src/monitor/events/messages.ts | 158 ++++++++++++++++++ .../events/system-event-test-harness.ts | 2 + .../slack/src/monitor/provider-support.ts | 1 + .../src/monitor/provider.interop.test.ts | 2 + 7 files changed, 227 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 397ab63f5e4..a6ee280d5d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Discord/subagents: pass runtime config into thread-bound native subagent binding and require it at the helper boundary so Discord channel resolution keeps account-aware config. Fixes #71054. (#70945) Thanks @jai. +- Slack/Assistant: accept Slack Assistant DM `message_changed` events when their metadata identifies the human sender, while continuing to drop self-authored bot edits. Fixes #55445. Thanks @AlfredPros. - Agents/failover: stop body-less HTTP 400/422 proxy failures from defaulting to `"format"` classification, so embedded retries surface the opaque provider failure instead of falling into a compaction loop. Fixes #66462. (#67024) Thanks @altaywtf and @HongzhuLiu. - Plugins/loader: use cached discovery-mode snapshot loads for read-only plugin capability lookups, keep snapshot caches isolated from active Gateway registries, and make same-plugin channel/HTTP route re-registration idempotent so repeated snapshot or hot-reload paths no longer rerun full plugin side effects or accumulate duplicate surfaces. Fixes #51781, #52031, #54181, and #57514. Thanks @livingghost, @okuyam2y, @ShionEria, and @bbshih. ## 2026.4.24 diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 35ec94e0e98..3470fe87c97 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -826,6 +826,9 @@ openclaw doctor - `channels.slack.dm.enabled` - `channels.slack.dmPolicy` (or legacy `channels.slack.dm.policy`) - pairing approvals / allowlist entries + - Slack Assistant DM events: verbose logs mentioning `drop message_changed` + usually mean Slack sent an edited Assistant-thread event without a + recoverable human sender in message metadata ```bash openclaw pairing list slack diff --git a/extensions/slack/src/monitor/events/messages.test.ts b/extensions/slack/src/monitor/events/messages.test.ts index d6cb37eaafe..76e32253195 100644 --- a/extensions/slack/src/monitor/events/messages.test.ts +++ b/extensions/slack/src/monitor/events/messages.test.ts @@ -72,6 +72,26 @@ function makeChangedEvent(overrides?: { channel?: string; user?: string }) { }; } +function makeAssistantChangedEvent(overrides?: { user?: string }) { + const user = overrides?.user ?? "UREAL123"; + return { + type: "message", + subtype: "message_changed", + channel: "D1", + channel_type: "im", + user: "U_BOT", + message: { + ts: "123.456", + thread_ts: "123.000", + user: "U_BOT", + text: "assistant wrapped user text", + metadata: { event_payload: { user } }, + }, + previous_message: { ts: "123.456", user: "U_BOT" }, + event_ts: "123.789", + }; +} + function makeDeletedEvent(overrides?: { channel?: string; user?: string }) { return { type: "message", @@ -204,6 +224,46 @@ describe("registerSlackMessageEvents", () => { expect(messageQueueMock).not.toHaveBeenCalled(); }); + it("rehydrates assistant DM message_changed events with a metadata user as inbound messages", async () => { + const { handleSlackMessage } = await invokeRegisteredHandler({ + eventName: "message", + overrides: { dmPolicy: "open" }, + event: makeAssistantChangedEvent(), + }); + + expect(handleSlackMessage).toHaveBeenCalledTimes(1); + expect(handleSlackMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "D1", + channel_type: "im", + user: "UREAL123", + text: "assistant wrapped user text", + ts: "123.456", + thread_ts: "123.000", + }), + { source: "message" }, + ); + expect(messageQueueMock).not.toHaveBeenCalled(); + }); + + it("drops self-authored message_changed events without assistant sender metadata", async () => { + const { handleSlackMessage } = await invokeRegisteredHandler({ + eventName: "message", + overrides: { dmPolicy: "open" }, + event: { + ...makeAssistantChangedEvent(), + message: { + ts: "123.456", + user: "U_BOT", + text: "preview edit", + }, + }, + }); + + expect(handleSlackMessage).not.toHaveBeenCalled(); + expect(messageQueueMock).not.toHaveBeenCalled(); + }); + it("handles channel and group messages via the unified message handler", async () => { const { handler, handleSlackMessage } = createHandlers("message", { dmPolicy: "open", diff --git a/extensions/slack/src/monitor/events/messages.ts b/extensions/slack/src/monitor/events/messages.ts index 298bd858c40..4a664844c06 100644 --- a/extensions/slack/src/monitor/events/messages.ts +++ b/extensions/slack/src/monitor/events/messages.ts @@ -6,9 +6,145 @@ import type { SlackAppMentionEvent, SlackMessageEvent } from "../../types.js"; import { normalizeSlackChannelType } from "../channel-type.js"; import type { SlackMonitorContext } from "../context.js"; import type { SlackMessageHandler } from "../message-handler.js"; +import type { SlackMessageChangedEvent } from "../types.js"; import { resolveSlackMessageSubtypeHandler } from "./message-subtype-handlers.js"; import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js"; +type SlackAssistantMessageRecord = { + bot_id?: unknown; + user?: unknown; + text?: unknown; + ts?: unknown; + thread_ts?: unknown; + files?: unknown; + attachments?: unknown; + metadata?: unknown; + blocks?: unknown; +}; + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function isSlackUserId(value: string): boolean { + return /^[UW][A-Z0-9]+$/.test(value); +} + +function addUserCandidate(candidates: Set, value: unknown, botUserId: string): void { + const id = asString(value); + if (!id || id === botUserId || !isSlackUserId(id)) { + return; + } + candidates.add(id); +} + +function collectMetadataUserCandidates( + candidates: Set, + value: unknown, + botUserId: string, +): void { + const metadata = asRecord(value); + const payload = asRecord(metadata?.event_payload); + if (!payload) { + return; + } + for (const key of ["user", "user_id", "actor_user_id", "author_user_id", "slack_user_id"]) { + addUserCandidate(candidates, payload[key], botUserId); + } +} + +function collectBlockUserIds(candidates: Set, value: unknown, botUserId: string): void { + if (Array.isArray(value)) { + for (const entry of value) { + collectBlockUserIds(candidates, entry, botUserId); + } + return; + } + const record = asRecord(value); + if (!record) { + return; + } + addUserCandidate(candidates, record.user_id, botUserId); + for (const key of ["elements", "accessory", "fields"]) { + collectBlockUserIds(candidates, record[key], botUserId); + } +} + +function resolveAssistantMessageChangedSender(params: { + event: SlackMessageChangedEvent; + message?: SlackAssistantMessageRecord; + botUserId: string; +}): string | undefined { + const candidates = new Set(); + collectMetadataUserCandidates(candidates, params.message?.metadata, params.botUserId); + collectBlockUserIds(candidates, params.message?.blocks, params.botUserId); + return candidates.size === 1 ? [...candidates][0] : undefined; +} + +function isSelfAttributedMessageChange(params: { + event: SlackMessageChangedEvent; + message?: SlackAssistantMessageRecord; + ctx: SlackMonitorContext; +}): boolean { + const topUser = asString((params.event as SlackMessageChangedEvent & { user?: unknown }).user); + const messageUser = asString(params.message?.user); + const messageBotId = asString(params.message?.bot_id); + return ( + (Boolean(params.ctx.botUserId) && + (topUser === params.ctx.botUserId || messageUser === params.ctx.botUserId)) || + (Boolean(params.ctx.botId) && messageBotId === params.ctx.botId) + ); +} + +function resolveAssistantMessageChangedInbound(params: { + event: SlackMessageEvent; + ctx: SlackMonitorContext; +}): SlackMessageEvent | undefined { + if (params.event.subtype !== "message_changed") { + return undefined; + } + const changed = params.event as SlackMessageChangedEvent; + const message = asRecord(changed.message) as SlackAssistantMessageRecord | undefined; + if (!message || !isSelfAttributedMessageChange({ event: changed, message, ctx: params.ctx })) { + return undefined; + } + const channelType = normalizeSlackChannelType( + asString((changed as SlackMessageChangedEvent & { channel_type?: unknown }).channel_type), + changed.channel, + ); + if (channelType !== "im") { + return undefined; + } + const senderId = resolveAssistantMessageChangedSender({ + event: changed, + message, + botUserId: params.ctx.botUserId, + }); + if (!senderId) { + return undefined; + } + return { + type: "message", + channel: changed.channel ?? params.event.channel, + channel_type: "im", + user: senderId, + text: asString(message.text), + ts: asString(message.ts) ?? asString(changed.event_ts), + thread_ts: asString(message.thread_ts), + event_ts: changed.event_ts, + files: Array.isArray(message.files) ? (message.files as SlackMessageEvent["files"]) : undefined, + attachments: Array.isArray(message.attachments) + ? (message.attachments as SlackMessageEvent["attachments"]) + : undefined, + }; +} + export function registerSlackMessageEvents(params: { ctx: SlackMonitorContext; handleSlackMessage: SlackMessageHandler; @@ -22,6 +158,28 @@ export function registerSlackMessageEvents(params: { } const message = event as SlackMessageEvent; + const assistantChangedInbound = resolveAssistantMessageChangedInbound({ + event: message, + ctx, + }); + if (assistantChangedInbound) { + await handleSlackMessage(assistantChangedInbound, { source: "message" }); + return; + } + + if ( + message.subtype === "message_changed" && + isSelfAttributedMessageChange({ + event: message as SlackMessageChangedEvent, + message: asRecord((message as SlackMessageChangedEvent).message) as + | SlackAssistantMessageRecord + | undefined, + ctx, + }) + ) { + return; + } + const subtypeHandler = resolveSlackMessageSubtypeHandler(message); if (subtypeHandler) { const channelId = subtypeHandler.resolveChannelId(message); diff --git a/extensions/slack/src/monitor/events/system-event-test-harness.ts b/extensions/slack/src/monitor/events/system-event-test-harness.ts index 432cdb4d53d..18ed10a74a4 100644 --- a/extensions/slack/src/monitor/events/system-event-test-harness.ts +++ b/extensions/slack/src/monitor/events/system-event-test-harness.ts @@ -23,6 +23,8 @@ export function createSlackSystemEventTestHarness(overrides?: SlackSystemEventTe const ctx = { app, runtime: { error: () => {} }, + botUserId: "U_BOT", + botId: "B_BOT", dmEnabled: true, dmPolicy: overrides?.dmPolicy ?? "open", defaultRequireMention: true, diff --git a/extensions/slack/src/monitor/provider-support.ts b/extensions/slack/src/monitor/provider-support.ts index e3b4e9f8143..bdfa8393012 100644 --- a/extensions/slack/src/monitor/provider-support.ts +++ b/extensions/slack/src/monitor/provider-support.ts @@ -144,6 +144,7 @@ export function createSlackBoltApp(params: { token: params.botToken, receiver, clientOptions: params.clientOptions, + ignoreSelf: false, }); return { app, receiver }; } diff --git a/extensions/slack/src/monitor/provider.interop.test.ts b/extensions/slack/src/monitor/provider.interop.test.ts index 7d029883445..5e030dd4b01 100644 --- a/extensions/slack/src/monitor/provider.interop.test.ts +++ b/extensions/slack/src/monitor/provider.interop.test.ts @@ -157,6 +157,7 @@ describe("createSlackBoltApp", () => { token: "xoxb-test", receiver, clientOptions, + ignoreSelf: false, }); }); @@ -185,6 +186,7 @@ describe("createSlackBoltApp", () => { token: "xoxb-test", receiver, clientOptions, + ignoreSelf: false, }); }); });