fix(slack): process thread broadcasts as messages

This commit is contained in:
Peter Steinberger
2026-04-25 00:26:25 +01:00
parent dea05aae6b
commit 2b5c719a62
7 changed files with 70 additions and 52 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- 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.
- Slack/native streaming: suppress reasoning-only payloads before `chat.startStream`/`appendStream`, so Claude extended-thinking blocks no longer appear as visible Slack messages. Fixes #59687. Thanks @vision-ifc.
- Slack/block replies: keep multi-part block deliveries in the first Slack reply thread when `replyToMode` is `first`, matching text reply threading instead of leaking later blocks into the channel. Fixes #49341. Thanks @pholmstr and @xiwuqi.
- Slack/thread broadcasts: process `thread_broadcast` events as user messages so replies sent with "Also send to channel" reach the agent instead of becoming metadata-only system events. Fixes #56605 and #4351. Thanks @clawSean and @jlowin.
- 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.
- Plugins/loader: reuse the compatible active Gateway registry for broad runtime plugin ensure calls after a gateway-bindable boot load, so non-bundled plugins no longer re-run `register()` during the same boot path. Fixes #69250. Thanks @markthebest12.

View File

@@ -773,7 +773,8 @@ Same-chat `/approve` also works in Slack channels and DMs that already support c
## Events and operational behavior
- Message edits/deletes/thread broadcasts are mapped into system events.
- Message edits/deletes are mapped into system events.
- Thread broadcasts ("Also send to channel" thread replies) are processed as normal user messages.
- Reaction add/remove events are mapped into system events.
- Member join/leave, channel created/renamed, and pin add/remove events are mapped into system events.
- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled.

View File

@@ -41,7 +41,7 @@ describe("resolveSlackMessageSubtypeHandler", () => {
expect(handler?.describe("general")).toContain("deleted");
});
it("resolves thread_broadcast metadata and identifiers", () => {
it("does not treat thread_broadcast as a metadata-only system event", () => {
const event = {
type: "message",
subtype: "thread_broadcast",
@@ -51,13 +51,7 @@ describe("resolveSlackMessageSubtypeHandler", () => {
user: "U1",
} as unknown as SlackMessageEvent;
const handler = resolveSlackMessageSubtypeHandler(event);
expect(handler?.eventKind).toBe("thread_broadcast");
expect(handler?.resolveSenderId(event)).toBe("U1");
expect(handler?.resolveChannelId(event)).toBe("C1");
expect(handler?.resolveChannelType(event)).toBeUndefined();
expect(handler?.contextKey(event)).toBe("slack:thread:broadcast:C1:123.456");
expect(handler?.describe("general")).toContain("broadcast");
expect(resolveSlackMessageSubtypeHandler(event)).toBeUndefined();
});
it("returns undefined for regular messages", () => {

View File

@@ -1,11 +1,7 @@
import type { SlackMessageEvent } from "../../types.js";
import type {
SlackMessageChangedEvent,
SlackMessageDeletedEvent,
SlackThreadBroadcastEvent,
} from "../types.js";
import type { SlackMessageChangedEvent, SlackMessageDeletedEvent } from "../types.js";
type SupportedSubtype = "message_changed" | "message_deleted" | "thread_broadcast";
type SupportedSubtype = "message_changed" | "message_deleted";
export type SlackMessageSubtypeHandler = {
subtype: SupportedSubtype;
@@ -59,39 +55,16 @@ const deletedHandler: SlackMessageSubtypeHandler = {
resolveChannelType: () => undefined,
};
const threadBroadcastHandler: SlackMessageSubtypeHandler = {
subtype: "thread_broadcast",
eventKind: "thread_broadcast",
describe: (channelLabel) => `Slack thread reply broadcast in ${channelLabel}.`,
contextKey: (event) => {
const thread = event as SlackThreadBroadcastEvent;
const channelId = thread.channel ?? "unknown";
const messageId = thread.message?.ts ?? thread.event_ts ?? "unknown";
return `slack:thread:broadcast:${channelId}:${messageId}`;
},
resolveSenderId: (event) => {
const thread = event as SlackThreadBroadcastEvent;
return thread.user ?? thread.message?.user ?? thread.message?.bot_id;
},
resolveChannelId: (event) => (event as SlackThreadBroadcastEvent).channel,
resolveChannelType: () => undefined,
};
const SUBTYPE_HANDLER_REGISTRY: Record<SupportedSubtype, SlackMessageSubtypeHandler> = {
message_changed: changedHandler,
message_deleted: deletedHandler,
thread_broadcast: threadBroadcastHandler,
};
export function resolveSlackMessageSubtypeHandler(
event: SlackMessageEvent,
): SlackMessageSubtypeHandler | undefined {
const subtype = event.subtype;
if (
subtype !== "message_changed" &&
subtype !== "message_deleted" &&
subtype !== "thread_broadcast"
) {
if (subtype !== "message_changed" && subtype !== "message_deleted") {
return undefined;
}
return SUBTYPE_HANDLER_REGISTRY[subtype];

View File

@@ -189,18 +189,6 @@ describe("registerSlackMessageEvents", () => {
},
calls: 0,
},
{
name: "blocks thread_broadcast system events without an authenticated sender",
input: {
overrides: { dmPolicy: "open" },
event: {
...makeThreadBroadcastEvent(),
user: undefined,
message: { ts: "123.456" },
},
},
calls: 0,
},
];
it.each(cases)("$name", async ({ input, calls }) => {
await runMessageCase(input);
@@ -224,6 +212,25 @@ describe("registerSlackMessageEvents", () => {
expect(messageQueueMock).not.toHaveBeenCalled();
});
it("passes thread_broadcast events to the message handler", async () => {
const { handleSlackMessage } = await invokeRegisteredHandler({
eventName: "message",
overrides: { dmPolicy: "open" },
event: makeThreadBroadcastEvent({ channel: "C1", user: "U1" }),
});
expect(handleSlackMessage).toHaveBeenCalledTimes(1);
expect(handleSlackMessage).toHaveBeenCalledWith(
expect.objectContaining({
subtype: "thread_broadcast",
channel: "C1",
user: "U1",
}),
{ source: "message" },
);
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",

View File

@@ -126,6 +126,47 @@ describe("createSlackMessageHandler", () => {
expect(enqueueMock).toHaveBeenCalledTimes(1);
});
it("accepts thread_broadcast messages from the message stream", async () => {
const { handler, trackEvent } = createHandlerWithTracker();
await handler(
{
type: "message",
subtype: "thread_broadcast",
channel: "C111",
user: "U111",
ts: "1709000000.000300",
text: "also send to channel",
thread_ts: "1709000000.000100",
} as never,
{ source: "message" },
);
expect(trackEvent).toHaveBeenCalledTimes(1);
expect(resolveThreadTsMock).toHaveBeenCalledTimes(1);
expect(enqueueMock).toHaveBeenCalledTimes(1);
});
it("drops message subtypes that do not carry user message text", async () => {
const { handler, trackEvent } = createHandlerWithTracker();
await handler(
{
type: "message",
subtype: "channel_join",
channel: "C111",
user: "U111",
ts: "1709000000.000400",
text: "<@U111> joined the channel",
} as never,
{ source: "message" },
);
expect(trackEvent).not.toHaveBeenCalled();
expect(resolveThreadTsMock).not.toHaveBeenCalled();
expect(enqueueMock).not.toHaveBeenCalled();
});
it("flushes pending top-level buffered keys before immediate non-debounce follow-ups", async () => {
const handler = createSlackMessageHandler({
ctx: createContext(),

View File

@@ -195,7 +195,8 @@ export function createSlackMessageHandler(params: {
opts.source === "message" &&
message.subtype &&
message.subtype !== "file_share" &&
message.subtype !== "bot_message"
message.subtype !== "bot_message" &&
message.subtype !== "thread_broadcast"
) {
return;
}