diff --git a/CHANGELOG.md b/CHANGELOG.md index 007e484cdad..8c07cbf31f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 3470fe87c97..b3cb20ae311 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -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. diff --git a/extensions/slack/src/monitor/events/message-subtype-handlers.test.ts b/extensions/slack/src/monitor/events/message-subtype-handlers.test.ts index 35923266b40..376c3bde6e8 100644 --- a/extensions/slack/src/monitor/events/message-subtype-handlers.test.ts +++ b/extensions/slack/src/monitor/events/message-subtype-handlers.test.ts @@ -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", () => { diff --git a/extensions/slack/src/monitor/events/message-subtype-handlers.ts b/extensions/slack/src/monitor/events/message-subtype-handlers.ts index 524baf0cb67..c3840894e20 100644 --- a/extensions/slack/src/monitor/events/message-subtype-handlers.ts +++ b/extensions/slack/src/monitor/events/message-subtype-handlers.ts @@ -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 = { 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]; diff --git a/extensions/slack/src/monitor/events/messages.test.ts b/extensions/slack/src/monitor/events/messages.test.ts index 76e32253195..d44c2a97b56 100644 --- a/extensions/slack/src/monitor/events/messages.test.ts +++ b/extensions/slack/src/monitor/events/messages.test.ts @@ -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", diff --git a/extensions/slack/src/monitor/message-handler.test.ts b/extensions/slack/src/monitor/message-handler.test.ts index f072d5136e2..253ba2ece95 100644 --- a/extensions/slack/src/monitor/message-handler.test.ts +++ b/extensions/slack/src/monitor/message-handler.test.ts @@ -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(), diff --git a/extensions/slack/src/monitor/message-handler.ts b/extensions/slack/src/monitor/message-handler.ts index f1ddb4520db..41e23359b35 100644 --- a/extensions/slack/src/monitor/message-handler.ts +++ b/extensions/slack/src/monitor/message-handler.ts @@ -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; }