From f97c6f8a049a25534441c9014cc4287f04e55277 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 20:41:14 +0100 Subject: [PATCH] fix(discord): harden partial thread channels --- CHANGELOG.md | 1 + .../discord/src/monitor/channel-access.ts | 6 +- .../discord/src/monitor/inbound-job.test.ts | 28 +++++++++ extensions/discord/src/monitor/inbound-job.ts | 21 ++++--- .../monitor/message-handler.preflight.test.ts | 62 +++++++++++++++++++ .../src/monitor/message-handler.preflight.ts | 6 +- .../src/monitor/threading.parent-info.test.ts | 45 ++++++++++++++ extensions/discord/src/monitor/threading.ts | 15 ++++- 8 files changed, 171 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ccb9cedd84..abc8b4cbdf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord: harden inbound thread metadata handling against partial Carbon channel getters, so non-command thread messages and queued jobs no longer crash when `name`, `parentId`, `parent`, or `ownerId` requires fetched raw data. - Discord: let `message` tool reactions resolve `user:` DM targets and preserve `channels.discord.guilds..channels..requireMention: false` during reply-stage activation fallback. Fixes #70165 and #69441. - Telegram/webhooks: lower the grammY webhook callback timeout to 5s so Telegram gets an early 200 response instead of retrying long-running updates as read timeouts. (#70146) Thanks @friday-james. - Telegram/polling: rebuild the polling HTTP transport after `getUpdates` 409 conflicts, so retries use a fresh TCP connection instead of looping on a Telegram-terminated keep-alive socket. (#69873) Thanks @hclsys. diff --git a/extensions/discord/src/monitor/channel-access.ts b/extensions/discord/src/monitor/channel-access.ts index 695741b764b..843cb3794de 100644 --- a/extensions/discord/src/monitor/channel-access.ts +++ b/extensions/discord/src/monitor/channel-access.ts @@ -53,8 +53,12 @@ export function resolveDiscordChannelParentIdSafe(channel: unknown): string | un return resolveDiscordChannelStringPropertySafe(channel, "parentId"); } +export function resolveDiscordChannelParentSafe(channel: unknown): unknown { + return readDiscordChannelPropertySafe(channel, "parent"); +} + export function resolveDiscordChannelInfoSafe(channel: unknown): DiscordChannelInfoSafe { - const parent = readDiscordChannelPropertySafe(channel, "parent"); + const parent = resolveDiscordChannelParentSafe(channel); return { name: resolveDiscordChannelNameSafe(channel), topic: resolveDiscordChannelTopicSafe(channel), diff --git a/extensions/discord/src/monitor/inbound-job.test.ts b/extensions/discord/src/monitor/inbound-job.test.ts index 8cf7983792a..c616245dba5 100644 --- a/extensions/discord/src/monitor/inbound-job.test.ts +++ b/extensions/discord/src/monitor/inbound-job.test.ts @@ -1,5 +1,6 @@ import { Message } from "@buape/carbon"; import { describe, expect, it } from "vitest"; +import { createPartialDiscordChannelWithThrowingGetters } from "../test-support/partial-channel.js"; import { buildDiscordInboundJob, materializeDiscordInboundJob, @@ -90,6 +91,33 @@ describe("buildDiscordInboundJob", () => { expect(() => JSON.stringify(job.payload)).not.toThrow(); }); + it("normalizes partial thread channels without reading throwing getters", async () => { + const threadChannel = createPartialDiscordChannelWithThrowingGetters( + { + id: "thread-1", + name: "codex", + parentId: "forum-1", + parent: { id: "forum-1", name: "Forum" }, + ownerId: "user-1", + }, + ["name", "parentId", "parent", "ownerId"], + ); + const ctx = await createBaseDiscordMessageContext({ + threadChannel, + }); + + const job = buildDiscordInboundJob(ctx); + + expect(job.payload.threadChannel).toEqual({ + id: "thread-1", + name: undefined, + parentId: undefined, + parent: undefined, + ownerId: undefined, + }); + expect(() => JSON.stringify(job.payload)).not.toThrow(); + }); + it("re-materializes the process context with an overridden abort signal", async () => { const ctx = await createBaseDiscordMessageContext(); const job = buildDiscordInboundJob(ctx, { replayKeys: ["default:ch-1:m-1"] }); diff --git a/extensions/discord/src/monitor/inbound-job.ts b/extensions/discord/src/monitor/inbound-job.ts index 177b2bdd99d..9c7210f3519 100644 --- a/extensions/discord/src/monitor/inbound-job.ts +++ b/extensions/discord/src/monitor/inbound-job.ts @@ -1,4 +1,9 @@ -import { resolveDiscordChannelNameSafe } from "./channel-access.js"; +import { + resolveDiscordChannelIdSafe, + resolveDiscordChannelInfoSafe, + resolveDiscordChannelNameSafe, + resolveDiscordChannelParentSafe, +} from "./channel-access.js"; import type { DiscordMessagePreflightContext } from "./message-handler.preflight.types.js"; type DiscordInboundJobRuntimeField = @@ -102,16 +107,18 @@ function normalizeDiscordThreadChannel( if (!threadChannel) { return null; } + const channelInfo = resolveDiscordChannelInfoSafe(threadChannel); + const parent = resolveDiscordChannelParentSafe(threadChannel); return { id: threadChannel.id, - name: threadChannel.name, - parentId: threadChannel.parentId, - parent: threadChannel.parent + name: channelInfo.name, + parentId: channelInfo.parentId, + parent: parent ? { - id: threadChannel.parent.id, - name: resolveDiscordChannelNameSafe(threadChannel.parent), + id: resolveDiscordChannelIdSafe(parent), + name: resolveDiscordChannelNameSafe(parent), } : undefined, - ownerId: threadChannel.ownerId, + ownerId: channelInfo.ownerId, }; } diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index 77f98f96130..e4ad1f70621 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -1,5 +1,6 @@ import { ChannelType } from "@buape/carbon"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createPartialDiscordChannelWithThrowingGetters } from "../test-support/partial-channel.js"; const transcribeFirstAudioMock = vi.hoisted(() => vi.fn()); const resolveDiscordDmCommandAccessMock = vi.hoisted(() => vi.fn()); @@ -757,6 +758,67 @@ describe("preflightDiscordMessage", () => { expect(result?.shouldRequireMention).toBe(false); }); + it("handles partial thread channel owner getters during mention preflight", async () => { + const threadId = "thread-partial-owner"; + const parentId = "parent-partial-owner"; + const message = createDiscordMessage({ + id: "m-thread-partial-owner", + channelId: threadId, + content: "thread hello", + author: { + id: "user-1", + bot: false, + username: "Peter", + }, + }); + Object.defineProperty(message, "channel", { + value: createPartialDiscordChannelWithThrowingGetters( + { + id: threadId, + isThread: () => true, + ownerId: "owner-1", + parentId, + parent: { id: parentId, name: "general" }, + }, + ["ownerId", "parentId", "parent"], + ), + configurable: true, + enumerable: true, + }); + + const result = await preflightDiscordMessage({ + ...createPreflightArgs({ + cfg: DEFAULT_PREFLIGHT_CFG, + discordConfig: {} as DiscordConfig, + data: createGuildEvent({ + channelId: threadId, + guildId: "guild-1", + author: message.author, + message, + includeGuildObject: false, + }), + client: createThreadClient({ + threadId, + parentId, + }), + }), + guildEntries: { + "guild-1": { + channels: { + [parentId]: { + enabled: true, + requireMention: false, + }, + }, + }, + }, + }); + + expect(result).not.toBeNull(); + expect(result?.threadParentId).toBe(parentId); + expect(result?.shouldRequireMention).toBe(false); + }); + it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => { const channelId = "channel-other-mention-1"; const guildId = "guild-other-mention-1"; diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index 86730022e85..bf6a6115b43 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -32,7 +32,7 @@ import { resolveDiscordShouldRequireMention, resolveGroupDmAllow, } from "./allow-list.js"; -import { resolveDiscordChannelNameSafe } from "./channel-access.js"; +import { resolveDiscordChannelInfoSafe, resolveDiscordChannelNameSafe } from "./channel-access.js"; import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js"; import { handleDiscordDmCommandDecision } from "./dm-command-decision.js"; import { @@ -849,7 +849,9 @@ export async function preflightDiscordMessage( } satisfies HistoryEntry) : undefined; - const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined; + const threadOwnerId = threadChannel + ? (resolveDiscordChannelInfoSafe(threadChannel).ownerId ?? channelInfo?.ownerId) + : undefined; const shouldRequireMentionByConfig = resolveDiscordShouldRequireMention({ isGuildMessage, isThread: Boolean(threadChannel), diff --git a/extensions/discord/src/monitor/threading.parent-info.test.ts b/extensions/discord/src/monitor/threading.parent-info.test.ts index 6d2d169002c..9cbd020f99e 100644 --- a/extensions/discord/src/monitor/threading.parent-info.test.ts +++ b/extensions/discord/src/monitor/threading.parent-info.test.ts @@ -1,5 +1,6 @@ import { ChannelType } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPartialDiscordChannelWithThrowingGetters } from "../test-support/partial-channel.js"; import { __resetDiscordChannelInfoCacheForTest } from "./message-utils.js"; import { resolveDiscordThreadParentInfo } from "./threading.js"; @@ -50,6 +51,50 @@ describe("resolveDiscordThreadParentInfo", () => { }); }); + it("falls back to fetched thread parentId when partial channel getters throw", async () => { + const fetchChannel = vi.fn(async (channelId: string) => { + if (channelId === "thread-1") { + return { + id: "thread-1", + type: ChannelType.PublicThread, + name: "thread-name", + parentId: "parent-1", + }; + } + if (channelId === "parent-1") { + return { + id: "parent-1", + type: ChannelType.GuildText, + name: "parent-name", + }; + } + return null; + }); + + const client = { fetchChannel } as unknown as import("@buape/carbon").Client; + const threadChannel = createPartialDiscordChannelWithThrowingGetters( + { + id: "thread-1", + parent: { id: "stale-parent", name: "stale-parent-name" }, + }, + ["parentId", "parent"], + ); + + const result = await resolveDiscordThreadParentInfo({ + client, + threadChannel, + channelInfo: null, + }); + + expect(fetchChannel).toHaveBeenCalledWith("thread-1"); + expect(fetchChannel).toHaveBeenCalledWith("parent-1"); + expect(result).toEqual({ + id: "parent-1", + name: "parent-name", + type: ChannelType.GuildText, + }); + }); + it("does not fetch thread info when parentId is already present", async () => { const fetchChannel = vi.fn(async (channelId: string) => { if (channelId === "parent-1") { diff --git a/extensions/discord/src/monitor/threading.ts b/extensions/discord/src/monitor/threading.ts index 2390734b9ae..162a4dfb35b 100644 --- a/extensions/discord/src/monitor/threading.ts +++ b/extensions/discord/src/monitor/threading.ts @@ -14,7 +14,12 @@ import { truncateUtf16Safe, } from "openclaw/plugin-sdk/text-runtime"; import type { DiscordChannelConfigResolved } from "./allow-list.js"; -import { resolveDiscordChannelNameSafe } from "./channel-access.js"; +import { + resolveDiscordChannelIdSafe, + resolveDiscordChannelNameSafe, + resolveDiscordChannelParentIdSafe, + resolveDiscordChannelParentSafe, +} from "./channel-access.js"; import { resolveDiscordChannelInfo, resolveDiscordEmbedText, @@ -199,8 +204,12 @@ export async function resolveDiscordThreadParentInfo(params: { channelInfo: import("./message-utils.js").DiscordChannelInfo | null; }): Promise { const { threadChannel, channelInfo, client } = params; + const parent = resolveDiscordChannelParentSafe(threadChannel); let parentId = - threadChannel.parentId ?? threadChannel.parent?.id ?? channelInfo?.parentId ?? undefined; + resolveDiscordChannelParentIdSafe(threadChannel) ?? + resolveDiscordChannelIdSafe(parent) ?? + channelInfo?.parentId ?? + undefined; if (!parentId && threadChannel.id) { const threadInfo = await resolveDiscordChannelInfo(client, threadChannel.id); parentId = threadInfo?.parentId ?? undefined; @@ -208,7 +217,7 @@ export async function resolveDiscordThreadParentInfo(params: { if (!parentId) { return {}; } - let parentName = resolveDiscordChannelNameSafe(threadChannel.parent); + let parentName = resolveDiscordChannelNameSafe(parent); const parentInfo = await resolveDiscordChannelInfo(client, parentId); parentName = parentName ?? parentInfo?.name; const parentType = parentInfo?.type;