diff --git a/extensions/discord/src/channel.conversation.ts b/extensions/discord/src/channel.conversation.ts new file mode 100644 index 00000000000..0a80d0d779b --- /dev/null +++ b/extensions/discord/src/channel.conversation.ts @@ -0,0 +1,159 @@ +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, + normalizeOptionalStringifiedId, +} from "openclaw/plugin-sdk/text-runtime"; +import { resolveDiscordCurrentConversationIdentity } from "./conversation-identity.js"; +import { normalizeDiscordMessagingTarget } from "./normalize.js"; +import { parseDiscordTarget } from "./target-parsing.js"; + +export function resolveDiscordAttachedOutboundTarget(params: { + to: string; + threadId?: string | number | null; +}): string { + if (params.threadId == null) { + return params.to; + } + const threadId = normalizeOptionalStringifiedId(params.threadId) ?? ""; + return threadId ? `channel:${threadId}` : params.to; +} + +export function buildDiscordCrossContextPresentation(params: { + originLabel: string; + message: string; +}) { + const trimmed = params.message.trim(); + return { + tone: "neutral" as const, + blocks: [ + ...(trimmed + ? ([{ type: "text" as const, text: params.message }, { type: "divider" as const }] as const) + : []), + { type: "context" as const, text: `From ${params.originLabel}` }, + ], + }; +} + +export function normalizeDiscordAcpConversationId(conversationId: string) { + const normalized = conversationId.trim(); + return normalized ? { conversationId: normalized } : null; +} + +export function matchDiscordAcpConversation(params: { + bindingConversationId: string; + conversationId: string; + parentConversationId?: string; +}) { + if (params.bindingConversationId === params.conversationId) { + return { conversationId: params.conversationId, matchPriority: 2 }; + } + if ( + params.parentConversationId && + params.parentConversationId !== params.conversationId && + params.bindingConversationId === params.parentConversationId + ) { + return { + conversationId: params.parentConversationId, + matchPriority: 1, + }; + } + return null; +} + +function resolveDiscordConversationIdFromTargets( + targets: Array, +): string | undefined { + for (const raw of targets) { + const trimmed = raw?.trim(); + if (!trimmed) { + continue; + } + try { + const target = parseDiscordTarget(trimmed, { defaultKind: "channel" }); + if (target?.normalized) { + return target.normalized; + } + } catch { + const mentionMatch = trimmed.match(/^<#(\d+)>$/); + if (mentionMatch?.[1]) { + return `channel:${mentionMatch[1]}`; + } + if (/^\d{6,}$/.test(trimmed)) { + return normalizeDiscordMessagingTarget(trimmed); + } + } + } + return undefined; +} + +function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined { + const sessionKey = normalizeLowercaseStringOrEmpty(raw); + if (!sessionKey) { + return undefined; + } + const match = sessionKey.match(/(?:^|:)channel:([^:]+)$/); + return match?.[1] ? `channel:${match[1]}` : undefined; +} + +export function resolveDiscordCommandConversation(params: { + threadId?: string; + threadParentId?: string; + parentSessionKey?: string; + from?: string; + chatType?: string; + originatingTo?: string; + commandTo?: string; + fallbackTo?: string; +}) { + const targets = [params.originatingTo, params.commandTo, params.fallbackTo]; + if (params.threadId) { + const parentConversationId = + normalizeDiscordMessagingTarget(normalizeOptionalString(params.threadParentId) ?? "") || + parseDiscordParentChannelFromSessionKey(params.parentSessionKey) || + resolveDiscordConversationIdFromTargets(targets); + return { + conversationId: params.threadId, + ...(parentConversationId && parentConversationId !== params.threadId + ? { parentConversationId } + : {}), + }; + } + const conversationId = resolveDiscordCurrentConversationIdentity({ + from: params.from, + chatType: params.chatType, + originatingTo: params.originatingTo, + commandTo: params.commandTo, + fallbackTo: params.fallbackTo, + }); + return conversationId ? { conversationId } : null; +} + +export function resolveDiscordInboundConversation(params: { + from?: string; + to?: string; + conversationId?: string; + isGroup: boolean; +}) { + const conversationId = resolveDiscordCurrentConversationIdentity({ + from: params.from, + chatType: params.isGroup ? "group" : "direct", + originatingTo: params.to, + fallbackTo: params.conversationId, + }); + return conversationId ? { conversationId } : null; +} + +export function parseDiscordExplicitTarget(raw: string) { + try { + const target = parseDiscordTarget(raw, { defaultKind: "channel" }); + if (!target) { + return null; + } + return { + to: target.normalized, + chatType: target.kind === "user" ? ("direct" as const) : ("channel" as const), + }; + } catch { + return null; + } +} diff --git a/extensions/discord/src/channel.loaders.ts b/extensions/discord/src/channel.loaders.ts new file mode 100644 index 00000000000..154bb890d01 --- /dev/null +++ b/extensions/discord/src/channel.loaders.ts @@ -0,0 +1,47 @@ +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; + +let discordProviderRuntimePromise: + | Promise + | undefined; +let discordProbeRuntimePromise: Promise | undefined; +let discordAuditModulePromise: Promise | undefined; +let discordSendModulePromise: Promise | undefined; +let discordDirectoryLiveModulePromise: Promise | undefined; + +export const loadDiscordDirectoryConfigModule = createLazyRuntimeModule( + () => import("./directory-config.js"), +); +export const loadDiscordResolveChannelsModule = createLazyRuntimeModule( + () => import("./resolve-channels.js"), +); +export const loadDiscordResolveUsersModule = createLazyRuntimeModule( + () => import("./resolve-users.js"), +); +export const loadDiscordThreadBindingsManagerModule = createLazyRuntimeModule( + () => import("./monitor/thread-bindings.manager.js"), +); + +export async function loadDiscordProviderRuntime() { + discordProviderRuntimePromise ??= import("./monitor/provider.runtime.js"); + return await discordProviderRuntimePromise; +} + +export async function loadDiscordProbeRuntime() { + discordProbeRuntimePromise ??= import("./probe.runtime.js"); + return await discordProbeRuntimePromise; +} + +export async function loadDiscordAuditModule() { + discordAuditModulePromise ??= import("./audit.js"); + return await discordAuditModulePromise; +} + +export async function loadDiscordSendModule() { + discordSendModulePromise ??= import("./send.js"); + return await discordSendModulePromise; +} + +export async function loadDiscordDirectoryLiveModule() { + discordDirectoryLiveModulePromise ??= import("./directory-live.js"); + return await discordDirectoryLiveModulePromise; +} diff --git a/extensions/discord/src/channel.runtime.ts b/extensions/discord/src/channel.runtime.ts index 263cba44e35..bc22b64706a 100644 --- a/extensions/discord/src/channel.runtime.ts +++ b/extensions/discord/src/channel.runtime.ts @@ -1,7 +1 @@ -import { createDiscordSetupWizardProxy } from "./setup-core.js"; - -type DiscordSetupWizard = typeof import("./setup-surface.js").discordSetupWizard; - -export const discordSetupWizard: DiscordSetupWizard = createDiscordSetupWizardProxy( - async () => (await import("./setup-surface.js")).discordSetupWizard, -); +export { discordSetupWizard } from "./setup-surface.js"; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 8b51487596a..700f4cf12bb 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -14,18 +14,13 @@ import { createRuntimeDirectoryLiveAdapter, } from "openclaw/plugin-sdk/directory-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/target-resolver-runtime"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, - normalizeOptionalStringifiedId, -} from "openclaw/plugin-sdk/text-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { listDiscordAccountIds, resolveDiscordAccount, @@ -43,7 +38,26 @@ import { type ChannelPlugin, type OpenClawConfig, } from "./channel-api.js"; -import { resolveDiscordCurrentConversationIdentity } from "./conversation-identity.js"; +import { + buildDiscordCrossContextPresentation, + matchDiscordAcpConversation, + normalizeDiscordAcpConversationId, + parseDiscordExplicitTarget, + resolveDiscordAttachedOutboundTarget, + resolveDiscordCommandConversation, + resolveDiscordInboundConversation, +} from "./channel.conversation.js"; +import { + loadDiscordAuditModule, + loadDiscordDirectoryConfigModule, + loadDiscordDirectoryLiveModule, + loadDiscordProbeRuntime, + loadDiscordProviderRuntime, + loadDiscordResolveChannelsModule, + loadDiscordResolveUsersModule, + loadDiscordSendModule, + loadDiscordThreadBindingsManagerModule, +} from "./channel.loaders.js"; import { shouldSuppressLocalDiscordExecApprovalPrompt } from "./exec-approvals.js"; import { resolveDiscordGroupRequireMention, @@ -65,62 +79,8 @@ import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js"; import { collectDiscordStatusIssues } from "./status-issues.js"; import { parseDiscordTarget } from "./target-parsing.js"; -let discordProviderRuntimePromise: - | Promise - | undefined; -let discordProbeRuntimePromise: Promise | undefined; -let discordAuditModulePromise: Promise | undefined; -let discordSendModulePromise: Promise | undefined; -let discordDirectoryLiveModulePromise: Promise | undefined; - -const loadDiscordDirectoryConfigModule = createLazyRuntimeModule( - () => import("./directory-config.js"), -); -const loadDiscordResolveChannelsModule = createLazyRuntimeModule( - () => import("./resolve-channels.js"), -); -const loadDiscordResolveUsersModule = createLazyRuntimeModule(() => import("./resolve-users.js")); -const loadDiscordThreadBindingsManagerModule = createLazyRuntimeModule( - () => import("./monitor/thread-bindings.manager.js"), -); - -async function loadDiscordProviderRuntime() { - discordProviderRuntimePromise ??= import("./monitor/provider.runtime.js"); - return await discordProviderRuntimePromise; -} - -async function loadDiscordProbeRuntime() { - discordProbeRuntimePromise ??= import("./probe.runtime.js"); - return await discordProbeRuntimePromise; -} - -async function loadDiscordAuditModule() { - discordAuditModulePromise ??= import("./audit.js"); - return await discordAuditModulePromise; -} - -async function loadDiscordSendModule() { - discordSendModulePromise ??= import("./send.js"); - return await discordSendModulePromise; -} - -async function loadDiscordDirectoryLiveModule() { - discordDirectoryLiveModulePromise ??= import("./directory-live.js"); - return await discordDirectoryLiveModulePromise; -} - const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000; -function resolveDiscordAttachedOutboundTarget(params: { - to: string; - threadId?: string | number | null; -}): string { - if (params.threadId == null) { - return params.to; - } - const threadId = normalizeOptionalStringifiedId(params.threadId) ?? ""; - return threadId ? `channel:${threadId}` : params.to; -} function shouldTreatDiscordDeliveredTextAsVisible(params: { kind: "tool" | "block" | "final"; @@ -194,19 +154,6 @@ function formatDiscordIntents(intents?: { ].join(" "); } -function buildDiscordCrossContextPresentation(params: { originLabel: string; message: string }) { - const trimmed = params.message.trim(); - return { - tone: "neutral" as const, - blocks: [ - ...(trimmed - ? ([{ type: "text" as const, text: params.message }, { type: "divider" as const }] as const) - : []), - { type: "context" as const, text: `From ${params.originLabel}` }, - ], - }; -} - const resolveDiscordAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({ resolveRecord: (account: ResolvedDiscordAccount) => account.config.guilds, outerLabel: (guildKey) => `guild ${guildKey}`, @@ -223,115 +170,6 @@ const resolveDiscordAllowlistNames = createAccountScopedAllowlistNameResolver({ (await loadDiscordResolveUsersModule()).resolveDiscordUserAllowlist({ token, entries }), }); -function normalizeDiscordAcpConversationId(conversationId: string) { - const normalized = conversationId.trim(); - return normalized ? { conversationId: normalized } : null; -} - -function matchDiscordAcpConversation(params: { - bindingConversationId: string; - conversationId: string; - parentConversationId?: string; -}) { - if (params.bindingConversationId === params.conversationId) { - return { conversationId: params.conversationId, matchPriority: 2 }; - } - if ( - params.parentConversationId && - params.parentConversationId !== params.conversationId && - params.bindingConversationId === params.parentConversationId - ) { - return { - conversationId: params.parentConversationId, - matchPriority: 1, - }; - } - return null; -} - -function resolveDiscordConversationIdFromTargets( - targets: Array, -): string | undefined { - for (const raw of targets) { - const trimmed = raw?.trim(); - if (!trimmed) { - continue; - } - try { - const target = parseDiscordTarget(trimmed, { defaultKind: "channel" }); - if (target?.normalized) { - return target.normalized; - } - } catch { - const mentionMatch = trimmed.match(/^<#(\d+)>$/); - if (mentionMatch?.[1]) { - return `channel:${mentionMatch[1]}`; - } - if (/^\d{6,}$/.test(trimmed)) { - return normalizeDiscordMessagingTarget(trimmed); - } - } - } - return undefined; -} - -function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined { - const sessionKey = normalizeLowercaseStringOrEmpty(raw); - if (!sessionKey) { - return undefined; - } - const match = sessionKey.match(/(?:^|:)channel:([^:]+)$/); - return match?.[1] ? `channel:${match[1]}` : undefined; -} - -function resolveDiscordCommandConversation(params: { - threadId?: string; - threadParentId?: string; - parentSessionKey?: string; - from?: string; - chatType?: string; - originatingTo?: string; - commandTo?: string; - fallbackTo?: string; -}) { - const targets = [params.originatingTo, params.commandTo, params.fallbackTo]; - if (params.threadId) { - const parentConversationId = - normalizeDiscordMessagingTarget(normalizeOptionalString(params.threadParentId) ?? "") || - parseDiscordParentChannelFromSessionKey(params.parentSessionKey) || - resolveDiscordConversationIdFromTargets(targets); - return { - conversationId: params.threadId, - ...(parentConversationId && parentConversationId !== params.threadId - ? { parentConversationId } - : {}), - }; - } - const conversationId = resolveDiscordCurrentConversationIdentity({ - from: params.from, - chatType: params.chatType, - originatingTo: params.originatingTo, - commandTo: params.commandTo, - fallbackTo: params.fallbackTo, - }); - return conversationId ? { conversationId } : null; -} - -function resolveDiscordInboundConversation(params: { - from?: string; - to?: string; - conversationId?: string; - isGroup: boolean; -}) { - const conversationId = resolveDiscordCurrentConversationIdentity({ - from: params.from, - chatType: params.isGroup ? "group" : "direct", - originatingTo: params.to, - fallbackTo: params.conversationId, - }); - return conversationId ? { conversationId } : null; -} - function toConversationLifecycleBinding(binding: { boundAt: number; lastActivityAt?: number; @@ -347,21 +185,6 @@ function toConversationLifecycleBinding(binding: { }; } -function parseDiscordExplicitTarget(raw: string) { - try { - const target = parseDiscordTarget(raw, { defaultKind: "channel" }); - if (!target) { - return null; - } - return { - to: target.normalized, - chatType: target.kind === "user" ? ("direct" as const) : ("channel" as const), - }; - } catch { - return null; - } -} - export const discordPlugin: ChannelPlugin = createChatChannelPlugin({ base: { diff --git a/extensions/discord/src/internal/gateway-close-codes.ts b/extensions/discord/src/internal/gateway-close-codes.ts new file mode 100644 index 00000000000..06a13854ba2 --- /dev/null +++ b/extensions/discord/src/internal/gateway-close-codes.ts @@ -0,0 +1,25 @@ +import { GatewayCloseCodes } from "discord-api-types/v10"; + +const fatalGatewayCloseCodes = new Set([ + GatewayCloseCodes.AuthenticationFailed, + GatewayCloseCodes.InvalidShard, + GatewayCloseCodes.ShardingRequired, + GatewayCloseCodes.InvalidAPIVersion, + GatewayCloseCodes.InvalidIntents, + GatewayCloseCodes.DisallowedIntents, +]); + +const nonResumableGatewayCloseCodes = new Set([ + GatewayCloseCodes.NotAuthenticated, + GatewayCloseCodes.InvalidSeq, + GatewayCloseCodes.SessionTimedOut, + GatewayCloseCodes.AlreadyAuthenticated, +]); + +export function isFatalGatewayCloseCode(code: GatewayCloseCodes): boolean { + return fatalGatewayCloseCodes.has(code); +} + +export function canResumeAfterGatewayClose(code: GatewayCloseCodes): boolean { + return !nonResumableGatewayCloseCodes.has(code); +} diff --git a/extensions/discord/src/internal/gateway.ts b/extensions/discord/src/internal/gateway.ts index 481efe34e75..d1058fd9741 100644 --- a/extensions/discord/src/internal/gateway.ts +++ b/extensions/discord/src/internal/gateway.ts @@ -15,6 +15,7 @@ import { } from "discord-api-types/v10"; import * as ws from "ws"; import { Plugin, type Client } from "./client.js"; +import { canResumeAfterGatewayClose, isFatalGatewayCloseCode } from "./gateway-close-codes.js"; import { dispatchVoiceGatewayEvent, mapGatewayDispatchData } from "./gateway-dispatch.js"; import { sharedGatewayIdentifyLimiter } from "./gateway-identify-limiter.js"; import { GatewayHeartbeatTimers, GatewayReconnectTimer } from "./gateway-lifecycle.js"; @@ -70,26 +71,6 @@ function decodeGatewayMessage(incoming: unknown): GatewayReceivePayload | null { } } -function isFatalGatewayCloseCode(code: GatewayCloseCodes): boolean { - return ( - code === GatewayCloseCodes.AuthenticationFailed || - code === GatewayCloseCodes.InvalidShard || - code === GatewayCloseCodes.ShardingRequired || - code === GatewayCloseCodes.InvalidAPIVersion || - code === GatewayCloseCodes.InvalidIntents || - code === GatewayCloseCodes.DisallowedIntents - ); -} - -function canResumeAfterGatewayClose(code: GatewayCloseCodes): boolean { - return ( - code !== GatewayCloseCodes.NotAuthenticated && - code !== GatewayCloseCodes.InvalidSeq && - code !== GatewayCloseCodes.SessionTimedOut && - code !== GatewayCloseCodes.AlreadyAuthenticated - ); -} - export class GatewayPlugin extends Plugin { readonly id = "gateway"; protected client?: Client; diff --git a/extensions/discord/src/monitor/listeners.queue.ts b/extensions/discord/src/monitor/listeners.queue.ts new file mode 100644 index 00000000000..0dadfced32d --- /dev/null +++ b/extensions/discord/src/monitor/listeners.queue.ts @@ -0,0 +1,91 @@ +import { createSubsystemLogger, formatDurationSeconds } from "openclaw/plugin-sdk/runtime-env"; + +export type DiscordListenerLogger = ReturnType< + typeof import("openclaw/plugin-sdk/runtime-env").createSubsystemLogger +>; + +const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 30_000; + +export const discordEventQueueLog = createSubsystemLogger("discord/event-queue"); + +function formatListenerContextValue(value: unknown): string | null { + if (value === undefined || value === null) { + return null; + } + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); + } + return null; +} + +function formatListenerContextSuffix(context?: Record): string { + if (!context) { + return ""; + } + const entries = Object.entries(context).flatMap(([key, value]) => { + const formatted = formatListenerContextValue(value); + return formatted ? [`${key}=${formatted}`] : []; + }); + if (entries.length === 0) { + return ""; + } + return ` (${entries.join(" ")})`; +} + +function logSlowDiscordListener(params: { + logger: DiscordListenerLogger | undefined; + listener: string; + event: string; + durationMs: number; + context?: Record; +}) { + if (params.durationMs < DISCORD_SLOW_LISTENER_THRESHOLD_MS) { + return; + } + const duration = formatDurationSeconds(params.durationMs, { + decimals: 1, + unit: "seconds", + }); + const message = `Slow listener detected: ${params.listener} took ${duration} for event ${params.event}`; + const logger = params.logger ?? discordEventQueueLog; + logger.warn("Slow listener detected", { + listener: params.listener, + event: params.event, + durationMs: params.durationMs, + duration, + ...params.context, + consoleMessage: `${message}${formatListenerContextSuffix(params.context)}`, + }); +} + +export async function runDiscordListenerWithSlowLog(params: { + logger: DiscordListenerLogger | undefined; + listener: string; + event: string; + run: () => Promise; + context?: Record; + onError?: (err: unknown) => void; +}) { + const startedAt = Date.now(); + try { + await params.run(); + } catch (err) { + if (params.onError) { + params.onError(err); + return; + } + throw err; + } finally { + logSlowDiscordListener({ + logger: params.logger, + listener: params.listener, + event: params.event, + durationMs: Date.now() - startedAt, + context: params.context, + }); + } +} diff --git a/extensions/discord/src/monitor/listeners.reactions.ts b/extensions/discord/src/monitor/listeners.reactions.ts new file mode 100644 index 00000000000..2577b5d33bd --- /dev/null +++ b/extensions/discord/src/monitor/listeners.reactions.ts @@ -0,0 +1,610 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithLists, +} from "openclaw/plugin-sdk/security-runtime"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime"; +import { + ChannelType, + type Client, + MessageReactionAddListener, + MessageReactionRemoveListener, + type User, +} from "../internal/discord.js"; +import { + isDiscordGroupAllowedByPolicy, + normalizeDiscordAllowList, + normalizeDiscordSlug, + resolveDiscordAllowListMatch, + resolveDiscordChannelConfigWithFallback, + resolveDiscordGuildEntry, + resolveDiscordMemberAccessState, + resolveGroupDmAllow, + shouldEmitDiscordReactionNotification, +} from "./allow-list.js"; +import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js"; +import { runDiscordListenerWithSlowLog, type DiscordListenerLogger } from "./listeners.queue.js"; +import { resolveFetchedDiscordThreadLikeChannelContext } from "./thread-channel-context.js"; + +type LoadedConfig = OpenClawConfig; +type RuntimeEnv = import("openclaw/plugin-sdk/runtime-env").RuntimeEnv; + +type DiscordReactionEvent = Parameters[0]; + +type DiscordReactionListenerParams = { + cfg: LoadedConfig; + runtime: RuntimeEnv; + logger: DiscordListenerLogger; + onEvent?: () => void; +} & DiscordReactionRoutingParams; + +type DiscordReactionRoutingParams = { + accountId: string; + botUserId?: string; + dmEnabled: boolean; + groupDmEnabled: boolean; + groupDmChannels: string[]; + dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; + allowFrom: string[]; + groupPolicy: "open" | "allowlist" | "disabled"; + allowNameMatching: boolean; + guildEntries?: Record; +}; + +type DiscordReactionMode = "off" | "own" | "all" | "allowlist"; +type DiscordReactionChannelConfig = ReturnType; +type DiscordReactionIngressAccess = Awaited>; +type DiscordFetchedReactionMessage = { author?: User | null } | null; + +export class DiscordReactionListener extends MessageReactionAddListener { + constructor(private params: DiscordReactionListenerParams) { + super(); + } + + async handle(data: DiscordReactionEvent, client: Client) { + this.params.onEvent?.(); + await runDiscordReactionHandler({ + data, + client, + action: "added", + handlerParams: this.params, + listener: this.constructor.name, + event: this.type, + }); + } +} + +export class DiscordReactionRemoveListener extends MessageReactionRemoveListener { + constructor(private params: DiscordReactionListenerParams) { + super(); + } + + async handle(data: DiscordReactionEvent, client: Client) { + this.params.onEvent?.(); + await runDiscordReactionHandler({ + data, + client, + action: "removed", + handlerParams: this.params, + listener: this.constructor.name, + event: this.type, + }); + } +} + +async function runDiscordReactionHandler(params: { + data: DiscordReactionEvent; + client: Client; + action: "added" | "removed"; + handlerParams: DiscordReactionListenerParams; + listener: string; + event: string; +}): Promise { + await runDiscordListenerWithSlowLog({ + logger: params.handlerParams.logger, + listener: params.listener, + event: params.event, + run: async () => + handleDiscordReactionEvent({ + data: params.data, + client: params.client, + action: params.action, + cfg: params.handlerParams.cfg, + accountId: params.handlerParams.accountId, + botUserId: params.handlerParams.botUserId, + dmEnabled: params.handlerParams.dmEnabled, + groupDmEnabled: params.handlerParams.groupDmEnabled, + groupDmChannels: params.handlerParams.groupDmChannels, + dmPolicy: params.handlerParams.dmPolicy, + allowFrom: params.handlerParams.allowFrom, + groupPolicy: params.handlerParams.groupPolicy, + allowNameMatching: params.handlerParams.allowNameMatching, + guildEntries: params.handlerParams.guildEntries, + logger: params.handlerParams.logger, + }), + }); +} + +type DiscordReactionIngressAuthorizationParams = { + accountId: string; + user: User; + memberRoleIds: string[]; + isDirectMessage: boolean; + isGroupDm: boolean; + isGuildMessage: boolean; + channelId: string; + channelName?: string; + channelSlug: string; + dmEnabled: boolean; + groupDmEnabled: boolean; + groupDmChannels: string[]; + dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; + allowFrom: string[]; + groupPolicy: "open" | "allowlist" | "disabled"; + allowNameMatching: boolean; + guildInfo: import("./allow-list.js").DiscordGuildEntryResolved | null; + channelConfig?: import("./allow-list.js").DiscordChannelConfigResolved | null; +}; + +async function authorizeDiscordReactionIngress( + params: DiscordReactionIngressAuthorizationParams, +): Promise<{ allowed: true } | { allowed: false; reason: string }> { + if (params.isDirectMessage && !params.dmEnabled) { + return { allowed: false, reason: "dm-disabled" }; + } + if (params.isGroupDm && !params.groupDmEnabled) { + return { allowed: false, reason: "group-dm-disabled" }; + } + if (params.isDirectMessage) { + const storeAllowFrom = await readStoreAllowFromForDmPolicy({ + provider: "discord", + accountId: params.accountId, + dmPolicy: params.dmPolicy, + }); + const access = resolveDmGroupAccessWithLists({ + isGroup: false, + dmPolicy: params.dmPolicy, + groupPolicy: params.groupPolicy, + allowFrom: params.allowFrom, + groupAllowFrom: [], + storeAllowFrom, + isSenderAllowed: (allowEntries) => { + const allowList = normalizeDiscordAllowList(allowEntries, ["discord:", "user:", "pk:"]); + const allowMatch = allowList + ? resolveDiscordAllowListMatch({ + allowList, + candidate: { + id: params.user.id, + name: params.user.username, + tag: formatDiscordUserTag(params.user), + }, + allowNameMatching: params.allowNameMatching, + }) + : { allowed: false }; + return allowMatch.allowed; + }, + }); + if (access.decision !== "allow") { + return { allowed: false, reason: access.reason }; + } + } + if ( + params.isGroupDm && + !resolveGroupDmAllow({ + channels: params.groupDmChannels, + channelId: params.channelId, + channelName: params.channelName, + channelSlug: params.channelSlug, + }) + ) { + return { allowed: false, reason: "group-dm-not-allowlisted" }; + } + if (!params.isGuildMessage) { + return { allowed: true }; + } + const channelAllowlistConfigured = + Boolean(params.guildInfo?.channels) && Object.keys(params.guildInfo?.channels ?? {}).length > 0; + const channelAllowed = params.channelConfig?.allowed !== false; + if ( + !isDiscordGroupAllowedByPolicy({ + groupPolicy: params.groupPolicy, + guildAllowlisted: Boolean(params.guildInfo), + channelAllowlistConfigured, + channelAllowed, + }) + ) { + return { allowed: false, reason: "guild-policy" }; + } + if (params.channelConfig?.allowed === false) { + return { allowed: false, reason: "guild-channel-denied" }; + } + const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ + channelConfig: params.channelConfig, + guildInfo: params.guildInfo, + memberRoleIds: params.memberRoleIds, + sender: { + id: params.user.id, + name: params.user.username, + tag: formatDiscordUserTag(params.user), + }, + allowNameMatching: params.allowNameMatching, + }); + if (hasAccessRestrictions && !memberAllowed) { + return { allowed: false, reason: "guild-member-denied" }; + } + return { allowed: true }; +} + +async function handleDiscordThreadReactionNotification(params: { + reactionMode: DiscordReactionMode; + message: DiscordReactionEvent["message"]; + parentId?: string; + resolveThreadChannelAccess: () => Promise<{ + access: DiscordReactionIngressAccess; + channelConfig: DiscordReactionChannelConfig; + }>; + shouldNotifyReaction: (options: { + mode: DiscordReactionMode; + messageAuthorId?: string; + channelConfig?: DiscordReactionChannelConfig; + }) => boolean; + resolveReactionBase: () => { baseText: string; contextKey: string }; + emitReaction: (text: string, parentPeerId?: string) => void; + emitReactionWithAuthor: (message: DiscordFetchedReactionMessage) => void; +}) { + if (params.reactionMode === "off") { + return; + } + + if (params.reactionMode === "all" || params.reactionMode === "allowlist") { + const { access, channelConfig } = await params.resolveThreadChannelAccess(); + if ( + !access.allowed || + !params.shouldNotifyReaction({ mode: params.reactionMode, channelConfig }) + ) { + return; + } + + const { baseText } = params.resolveReactionBase(); + params.emitReaction(baseText, params.parentId); + return; + } + + const message = await params.message.fetch().catch(() => null); + const { access, channelConfig } = await params.resolveThreadChannelAccess(); + const messageAuthorId = message?.author?.id ?? undefined; + if ( + !access.allowed || + !params.shouldNotifyReaction({ + mode: params.reactionMode, + messageAuthorId, + channelConfig, + }) + ) { + return; + } + + params.emitReactionWithAuthor(message); +} + +async function handleDiscordChannelReactionNotification(params: { + isGuildMessage: boolean; + reactionMode: DiscordReactionMode; + message: DiscordReactionEvent["message"]; + channelConfig: DiscordReactionChannelConfig; + parentId?: string; + authorizeReactionIngressForChannel: ( + channelConfig: DiscordReactionChannelConfig, + ) => Promise; + shouldNotifyReaction: (options: { + mode: DiscordReactionMode; + messageAuthorId?: string; + channelConfig?: DiscordReactionChannelConfig; + }) => boolean; + resolveReactionBase: () => { baseText: string; contextKey: string }; + emitReaction: (text: string, parentPeerId?: string) => void; + emitReactionWithAuthor: (message: DiscordFetchedReactionMessage) => void; +}) { + if (params.isGuildMessage) { + const access = await params.authorizeReactionIngressForChannel(params.channelConfig); + if (!access.allowed) { + return; + } + } + + if (params.reactionMode === "off") { + return; + } + + if (params.reactionMode === "all" || params.reactionMode === "allowlist") { + if ( + !params.shouldNotifyReaction({ + mode: params.reactionMode, + channelConfig: params.channelConfig, + }) + ) { + return; + } + + const { baseText } = params.resolveReactionBase(); + params.emitReaction(baseText, params.parentId); + return; + } + + const message = await params.message.fetch().catch(() => null); + const messageAuthorId = message?.author?.id ?? undefined; + if ( + !params.shouldNotifyReaction({ + mode: params.reactionMode, + messageAuthorId, + channelConfig: params.channelConfig, + }) + ) { + return; + } + + params.emitReactionWithAuthor(message); +} + +function hasDiscordGuildChannelOverrides( + guildInfo: import("./allow-list.js").DiscordGuildEntryResolved | null, +) { + return Boolean(guildInfo?.channels && Object.keys(guildInfo.channels).length > 0); +} + +function shouldSkipGuildReactionBeforeChannelFetch(params: { + reactionMode: DiscordReactionMode; + guildInfo: import("./allow-list.js").DiscordGuildEntryResolved | null; + groupPolicy: DiscordReactionRoutingParams["groupPolicy"]; + memberRoleIds: string[]; + user: User; + botUserId?: string; + allowNameMatching: boolean; +}) { + if (params.reactionMode === "off" || params.groupPolicy === "disabled") { + return true; + } + if (params.reactionMode !== "allowlist") { + return false; + } + if (hasDiscordGuildChannelOverrides(params.guildInfo)) { + return false; + } + return !shouldEmitDiscordReactionNotification({ + mode: params.reactionMode, + botId: params.botUserId, + userId: params.user.id, + userName: params.user.username, + userTag: formatDiscordUserTag(params.user), + guildInfo: params.guildInfo, + memberRoleIds: params.memberRoleIds, + allowNameMatching: params.allowNameMatching, + }); +} + +async function handleDiscordReactionEvent( + params: { + data: DiscordReactionEvent; + client: Client; + action: "added" | "removed"; + cfg: LoadedConfig; + logger: DiscordListenerLogger; + } & DiscordReactionRoutingParams, +) { + try { + const { data, client, action, botUserId, guildEntries } = params; + if (!("user" in data)) { + return; + } + const user = data.user; + if (!user || user.bot) { + return; + } + if (botUserId && user.id === botUserId) { + return; + } + + const isGuildMessage = Boolean(data.guild_id); + const guildInfo = isGuildMessage + ? resolveDiscordGuildEntry({ + guild: data.guild ?? undefined, + guildId: data.guild_id ?? undefined, + guildEntries, + }) + : null; + if (isGuildMessage && guildEntries && Object.keys(guildEntries).length > 0 && !guildInfo) { + return; + } + const memberRoleIds = Array.isArray(data.rawMember?.roles) + ? data.rawMember.roles.map((roleId: string) => roleId) + : []; + const reactionMode = guildInfo?.reactionNotifications ?? "own"; + if ( + isGuildMessage && + shouldSkipGuildReactionBeforeChannelFetch({ + reactionMode, + guildInfo, + groupPolicy: params.groupPolicy, + memberRoleIds, + user, + botUserId, + allowNameMatching: params.allowNameMatching, + }) + ) { + return; + } + + const channel = await client.fetchChannel(data.channel_id); + if (!channel) { + return; + } + const channelContext = await resolveFetchedDiscordThreadLikeChannelContext({ + client, + channel, + channelIdFallback: data.channel_id, + }); + const channelName = channelContext.channelName; + const channelSlug = channelContext.channelSlug; + const channelType = channelContext.channelType; + const isDirectMessage = channelType === ChannelType.DM; + const isGroupDm = channelType === ChannelType.GroupDM; + const isThreadChannel = channelContext.isThreadChannel; + const reactionIngressBase: Omit = { + accountId: params.accountId, + user, + memberRoleIds, + isDirectMessage, + isGroupDm, + isGuildMessage, + channelId: data.channel_id, + channelName, + channelSlug, + dmEnabled: params.dmEnabled, + groupDmEnabled: params.groupDmEnabled, + groupDmChannels: params.groupDmChannels, + dmPolicy: params.dmPolicy, + allowFrom: params.allowFrom, + groupPolicy: params.groupPolicy, + allowNameMatching: params.allowNameMatching, + guildInfo, + }; + if (!isGuildMessage) { + const ingressAccess = await authorizeDiscordReactionIngress(reactionIngressBase); + if (!ingressAccess.allowed) { + logVerbose(`discord reaction blocked sender=${user.id} (reason=${ingressAccess.reason})`); + return; + } + } + const parentId = isThreadChannel ? channelContext.threadParentId : channelContext.parentId; + const parentName = isThreadChannel ? channelContext.threadParentName : undefined; + const parentSlug = isThreadChannel ? channelContext.threadParentSlug : ""; + let reactionBase: { baseText: string; contextKey: string } | null = null; + const resolveReactionBase = () => { + if (reactionBase) { + return reactionBase; + } + const emojiLabel = formatDiscordReactionEmoji(data.emoji); + const actorLabel = formatDiscordUserTag(user); + const guildSlug = + guildInfo?.slug || + (data.guild?.name + ? normalizeDiscordSlug(data.guild.name) + : (data.guild_id ?? (isGroupDm ? "group-dm" : "dm"))); + const channelLabel = channelSlug + ? `#${channelSlug}` + : channelName + ? `#${normalizeDiscordSlug(channelName)}` + : `#${data.channel_id}`; + const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`; + const contextKey = `discord:reaction:${action}:${data.message_id}:${user.id}:${emojiLabel}`; + reactionBase = { baseText, contextKey }; + return reactionBase; + }; + const emitReaction = (text: string, parentPeerId?: string) => { + const { contextKey } = resolveReactionBase(); + const route = resolveAgentRoute({ + cfg: params.cfg, + channel: "discord", + accountId: params.accountId, + guildId: data.guild_id ?? undefined, + memberRoleIds, + peer: { + kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel", + id: isDirectMessage ? user.id : data.channel_id, + }, + parentPeer: parentPeerId ? { kind: "channel", id: parentPeerId } : undefined, + }); + enqueueSystemEvent(text, { + sessionKey: route.sessionKey, + contextKey, + }); + }; + const shouldNotifyReaction = (options: { + mode: DiscordReactionMode; + messageAuthorId?: string; + channelConfig?: DiscordReactionChannelConfig; + }) => + shouldEmitDiscordReactionNotification({ + mode: options.mode, + botId: botUserId, + messageAuthorId: options.messageAuthorId, + userId: user.id, + userName: user.username, + userTag: formatDiscordUserTag(user), + channelConfig: options.channelConfig, + guildInfo, + memberRoleIds, + allowNameMatching: params.allowNameMatching, + }); + const emitReactionWithAuthor = (message: DiscordFetchedReactionMessage) => { + const { baseText } = resolveReactionBase(); + const authorLabel = message?.author ? formatDiscordUserTag(message.author) : undefined; + const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; + emitReaction(text, parentId); + }; + const resolveThreadChannelConfig = () => + resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId: data.channel_id, + channelName, + channelSlug, + parentId, + parentName, + parentSlug, + scope: "thread", + }); + const authorizeReactionIngressForChannel = async ( + channelConfig: DiscordReactionChannelConfig, + ) => + await authorizeDiscordReactionIngress({ + ...reactionIngressBase, + channelConfig, + }); + const resolveThreadChannelAccess = async () => { + const channelConfig = resolveThreadChannelConfig(); + const access = await authorizeReactionIngressForChannel(channelConfig); + return { access, channelConfig }; + }; + + if (isThreadChannel) { + await handleDiscordThreadReactionNotification({ + reactionMode, + message: data.message, + parentId, + resolveThreadChannelAccess, + shouldNotifyReaction, + resolveReactionBase, + emitReaction, + emitReactionWithAuthor, + }); + return; + } + + const channelConfig = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId: data.channel_id, + channelName, + channelSlug, + parentId, + parentName, + parentSlug, + scope: "channel", + }); + await handleDiscordChannelReactionNotification({ + isGuildMessage, + reactionMode, + message: data.message, + channelConfig, + parentId, + authorizeReactionIngressForChannel, + shouldNotifyReaction, + resolveReactionBase, + emitReaction, + emitReactionWithAuthor, + }); + } catch (err) { + params.logger.error(danger(`discord reaction handler failed: ${String(err)}`)); + } +} diff --git a/extensions/discord/src/monitor/listeners.ts b/extensions/discord/src/monitor/listeners.ts index 17936d8db6f..34fd59a7469 100644 --- a/extensions/discord/src/monitor/listeners.ts +++ b/extensions/discord/src/monitor/listeners.ts @@ -1,46 +1,18 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; import { - createSubsystemLogger, - danger, - formatDurationSeconds, - logVerbose, -} from "openclaw/plugin-sdk/runtime-env"; -import { - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, -} from "openclaw/plugin-sdk/security-runtime"; -import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime"; -import { - ChannelType, type Client, InteractionCreateListener, MessageCreateListener, - MessageReactionAddListener, - MessageReactionRemoveListener, PresenceUpdateListener, ThreadUpdateListener, - type User, } from "../internal/discord.js"; -import { - isDiscordGroupAllowedByPolicy, - normalizeDiscordAllowList, - normalizeDiscordSlug, - resolveDiscordAllowListMatch, - resolveDiscordChannelConfigWithFallback, - resolveDiscordMemberAccessState, - resolveGroupDmAllow, - resolveDiscordGuildEntry, - shouldEmitDiscordReactionNotification, -} from "./allow-list.js"; -import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js"; +import { discordEventQueueLog, runDiscordListenerWithSlowLog } from "./listeners.queue.js"; +export { DiscordReactionListener, DiscordReactionRemoveListener } from "./listeners.reactions.js"; import { setPresence } from "./presence-cache.js"; import { isThreadArchived } from "./thread-bindings.discord-api.js"; -import { resolveFetchedDiscordThreadLikeChannelContext } from "./thread-channel-context.js"; import { closeDiscordThreadSessions } from "./thread-session-close.js"; -type LoadedConfig = OpenClawConfig; -type RuntimeEnv = import("openclaw/plugin-sdk/runtime-env").RuntimeEnv; type Logger = ReturnType; export type DiscordMessageEvent = Parameters[0]; @@ -52,118 +24,6 @@ export type DiscordMessageHandler = ( options?: { abortSignal?: AbortSignal }, ) => Promise; -type DiscordReactionEvent = Parameters[0]; - -type DiscordReactionListenerParams = { - cfg: LoadedConfig; - runtime: RuntimeEnv; - logger: Logger; - onEvent?: () => void; -} & DiscordReactionRoutingParams; - -type DiscordReactionRoutingParams = { - accountId: string; - botUserId?: string; - dmEnabled: boolean; - groupDmEnabled: boolean; - groupDmChannels: string[]; - dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; - allowFrom: string[]; - groupPolicy: "open" | "allowlist" | "disabled"; - allowNameMatching: boolean; - guildEntries?: Record; -}; - -type DiscordReactionMode = "off" | "own" | "all" | "allowlist"; -type DiscordReactionChannelConfig = ReturnType; -type DiscordReactionIngressAccess = Awaited>; -type DiscordFetchedReactionMessage = { author?: User | null } | null; - -const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 30_000; -const discordEventQueueLog = createSubsystemLogger("discord/event-queue"); - -function formatListenerContextValue(value: unknown): string | null { - if (value === undefined || value === null) { - return null; - } - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - } - if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { - return String(value); - } - return null; -} - -function formatListenerContextSuffix(context?: Record): string { - if (!context) { - return ""; - } - const entries = Object.entries(context).flatMap(([key, value]) => { - const formatted = formatListenerContextValue(value); - return formatted ? [`${key}=${formatted}`] : []; - }); - if (entries.length === 0) { - return ""; - } - return ` (${entries.join(" ")})`; -} - -function logSlowDiscordListener(params: { - logger: Logger | undefined; - listener: string; - event: string; - durationMs: number; - context?: Record; -}) { - if (params.durationMs < DISCORD_SLOW_LISTENER_THRESHOLD_MS) { - return; - } - const duration = formatDurationSeconds(params.durationMs, { - decimals: 1, - unit: "seconds", - }); - const message = `Slow listener detected: ${params.listener} took ${duration} for event ${params.event}`; - const logger = params.logger ?? discordEventQueueLog; - logger.warn("Slow listener detected", { - listener: params.listener, - event: params.event, - durationMs: params.durationMs, - duration, - ...params.context, - consoleMessage: `${message}${formatListenerContextSuffix(params.context)}`, - }); -} - -async function runDiscordListenerWithSlowLog(params: { - logger: Logger | undefined; - listener: string; - event: string; - run: () => Promise; - context?: Record; - onError?: (err: unknown) => void; -}) { - const startedAt = Date.now(); - try { - await params.run(); - } catch (err) { - if (params.onError) { - params.onError(err); - return; - } - throw err; - } finally { - logSlowDiscordListener({ - logger: params.logger, - listener: params.listener, - event: params.event, - durationMs: Date.now() - startedAt, - context: params.context, - }); - } -} - export function registerDiscordListener(listeners: Array, listener: object) { if (listeners.some((existing) => existing.constructor === listener.constructor)) { return false; @@ -215,562 +75,6 @@ export class DiscordInteractionListener extends InteractionCreateListener { } } -export class DiscordReactionListener extends MessageReactionAddListener { - constructor(private params: DiscordReactionListenerParams) { - super(); - } - - async handle(data: DiscordReactionEvent, client: Client) { - this.params.onEvent?.(); - await runDiscordReactionHandler({ - data, - client, - action: "added", - handlerParams: this.params, - listener: this.constructor.name, - event: this.type, - }); - } -} - -export class DiscordReactionRemoveListener extends MessageReactionRemoveListener { - constructor(private params: DiscordReactionListenerParams) { - super(); - } - - async handle(data: DiscordReactionEvent, client: Client) { - this.params.onEvent?.(); - await runDiscordReactionHandler({ - data, - client, - action: "removed", - handlerParams: this.params, - listener: this.constructor.name, - event: this.type, - }); - } -} - -async function runDiscordReactionHandler(params: { - data: DiscordReactionEvent; - client: Client; - action: "added" | "removed"; - handlerParams: DiscordReactionListenerParams; - listener: string; - event: string; -}): Promise { - await runDiscordListenerWithSlowLog({ - logger: params.handlerParams.logger, - listener: params.listener, - event: params.event, - run: async () => - handleDiscordReactionEvent({ - data: params.data, - client: params.client, - action: params.action, - cfg: params.handlerParams.cfg, - accountId: params.handlerParams.accountId, - botUserId: params.handlerParams.botUserId, - dmEnabled: params.handlerParams.dmEnabled, - groupDmEnabled: params.handlerParams.groupDmEnabled, - groupDmChannels: params.handlerParams.groupDmChannels, - dmPolicy: params.handlerParams.dmPolicy, - allowFrom: params.handlerParams.allowFrom, - groupPolicy: params.handlerParams.groupPolicy, - allowNameMatching: params.handlerParams.allowNameMatching, - guildEntries: params.handlerParams.guildEntries, - logger: params.handlerParams.logger, - }), - }); -} - -type DiscordReactionIngressAuthorizationParams = { - accountId: string; - user: User; - memberRoleIds: string[]; - isDirectMessage: boolean; - isGroupDm: boolean; - isGuildMessage: boolean; - channelId: string; - channelName?: string; - channelSlug: string; - dmEnabled: boolean; - groupDmEnabled: boolean; - groupDmChannels: string[]; - dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; - allowFrom: string[]; - groupPolicy: "open" | "allowlist" | "disabled"; - allowNameMatching: boolean; - guildInfo: import("./allow-list.js").DiscordGuildEntryResolved | null; - channelConfig?: import("./allow-list.js").DiscordChannelConfigResolved | null; -}; - -async function authorizeDiscordReactionIngress( - params: DiscordReactionIngressAuthorizationParams, -): Promise<{ allowed: true } | { allowed: false; reason: string }> { - if (params.isDirectMessage && !params.dmEnabled) { - return { allowed: false, reason: "dm-disabled" }; - } - if (params.isGroupDm && !params.groupDmEnabled) { - return { allowed: false, reason: "group-dm-disabled" }; - } - if (params.isDirectMessage) { - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "discord", - accountId: params.accountId, - dmPolicy: params.dmPolicy, - }); - const access = resolveDmGroupAccessWithLists({ - isGroup: false, - dmPolicy: params.dmPolicy, - groupPolicy: params.groupPolicy, - allowFrom: params.allowFrom, - groupAllowFrom: [], - storeAllowFrom, - isSenderAllowed: (allowEntries) => { - const allowList = normalizeDiscordAllowList(allowEntries, ["discord:", "user:", "pk:"]); - const allowMatch = allowList - ? resolveDiscordAllowListMatch({ - allowList, - candidate: { - id: params.user.id, - name: params.user.username, - tag: formatDiscordUserTag(params.user), - }, - allowNameMatching: params.allowNameMatching, - }) - : { allowed: false }; - return allowMatch.allowed; - }, - }); - if (access.decision !== "allow") { - return { allowed: false, reason: access.reason }; - } - } - if ( - params.isGroupDm && - !resolveGroupDmAllow({ - channels: params.groupDmChannels, - channelId: params.channelId, - channelName: params.channelName, - channelSlug: params.channelSlug, - }) - ) { - return { allowed: false, reason: "group-dm-not-allowlisted" }; - } - if (!params.isGuildMessage) { - return { allowed: true }; - } - const channelAllowlistConfigured = - Boolean(params.guildInfo?.channels) && Object.keys(params.guildInfo?.channels ?? {}).length > 0; - const channelAllowed = params.channelConfig?.allowed !== false; - if ( - !isDiscordGroupAllowedByPolicy({ - groupPolicy: params.groupPolicy, - guildAllowlisted: Boolean(params.guildInfo), - channelAllowlistConfigured, - channelAllowed, - }) - ) { - return { allowed: false, reason: "guild-policy" }; - } - if (params.channelConfig?.allowed === false) { - return { allowed: false, reason: "guild-channel-denied" }; - } - const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ - channelConfig: params.channelConfig, - guildInfo: params.guildInfo, - memberRoleIds: params.memberRoleIds, - sender: { - id: params.user.id, - name: params.user.username, - tag: formatDiscordUserTag(params.user), - }, - allowNameMatching: params.allowNameMatching, - }); - if (hasAccessRestrictions && !memberAllowed) { - return { allowed: false, reason: "guild-member-denied" }; - } - return { allowed: true }; -} - -async function handleDiscordThreadReactionNotification(params: { - reactionMode: DiscordReactionMode; - message: DiscordReactionEvent["message"]; - parentId?: string; - resolveThreadChannelAccess: () => Promise<{ - access: DiscordReactionIngressAccess; - channelConfig: DiscordReactionChannelConfig; - }>; - shouldNotifyReaction: (options: { - mode: DiscordReactionMode; - messageAuthorId?: string; - channelConfig?: DiscordReactionChannelConfig; - }) => boolean; - resolveReactionBase: () => { baseText: string; contextKey: string }; - emitReaction: (text: string, parentPeerId?: string) => void; - emitReactionWithAuthor: (message: DiscordFetchedReactionMessage) => void; -}) { - if (params.reactionMode === "off") { - return; - } - - if (params.reactionMode === "all" || params.reactionMode === "allowlist") { - const { access, channelConfig } = await params.resolveThreadChannelAccess(); - if ( - !access.allowed || - !params.shouldNotifyReaction({ mode: params.reactionMode, channelConfig }) - ) { - return; - } - - const { baseText } = params.resolveReactionBase(); - params.emitReaction(baseText, params.parentId); - return; - } - - const message = await params.message.fetch().catch(() => null); - const { access, channelConfig } = await params.resolveThreadChannelAccess(); - const messageAuthorId = message?.author?.id ?? undefined; - if ( - !access.allowed || - !params.shouldNotifyReaction({ - mode: params.reactionMode, - messageAuthorId, - channelConfig, - }) - ) { - return; - } - - params.emitReactionWithAuthor(message); -} - -async function handleDiscordChannelReactionNotification(params: { - isGuildMessage: boolean; - reactionMode: DiscordReactionMode; - message: DiscordReactionEvent["message"]; - channelConfig: DiscordReactionChannelConfig; - parentId?: string; - authorizeReactionIngressForChannel: ( - channelConfig: DiscordReactionChannelConfig, - ) => Promise; - shouldNotifyReaction: (options: { - mode: DiscordReactionMode; - messageAuthorId?: string; - channelConfig?: DiscordReactionChannelConfig; - }) => boolean; - resolveReactionBase: () => { baseText: string; contextKey: string }; - emitReaction: (text: string, parentPeerId?: string) => void; - emitReactionWithAuthor: (message: DiscordFetchedReactionMessage) => void; -}) { - if (params.isGuildMessage) { - const access = await params.authorizeReactionIngressForChannel(params.channelConfig); - if (!access.allowed) { - return; - } - } - - if (params.reactionMode === "off") { - return; - } - - if (params.reactionMode === "all" || params.reactionMode === "allowlist") { - if ( - !params.shouldNotifyReaction({ - mode: params.reactionMode, - channelConfig: params.channelConfig, - }) - ) { - return; - } - - const { baseText } = params.resolveReactionBase(); - params.emitReaction(baseText, params.parentId); - return; - } - - const message = await params.message.fetch().catch(() => null); - const messageAuthorId = message?.author?.id ?? undefined; - if ( - !params.shouldNotifyReaction({ - mode: params.reactionMode, - messageAuthorId, - channelConfig: params.channelConfig, - }) - ) { - return; - } - - params.emitReactionWithAuthor(message); -} - -function hasDiscordGuildChannelOverrides( - guildInfo: import("./allow-list.js").DiscordGuildEntryResolved | null, -) { - return Boolean(guildInfo?.channels && Object.keys(guildInfo.channels).length > 0); -} - -function shouldSkipGuildReactionBeforeChannelFetch(params: { - reactionMode: DiscordReactionMode; - guildInfo: import("./allow-list.js").DiscordGuildEntryResolved | null; - groupPolicy: DiscordReactionRoutingParams["groupPolicy"]; - memberRoleIds: string[]; - user: User; - botUserId?: string; - allowNameMatching: boolean; -}) { - if (params.reactionMode === "off" || params.groupPolicy === "disabled") { - return true; - } - if (params.reactionMode !== "allowlist") { - return false; - } - if (hasDiscordGuildChannelOverrides(params.guildInfo)) { - return false; - } - return !shouldEmitDiscordReactionNotification({ - mode: params.reactionMode, - botId: params.botUserId, - userId: params.user.id, - userName: params.user.username, - userTag: formatDiscordUserTag(params.user), - guildInfo: params.guildInfo, - memberRoleIds: params.memberRoleIds, - allowNameMatching: params.allowNameMatching, - }); -} - -async function handleDiscordReactionEvent( - params: { - data: DiscordReactionEvent; - client: Client; - action: "added" | "removed"; - cfg: LoadedConfig; - logger: Logger; - } & DiscordReactionRoutingParams, -) { - try { - const { data, client, action, botUserId, guildEntries } = params; - if (!("user" in data)) { - return; - } - const user = data.user; - if (!user || user.bot) { - return; - } - - // Early exit: skip bot's own reactions before expensive network calls - if (botUserId && user.id === botUserId) { - return; - } - - const isGuildMessage = Boolean(data.guild_id); - const guildInfo = isGuildMessage - ? resolveDiscordGuildEntry({ - guild: data.guild ?? undefined, - guildId: data.guild_id ?? undefined, - guildEntries, - }) - : null; - if (isGuildMessage && guildEntries && Object.keys(guildEntries).length > 0 && !guildInfo) { - return; - } - const memberRoleIds = Array.isArray(data.rawMember?.roles) - ? data.rawMember.roles.map((roleId: string) => roleId) - : []; - const reactionMode = guildInfo?.reactionNotifications ?? "own"; - if ( - isGuildMessage && - shouldSkipGuildReactionBeforeChannelFetch({ - reactionMode, - guildInfo, - groupPolicy: params.groupPolicy, - memberRoleIds, - user, - botUserId, - allowNameMatching: params.allowNameMatching, - }) - ) { - return; - } - - const channel = await client.fetchChannel(data.channel_id); - if (!channel) { - return; - } - const channelContext = await resolveFetchedDiscordThreadLikeChannelContext({ - client, - channel, - channelIdFallback: data.channel_id, - }); - const channelName = channelContext.channelName; - const channelSlug = channelContext.channelSlug; - const channelType = channelContext.channelType; - const isDirectMessage = channelType === ChannelType.DM; - const isGroupDm = channelType === ChannelType.GroupDM; - const isThreadChannel = channelContext.isThreadChannel; - const reactionIngressBase: Omit = { - accountId: params.accountId, - user, - memberRoleIds, - isDirectMessage, - isGroupDm, - isGuildMessage, - channelId: data.channel_id, - channelName, - channelSlug, - dmEnabled: params.dmEnabled, - groupDmEnabled: params.groupDmEnabled, - groupDmChannels: params.groupDmChannels, - dmPolicy: params.dmPolicy, - allowFrom: params.allowFrom, - groupPolicy: params.groupPolicy, - allowNameMatching: params.allowNameMatching, - guildInfo, - }; - // Guild reactions need resolved channel/thread config before member access - // can mirror the normal message preflight path. - if (!isGuildMessage) { - const ingressAccess = await authorizeDiscordReactionIngress(reactionIngressBase); - if (!ingressAccess.allowed) { - logVerbose(`discord reaction blocked sender=${user.id} (reason=${ingressAccess.reason})`); - return; - } - } - const parentId = isThreadChannel ? channelContext.threadParentId : channelContext.parentId; - const parentName = isThreadChannel ? channelContext.threadParentName : undefined; - const parentSlug = isThreadChannel ? channelContext.threadParentSlug : ""; - let reactionBase: { baseText: string; contextKey: string } | null = null; - const resolveReactionBase = () => { - if (reactionBase) { - return reactionBase; - } - const emojiLabel = formatDiscordReactionEmoji(data.emoji); - const actorLabel = formatDiscordUserTag(user); - const guildSlug = - guildInfo?.slug || - (data.guild?.name - ? normalizeDiscordSlug(data.guild.name) - : (data.guild_id ?? (isGroupDm ? "group-dm" : "dm"))); - const channelLabel = channelSlug - ? `#${channelSlug}` - : channelName - ? `#${normalizeDiscordSlug(channelName)}` - : `#${data.channel_id}`; - const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`; - const contextKey = `discord:reaction:${action}:${data.message_id}:${user.id}:${emojiLabel}`; - reactionBase = { baseText, contextKey }; - return reactionBase; - }; - const emitReaction = (text: string, parentPeerId?: string) => { - const { contextKey } = resolveReactionBase(); - const route = resolveAgentRoute({ - cfg: params.cfg, - channel: "discord", - accountId: params.accountId, - guildId: data.guild_id ?? undefined, - memberRoleIds, - peer: { - kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel", - id: isDirectMessage ? user.id : data.channel_id, - }, - parentPeer: parentPeerId ? { kind: "channel", id: parentPeerId } : undefined, - }); - enqueueSystemEvent(text, { - sessionKey: route.sessionKey, - contextKey, - }); - }; - const shouldNotifyReaction = (options: { - mode: DiscordReactionMode; - messageAuthorId?: string; - channelConfig?: DiscordReactionChannelConfig; - }) => - shouldEmitDiscordReactionNotification({ - mode: options.mode, - botId: botUserId, - messageAuthorId: options.messageAuthorId, - userId: user.id, - userName: user.username, - userTag: formatDiscordUserTag(user), - channelConfig: options.channelConfig, - guildInfo, - memberRoleIds, - allowNameMatching: params.allowNameMatching, - }); - const emitReactionWithAuthor = (message: DiscordFetchedReactionMessage) => { - const { baseText } = resolveReactionBase(); - const authorLabel = message?.author ? formatDiscordUserTag(message.author) : undefined; - const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; - emitReaction(text, parentId); - }; - const resolveThreadChannelConfig = () => - resolveDiscordChannelConfigWithFallback({ - guildInfo, - channelId: data.channel_id, - channelName, - channelSlug, - parentId, - parentName, - parentSlug, - scope: "thread", - }); - const authorizeReactionIngressForChannel = async ( - channelConfig: DiscordReactionChannelConfig, - ) => - await authorizeDiscordReactionIngress({ - ...reactionIngressBase, - channelConfig, - }); - const resolveThreadChannelAccess = async () => { - const channelConfig = resolveThreadChannelConfig(); - const access = await authorizeReactionIngressForChannel(channelConfig); - return { access, channelConfig }; - }; - - if (isThreadChannel) { - await handleDiscordThreadReactionNotification({ - reactionMode, - message: data.message, - parentId, - resolveThreadChannelAccess, - shouldNotifyReaction, - resolveReactionBase, - emitReaction, - emitReactionWithAuthor, - }); - return; - } - - // Non-thread channel path - const channelConfig = resolveDiscordChannelConfigWithFallback({ - guildInfo, - channelId: data.channel_id, - channelName, - channelSlug, - parentId, - parentName, - parentSlug, - scope: "channel", - }); - await handleDiscordChannelReactionNotification({ - isGuildMessage, - reactionMode, - message: data.message, - channelConfig, - parentId, - authorizeReactionIngressForChannel, - shouldNotifyReaction, - resolveReactionBase, - emitReaction, - emitReactionWithAuthor, - }); - } catch (err) { - params.logger.error(danger(`discord reaction handler failed: ${String(err)}`)); - } -} - type PresenceUpdateEvent = Parameters[0]; export class DiscordPresenceListener extends PresenceUpdateListener { diff --git a/extensions/discord/src/monitor/message-handler.preflight-runtime.ts b/extensions/discord/src/monitor/message-handler.preflight-runtime.ts new file mode 100644 index 00000000000..11a75043bef --- /dev/null +++ b/extensions/discord/src/monitor/message-handler.preflight-runtime.ts @@ -0,0 +1,28 @@ +let pluralkitRuntimePromise: Promise | undefined; +let preflightAudioRuntimePromise: Promise | undefined; +let systemEventsRuntimePromise: Promise | undefined; +let discordThreadingRuntimePromise: Promise | undefined; + +export async function loadPluralKitRuntime() { + pluralkitRuntimePromise ??= import("../pluralkit.js"); + return await pluralkitRuntimePromise; +} + +export async function loadPreflightAudioRuntime() { + preflightAudioRuntimePromise ??= import("./preflight-audio.js"); + return await preflightAudioRuntimePromise; +} + +export async function loadSystemEventsRuntime() { + systemEventsRuntimePromise ??= import("./system-events.js"); + return await systemEventsRuntimePromise; +} + +export async function loadDiscordThreadingRuntime() { + discordThreadingRuntimePromise ??= import("./threading.js"); + return await discordThreadingRuntimePromise; +} + +export function isPreflightAborted(abortSignal?: AbortSignal): boolean { + return Boolean(abortSignal?.aborted); +} diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index e4d01347283..b25703aef1e 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -40,6 +40,13 @@ import { resolvePreflightMentionRequirement, shouldIgnoreBoundThreadWebhookMessage, } from "./message-handler.preflight-helpers.js"; +import { + isPreflightAborted, + loadDiscordThreadingRuntime, + loadPluralKitRuntime, + loadPreflightAudioRuntime, + loadSystemEventsRuntime, +} from "./message-handler.preflight-runtime.js"; import type { DiscordMessagePreflightContext, DiscordMessagePreflightParams, @@ -62,35 +69,6 @@ export { shouldIgnoreBoundThreadWebhookMessage, } from "./message-handler.preflight-helpers.js"; -let pluralkitRuntimePromise: Promise | undefined; -let preflightAudioRuntimePromise: Promise | undefined; -let systemEventsRuntimePromise: Promise | undefined; -let discordThreadingRuntimePromise: Promise | undefined; - -async function loadPluralKitRuntime() { - pluralkitRuntimePromise ??= import("../pluralkit.js"); - return await pluralkitRuntimePromise; -} - -async function loadPreflightAudioRuntime() { - preflightAudioRuntimePromise ??= import("./preflight-audio.js"); - return await preflightAudioRuntimePromise; -} - -async function loadSystemEventsRuntime() { - systemEventsRuntimePromise ??= import("./system-events.js"); - return await systemEventsRuntimePromise; -} - -async function loadDiscordThreadingRuntime() { - discordThreadingRuntimePromise ??= import("./threading.js"); - return await discordThreadingRuntimePromise; -} - -function isPreflightAborted(abortSignal?: AbortSignal): boolean { - return Boolean(abortSignal?.aborted); -} - export async function preflightDiscordMessage( params: DiscordMessagePreflightParams, ): Promise { diff --git a/extensions/discord/src/monitor/native-command.runtime.ts b/extensions/discord/src/monitor/native-command.runtime.ts new file mode 100644 index 00000000000..b0dc94198ed --- /dev/null +++ b/extensions/discord/src/monitor/native-command.runtime.ts @@ -0,0 +1,50 @@ +import { resolveDirectStatusReplyForSession } from "openclaw/plugin-sdk/command-status-runtime"; +import * as pluginRuntime from "openclaw/plugin-sdk/plugin-runtime"; +import { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime"; +import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js"; + +export const nativeCommandRuntime = { + matchPluginCommand: pluginRuntime.matchPluginCommand, + executePluginCommand: pluginRuntime.executePluginCommand, + dispatchReplyWithDispatcher, + resolveDirectStatusReplyForSession, + resolveDiscordNativeInteractionRouteState, +}; + +export const __testing = { + setMatchPluginCommand( + next: typeof pluginRuntime.matchPluginCommand, + ): typeof pluginRuntime.matchPluginCommand { + const previous = nativeCommandRuntime.matchPluginCommand; + nativeCommandRuntime.matchPluginCommand = next; + return previous; + }, + setExecutePluginCommand( + next: typeof pluginRuntime.executePluginCommand, + ): typeof pluginRuntime.executePluginCommand { + const previous = nativeCommandRuntime.executePluginCommand; + nativeCommandRuntime.executePluginCommand = next; + return previous; + }, + setDispatchReplyWithDispatcher( + next: typeof dispatchReplyWithDispatcher, + ): typeof dispatchReplyWithDispatcher { + const previous = nativeCommandRuntime.dispatchReplyWithDispatcher; + nativeCommandRuntime.dispatchReplyWithDispatcher = next; + return previous; + }, + setResolveDirectStatusReplyForSession( + next: typeof resolveDirectStatusReplyForSession, + ): typeof resolveDirectStatusReplyForSession { + const previous = nativeCommandRuntime.resolveDirectStatusReplyForSession; + nativeCommandRuntime.resolveDirectStatusReplyForSession = next; + return previous; + }, + setResolveDiscordNativeInteractionRouteState( + next: typeof resolveDiscordNativeInteractionRouteState, + ): typeof resolveDiscordNativeInteractionRouteState { + const previous = nativeCommandRuntime.resolveDiscordNativeInteractionRouteState; + nativeCommandRuntime.resolveDiscordNativeInteractionRouteState = next; + return previous; + }, +}; diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 728e5381578..32b5ea65d82 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -3,7 +3,6 @@ import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming"; import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native"; -import { resolveDirectStatusReplyForSession } from "openclaw/plugin-sdk/command-status-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; @@ -17,9 +16,7 @@ import { type ChatCommandDefinition, type NativeCommandSpec, } from "openclaw/plugin-sdk/native-command-registry"; -import * as pluginRuntime from "openclaw/plugin-sdk/plugin-runtime"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking"; -import { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime"; import { createSubsystemLogger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; @@ -60,7 +57,6 @@ import { isDiscordUnknownInteraction, safeDiscordInteractionCall, } from "./native-command-reply.js"; -import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js"; import { maybeDeliverDiscordDirectStatus } from "./native-command-status.js"; import { buildDiscordCommandArgMenu, @@ -78,55 +74,14 @@ import { buildDiscordCommandOptions, truncateDiscordCommandDescription, } from "./native-command.options.js"; +import { nativeCommandRuntime } from "./native-command.runtime.js"; import type { DiscordCommandArgs, DiscordConfig } from "./native-command.types.js"; import { resolveDiscordNativeInteractionChannelContext } from "./native-interaction-channel-context.js"; import { resolveDiscordSenderIdentity } from "./sender-identity.js"; import type { ThreadBindingManager } from "./thread-bindings.js"; const log = createSubsystemLogger("discord/native-command"); -let matchPluginCommandImpl = pluginRuntime.matchPluginCommand; -let executePluginCommandImpl = pluginRuntime.executePluginCommand; -let dispatchReplyWithDispatcherImpl = dispatchReplyWithDispatcher; -let resolveDirectStatusReplyForSessionImpl = resolveDirectStatusReplyForSession; -let resolveDiscordNativeInteractionRouteStateImpl = resolveDiscordNativeInteractionRouteState; - -export const __testing = { - setMatchPluginCommand( - next: typeof pluginRuntime.matchPluginCommand, - ): typeof pluginRuntime.matchPluginCommand { - const previous = matchPluginCommandImpl; - matchPluginCommandImpl = next; - return previous; - }, - setExecutePluginCommand( - next: typeof pluginRuntime.executePluginCommand, - ): typeof pluginRuntime.executePluginCommand { - const previous = executePluginCommandImpl; - executePluginCommandImpl = next; - return previous; - }, - setDispatchReplyWithDispatcher( - next: typeof dispatchReplyWithDispatcher, - ): typeof dispatchReplyWithDispatcher { - const previous = dispatchReplyWithDispatcherImpl; - dispatchReplyWithDispatcherImpl = next; - return previous; - }, - setResolveDirectStatusReplyForSession( - next: typeof resolveDirectStatusReplyForSession, - ): typeof resolveDirectStatusReplyForSession { - const previous = resolveDirectStatusReplyForSessionImpl; - resolveDirectStatusReplyForSessionImpl = next; - return previous; - }, - setResolveDiscordNativeInteractionRouteState( - next: typeof resolveDiscordNativeInteractionRouteState, - ): typeof resolveDiscordNativeInteractionRouteState { - const previous = resolveDiscordNativeInteractionRouteStateImpl; - resolveDiscordNativeInteractionRouteStateImpl = next; - return previous; - }, -}; +export { __testing } from "./native-command.runtime.js"; function shouldBypassConfiguredAcpEnsure(commandName: string): boolean { const normalized = normalizeLowercaseStringOrEmpty(commandName); @@ -160,7 +115,7 @@ export function createDiscordNativeCommand(params: { } = params; const fallbackCommandDefinition = createNativeCommandDefinition(command); const commandDefinition = - matchPluginCommandImpl(`/${command.name}`) !== null + nativeCommandRuntime.matchPluginCommand(`/${command.name}`) !== null ? fallbackCommandDefinition : (findCommandByNativeName(command.name, "discord", { includeBundledChannelFallback: false, @@ -366,10 +321,10 @@ async function dispatchDiscordCommandInteraction(params: { }) : null; let nativeRouteStatePromise: - | ReturnType + | ReturnType | undefined; const getNativeRouteState = () => - (nativeRouteStatePromise ??= resolveDiscordNativeInteractionRouteStateImpl({ + (nativeRouteStatePromise ??= nativeCommandRuntime.resolveDiscordNativeInteractionRouteState({ cfg, accountId, guildId: interaction.guild?.id ?? undefined, @@ -560,7 +515,7 @@ async function dispatchDiscordCommandInteraction(params: { return { accepted: true }; } - const pluginMatch = matchPluginCommandImpl(prompt); + const pluginMatch = nativeCommandRuntime.matchPluginCommand(prompt); if (pluginMatch && commandName !== "status") { if (suppressReplies) { return { accepted: true }; @@ -569,7 +524,7 @@ async function dispatchDiscordCommandInteraction(params: { const messageThreadId = !isDirectMessage && isThreadChannel ? channelId : undefined; const pluginThreadParentId = !isDirectMessage && isThreadChannel ? threadParentId : undefined; const { effectiveRoute } = await getNativeRouteState(); - const pluginReply = await executePluginCommandImpl({ + const pluginReply = await nativeCommandRuntime.executePluginCommand({ command: pluginMatch.command, args: pluginMatch.args, senderId: sender.id, @@ -652,7 +607,7 @@ async function dispatchDiscordCommandInteraction(params: { const directStatusResult = await maybeDeliverDiscordDirectStatus({ commandName, suppressReplies, - resolveDirectStatusReplyForSession: resolveDirectStatusReplyForSessionImpl, + resolveDirectStatusReplyForSession: nativeCommandRuntime.resolveDirectStatusReplyForSession, cfg, discordConfig, accountId, @@ -713,7 +668,7 @@ async function dispatchDiscordCommandInteraction(params: { const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig); let didReply = false; - const dispatchResult = await dispatchReplyWithDispatcherImpl({ + const dispatchResult = await nativeCommandRuntime.dispatchReplyWithDispatcher({ ctx: ctxPayload, cfg, dispatcherOptions: { diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index 0ada40e2e58..e612fbd976f 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -3,13 +3,12 @@ import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-types"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; -import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordAccountAllowFrom } from "../accounts.js"; -import { ChannelType, type Client, ReadyListener } from "../internal/discord.js"; +import { type Client, ReadyListener } from "../internal/discord.js"; import type { VoicePlugin } from "../internal/voice.js"; import { formatMention } from "../mentions.js"; import { normalizeDiscordSlug } from "../monitor/allow-list.js"; @@ -24,7 +23,6 @@ import { isVoiceCaptureActive, scheduleVoiceCaptureFinalize, stopVoiceCaptureState, - type VoiceCaptureState, } from "./capture-state.js"; import { formatVoiceIngressPrompt } from "./prompt.js"; import { @@ -36,47 +34,24 @@ import { finishVoiceDecryptRecovery, noteVoiceDecryptFailure, resetVoiceReceiveRecoveryState, - type VoiceReceiveRecoveryState, } from "./receive-recovery.js"; import { loadDiscordVoiceSdk } from "./sdk-runtime.js"; +import { + CAPTURE_FINALIZE_GRACE_MS, + isVoiceChannel, + logVoiceVerbose, + MIN_SEGMENT_SECONDS, + PLAYBACK_READY_TIMEOUT_MS, + SPEAKING_READY_TIMEOUT_MS, + VOICE_CONNECT_READY_TIMEOUT_MS, + type VoiceOperationResult, + type VoiceSessionEntry, +} from "./session.js"; import { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js"; import { synthesizeVoiceReplyAudio, transcribeVoiceAudio } from "./tts.js"; -const MIN_SEGMENT_SECONDS = 0.35; -const CAPTURE_FINALIZE_GRACE_MS = 1_200; -const VOICE_CONNECT_READY_TIMEOUT_MS = 15_000; -const PLAYBACK_READY_TIMEOUT_MS = 60_000; -const SPEAKING_READY_TIMEOUT_MS = 60_000; - const logger = createSubsystemLogger("discord/voice"); -const logVoiceVerbose = (message: string) => { - logVerbose(`discord voice: ${message}`); -}; - -type VoiceOperationResult = { - ok: boolean; - message: string; - channelId?: string; - guildId?: string; -}; - -type VoiceSessionEntry = { - guildId: string; - guildName?: string; - channelId: string; - channelName?: string; - sessionChannelId: string; - route: ReturnType; - connection: import("@discordjs/voice").VoiceConnection; - player: import("@discordjs/voice").AudioPlayer; - playbackQueue: Promise; - processingQueue: Promise; - capture: VoiceCaptureState; - receiveRecovery: VoiceReceiveRecoveryState; - stop: () => void; -}; - export class DiscordVoiceManager { private sessions = new Map(); private botUserId?: string; @@ -723,7 +698,3 @@ export class DiscordVoiceReadyListener extends ReadyListener { .catch((err) => logger.warn(`discord voice: autoJoin failed: ${formatErrorMessage(err)}`)); } } - -function isVoiceChannel(type: ChannelType) { - return type === ChannelType.GuildVoice || type === ChannelType.GuildStageVoice; -} diff --git a/extensions/discord/src/voice/session.ts b/extensions/discord/src/voice/session.ts new file mode 100644 index 00000000000..5e2f18d5c96 --- /dev/null +++ b/extensions/discord/src/voice/session.ts @@ -0,0 +1,42 @@ +import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { ChannelType } from "../internal/discord.js"; +import type { VoiceCaptureState } from "./capture-state.js"; +import type { VoiceReceiveRecoveryState } from "./receive-recovery.js"; + +export const MIN_SEGMENT_SECONDS = 0.35; +export const CAPTURE_FINALIZE_GRACE_MS = 1_200; +export const VOICE_CONNECT_READY_TIMEOUT_MS = 15_000; +export const PLAYBACK_READY_TIMEOUT_MS = 60_000; +export const SPEAKING_READY_TIMEOUT_MS = 60_000; + +export type VoiceOperationResult = { + ok: boolean; + message: string; + channelId?: string; + guildId?: string; +}; + +export type VoiceSessionEntry = { + guildId: string; + guildName?: string; + channelId: string; + channelName?: string; + sessionChannelId: string; + route: ReturnType; + connection: import("@discordjs/voice").VoiceConnection; + player: import("@discordjs/voice").AudioPlayer; + playbackQueue: Promise; + processingQueue: Promise; + capture: VoiceCaptureState; + receiveRecovery: VoiceReceiveRecoveryState; + stop: () => void; +}; + +export function logVoiceVerbose(message: string): void { + logVerbose(`discord voice: ${message}`); +} + +export function isVoiceChannel(type: ChannelType): boolean { + return type === ChannelType.GuildVoice || type === ChannelType.GuildStageVoice; +}