diff --git a/extensions/discord/api.ts b/extensions/discord/api.ts index 51030369ffb..b67fe4f9e16 100644 --- a/extensions/discord/api.ts +++ b/extensions/discord/api.ts @@ -1,123 +1,23 @@ export { discordPlugin } from "./src/channel.js"; export { discordSetupPlugin } from "./src/channel.setup.js"; +export { inspectDiscordAccount } from "./src/account-inspect.js"; export { - handleDiscordSubagentDeliveryTarget, - handleDiscordSubagentEnded, - handleDiscordSubagentSpawning, -} from "./src/subagent-hooks.js"; -export { - type DiscordCredentialStatus, - inspectDiscordAccount, - type InspectedDiscordAccount, -} from "./src/account-inspect.js"; -export { - createDiscordActionGate, listDiscordAccountIds, - listEnabledDiscordAccounts, - mergeDiscordAccountConfig, - type ResolvedDiscordAccount, resolveDefaultDiscordAccountId, resolveDiscordAccount, - resolveDiscordAccountConfig, - resolveDiscordMaxLinesPerMessage, } from "./src/accounts.js"; -export { tryHandleDiscordMessageActionGuildAdmin } from "./src/actions/handle-action.guild-admin.js"; -export { handleDiscordMessageAction } from "./src/actions/handle-action.js"; -export { - buildDiscordComponentCustomId, - buildDiscordComponentMessage, - buildDiscordComponentMessageFlags, - buildDiscordInteractiveComponents, - buildDiscordModalCustomId, - createDiscordFormModal, - DISCORD_COMPONENT_ATTACHMENT_PREFIX, - DISCORD_COMPONENT_CUSTOM_ID_KEY, - DISCORD_MODAL_CUSTOM_ID_KEY, - type DiscordComponentBlock, - type DiscordComponentBuildResult, - type DiscordComponentButtonSpec, - type DiscordComponentButtonStyle, - type DiscordComponentEntry, - type DiscordComponentMessageSpec, - type DiscordComponentModalFieldType, - type DiscordComponentSectionAccessory, - type DiscordComponentSelectOption, - type DiscordComponentSelectSpec, - type DiscordComponentSelectType, - DiscordFormModal, - type DiscordModalEntry, - type DiscordModalFieldDefinition, - type DiscordModalFieldSpec, - type DiscordModalSpec, - formatDiscordComponentEventText, - parseDiscordComponentCustomId, - parseDiscordComponentCustomIdForCarbon, - parseDiscordComponentCustomIdForInteraction, - parseDiscordModalCustomId, - parseDiscordModalCustomIdForCarbon, - parseDiscordModalCustomIdForInteraction, - readDiscordComponentSpec, - resolveDiscordComponentAttachmentName, -} from "./src/components.js"; +export { buildDiscordComponentMessage } from "./src/components.js"; export { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, } from "./src/directory-config.js"; -export { - getDiscordExecApprovalApprovers, - isDiscordExecApprovalApprover, - isDiscordExecApprovalClientEnabled, - shouldSuppressLocalDiscordExecApprovalPrompt, -} from "./src/exec-approvals.js"; export { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, } from "./src/group-policy.js"; -export type { - DiscordInteractiveHandlerContext, - DiscordInteractiveHandlerRegistration, -} from "./src/interactive-dispatch.js"; export { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget, normalizeDiscordOutboundTarget, } from "./src/normalize.js"; -export { - type DiscordPluralKitConfig, - fetchPluralKitMessageInfo, - type PluralKitMemberInfo, - type PluralKitMessageInfo, - type PluralKitSystemInfo, -} from "./src/pluralkit.js"; -export { - type DiscordApplicationSummary, - type DiscordPrivilegedIntentsSummary, - type DiscordPrivilegedIntentStatus, - type DiscordProbe, - fetchDiscordApplicationId, - fetchDiscordApplicationSummary, - parseApplicationIdFromToken, - probeDiscord, - resolveDiscordPrivilegedIntentsFromFlags, -} from "./src/probe.js"; -export { normalizeExplicitDiscordSessionKey } from "./src/session-key-normalization.js"; export { collectDiscordStatusIssues } from "./src/status-issues.js"; -export { - type DiscordTarget, - type DiscordTargetKind, - type DiscordTargetParseOptions, - parseDiscordTarget, - resolveDiscordChannelId, - resolveDiscordTarget, -} from "./src/targets.js"; -export { collectDiscordSecurityAuditFindings } from "./src/security-audit.js"; -export { resolveDiscordRuntimeGroupPolicy } from "./src/runtime-group-policy.js"; -export { - DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS, - DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS, - DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS, - DISCORD_DEFAULT_LISTENER_TIMEOUT_MS, -} from "./src/monitor/timeouts.js"; -export type { DiscordSendComponents, DiscordSendEmbeds } from "./src/send.shared.js"; -export type { DiscordSendResult } from "./src/send.types.js"; -export type { DiscordTokenResolution } from "./src/token.js"; diff --git a/extensions/discord/src/client.test.ts b/extensions/discord/src/client.test.ts index 38f7e1eebb6..2698019e6aa 100644 --- a/extensions/discord/src/client.test.ts +++ b/extensions/discord/src/client.test.ts @@ -1,11 +1,5 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { describe, expect, it } from "vitest"; -import { - parseDiscordComponentCustomIdForCarbon, - parseDiscordComponentCustomIdForInteraction, - parseDiscordModalCustomIdForCarbon, - parseDiscordModalCustomIdForInteraction, -} from "../api.js"; import { createDiscordRestClient } from "./client.js"; import type { RequestClient } from "./internal/discord.js"; @@ -80,14 +74,3 @@ describe("createDiscordRestClient", () => { expect(() => createDiscordRestClient({ cfg, rest: fakeRest })).toThrow(/unresolved SecretRef/i); }); }); - -describe("public Discord API compatibility", () => { - it("keeps legacy Carbon parser aliases wired to the interaction parsers", () => { - expect(parseDiscordComponentCustomIdForCarbon).toBe( - parseDiscordComponentCustomIdForInteraction, - ); - expect(parseDiscordModalCustomIdForCarbon).toBe(parseDiscordModalCustomIdForInteraction); - expect(parseDiscordComponentCustomIdForCarbon("occomp:cid=one").data.cid).toBe("one"); - expect(parseDiscordModalCustomIdForCarbon("ocmodal:mid=two").data.mid).toBe("two"); - }); -}); diff --git a/extensions/discord/src/component-custom-id.ts b/extensions/discord/src/component-custom-id.ts index fc56dfa9411..2ce5bafb0bc 100644 --- a/extensions/discord/src/component-custom-id.ts +++ b/extensions/discord/src/component-custom-id.ts @@ -60,8 +60,6 @@ export function parseDiscordComponentCustomIdForInteraction(id: string): Compone return { key: "*", data: parsed.data }; } -export const parseDiscordComponentCustomIdForCarbon = parseDiscordComponentCustomIdForInteraction; - export function parseDiscordModalCustomIdForInteraction(id: string): ComponentParserResult { if (id === "*" || isDiscordComponentWildcardRegistrationId(id)) { return { key: "*", data: {} }; @@ -72,5 +70,3 @@ export function parseDiscordModalCustomIdForInteraction(id: string): ComponentPa } return { key: "*", data: parsed.data }; } - -export const parseDiscordModalCustomIdForCarbon = parseDiscordModalCustomIdForInteraction; diff --git a/extensions/discord/src/components.ts b/extensions/discord/src/components.ts index e181525ed97..aaea3931cd6 100644 --- a/extensions/discord/src/components.ts +++ b/extensions/discord/src/components.ts @@ -4,10 +4,8 @@ export { buildDiscordComponentCustomId, buildDiscordModalCustomId, parseDiscordComponentCustomId, - parseDiscordComponentCustomIdForCarbon, parseDiscordComponentCustomIdForInteraction, parseDiscordModalCustomId, - parseDiscordModalCustomIdForCarbon, parseDiscordModalCustomIdForInteraction, } from "./component-custom-id.js"; export { diff --git a/extensions/discord/src/monitor/agent-components.handlers.ts b/extensions/discord/src/monitor/agent-components.handlers.ts new file mode 100644 index 00000000000..46b899d7567 --- /dev/null +++ b/extensions/discord/src/monitor/agent-components.handlers.ts @@ -0,0 +1,356 @@ +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; +import { logError } from "openclaw/plugin-sdk/text-runtime"; +import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js"; +import type { ButtonInteraction, ComponentData } from "../internal/discord.js"; +import { + type AgentComponentContext, + type AgentComponentMessageInteraction, + ensureComponentUserAllowed, + ensureGuildComponentMemberAllowed, + mapSelectValues, + parseDiscordComponentData, + resolveComponentCommandAuthorized, + resolveDiscordChannelContext, + resolveInteractionContextWithDmAuth, + resolveInteractionCustomId, + type ComponentInteractionContext, +} from "./agent-components-helpers.js"; +import { dispatchDiscordComponentEvent } from "./agent-components.dispatch.js"; +import { dispatchPluginDiscordInteractiveEvent } from "./agent-components.plugin-interactive.js"; +import { resolveComponentGroupPolicy } from "./agent-components.policy.js"; +import type { DiscordComponentControlHandlers } from "./agent-components.wildcard-controls.js"; +import { resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry } from "./allow-list.js"; + +let componentsRuntimePromise: Promise | undefined; + +async function loadComponentsRuntime() { + componentsRuntimePromise ??= import("../components.js"); + return await componentsRuntimePromise; +} + +async function handleDiscordComponentEvent(params: { + ctx: AgentComponentContext; + interaction: AgentComponentMessageInteraction; + data: ComponentData; + componentLabel: string; + values?: string[]; + label: string; +}): Promise { + const parsed = parseDiscordComponentData( + params.data, + resolveInteractionCustomId(params.interaction), + ); + if (!parsed) { + logError(`${params.label}: failed to parse component data`); + try { + await params.interaction.reply({ + content: "This component is no longer valid.", + ephemeral: true, + }); + } catch { + // Interaction may have expired + } + return; + } + + const entry = resolveDiscordComponentEntry({ id: parsed.componentId, consume: false }); + if (!entry) { + try { + await params.interaction.reply({ + content: "This component has expired.", + ephemeral: true, + }); + } catch { + // Interaction may have expired + } + return; + } + + const interactionCtx = await resolveInteractionContextWithDmAuth({ + ctx: params.ctx, + interaction: params.interaction, + label: params.label, + componentLabel: params.componentLabel, + defer: false, + }); + if (!interactionCtx) { + return; + } + const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; + const guildInfo = resolveDiscordGuildEntry({ + guild: params.interaction.guild ?? undefined, + guildId: rawGuildId, + guildEntries: params.ctx.guildEntries, + }); + const channelCtx = resolveDiscordChannelContext(params.interaction); + const allowNameMatching = isDangerousNameMatchingEnabled(params.ctx.discordConfig); + const channelConfig = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId, + channelName: channelCtx.channelName, + channelSlug: channelCtx.channelSlug, + parentId: channelCtx.parentId, + parentName: channelCtx.parentName, + parentSlug: channelCtx.parentSlug, + scope: channelCtx.isThread ? "thread" : "channel", + }); + const unauthorizedReply = `You are not authorized to use this ${params.componentLabel}.`; + const memberAllowed = await ensureGuildComponentMemberAllowed({ + interaction: params.interaction, + guildInfo, + channelId, + rawGuildId, + channelCtx, + memberRoleIds, + user, + replyOpts, + componentLabel: params.componentLabel, + unauthorizedReply, + allowNameMatching, + groupPolicy: resolveComponentGroupPolicy(params.ctx), + }); + if (!memberAllowed) { + return; + } + + const componentAllowed = await ensureComponentUserAllowed({ + entry, + interaction: params.interaction, + user, + replyOpts, + componentLabel: params.componentLabel, + unauthorizedReply, + allowNameMatching, + }); + if (!componentAllowed) { + return; + } + const commandAuthorized = resolveComponentCommandAuthorized({ + ctx: params.ctx, + interactionCtx, + channelConfig, + guildInfo, + allowNameMatching, + }); + + const consumed = resolveDiscordComponentEntry({ + id: parsed.componentId, + consume: !entry.reusable, + }); + if (!consumed) { + try { + await params.interaction.reply({ + content: "This component has expired.", + ephemeral: true, + }); + } catch { + // Interaction may have expired + } + return; + } + + if (consumed.kind === "modal-trigger") { + try { + await params.interaction.reply({ + content: "This form is no longer available.", + ephemeral: true, + }); + } catch { + // Interaction may have expired + } + return; + } + + const values = params.values ? mapSelectValues(consumed, params.values) : undefined; + if (consumed.callbackData) { + const pluginDispatch = await dispatchPluginDiscordInteractiveEvent({ + ctx: params.ctx, + interaction: params.interaction, + interactionCtx, + channelCtx, + isAuthorizedSender: commandAuthorized, + data: consumed.callbackData, + kind: consumed.kind === "select" ? "select" : "button", + values, + messageId: consumed.messageId ?? params.interaction.message?.id, + }); + if (pluginDispatch === "handled") { + return; + } + } + // Preserve explicit callback payloads for button fallbacks so Discord + // behaves like Telegram when buttons carry synthetic command text. Select + // fallbacks still need their chosen values in the synthesized event text. + const eventText = + (consumed.kind === "button" ? consumed.callbackData?.trim() : undefined) || + (await loadComponentsRuntime()).formatDiscordComponentEventText({ + kind: consumed.kind === "select" ? "select" : "button", + label: consumed.label, + values, + }); + + try { + await params.interaction.reply({ content: "✓", ...replyOpts }); + } catch (err) { + logError(`${params.label}: failed to acknowledge interaction: ${String(err)}`); + } + + await dispatchDiscordComponentEvent({ + ctx: params.ctx, + interaction: params.interaction, + interactionCtx, + channelCtx, + guildInfo, + eventText, + replyToId: consumed.messageId ?? params.interaction.message?.id, + routeOverrides: { + sessionKey: consumed.sessionKey, + agentId: consumed.agentId, + accountId: consumed.accountId, + }, + }); +} + +async function handleDiscordModalTrigger(params: { + ctx: AgentComponentContext; + interaction: ButtonInteraction; + data: ComponentData; + label: string; + interactionCtx?: ComponentInteractionContext; +}): Promise { + const parsed = parseDiscordComponentData( + params.data, + resolveInteractionCustomId(params.interaction), + ); + if (!parsed) { + logError(`${params.label}: failed to parse modal trigger data`); + try { + await params.interaction.reply({ + content: "This button is no longer valid.", + ephemeral: true, + }); + } catch { + // Interaction may have expired + } + return; + } + const entry = resolveDiscordComponentEntry({ id: parsed.componentId, consume: false }); + if (!entry || entry.kind !== "modal-trigger") { + try { + await params.interaction.reply({ + content: "This button has expired.", + ephemeral: true, + }); + } catch { + // Interaction may have expired + } + return; + } + + const modalId = entry.modalId ?? parsed.modalId; + if (!modalId) { + try { + await params.interaction.reply({ + content: "This form is no longer available.", + ephemeral: true, + }); + } catch { + // Interaction may have expired + } + return; + } + + const interactionCtx = + params.interactionCtx ?? + (await resolveInteractionContextWithDmAuth({ + ctx: params.ctx, + interaction: params.interaction, + label: params.label, + componentLabel: "form", + defer: false, + })); + if (!interactionCtx) { + return; + } + const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; + const guildInfo = resolveDiscordGuildEntry({ + guild: params.interaction.guild ?? undefined, + guildId: rawGuildId, + guildEntries: params.ctx.guildEntries, + }); + const channelCtx = resolveDiscordChannelContext(params.interaction); + const unauthorizedReply = "You are not authorized to use this form."; + const memberAllowed = await ensureGuildComponentMemberAllowed({ + interaction: params.interaction, + guildInfo, + channelId, + rawGuildId, + channelCtx, + memberRoleIds, + user, + replyOpts, + componentLabel: "form", + unauthorizedReply, + allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig), + groupPolicy: resolveComponentGroupPolicy(params.ctx), + }); + if (!memberAllowed) { + return; + } + + const componentAllowed = await ensureComponentUserAllowed({ + entry, + interaction: params.interaction, + user, + replyOpts, + componentLabel: "form", + unauthorizedReply, + allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig), + }); + if (!componentAllowed) { + return; + } + + const consumed = resolveDiscordComponentEntry({ + id: parsed.componentId, + consume: !entry.reusable, + }); + if (!consumed) { + try { + await params.interaction.reply({ + content: "This form has expired.", + ephemeral: true, + }); + } catch { + // Interaction may have expired + } + return; + } + + const resolvedModalId = consumed.modalId ?? modalId; + const modalEntry = resolveDiscordModalEntry({ id: resolvedModalId, consume: false }); + if (!modalEntry) { + try { + await params.interaction.reply({ + content: "This form has expired.", + ephemeral: true, + }); + } catch { + // Interaction may have expired + } + return; + } + + try { + await params.interaction.showModal( + (await loadComponentsRuntime()).createDiscordFormModal(modalEntry), + ); + } catch (err) { + logError(`${params.label}: failed to show modal: ${String(err)}`); + } +} + +export const discordComponentControlHandlers: DiscordComponentControlHandlers = { + handleComponentEvent: handleDiscordComponentEvent, + handleModalTrigger: handleDiscordModalTrigger, +}; diff --git a/extensions/discord/src/monitor/agent-components.modal.ts b/extensions/discord/src/monitor/agent-components.modal.ts new file mode 100644 index 00000000000..7b59b84d3b1 --- /dev/null +++ b/extensions/discord/src/monitor/agent-components.modal.ts @@ -0,0 +1,194 @@ +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; +import { logError } from "openclaw/plugin-sdk/text-runtime"; +import { parseDiscordModalCustomIdForInteraction } from "../component-custom-id.js"; +import { resolveDiscordModalEntry } from "../components-registry.js"; +import { Modal, type ComponentData, type ModalInteraction } from "../internal/discord.js"; +import { + type AgentComponentContext, + ensureComponentUserAllowed, + ensureGuildComponentMemberAllowed, + formatModalSubmissionText, + parseDiscordModalId, + resolveComponentCommandAuthorized, + resolveDiscordChannelContext, + resolveInteractionContextWithDmAuth, + resolveInteractionCustomId, + resolveModalFieldValues, +} from "./agent-components-helpers.js"; +import { dispatchDiscordComponentEvent } from "./agent-components.dispatch.js"; +import { dispatchPluginDiscordInteractiveEvent } from "./agent-components.plugin-interactive.js"; +import { resolveComponentGroupPolicy } from "./agent-components.policy.js"; +import { resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry } from "./allow-list.js"; + +export class DiscordComponentModal extends Modal { + title = "OpenClaw form"; + customId = "__openclaw_discord_component_modal_wildcard__"; + components = []; + customIdParser = parseDiscordModalCustomIdForInteraction; + private ctx: AgentComponentContext; + + constructor(ctx: AgentComponentContext) { + super(); + this.ctx = ctx; + } + + async run(interaction: ModalInteraction, data: ComponentData): Promise { + const modalId = parseDiscordModalId(data, resolveInteractionCustomId(interaction)); + if (!modalId) { + logError("discord component modal: missing modal id"); + try { + await interaction.reply({ + content: "This form is no longer valid.", + ephemeral: true, + }); + } catch { + // Interaction may have expired + } + return; + } + + const modalEntry = resolveDiscordModalEntry({ id: modalId, consume: false }); + if (!modalEntry) { + try { + await interaction.reply({ + content: "This form has expired.", + ephemeral: true, + }); + } catch { + // Interaction may have expired + } + return; + } + + const interactionCtx = await resolveInteractionContextWithDmAuth({ + ctx: this.ctx, + interaction, + label: "discord component modal", + componentLabel: "form", + defer: false, + }); + if (!interactionCtx) { + return; + } + const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; + const guildInfo = resolveDiscordGuildEntry({ + guild: interaction.guild ?? undefined, + guildId: rawGuildId, + guildEntries: this.ctx.guildEntries, + }); + const channelCtx = resolveDiscordChannelContext(interaction); + const allowNameMatching = isDangerousNameMatchingEnabled(this.ctx.discordConfig); + const channelConfig = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId, + channelName: channelCtx.channelName, + channelSlug: channelCtx.channelSlug, + parentId: channelCtx.parentId, + parentName: channelCtx.parentName, + parentSlug: channelCtx.parentSlug, + scope: channelCtx.isThread ? "thread" : "channel", + }); + const memberAllowed = await ensureGuildComponentMemberAllowed({ + interaction, + guildInfo, + channelId, + rawGuildId, + channelCtx, + memberRoleIds, + user, + replyOpts, + componentLabel: "form", + unauthorizedReply: "You are not authorized to use this form.", + allowNameMatching, + groupPolicy: resolveComponentGroupPolicy(this.ctx), + }); + if (!memberAllowed) { + return; + } + + const modalAllowed = await ensureComponentUserAllowed({ + entry: { + id: modalEntry.id, + kind: "button", + label: modalEntry.title, + allowedUsers: modalEntry.allowedUsers, + }, + interaction, + user, + replyOpts, + componentLabel: "form", + unauthorizedReply: "You are not authorized to use this form.", + allowNameMatching, + }); + if (!modalAllowed) { + return; + } + const commandAuthorized = resolveComponentCommandAuthorized({ + ctx: this.ctx, + interactionCtx, + channelConfig, + guildInfo, + allowNameMatching, + }); + + const consumed = resolveDiscordModalEntry({ + id: modalId, + consume: !modalEntry.reusable, + }); + if (!consumed) { + try { + await interaction.reply({ + content: "This form has expired.", + ephemeral: true, + }); + } catch { + // Interaction may have expired + } + return; + } + + if (consumed.callbackData) { + const fields = consumed.fields.map((field) => ({ + id: field.id, + name: field.name, + values: resolveModalFieldValues(field, interaction), + })); + const pluginDispatch = await dispatchPluginDiscordInteractiveEvent({ + ctx: this.ctx, + interaction, + interactionCtx, + channelCtx, + isAuthorizedSender: commandAuthorized, + data: consumed.callbackData, + kind: "modal", + fields, + messageId: consumed.messageId, + }); + if (pluginDispatch === "handled") { + return; + } + } + + try { + await interaction.acknowledge(); + } catch (err) { + logError(`discord component modal: failed to acknowledge: ${String(err)}`); + } + + const eventText = formatModalSubmissionText(consumed, interaction); + await dispatchDiscordComponentEvent({ + ctx: this.ctx, + interaction, + interactionCtx, + channelCtx, + guildInfo, + eventText, + replyToId: consumed.messageId, + routeOverrides: { + sessionKey: consumed.sessionKey, + agentId: consumed.agentId, + accountId: consumed.accountId, + }, + }); + } +} diff --git a/extensions/discord/src/monitor/agent-components.policy.ts b/extensions/discord/src/monitor/agent-components.policy.ts new file mode 100644 index 00000000000..e2da8104cdd --- /dev/null +++ b/extensions/discord/src/monitor/agent-components.policy.ts @@ -0,0 +1,12 @@ +import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; +import type { AgentComponentContext } from "./agent-components-helpers.js"; + +export function resolveComponentGroupPolicy( + ctx: AgentComponentContext, +): "open" | "disabled" | "allowlist" { + return resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: ctx.cfg.channels?.discord !== undefined, + groupPolicy: ctx.discordConfig?.groupPolicy, + defaultGroupPolicy: ctx.cfg.channels?.defaults?.groupPolicy, + }).groupPolicy; +} diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index d604f9e4b42..378349d7b7b 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -1,38 +1,7 @@ -import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; -import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; -import { logError } from "openclaw/plugin-sdk/text-runtime"; -import { parseDiscordModalCustomIdForInteraction } from "../component-custom-id.js"; -import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js"; -import { - Modal, - type Button, - type ButtonInteraction, - type ChannelSelectMenu, - type ComponentData, - type MentionableSelectMenu, - type ModalInteraction, - type RoleSelectMenu, - type StringSelectMenu, - type UserSelectMenu, -} from "../internal/discord.js"; -import { - type AgentComponentContext, - type AgentComponentMessageInteraction, - ensureComponentUserAllowed, - ensureGuildComponentMemberAllowed, - formatModalSubmissionText, - mapSelectValues, - parseDiscordComponentData, - parseDiscordModalId, - resolveComponentCommandAuthorized, - resolveDiscordChannelContext, - resolveInteractionContextWithDmAuth, - resolveInteractionCustomId, - resolveModalFieldValues, - type ComponentInteractionContext, -} from "./agent-components-helpers.js"; -import { dispatchDiscordComponentEvent } from "./agent-components.dispatch.js"; -import { dispatchPluginDiscordInteractiveEvent } from "./agent-components.plugin-interactive.js"; +import { Modal, type BaseMessageInteractiveComponent } from "../internal/discord.js"; +import type { AgentComponentContext } from "./agent-components-helpers.js"; +import { discordComponentControlHandlers } from "./agent-components.handlers.js"; +import { DiscordComponentModal } from "./agent-components.modal.js"; import { createDiscordComponentButtonControl, createDiscordComponentChannelSelectControl, @@ -42,7 +11,6 @@ import { createDiscordComponentUserSelectControl, type DiscordComponentControlHandlers, } from "./agent-components.wildcard-controls.js"; -import { resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry } from "./allow-list.js"; export { resolveDiscordComponentOriginatingTo } from "./agent-components.dispatch.js"; export { @@ -52,548 +20,30 @@ export { createAgentSelectMenu, } from "./agent-components.system-controls.js"; -let componentsRuntimePromise: Promise | undefined; - -async function loadComponentsRuntime() { - componentsRuntimePromise ??= import("../components.js"); - return await componentsRuntimePromise; +function bindDiscordComponentControl( + createControl: (ctx: AgentComponentContext, handlers: DiscordComponentControlHandlers) => T, +) { + return (ctx: AgentComponentContext): T => createControl(ctx, discordComponentControlHandlers); } -function resolveComponentGroupPolicy( - ctx: AgentComponentContext, -): "open" | "disabled" | "allowlist" { - return resolveOpenProviderRuntimeGroupPolicy({ - providerConfigPresent: ctx.cfg.channels?.discord !== undefined, - groupPolicy: ctx.discordConfig?.groupPolicy, - defaultGroupPolicy: ctx.cfg.channels?.defaults?.groupPolicy, - }).groupPolicy; -} - -async function handleDiscordComponentEvent(params: { - ctx: AgentComponentContext; - interaction: AgentComponentMessageInteraction; - data: ComponentData; - componentLabel: string; - values?: string[]; - label: string; -}): Promise { - const parsed = parseDiscordComponentData( - params.data, - resolveInteractionCustomId(params.interaction), - ); - if (!parsed) { - logError(`${params.label}: failed to parse component data`); - try { - await params.interaction.reply({ - content: "This component is no longer valid.", - ephemeral: true, - }); - } catch { - // Interaction may have expired - } - return; - } - - const entry = resolveDiscordComponentEntry({ id: parsed.componentId, consume: false }); - if (!entry) { - try { - await params.interaction.reply({ - content: "This component has expired.", - ephemeral: true, - }); - } catch { - // Interaction may have expired - } - return; - } - - const interactionCtx = await resolveInteractionContextWithDmAuth({ - ctx: params.ctx, - interaction: params.interaction, - label: params.label, - componentLabel: params.componentLabel, - defer: false, - }); - if (!interactionCtx) { - return; - } - const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; - const guildInfo = resolveDiscordGuildEntry({ - guild: params.interaction.guild ?? undefined, - guildId: rawGuildId, - guildEntries: params.ctx.guildEntries, - }); - const channelCtx = resolveDiscordChannelContext(params.interaction); - const allowNameMatching = isDangerousNameMatchingEnabled(params.ctx.discordConfig); - const channelConfig = resolveDiscordChannelConfigWithFallback({ - guildInfo, - channelId, - channelName: channelCtx.channelName, - channelSlug: channelCtx.channelSlug, - parentId: channelCtx.parentId, - parentName: channelCtx.parentName, - parentSlug: channelCtx.parentSlug, - scope: channelCtx.isThread ? "thread" : "channel", - }); - const unauthorizedReply = `You are not authorized to use this ${params.componentLabel}.`; - const memberAllowed = await ensureGuildComponentMemberAllowed({ - interaction: params.interaction, - guildInfo, - channelId, - rawGuildId, - channelCtx, - memberRoleIds, - user, - replyOpts, - componentLabel: params.componentLabel, - unauthorizedReply, - allowNameMatching, - groupPolicy: resolveComponentGroupPolicy(params.ctx), - }); - if (!memberAllowed) { - return; - } - - const componentAllowed = await ensureComponentUserAllowed({ - entry, - interaction: params.interaction, - user, - replyOpts, - componentLabel: params.componentLabel, - unauthorizedReply, - allowNameMatching, - }); - if (!componentAllowed) { - return; - } - const commandAuthorized = resolveComponentCommandAuthorized({ - ctx: params.ctx, - interactionCtx, - channelConfig, - guildInfo, - allowNameMatching, - }); - - const consumed = resolveDiscordComponentEntry({ - id: parsed.componentId, - consume: !entry.reusable, - }); - if (!consumed) { - try { - await params.interaction.reply({ - content: "This component has expired.", - ephemeral: true, - }); - } catch { - // Interaction may have expired - } - return; - } - - if (consumed.kind === "modal-trigger") { - try { - await params.interaction.reply({ - content: "This form is no longer available.", - ephemeral: true, - }); - } catch { - // Interaction may have expired - } - return; - } - - const values = params.values ? mapSelectValues(consumed, params.values) : undefined; - if (consumed.callbackData) { - const pluginDispatch = await dispatchPluginDiscordInteractiveEvent({ - ctx: params.ctx, - interaction: params.interaction, - interactionCtx, - channelCtx, - isAuthorizedSender: commandAuthorized, - data: consumed.callbackData, - kind: consumed.kind === "select" ? "select" : "button", - values, - messageId: consumed.messageId ?? params.interaction.message?.id, - }); - if (pluginDispatch === "handled") { - return; - } - } - // Preserve explicit callback payloads for button fallbacks so Discord - // behaves like Telegram when buttons carry synthetic command text. Select - // fallbacks still need their chosen values in the synthesized event text. - const eventText = - (consumed.kind === "button" ? consumed.callbackData?.trim() : undefined) || - (await loadComponentsRuntime()).formatDiscordComponentEventText({ - kind: consumed.kind === "select" ? "select" : "button", - label: consumed.label, - values, - }); - - try { - await params.interaction.reply({ content: "✓", ...replyOpts }); - } catch (err) { - logError(`${params.label}: failed to acknowledge interaction: ${String(err)}`); - } - - await dispatchDiscordComponentEvent({ - ctx: params.ctx, - interaction: params.interaction, - interactionCtx, - channelCtx, - guildInfo, - eventText, - replyToId: consumed.messageId ?? params.interaction.message?.id, - routeOverrides: { - sessionKey: consumed.sessionKey, - agentId: consumed.agentId, - accountId: consumed.accountId, - }, - }); -} - -async function handleDiscordModalTrigger(params: { - ctx: AgentComponentContext; - interaction: ButtonInteraction; - data: ComponentData; - label: string; - interactionCtx?: ComponentInteractionContext; -}): Promise { - const parsed = parseDiscordComponentData( - params.data, - resolveInteractionCustomId(params.interaction), - ); - if (!parsed) { - logError(`${params.label}: failed to parse modal trigger data`); - try { - await params.interaction.reply({ - content: "This button is no longer valid.", - ephemeral: true, - }); - } catch { - // Interaction may have expired - } - return; - } - const entry = resolveDiscordComponentEntry({ id: parsed.componentId, consume: false }); - if (!entry || entry.kind !== "modal-trigger") { - try { - await params.interaction.reply({ - content: "This button has expired.", - ephemeral: true, - }); - } catch { - // Interaction may have expired - } - return; - } - - const modalId = entry.modalId ?? parsed.modalId; - if (!modalId) { - try { - await params.interaction.reply({ - content: "This form is no longer available.", - ephemeral: true, - }); - } catch { - // Interaction may have expired - } - return; - } - - const interactionCtx = - params.interactionCtx ?? - (await resolveInteractionContextWithDmAuth({ - ctx: params.ctx, - interaction: params.interaction, - label: params.label, - componentLabel: "form", - defer: false, - })); - if (!interactionCtx) { - return; - } - const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; - const guildInfo = resolveDiscordGuildEntry({ - guild: params.interaction.guild ?? undefined, - guildId: rawGuildId, - guildEntries: params.ctx.guildEntries, - }); - const channelCtx = resolveDiscordChannelContext(params.interaction); - const unauthorizedReply = "You are not authorized to use this form."; - const memberAllowed = await ensureGuildComponentMemberAllowed({ - interaction: params.interaction, - guildInfo, - channelId, - rawGuildId, - channelCtx, - memberRoleIds, - user, - replyOpts, - componentLabel: "form", - unauthorizedReply, - allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig), - groupPolicy: resolveComponentGroupPolicy(params.ctx), - }); - if (!memberAllowed) { - return; - } - - const componentAllowed = await ensureComponentUserAllowed({ - entry, - interaction: params.interaction, - user, - replyOpts, - componentLabel: "form", - unauthorizedReply, - allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig), - }); - if (!componentAllowed) { - return; - } - - const consumed = resolveDiscordComponentEntry({ - id: parsed.componentId, - consume: !entry.reusable, - }); - if (!consumed) { - try { - await params.interaction.reply({ - content: "This form has expired.", - ephemeral: true, - }); - } catch { - // Interaction may have expired - } - return; - } - - const resolvedModalId = consumed.modalId ?? modalId; - const modalEntry = resolveDiscordModalEntry({ id: resolvedModalId, consume: false }); - if (!modalEntry) { - try { - await params.interaction.reply({ - content: "This form has expired.", - ephemeral: true, - }); - } catch { - // Interaction may have expired - } - return; - } - - try { - await params.interaction.showModal( - (await loadComponentsRuntime()).createDiscordFormModal(modalEntry), - ); - } catch (err) { - logError(`${params.label}: failed to show modal: ${String(err)}`); - } -} - -class DiscordComponentModal extends Modal { - title = "OpenClaw form"; - customId = "__openclaw_discord_component_modal_wildcard__"; - components = []; - customIdParser = parseDiscordModalCustomIdForInteraction; - private ctx: AgentComponentContext; - - constructor(ctx: AgentComponentContext) { - super(); - this.ctx = ctx; - } - - async run(interaction: ModalInteraction, data: ComponentData): Promise { - const modalId = parseDiscordModalId(data, resolveInteractionCustomId(interaction)); - if (!modalId) { - logError("discord component modal: missing modal id"); - try { - await interaction.reply({ - content: "This form is no longer valid.", - ephemeral: true, - }); - } catch { - // Interaction may have expired - } - return; - } - - const modalEntry = resolveDiscordModalEntry({ id: modalId, consume: false }); - if (!modalEntry) { - try { - await interaction.reply({ - content: "This form has expired.", - ephemeral: true, - }); - } catch { - // Interaction may have expired - } - return; - } - - const interactionCtx = await resolveInteractionContextWithDmAuth({ - ctx: this.ctx, - interaction, - label: "discord component modal", - componentLabel: "form", - defer: false, - }); - if (!interactionCtx) { - return; - } - const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; - const guildInfo = resolveDiscordGuildEntry({ - guild: interaction.guild ?? undefined, - guildId: rawGuildId, - guildEntries: this.ctx.guildEntries, - }); - const channelCtx = resolveDiscordChannelContext(interaction); - const allowNameMatching = isDangerousNameMatchingEnabled(this.ctx.discordConfig); - const channelConfig = resolveDiscordChannelConfigWithFallback({ - guildInfo, - channelId, - channelName: channelCtx.channelName, - channelSlug: channelCtx.channelSlug, - parentId: channelCtx.parentId, - parentName: channelCtx.parentName, - parentSlug: channelCtx.parentSlug, - scope: channelCtx.isThread ? "thread" : "channel", - }); - const memberAllowed = await ensureGuildComponentMemberAllowed({ - interaction, - guildInfo, - channelId, - rawGuildId, - channelCtx, - memberRoleIds, - user, - replyOpts, - componentLabel: "form", - unauthorizedReply: "You are not authorized to use this form.", - allowNameMatching, - groupPolicy: resolveComponentGroupPolicy(this.ctx), - }); - if (!memberAllowed) { - return; - } - - const modalAllowed = await ensureComponentUserAllowed({ - entry: { - id: modalEntry.id, - kind: "button", - label: modalEntry.title, - allowedUsers: modalEntry.allowedUsers, - }, - interaction, - user, - replyOpts, - componentLabel: "form", - unauthorizedReply: "You are not authorized to use this form.", - allowNameMatching, - }); - if (!modalAllowed) { - return; - } - const commandAuthorized = resolveComponentCommandAuthorized({ - ctx: this.ctx, - interactionCtx, - channelConfig, - guildInfo, - allowNameMatching, - }); - - const consumed = resolveDiscordModalEntry({ - id: modalId, - consume: !modalEntry.reusable, - }); - if (!consumed) { - try { - await interaction.reply({ - content: "This form has expired.", - ephemeral: true, - }); - } catch { - // Interaction may have expired - } - return; - } - - if (consumed.callbackData) { - const fields = consumed.fields.map((field) => ({ - id: field.id, - name: field.name, - values: resolveModalFieldValues(field, interaction), - })); - const pluginDispatch = await dispatchPluginDiscordInteractiveEvent({ - ctx: this.ctx, - interaction, - interactionCtx, - channelCtx, - isAuthorizedSender: commandAuthorized, - data: consumed.callbackData, - kind: "modal", - fields, - messageId: consumed.messageId, - }); - if (pluginDispatch === "handled") { - return; - } - } - - try { - await interaction.acknowledge(); - } catch (err) { - logError(`discord component modal: failed to acknowledge: ${String(err)}`); - } - - const eventText = formatModalSubmissionText(consumed, interaction); - await dispatchDiscordComponentEvent({ - ctx: this.ctx, - interaction, - interactionCtx, - channelCtx, - guildInfo, - eventText, - replyToId: consumed.messageId, - routeOverrides: { - sessionKey: consumed.sessionKey, - agentId: consumed.agentId, - accountId: consumed.accountId, - }, - }); - } -} - -const discordComponentControlHandlers: DiscordComponentControlHandlers = { - handleComponentEvent: handleDiscordComponentEvent, - handleModalTrigger: handleDiscordModalTrigger, -}; - -export function createDiscordComponentButton(ctx: AgentComponentContext): Button { - return createDiscordComponentButtonControl(ctx, discordComponentControlHandlers); -} - -export function createDiscordComponentStringSelect(ctx: AgentComponentContext): StringSelectMenu { - return createDiscordComponentStringSelectControl(ctx, discordComponentControlHandlers); -} - -export function createDiscordComponentUserSelect(ctx: AgentComponentContext): UserSelectMenu { - return createDiscordComponentUserSelectControl(ctx, discordComponentControlHandlers); -} - -export function createDiscordComponentRoleSelect(ctx: AgentComponentContext): RoleSelectMenu { - return createDiscordComponentRoleSelectControl(ctx, discordComponentControlHandlers); -} - -export function createDiscordComponentMentionableSelect( - ctx: AgentComponentContext, -): MentionableSelectMenu { - return createDiscordComponentMentionableSelectControl(ctx, discordComponentControlHandlers); -} - -export function createDiscordComponentChannelSelect(ctx: AgentComponentContext): ChannelSelectMenu { - return createDiscordComponentChannelSelectControl(ctx, discordComponentControlHandlers); -} +export const createDiscordComponentButton = bindDiscordComponentControl( + createDiscordComponentButtonControl, +); +export const createDiscordComponentStringSelect = bindDiscordComponentControl( + createDiscordComponentStringSelectControl, +); +export const createDiscordComponentUserSelect = bindDiscordComponentControl( + createDiscordComponentUserSelectControl, +); +export const createDiscordComponentRoleSelect = bindDiscordComponentControl( + createDiscordComponentRoleSelectControl, +); +export const createDiscordComponentMentionableSelect = bindDiscordComponentControl( + createDiscordComponentMentionableSelectControl, +); +export const createDiscordComponentChannelSelect = bindDiscordComponentControl( + createDiscordComponentChannelSelectControl, +); export function createDiscordComponentModal(ctx: AgentComponentContext): Modal { return new DiscordComponentModal(ctx); diff --git a/extensions/discord/src/monitor/agent-components.wildcard-controls.ts b/extensions/discord/src/monitor/agent-components.wildcard-controls.ts index bd9a458b222..f5226269762 100644 --- a/extensions/discord/src/monitor/agent-components.wildcard-controls.ts +++ b/extensions/discord/src/monitor/agent-components.wildcard-controls.ts @@ -1,20 +1,10 @@ -import type { APIStringSelectComponent } from "discord-api-types/v10"; -import { ButtonStyle } from "discord-api-types/v10"; +import { ButtonStyle, ComponentType } from "discord-api-types/v10"; import { parseDiscordComponentCustomIdForInteraction } from "../component-custom-id.js"; import { + BaseMessageInteractiveComponent, Button, - ChannelSelectMenu, - MentionableSelectMenu, - RoleSelectMenu, - StringSelectMenu, - UserSelectMenu, type ButtonInteraction, - type ChannelSelectMenuInteraction, type ComponentData, - type MentionableSelectMenuInteraction, - type RoleSelectMenuInteraction, - type StringSelectMenuInteraction, - type UserSelectMenuInteraction, } from "../internal/discord.js"; import { parseDiscordComponentData, @@ -43,6 +33,79 @@ export type DiscordComponentControlHandlers = { }) => Promise; }; +type SelectControlSpec = { + type: ComponentType; + customId: string; + componentLabel: string; + label: string; +}; + +const SELECT_CONTROLS = { + string: { + type: ComponentType.StringSelect, + customId: "__openclaw_discord_component_string_select_wildcard__", + componentLabel: "select menu", + label: "discord component select", + }, + user: { + type: ComponentType.UserSelect, + customId: "__openclaw_discord_component_user_select_wildcard__", + componentLabel: "user select", + label: "discord component user select", + }, + role: { + type: ComponentType.RoleSelect, + customId: "__openclaw_discord_component_role_select_wildcard__", + componentLabel: "role select", + label: "discord component role select", + }, + mentionable: { + type: ComponentType.MentionableSelect, + customId: "__openclaw_discord_component_mentionable_select_wildcard__", + componentLabel: "mentionable select", + label: "discord component mentionable select", + }, + channel: { + type: ComponentType.ChannelSelect, + customId: "__openclaw_discord_component_channel_select_wildcard__", + componentLabel: "channel select", + label: "discord component channel select", + }, +} satisfies Record; + +class DiscordComponentSelectControl extends BaseMessageInteractiveComponent { + customIdParser = parseDiscordComponentCustomIdForInteraction; + readonly type: ComponentType; + readonly customId: string; + + constructor( + private spec: SelectControlSpec, + private ctx: AgentComponentContext, + private handlers: DiscordComponentControlHandlers, + ) { + super(); + this.type = spec.type; + this.customId = spec.customId; + } + + serialize(): unknown { + return this.type === ComponentType.StringSelect + ? { type: this.type, custom_id: this.customId, options: [] } + : { type: this.type, custom_id: this.customId }; + } + + async run(interaction: AgentComponentMessageInteraction, data: ComponentData): Promise { + await this.handlers.handleComponentEvent({ + ctx: this.ctx, + interaction, + data, + componentLabel: this.spec.componentLabel, + label: this.spec.label, + values: interaction.values ?? [], + }); + } +} + class DiscordComponentButton extends Button { label = "component"; customId = "__openclaw_discord_component_button_wildcard__"; @@ -88,120 +151,17 @@ class DiscordComponentButton extends Button { } } -class DiscordComponentStringSelect extends StringSelectMenu { - customId = "__openclaw_discord_component_string_select_wildcard__"; - options: APIStringSelectComponent["options"] = []; - customIdParser = parseDiscordComponentCustomIdForInteraction; - - constructor( - private ctx: AgentComponentContext, - private handlers: DiscordComponentControlHandlers, - ) { - super(); - } - - async run(interaction: StringSelectMenuInteraction, data: ComponentData): Promise { - await this.handlers.handleComponentEvent({ - ctx: this.ctx, - interaction, - data, - componentLabel: "select menu", - label: "discord component select", - values: interaction.values ?? [], - }); - } +function createSelectControl( + spec: SelectControlSpec, + ctx: AgentComponentContext, + handlers: DiscordComponentControlHandlers, +): BaseMessageInteractiveComponent { + return new DiscordComponentSelectControl(spec, ctx, handlers); } -class DiscordComponentUserSelect extends UserSelectMenu { - customId = "__openclaw_discord_component_user_select_wildcard__"; - customIdParser = parseDiscordComponentCustomIdForInteraction; - - constructor( - private ctx: AgentComponentContext, - private handlers: DiscordComponentControlHandlers, - ) { - super(); - } - - async run(interaction: UserSelectMenuInteraction, data: ComponentData): Promise { - await this.handlers.handleComponentEvent({ - ctx: this.ctx, - interaction, - data, - componentLabel: "user select", - label: "discord component user select", - values: interaction.values ?? [], - }); - } -} - -class DiscordComponentRoleSelect extends RoleSelectMenu { - customId = "__openclaw_discord_component_role_select_wildcard__"; - customIdParser = parseDiscordComponentCustomIdForInteraction; - - constructor( - private ctx: AgentComponentContext, - private handlers: DiscordComponentControlHandlers, - ) { - super(); - } - - async run(interaction: RoleSelectMenuInteraction, data: ComponentData): Promise { - await this.handlers.handleComponentEvent({ - ctx: this.ctx, - interaction, - data, - componentLabel: "role select", - label: "discord component role select", - values: interaction.values ?? [], - }); - } -} - -class DiscordComponentMentionableSelect extends MentionableSelectMenu { - customId = "__openclaw_discord_component_mentionable_select_wildcard__"; - customIdParser = parseDiscordComponentCustomIdForInteraction; - - constructor( - private ctx: AgentComponentContext, - private handlers: DiscordComponentControlHandlers, - ) { - super(); - } - - async run(interaction: MentionableSelectMenuInteraction, data: ComponentData): Promise { - await this.handlers.handleComponentEvent({ - ctx: this.ctx, - interaction, - data, - componentLabel: "mentionable select", - label: "discord component mentionable select", - values: interaction.values ?? [], - }); - } -} - -class DiscordComponentChannelSelect extends ChannelSelectMenu { - customId = "__openclaw_discord_component_channel_select_wildcard__"; - customIdParser = parseDiscordComponentCustomIdForInteraction; - - constructor( - private ctx: AgentComponentContext, - private handlers: DiscordComponentControlHandlers, - ) { - super(); - } - - async run(interaction: ChannelSelectMenuInteraction, data: ComponentData): Promise { - await this.handlers.handleComponentEvent({ - ctx: this.ctx, - interaction, - data, - componentLabel: "channel select", - label: "discord component channel select", - values: interaction.values ?? [], - }); - } +function bindSelectControl(spec: SelectControlSpec) { + return (ctx: AgentComponentContext, handlers: DiscordComponentControlHandlers) => + createSelectControl(spec, ctx, handlers); } export function createDiscordComponentButtonControl( @@ -211,37 +171,12 @@ export function createDiscordComponentButtonControl( return new DiscordComponentButton(ctx, handlers); } -export function createDiscordComponentStringSelectControl( - ctx: AgentComponentContext, - handlers: DiscordComponentControlHandlers, -): StringSelectMenu { - return new DiscordComponentStringSelect(ctx, handlers); -} - -export function createDiscordComponentUserSelectControl( - ctx: AgentComponentContext, - handlers: DiscordComponentControlHandlers, -): UserSelectMenu { - return new DiscordComponentUserSelect(ctx, handlers); -} - -export function createDiscordComponentRoleSelectControl( - ctx: AgentComponentContext, - handlers: DiscordComponentControlHandlers, -): RoleSelectMenu { - return new DiscordComponentRoleSelect(ctx, handlers); -} - -export function createDiscordComponentMentionableSelectControl( - ctx: AgentComponentContext, - handlers: DiscordComponentControlHandlers, -): MentionableSelectMenu { - return new DiscordComponentMentionableSelect(ctx, handlers); -} - -export function createDiscordComponentChannelSelectControl( - ctx: AgentComponentContext, - handlers: DiscordComponentControlHandlers, -): ChannelSelectMenu { - return new DiscordComponentChannelSelect(ctx, handlers); -} +export const createDiscordComponentStringSelectControl = bindSelectControl(SELECT_CONTROLS.string); +export const createDiscordComponentUserSelectControl = bindSelectControl(SELECT_CONTROLS.user); +export const createDiscordComponentRoleSelectControl = bindSelectControl(SELECT_CONTROLS.role); +export const createDiscordComponentMentionableSelectControl = bindSelectControl( + SELECT_CONTROLS.mentionable, +); +export const createDiscordComponentChannelSelectControl = bindSelectControl( + SELECT_CONTROLS.channel, +); diff --git a/extensions/discord/src/monitor/gateway-plugin.test.ts b/extensions/discord/src/monitor/gateway-plugin.test.ts index 7a8659dc1ac..9a5edfd73fa 100644 --- a/extensions/discord/src/monitor/gateway-plugin.test.ts +++ b/extensions/discord/src/monitor/gateway-plugin.test.ts @@ -1,10 +1,8 @@ import { EventEmitter } from "node:events"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import { DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT } from "./gateway-handle.js"; -const { baseConnectSpy, GatewayIntents, GatewayPlugin } = vi.hoisted(() => { - const baseConnectSpy = vi.fn<(resume: boolean) => void>(); - +const { GatewayIntents, GatewayPlugin } = vi.hoisted(() => { const GatewayIntents = { Guilds: 1 << 0, GuildMessages: 1 << 1, @@ -37,9 +35,9 @@ const { baseConnectSpy, GatewayIntents, GatewayPlugin } = vi.hoisted(() => { options: unknown; gatewayInfo: unknown; emitter = new TestEmitter(); - heartbeatInterval: ReturnType | undefined = undefined; - firstHeartbeatTimeout: ReturnType | undefined = undefined; isConnecting: boolean = false; + heartbeatInterval?: NodeJS.Timeout; + firstHeartbeatTimeout?: NodeJS.Timeout; ws?: unknown; constructor(options?: unknown) { @@ -48,12 +46,14 @@ const { baseConnectSpy, GatewayIntents, GatewayPlugin } = vi.hoisted(() => { async registerClient(_client: unknown): Promise {} - connect(resume = false): void { - baseConnectSpy(resume); + connect(_resume = false): void { + if (this.isConnecting) { + return; + } } } - return { baseConnectSpy, GatewayIntents, GatewayPlugin }; + return { GatewayIntents, GatewayPlugin }; }); vi.mock("../internal/gateway.js", () => ({ @@ -72,7 +72,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ danger: (value: string) => value, })); -describe("SafeGatewayPlugin.connect()", () => { +describe("createDiscordGatewayPlugin", () => { let createDiscordGatewayPlugin: typeof import("./gateway-plugin.js").createDiscordGatewayPlugin; let parseDiscordGatewayInfoBody: typeof import("./gateway-plugin.js").parseDiscordGatewayInfoBody; let resolveDiscordGatewayIntents: typeof import("./gateway-plugin.js").resolveDiscordGatewayIntents; @@ -87,10 +87,6 @@ describe("SafeGatewayPlugin.connect()", () => { } = await import("./gateway-plugin.js")); }); - beforeEach(() => { - baseConnectSpy.mockClear(); - }); - function createPlugin( testing?: NonNullable[0]["__testing"]>, discordConfig: Parameters[0]["discordConfig"] = {}, @@ -201,25 +197,6 @@ describe("SafeGatewayPlugin.connect()", () => { expect((options?.intents ?? 0) & GatewayIntents.GuildVoiceStates).toBe(0); }); - it("clears stale heartbeatInterval before delegating to super when isConnecting=true", () => { - const plugin = createPlugin(); - - const staleInterval = setInterval(() => {}, 99_999); - try { - plugin.heartbeatInterval = staleInterval; - - // isConnecting is private on GatewayPlugin — cast required. - (plugin as unknown as { isConnecting: boolean }).isConnecting = true; - - plugin.connect(false); - - expect(plugin.heartbeatInterval).toBeUndefined(); - expect(baseConnectSpy).toHaveBeenCalledWith(false); - } finally { - clearInterval(staleInterval); - } - }); - it("leaves autoInteractions disabled so OpenClaw owns interaction handoff", () => { const plugin = createPlugin(); @@ -244,23 +221,21 @@ describe("SafeGatewayPlugin.connect()", () => { ).toBeUndefined(); }); - it("clears stale firstHeartbeatTimeout before delegating to super when isConnecting=true", () => { - const plugin = createPlugin(); + it("clears stale heartbeat timers before reconnecting", () => { + const plugin = createPlugin() as unknown as { + connect: (resume?: boolean) => void; + isConnecting: boolean; + heartbeatInterval?: NodeJS.Timeout; + firstHeartbeatTimeout?: NodeJS.Timeout; + }; + plugin.isConnecting = true; + plugin.heartbeatInterval = setInterval(() => {}, 1_000); + plugin.firstHeartbeatTimeout = setTimeout(() => {}, 1_000); - const staleTimeout = setTimeout(() => {}, 99_999); - try { - plugin.firstHeartbeatTimeout = staleTimeout; + plugin.connect(true); - // isConnecting is private on GatewayPlugin — cast required. - (plugin as unknown as { isConnecting: boolean }).isConnecting = true; - - plugin.connect(false); - - expect(plugin.firstHeartbeatTimeout).toBeUndefined(); - expect(baseConnectSpy).toHaveBeenCalledWith(false); - } finally { - clearTimeout(staleTimeout); - } + expect(plugin.heartbeatInterval).toBeUndefined(); + expect(plugin.firstHeartbeatTimeout).toBeUndefined(); }); it("emits transport activity for current gateway socket messages", () => { diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index 03f45c1fcb1..9c8d3f6b6f9 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -33,15 +33,26 @@ type DiscordGatewayWebSocketCtor = new ( options?: { agent?: unknown; handshakeTimeout?: number }, ) => ws.WebSocket; const registrationPromises = new WeakMap>(); +type DiscordGatewayClient = Parameters[0]; +type GatewayPluginTestingOptions = { + registerClient?: ( + plugin: discordGateway.GatewayPlugin, + client: DiscordGatewayClient, + ) => Promise; + webSocketCtor?: DiscordGatewayWebSocketCtor; +}; +type CreateDiscordGatewayPluginTestingOptions = GatewayPluginTestingOptions & { + HttpsProxyAgentCtor?: typeof httpsProxyAgent.HttpsProxyAgent; +}; type DiscordGatewayRegistrationState = { - client?: Parameters[0]; + client?: DiscordGatewayClient; ws?: unknown; isConnecting?: boolean; }; function assignGatewayClient( plugin: discordGateway.GatewayPlugin, - client: Parameters[0], + client: DiscordGatewayClient, ): void { (plugin as unknown as DiscordGatewayRegistrationState).client = client; } @@ -90,15 +101,9 @@ function createGatewayPlugin(params: { fetchInit?: DiscordGatewayFetchInit; wsAgent?: InstanceType>; runtime?: RuntimeEnv; - testing?: { - registerClient?: ( - plugin: discordGateway.GatewayPlugin, - client: Parameters[0], - ) => Promise; - webSocketCtor?: DiscordGatewayWebSocketCtor; - }; + testing?: GatewayPluginTestingOptions; }): discordGateway.GatewayPlugin { - class SafeGatewayPlugin extends discordGateway.GatewayPlugin { + class OpenClawGatewayPlugin extends discordGateway.GatewayPlugin { private gatewayInfoUsedFallback = false; constructor() { @@ -106,8 +111,8 @@ function createGatewayPlugin(params: { } public override connect(resume = false): void { - // Guard against stale heartbeat timers from an early reconnect race - // (openclaw/openclaw#65009, #64011, #63387). + // Base connect returns early while isConnecting; clear stale gateway + // timers first so early reconnect races cannot keep old heartbeats alive. if (this.heartbeatInterval !== undefined) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = undefined; @@ -119,7 +124,7 @@ function createGatewayPlugin(params: { super.connect(resume); } - override registerClient(client: Parameters[0]) { + override registerClient(client: DiscordGatewayClient) { const registration = this.registerClientInternal(client); // Client construction starts plugin hooks without awaiting them. Mark the // promise handled immediately, then let startup await the original promise. @@ -128,9 +133,7 @@ function createGatewayPlugin(params: { return registration; } - private async registerClientInternal( - client: Parameters[0], - ) { + private async registerClientInternal(client: DiscordGatewayClient) { // Publish the client reference before the metadata fetch can yield, so an external // connect()->identify() cannot silently drop IDENTIFY (#52372). assignGatewayClient(this, client); @@ -231,7 +234,21 @@ function createGatewayPlugin(params: { } } - return new SafeGatewayPlugin(); + return new OpenClawGatewayPlugin(); +} + +function createDiscordGatewayMetadataFetch(debugCaptureEnabled: boolean): DiscordGatewayFetch { + return (input, init) => + fetchDiscordGatewayMetadataDirect( + input, + init, + debugCaptureEnabled + ? false + : { + flowId: randomUUID(), + meta: { subsystem: "discord-gateway-metadata" }, + }, + ); } export function waitForDiscordGatewayPluginRegistration( @@ -246,14 +263,7 @@ export function waitForDiscordGatewayPluginRegistration( export function createDiscordGatewayPlugin(params: { discordConfig: DiscordAccountConfig; runtime: RuntimeEnv; - __testing?: { - HttpsProxyAgentCtor?: typeof httpsProxyAgent.HttpsProxyAgent; - webSocketCtor?: DiscordGatewayWebSocketCtor; - registerClient?: ( - plugin: discordGateway.GatewayPlugin, - client: Parameters[0], - ) => Promise; - }; + __testing?: CreateDiscordGatewayPluginTestingOptions; }): discordGateway.GatewayPlugin { const intents = resolveDiscordGatewayIntents({ intentsConfig: params.discordConfig?.intents, @@ -265,84 +275,33 @@ export function createDiscordGatewayPlugin(params: { configuredTimeoutMs: params.discordConfig?.gatewayInfoTimeoutMs, env: process.env, }); - const options = { - reconnect: { maxAttempts: 50 }, - intents, - // OpenClaw registers its own async interaction listener. - autoInteractions: false, - }; + let fetchImpl = createDiscordGatewayMetadataFetch(debugProxySettings.enabled); + let wsAgent: InstanceType> | undefined; - if (!proxy) { - return createGatewayPlugin({ - options, - gatewayInfoTimeoutMs, - fetchImpl: async (input, init) => { - return await fetchDiscordGatewayMetadataDirect( - input, - init, - debugProxySettings.enabled - ? false - : { - flowId: randomUUID(), - meta: { subsystem: "discord-gateway-metadata" }, - }, - ); - }, - runtime: params.runtime, - testing: params.__testing - ? { - registerClient: params.__testing.registerClient, - webSocketCtor: params.__testing.webSocketCtor, - } - : undefined, - }); + if (proxy) { + try { + validateDiscordProxyUrl(proxy); + const HttpsProxyAgentCtor = + params.__testing?.HttpsProxyAgentCtor ?? httpsProxyAgent.HttpsProxyAgent; + wsAgent = new HttpsProxyAgentCtor(proxy); + params.runtime.log?.("discord: gateway proxy enabled"); + } catch (err) { + params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`)); + fetchImpl = (input, init) => fetchDiscordGatewayMetadataDirect(input, init, false); + } } - try { - validateDiscordProxyUrl(proxy); - const HttpsProxyAgentCtor = - params.__testing?.HttpsProxyAgentCtor ?? httpsProxyAgent.HttpsProxyAgent; - const wsAgent = new HttpsProxyAgentCtor(proxy); - - params.runtime.log?.("discord: gateway proxy enabled"); - - return createGatewayPlugin({ - options, - gatewayInfoTimeoutMs, - fetchImpl: async (input, init) => { - return await fetchDiscordGatewayMetadataDirect( - input, - init, - debugProxySettings.enabled - ? false - : { - flowId: randomUUID(), - meta: { subsystem: "discord-gateway-metadata" }, - }, - ); - }, - wsAgent, - runtime: params.runtime, - testing: params.__testing - ? { - registerClient: params.__testing.registerClient, - webSocketCtor: params.__testing.webSocketCtor, - } - : undefined, - }); - } catch (err) { - params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`)); - return createGatewayPlugin({ - options, - gatewayInfoTimeoutMs, - fetchImpl: (input, init) => fetchDiscordGatewayMetadataDirect(input, init, false), - runtime: params.runtime, - testing: params.__testing - ? { - registerClient: params.__testing.registerClient, - webSocketCtor: params.__testing.webSocketCtor, - } - : undefined, - }); - } + return createGatewayPlugin({ + options: { + reconnect: { maxAttempts: 50 }, + intents, + // OpenClaw registers its own async interaction listener. + autoInteractions: false, + }, + gatewayInfoTimeoutMs, + fetchImpl, + runtime: params.runtime, + testing: params.__testing, + ...(wsAgent ? { wsAgent } : {}), + }); }