mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
fix: tolerate partial discord channel metadata
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
55
extensions/discord/src/monitor/channel-access.ts
Normal file
55
extensions/discord/src/monitor/channel-access.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user