diff --git a/extensions/discord/src/monitor/agent-components-helpers.ts b/extensions/discord/src/monitor/agent-components-helpers.ts index f6c5f9f5cbe..bd075cf722c 100644 --- a/extensions/discord/src/monitor/agent-components-helpers.ts +++ b/extensions/discord/src/monitor/agent-components-helpers.ts @@ -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; } diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index 85f415f54cd..822cd8c51a4 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -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 { 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 { 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 { 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; } diff --git a/extensions/discord/src/monitor/monitor.agent-components.test.ts b/extensions/discord/src/monitor/monitor.agent-components.test.ts index 9a7de6796cd..01f9cff05bb 100644 --- a/extensions/discord/src/monitor/monitor.agent-components.test.ts +++ b/extensions/discord/src/monitor/monitor.agent-components.test.ts @@ -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 = {}) => { + 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 = {}) => { + const { interaction, defer, reply } = createBaseGroupDmInteraction( + overrides as Record, + ); + 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({ diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index 4422a046106..155cae5fb68 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -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" })],