refactor(discord): simplify gateway and component wiring

This commit is contained in:
Peter Steinberger
2026-04-29 16:14:49 +01:00
parent a0300378d6
commit 8935dd154a
18 changed files with 428 additions and 291 deletions

View File

@@ -0,0 +1,15 @@
export { handleDiscordAction } from "./src/actions/runtime.js";
export {
isDiscordModerationAction,
readDiscordModerationCommand,
requiredGuildPermissionForModerationAction,
type DiscordModerationAction,
type DiscordModerationCommand,
} from "./src/actions/runtime.moderation-shared.js";
export {
readDiscordChannelCreateParams,
readDiscordChannelEditParams,
readDiscordChannelMoveParams,
readDiscordParentIdParam,
} from "./src/actions/runtime.shared.js";
export { discordMessageActions } from "./src/channel-actions.js";

View File

@@ -0,0 +1,22 @@
export { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./src/audit.js";
export {
listDiscordDirectoryGroupsLive,
listDiscordDirectoryPeersLive,
} from "./src/directory-live.js";
export {
fetchDiscordApplicationId,
fetchDiscordApplicationSummary,
parseApplicationIdFromToken,
probeDiscord,
resolveDiscordPrivilegedIntentsFromFlags,
type DiscordApplicationSummary,
type DiscordPrivilegedIntentsSummary,
type DiscordPrivilegedIntentStatus,
type DiscordProbe,
} from "./src/probe.js";
export {
resolveDiscordChannelAllowlist,
type DiscordChannelResolution,
} from "./src/resolve-channels.js";
export { resolveDiscordUserAllowlist, type DiscordUserResolution } from "./src/resolve-users.js";
export { setDiscordRuntime } from "./src/runtime.js";

View File

@@ -0,0 +1,50 @@
export {
allowListMatches,
buildDiscordMediaPayload,
createDiscordMessageHandler,
createDiscordNativeCommand,
isDiscordGroupAllowedByPolicy,
monitorDiscordProvider,
normalizeDiscordAllowList,
normalizeDiscordSlug,
registerDiscordListener,
resolveDiscordChannelConfig,
resolveDiscordChannelConfigWithFallback,
resolveDiscordCommandAuthorized,
resolveDiscordGuildEntry,
resolveDiscordReplyTarget,
resolveDiscordShouldRequireMention,
resolveGroupDmAllow,
sanitizeDiscordThreadName,
shouldEmitDiscordReactionNotification,
type DiscordAllowList,
type DiscordChannelConfigResolved,
type DiscordGuildEntryResolved,
type DiscordMessageEvent,
type DiscordMessageHandler,
type MonitorDiscordOpts,
} from "./src/monitor.js";
export {
createDiscordGatewayPlugin,
resolveDiscordGatewayIntents,
waitForDiscordGatewayPluginRegistration,
} from "./src/monitor/gateway-plugin.js";
export {
clearGateways,
getGateway,
registerGateway,
unregisterGateway,
} from "./src/monitor/gateway-registry.js";
export {
clearPresences,
getPresence,
presenceCacheSize,
setPresence,
} from "./src/monitor/presence-cache.js";
export {
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS,
DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS,
DISCORD_DEFAULT_LISTENER_TIMEOUT_MS,
mergeAbortSignals,
} from "./src/monitor/timeouts.js";

View File

@@ -0,0 +1,79 @@
export {
resolveDiscordOutboundSessionRoute,
type ResolveDiscordOutboundSessionRouteParams,
} from "./src/outbound-session-route.js";
export {
addRoleDiscord,
banMemberDiscord,
createChannelDiscord,
createScheduledEventDiscord,
createThreadDiscord,
deleteChannelDiscord,
deleteMessageDiscord,
DiscordSendError,
editChannelDiscord,
editMessageDiscord,
fetchChannelInfoDiscord,
fetchChannelPermissionsDiscord,
fetchMemberGuildPermissionsDiscord,
fetchMemberInfoDiscord,
fetchMessageDiscord,
fetchReactionsDiscord,
fetchRoleInfoDiscord,
fetchVoiceStatusDiscord,
hasAllGuildPermissionsDiscord,
hasAnyGuildPermissionDiscord,
kickMemberDiscord,
listGuildChannelsDiscord,
listGuildEmojisDiscord,
listPinsDiscord,
listScheduledEventsDiscord,
listThreadsDiscord,
moveChannelDiscord,
pinMessageDiscord,
reactMessageDiscord,
readMessagesDiscord,
removeChannelPermissionDiscord,
removeOwnReactionsDiscord,
removeReactionDiscord,
removeRoleDiscord,
resolveEventCoverImage,
searchMessagesDiscord,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
sendTypingDiscord,
sendVoiceMessageDiscord,
sendWebhookMessageDiscord,
setChannelPermissionDiscord,
timeoutMemberDiscord,
unpinMessageDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
type DiscordChannelCreate,
type DiscordChannelEdit,
type DiscordChannelMove,
type DiscordChannelPermissionSet,
type DiscordEmojiUpload,
type DiscordMessageEdit,
type DiscordMessageQuery,
type DiscordModerationTarget,
type DiscordPermissionsSummary,
type DiscordReactionRuntimeContext,
type DiscordReactionSummary,
type DiscordReactionUser,
type DiscordReactOpts,
type DiscordRoleChange,
type DiscordRuntimeAccountContext,
type DiscordSearchQuery,
type DiscordSendResult,
type DiscordStickerUpload,
type DiscordThreadCreate,
type DiscordThreadList,
type DiscordTimeoutTarget,
} from "./src/send.js";
export {
editDiscordComponentMessage,
registerBuiltDiscordComponentMessage,
sendDiscordComponentMessage,
} from "./src/send.components.js";

View File

@@ -0,0 +1,30 @@
export {
__testing,
autoBindSpawnedDiscordSubagent,
createNoopThreadBindingManager,
createThreadBindingManager,
formatThreadBindingDurationLabel,
getThreadBindingManager,
isRecentlyUnboundThreadWebhookMessage,
listThreadBindingsBySessionKey,
listThreadBindingsForAccount,
reconcileAcpThreadBindingsOnStartup,
resolveDiscordThreadBindingIdleTimeoutMs,
resolveDiscordThreadBindingMaxAgeMs,
resolveThreadBindingIdleTimeoutMs,
resolveThreadBindingInactivityExpiresAt,
resolveThreadBindingIntroText,
resolveThreadBindingMaxAgeExpiresAt,
resolveThreadBindingMaxAgeMs,
resolveThreadBindingPersona,
resolveThreadBindingPersonaFromRecord,
resolveThreadBindingsEnabled,
resolveThreadBindingThreadName,
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
unbindThreadBindingsBySessionKey,
type AcpThreadBindingReconciliationResult,
type ThreadBindingManager,
type ThreadBindingRecord,
type ThreadBindingTargetKind,
} from "./src/monitor/thread-bindings.js";

View File

@@ -1,124 +1,80 @@
export { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./src/audit.js";
export { handleDiscordAction } from "./src/actions/runtime.js";
export {
discordMessageActions,
handleDiscordAction,
isDiscordModerationAction,
readDiscordModerationCommand,
requiredGuildPermissionForModerationAction,
type DiscordModerationAction,
type DiscordModerationCommand,
} from "./src/actions/runtime.moderation-shared.js";
export {
readDiscordChannelCreateParams,
readDiscordChannelEditParams,
readDiscordChannelMoveParams,
readDiscordModerationCommand,
readDiscordParentIdParam,
} from "./src/actions/runtime.shared.js";
export { discordMessageActions } from "./src/channel-actions.js";
requiredGuildPermissionForModerationAction,
type DiscordModerationAction,
type DiscordModerationCommand,
} from "./runtime-api.actions.js";
export {
auditDiscordChannelPermissions,
collectDiscordAuditChannelIds,
fetchDiscordApplicationId,
fetchDiscordApplicationSummary,
listDiscordDirectoryGroupsLive,
listDiscordDirectoryPeersLive,
} from "./src/directory-live.js";
parseApplicationIdFromToken,
probeDiscord,
resolveDiscordChannelAllowlist,
resolveDiscordPrivilegedIntentsFromFlags,
resolveDiscordUserAllowlist,
setDiscordRuntime,
type DiscordApplicationSummary,
type DiscordChannelResolution,
type DiscordPrivilegedIntentsSummary,
type DiscordPrivilegedIntentStatus,
type DiscordProbe,
type DiscordUserResolution,
} from "./runtime-api.lookup.js";
export {
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS,
DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS,
DISCORD_DEFAULT_LISTENER_TIMEOUT_MS,
allowListMatches,
buildDiscordMediaPayload,
clearGateways,
clearPresences,
createDiscordGatewayPlugin,
createDiscordMessageHandler,
createDiscordNativeCommand,
getGateway,
getPresence,
isDiscordGroupAllowedByPolicy,
mergeAbortSignals,
monitorDiscordProvider,
normalizeDiscordAllowList,
normalizeDiscordSlug,
presenceCacheSize,
registerDiscordListener,
registerGateway,
resolveDiscordChannelConfig,
resolveDiscordChannelConfigWithFallback,
resolveDiscordCommandAuthorized,
resolveDiscordGatewayIntents,
resolveDiscordGuildEntry,
resolveDiscordReplyTarget,
resolveDiscordShouldRequireMention,
resolveGroupDmAllow,
sanitizeDiscordThreadName,
setPresence,
shouldEmitDiscordReactionNotification,
unregisterGateway,
waitForDiscordGatewayPluginRegistration,
type DiscordAllowList,
type DiscordChannelConfigResolved,
type DiscordGuildEntryResolved,
type DiscordMessageEvent,
type DiscordMessageHandler,
type MonitorDiscordOpts,
} from "./src/monitor.js";
export {
createDiscordGatewayPlugin,
resolveDiscordGatewayIntents,
waitForDiscordGatewayPluginRegistration,
} from "./src/monitor/gateway-plugin.js";
export {
clearGateways,
getGateway,
registerGateway,
unregisterGateway,
} from "./src/monitor/gateway-registry.js";
export {
clearPresences,
getPresence,
presenceCacheSize,
setPresence,
} from "./src/monitor/presence-cache.js";
export {
__testing,
autoBindSpawnedDiscordSubagent,
createNoopThreadBindingManager,
createThreadBindingManager,
formatThreadBindingDurationLabel,
getThreadBindingManager,
isRecentlyUnboundThreadWebhookMessage,
listThreadBindingsBySessionKey,
listThreadBindingsForAccount,
reconcileAcpThreadBindingsOnStartup,
resolveDiscordThreadBindingIdleTimeoutMs,
resolveDiscordThreadBindingMaxAgeMs,
resolveThreadBindingIdleTimeoutMs,
resolveThreadBindingInactivityExpiresAt,
resolveThreadBindingIntroText,
resolveThreadBindingMaxAgeExpiresAt,
resolveThreadBindingMaxAgeMs,
resolveThreadBindingPersona,
resolveThreadBindingPersonaFromRecord,
resolveThreadBindingsEnabled,
resolveThreadBindingThreadName,
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
unbindThreadBindingsBySessionKey,
type AcpThreadBindingReconciliationResult,
type ThreadBindingManager,
type ThreadBindingRecord,
type ThreadBindingTargetKind,
} from "./src/monitor/thread-bindings.js";
export {
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS,
DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS,
DISCORD_DEFAULT_LISTENER_TIMEOUT_MS,
mergeAbortSignals,
} from "./src/monitor/timeouts.js";
export {
fetchDiscordApplicationId,
fetchDiscordApplicationSummary,
parseApplicationIdFromToken,
probeDiscord,
resolveDiscordPrivilegedIntentsFromFlags,
type DiscordApplicationSummary,
type DiscordPrivilegedIntentsSummary,
type DiscordPrivilegedIntentStatus,
type DiscordProbe,
} from "./src/probe.js";
export {
resolveDiscordChannelAllowlist,
type DiscordChannelResolution,
} from "./src/resolve-channels.js";
export { resolveDiscordUserAllowlist, type DiscordUserResolution } from "./src/resolve-users.js";
export {
resolveDiscordOutboundSessionRoute,
type ResolveDiscordOutboundSessionRouteParams,
} from "./src/outbound-session-route.js";
} from "./runtime-api.monitor.js";
export {
DiscordSendError,
addRoleDiscord,
banMemberDiscord,
createChannelDiscord,
@@ -126,8 +82,8 @@ export {
createThreadDiscord,
deleteChannelDiscord,
deleteMessageDiscord,
DiscordSendError,
editChannelDiscord,
editDiscordComponentMessage,
editMessageDiscord,
fetchChannelInfoDiscord,
fetchChannelPermissionsDiscord,
@@ -149,12 +105,15 @@ export {
pinMessageDiscord,
reactMessageDiscord,
readMessagesDiscord,
registerBuiltDiscordComponentMessage,
removeChannelPermissionDiscord,
removeOwnReactionsDiscord,
removeReactionDiscord,
removeRoleDiscord,
resolveDiscordOutboundSessionRoute,
resolveEventCoverImage,
searchMessagesDiscord,
sendDiscordComponentMessage,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
@@ -187,10 +146,35 @@ export {
type DiscordThreadCreate,
type DiscordThreadList,
type DiscordTimeoutTarget,
} from "./src/send.js";
type ResolveDiscordOutboundSessionRouteParams,
} from "./runtime-api.send.js";
export {
editDiscordComponentMessage,
registerBuiltDiscordComponentMessage,
sendDiscordComponentMessage,
} from "./src/send.components.js";
export { setDiscordRuntime } from "./src/runtime.js";
__testing,
autoBindSpawnedDiscordSubagent,
createNoopThreadBindingManager,
createThreadBindingManager,
formatThreadBindingDurationLabel,
getThreadBindingManager,
isRecentlyUnboundThreadWebhookMessage,
listThreadBindingsBySessionKey,
listThreadBindingsForAccount,
reconcileAcpThreadBindingsOnStartup,
resolveDiscordThreadBindingIdleTimeoutMs,
resolveDiscordThreadBindingMaxAgeMs,
resolveThreadBindingIdleTimeoutMs,
resolveThreadBindingInactivityExpiresAt,
resolveThreadBindingIntroText,
resolveThreadBindingMaxAgeExpiresAt,
resolveThreadBindingMaxAgeMs,
resolveThreadBindingPersona,
resolveThreadBindingPersonaFromRecord,
resolveThreadBindingsEnabled,
resolveThreadBindingThreadName,
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
unbindThreadBindingsBySessionKey,
type AcpThreadBindingReconciliationResult,
type ThreadBindingManager,
type ThreadBindingRecord,
type ThreadBindingTargetKind,
} from "./runtime-api.threads.js";

View File

@@ -411,6 +411,22 @@ describe("GatewayPlugin", () => {
).not.toThrow();
});
it("clears stale heartbeat timers before early reconnect exits", () => {
vi.useFakeTimers();
const gateway = new GatewayPlugin({
autoInteractions: false,
url: "wss://gateway.example.test",
});
(gateway as unknown as { isConnecting: boolean }).isConnecting = true;
gateway.heartbeatInterval = setInterval(() => {}, 1_000);
gateway.firstHeartbeatTimeout = setTimeout(() => {}, 1_000);
gateway.connect(true);
expect(gateway.heartbeatInterval).toBeUndefined();
expect(gateway.firstHeartbeatTimeout).toBeUndefined();
});
it("spaces identify sends by gateway max concurrency bucket", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);

View File

@@ -158,11 +158,11 @@ export class GatewayPlugin extends Plugin {
}
connect(resume = false): void {
this.stopReconnectTimer();
this.stopHeartbeat();
if (this.isConnecting) {
return;
}
this.stopReconnectTimer();
this.stopHeartbeat();
this.shouldReconnect = true;
this.lastHeartbeatAck = true;
this.ws?.close(1000, "Reconnecting");

View File

@@ -386,6 +386,85 @@ export async function resolveInteractionContextWithDmAuth(params: {
return interactionCtx;
}
export async function resolveAuthorizedComponentInteraction(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
label: string;
componentLabel: string;
unauthorizedReply: string;
defer?: boolean;
}) {
const interactionCtx = await resolveInteractionContextWithDmAuth({
ctx: params.ctx,
interaction: params.interaction,
label: params.label,
componentLabel: params.componentLabel,
defer: params.defer,
});
if (!interactionCtx) {
return null;
}
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 memberAllowed = await ensureGuildComponentMemberAllowed({
interaction: params.interaction,
guildInfo,
channelId,
rawGuildId,
channelCtx,
memberRoleIds,
user,
replyOpts,
componentLabel: params.componentLabel,
unauthorizedReply: params.unauthorizedReply,
allowNameMatching,
groupPolicy: resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: params.ctx.cfg.channels?.discord !== undefined,
groupPolicy: params.ctx.discordConfig?.groupPolicy,
defaultGroupPolicy: params.ctx.cfg.channels?.defaults?.groupPolicy,
}).groupPolicy,
});
if (!memberAllowed) {
return null;
}
const commandAuthorized = resolveComponentCommandAuthorized({
ctx: params.ctx,
interactionCtx,
channelConfig,
guildInfo,
allowNameMatching,
});
return {
interactionCtx,
channelCtx,
guildInfo,
channelConfig,
allowNameMatching,
commandAuthorized,
user,
replyOpts,
};
}
export function resolveComponentCommandAuthorized(params: {
ctx: AgentComponentContext;
interactionCtx: ComponentInteractionContext;

View File

@@ -23,6 +23,7 @@ export {
ensureAgentComponentInteractionAllowed,
ensureComponentUserAllowed,
ensureGuildComponentMemberAllowed,
resolveAuthorizedComponentInteraction,
resolveComponentCommandAuthorized,
resolveInteractionContextWithDmAuth,
} from "./agent-components-auth.js";

View File

@@ -1,4 +1,3 @@
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";
@@ -6,20 +5,14 @@ import {
type AgentComponentContext,
type AgentComponentMessageInteraction,
ensureComponentUserAllowed,
ensureGuildComponentMemberAllowed,
mapSelectValues,
parseDiscordComponentData,
resolveComponentCommandAuthorized,
resolveDiscordChannelContext,
resolveInteractionContextWithDmAuth,
resolveAuthorizedComponentInteraction,
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;
@@ -66,52 +59,27 @@ async function handleDiscordComponentEvent(params: {
return;
}
const interactionCtx = await resolveInteractionContextWithDmAuth({
const unauthorizedReply = `You are not authorized to use this ${params.componentLabel}.`;
const authorized = await resolveAuthorizedComponentInteraction({
ctx: params.ctx,
interaction: params.interaction,
label: params.label,
componentLabel: params.componentLabel,
unauthorizedReply,
defer: false,
});
if (!interactionCtx) {
if (!authorized) {
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,
const {
interactionCtx,
channelCtx,
memberRoleIds,
guildInfo,
allowNameMatching,
commandAuthorized,
user,
replyOpts,
componentLabel: params.componentLabel,
unauthorizedReply,
allowNameMatching,
groupPolicy: resolveComponentGroupPolicy(params.ctx),
});
if (!memberAllowed) {
return;
}
} = authorized;
const componentAllowed = await ensureComponentUserAllowed({
entry,
@@ -125,14 +93,6 @@ async function handleDiscordComponentEvent(params: {
if (!componentAllowed) {
return;
}
const commandAuthorized = resolveComponentCommandAuthorized({
ctx: params.ctx,
interactionCtx,
channelConfig,
guildInfo,
allowNameMatching,
});
const consumed = resolveDiscordComponentEntry({
id: parsed.componentId,
consume: !entry.reusable,
@@ -216,7 +176,6 @@ async function handleDiscordModalTrigger(params: {
interaction: ButtonInteraction;
data: ComponentData;
label: string;
interactionCtx?: ComponentInteractionContext;
}): Promise<void> {
const parsed = parseDiscordComponentData(
params.data,
@@ -260,43 +219,19 @@ async function handleDiscordModalTrigger(params: {
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({
const authorized = await resolveAuthorizedComponentInteraction({
ctx: params.ctx,
interaction: params.interaction,
guildInfo,
channelId,
rawGuildId,
channelCtx,
memberRoleIds,
user,
replyOpts,
label: params.label,
componentLabel: "form",
unauthorizedReply,
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
groupPolicy: resolveComponentGroupPolicy(params.ctx),
defer: false,
});
if (!memberAllowed) {
if (!authorized) {
return;
}
const { user, replyOpts, allowNameMatching } = authorized;
const componentAllowed = await ensureComponentUserAllowed({
entry,
@@ -305,7 +240,7 @@ async function handleDiscordModalTrigger(params: {
replyOpts,
componentLabel: "form",
unauthorizedReply,
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
allowNameMatching,
});
if (!componentAllowed) {
return;

View File

@@ -1,4 +1,3 @@
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";
@@ -6,19 +5,14 @@ import { Modal, type ComponentData, type ModalInteraction } from "../internal/di
import {
type AgentComponentContext,
ensureComponentUserAllowed,
ensureGuildComponentMemberAllowed,
formatModalSubmissionText,
parseDiscordModalId,
resolveComponentCommandAuthorized,
resolveDiscordChannelContext,
resolveInteractionContextWithDmAuth,
resolveAuthorizedComponentInteraction,
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";
@@ -60,51 +54,27 @@ export class DiscordComponentModal extends Modal {
return;
}
const interactionCtx = await resolveInteractionContextWithDmAuth({
const unauthorizedReply = "You are not authorized to use this form.";
const authorized = await resolveAuthorizedComponentInteraction({
ctx: this.ctx,
interaction,
label: "discord component modal",
componentLabel: "form",
unauthorizedReply,
defer: false,
});
if (!interactionCtx) {
if (!authorized) {
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,
const {
interactionCtx,
channelCtx,
memberRoleIds,
guildInfo,
allowNameMatching,
commandAuthorized,
user,
replyOpts,
componentLabel: "form",
unauthorizedReply: "You are not authorized to use this form.",
allowNameMatching,
groupPolicy: resolveComponentGroupPolicy(this.ctx),
});
if (!memberAllowed) {
return;
}
} = authorized;
const modalAllowed = await ensureComponentUserAllowed({
entry: {
@@ -117,19 +87,12 @@ export class DiscordComponentModal extends Modal {
user,
replyOpts,
componentLabel: "form",
unauthorizedReply: "You are not authorized to use this form.",
unauthorizedReply,
allowNameMatching,
});
if (!modalAllowed) {
return;
}
const commandAuthorized = resolveComponentCommandAuthorized({
ctx: this.ctx,
interactionCtx,
channelConfig,
guildInfo,
allowNameMatching,
});
const consumed = resolveDiscordModalEntry({
id: modalId,

View File

@@ -2,6 +2,10 @@ import { Modal, type BaseMessageInteractiveComponent } from "../internal/discord
import type { AgentComponentContext } from "./agent-components-helpers.js";
import { discordComponentControlHandlers } from "./agent-components.handlers.js";
import { DiscordComponentModal } from "./agent-components.modal.js";
import {
createAgentComponentButton,
createAgentSelectMenu,
} from "./agent-components.system-controls.js";
import {
createDiscordComponentButtonControl,
createDiscordComponentChannelSelectControl,
@@ -20,6 +24,8 @@ export {
createAgentSelectMenu,
} from "./agent-components.system-controls.js";
type ComponentFactory = (ctx: AgentComponentContext) => BaseMessageInteractiveComponent;
function bindDiscordComponentControl<T extends BaseMessageInteractiveComponent>(
createControl: (ctx: AgentComponentContext, handlers: DiscordComponentControlHandlers) => T,
) {
@@ -45,6 +51,20 @@ export const createDiscordComponentChannelSelect = bindDiscordComponentControl(
createDiscordComponentChannelSelectControl,
);
export const createAgentComponentControls = [
createAgentComponentButton,
createAgentSelectMenu,
] satisfies readonly ComponentFactory[];
export const createDiscordComponentControls = [
createDiscordComponentButton,
createDiscordComponentStringSelect,
createDiscordComponentUserSelect,
createDiscordComponentRoleSelect,
createDiscordComponentMentionableSelect,
createDiscordComponentChannelSelect,
] satisfies readonly ComponentFactory[];
export function createDiscordComponentModal(ctx: AgentComponentContext): Modal {
return new DiscordComponentModal(ctx);
}

View File

@@ -8,11 +8,9 @@ import {
} from "../internal/discord.js";
import {
parseDiscordComponentData,
resolveInteractionContextWithDmAuth,
resolveInteractionCustomId,
type AgentComponentContext,
type AgentComponentMessageInteraction,
type ComponentInteractionContext,
} from "./agent-components-helpers.js";
export type DiscordComponentControlHandlers = {
@@ -29,7 +27,6 @@ export type DiscordComponentControlHandlers = {
interaction: ButtonInteraction;
data: ComponentData;
label: string;
interactionCtx?: ComponentInteractionContext;
}) => Promise<void>;
};
@@ -122,22 +119,11 @@ class DiscordComponentButton extends Button {
async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
const parsed = parseDiscordComponentData(data, resolveInteractionCustomId(interaction));
if (parsed?.modalId) {
const interactionCtx = await resolveInteractionContextWithDmAuth({
ctx: this.ctx,
interaction,
label: "discord component button",
componentLabel: "form",
defer: false,
});
if (!interactionCtx) {
return;
}
await this.handlers.handleModalTrigger({
ctx: this.ctx,
interaction,
data,
label: "discord component modal",
interactionCtx,
});
return;
}

View File

@@ -221,23 +221,6 @@ describe("createDiscordGatewayPlugin", () => {
).toBeUndefined();
});
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);
plugin.connect(true);
expect(plugin.heartbeatInterval).toBeUndefined();
expect(plugin.firstHeartbeatTimeout).toBeUndefined();
});
it("emits transport activity for current gateway socket messages", () => {
const socket = new EventEmitter() as EventEmitter & { binaryType?: string };
const plugin = createPlugin({

View File

@@ -110,20 +110,6 @@ function createGatewayPlugin(params: {
super(params.options);
}
public override connect(resume = false): void {
// 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;
}
if (this.firstHeartbeatTimeout !== undefined) {
clearTimeout(this.firstHeartbeatTimeout);
this.firstHeartbeatTimeout = undefined;
}
super.connect(resume);
}
override registerClient(client: DiscordGatewayClient) {
const registration = this.registerClientInternal(client);
// Client construction starts plugin hooks without awaiting them. Mark the

View File

@@ -12,15 +12,9 @@ import {
} from "../internal/discord.js";
import { createDiscordVoiceCommand } from "../voice/command.js";
import {
createAgentComponentButton,
createAgentSelectMenu,
createDiscordComponentButton,
createDiscordComponentChannelSelect,
createDiscordComponentMentionableSelect,
createAgentComponentControls,
createDiscordComponentControls,
createDiscordComponentModal,
createDiscordComponentRoleSelect,
createDiscordComponentStringSelect,
createDiscordComponentUserSelect,
} from "./agent-components.js";
import {
createDiscordExecApprovalButtonContext,
@@ -157,14 +151,8 @@ export function createDiscordProviderInteractionSurface(params: {
runtime: params.runtime,
token: params.token,
};
components.push(createAgentComponentButton(componentContext));
components.push(createAgentSelectMenu(componentContext));
components.push(createDiscordComponentButton(componentContext));
components.push(createDiscordComponentStringSelect(componentContext));
components.push(createDiscordComponentUserSelect(componentContext));
components.push(createDiscordComponentRoleSelect(componentContext));
components.push(createDiscordComponentMentionableSelect(componentContext));
components.push(createDiscordComponentChannelSelect(componentContext));
components.push(...createAgentComponentControls.map((create) => create(componentContext)));
components.push(...createDiscordComponentControls.map((create) => create(componentContext)));
modals.push(createDiscordComponentModal(componentContext));
}

View File

@@ -74,7 +74,7 @@ vi.mock("./runtime-config-snapshot.js", () => ({
getRuntimeConfigSnapshot: () => mocks.runtimeConfig,
}));
describe("discord plugin-sdk compatibility facade", () => {
describe("discord plugin-sdk facade", () => {
it("exports the @openclaw/discord 2026.3.13 import surface", async () => {
const discordSdk = await import("./discord.js");
@@ -117,7 +117,7 @@ describe("discord plugin-sdk compatibility facade", () => {
}
});
it("forwards Discord component helpers through the compatibility facade", async () => {
it("forwards Discord component helpers through the facade", async () => {
const {
buildDiscordComponentMessage,
editDiscordComponentMessage,
@@ -151,7 +151,7 @@ describe("discord plugin-sdk compatibility facade", () => {
});
});
it("keeps legacy Discord subagent auto-bind calls working without cfg", async () => {
it("fills runtime config for Discord subagent auto-bind calls without cfg", async () => {
const { autoBindSpawnedDiscordSubagent } = await import("./discord.js");
const binding = await autoBindSpawnedDiscordSubagent({