mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:50:43 +00:00
fix: normalize discord thread channel metadata
This commit is contained in:
@@ -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.
|
||||
|
||||
43
extensions/discord/src/internal/structures.test.ts
Normal file
43
extensions/discord/src/internal/structures.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
99
extensions/discord/src/monitor/channel-access.test.ts
Normal file
99
extensions/discord/src/monitor/channel-access.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user