From 120a30288cd0f96ae640ee9099ed0b1d18574e35 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 8 Jun 2026 11:37:02 +0000 Subject: [PATCH] feat(config): add broadcastReplies to mirror threaded replies to channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in `broadcastReplies` config flag (default false). When enabled, the bot's reply text in subscribed-channel threads is posted with Slack's `reply_broadcast: true`, so the reply also appears in the channel's main timeline — useful for highlighting important answers without making members open the thread. Only affects threaded replies (SlackTextBuffer output). DM and non-threaded posts are unchanged; `reply_broadcast` is omitted when there is no `thread_ts` since Slack ignores it on top-level messages. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + src/__tests__/config-schema.test.ts | 10 ++++++++++ src/__tests__/text-buffer.test.ts | 30 +++++++++++++++++++++++++++++ src/adapter.ts | 2 +- src/text-buffer.ts | 4 ++++ src/types.ts | 7 +++++++ 6 files changed, 53 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3cdefab..c0bb5d4 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Add to your `~/.openacp/config.json`: | `autoCreateSession` | Create a startup session on boot. Default: `true` | | `outputMode` | Default verbosity: `"low"`, `"medium"`, or `"high"`. Default: `"medium"` | | `subscribedChannels` | Optional. Channels to watch: `[{ "channelId": "C...", "trigger": "mention" \| "all" }]`. Invite the bot to each. Default: `[]` | +| `broadcastReplies` | Optional. Also mirror the bot's threaded replies into the channel's main timeline (Slack `reply_broadcast`), so important answers are visible without opening the thread. Default: `false` | ### Output Mode diff --git a/src/__tests__/config-schema.test.ts b/src/__tests__/config-schema.test.ts index 9af11be..535cbe9 100644 --- a/src/__tests__/config-schema.test.ts +++ b/src/__tests__/config-schema.test.ts @@ -11,4 +11,14 @@ describe("SlackChannelConfigSchema", () => { const cfg = SlackChannelConfigSchema.parse({ respondToDms: false }); expect(cfg.respondToDms).toBe(false); }); + + it("defaults broadcastReplies to false", () => { + const cfg = SlackChannelConfigSchema.parse({}); + expect(cfg.broadcastReplies).toBe(false); + }); + + it("allows broadcastReplies to be enabled", () => { + const cfg = SlackChannelConfigSchema.parse({ broadcastReplies: true }); + expect(cfg.broadcastReplies).toBe(true); + }); }); diff --git a/src/__tests__/text-buffer.test.ts b/src/__tests__/text-buffer.test.ts index 8e40797..75f5ee2 100644 --- a/src/__tests__/text-buffer.test.ts +++ b/src/__tests__/text-buffer.test.ts @@ -137,4 +137,34 @@ describe("SlackTextBuffer", () => { expect.objectContaining({ channel: "C_SUB", thread_ts: "169.1" }), ); }); + + it("does not set reply_broadcast by default", async () => { + const enqueue = vi.fn().mockResolvedValue({ ts: "1.1" }); + const queue = { enqueue } as any; + const buf = new SlackTextBuffer("C_SUB", "169.1", "sess-1", queue); + buf.append("hello world"); + await buf.flush(); + expect(enqueue.mock.calls[0][1]).not.toHaveProperty("reply_broadcast"); + }); + + it("sets reply_broadcast on threaded replies when broadcast is enabled", async () => { + const enqueue = vi.fn().mockResolvedValue({ ts: "1.1" }); + const queue = { enqueue } as any; + const buf = new SlackTextBuffer("C_SUB", "169.1", "sess-1", queue, undefined, true); + buf.append("important answer"); + await buf.flush(); + expect(enqueue).toHaveBeenCalledWith( + "chat.postMessage", + expect.objectContaining({ thread_ts: "169.1", reply_broadcast: true }), + ); + }); + + it("omits reply_broadcast when broadcast is enabled but there is no thread", async () => { + const enqueue = vi.fn().mockResolvedValue({ ts: "1.1" }); + const queue = { enqueue } as any; + const buf = new SlackTextBuffer("C_SUB", undefined, "sess-1", queue, undefined, true); + buf.append("dm answer"); + await buf.flush(); + expect(enqueue.mock.calls[0][1]).not.toHaveProperty("reply_broadcast"); + }); }); diff --git a/src/adapter.ts b/src/adapter.ts index e4c8a27..3664cae 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -1053,7 +1053,7 @@ export class SlackAdapter extends MessagingAdapter { private getTextBuffer(sessionId: string, channelId: string, threadTs?: string): SlackTextBuffer { let buf = this.textBuffers.get(sessionId); if (!buf) { - buf = new SlackTextBuffer(channelId, threadTs, sessionId, this.queue, this.log); + buf = new SlackTextBuffer(channelId, threadTs, sessionId, this.queue, this.log, this.slackConfig.broadcastReplies); this.textBuffers.set(sessionId, buf); } return buf; diff --git a/src/text-buffer.ts b/src/text-buffer.ts index 91c4049..5d8757d 100644 --- a/src/text-buffer.ts +++ b/src/text-buffer.ts @@ -23,6 +23,7 @@ export class SlackTextBuffer { private sessionId: string, private queue: ISlackSendQueue, logger?: Logger, + private broadcast: boolean = false, ) { this.log = logger ?? { info() {}, warn() {}, error() {}, debug() {} }; } @@ -57,6 +58,9 @@ export class SlackTextBuffer { const result = await this.queue.enqueue("chat.postMessage", { channel: this.channelId, ...(this.threadTs ? { thread_ts: this.threadTs } : {}), + // reply_broadcast only has meaning on a threaded reply; Slack + // ignores it on top-level posts. + ...(this.threadTs && this.broadcast ? { reply_broadcast: true } : {}), text: chunk, blocks: [{ type: "section", text: { type: "mrkdwn", text: chunk } }], }); diff --git a/src/types.ts b/src/types.ts index 3740d4f..13496c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,6 +48,13 @@ export const SlackChannelConfigSchema = z.object({ * gates who may start a DM session. Set false to ignore DMs entirely. */ respondToDms: z.boolean().default(true), + /** + * When true, the bot's reply text in subscribed-channel threads is also + * mirrored into the channel's main timeline via Slack's `reply_broadcast`, + * so important answers are visible without opening the thread. Only affects + * threaded replies; DM and non-threaded output are unchanged. Default false. + */ + broadcastReplies: z.boolean().default(false), }); export type SlackChannelConfig = z.infer;