Discord: per-channel autoThread (#800)
Co-authored-by: Shadow <shadow@clawd.bot>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user