fix: normalize discord thread channel metadata

This commit is contained in:
Peter Steinberger
2026-05-02 11:32:12 +01:00
parent f8cbd356e1
commit 8631cadf5b
6 changed files with 187 additions and 18 deletions

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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<true>(_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;
}

View File

@@ -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" });
});
});

View File

@@ -28,7 +28,35 @@ function resolveDiscordChannelNumberPropertySafe(
return typeof value === "number" ? value : undefined;
}
type DiscordChannelInfoSafe = {
const DISCORD_CHANNEL_SNAKE_CASE_ALIASES: Record<string, string> = {
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),
};
}

View File

@@ -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)) {