mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:50:43 +00:00
refactor(discord): simplify internal component wiring
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,10 +4,8 @@ export {
|
||||
buildDiscordComponentCustomId,
|
||||
buildDiscordModalCustomId,
|
||||
parseDiscordComponentCustomId,
|
||||
parseDiscordComponentCustomIdForCarbon,
|
||||
parseDiscordComponentCustomIdForInteraction,
|
||||
parseDiscordModalCustomId,
|
||||
parseDiscordModalCustomIdForCarbon,
|
||||
parseDiscordModalCustomIdForInteraction,
|
||||
} from "./component-custom-id.js";
|
||||
export {
|
||||
|
||||
356
extensions/discord/src/monitor/agent-components.handlers.ts
Normal file
356
extensions/discord/src/monitor/agent-components.handlers.ts
Normal 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,
|
||||
};
|
||||
194
extensions/discord/src/monitor/agent-components.modal.ts
Normal file
194
extensions/discord/src/monitor/agent-components.modal.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
12
extensions/discord/src/monitor/agent-components.policy.ts
Normal file
12
extensions/discord/src/monitor/agent-components.policy.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user