fix: tolerate partial discord channel metadata

This commit is contained in:
Bob
2026-04-19 16:40:01 +02:00
parent bd3ad3436e
commit 1fc76adefe
11 changed files with 226 additions and 30 deletions

View File

@@ -39,6 +39,7 @@ import {
resolveDiscordOwnerAccess,
resolveGroupDmAllow,
} from "./allow-list.js";
import { resolveDiscordChannelInfoSafe } from "./channel-access.js";
import { formatDiscordUserTag } from "./format.js";
export const AGENT_BUTTON_KEY = "agent";
@@ -182,22 +183,20 @@ export function resolveDiscordChannelContext(
interaction: AgentComponentInteraction,
): DiscordChannelContext {
const channel = interaction.channel;
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
const channelInfo = resolveDiscordChannelInfoSafe(channel);
const channelName = channelInfo.name;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const channelType = channel && "type" in channel ? (channel.type as number) : undefined;
const channelType = channelInfo.type;
const isThread = isThreadChannelType(channelType);
let parentId: string | undefined;
let parentName: string | undefined;
let parentSlug = "";
if (isThread && channel && "parentId" in channel) {
parentId = (channel.parentId as string) ?? undefined;
if ("parent" in channel) {
const parent = (channel as { parent?: { name?: string } }).parent;
if (parent?.name) {
parentName = parent.name;
parentSlug = normalizeDiscordSlug(parentName);
}
if (isThread) {
parentId = channelInfo.parentId;
parentName = channelInfo.parentName;
if (parentName) {
parentSlug = normalizeDiscordSlug(parentName);
}
}

View File

@@ -0,0 +1,55 @@
function readDiscordChannelPropertySafe(channel: unknown, key: string): unknown {
if (!channel || typeof channel !== "object" || !(key in channel)) {
return undefined;
}
try {
return (channel as Record<string, unknown>)[key];
} catch {
return undefined;
}
}
function resolveDiscordChannelStringPropertySafe(
channel: unknown,
key: string,
): string | undefined {
const value = readDiscordChannelPropertySafe(channel, key);
return typeof value === "string" ? value : undefined;
}
function resolveDiscordChannelNumberPropertySafe(
channel: unknown,
key: string,
): number | undefined {
const value = readDiscordChannelPropertySafe(channel, key);
return typeof value === "number" ? value : undefined;
}
export type DiscordChannelInfoSafe = {
name?: string;
topic?: string;
type?: number;
parentId?: string;
ownerId?: string;
parentName?: string;
};
export function resolveDiscordChannelNameSafe(channel: unknown): string | undefined {
return resolveDiscordChannelStringPropertySafe(channel, "name");
}
export function resolveDiscordChannelTopicSafe(channel: unknown): string | undefined {
return resolveDiscordChannelStringPropertySafe(channel, "topic");
}
export function resolveDiscordChannelInfoSafe(channel: unknown): DiscordChannelInfoSafe {
const parent = readDiscordChannelPropertySafe(channel, "parent");
return {
name: resolveDiscordChannelNameSafe(channel),
topic: resolveDiscordChannelTopicSafe(channel),
type: resolveDiscordChannelNumberPropertySafe(channel, "type"),
parentId: resolveDiscordChannelStringPropertySafe(channel, "parentId"),
ownerId: resolveDiscordChannelStringPropertySafe(channel, "ownerId"),
parentName: resolveDiscordChannelNameSafe(parent),
};
}

View File

@@ -1,3 +1,4 @@
import { resolveDiscordChannelNameSafe } from "./channel-access.js";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.types.js";
type DiscordInboundJobRuntimeField =
@@ -108,7 +109,7 @@ function normalizeDiscordThreadChannel(
parent: threadChannel.parent
? {
id: threadChannel.parent.id,
name: threadChannel.parent.name,
name: resolveDiscordChannelNameSafe(threadChannel.parent),
}
: undefined,
ownerId: threadChannel.ownerId,

View File

@@ -32,6 +32,7 @@ import {
resolveDiscordGuildEntry,
shouldEmitDiscordReactionNotification,
} from "./allow-list.js";
import { resolveDiscordChannelInfoSafe } from "./channel-access.js";
import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js";
import { resolveDiscordChannelInfo } from "./message-utils.js";
import { setPresence } from "./presence-cache.js";
@@ -445,9 +446,10 @@ async function handleDiscordReactionEvent(
if (!channel) {
return;
}
const channelName = "name" in channel ? (channel.name ?? undefined) : undefined;
const channelInfo = resolveDiscordChannelInfoSafe(channel);
const channelName = channelInfo.name;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const channelType = "type" in channel ? channel.type : undefined;
const channelType = channelInfo.type;
const isDirectMessage = channelType === ChannelType.DM;
const isGroupDm = channelType === ChannelType.GroupDM;
const isThreadChannel =

View File

@@ -32,6 +32,7 @@ import {
resolveDiscordShouldRequireMention,
resolveGroupDmAllow,
} from "./allow-list.js";
import { resolveDiscordChannelNameSafe } from "./channel-access.js";
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
import {
@@ -575,9 +576,7 @@ export async function preflightDiscordMessage(
// Resolve thread parent early for binding inheritance
const channelName =
channelInfo?.name ??
((isGuildMessage || isGroupDm) && message.channel && "name" in message.channel
? message.channel.name
: undefined);
(isGuildMessage || isGroupDm ? resolveDiscordChannelNameSafe(message.channel) : undefined);
const { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } =
await loadDiscordThreadingRuntime();
const earlyThreadChannel = resolveDiscordThreadChannel({

View File

@@ -10,6 +10,7 @@ import {
normalizeOptionalString,
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordChannelInfoSafe } from "./channel-access.js";
import { mergeAbortSignals } from "./timeouts.js";
const DISCORD_CDN_HOSTNAMES = [
@@ -158,16 +159,13 @@ export async function resolveDiscordChannelInfo(
});
return null;
}
const name = "name" in channel ? (channel.name ?? undefined) : undefined;
const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined;
const parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined;
const ownerId = "ownerId" in channel ? (channel.ownerId ?? undefined) : undefined;
const channelInfo = resolveDiscordChannelInfoSafe(channel);
const payload: DiscordChannelInfo = {
type: channel.type,
name,
topic,
parentId,
ownerId,
type: (channelInfo.type as ChannelType | undefined) ?? channel.type,
name: channelInfo.name,
topic: channelInfo.topic,
parentId: channelInfo.parentId,
ownerId: channelInfo.ownerId,
};
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: payload,

View File

@@ -34,6 +34,7 @@ import {
normalizeOptionalString,
withTimeout,
} from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordChannelNameSafe } from "./channel-access.js";
import { resolveDiscordChannelInfo } from "./message-utils.js";
import {
readDiscordModelPickerRecentModels,
@@ -254,7 +255,7 @@ async function resolveDiscordModelPickerRouteState(params: {
client: interaction.client,
threadChannel: {
id: rawChannelId,
name: "name" in channel ? channel.name : undefined,
name: resolveDiscordChannelNameSafe(channel),
parentId: "parentId" in channel ? (channel.parentId ?? undefined) : undefined,
parent: undefined,
},

View File

@@ -83,11 +83,13 @@ async function runGuildSlashCommand(params?: {
userId?: string;
mutateConfig?: (cfg: OpenClawConfig) => void;
runtimeDiscordConfig?: DiscordAccountConfig;
mutateInteraction?: (interaction: MockCommandInteraction) => void;
}) {
const cfg = createConfig();
params?.mutateConfig?.(cfg);
const command = createCommand(cfg, params?.runtimeDiscordConfig);
const interaction = createInteraction({ userId: params?.userId });
params?.mutateInteraction?.(interaction);
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
const dispatchSpy = createDispatchSpy();
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
@@ -152,6 +154,44 @@ describe("Discord native slash commands with commands.allowFrom", () => {
expectNotUnauthorizedReply(interaction);
});
it("tolerates partial guild channels whose name getter throws", async () => {
const { dispatchSpy, interaction } = await runGuildSlashCommand({
mutateInteraction: (currentInteraction) => {
Object.defineProperty(currentInteraction.channel, "name", {
configurable: true,
enumerable: true,
get() {
throw new Error(
"Cannot access rawData on partial Channel. Use fetch() to populate data.",
);
},
});
},
});
expect(interaction.defer).toHaveBeenCalledTimes(1);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
expectNotUnauthorizedReply(interaction);
});
it("tolerates partial guild channels whose topic getter throws", async () => {
const { dispatchSpy, interaction } = await runGuildSlashCommand({
mutateInteraction: (currentInteraction) => {
Object.defineProperty(currentInteraction.channel, "topic", {
configurable: true,
enumerable: true,
get() {
throw new Error(
"Cannot access rawData on partial Channel. Use fetch() to populate data.",
);
},
});
},
});
expect(interaction.defer).toHaveBeenCalledTimes(1);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
expectNotUnauthorizedReply(interaction);
});
it("authorizes guild slash commands from an allowlisted channel when commands.allowFrom is not configured", async () => {
const { dispatchSpy, interaction } = await runGuildSlashCommand({
mutateConfig: (cfg) => {

View File

@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import * as globalsModule from "openclaw/plugin-sdk/runtime-env";
import * as commandTextModule from "openclaw/plugin-sdk/text-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveDiscordChannelContext } from "./agent-components-helpers.js";
import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
import * as modelPickerModule from "./model-picker.js";
import { createModelsProviderData as createBaseModelsProviderData } from "./model-picker.test-utils.js";
@@ -104,6 +105,20 @@ function createInteraction(params?: { userId?: string; values?: string[] }): Moc
};
}
function makePartialChannelThrow<T extends object>(
target: T,
key: keyof T & string,
message = "Cannot access rawData on partial Channel. Use fetch() to populate data.",
) {
Object.defineProperty(target, key, {
configurable: true,
enumerable: true,
get() {
throw new Error(message);
},
});
}
function createDefaultModelPickerData(): ModelsProviderData {
return createModelsProviderData({
openai: ["gpt-4.1", "gpt-4o"],
@@ -332,6 +347,90 @@ describe("Discord model picker interactions", () => {
});
});
it("applies the selected model even when component channel.name throws on a partial channel", async () => {
const context = createModelPickerContext();
const pickerData = createDefaultModelPickerData();
const modelCommand = createModelCommandDefinition();
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
mockModelCommandPipeline(modelCommand);
const dispatchSpy = createDispatchSpy();
const submitInteraction = createInteraction({ userId: "owner" });
makePartialChannelThrow(submitInteraction.channel, "name");
const button = createModelPickerFallbackButton(context, dispatchSpy);
await button.run(
submitInteraction as unknown as PickerButtonInteraction,
createModelsViewSubmitData(),
);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
expectDispatchedModelSelection({
dispatchSpy,
model: "openai/gpt-4o",
});
});
it("applies the selected model even when component thread parent.name throws on a partial channel", async () => {
const context = createModelPickerContext();
const pickerData = createDefaultModelPickerData();
const modelCommand = createModelCommandDefinition();
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
mockModelCommandPipeline(modelCommand);
const dispatchSpy = createDispatchSpy();
const submitInteraction = createInteraction({ userId: "owner" });
submitInteraction.guild = { id: "guild-1" };
const threadChannel = {
type: ChannelType.PublicThread,
id: "thread-1",
parentId: "parent-1",
parent: { id: "parent-1", name: "parent-name" },
} as {
type: ChannelType;
id: string;
parentId: string;
parent?: { id?: string; name?: string };
};
submitInteraction.channel = threadChannel as MockInteraction["channel"];
makePartialChannelThrow(threadChannel.parent as { id?: string; name?: string }, "name");
const button = createModelPickerFallbackButton(context, dispatchSpy);
await button.run(
submitInteraction as unknown as PickerButtonInteraction,
createModelsViewSubmitData(),
);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
expectDispatchedModelSelection({
dispatchSpy,
model: "openai/gpt-4o",
});
});
it("ignores category parent metadata for non-thread component channels", () => {
const interaction = createInteraction({ userId: "owner" });
interaction.guild = { id: "guild-1" };
interaction.channel = {
type: ChannelType.GuildText,
id: "channel-1",
name: "general",
parentId: "category-1",
parent: { id: "category-1", name: "category-name" },
} as MockInteraction["channel"] & { parent?: { id?: string; name?: string } };
const channelCtx = resolveDiscordChannelContext(
interaction as unknown as Parameters<typeof resolveDiscordChannelContext>[0],
);
expect(channelCtx.isThread).toBe(false);
expect(channelCtx.parentId).toBeUndefined();
expect(channelCtx.parentName).toBeUndefined();
expect(channelCtx.parentSlug).toBe("");
});
it("shows timeout status and skips recents write when apply is still processing", async () => {
const context = createModelPickerContext();
const pickerData = createDefaultModelPickerData();

View File

@@ -66,6 +66,7 @@ import {
resolveDiscordOwnerAccess,
resolveGroupDmAllow,
} from "./allow-list.js";
import { resolveDiscordChannelNameSafe, resolveDiscordChannelTopicSafe } from "./channel-access.js";
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
import { resolveDiscordChannelInfo } from "./message-utils.js";
@@ -412,7 +413,7 @@ async function resolveDiscordNativeAutocompleteAuthorized(params: {
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread;
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
const channelName = resolveDiscordChannelNameSafe(channel);
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const rawChannelId = channel?.id ?? "";
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
@@ -801,7 +802,7 @@ async function dispatchDiscordCommandInteraction(params: {
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread;
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
const channelName = resolveDiscordChannelNameSafe(channel);
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const rawChannelId = channel?.id ?? "";
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
@@ -1186,7 +1187,7 @@ async function dispatchDiscordCommandInteraction(params: {
memberRoleIds,
guildId: interaction.guild?.id,
guildName: interaction.guild?.name,
channelTopic: channel && "topic" in channel ? (channel.topic ?? undefined) : undefined,
channelTopic: resolveDiscordChannelTopicSafe(channel),
channelConfig,
guildInfo,
allowNameMatching,

View File

@@ -14,6 +14,7 @@ import {
truncateUtf16Safe,
} from "openclaw/plugin-sdk/text-runtime";
import type { DiscordChannelConfigResolved } from "./allow-list.js";
import { resolveDiscordChannelNameSafe } from "./channel-access.js";
import type { DiscordMessageEvent } from "./listeners.js";
import {
resolveDiscordChannelInfo,
@@ -207,7 +208,7 @@ export async function resolveDiscordThreadParentInfo(params: {
if (!parentId) {
return {};
}
let parentName = threadChannel.parent?.name;
let parentName = resolveDiscordChannelNameSafe(threadChannel.parent);
const parentInfo = await resolveDiscordChannelInfo(client, parentId);
parentName = parentName ?? parentInfo?.name;
const parentType = parentInfo?.type;