Discord: fix Group DM component interaction routing and auth (#57763)

* Discord: fix Group DM component interaction routing and auth

* Update tests
This commit is contained in:
Devin Robison 2026-03-30 11:17:53 -06:00 committed by GitHub
parent 8fdb19676a
commit 8c83128fc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 310 additions and 31 deletions

View File

@ -33,6 +33,7 @@ import {
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveGroupDmAllow,
resolveDiscordAllowListMatch,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
@ -146,6 +147,7 @@ export function resolveAgentComponentRoute(params: {
rawGuildId: string | undefined;
memberRoleIds: string[];
isDirectMessage: boolean;
isGroupDm: boolean;
userId: string;
channelId: string;
parentId: string | undefined;
@ -157,7 +159,7 @@ export function resolveAgentComponentRoute(params: {
guildId: params.rawGuildId,
memberRoleIds: params.memberRoleIds,
peer: {
kind: params.isDirectMessage ? "direct" : "channel",
kind: params.isDirectMessage ? "direct" : params.isGroupDm ? "group" : "channel",
id: params.isDirectMessage ? params.userId : params.channelId,
},
parentPeer: params.parentId ? { kind: "channel", id: params.parentId } : undefined,
@ -238,7 +240,10 @@ export async function resolveComponentInteractionContext(params: {
const username = formatUsername(user);
const userId = user.id;
const rawGuildId = interaction.rawData.guild_id;
const isDirectMessage = !rawGuildId;
const channelType = resolveDiscordChannelContext(interaction).channelType;
const isGroupDm = channelType === ChannelType.GroupDM;
const isDirectMessage =
channelType === ChannelType.DM || (!rawGuildId && !isGroupDm && channelType == null);
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
@ -251,6 +256,7 @@ export async function resolveComponentInteractionContext(params: {
replyOpts,
rawGuildId,
isDirectMessage,
isGroupDm,
memberRoleIds,
};
}
@ -563,6 +569,47 @@ async function ensureDmComponentAuthorized(params: {
return false;
}
async function ensureGroupDmComponentAuthorized(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
channelId: string;
componentLabel: string;
replyOpts: { ephemeral?: boolean };
}) {
const { ctx, interaction, channelId, componentLabel, replyOpts } = params;
const groupDmEnabled = ctx.discordConfig?.dm?.groupEnabled ?? false;
if (!groupDmEnabled) {
logVerbose(`agent ${componentLabel}: blocked group dm ${channelId} (group DMs disabled)`);
try {
await interaction.reply({
content: "Group DM interactions are disabled.",
...replyOpts,
});
} catch {}
return false;
}
const channelCtx = resolveDiscordChannelContext(interaction);
const allowed = resolveGroupDmAllow({
channels: ctx.discordConfig?.dm?.groupChannels,
channelId,
channelName: channelCtx.channelName,
channelSlug: channelCtx.channelSlug,
});
if (allowed) {
return true;
}
logVerbose(`agent ${componentLabel}: blocked group dm ${channelId} (not allowlisted)`);
try {
await interaction.reply({
content: `You are not authorized to use this ${componentLabel}.`,
...replyOpts,
});
} catch {}
return false;
}
export async function resolveInteractionContextWithDmAuth(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
@ -590,6 +637,18 @@ export async function resolveInteractionContextWithDmAuth(params: {
return null;
}
}
if (interactionCtx.isGroupDm) {
const authorized = await ensureGroupDmComponentAuthorized({
ctx: params.ctx,
interaction: params.interaction,
channelId: interactionCtx.channelId,
componentLabel: params.componentLabel,
replyOpts: interactionCtx.replyOpts,
});
if (!authorized) {
return null;
}
}
return interactionCtx;
}

View File

@ -46,7 +46,6 @@ import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/rep
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
@ -115,6 +114,34 @@ function resolveComponentGroupPolicy(
}).groupPolicy;
}
function buildDiscordComponentConversationLabel(params: {
interactionCtx: ComponentInteractionContext;
interaction: AgentComponentInteraction;
channelCtx: DiscordChannelContext;
}) {
if (params.interactionCtx.isDirectMessage) {
return buildDirectLabel(params.interactionCtx.user);
}
if (params.interactionCtx.isGroupDm) {
return `Group DM #${params.channelCtx.channelName ?? params.interactionCtx.channelId} channel id:${params.interactionCtx.channelId}`;
}
return buildGuildLabel({
guild: params.interaction.guild ?? undefined,
channelName: params.channelCtx.channelName ?? params.interactionCtx.channelId,
channelId: params.interactionCtx.channelId,
});
}
function resolveDiscordComponentChatType(interactionCtx: ComponentInteractionContext) {
if (interactionCtx.isDirectMessage) {
return "direct";
}
if (interactionCtx.isGroupDm) {
return "group";
}
return "channel";
}
async function dispatchPluginDiscordInteractiveEvent(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
@ -289,29 +316,25 @@ async function dispatchDiscordComponentEvent(params: {
}): Promise<void> {
const { ctx, interaction, interactionCtx, channelCtx, guildInfo, eventText } = params;
const runtime = ctx.runtime ?? createNonExitingRuntime();
const route = resolveAgentRoute({
cfg: ctx.cfg,
channel: "discord",
accountId: ctx.accountId,
guildId: interactionCtx.rawGuildId,
const route = resolveAgentComponentRoute({
ctx,
rawGuildId: interactionCtx.rawGuildId,
memberRoleIds: interactionCtx.memberRoleIds,
peer: {
kind: interactionCtx.isDirectMessage ? "direct" : "channel",
id: interactionCtx.isDirectMessage ? interactionCtx.userId : interactionCtx.channelId,
},
parentPeer: channelCtx.parentId ? { kind: "channel", id: channelCtx.parentId } : undefined,
isDirectMessage: interactionCtx.isDirectMessage,
isGroupDm: interactionCtx.isGroupDm,
userId: interactionCtx.userId,
channelId: interactionCtx.channelId,
parentId: channelCtx.parentId,
});
const sessionKey = params.routeOverrides?.sessionKey ?? route.sessionKey;
const agentId = params.routeOverrides?.agentId ?? route.agentId;
const accountId = params.routeOverrides?.accountId ?? route.accountId;
const fromLabel = interactionCtx.isDirectMessage
? buildDirectLabel(interactionCtx.user)
: buildGuildLabel({
guild: interaction.guild ?? undefined,
channelName: channelCtx.channelName ?? interactionCtx.channelId,
channelId: interactionCtx.channelId,
});
const fromLabel = buildDiscordComponentConversationLabel({
interactionCtx,
interaction,
channelCtx,
});
const chatType = resolveDiscordComponentChatType(interactionCtx);
const senderName = interactionCtx.user.globalName ?? interactionCtx.user.username;
const senderUsername = interactionCtx.user.username;
const senderTag = formatDiscordUserTag(interactionCtx.user);
@ -369,7 +392,7 @@ async function dispatchDiscordComponentEvent(params: {
from: fromLabel,
timestamp,
body: eventText,
chatType: interactionCtx.isDirectMessage ? "direct" : "channel",
chatType,
senderLabel: senderName,
previousTimestamp,
envelope: envelopeOptions,
@ -382,11 +405,13 @@ async function dispatchDiscordComponentEvent(params: {
CommandBody: eventText,
From: interactionCtx.isDirectMessage
? `discord:${interactionCtx.userId}`
: `discord:channel:${interactionCtx.channelId}`,
: interactionCtx.isGroupDm
? `discord:group:${interactionCtx.channelId}`
: `discord:channel:${interactionCtx.channelId}`,
To: `channel:${interactionCtx.channelId}`,
SessionKey: sessionKey,
AccountId: accountId,
ChatType: interactionCtx.isDirectMessage ? "direct" : "channel",
ChatType: chatType,
ConversationLabel: fromLabel,
SenderName: senderName,
SenderId: interactionCtx.userId,
@ -694,6 +719,7 @@ async function handleDiscordModalTrigger(params: {
interaction: ButtonInteraction;
data: ComponentData;
label: string;
interactionCtx?: ComponentInteractionContext;
}): Promise<void> {
const parsed = parseDiscordComponentData(
params.data,
@ -737,13 +763,15 @@ async function handleDiscordModalTrigger(params: {
return;
}
const interactionCtx = await resolveInteractionContextWithDmAuth({
ctx: params.ctx,
interaction: params.interaction,
label: params.label,
componentLabel: "form",
defer: false,
});
const interactionCtx =
params.interactionCtx ??
(await resolveInteractionContextWithDmAuth({
ctx: params.ctx,
interaction: params.interaction,
label: params.label,
componentLabel: "form",
defer: false,
}));
if (!interactionCtx) {
return;
}
@ -870,6 +898,7 @@ export class AgentComponentButton extends Button {
replyOpts,
rawGuildId,
isDirectMessage,
isGroupDm,
memberRoleIds,
} = interactionCtx;
@ -896,6 +925,7 @@ export class AgentComponentButton extends Button {
rawGuildId,
memberRoleIds,
isDirectMessage,
isGroupDm,
userId,
channelId,
parentId,
@ -960,6 +990,7 @@ export class AgentSelectMenu extends StringSelectMenu {
replyOpts,
rawGuildId,
isDirectMessage,
isGroupDm,
memberRoleIds,
} = interactionCtx;
@ -989,6 +1020,7 @@ export class AgentSelectMenu extends StringSelectMenu {
rawGuildId,
memberRoleIds,
isDirectMessage,
isGroupDm,
userId,
channelId,
parentId,
@ -1022,11 +1054,22 @@ class DiscordComponentButton extends Button {
async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
const parsed = parseDiscordComponentData(data, resolveInteractionCustomId(interaction));
if (parsed?.modalId) {
const interactionCtx = await resolveInteractionContextWithDmAuth({
ctx: this.ctx,
interaction,
label: "discord component button",
componentLabel: "form",
defer: false,
});
if (!interactionCtx) {
return;
}
await handleDiscordModalTrigger({
ctx: this.ctx,
interaction,
data,
label: "discord component modal",
interactionCtx,
});
return;
}

View File

@ -1,4 +1,5 @@
import type { ButtonInteraction, ComponentData, StringSelectMenuInteraction } from "@buape/carbon";
import { ChannelType } from "discord-api-types/v10";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import * as conversationRuntime from "openclaw/plugin-sdk/conversation-runtime";
@ -12,6 +13,7 @@ import {
resetDiscordComponentRuntimeMocks,
upsertPairingRequestMock,
} from "../test-support/component-runtime.js";
import { resolveComponentInteractionContext } from "./agent-components-helpers.js";
import { createAgentComponentButton, createAgentSelectMenu } from "./agent-components.js";
describe("agent components", () => {
@ -21,6 +23,12 @@ describe("agent components", () => {
accountId: "default",
peer: { kind: "direct", id: "123456789" },
});
const defaultGroupDmSessionKey = buildAgentSessionKey({
agentId: "main",
channel: "discord",
accountId: "default",
peer: { kind: "group", id: "group-dm-channel" },
});
const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
@ -60,6 +68,35 @@ describe("agent components", () => {
};
};
const createBaseGroupDmInteraction = (overrides: Record<string, unknown> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined);
const defer = vi.fn().mockResolvedValue(undefined);
const interaction = {
rawData: { channel_id: "group-dm-channel" },
channel: {
id: "group-dm-channel",
type: ChannelType.GroupDM,
name: "incident-room",
},
user: { id: "123456789", username: "Alice", discriminator: "1234" },
defer,
reply,
...overrides,
};
return { interaction, defer, reply };
};
const createGroupDmButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
const { interaction, defer, reply } = createBaseGroupDmInteraction(
overrides as Record<string, unknown>,
);
return {
interaction: interaction as unknown as ButtonInteraction,
defer,
reply,
};
};
beforeEach(() => {
resetDiscordComponentRuntimeMocks();
resetSystemEventsForTest();
@ -117,6 +154,76 @@ describe("agent components", () => {
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
it("classifies Group DM component interactions separately from direct messages", async () => {
const { interaction, defer } = createGroupDmButtonInteraction();
const ctx = await resolveComponentInteractionContext({
interaction,
label: "group-dm-test",
defer: false,
});
expect(defer).not.toHaveBeenCalled();
expect(ctx).toMatchObject({
channelId: "group-dm-channel",
isDirectMessage: false,
isGroupDm: true,
rawGuildId: undefined,
userId: "123456789",
});
});
it("blocks Group DM interactions that are not allowlisted even when dmPolicy is open", async () => {
const button = createAgentComponentButton({
cfg: createCfg(),
accountId: "default",
dmPolicy: "open",
discordConfig: {
dm: {
groupEnabled: true,
groupChannels: ["other-group-dm"],
},
} as DiscordAccountConfig,
});
const { interaction, defer, reply } = createGroupDmButtonInteraction();
await button.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({
content: "You are not authorized to use this button.",
ephemeral: true,
});
expect(peekSystemEvents(defaultGroupDmSessionKey)).toEqual([]);
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([]);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
it("routes allowlisted Group DM interactions to the group session without applying DM policy", async () => {
const button = createAgentComponentButton({
cfg: createCfg(),
accountId: "default",
dmPolicy: "disabled",
discordConfig: {
dm: {
groupEnabled: true,
groupChannels: ["group-dm-channel"],
},
} as DiscordAccountConfig,
});
const { interaction, defer, reply } = createGroupDmButtonInteraction();
await button.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(peekSystemEvents(defaultGroupDmSessionKey)).toEqual([
"[Discord component: hello clicked by Alice#1234 (123456789)]",
]);
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([]);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
it("authorizes DM interactions from pairing-store entries in pairing mode", async () => {
readAllowFromStoreMock.mockResolvedValue(["123456789"]);
const button = createAgentComponentButton({

View File

@ -701,6 +701,76 @@ describe("discord component interactions", () => {
expect(dispatchReplyMock).not.toHaveBeenCalled();
});
it("marks built-in Group DM component fallbacks with group metadata", async () => {
registerDiscordComponentEntries({
entries: [createButtonEntry()],
modals: [],
});
const button = createDiscordComponentButton(
createComponentContext({
discordConfig: createDiscordConfig({
dm: {
groupEnabled: true,
groupChannels: ["group-dm-1"],
},
}),
}),
);
const { interaction, reply } = createComponentButtonInteraction({
rawData: {
channel_id: "group-dm-1",
id: "interaction-group-dm-fallback",
} as unknown as ButtonInteraction["rawData"],
channel: {
id: "group-dm-1",
type: ChannelType.GroupDM,
name: "incident-room",
} as unknown as ButtonInteraction["channel"],
});
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
expect(lastDispatchCtx).toMatchObject({
From: "discord:group:group-dm-1",
ChatType: "group",
ConversationLabel: "Group DM #incident-room channel id:group-dm-1",
});
});
it("blocks Group DM modal triggers before showing the modal", async () => {
registerDiscordComponentEntries({
entries: [createButtonEntry({ kind: "modal-trigger", modalId: "mdl_1" })],
modals: [createModalEntry()],
});
const button = createDiscordComponentButton(createComponentContext());
const showModal = vi.fn().mockResolvedValue(undefined);
const { interaction, reply } = createComponentButtonInteraction({
rawData: {
channel_id: "group-dm-1",
id: "interaction-group-dm-modal-trigger",
} as unknown as ButtonInteraction["rawData"],
channel: {
id: "group-dm-1",
type: ChannelType.GroupDM,
name: "incident-room",
} as unknown as ButtonInteraction["channel"],
showModal,
});
await button.run(interaction, { cid: "btn_1", mid: "mdl_1" } as ComponentData);
expect(reply).toHaveBeenCalledWith({
content: "Group DM interactions are disabled.",
ephemeral: true,
});
expect(showModal).not.toHaveBeenCalled();
expect(dispatchReplyMock).not.toHaveBeenCalled();
});
it("does not fall through to Claw when a plugin Discord interaction already replied", async () => {
registerDiscordComponentEntries({
entries: [createButtonEntry({ callbackData: "codex:approve" })],