diff --git a/extensions/discord/runtime-api.actions.ts b/extensions/discord/runtime-api.actions.ts new file mode 100644 index 00000000000..d221ee34666 --- /dev/null +++ b/extensions/discord/runtime-api.actions.ts @@ -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"; diff --git a/extensions/discord/runtime-api.lookup.ts b/extensions/discord/runtime-api.lookup.ts new file mode 100644 index 00000000000..32938cb6bf1 --- /dev/null +++ b/extensions/discord/runtime-api.lookup.ts @@ -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"; diff --git a/extensions/discord/runtime-api.monitor.ts b/extensions/discord/runtime-api.monitor.ts new file mode 100644 index 00000000000..bac8d6d0a04 --- /dev/null +++ b/extensions/discord/runtime-api.monitor.ts @@ -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"; diff --git a/extensions/discord/runtime-api.send.ts b/extensions/discord/runtime-api.send.ts new file mode 100644 index 00000000000..19cbd87cc1c --- /dev/null +++ b/extensions/discord/runtime-api.send.ts @@ -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"; diff --git a/extensions/discord/runtime-api.threads.ts b/extensions/discord/runtime-api.threads.ts new file mode 100644 index 00000000000..ae4a9e79362 --- /dev/null +++ b/extensions/discord/runtime-api.threads.ts @@ -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"; diff --git a/extensions/discord/runtime-api.ts b/extensions/discord/runtime-api.ts index 9d7a9aa4c14..aa8db6145c1 100644 --- a/extensions/discord/runtime-api.ts +++ b/extensions/discord/runtime-api.ts @@ -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"; diff --git a/extensions/discord/src/internal/gateway.test.ts b/extensions/discord/src/internal/gateway.test.ts index 85e7253ef36..a7ebf3aedcd 100644 --- a/extensions/discord/src/internal/gateway.test.ts +++ b/extensions/discord/src/internal/gateway.test.ts @@ -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); diff --git a/extensions/discord/src/internal/gateway.ts b/extensions/discord/src/internal/gateway.ts index d69dbecbc4f..481efe34e75 100644 --- a/extensions/discord/src/internal/gateway.ts +++ b/extensions/discord/src/internal/gateway.ts @@ -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"); diff --git a/extensions/discord/src/monitor/agent-components-auth.ts b/extensions/discord/src/monitor/agent-components-auth.ts index 82a678f1f8c..f0c178f9ee5 100644 --- a/extensions/discord/src/monitor/agent-components-auth.ts +++ b/extensions/discord/src/monitor/agent-components-auth.ts @@ -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; diff --git a/extensions/discord/src/monitor/agent-components-helpers.ts b/extensions/discord/src/monitor/agent-components-helpers.ts index 29b21eaef86..a919bcb7589 100644 --- a/extensions/discord/src/monitor/agent-components-helpers.ts +++ b/extensions/discord/src/monitor/agent-components-helpers.ts @@ -23,6 +23,7 @@ export { ensureAgentComponentInteractionAllowed, ensureComponentUserAllowed, ensureGuildComponentMemberAllowed, + resolveAuthorizedComponentInteraction, resolveComponentCommandAuthorized, resolveInteractionContextWithDmAuth, } from "./agent-components-auth.js"; diff --git a/extensions/discord/src/monitor/agent-components.handlers.ts b/extensions/discord/src/monitor/agent-components.handlers.ts index 46b899d7567..c59b71043ed 100644 --- a/extensions/discord/src/monitor/agent-components.handlers.ts +++ b/extensions/discord/src/monitor/agent-components.handlers.ts @@ -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 | 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 { 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; diff --git a/extensions/discord/src/monitor/agent-components.modal.ts b/extensions/discord/src/monitor/agent-components.modal.ts index 7b59b84d3b1..901da881c74 100644 --- a/extensions/discord/src/monitor/agent-components.modal.ts +++ b/extensions/discord/src/monitor/agent-components.modal.ts @@ -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, diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index 378349d7b7b..1d60e703122 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -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( 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); } diff --git a/extensions/discord/src/monitor/agent-components.wildcard-controls.ts b/extensions/discord/src/monitor/agent-components.wildcard-controls.ts index f5226269762..94738d50ee0 100644 --- a/extensions/discord/src/monitor/agent-components.wildcard-controls.ts +++ b/extensions/discord/src/monitor/agent-components.wildcard-controls.ts @@ -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; }; @@ -122,22 +119,11 @@ class DiscordComponentButton extends Button { async run(interaction: ButtonInteraction, data: ComponentData): Promise { const parsed = parseDiscordComponentData(data, resolveInteractionCustomId(interaction)); if (parsed?.modalId) { - const interactionCtx = await resolveInteractionContextWithDmAuth({ - ctx: this.ctx, - interaction, - label: "discord component button", - componentLabel: "form", - defer: false, - }); - if (!interactionCtx) { - return; - } await this.handlers.handleModalTrigger({ ctx: this.ctx, interaction, data, label: "discord component modal", - interactionCtx, }); return; } diff --git a/extensions/discord/src/monitor/gateway-plugin.test.ts b/extensions/discord/src/monitor/gateway-plugin.test.ts index 9a5edfd73fa..f9aa1e8bc9b 100644 --- a/extensions/discord/src/monitor/gateway-plugin.test.ts +++ b/extensions/discord/src/monitor/gateway-plugin.test.ts @@ -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({ diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index 9c8d3f6b6f9..cdbe5f3699b 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -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 diff --git a/extensions/discord/src/monitor/provider.interactions.ts b/extensions/discord/src/monitor/provider.interactions.ts index 16d25daafbf..c9b92ef320b 100644 --- a/extensions/discord/src/monitor/provider.interactions.ts +++ b/extensions/discord/src/monitor/provider.interactions.ts @@ -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)); } diff --git a/src/plugin-sdk/discord.test.ts b/src/plugin-sdk/discord.test.ts index a5d4bf6ccf8..1e0d9fb8891 100644 --- a/src/plugin-sdk/discord.test.ts +++ b/src/plugin-sdk/discord.test.ts @@ -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({