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.
|
- 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.
|
- 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)”).
|
- 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)
|
- 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
|
## 2026.1.11
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ const DiscordGuildChannelSchema = z.object({
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
systemPrompt: z.string().optional(),
|
systemPrompt: z.string().optional(),
|
||||||
|
autoThread: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const DiscordGuildSchema = z.object({
|
const DiscordGuildSchema = z.object({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
resolveDiscordChannelConfig,
|
resolveDiscordChannelConfig,
|
||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
resolveDiscordReplyTarget,
|
resolveDiscordReplyTarget,
|
||||||
|
resolveDiscordShouldRequireMention,
|
||||||
resolveGroupDmAllow,
|
resolveGroupDmAllow,
|
||||||
shouldEmitDiscordReactionNotification,
|
shouldEmitDiscordReactionNotification,
|
||||||
} from "./monitor.js";
|
} from "./monitor.js";
|
||||||
@@ -103,6 +104,7 @@ describe("discord guild/channel resolution", () => {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
users: ["123"],
|
users: ["123"],
|
||||||
systemPrompt: "Use short answers.",
|
systemPrompt: "Use short answers.",
|
||||||
|
autoThread: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -127,6 +129,7 @@ describe("discord guild/channel resolution", () => {
|
|||||||
expect(help?.enabled).toBe(false);
|
expect(help?.enabled).toBe(false);
|
||||||
expect(help?.users).toEqual(["123"]);
|
expect(help?.users).toEqual(["123"]);
|
||||||
expect(help?.systemPrompt).toBe("Use short answers.");
|
expect(help?.systemPrompt).toBe("Use short answers.");
|
||||||
|
expect(help?.autoThread).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("denies channel when config present but no match", () => {
|
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", () => {
|
describe("discord groupPolicy gating", () => {
|
||||||
it("allows when policy is open", () => {
|
it("allows when policy is open", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ export type DiscordGuildEntryResolved = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
users?: Array<string | number>;
|
users?: Array<string | number>;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
|
autoThread?: boolean;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
@@ -258,6 +259,7 @@ export type DiscordChannelConfigResolved = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
users?: Array<string | number>;
|
users?: Array<string | number>;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
|
autoThread?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordMessageEvent = Parameters<
|
export type DiscordMessageEvent = Parameters<
|
||||||
@@ -905,8 +907,12 @@ export function createDiscordMessageHandler(params: {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const shouldRequireMention =
|
const shouldRequireMention = resolveDiscordShouldRequireMention({
|
||||||
channelConfig?.requireMention ?? guildInfo?.requireMention ?? true;
|
isGuildMessage,
|
||||||
|
isThread: Boolean(threadChannel),
|
||||||
|
channelConfig,
|
||||||
|
guildInfo,
|
||||||
|
});
|
||||||
const hasAnyMention = Boolean(
|
const hasAnyMention = Boolean(
|
||||||
!isDirectMessage &&
|
!isDirectMessage &&
|
||||||
(message.mentionedEveryone ||
|
(message.mentionedEveryone ||
|
||||||
@@ -1155,6 +1161,39 @@ export function createDiscordMessageHandler(params: {
|
|||||||
return;
|
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) {
|
if (isDirectMessage) {
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
const storePath = resolveStorePath(sessionCfg?.store, {
|
const storePath = resolveStorePath(sessionCfg?.store, {
|
||||||
@@ -1188,12 +1227,12 @@ export function createDiscordMessageHandler(params: {
|
|||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
await deliverDiscordReply({
|
await deliverDiscordReply({
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
target: replyTarget,
|
target: deliverTarget,
|
||||||
token,
|
token,
|
||||||
accountId,
|
accountId,
|
||||||
rest: client.rest,
|
rest: client.rest,
|
||||||
runtime,
|
runtime,
|
||||||
replyToMode,
|
replyToMode: deliverTarget !== replyTarget ? "off" : replyToMode,
|
||||||
textLimit,
|
textLimit,
|
||||||
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||||
});
|
});
|
||||||
@@ -2370,6 +2409,7 @@ export function resolveDiscordChannelConfig(params: {
|
|||||||
enabled: byId.enabled,
|
enabled: byId.enabled,
|
||||||
users: byId.users,
|
users: byId.users,
|
||||||
systemPrompt: byId.systemPrompt,
|
systemPrompt: byId.systemPrompt,
|
||||||
|
autoThread: byId.autoThread,
|
||||||
};
|
};
|
||||||
if (channelSlug && channels[channelSlug]) {
|
if (channelSlug && channels[channelSlug]) {
|
||||||
const entry = channels[channelSlug];
|
const entry = channels[channelSlug];
|
||||||
@@ -2380,6 +2420,7 @@ export function resolveDiscordChannelConfig(params: {
|
|||||||
enabled: entry.enabled,
|
enabled: entry.enabled,
|
||||||
users: entry.users,
|
users: entry.users,
|
||||||
systemPrompt: entry.systemPrompt,
|
systemPrompt: entry.systemPrompt,
|
||||||
|
autoThread: entry.autoThread,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (channelName && channels[channelName]) {
|
if (channelName && channels[channelName]) {
|
||||||
@@ -2391,11 +2432,23 @@ export function resolveDiscordChannelConfig(params: {
|
|||||||
enabled: entry.enabled,
|
enabled: entry.enabled,
|
||||||
users: entry.users,
|
users: entry.users,
|
||||||
systemPrompt: entry.systemPrompt,
|
systemPrompt: entry.systemPrompt,
|
||||||
|
autoThread: entry.autoThread,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { allowed: false };
|
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: {
|
export function isDiscordGroupAllowedByPolicy(params: {
|
||||||
groupPolicy: "open" | "disabled" | "allowlist";
|
groupPolicy: "open" | "disabled" | "allowlist";
|
||||||
channelAllowlistConfigured: boolean;
|
channelAllowlistConfigured: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user