mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:40:42 +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";
|
export { discordSetupWizard } from "./setup-surface.js";
|
||||||
|
|
||||||
type DiscordSetupWizard = typeof import("./setup-surface.js").discordSetupWizard;
|
|
||||||
|
|
||||||
export const discordSetupWizard: DiscordSetupWizard = createDiscordSetupWizardProxy(
|
|
||||||
async () => (await import("./setup-surface.js")).discordSetupWizard,
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -14,18 +14,13 @@ import {
|
|||||||
createRuntimeDirectoryLiveAdapter,
|
createRuntimeDirectoryLiveAdapter,
|
||||||
} from "openclaw/plugin-sdk/directory-runtime";
|
} from "openclaw/plugin-sdk/directory-runtime";
|
||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-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 { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import {
|
import {
|
||||||
createComputedAccountStatusAdapter,
|
createComputedAccountStatusAdapter,
|
||||||
createDefaultChannelRuntimeState,
|
createDefaultChannelRuntimeState,
|
||||||
} from "openclaw/plugin-sdk/status-helpers";
|
} from "openclaw/plugin-sdk/status-helpers";
|
||||||
import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/target-resolver-runtime";
|
import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/target-resolver-runtime";
|
||||||
import {
|
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||||
normalizeLowercaseStringOrEmpty,
|
|
||||||
normalizeOptionalString,
|
|
||||||
normalizeOptionalStringifiedId,
|
|
||||||
} from "openclaw/plugin-sdk/text-runtime";
|
|
||||||
import {
|
import {
|
||||||
listDiscordAccountIds,
|
listDiscordAccountIds,
|
||||||
resolveDiscordAccount,
|
resolveDiscordAccount,
|
||||||
@@ -43,7 +38,26 @@ import {
|
|||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
type OpenClawConfig,
|
type OpenClawConfig,
|
||||||
} from "./channel-api.js";
|
} 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 { shouldSuppressLocalDiscordExecApprovalPrompt } from "./exec-approvals.js";
|
||||||
import {
|
import {
|
||||||
resolveDiscordGroupRequireMention,
|
resolveDiscordGroupRequireMention,
|
||||||
@@ -65,62 +79,8 @@ import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
|
|||||||
import { collectDiscordStatusIssues } from "./status-issues.js";
|
import { collectDiscordStatusIssues } from "./status-issues.js";
|
||||||
import { parseDiscordTarget } from "./target-parsing.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 REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
|
||||||
const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000;
|
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: {
|
function shouldTreatDiscordDeliveredTextAsVisible(params: {
|
||||||
kind: "tool" | "block" | "final";
|
kind: "tool" | "block" | "final";
|
||||||
@@ -194,19 +154,6 @@ function formatDiscordIntents(intents?: {
|
|||||||
].join(" ");
|
].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({
|
const resolveDiscordAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({
|
||||||
resolveRecord: (account: ResolvedDiscordAccount) => account.config.guilds,
|
resolveRecord: (account: ResolvedDiscordAccount) => account.config.guilds,
|
||||||
outerLabel: (guildKey) => `guild ${guildKey}`,
|
outerLabel: (guildKey) => `guild ${guildKey}`,
|
||||||
@@ -223,115 +170,6 @@ const resolveDiscordAllowlistNames = createAccountScopedAllowlistNameResolver({
|
|||||||
(await loadDiscordResolveUsersModule()).resolveDiscordUserAllowlist({ token, entries }),
|
(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: {
|
function toConversationLifecycleBinding(binding: {
|
||||||
boundAt: number;
|
boundAt: number;
|
||||||
lastActivityAt?: 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> =
|
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe> =
|
||||||
createChatChannelPlugin<ResolvedDiscordAccount, DiscordProbe>({
|
createChatChannelPlugin<ResolvedDiscordAccount, DiscordProbe>({
|
||||||
base: {
|
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";
|
} from "discord-api-types/v10";
|
||||||
import * as ws from "ws";
|
import * as ws from "ws";
|
||||||
import { Plugin, type Client } from "./client.js";
|
import { Plugin, type Client } from "./client.js";
|
||||||
|
import { canResumeAfterGatewayClose, isFatalGatewayCloseCode } from "./gateway-close-codes.js";
|
||||||
import { dispatchVoiceGatewayEvent, mapGatewayDispatchData } from "./gateway-dispatch.js";
|
import { dispatchVoiceGatewayEvent, mapGatewayDispatchData } from "./gateway-dispatch.js";
|
||||||
import { sharedGatewayIdentifyLimiter } from "./gateway-identify-limiter.js";
|
import { sharedGatewayIdentifyLimiter } from "./gateway-identify-limiter.js";
|
||||||
import { GatewayHeartbeatTimers, GatewayReconnectTimer } from "./gateway-lifecycle.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 {
|
export class GatewayPlugin extends Plugin {
|
||||||
readonly id = "gateway";
|
readonly id = "gateway";
|
||||||
protected client?: Client;
|
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 type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
import { danger } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import {
|
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,
|
type Client,
|
||||||
InteractionCreateListener,
|
InteractionCreateListener,
|
||||||
MessageCreateListener,
|
MessageCreateListener,
|
||||||
MessageReactionAddListener,
|
|
||||||
MessageReactionRemoveListener,
|
|
||||||
PresenceUpdateListener,
|
PresenceUpdateListener,
|
||||||
ThreadUpdateListener,
|
ThreadUpdateListener,
|
||||||
type User,
|
|
||||||
} from "../internal/discord.js";
|
} from "../internal/discord.js";
|
||||||
import {
|
import { discordEventQueueLog, runDiscordListenerWithSlowLog } from "./listeners.queue.js";
|
||||||
isDiscordGroupAllowedByPolicy,
|
export { DiscordReactionListener, DiscordReactionRemoveListener } from "./listeners.reactions.js";
|
||||||
normalizeDiscordAllowList,
|
|
||||||
normalizeDiscordSlug,
|
|
||||||
resolveDiscordAllowListMatch,
|
|
||||||
resolveDiscordChannelConfigWithFallback,
|
|
||||||
resolveDiscordMemberAccessState,
|
|
||||||
resolveGroupDmAllow,
|
|
||||||
resolveDiscordGuildEntry,
|
|
||||||
shouldEmitDiscordReactionNotification,
|
|
||||||
} from "./allow-list.js";
|
|
||||||
import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js";
|
|
||||||
import { setPresence } from "./presence-cache.js";
|
import { setPresence } from "./presence-cache.js";
|
||||||
import { isThreadArchived } from "./thread-bindings.discord-api.js";
|
import { isThreadArchived } from "./thread-bindings.discord-api.js";
|
||||||
import { resolveFetchedDiscordThreadLikeChannelContext } from "./thread-channel-context.js";
|
|
||||||
import { closeDiscordThreadSessions } from "./thread-session-close.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>;
|
type Logger = ReturnType<typeof import("openclaw/plugin-sdk/runtime-env").createSubsystemLogger>;
|
||||||
|
|
||||||
export type DiscordMessageEvent = Parameters<MessageCreateListener["handle"]>[0];
|
export type DiscordMessageEvent = Parameters<MessageCreateListener["handle"]>[0];
|
||||||
@@ -52,118 +24,6 @@ export type DiscordMessageHandler = (
|
|||||||
options?: { abortSignal?: AbortSignal },
|
options?: { abortSignal?: AbortSignal },
|
||||||
) => Promise<void>;
|
) => 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) {
|
export function registerDiscordListener(listeners: Array<object>, listener: object) {
|
||||||
if (listeners.some((existing) => existing.constructor === listener.constructor)) {
|
if (listeners.some((existing) => existing.constructor === listener.constructor)) {
|
||||||
return false;
|
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];
|
type PresenceUpdateEvent = Parameters<PresenceUpdateListener["handle"]>[0];
|
||||||
|
|
||||||
export class DiscordPresenceListener extends PresenceUpdateListener {
|
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,
|
resolvePreflightMentionRequirement,
|
||||||
shouldIgnoreBoundThreadWebhookMessage,
|
shouldIgnoreBoundThreadWebhookMessage,
|
||||||
} from "./message-handler.preflight-helpers.js";
|
} from "./message-handler.preflight-helpers.js";
|
||||||
|
import {
|
||||||
|
isPreflightAborted,
|
||||||
|
loadDiscordThreadingRuntime,
|
||||||
|
loadPluralKitRuntime,
|
||||||
|
loadPreflightAudioRuntime,
|
||||||
|
loadSystemEventsRuntime,
|
||||||
|
} from "./message-handler.preflight-runtime.js";
|
||||||
import type {
|
import type {
|
||||||
DiscordMessagePreflightContext,
|
DiscordMessagePreflightContext,
|
||||||
DiscordMessagePreflightParams,
|
DiscordMessagePreflightParams,
|
||||||
@@ -62,35 +69,6 @@ export {
|
|||||||
shouldIgnoreBoundThreadWebhookMessage,
|
shouldIgnoreBoundThreadWebhookMessage,
|
||||||
} from "./message-handler.preflight-helpers.js";
|
} 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(
|
export async function preflightDiscordMessage(
|
||||||
params: DiscordMessagePreflightParams,
|
params: DiscordMessagePreflightParams,
|
||||||
): Promise<DiscordMessagePreflightContext | null> {
|
): 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 { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||||
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming";
|
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming";
|
||||||
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
|
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 type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||||
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
|
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||||
@@ -17,9 +16,7 @@ import {
|
|||||||
type ChatCommandDefinition,
|
type ChatCommandDefinition,
|
||||||
type NativeCommandSpec,
|
type NativeCommandSpec,
|
||||||
} from "openclaw/plugin-sdk/native-command-registry";
|
} 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 { 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 { createSubsystemLogger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
|
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
|
||||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||||
@@ -60,7 +57,6 @@ import {
|
|||||||
isDiscordUnknownInteraction,
|
isDiscordUnknownInteraction,
|
||||||
safeDiscordInteractionCall,
|
safeDiscordInteractionCall,
|
||||||
} from "./native-command-reply.js";
|
} from "./native-command-reply.js";
|
||||||
import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js";
|
|
||||||
import { maybeDeliverDiscordDirectStatus } from "./native-command-status.js";
|
import { maybeDeliverDiscordDirectStatus } from "./native-command-status.js";
|
||||||
import {
|
import {
|
||||||
buildDiscordCommandArgMenu,
|
buildDiscordCommandArgMenu,
|
||||||
@@ -78,55 +74,14 @@ import {
|
|||||||
buildDiscordCommandOptions,
|
buildDiscordCommandOptions,
|
||||||
truncateDiscordCommandDescription,
|
truncateDiscordCommandDescription,
|
||||||
} from "./native-command.options.js";
|
} from "./native-command.options.js";
|
||||||
|
import { nativeCommandRuntime } from "./native-command.runtime.js";
|
||||||
import type { DiscordCommandArgs, DiscordConfig } from "./native-command.types.js";
|
import type { DiscordCommandArgs, DiscordConfig } from "./native-command.types.js";
|
||||||
import { resolveDiscordNativeInteractionChannelContext } from "./native-interaction-channel-context.js";
|
import { resolveDiscordNativeInteractionChannelContext } from "./native-interaction-channel-context.js";
|
||||||
import { resolveDiscordSenderIdentity } from "./sender-identity.js";
|
import { resolveDiscordSenderIdentity } from "./sender-identity.js";
|
||||||
import type { ThreadBindingManager } from "./thread-bindings.js";
|
import type { ThreadBindingManager } from "./thread-bindings.js";
|
||||||
|
|
||||||
const log = createSubsystemLogger("discord/native-command");
|
const log = createSubsystemLogger("discord/native-command");
|
||||||
let matchPluginCommandImpl = pluginRuntime.matchPluginCommand;
|
export { __testing } from "./native-command.runtime.js";
|
||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function shouldBypassConfiguredAcpEnsure(commandName: string): boolean {
|
function shouldBypassConfiguredAcpEnsure(commandName: string): boolean {
|
||||||
const normalized = normalizeLowercaseStringOrEmpty(commandName);
|
const normalized = normalizeLowercaseStringOrEmpty(commandName);
|
||||||
@@ -160,7 +115,7 @@ export function createDiscordNativeCommand(params: {
|
|||||||
} = params;
|
} = params;
|
||||||
const fallbackCommandDefinition = createNativeCommandDefinition(command);
|
const fallbackCommandDefinition = createNativeCommandDefinition(command);
|
||||||
const commandDefinition =
|
const commandDefinition =
|
||||||
matchPluginCommandImpl(`/${command.name}`) !== null
|
nativeCommandRuntime.matchPluginCommand(`/${command.name}`) !== null
|
||||||
? fallbackCommandDefinition
|
? fallbackCommandDefinition
|
||||||
: (findCommandByNativeName(command.name, "discord", {
|
: (findCommandByNativeName(command.name, "discord", {
|
||||||
includeBundledChannelFallback: false,
|
includeBundledChannelFallback: false,
|
||||||
@@ -366,10 +321,10 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
let nativeRouteStatePromise:
|
let nativeRouteStatePromise:
|
||||||
| ReturnType<typeof resolveDiscordNativeInteractionRouteStateImpl>
|
| ReturnType<typeof nativeCommandRuntime.resolveDiscordNativeInteractionRouteState>
|
||||||
| undefined;
|
| undefined;
|
||||||
const getNativeRouteState = () =>
|
const getNativeRouteState = () =>
|
||||||
(nativeRouteStatePromise ??= resolveDiscordNativeInteractionRouteStateImpl({
|
(nativeRouteStatePromise ??= nativeCommandRuntime.resolveDiscordNativeInteractionRouteState({
|
||||||
cfg,
|
cfg,
|
||||||
accountId,
|
accountId,
|
||||||
guildId: interaction.guild?.id ?? undefined,
|
guildId: interaction.guild?.id ?? undefined,
|
||||||
@@ -560,7 +515,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
return { accepted: true };
|
return { accepted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginMatch = matchPluginCommandImpl(prompt);
|
const pluginMatch = nativeCommandRuntime.matchPluginCommand(prompt);
|
||||||
if (pluginMatch && commandName !== "status") {
|
if (pluginMatch && commandName !== "status") {
|
||||||
if (suppressReplies) {
|
if (suppressReplies) {
|
||||||
return { accepted: true };
|
return { accepted: true };
|
||||||
@@ -569,7 +524,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
const messageThreadId = !isDirectMessage && isThreadChannel ? channelId : undefined;
|
const messageThreadId = !isDirectMessage && isThreadChannel ? channelId : undefined;
|
||||||
const pluginThreadParentId = !isDirectMessage && isThreadChannel ? threadParentId : undefined;
|
const pluginThreadParentId = !isDirectMessage && isThreadChannel ? threadParentId : undefined;
|
||||||
const { effectiveRoute } = await getNativeRouteState();
|
const { effectiveRoute } = await getNativeRouteState();
|
||||||
const pluginReply = await executePluginCommandImpl({
|
const pluginReply = await nativeCommandRuntime.executePluginCommand({
|
||||||
command: pluginMatch.command,
|
command: pluginMatch.command,
|
||||||
args: pluginMatch.args,
|
args: pluginMatch.args,
|
||||||
senderId: sender.id,
|
senderId: sender.id,
|
||||||
@@ -652,7 +607,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
const directStatusResult = await maybeDeliverDiscordDirectStatus({
|
const directStatusResult = await maybeDeliverDiscordDirectStatus({
|
||||||
commandName,
|
commandName,
|
||||||
suppressReplies,
|
suppressReplies,
|
||||||
resolveDirectStatusReplyForSession: resolveDirectStatusReplyForSessionImpl,
|
resolveDirectStatusReplyForSession: nativeCommandRuntime.resolveDirectStatusReplyForSession,
|
||||||
cfg,
|
cfg,
|
||||||
discordConfig,
|
discordConfig,
|
||||||
accountId,
|
accountId,
|
||||||
@@ -713,7 +668,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig);
|
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig);
|
||||||
|
|
||||||
let didReply = false;
|
let didReply = false;
|
||||||
const dispatchResult = await dispatchReplyWithDispatcherImpl({
|
const dispatchResult = await nativeCommandRuntime.dispatchReplyWithDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime";
|
|||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||||
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-types";
|
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-types";
|
||||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
|
||||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||||
import { resolveDiscordAccountAllowFrom } from "../accounts.js";
|
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 type { VoicePlugin } from "../internal/voice.js";
|
||||||
import { formatMention } from "../mentions.js";
|
import { formatMention } from "../mentions.js";
|
||||||
import { normalizeDiscordSlug } from "../monitor/allow-list.js";
|
import { normalizeDiscordSlug } from "../monitor/allow-list.js";
|
||||||
@@ -24,7 +23,6 @@ import {
|
|||||||
isVoiceCaptureActive,
|
isVoiceCaptureActive,
|
||||||
scheduleVoiceCaptureFinalize,
|
scheduleVoiceCaptureFinalize,
|
||||||
stopVoiceCaptureState,
|
stopVoiceCaptureState,
|
||||||
type VoiceCaptureState,
|
|
||||||
} from "./capture-state.js";
|
} from "./capture-state.js";
|
||||||
import { formatVoiceIngressPrompt } from "./prompt.js";
|
import { formatVoiceIngressPrompt } from "./prompt.js";
|
||||||
import {
|
import {
|
||||||
@@ -36,47 +34,24 @@ import {
|
|||||||
finishVoiceDecryptRecovery,
|
finishVoiceDecryptRecovery,
|
||||||
noteVoiceDecryptFailure,
|
noteVoiceDecryptFailure,
|
||||||
resetVoiceReceiveRecoveryState,
|
resetVoiceReceiveRecoveryState,
|
||||||
type VoiceReceiveRecoveryState,
|
|
||||||
} from "./receive-recovery.js";
|
} from "./receive-recovery.js";
|
||||||
import { loadDiscordVoiceSdk } from "./sdk-runtime.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 { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js";
|
||||||
import { synthesizeVoiceReplyAudio, transcribeVoiceAudio } from "./tts.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 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 {
|
export class DiscordVoiceManager {
|
||||||
private sessions = new Map<string, VoiceSessionEntry>();
|
private sessions = new Map<string, VoiceSessionEntry>();
|
||||||
private botUserId?: string;
|
private botUserId?: string;
|
||||||
@@ -723,7 +698,3 @@ export class DiscordVoiceReadyListener extends ReadyListener {
|
|||||||
.catch((err) => logger.warn(`discord voice: autoJoin failed: ${formatErrorMessage(err)}`));
|
.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