From b73042500ec073c3021ba2ccf4714e4a33cbc4ae Mon Sep 17 00:00:00 2001 From: David Guttman Date: Mon, 12 Jan 2026 10:33:07 -1000 Subject: [PATCH] Discord: per-channel autoThread (#800) Co-authored-by: Shadow --- CHANGELOG.md | 1 + src/config/zod-schema.ts | 1 + src/discord/monitor.test.ts | 51 +++++++++++++++++++++++++++++++ src/discord/monitor.ts | 61 ++++++++++++++++++++++++++++++++++--- 4 files changed, 110 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8264bfe28..4c94cc440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - Google: apply patched pi-ai `google-gemini-cli` function call handling (strips ids) after upgrading to pi-ai 0.43.0. - Auto-reply: elevated/reasoning toggles now enqueue system events so the model sees the mode change immediately. - Tools: keep `image` available in sandbox and fail over when image models return empty output (fixes “(no text returned)”). +- Discord: add per-channel `autoThread` to auto-create threads for top-level messages. (#800) — thanks @davidguttman. - Onboarding: TUI defaults to `deliver: false` to avoid cross-provider auto-delivery leaks; onboarding spawns the TUI with explicit `deliver: false`. (#791 — thanks @roshanasingh4) ## 2026.1.11 diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 5cf9662bd..e5f7c3a2f 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -350,6 +350,7 @@ const DiscordGuildChannelSchema = z.object({ enabled: z.boolean().optional(), users: z.array(z.union([z.string(), z.number()])).optional(), systemPrompt: z.string().optional(), + autoThread: z.boolean().optional(), }); const DiscordGuildSchema = z.object({ diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index 59555ae33..f9b745afb 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -10,6 +10,7 @@ import { resolveDiscordChannelConfig, resolveDiscordGuildEntry, resolveDiscordReplyTarget, + resolveDiscordShouldRequireMention, resolveGroupDmAllow, shouldEmitDiscordReactionNotification, } from "./monitor.js"; @@ -103,6 +104,7 @@ describe("discord guild/channel resolution", () => { enabled: false, users: ["123"], systemPrompt: "Use short answers.", + autoThread: true, }, }, }; @@ -127,6 +129,7 @@ describe("discord guild/channel resolution", () => { expect(help?.enabled).toBe(false); expect(help?.users).toEqual(["123"]); expect(help?.systemPrompt).toBe("Use short answers."); + expect(help?.autoThread).toBe(true); }); it("denies channel when config present but no match", () => { @@ -145,6 +148,54 @@ describe("discord guild/channel resolution", () => { }); }); +describe("discord mention gating", () => { + it("requires mention by default", () => { + const guildInfo: DiscordGuildEntryResolved = { + requireMention: true, + channels: { + general: { allow: true }, + }, + }; + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: "1", + channelName: "General", + channelSlug: "general", + }); + expect( + resolveDiscordShouldRequireMention({ + isGuildMessage: true, + isThread: false, + channelConfig, + guildInfo, + }), + ).toBe(true); + }); + + it("does not require mention inside autoThread threads", () => { + const guildInfo: DiscordGuildEntryResolved = { + requireMention: true, + channels: { + general: { allow: true, autoThread: true }, + }, + }; + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: "1", + channelName: "General", + channelSlug: "general", + }); + expect( + resolveDiscordShouldRequireMention({ + isGuildMessage: true, + isThread: true, + channelConfig, + guildInfo, + }), + ).toBe(false); + }); +}); + describe("discord groupPolicy gating", () => { it("allows when policy is open", () => { expect( diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 458112675..1ed748ade 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -247,6 +247,7 @@ export type DiscordGuildEntryResolved = { enabled?: boolean; users?: Array; systemPrompt?: string; + autoThread?: boolean; } >; }; @@ -258,6 +259,7 @@ export type DiscordChannelConfigResolved = { enabled?: boolean; users?: Array; systemPrompt?: string; + autoThread?: boolean; }; export type DiscordMessageEvent = Parameters< @@ -905,8 +907,12 @@ export function createDiscordMessageHandler(params: { } : undefined; - const shouldRequireMention = - channelConfig?.requireMention ?? guildInfo?.requireMention ?? true; + const shouldRequireMention = resolveDiscordShouldRequireMention({ + isGuildMessage, + isThread: Boolean(threadChannel), + channelConfig, + guildInfo, + }); const hasAnyMention = Boolean( !isDirectMessage && (message.mentionedEveryone || @@ -1155,6 +1161,39 @@ export function createDiscordMessageHandler(params: { return; } + let deliverTarget = replyTarget; + if (isGuildMessage && channelConfig?.autoThread && !threadChannel) { + try { + const base = truncateUtf16Safe( + (baseText || combinedBody || "Thread").replace(/\s+/g, " ").trim(), + 80, + ); + const authorLabel = author.username ?? author.id; + const threadName = + truncateUtf16Safe(`${authorLabel}: ${base}`.trim(), 100) || + `Thread ${message.id}`; + + const created = (await client.rest.post( + `${Routes.channelMessage(message.channelId, message.id)}/threads`, + { + body: { + name: threadName, + auto_archive_duration: 60, + }, + }, + )) as { id?: string }; + + const createdId = created?.id ? String(created.id) : ""; + if (createdId) { + deliverTarget = `channel:${createdId}`; + } + } catch (err) { + logVerbose( + `discord: autoThread failed for ${message.channelId}/${message.id}: ${String(err)}`, + ); + } + } + if (isDirectMessage) { const sessionCfg = cfg.session; const storePath = resolveStorePath(sessionCfg?.store, { @@ -1188,12 +1227,12 @@ export function createDiscordMessageHandler(params: { deliver: async (payload) => { await deliverDiscordReply({ replies: [payload], - target: replyTarget, + target: deliverTarget, token, accountId, rest: client.rest, runtime, - replyToMode, + replyToMode: deliverTarget !== replyTarget ? "off" : replyToMode, textLimit, maxLinesPerMessage: discordConfig?.maxLinesPerMessage, }); @@ -2370,6 +2409,7 @@ export function resolveDiscordChannelConfig(params: { enabled: byId.enabled, users: byId.users, systemPrompt: byId.systemPrompt, + autoThread: byId.autoThread, }; if (channelSlug && channels[channelSlug]) { const entry = channels[channelSlug]; @@ -2380,6 +2420,7 @@ export function resolveDiscordChannelConfig(params: { enabled: entry.enabled, users: entry.users, systemPrompt: entry.systemPrompt, + autoThread: entry.autoThread, }; } if (channelName && channels[channelName]) { @@ -2391,11 +2432,23 @@ export function resolveDiscordChannelConfig(params: { enabled: entry.enabled, users: entry.users, systemPrompt: entry.systemPrompt, + autoThread: entry.autoThread, }; } return { allowed: false }; } +export function resolveDiscordShouldRequireMention(params: { + isGuildMessage: boolean; + isThread: boolean; + channelConfig?: DiscordChannelConfigResolved | null; + guildInfo?: DiscordGuildEntryResolved | null; +}): boolean { + if (!params.isGuildMessage) return false; + if (params.isThread && params.channelConfig?.autoThread) return false; + return params.channelConfig?.requireMention ?? params.guildInfo?.requireMention ?? true; +} + export function isDiscordGroupAllowedByPolicy(params: { groupPolicy: "open" | "disabled" | "allowlist"; channelAllowlistConfigured: boolean;