mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
refactor(discord): split runtime helpers
This commit is contained in:
159
extensions/discord/src/channel.conversation.ts
Normal file
159
extensions/discord/src/channel.conversation.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
47
extensions/discord/src/channel.loaders.ts
Normal file
47
extensions/discord/src/channel.loaders.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
25
extensions/discord/src/internal/gateway-close-codes.ts
Normal file
25
extensions/discord/src/internal/gateway-close-codes.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
91
extensions/discord/src/monitor/listeners.queue.ts
Normal file
91
extensions/discord/src/monitor/listeners.queue.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
610
extensions/discord/src/monitor/listeners.reactions.ts
Normal file
610
extensions/discord/src/monitor/listeners.reactions.ts
Normal 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)}`));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
50
extensions/discord/src/monitor/native-command.runtime.ts
Normal file
50
extensions/discord/src/monitor/native-command.runtime.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
42
extensions/discord/src/voice/session.ts
Normal file
42
extensions/discord/src/voice/session.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user