refactor(discord): split runtime helpers

This commit is contained in:
Peter Steinberger
2026-04-29 17:43:01 +01:00
parent 6de9d71bfb
commit e420592bdf
15 changed files with 1106 additions and 1048 deletions

View File

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

View File

@@ -0,0 +1,47 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
let discordProviderRuntimePromise:
| Promise<typeof import("./monitor/provider.runtime.js")>
| undefined;
let discordProbeRuntimePromise: Promise<typeof import("./probe.runtime.js")> | undefined;
let discordAuditModulePromise: Promise<typeof import("./audit.js")> | undefined;
let discordSendModulePromise: Promise<typeof import("./send.js")> | undefined;
let discordDirectoryLiveModulePromise: Promise<typeof import("./directory-live.js")> | 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;
}

View File

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

View File

@@ -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<typeof import("./monitor/provider.runtime.js")>
| undefined;
let discordProbeRuntimePromise: Promise<typeof import("./probe.runtime.js")> | undefined;
let discordAuditModulePromise: Promise<typeof import("./audit.js")> | undefined;
let discordSendModulePromise: Promise<typeof import("./send.js")> | undefined;
let discordDirectoryLiveModulePromise: Promise<typeof import("./directory-live.js")> | 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>,
): 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<ResolvedDiscordAccount, DiscordProbe> =
createChatChannelPlugin<ResolvedDiscordAccount, DiscordProbe>({
base: {

View File

@@ -0,0 +1,25 @@
import { GatewayCloseCodes } from "discord-api-types/v10";
const fatalGatewayCloseCodes = new Set<GatewayCloseCodes>([
GatewayCloseCodes.AuthenticationFailed,
GatewayCloseCodes.InvalidShard,
GatewayCloseCodes.ShardingRequired,
GatewayCloseCodes.InvalidAPIVersion,
GatewayCloseCodes.InvalidIntents,
GatewayCloseCodes.DisallowedIntents,
]);
const nonResumableGatewayCloseCodes = new Set<GatewayCloseCodes>([
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);
}

View File

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

View File

@@ -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, unknown>): 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<string, unknown>;
}) {
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<void>;
context?: Record<string, unknown>;
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,
});
}
}

View File

@@ -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<MessageReactionAddListener["handle"]>[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<string, import("./allow-list.js").DiscordGuildEntryResolved>;
};
type DiscordReactionMode = "off" | "own" | "all" | "allowlist";
type DiscordReactionChannelConfig = ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
type DiscordReactionIngressAccess = Awaited<ReturnType<typeof authorizeDiscordReactionIngress>>;
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<void> {
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<DiscordReactionIngressAccess>;
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<DiscordReactionIngressAuthorizationParams, "channelConfig"> = {
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)}`));
}
}

View File

@@ -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<typeof import("openclaw/plugin-sdk/runtime-env").createSubsystemLogger>;
export type DiscordMessageEvent = Parameters<MessageCreateListener["handle"]>[0];
@@ -52,118 +24,6 @@ export type DiscordMessageHandler = (
options?: { abortSignal?: AbortSignal },
) => Promise<void>;
type DiscordReactionEvent = Parameters<MessageReactionAddListener["handle"]>[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<string, import("./allow-list.js").DiscordGuildEntryResolved>;
};
type DiscordReactionMode = "off" | "own" | "all" | "allowlist";
type DiscordReactionChannelConfig = ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
type DiscordReactionIngressAccess = Awaited<ReturnType<typeof authorizeDiscordReactionIngress>>;
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, unknown>): 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<string, unknown>;
}) {
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<void>;
context?: Record<string, unknown>;
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<object>, 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<void> {
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<DiscordReactionIngressAccess>;
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<DiscordReactionIngressAuthorizationParams, "channelConfig"> = {
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<PresenceUpdateListener["handle"]>[0];
export class DiscordPresenceListener extends PresenceUpdateListener {

View File

@@ -0,0 +1,28 @@
let pluralkitRuntimePromise: Promise<typeof import("../pluralkit.js")> | undefined;
let preflightAudioRuntimePromise: Promise<typeof import("./preflight-audio.js")> | undefined;
let systemEventsRuntimePromise: Promise<typeof import("./system-events.js")> | undefined;
let discordThreadingRuntimePromise: Promise<typeof import("./threading.js")> | 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);
}

View File

@@ -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<typeof import("../pluralkit.js")> | undefined;
let preflightAudioRuntimePromise: Promise<typeof import("./preflight-audio.js")> | undefined;
let systemEventsRuntimePromise: Promise<typeof import("./system-events.js")> | undefined;
let discordThreadingRuntimePromise: Promise<typeof import("./threading.js")> | 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<DiscordMessagePreflightContext | null> {

View File

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

View File

@@ -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<typeof resolveDiscordNativeInteractionRouteStateImpl>
| ReturnType<typeof nativeCommandRuntime.resolveDiscordNativeInteractionRouteState>
| 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: {

View File

@@ -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<typeof resolveAgentRoute>;
connection: import("@discordjs/voice").VoiceConnection;
player: import("@discordjs/voice").AudioPlayer;
playbackQueue: Promise<void>;
processingQueue: Promise<void>;
capture: VoiceCaptureState;
receiveRecovery: VoiceReceiveRecoveryState;
stop: () => void;
};
export class DiscordVoiceManager {
private sessions = new Map<string, VoiceSessionEntry>();
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;
}

View File

@@ -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<typeof resolveAgentRoute>;
connection: import("@discordjs/voice").VoiceConnection;
player: import("@discordjs/voice").AudioPlayer;
playbackQueue: Promise<void>;
processingQueue: Promise<void>;
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;
}