fix(slack): accept assistant dm message edits

This commit is contained in:
Peter Steinberger
2026-04-24 23:59:57 +01:00
parent 3a6d50deb3
commit 893a18ff5c
7 changed files with 227 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<string>, value: unknown, botUserId: string): void {
const id = asString(value);
if (!id || id === botUserId || !isSlackUserId(id)) {
return;
}
candidates.add(id);
}
function collectMetadataUserCandidates(
candidates: Set<string>,
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<string>, 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<string>();
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);

View File

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

View File

@@ -144,6 +144,7 @@ export function createSlackBoltApp(params: {
token: params.botToken,
receiver,
clientOptions: params.clientOptions,
ignoreSelf: false,
});
return { app, receiver };
}

View File

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