mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
fix(slack): accept assistant dm message edits
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -144,6 +144,7 @@ export function createSlackBoltApp(params: {
|
||||
token: params.botToken,
|
||||
receiver,
|
||||
clientOptions: params.clientOptions,
|
||||
ignoreSelf: false,
|
||||
});
|
||||
return { app, receiver };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user