Discord: per-channel autoThread (#800)

Co-authored-by: Shadow <shadow@clawd.bot>
This commit is contained in:
David Guttman
2026-01-12 10:33:07 -10:00
committed by GitHub
parent 6406ed869a
commit b73042500e
4 changed files with 110 additions and 4 deletions

View File

@@ -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

View File

@@ -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({

View File

@@ -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(

View File

@@ -247,6 +247,7 @@ export type DiscordGuildEntryResolved = {
enabled?: boolean;
users?: Array<string | number>;
systemPrompt?: string;
autoThread?: boolean;
}
>;
};
@@ -258,6 +259,7 @@ export type DiscordChannelConfigResolved = {
enabled?: boolean;
users?: Array<string | number>;
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;