diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b6c1969c..51b0d8afb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott - Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc - Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro +- Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete - Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow - Commands: accept /models as an alias for /model. - Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index e32f4f2e2..f17ee63da 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -216,8 +216,7 @@ export async function maybeRepairGatewayServiceConfig( ) { audit.issues.push({ code: SERVICE_AUDIT_CODES.gatewayEntrypointMismatch, - message: - "Gateway service entrypoint does not match the current install.", + message: "Gateway service entrypoint does not match the current install.", detail: `${currentEntrypoint} -> ${expectedEntrypoint}`, level: "recommended", }); diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index 952bfda15..50b5600cb 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -193,6 +193,95 @@ describe("discord tool result dispatch", () => { expect(fetchChannel).toHaveBeenCalledTimes(1); }); + it("uses channel id allowlists for non-thread channels with categories", async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + let capturedCtx: { SessionKey?: string } | undefined; + dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => { + capturedCtx = ctx; + dispatcher.sendFinalReply({ text: "hi" }); + return { queuedFinal: true, counts: { final: 1 } }; + }); + + const cfg = { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: "/tmp/clawd", + }, + }, + session: { store: "/tmp/clawdbot-sessions.json" }, + discord: { + dm: { enabled: true, policy: "open" }, + guilds: { + "*": { + requireMention: false, + channels: { c1: { allow: true } }, + }, + }, + }, + routing: { allowFrom: [] }, + } as ReturnType; + + const handler = createDiscordMessageHandler({ + cfg, + discordConfig: cfg.discord, + accountId: "default", + token: "token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, + guildEntries: { + "*": { requireMention: false, channels: { c1: { allow: true } } }, + }, + }); + + const client = { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.GuildText, + name: "general", + parentId: "category-1", + }), + rest: { get: vi.fn() }, + } as unknown as Client; + + await handler( + { + message: { + id: "m-category", + content: "hello", + channelId: "c1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" }, + }, + author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" }, + member: { displayName: "Ada" }, + guild: { id: "g1", name: "Guild" }, + guild_id: "g1", + }, + client, + ); + + expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:c1"); + }); + it("replies with pairing code and sender id when dmPolicy is pairing", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 59ef8e910..c3339ef66 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -255,6 +255,58 @@ function isDiscordThreadType(type: ChannelType | undefined): boolean { ); } +type DiscordThreadParentInfo = { + id?: string; + name?: string; + type?: ChannelType; +}; + +function resolveDiscordThreadChannel(params: { + isGuildMessage: boolean; + message: DiscordMessageEvent["message"]; + channelInfo: DiscordChannelInfo | null; +}): DiscordThreadChannel | null { + if (!params.isGuildMessage) return null; + const { message, channelInfo } = params; + const channel = + "channel" in message + ? (message as { channel?: unknown }).channel + : undefined; + const isThreadChannel = + channel && + typeof channel === "object" && + "isThread" in channel && + typeof (channel as { isThread?: unknown }).isThread === "function" && + (channel as { isThread: () => boolean }).isThread(); + if (isThreadChannel) return channel as unknown as DiscordThreadChannel; + if (!isDiscordThreadType(channelInfo?.type)) return null; + return { + id: message.channelId, + name: channelInfo?.name ?? undefined, + parentId: channelInfo?.parentId ?? undefined, + parent: undefined, + }; +} + +async function resolveDiscordThreadParentInfo(params: { + client: Client; + threadChannel: DiscordThreadChannel; + channelInfo: DiscordChannelInfo | null; +}): Promise { + const { threadChannel, channelInfo, client } = params; + const parentId = + threadChannel.parentId ?? + threadChannel.parent?.id ?? + channelInfo?.parentId ?? + undefined; + if (!parentId) return {}; + let parentName = threadChannel.parent?.name; + const parentInfo = await resolveDiscordChannelInfo(client, parentId); + parentName = parentName ?? parentInfo?.name; + const parentType = parentInfo?.type; + return { id: parentId, name: parentName, type: parentType }; +} + export function resolveDiscordReplyTarget(opts: { replyToMode: ReplyToMode; replyToId?: string; @@ -690,37 +742,23 @@ export function createDiscordMessageHandler(params: { "name" in message.channel ? message.channel.name : undefined); - const isThreadChannel = - isGuildMessage && - message.channel && - "isThread" in message.channel && - message.channel.isThread(); - const isThreadByType = - isGuildMessage && isDiscordThreadType(channelInfo?.type); - const threadChannel: DiscordThreadChannel | null = isThreadChannel - ? (message.channel as DiscordThreadChannel) - : isThreadByType - ? { - id: message.channelId, - name: channelInfo?.name ?? undefined, - parentId: channelInfo?.parentId ?? undefined, - parent: undefined, - } - : null; - const threadParentId = - threadChannel?.parentId ?? - threadChannel?.parent?.id ?? - channelInfo?.parentId ?? - undefined; - let threadParentName = threadChannel?.parent?.name; + const threadChannel = resolveDiscordThreadChannel({ + isGuildMessage, + message, + channelInfo, + }); + let threadParentId: string | undefined; + let threadParentName: string | undefined; let threadParentType: ChannelType | undefined; - if (threadChannel && threadParentId) { - const parentInfo = await resolveDiscordChannelInfo( + if (threadChannel) { + const parentInfo = await resolveDiscordThreadParentInfo({ client, - threadParentId, - ); - threadParentName = threadParentName ?? parentInfo?.name; - threadParentType = parentInfo?.type; + threadChannel, + channelInfo, + }); + threadParentId = parentInfo.id; + threadParentName = parentInfo.name; + threadParentType = parentInfo.type; } const threadName = threadChannel?.name; const configChannelName = threadParentName ?? channelName;