From 8631cadf5b7d42567f147f5d5b65ba7ffe8efefb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 11:32:12 +0100 Subject: [PATCH] fix: normalize discord thread channel metadata --- CHANGELOG.md | 1 + .../discord/src/internal/structures.test.ts | 43 ++++++++ extensions/discord/src/internal/structures.ts | 2 + .../src/monitor/channel-access.test.ts | 99 +++++++++++++++++++ .../discord/src/monitor/channel-access.ts | 40 +++++++- .../monitor/thread-bindings.discord-api.ts | 20 ++-- 6 files changed, 187 insertions(+), 18 deletions(-) create mode 100644 extensions/discord/src/internal/structures.test.ts create mode 100644 extensions/discord/src/monitor/channel-access.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c46aec3a7e7..0d318c548a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - Discord: preserve non-ASCII channel names in session display labels while keeping allowlist matching on the existing ASCII slug contract. Thanks @swjeong9. - Discord/PluralKit: canonicalize proxied webhook turns to the original Discord message id for inbound dedupe, while preserving the proxy message id for reply routing. Thanks @acgh213. - Discord: only inject thread starter context on the first turn of the effective thread session, so follow-up thread replies do not repeat the starter block. Fixes #41355; supersedes #44447 and #44449. Thanks @p3nchan. +- Discord: resolve thread `ownerId` and `parentId` from Discord API-style snake_case payload fields, so bot-owned autoThreads do not require unnecessary mentions. Thanks @mgh3326. - Gateway/diagnostics: include a bounded redacted startup error message in stability bundles, so crash-loop reports identify the failing plugin or contract without exposing secrets. Refs #75797. Thanks @ymebosma. - Gateway/pricing: defer optional model pricing catalog refresh until after sidecars and channels reach the ready path, so slow OpenRouter or LiteLLM pricing fetches cannot block Gateway readiness. Fixes #74128; supersedes #73486. Thanks @ctbritt and @alprclbi. - Gateway/pricing: abort in-flight model pricing catalog fetches when Gateway shutdown stops the refresh loop, and avoid post-stop cache writes or refresh timers. Fixes #72208. Thanks @rzcq. diff --git a/extensions/discord/src/internal/structures.test.ts b/extensions/discord/src/internal/structures.test.ts new file mode 100644 index 00000000000..994abade2c0 --- /dev/null +++ b/extensions/discord/src/internal/structures.test.ts @@ -0,0 +1,43 @@ +import { ChannelType } from "discord-api-types/v10"; +import { describe, expect, it } from "vitest"; +import { channelFactory, type StructureClient } from "./structures.js"; + +const client: StructureClient = { + rest: {} as StructureClient["rest"], + async fetchUser() { + throw new Error("not used"); + }, +}; + +describe("channelFactory", () => { + it("maps Discord API thread owner and parent fields to camelCase aliases", () => { + const channel = channelFactory(client, { + id: "thread-1", + type: ChannelType.PublicThread, + guild_id: "guild-1", + name: "support", + owner_id: "owner-1", + parent_id: "parent-1", + last_message_id: null, + rate_limit_per_user: 0, + thread_metadata: { + archived: false, + auto_archive_duration: 60, + locked: false, + archive_timestamp: new Date(0).toISOString(), + }, + message_count: 1, + member_count: 1, + total_message_sent: 1, + }); + + expect(channel.parentId).toBe("parent-1"); + expect(channel.ownerId).toBe("owner-1"); + expect( + channel.rawData && "parent_id" in channel.rawData ? channel.rawData.parent_id : undefined, + ).toBe("parent-1"); + expect( + channel.rawData && "owner_id" in channel.rawData ? channel.rawData.owner_id : undefined, + ).toBe("owner-1"); + }); +}); diff --git a/extensions/discord/src/internal/structures.ts b/extensions/discord/src/internal/structures.ts index 5cf793ccbc1..ac277147778 100644 --- a/extensions/discord/src/internal/structures.ts +++ b/extensions/discord/src/internal/structures.ts @@ -258,6 +258,7 @@ export type DiscordChannel = APIChannel & { guild?: Guild; name?: string; parentId?: string | null; + ownerId?: string | null; }; export function channelFactory( @@ -274,5 +275,6 @@ export function channelFactory( ? new Guild(_client, channelData.guild_id) : undefined, parentId: "parent_id" in channelData ? channelData.parent_id : undefined, + ownerId: "owner_id" in channelData ? channelData.owner_id : undefined, } as DiscordChannel; } diff --git a/extensions/discord/src/monitor/channel-access.test.ts b/extensions/discord/src/monitor/channel-access.test.ts new file mode 100644 index 00000000000..3707a0885d4 --- /dev/null +++ b/extensions/discord/src/monitor/channel-access.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + resolveDiscordChannelInfoSafe, + resolveDiscordChannelOwnerIdSafe, + resolveDiscordChannelParentIdSafe, +} from "./channel-access.js"; + +describe("resolveDiscordChannelOwnerIdSafe", () => { + it("reads camelCase ownerId directly", () => { + expect(resolveDiscordChannelOwnerIdSafe({ ownerId: "owner-1" })).toBe("owner-1"); + }); + + it("falls back to direct snake_case owner_id", () => { + expect(resolveDiscordChannelOwnerIdSafe({ owner_id: "owner-2" })).toBe("owner-2"); + }); + + it("falls back to rawData owner_id when direct fields are missing", () => { + expect(resolveDiscordChannelOwnerIdSafe({ rawData: { owner_id: "owner-3" } })).toBe("owner-3"); + }); + + it("prefers camelCase and direct snake_case before rawData", () => { + expect( + resolveDiscordChannelOwnerIdSafe({ + ownerId: "camel", + owner_id: "snake", + rawData: { owner_id: "raw" }, + }), + ).toBe("camel"); + expect( + resolveDiscordChannelOwnerIdSafe({ + owner_id: "snake", + rawData: { owner_id: "raw" }, + }), + ).toBe("snake"); + }); + + it("ignores invalid values and unsafe accessors", () => { + expect(resolveDiscordChannelOwnerIdSafe({ ownerId: 123 })).toBeUndefined(); + expect(resolveDiscordChannelOwnerIdSafe({ owner_id: 123 })).toBeUndefined(); + expect(resolveDiscordChannelOwnerIdSafe({ rawData: { owner_id: 123 } })).toBeUndefined(); + expect(resolveDiscordChannelOwnerIdSafe(null)).toBeUndefined(); + expect( + resolveDiscordChannelOwnerIdSafe( + new Proxy( + {}, + { + get() { + throw new Error("boom"); + }, + has() { + throw new Error("boom"); + }, + }, + ), + ), + ).toBeUndefined(); + }); +}); + +describe("resolveDiscordChannelParentIdSafe", () => { + it("reads parentId from camelCase, direct snake_case, and rawData", () => { + expect(resolveDiscordChannelParentIdSafe({ parentId: "parent-1" })).toBe("parent-1"); + expect(resolveDiscordChannelParentIdSafe({ parent_id: "parent-2" })).toBe("parent-2"); + expect(resolveDiscordChannelParentIdSafe({ rawData: { parent_id: "parent-3" } })).toBe( + "parent-3", + ); + }); + + it("prefers camelCase over snake_case and rawData", () => { + expect( + resolveDiscordChannelParentIdSafe({ + parentId: "camel", + parent_id: "snake", + rawData: { parent_id: "raw" }, + }), + ).toBe("camel"); + }); + + it("ignores invalid fallback values", () => { + expect(resolveDiscordChannelParentIdSafe({ parent_id: 7 })).toBeUndefined(); + expect(resolveDiscordChannelParentIdSafe({ rawData: { parent_id: 7 } })).toBeUndefined(); + }); +}); + +describe("resolveDiscordChannelInfoSafe", () => { + it("populates ownerId and parentId from Discord API-style snake_case fields", () => { + expect( + resolveDiscordChannelInfoSafe({ + owner_id: "owner-snake", + parent_id: "parent-snake", + }), + ).toMatchObject({ ownerId: "owner-snake", parentId: "parent-snake" }); + expect( + resolveDiscordChannelInfoSafe({ + rawData: { owner_id: "owner-raw", parent_id: "parent-raw" }, + }), + ).toMatchObject({ ownerId: "owner-raw", parentId: "parent-raw" }); + }); +}); diff --git a/extensions/discord/src/monitor/channel-access.ts b/extensions/discord/src/monitor/channel-access.ts index 81a8860e038..affd2ab4e0b 100644 --- a/extensions/discord/src/monitor/channel-access.ts +++ b/extensions/discord/src/monitor/channel-access.ts @@ -28,7 +28,35 @@ function resolveDiscordChannelNumberPropertySafe( return typeof value === "number" ? value : undefined; } -type DiscordChannelInfoSafe = { +const DISCORD_CHANNEL_SNAKE_CASE_ALIASES: Record = { + ownerId: "owner_id", + parentId: "parent_id", +}; + +function resolveDiscordChannelStringWithAliasSafe( + channel: unknown, + camelKey: string, +): string | undefined { + const camelValue = resolveDiscordChannelStringPropertySafe(channel, camelKey); + if (camelValue !== undefined) { + return camelValue; + } + + const snakeKey = DISCORD_CHANNEL_SNAKE_CASE_ALIASES[camelKey]; + if (!snakeKey) { + return undefined; + } + + const directSnakeValue = resolveDiscordChannelStringPropertySafe(channel, snakeKey); + if (directSnakeValue !== undefined) { + return directSnakeValue; + } + + const rawData = readDiscordChannelPropertySafe(channel, "rawData"); + return resolveDiscordChannelStringPropertySafe(rawData, snakeKey); +} + +export type DiscordChannelInfoSafe = { name?: string; topic?: string; type?: number; @@ -50,7 +78,11 @@ export function resolveDiscordChannelTopicSafe(channel: unknown): string | undef } export function resolveDiscordChannelParentIdSafe(channel: unknown): string | undefined { - return resolveDiscordChannelStringPropertySafe(channel, "parentId"); + return resolveDiscordChannelStringWithAliasSafe(channel, "parentId"); +} + +export function resolveDiscordChannelOwnerIdSafe(channel: unknown): string | undefined { + return resolveDiscordChannelStringWithAliasSafe(channel, "ownerId"); } export function resolveDiscordChannelParentSafe(channel: unknown): unknown { @@ -63,8 +95,8 @@ export function resolveDiscordChannelInfoSafe(channel: unknown): DiscordChannelI name: resolveDiscordChannelNameSafe(channel), topic: resolveDiscordChannelTopicSafe(channel), type: resolveDiscordChannelNumberPropertySafe(channel, "type"), - parentId: resolveDiscordChannelStringPropertySafe(channel, "parentId"), - ownerId: resolveDiscordChannelStringPropertySafe(channel, "ownerId"), + parentId: resolveDiscordChannelParentIdSafe(channel), + ownerId: resolveDiscordChannelOwnerIdSafe(channel), parentName: resolveDiscordChannelNameSafe(parent), }; } diff --git a/extensions/discord/src/monitor/thread-bindings.discord-api.ts b/extensions/discord/src/monitor/thread-bindings.discord-api.ts index afee541c0ff..573cdad80a8 100644 --- a/extensions/discord/src/monitor/thread-bindings.discord-api.ts +++ b/extensions/discord/src/monitor/thread-bindings.discord-api.ts @@ -7,6 +7,7 @@ import { createChannelWebhook, getChannel } from "../internal/discord.js"; import { sendMessageDiscord, sendWebhookMessageDiscord } from "../send.js"; import { createThreadDiscord } from "../send.messages.js"; import { resolveDiscordChannelId } from "../target-parsing.js"; +import { resolveDiscordChannelIdSafe, resolveDiscordChannelInfoSafe } from "./channel-access.js"; import { resolveThreadBindingPersonaFromRecord } from "./thread-bindings.persona.js"; import { BINDINGS_BY_THREAD_ID, @@ -259,20 +260,11 @@ export async function resolveChannelIdForBinding(params: { accountId: params.accountId, token: params.token, }).rest; - const channel = (await getChannel(rest, lookupThreadId)) as { - id?: string; - type?: number; - parent_id?: string; - parentId?: string; - }; - const channelId = normalizeOptionalString(channel?.id) ?? ""; - const type = channel?.type; - const parentId = - typeof channel?.parent_id === "string" - ? channel.parent_id.trim() - : typeof channel?.parentId === "string" - ? channel.parentId.trim() - : ""; + const channel = await getChannel(rest, lookupThreadId); + const channelInfo = resolveDiscordChannelInfoSafe(channel); + const channelId = normalizeOptionalString(resolveDiscordChannelIdSafe(channel)) ?? ""; + const type = channelInfo.type; + const parentId = normalizeOptionalString(channelInfo.parentId) ?? ""; // Only thread channels should resolve to their parent channel. // Non-thread channels (text/forum/media) must keep their own ID. if (parentId && isThreadChannelType(type)) {