refactor(discord): simplify internal component wiring

This commit is contained in:
Peter Steinberger
2026-04-29 15:37:04 +01:00
parent 542821cd1e
commit 587b537b47
11 changed files with 767 additions and 1009 deletions

View File

@@ -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";

View File

@@ -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");
});
});

View File

@@ -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;

View File

@@ -4,10 +4,8 @@ export {
buildDiscordComponentCustomId,
buildDiscordModalCustomId,
parseDiscordComponentCustomId,
parseDiscordComponentCustomIdForCarbon,
parseDiscordComponentCustomIdForInteraction,
parseDiscordModalCustomId,
parseDiscordModalCustomIdForCarbon,
parseDiscordModalCustomIdForInteraction,
} from "./component-custom-id.js";
export {

View File

@@ -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<typeof import("../components.js")> | 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<void> {
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<void> {
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,
};

View File

@@ -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<void> {
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,
},
});
}
}

View File

@@ -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;
}

View File

@@ -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<typeof import("../components.js")> | undefined;
async function loadComponentsRuntime() {
componentsRuntimePromise ??= import("../components.js");
return await componentsRuntimePromise;
function bindDiscordComponentControl<T extends BaseMessageInteractiveComponent>(
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<void> {
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<void> {
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<void> {
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);

View File

@@ -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<void>;
};
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<string, SelectControlSpec>;
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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,
);

View File

@@ -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<typeof setInterval> | undefined = undefined;
firstHeartbeatTimeout: ReturnType<typeof setTimeout> | 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<void> {}
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<Parameters<typeof createDiscordGatewayPlugin>[0]["__testing"]>,
discordConfig: Parameters<typeof createDiscordGatewayPlugin>[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", () => {

View File

@@ -33,15 +33,26 @@ type DiscordGatewayWebSocketCtor = new (
options?: { agent?: unknown; handshakeTimeout?: number },
) => ws.WebSocket;
const registrationPromises = new WeakMap<discordGateway.GatewayPlugin, Promise<void>>();
type DiscordGatewayClient = Parameters<discordGateway.GatewayPlugin["registerClient"]>[0];
type GatewayPluginTestingOptions = {
registerClient?: (
plugin: discordGateway.GatewayPlugin,
client: DiscordGatewayClient,
) => Promise<void>;
webSocketCtor?: DiscordGatewayWebSocketCtor;
};
type CreateDiscordGatewayPluginTestingOptions = GatewayPluginTestingOptions & {
HttpsProxyAgentCtor?: typeof httpsProxyAgent.HttpsProxyAgent;
};
type DiscordGatewayRegistrationState = {
client?: Parameters<discordGateway.GatewayPlugin["registerClient"]>[0];
client?: DiscordGatewayClient;
ws?: unknown;
isConnecting?: boolean;
};
function assignGatewayClient(
plugin: discordGateway.GatewayPlugin,
client: Parameters<discordGateway.GatewayPlugin["registerClient"]>[0],
client: DiscordGatewayClient,
): void {
(plugin as unknown as DiscordGatewayRegistrationState).client = client;
}
@@ -90,15 +101,9 @@ function createGatewayPlugin(params: {
fetchInit?: DiscordGatewayFetchInit;
wsAgent?: InstanceType<typeof httpsProxyAgent.HttpsProxyAgent<string>>;
runtime?: RuntimeEnv;
testing?: {
registerClient?: (
plugin: discordGateway.GatewayPlugin,
client: Parameters<discordGateway.GatewayPlugin["registerClient"]>[0],
) => Promise<void>;
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<discordGateway.GatewayPlugin["registerClient"]>[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<discordGateway.GatewayPlugin["registerClient"]>[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<discordGateway.GatewayPlugin["registerClient"]>[0],
) => Promise<void>;
};
__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<typeof httpsProxyAgent.HttpsProxyAgent<string>> | 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<string>(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<string>(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 } : {}),
});
}