Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
# OpenClaw Discord
Official OpenClaw channel plugin for Discord servers, channels, DMs, slash commands, and app events.
Install from OpenClaw:
```bash
openclaw plugin add @openclaw/discord
```
Configure a Discord bot token and the channels or servers OpenClaw should handle. The plugin lets OpenClaw agents receive Discord messages and respond through the configured Discord app.

View File

@@ -0,0 +1,7 @@
// Discord API module exposes the plugin public contract.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { inspectDiscordAccount } from "./src/account-inspect.js";
export function inspectDiscordReadOnlyAccount(cfg: OpenClawConfig, accountId?: string | null) {
return inspectDiscordAccount({ cfg, accountId });
}

View File

@@ -0,0 +1,2 @@
// Discord API module exposes the plugin public contract.
export { handleDiscordAction } from "./src/actions/runtime.js";

131
extensions/discord/api.ts Normal file
View File

@@ -0,0 +1,131 @@
// Discord API module exposes the plugin public contract.
export { discordPlugin } from "./src/channel.js";
export { discordSetupPlugin } from "./src/channel.setup.js";
export {
handleDiscordSubagentDeliveryTarget,
handleDiscordSubagentEnded,
handleDiscordSubagentSpawning,
} from "./src/subagent-hooks.js";
export { inspectDiscordAccount, type InspectedDiscordAccount } from "./src/account-inspect.js";
export { type DiscordCredentialStatus } from "./src/token.js";
export {
createDiscordActionGate,
listDiscordAccountIds,
listEnabledDiscordAccounts,
mergeDiscordAccountConfig,
type ResolvedDiscordAccount,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
resolveDiscordAccountConfig,
resolveDiscordMaxLinesPerMessage,
} from "./src/accounts.js";
export { tryHandleDiscordMessageActionGuildAdmin } from "./src/actions/handle-action.guild-admin.js";
export { DiscordApiError, fetchDiscord, requestDiscord } from "./src/api.js";
export { buildDiscordComponentMessage } from "./src/components.js";
type DiscordMessageActionHandler =
typeof import("./src/channel-actions.runtime.js").handleDiscordMessageAction;
// Deprecated compatibility surface for existing @openclaw/discord/api.js consumers.
export const handleDiscordMessageAction: DiscordMessageActionHandler = async (...args) =>
(await import("./src/channel-actions.runtime.js")).handleDiscordMessageAction(...args);
export {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
} from "./src/directory-config.js";
export {
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
} from "./src/group-policy.js";
export {
looksLikeDiscordTargetId,
normalizeDiscordMessagingTarget,
normalizeDiscordOutboundTarget,
} from "./src/normalize.js";
export { resolveOpenProviderRuntimeGroupPolicy as resolveDiscordRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
export { collectDiscordStatusIssues } from "./src/status-issues.js";
export {
buildDiscordComponentCustomId,
buildDiscordComponentMessageFlags,
buildDiscordInteractiveComponents,
buildDiscordModalCustomId,
createDiscordFormModal,
DISCORD_COMPONENT_ATTACHMENT_PREFIX,
DISCORD_COMPONENT_CUSTOM_ID_KEY,
DISCORD_MODAL_CUSTOM_ID_KEY,
DiscordFormModal,
formatDiscordComponentEventText,
parseDiscordComponentCustomId,
parseDiscordComponentCustomIdForInteraction,
parseDiscordComponentCustomIdForInteraction as parseDiscordComponentCustomIdForCarbon,
parseDiscordModalCustomId,
parseDiscordModalCustomIdForInteraction,
parseDiscordModalCustomIdForInteraction as parseDiscordModalCustomIdForCarbon,
readDiscordComponentSpec,
resolveDiscordComponentAttachmentName,
type ComponentData,
type DiscordComponentBlock,
type DiscordComponentBuildResult,
type DiscordComponentButtonSpec,
type DiscordComponentButtonStyle,
type DiscordComponentEntry,
type DiscordComponentMessageSpec,
type DiscordComponentModalFieldType,
type DiscordComponentSectionAccessory,
type DiscordComponentSelectOption,
type DiscordComponentSelectSpec,
type DiscordComponentSelectType,
type DiscordModalEntry,
type DiscordModalFieldDefinition,
type DiscordModalFieldSpec,
type DiscordModalSpec,
} from "./src/components.js";
export {
getDiscordExecApprovalApprovers,
isDiscordExecApprovalApprover,
isDiscordExecApprovalClientEnabled,
shouldSuppressLocalDiscordExecApprovalPrompt,
} from "./src/exec-approvals.js";
export type {
DiscordInteractiveHandlerContext,
DiscordInteractiveHandlerRegistration,
} from "./src/interactive-dispatch.js";
export {
type DiscordPluralKitConfig,
fetchPluralKitMessageInfo,
type PluralKitMemberInfo,
type PluralKitMessageInfo,
type PluralKitSystemInfo,
} from "./src/pluralkit.js";
export {
fetchDiscordApplicationId,
fetchDiscordApplicationSummary,
parseApplicationIdFromToken,
probeDiscord,
resolveDiscordPrivilegedIntentsFromFlags,
type DiscordApplicationSummary,
type DiscordPrivilegedIntentsSummary,
type DiscordPrivilegedIntentStatus,
type DiscordProbe,
} from "./src/probe.js";
export { normalizeExplicitDiscordSessionKey } from "./src/session-key-normalization.js";
export { parseDiscordSendTarget, type SendDiscordTarget } from "./src/send-target-parsing.js";
export {
parseDiscordTarget,
resolveDiscordChannelId,
resolveDiscordTarget,
type DiscordTarget,
type DiscordTargetKind,
type DiscordTargetParseOptions,
} from "./src/targets.js";
export { collectDiscordSecurityAuditFindings } from "./src/security-audit.js";
export {
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS,
DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS,
DISCORD_DEFAULT_LISTENER_TIMEOUT_MS,
mergeAbortSignals,
} from "./src/monitor/timeouts.js";
export type { DiscordSendComponents, DiscordSendEmbeds } from "./src/send.shared.js";
export type { DiscordSendResult } from "./src/send.types.js";
export type { DiscordTokenResolution } from "./src/token.js";

View File

@@ -0,0 +1,2 @@
// Discord API module exposes the plugin public contract.
export { DiscordChannelConfigSchema } from "./src/config-schema.js";

View File

@@ -0,0 +1,3 @@
// Keep bundled channel entry imports narrow so bootstrap/discovery paths do
// not drag setup-only surfaces into lightweight channel plugin loads.
export { discordPlugin } from "./src/channel.js";

View File

@@ -0,0 +1,5 @@
// Discord API module exposes the plugin public contract.
export {
buildChannelConfigSchema,
DiscordConfigSchema,
} from "openclaw/plugin-sdk/bundled-channel-config-schema";

View File

@@ -0,0 +1,7 @@
// Discord helper module supports configured state behavior.
export function hasDiscordConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
return (
typeof params.env?.DISCORD_BOT_TOKEN === "string" &&
params.env.DISCORD_BOT_TOKEN.trim().length > 0
);
}

View File

@@ -0,0 +1,6 @@
// Discord API module exposes the plugin public contract.
export { deriveLegacySessionChatType } from "./src/session-contract.js";
export type {
DiscordInteractiveHandlerContext,
DiscordInteractiveHandlerRegistration,
} from "./src/interactive-dispatch.js";

View File

@@ -0,0 +1,15 @@
// Discord API module exposes the plugin public contract.
import {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
} from "./src/directory-config.js";
export { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig };
export const discordDirectoryContractPlugin = {
id: "discord",
directory: {
listPeers: listDiscordDirectoryPeersFromConfig,
listGroups: listDiscordDirectoryGroupsFromConfig,
},
};

View File

@@ -0,0 +1,2 @@
// Discord API module exposes the plugin public contract.
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";

View File

@@ -0,0 +1,14 @@
// Discord tests cover index plugin behavior.
import { assertBundledChannelEntries } from "openclaw/plugin-sdk/channel-test-helpers";
import { describe } from "vitest";
import entry from "./index.js";
import setupEntry from "./setup-entry.js";
describe("discord bundled entries", () => {
assertBundledChannelEntries({
entry,
expectedId: "discord",
expectedName: "Discord",
setupEntry,
});
});

View File

@@ -0,0 +1,27 @@
// Discord plugin entrypoint registers its OpenClaw integration.
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
import { registerDiscordSubagentHooks } from "./subagent-hooks-api.js";
import { discordVoiceTranscriptsSourceProvider } from "./transcripts-source-api.js";
export default defineBundledChannelEntry({
id: "discord",
name: "Discord",
description: "Discord channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "discordPlugin",
},
runtime: {
specifier: "./runtime-setter-api.js",
exportName: "setDiscordRuntime",
},
accountInspect: {
specifier: "./account-inspect-api.js",
exportName: "inspectDiscordReadOnlyAccount",
},
registerFull(api) {
registerDiscordSubagentHooks(api);
api.registerTranscriptSourceProvider(discordVoiceTranscriptsSourceProvider);
},
});

View File

@@ -0,0 +1,2 @@
// Discord API module exposes the plugin public contract.
export { detectDiscordLegacyStateMigrations } from "./src/monitor/model-picker-preferences-migrations.js";

438
extensions/discord/npm-shrinkwrap.json generated Normal file
View File

@@ -0,0 +1,438 @@
{
"name": "@openclaw/discord",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/discord",
"version": "2026.6.11",
"dependencies": {
"@discordjs/voice": "0.19.2",
"discord-api-types": "0.38.49",
"libopus-wasm": "0.2.0",
"typebox": "1.3.3",
"undici": "8.5.0",
"ws": "8.21.0"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
}
},
"node_modules/@discordjs/voice": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.19.2.tgz",
"integrity": "sha512-3yJ255e4ag3wfZu/DSxeOZK1UtnqNxnspmLaQetGT0pDkThNZoHs+Zg6dgZZ19JEVomXygvfHn9lNpICZuYtEA==",
"license": "Apache-2.0",
"dependencies": {
"@snazzah/davey": "^0.1.9",
"@types/ws": "^8.18.1",
"discord-api-types": "^0.38.41",
"prism-media": "^1.3.5",
"tslib": "^2.8.1",
"ws": "^8.19.0"
},
"engines": {
"node": ">=22.12.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@snazzah/davey": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@snazzah/davey/-/davey-0.1.12.tgz",
"integrity": "sha512-V+NlX5931RwVamZhhEfZekMdcvXDKdMAmHW1AuGaykVQsNyBOq3bpmGpoKRBDCYgFWKIufJ0Dcg3m4cYhvUy6g==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"funding": {
"url": "https://github.com/sponsors/Snazzah"
},
"optionalDependencies": {
"@snazzah/davey-android-arm-eabi": "0.1.12",
"@snazzah/davey-android-arm64": "0.1.12",
"@snazzah/davey-darwin-arm64": "0.1.12",
"@snazzah/davey-darwin-x64": "0.1.12",
"@snazzah/davey-freebsd-x64": "0.1.12",
"@snazzah/davey-linux-arm-gnueabihf": "0.1.12",
"@snazzah/davey-linux-arm64-gnu": "0.1.12",
"@snazzah/davey-linux-arm64-musl": "0.1.12",
"@snazzah/davey-linux-x64-gnu": "0.1.12",
"@snazzah/davey-linux-x64-musl": "0.1.12",
"@snazzah/davey-wasm32-wasi": "0.1.12",
"@snazzah/davey-win32-arm64-msvc": "0.1.12",
"@snazzah/davey-win32-ia32-msvc": "0.1.12",
"@snazzah/davey-win32-x64-msvc": "0.1.12"
}
},
"node_modules/@snazzah/davey-android-arm-eabi": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm-eabi/-/davey-android-arm-eabi-0.1.12.tgz",
"integrity": "sha512-6VC/an+Sx5dI5skb+90rYcIB1jhm48Rl0nDaw0UNT4bz1rMjpVfmmZqeocYXMq96IdbBMlE6OTKGcBm2C3gkQg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@snazzah/davey-android-arm64": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm64/-/davey-android-arm64-0.1.12.tgz",
"integrity": "sha512-0Bwd03/JsTFhlPhF4q/LW0RxSzntFpQdhz+TBdFljYSg8IEyA38saPJeTNjpIgDfhAumPzvhCdfS6O5qT8yXDw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@snazzah/davey-darwin-arm64": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-arm64/-/davey-darwin-arm64-0.1.12.tgz",
"integrity": "sha512-lKMV6ITi9BQLt0fx/pAT7M8xcojVK7bryVJGdaW3bq8gABFslS3ti/KzrWabQvhpEV71FZe5mV0UcKHFVaTsZw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@snazzah/davey-darwin-x64": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-x64/-/davey-darwin-x64-0.1.12.tgz",
"integrity": "sha512-vXXc/eW/e3TQeb7VsdtrPqs3/22j0aSqiP1ZXmZtDjQRBwgSxwItWYa6sh5MELP2EHB2igNlGzB6Hc0XlbGi4g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@snazzah/davey-freebsd-x64": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@snazzah/davey-freebsd-x64/-/davey-freebsd-x64-0.1.12.tgz",
"integrity": "sha512-G1gas5HrC4Xp3mRY0+OeAqXS6fG2tRgBEc8gQh69Hw4YK9RV9mzQKcmoKMkBM72U1+2C+2u57dolnKKzwozByQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@snazzah/davey-linux-arm-gnueabihf": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm-gnueabihf/-/davey-linux-arm-gnueabihf-0.1.12.tgz",
"integrity": "sha512-97Fujh82r2Ll7dPZeNuoZ3yKfsqycf3c93OWXOo/ThNL/18Onl2Ht4SIvpX6VHhfeS9bDpHJ1lHCaz1b/i5ocw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@snazzah/davey-linux-arm64-gnu": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-gnu/-/davey-linux-arm64-gnu-0.1.12.tgz",
"integrity": "sha512-FWyAOv52cHKDM4BOsmImKKogHFvqNFoXmZcicNJbX3XpVl8Mas88ZoXQ+IA5V+qc9pNtAl1MbWwEZ9JrqAQtbg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@snazzah/davey-linux-arm64-musl": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-musl/-/davey-linux-arm64-musl-0.1.12.tgz",
"integrity": "sha512-ptRbLSQxtV6EjXppS5z7qaPDI0NRKhrkJYsTlAjEghmOvlAObozSCYYnMO6nbkt6Ab3+lWqyahClzcRNcD2ouw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@snazzah/davey-linux-x64-gnu": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-gnu/-/davey-linux-x64-gnu-0.1.12.tgz",
"integrity": "sha512-w86fZvhJn0ErOoAQHt2UbQ95V/cgwvfvQ4GlTPQLCzt58nn+rLlXLgPn90qYlSQrZxFW38rKXwqVOMbm9p+pwQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@snazzah/davey-linux-x64-musl": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-musl/-/davey-linux-x64-musl-0.1.12.tgz",
"integrity": "sha512-LLNnO+hfG41ymeI+O1YHo5/0h3aKaetUNLdkBwpdJsjoyKMXZaeCnB+aHNkkupJCMPmWS0g6iPMCHUOqZSBcTg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@snazzah/davey-wasm32-wasi": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@snazzah/davey-wasm32-wasi/-/davey-wasm32-wasi-0.1.12.tgz",
"integrity": "sha512-MPKFuqYVkDFXheR7qmtEY4FWxQ/ADfgsCojQWHi13sibUqCTR9q2F1LqNn2i9IVh3sh1sxeg87fdFMCH63pl7g==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.5"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@snazzah/davey-win32-arm64-msvc": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@snazzah/davey-win32-arm64-msvc/-/davey-win32-arm64-msvc-0.1.12.tgz",
"integrity": "sha512-Uf4OYHyfbXpzyaOqIV8/h6kv166Qni5+Bxmc1E/ov4uhhKO8qXbOky8zbVOzu0U4cv5ll3s4IQ8jDAJkx/K75Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@snazzah/davey-win32-ia32-msvc": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@snazzah/davey-win32-ia32-msvc/-/davey-win32-ia32-msvc-0.1.12.tgz",
"integrity": "sha512-nRVbKTsb2ldcPI8D4BDA7P/UeiMEMvR+wYuUMp7H1pRD/3dF2hKo+MzUU+Pn78EhuYA3CWHItiAcS/GsPld67A==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@snazzah/davey-win32-x64-msvc": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@snazzah/davey-win32-x64-msvc/-/davey-win32-x64-msvc-0.1.12.tgz",
"integrity": "sha512-AgUA3itPDVkxQq7RIkgE1thCiWePwWjyfOZefBBxGIlMWpRUOSwF4vY9kNLnrScyJcFULdR+Zm8PwiwV8/RKnw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/node": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
"integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/discord-api-types": {
"version": "0.38.49",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz",
"integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==",
"license": "MIT",
"workspaces": [
"scripts/actions/documentation"
]
},
"node_modules/libopus-wasm": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/libopus-wasm/-/libopus-wasm-0.2.0.tgz",
"integrity": "sha512-x/2Gu1/C6L3IICY09zyfp984AWiOYjn53u4WfdY3yh+3KTzMN8Xkm77q3lenWMVIk5SnSzjGEkQT+VQMFHLBHQ==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/prism-media": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz",
"integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==",
"license": "Apache-2.0",
"peerDependencies": {
"@discordjs/opus": ">=0.8.0 <1.0.0",
"ffmpeg-static": "^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0",
"node-opus": "^0.3.3",
"opusscript": "^0.0.8"
},
"peerDependenciesMeta": {
"@discordjs/opus": {
"optional": true
},
"ffmpeg-static": {
"optional": true
},
"node-opus": {
"optional": true
},
"opusscript": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typebox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.3.tgz",
"integrity": "sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==",
"license": "MIT"
},
"node_modules/undici": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz",
"integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@@ -0,0 +1,22 @@
{
"id": "discord",
"name": "Discord",
"description": "OpenClaw Discord channel plugin for channels, DMs, commands, and app events.",
"icon": "https://cdn.simpleicons.org/discord",
"skills": ["./skills"],
"activation": {
"onStartup": false
},
"channels": ["discord"],
"contracts": {
"transcriptSourceProviders": ["discord-voice"]
},
"channelEnvVars": {
"discord": ["DISCORD_BOT_TOKEN"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,80 @@
{
"name": "@openclaw/discord",
"version": "2026.6.11",
"description": "OpenClaw Discord channel plugin for channels, DMs, commands, and app events.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"@discordjs/voice": "0.19.2",
"discord-api-types": "0.38.49",
"libopus-wasm": "0.2.0",
"typebox": "1.3.3",
"undici": "8.5.0",
"ws": "8.21.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"legacyStateMigrations": true
},
"channel": {
"id": "discord",
"label": "Discord",
"selectionLabel": "Discord (Bot API)",
"detailLabel": "Discord Bot",
"docsPath": "/channels/discord",
"docsLabel": "discord",
"blurb": "very well supported right now.",
"systemImage": "bubble.left.and.bubble.right",
"markdownCapable": true,
"preferSessionLookupForAnnounceTarget": true,
"commands": {
"nativeCommandsAutoEnabled": true,
"nativeSkillsAutoEnabled": true
},
"configuredState": {
"env": {
"allOf": [
"DISCORD_BOT_TOKEN"
]
},
"specifier": "./configured-state",
"exportName": "hasDiscordConfiguredState"
}
},
"install": {
"npmSpec": "@openclaw/discord",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.26",
"allowInvalidConfigRecovery": true
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,16 @@
// Discord plugin module implements runtime api.actions behavior.
export { handleDiscordAction } from "./src/actions/runtime.js";
export {
isDiscordModerationAction,
readDiscordModerationCommand,
requiredGuildPermissionForModerationAction,
type DiscordModerationAction,
type DiscordModerationCommand,
} from "./src/actions/runtime.moderation-shared.js";
export {
readDiscordChannelCreateParams,
readDiscordChannelEditParams,
readDiscordChannelMoveParams,
readDiscordParentIdParam,
} from "./src/actions/runtime.shared.js";
export { discordMessageActions } from "./src/channel-actions.js";

View File

@@ -0,0 +1,23 @@
// Discord plugin module implements runtime api.lookup behavior.
export { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./src/audit.js";
export {
listDiscordDirectoryGroupsLive,
listDiscordDirectoryPeersLive,
} from "./src/directory-live.js";
export {
fetchDiscordApplicationId,
fetchDiscordApplicationSummary,
parseApplicationIdFromToken,
probeDiscord,
resolveDiscordPrivilegedIntentsFromFlags,
type DiscordApplicationSummary,
type DiscordPrivilegedIntentsSummary,
type DiscordPrivilegedIntentStatus,
type DiscordProbe,
} from "./src/probe.js";
export {
resolveDiscordChannelAllowlist,
type DiscordChannelResolution,
} from "./src/resolve-channels.js";
export { resolveDiscordUserAllowlist, type DiscordUserResolution } from "./src/resolve-users.js";
export { setDiscordRuntime } from "./src/runtime.js";

View File

@@ -0,0 +1,55 @@
// Discord plugin module implements runtime api.monitor behavior.
export {
allowListMatches,
buildDiscordMediaPayload,
createDiscordMessageHandler,
createDiscordNativeCommand,
isDiscordGroupAllowedByPolicy,
monitorDiscordProvider,
normalizeDiscordAllowList,
normalizeDiscordSlug,
registerDiscordListener,
resolveDiscordChannelConfig,
resolveDiscordChannelConfigWithFallback,
resolveDiscordCommandAuthorized,
resolveDiscordGuildEntry,
resolveDiscordReplyTarget,
resolveDiscordShouldRequireMention,
resolveGroupDmAllow,
sanitizeDiscordThreadName,
shouldEmitDiscordReactionNotification,
type DiscordAllowList,
type DiscordChannelConfigResolved,
type DiscordGuildEntryResolved,
type DiscordMessageEvent,
type DiscordMessageHandler,
type MonitorDiscordOpts,
} from "./src/monitor.js";
export {
createDiscordGatewayPlugin,
resolveDiscordGatewayIntents,
waitForDiscordGatewayPluginRegistration,
} from "./src/monitor/gateway-plugin.js";
export {
clearGateways,
getGateway,
registerGateway,
unregisterGateway,
} from "./src/monitor/gateway-registry.js";
export {
clearPresences,
getPresence,
presenceCacheSize,
setPresence,
} from "./src/monitor/presence-cache.js";
export {
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS,
DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS,
DISCORD_DEFAULT_LISTENER_TIMEOUT_MS,
isAbortError,
mergeAbortSignals,
normalizeDiscordInboundWorkerTimeoutMs,
normalizeDiscordListenerTimeoutMs,
runDiscordTaskWithTimeout,
} from "./src/monitor/timeouts.js";

View File

@@ -0,0 +1,80 @@
// Discord plugin module implements runtime api.send behavior.
export {
resolveDiscordOutboundSessionRoute,
type ResolveDiscordOutboundSessionRouteParams,
} from "./src/outbound-session-route.js";
export {
addRoleDiscord,
banMemberDiscord,
createChannelDiscord,
createScheduledEventDiscord,
createThreadDiscord,
deleteChannelDiscord,
deleteMessageDiscord,
DiscordSendError,
editChannelDiscord,
editMessageDiscord,
fetchChannelInfoDiscord,
fetchChannelPermissionsDiscord,
fetchMemberGuildPermissionsDiscord,
fetchMemberInfoDiscord,
fetchMessageDiscord,
fetchReactionsDiscord,
fetchRoleInfoDiscord,
fetchVoiceStatusDiscord,
hasAllGuildPermissionsDiscord,
hasAnyGuildPermissionDiscord,
kickMemberDiscord,
listGuildChannelsDiscord,
listGuildEmojisDiscord,
listPinsDiscord,
listScheduledEventsDiscord,
listThreadsDiscord,
moveChannelDiscord,
pinMessageDiscord,
reactMessageDiscord,
readMessagesDiscord,
removeChannelPermissionDiscord,
removeOwnReactionsDiscord,
removeReactionDiscord,
removeRoleDiscord,
resolveEventCoverImage,
searchMessagesDiscord,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
sendTypingDiscord,
sendVoiceMessageDiscord,
sendWebhookMessageDiscord,
setChannelPermissionDiscord,
timeoutMemberDiscord,
unpinMessageDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
type DiscordChannelCreate,
type DiscordChannelEdit,
type DiscordChannelMove,
type DiscordChannelPermissionSet,
type DiscordEmojiUpload,
type DiscordMessageEdit,
type DiscordMessageQuery,
type DiscordModerationTarget,
type DiscordPermissionsSummary,
type DiscordReactionRuntimeContext,
type DiscordReactionSummary,
type DiscordReactionUser,
type DiscordReactOpts,
type DiscordRoleChange,
type DiscordRuntimeAccountContext,
type DiscordSearchQuery,
type DiscordSendResult,
type DiscordStickerUpload,
type DiscordThreadCreate,
type DiscordThreadList,
type DiscordTimeoutTarget,
} from "./src/send.js";
export {
editDiscordComponentMessage,
registerBuiltDiscordComponentMessage,
sendDiscordComponentMessage,
} from "./src/send.components.js";

View File

@@ -0,0 +1,32 @@
// Discord plugin module implements runtime api.threads behavior.
export {
testing as __testing,
testing,
autoBindSpawnedDiscordSubagent,
createNoopThreadBindingManager,
createThreadBindingManager,
formatThreadBindingDurationLabel,
getThreadBindingManager,
isRecentlyUnboundThreadWebhookMessage,
listThreadBindingsBySessionKey,
listThreadBindingsForAccount,
reconcileAcpThreadBindingsOnStartup,
resolveDiscordThreadBindingIdleTimeoutMs,
resolveDiscordThreadBindingMaxAgeMs,
resolveThreadBindingIdleTimeoutMs,
resolveThreadBindingInactivityExpiresAt,
resolveThreadBindingIntroText,
resolveThreadBindingMaxAgeExpiresAt,
resolveThreadBindingMaxAgeMs,
resolveThreadBindingPersona,
resolveThreadBindingPersonaFromRecord,
resolveThreadBindingsEnabled,
resolveThreadBindingThreadName,
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
unbindThreadBindingsBySessionKey,
type AcpThreadBindingReconciliationResult,
type ThreadBindingManager,
type ThreadBindingRecord,
type ThreadBindingTargetKind,
} from "./src/monitor/thread-bindings.js";

View File

@@ -0,0 +1,186 @@
// Discord API module exposes the plugin public contract.
export {
discordMessageActions,
handleDiscordAction,
isDiscordModerationAction,
readDiscordChannelCreateParams,
readDiscordChannelEditParams,
readDiscordChannelMoveParams,
readDiscordModerationCommand,
readDiscordParentIdParam,
requiredGuildPermissionForModerationAction,
type DiscordModerationAction,
type DiscordModerationCommand,
} from "./runtime-api.actions.js";
export {
auditDiscordChannelPermissions,
collectDiscordAuditChannelIds,
fetchDiscordApplicationId,
fetchDiscordApplicationSummary,
listDiscordDirectoryGroupsLive,
listDiscordDirectoryPeersLive,
parseApplicationIdFromToken,
probeDiscord,
resolveDiscordChannelAllowlist,
resolveDiscordPrivilegedIntentsFromFlags,
resolveDiscordUserAllowlist,
setDiscordRuntime,
type DiscordApplicationSummary,
type DiscordChannelResolution,
type DiscordPrivilegedIntentsSummary,
type DiscordPrivilegedIntentStatus,
type DiscordProbe,
type DiscordUserResolution,
} from "./runtime-api.lookup.js";
export {
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS,
DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS,
DISCORD_DEFAULT_LISTENER_TIMEOUT_MS,
allowListMatches,
buildDiscordMediaPayload,
clearGateways,
clearPresences,
createDiscordGatewayPlugin,
createDiscordMessageHandler,
createDiscordNativeCommand,
getGateway,
getPresence,
isAbortError,
isDiscordGroupAllowedByPolicy,
mergeAbortSignals,
monitorDiscordProvider,
normalizeDiscordAllowList,
normalizeDiscordInboundWorkerTimeoutMs,
normalizeDiscordListenerTimeoutMs,
normalizeDiscordSlug,
presenceCacheSize,
registerDiscordListener,
registerGateway,
resolveDiscordChannelConfig,
resolveDiscordChannelConfigWithFallback,
resolveDiscordCommandAuthorized,
resolveDiscordGatewayIntents,
resolveDiscordGuildEntry,
resolveDiscordReplyTarget,
resolveDiscordShouldRequireMention,
resolveGroupDmAllow,
runDiscordTaskWithTimeout,
sanitizeDiscordThreadName,
setPresence,
shouldEmitDiscordReactionNotification,
unregisterGateway,
waitForDiscordGatewayPluginRegistration,
type DiscordAllowList,
type DiscordChannelConfigResolved,
type DiscordGuildEntryResolved,
type DiscordMessageEvent,
type DiscordMessageHandler,
type MonitorDiscordOpts,
} from "./runtime-api.monitor.js";
export {
DiscordSendError,
addRoleDiscord,
banMemberDiscord,
createChannelDiscord,
createScheduledEventDiscord,
createThreadDiscord,
deleteChannelDiscord,
deleteMessageDiscord,
editChannelDiscord,
editDiscordComponentMessage,
editMessageDiscord,
fetchChannelInfoDiscord,
fetchChannelPermissionsDiscord,
fetchMemberGuildPermissionsDiscord,
fetchMemberInfoDiscord,
fetchMessageDiscord,
fetchReactionsDiscord,
fetchRoleInfoDiscord,
fetchVoiceStatusDiscord,
hasAllGuildPermissionsDiscord,
hasAnyGuildPermissionDiscord,
kickMemberDiscord,
listGuildChannelsDiscord,
listGuildEmojisDiscord,
listPinsDiscord,
listScheduledEventsDiscord,
listThreadsDiscord,
moveChannelDiscord,
pinMessageDiscord,
reactMessageDiscord,
readMessagesDiscord,
registerBuiltDiscordComponentMessage,
removeChannelPermissionDiscord,
removeOwnReactionsDiscord,
removeReactionDiscord,
removeRoleDiscord,
resolveDiscordOutboundSessionRoute,
resolveEventCoverImage,
searchMessagesDiscord,
sendDiscordComponentMessage,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
sendTypingDiscord,
sendVoiceMessageDiscord,
sendWebhookMessageDiscord,
setChannelPermissionDiscord,
timeoutMemberDiscord,
unpinMessageDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
type DiscordChannelCreate,
type DiscordChannelEdit,
type DiscordChannelMove,
type DiscordChannelPermissionSet,
type DiscordEmojiUpload,
type DiscordMessageEdit,
type DiscordMessageQuery,
type DiscordModerationTarget,
type DiscordPermissionsSummary,
type DiscordReactionRuntimeContext,
type DiscordReactionSummary,
type DiscordReactionUser,
type DiscordReactOpts,
type DiscordRoleChange,
type DiscordRuntimeAccountContext,
type DiscordSearchQuery,
type DiscordSendResult,
type DiscordStickerUpload,
type DiscordThreadCreate,
type DiscordThreadList,
type DiscordTimeoutTarget,
type ResolveDiscordOutboundSessionRouteParams,
} from "./runtime-api.send.js";
export {
testing as __testing,
testing,
autoBindSpawnedDiscordSubagent,
createNoopThreadBindingManager,
createThreadBindingManager,
formatThreadBindingDurationLabel,
getThreadBindingManager,
isRecentlyUnboundThreadWebhookMessage,
listThreadBindingsBySessionKey,
listThreadBindingsForAccount,
reconcileAcpThreadBindingsOnStartup,
resolveDiscordThreadBindingIdleTimeoutMs,
resolveDiscordThreadBindingMaxAgeMs,
resolveThreadBindingIdleTimeoutMs,
resolveThreadBindingInactivityExpiresAt,
resolveThreadBindingIntroText,
resolveThreadBindingMaxAgeExpiresAt,
resolveThreadBindingMaxAgeMs,
resolveThreadBindingPersona,
resolveThreadBindingPersonaFromRecord,
resolveThreadBindingsEnabled,
resolveThreadBindingThreadName,
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
unbindThreadBindingsBySessionKey,
type AcpThreadBindingReconciliationResult,
type ThreadBindingManager,
type ThreadBindingRecord,
type ThreadBindingTargetKind,
} from "./runtime-api.threads.js";

View File

@@ -0,0 +1,3 @@
// Keep bundled registration fast: runtime wiring only needs the store setter,
// while runtime-api.js remains the broad runtime surface.
export { setDiscordRuntime } from "./src/runtime.js";

View File

@@ -0,0 +1,5 @@
// Discord API module exposes the plugin public contract.
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-config-contract.js";

View File

@@ -0,0 +1,2 @@
// Discord API module exposes the plugin public contract.
export { collectDiscordSecurityAuditFindings } from "./src/security-audit.js";

View File

@@ -0,0 +1,5 @@
// Discord API module exposes the plugin public contract.
export {
collectUnsupportedSecretRefConfigCandidates,
unsupportedSecretRefSurfacePatterns,
} from "./src/security-contract.js";

View File

@@ -0,0 +1,3 @@
// Discord API module exposes the plugin public contract.
export { createThreadBindingManager } from "./src/monitor/thread-bindings.manager.js";
export { testing as discordThreadBindingTesting } from "./src/monitor/thread-bindings.manager.js";

View File

@@ -0,0 +1,2 @@
// Discord API module exposes the plugin public contract.
export { normalizeExplicitDiscordSessionKey } from "./src/session-key-normalization.js";

View File

@@ -0,0 +1,11 @@
// Discord tests cover setup entry plugin behavior.
import { describe, expect, it } from "vitest";
import setupEntry from "./setup-entry.js";
describe("discord setup entry", () => {
it("exposes legacy state migration detector through setup entry metadata", () => {
expect(setupEntry.kind).toBe("bundled-channel-setup-entry");
expect(setupEntry.features).toEqual({ legacyStateMigrations: true });
expect(setupEntry.loadLegacyStateMigrationDetector?.()).toBeTypeOf("function");
});
});

View File

@@ -0,0 +1,17 @@
// Discord plugin module implements setup entry behavior.
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
features: {
legacyStateMigrations: true,
},
plugin: {
specifier: "./setup-plugin-api.js",
exportName: "discordSetupPlugin",
},
legacyStateMigrations: {
specifier: "./legacy-state-migrations-api.js",
exportName: "detectDiscordLegacyStateMigrations",
},
});

View File

@@ -0,0 +1,3 @@
// Keep bundled setup entry imports narrow so setup loads do not pull the
// broader Discord channel plugin surface.
export { discordSetupPlugin } from "./src/channel.setup.js";

View File

@@ -0,0 +1,136 @@
---
name: discord
description: "Discord message-tool ops: send/read/edit/delete, react, poll, pin, thread, search, presence, media/components."
metadata: { "openclaw": { "emoji": "🎮", "requires": { "config": ["channels.discord.token"] } } }
allowed-tools: ["message"]
---
# Discord
Use the `message` tool with `channel: "discord"`. No separate Discord tool.
## Rules
- Respect `channels.discord.actions.*` gates.
- Prefer explicit `guildId`, `channelId`, `messageId`, `userId`.
- Multi-account: pass `accountId` when needed.
- Send targets: `to: "channel:<id>"` or `to: "user:<id>"`.
- Mention users as `<@USER_ID>`.
- Avoid Markdown tables in outbound Discord messages.
- Prefer components v2 for rich UI; do not mix v2 `components` with legacy `embeds`.
## Common actions
Send:
```json
{ "action": "send", "channel": "discord", "to": "channel:123", "message": "hello", "silent": true }
```
Send media:
```json
{
"action": "send",
"channel": "discord",
"to": "channel:123",
"message": "see attachment",
"media": "file:///tmp/example.png"
}
```
Components v2:
```json
{
"action": "send",
"channel": "discord",
"to": "channel:123",
"message": "Status",
"components": "[Carbon v2 components]"
}
```
React:
```json
{ "action": "react", "channel": "discord", "channelId": "123", "messageId": "456", "emoji": "👍" }
```
Read:
```json
{ "action": "read", "channel": "discord", "to": "channel:123", "limit": 20 }
```
Edit/delete:
```json
{
"action": "edit",
"channel": "discord",
"channelId": "123",
"messageId": "456",
"message": "fixed typo"
}
```
```json
{ "action": "delete", "channel": "discord", "channelId": "123", "messageId": "456" }
```
Poll:
```json
{
"action": "poll",
"channel": "discord",
"to": "channel:123",
"pollQuestion": "Lunch?",
"pollOption": ["Pizza", "Sushi"],
"pollDurationHours": 24
}
```
Pin:
```json
{ "action": "pin", "channel": "discord", "channelId": "123", "messageId": "456" }
```
Thread:
```json
{
"action": "thread-create",
"channel": "discord",
"channelId": "123",
"messageId": "456",
"threadName": "bug triage"
}
```
Search:
```json
{
"action": "search",
"channel": "discord",
"guildId": "999",
"query": "release notes",
"channelIds": ["123"],
"limit": 10
}
```
Presence, often gated:
```json
{
"action": "set-presence",
"channel": "discord",
"activityType": "playing",
"activityName": "OpenClaw",
"status": "online"
}
```

View File

@@ -0,0 +1,127 @@
// Discord tests cover account inspect plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { inspectDiscordAccount } from "./account-inspect.js";
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
describe("inspectDiscordAccount", () => {
it("prefers account token over channel token and strips Bot prefix", () => {
const inspected = inspectDiscordAccount({
cfg: asConfig({
channels: {
discord: {
token: "Bot channel-token",
accounts: {
work: {
token: "Bot account-token",
},
},
},
},
}),
accountId: "work",
});
expect(inspected.token).toBe("account-token");
expect(inspected.tokenSource).toBe("config");
expect(inspected.tokenStatus).toBe("available");
expect(inspected.configured).toBe(true);
});
it("reports configured_unavailable for unresolved configured secret input", () => {
const inspected = inspectDiscordAccount({
cfg: asConfig({
channels: {
discord: {
accounts: {
work: {
token: { source: "env", id: "DISCORD_TOKEN" },
},
},
},
},
}),
accountId: "work",
});
expect(inspected.token).toBe("");
expect(inspected.tokenSource).toBe("config");
expect(inspected.tokenStatus).toBe("configured_unavailable");
expect(inspected.configured).toBe(true);
});
it("does not fall back when account token key exists but is missing", () => {
const inspected = inspectDiscordAccount({
cfg: asConfig({
channels: {
discord: {
token: "Bot channel-token",
accounts: {
work: {
token: "",
},
},
},
},
}),
accountId: "work",
});
expect(inspected.token).toBe("");
expect(inspected.tokenSource).toBe("none");
expect(inspected.tokenStatus).toBe("missing");
expect(inspected.configured).toBe(false);
});
it("falls back to channel token when account token is absent", () => {
const inspected = inspectDiscordAccount({
cfg: asConfig({
channels: {
discord: {
token: "Bot channel-token",
accounts: {
work: {},
},
},
},
}),
accountId: "work",
});
expect(inspected.token).toBe("channel-token");
expect(inspected.tokenSource).toBe("config");
expect(inspected.tokenStatus).toBe("available");
expect(inspected.configured).toBe(true);
});
it("allows env token only for default account", () => {
const defaultInspected = inspectDiscordAccount({
cfg: asConfig({}),
accountId: "default",
envToken: "Bot env-default",
});
const namedInspected = inspectDiscordAccount({
cfg: asConfig({
channels: {
discord: {
accounts: {
work: {},
},
},
},
}),
accountId: "work",
envToken: "Bot env-work",
});
expect(defaultInspected.token).toBe("env-default");
expect(defaultInspected.tokenSource).toBe("env");
expect(defaultInspected.configured).toBe(true);
expect(namedInspected.token).toBe("");
expect(namedInspected.tokenSource).toBe("none");
expect(namedInspected.configured).toBe(false);
});
});

View File

@@ -0,0 +1,106 @@
// Discord plugin module implements account inspect behavior.
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { inspectDiscordConfiguredToken } from "./account-token-inspect.js";
import {
mergeDiscordAccountConfig,
resolveDefaultDiscordAccountId,
resolveDiscordAccountConfig,
} from "./accounts.js";
import type { DiscordAccountConfig, OpenClawConfig } from "./runtime-api.js";
import type { DiscordCredentialStatus } from "./token.js";
export type InspectedDiscordAccount = {
accountId: string;
enabled: boolean;
name?: string;
token: string;
tokenSource: "env" | "config" | "none";
tokenStatus: DiscordCredentialStatus;
configured: boolean;
config: DiscordAccountConfig;
};
export function inspectDiscordAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
envToken?: string | null;
}): InspectedDiscordAccount {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultDiscordAccountId(params.cfg),
);
const merged = mergeDiscordAccountConfig(params.cfg, accountId);
const enabled = params.cfg.channels?.discord?.enabled !== false && merged.enabled !== false;
const accountConfig = resolveDiscordAccountConfig(params.cfg, accountId);
const hasAccountToken = Boolean(
accountConfig && Object.hasOwn(accountConfig as Record<string, unknown>, "token"),
);
const accountToken = inspectDiscordConfiguredToken(accountConfig?.token);
if (accountToken) {
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
token: accountToken.token,
tokenSource: accountToken.tokenSource,
tokenStatus: accountToken.tokenStatus,
configured: true,
config: merged,
};
}
if (hasAccountToken) {
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
token: "",
tokenSource: "none",
tokenStatus: "missing",
configured: false,
config: merged,
};
}
const channelToken = inspectDiscordConfiguredToken(params.cfg.channels?.discord?.token);
if (channelToken) {
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
token: channelToken.token,
tokenSource: channelToken.tokenSource,
tokenStatus: channelToken.tokenStatus,
configured: true,
config: merged,
};
}
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const envToken = allowEnv
? normalizeSecretInputString(params.envToken ?? process.env.DISCORD_BOT_TOKEN)
: undefined;
if (envToken) {
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
token: envToken.replace(/^Bot\s+/i, ""),
tokenSource: "env",
tokenStatus: "available",
configured: true,
config: merged,
};
}
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
token: "",
tokenSource: "none",
tokenStatus: "missing",
configured: false,
config: merged,
};
}

View File

@@ -0,0 +1,33 @@
// Discord plugin module implements account token inspect behavior.
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
import type { DiscordCredentialStatus } from "./token.js";
export type InspectedDiscordConfiguredToken = {
token: string;
tokenSource: "config";
tokenStatus: Exclude<DiscordCredentialStatus, "missing">;
};
export function inspectDiscordConfiguredToken(
value: unknown,
): InspectedDiscordConfiguredToken | null {
const normalized = normalizeSecretInputString(value);
if (normalized) {
return {
token: normalized.replace(/^Bot\s+/i, ""),
tokenSource: "config",
tokenStatus: "available",
};
}
if (hasConfiguredSecretInput(value)) {
return {
token: "",
tokenSource: "config",
tokenStatus: "configured_unavailable",
};
}
return null;
}

View File

@@ -0,0 +1,441 @@
// Discord tests cover accounts plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createDiscordActionGate,
isDiscordAccountEnabledForRuntime,
listDiscordAccountIds,
listEnabledDiscordAccounts,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
resolveDiscordAccountDisabledReason,
resolveDiscordMaxLinesPerMessage,
} from "./accounts.js";
afterEach(() => {
clearRuntimeConfigSnapshot();
vi.unstubAllEnvs();
});
const defaultAccountOmissionCases = [
{
name: "resolveDiscordAccount",
assert: () => {
const resolved = resolveDiscordAccount({
cfg: {
channels: {
discord: {
defaultAccount: "work",
accounts: {
work: { token: "token-work", name: "Work" },
},
},
},
},
});
expect(resolved.accountId).toBe("work");
expect(resolved.name).toBe("Work");
expect(resolved.token).toBe("token-work");
},
},
{
name: "createDiscordActionGate",
assert: () => {
const gate = createDiscordActionGate({
cfg: {
channels: {
discord: {
actions: { reactions: false },
defaultAccount: "work",
accounts: {
work: {
token: "token-work",
actions: { reactions: true },
},
},
},
},
},
});
expect(gate("reactions")).toBe(true);
},
},
];
describe("Discord defaultAccount omission contract", () => {
it.each(defaultAccountOmissionCases)(
"$name uses configured defaultAccount when accountId is omitted",
({ assert }) => {
assert();
},
);
it("keeps the implicit default account when named accounts are added to top-level credentials", () => {
const cfg = {
channels: {
discord: {
token: "token-default",
accounts: {
work: {
enabled: false,
token: "token-work",
},
},
},
},
} as OpenClawConfig;
expect(listDiscordAccountIds(cfg)).toEqual(["default", "work"]);
expect(resolveDefaultDiscordAccountId(cfg)).toBe("default");
expect(listEnabledDiscordAccounts(cfg).map((account) => account.accountId)).toEqual([
"default",
]);
});
});
describe("resolveDiscordAccount allowFrom precedence", () => {
it("prefers accounts.default.allowFrom over top-level for default account", () => {
const resolved = resolveDiscordAccount({
cfg: {
channels: {
discord: {
allowFrom: ["top"],
accounts: {
default: { allowFrom: ["default"], token: "token-default" },
},
},
},
},
accountId: "default",
});
expect(resolved.config.allowFrom).toEqual(["default"]);
});
it("falls back to top-level allowFrom for named account without override", () => {
const resolved = resolveDiscordAccount({
cfg: {
channels: {
discord: {
allowFrom: ["top"],
accounts: {
work: { token: "token-work" },
},
},
},
},
accountId: "work",
});
expect(resolved.config.allowFrom).toEqual(["top"]);
});
it("does not inherit default account allowFrom for named account when top-level is absent", () => {
const resolved = resolveDiscordAccount({
cfg: {
channels: {
discord: {
accounts: {
default: { allowFrom: ["default"], token: "token-default" },
work: { token: "token-work" },
},
},
},
},
accountId: "work",
});
expect(resolved.config.allowFrom).toBeUndefined();
});
});
describe("resolveDiscordAccount botLoopProtection precedence", () => {
it("merges account overrides over Discord channel defaults field-by-field", () => {
const resolved = resolveDiscordAccount({
cfg: {
channels: {
discord: {
botLoopProtection: {
maxEventsPerWindow: 4,
windowSeconds: 60,
cooldownSeconds: 30,
},
accounts: {
work: {
token: "token-work",
botLoopProtection: {
windowSeconds: 10,
},
},
},
},
},
},
accountId: "work",
});
expect(resolved.config.botLoopProtection).toEqual({
maxEventsPerWindow: 4,
windowSeconds: 10,
cooldownSeconds: 30,
});
});
});
describe("resolveDiscordAccount agentComponents precedence", () => {
it("preserves a disabled channel default when an account only overrides ttlMs", () => {
const resolved = resolveDiscordAccount({
cfg: {
channels: {
discord: {
agentComponents: {
enabled: false,
},
accounts: {
work: {
token: "token-work",
agentComponents: {
ttlMs: 120_000,
},
},
},
},
},
},
accountId: "work",
});
expect(resolved.config.agentComponents).toEqual({
enabled: false,
ttlMs: 120_000,
});
});
it("preserves channel ttlMs when an account only overrides enabled", () => {
const resolved = resolveDiscordAccount({
cfg: {
channels: {
discord: {
agentComponents: {
enabled: false,
ttlMs: 180_000,
},
accounts: {
work: {
token: "token-work",
agentComponents: {
enabled: true,
},
},
},
},
},
},
accountId: "work",
});
expect(resolved.config.agentComponents).toEqual({
enabled: true,
ttlMs: 180_000,
});
});
});
describe("resolveDiscordMaxLinesPerMessage", () => {
it("falls back to merged root discord maxLinesPerMessage when runtime config omits it", () => {
const resolved = resolveDiscordMaxLinesPerMessage({
cfg: {
channels: {
discord: {
maxLinesPerMessage: 120,
accounts: {
default: { token: "token-default" },
},
},
},
},
discordConfig: {},
accountId: "default",
});
expect(resolved).toBe(120);
});
it("prefers explicit runtime discord maxLinesPerMessage over merged config", () => {
const resolved = resolveDiscordMaxLinesPerMessage({
cfg: {
channels: {
discord: {
maxLinesPerMessage: 120,
accounts: {
default: { token: "token-default", maxLinesPerMessage: 80 },
},
},
},
},
discordConfig: { maxLinesPerMessage: 55 },
accountId: "default",
});
expect(resolved).toBe(55);
});
it("uses per-account discord maxLinesPerMessage over the root value when runtime config omits it", () => {
const resolved = resolveDiscordMaxLinesPerMessage({
cfg: {
channels: {
discord: {
maxLinesPerMessage: 120,
accounts: {
work: { token: "token-work", maxLinesPerMessage: 80 },
},
},
},
},
discordConfig: {},
accountId: "work",
});
expect(resolved).toBe(80);
});
});
describe("Discord duplicate-token account filtering", () => {
it("keeps the config-token account over default env fallback when tokens collide", () => {
vi.stubEnv("DISCORD_BOT_TOKEN", "same-token");
const cfg = {
channels: {
discord: {
accounts: {
work: {
token: "same-token",
},
},
},
},
};
const defaultAccount = resolveDiscordAccount({ cfg, accountId: "default" });
const workAccount = resolveDiscordAccount({ cfg, accountId: "work" });
expect(isDiscordAccountEnabledForRuntime(defaultAccount, cfg)).toBe(false);
expect(resolveDiscordAccountDisabledReason(defaultAccount, cfg)).toBe(
'duplicate bot token; using account "work"',
);
expect(isDiscordAccountEnabledForRuntime(workAccount, cfg)).toBe(true);
expect(listEnabledDiscordAccounts(cfg).map((account) => account.accountId)).toEqual(["work"]);
});
it("keeps the first enabled account when duplicate tokens have the same source", () => {
const cfg = {
channels: {
discord: {
accounts: {
first: {
token: "same-token",
},
second: {
token: "same-token",
},
},
},
},
};
const firstAccount = resolveDiscordAccount({ cfg, accountId: "first" });
const secondAccount = resolveDiscordAccount({ cfg, accountId: "second" });
expect(isDiscordAccountEnabledForRuntime(firstAccount, cfg)).toBe(true);
expect(isDiscordAccountEnabledForRuntime(secondAccount, cfg)).toBe(false);
expect(resolveDiscordAccountDisabledReason(secondAccount, cfg)).toBe(
'duplicate bot token; using account "first"',
);
expect(listEnabledDiscordAccounts(cfg).map((account) => account.accountId)).toEqual(["first"]);
});
it("does not let disabled duplicate-token accounts suppress enabled accounts", () => {
const cfg = {
channels: {
discord: {
accounts: {
disabled: {
enabled: false,
token: "same-token",
},
active: {
token: "same-token",
},
},
},
},
};
const activeAccount = resolveDiscordAccount({ cfg, accountId: "active" });
expect(isDiscordAccountEnabledForRuntime(activeAccount, cfg)).toBe(true);
expect(listEnabledDiscordAccounts(cfg).map((account) => account.accountId)).toEqual(["active"]);
});
});
describe("resolveDiscordAccount runtime config selection", () => {
it("resolves named account SecretRefs from the active runtime snapshot", () => {
const sourceCfg = {
channels: {
discord: {
defaultAccount: "work",
accounts: {
work: {
name: "Work",
token: { source: "env", provider: "default", id: "DISCORD_WORK_TOKEN" },
},
},
},
},
} as unknown as OpenClawConfig;
const runtimeCfg = {
channels: {
discord: {
defaultAccount: "work",
accounts: {
work: {
name: "Work",
token: "Bot runtime-work-token",
},
},
},
},
} as OpenClawConfig;
setRuntimeConfigSnapshot(runtimeCfg, sourceCfg);
const resolved = resolveDiscordAccount({ cfg: sourceCfg });
expect(resolved.accountId).toBe("work");
expect(resolved.token).toBe("runtime-work-token");
expect(resolved.tokenSource).toBe("config");
expect(resolved.tokenStatus).toBe("available");
});
it("preserves configured unavailable tokens without falling through to env", () => {
vi.stubEnv("DISCORD_BOT_TOKEN", "env-token");
const resolved = resolveDiscordAccount({
cfg: {
channels: {
discord: {
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
},
},
} as unknown as OpenClawConfig,
accountId: "default",
});
expect(resolved.token).toBe("");
expect(resolved.tokenSource).toBe("config");
expect(resolved.tokenStatus).toBe("configured_unavailable");
});
});

View File

@@ -0,0 +1,206 @@
// Discord plugin module implements accounts behavior.
import {
createAccountActionGate,
createAccountListHelpers,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-helpers";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
mapAllowFromEntries,
normalizeChannelDmPolicy,
resolveChannelDmAllowFrom,
resolveChannelDmPolicy,
type ChannelDmPolicy,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { DiscordAccountConfig, DiscordActionConfig, OpenClawConfig } from "./runtime-api.js";
import { selectDiscordRuntimeConfig } from "./runtime-config.js";
import { resolveDiscordToken, type DiscordCredentialStatus } from "./token.js";
export type ResolvedDiscordAccount = {
accountId: string;
enabled: boolean;
name?: string;
token: string;
tokenSource: "env" | "config" | "none";
tokenStatus: DiscordCredentialStatus;
config: DiscordAccountConfig;
};
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("discord", {
implicitDefaultAccount: {
channelKeys: ["token"],
envVars: ["DISCORD_BOT_TOKEN"],
},
});
export const listDiscordAccountIds = listAccountIds;
export const resolveDefaultDiscordAccountId = resolveDefaultAccountId;
export function resolveDiscordAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): DiscordAccountConfig | undefined {
return resolveAccountEntry(cfg.channels?.discord?.accounts, accountId);
}
export function mergeDiscordAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): DiscordAccountConfig {
const merged = resolveMergedAccountConfig<DiscordAccountConfig>({
channelConfig: cfg.channels?.discord as DiscordAccountConfig | undefined,
accounts: cfg.channels?.discord?.accounts as
| Record<string, Partial<DiscordAccountConfig>>
| undefined,
accountId,
nestedObjectKeys: ["agentComponents", "botLoopProtection"],
});
return merged;
}
export function resolveDiscordAccountAllowFrom(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] | undefined {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultDiscordAccountId(params.cfg),
);
const accountConfig = resolveDiscordAccountConfig(params.cfg, accountId);
const rootConfig = params.cfg.channels?.discord as DiscordAccountConfig | undefined;
const allowFrom = resolveChannelDmAllowFrom({
account: accountConfig as Record<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | undefined,
});
return allowFrom ? mapAllowFromEntries(allowFrom) : undefined;
}
export function resolveDiscordAccountDmPolicy(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ChannelDmPolicy | undefined {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultDiscordAccountId(params.cfg),
);
const accountConfig = resolveDiscordAccountConfig(params.cfg, accountId);
const rootConfig = params.cfg.channels?.discord as DiscordAccountConfig | undefined;
const policy = resolveChannelDmPolicy({
account: accountConfig as Record<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | undefined,
defaultPolicy: "pairing",
});
return normalizeChannelDmPolicy(policy);
}
export function createDiscordActionGate(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): (key: keyof DiscordActionConfig, defaultValue?: boolean) => boolean {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultDiscordAccountId(params.cfg),
);
return createAccountActionGate({
baseActions: params.cfg.channels?.discord?.actions,
accountActions: resolveDiscordAccountConfig(params.cfg, accountId)?.actions,
});
}
export function resolveDiscordAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedDiscordAccount {
const cfg = selectDiscordRuntimeConfig(params.cfg);
const accountId = normalizeAccountId(params.accountId ?? resolveDefaultDiscordAccountId(cfg));
const baseEnabled = cfg.channels?.discord?.enabled !== false;
const merged = mergeDiscordAccountConfig(cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const tokenResolution = resolveDiscordToken(cfg, { accountId });
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
token: tokenResolution.token,
tokenSource: tokenResolution.source,
tokenStatus: tokenResolution.tokenStatus,
config: merged,
};
}
export function resolveDiscordMaxLinesPerMessage(params: {
cfg: OpenClawConfig;
discordConfig?: DiscordAccountConfig | null;
accountId?: string | null;
}): number | undefined {
if (typeof params.discordConfig?.maxLinesPerMessage === "number") {
return params.discordConfig.maxLinesPerMessage;
}
return resolveDiscordAccount({
cfg: params.cfg,
accountId: params.accountId,
}).config.maxLinesPerMessage;
}
function resolveDiscordAccountTokenOwner(params: {
cfg: OpenClawConfig;
token: string;
}): string | undefined {
const token = params.token.trim();
if (!token) {
return undefined;
}
let owner: { accountId: string; priority: number; index: number } | undefined;
const accountIds = listDiscordAccountIds(params.cfg);
for (const [index, accountId] of accountIds.entries()) {
const account = resolveDiscordAccount({ cfg: params.cfg, accountId });
const accountToken = account.token.trim();
if (!account.enabled || accountToken !== token) {
continue;
}
const priority = account.tokenSource === "config" ? 2 : account.tokenSource === "env" ? 1 : 0;
if (!owner || priority > owner.priority) {
owner = { accountId: account.accountId, priority, index };
continue;
}
if (priority === owner.priority && index < owner.index) {
owner = { accountId: account.accountId, priority, index };
}
}
return owner?.accountId;
}
function resolveDiscordDuplicateTokenOwner(params: {
cfg: OpenClawConfig;
account: ResolvedDiscordAccount;
}): string | undefined {
const owner = resolveDiscordAccountTokenOwner({
cfg: params.cfg,
token: params.account.token,
});
return owner && owner !== params.account.accountId ? owner : undefined;
}
export function isDiscordAccountEnabledForRuntime(
account: ResolvedDiscordAccount,
cfg: OpenClawConfig,
): boolean {
return account.enabled && !resolveDiscordDuplicateTokenOwner({ cfg, account });
}
export function resolveDiscordAccountDisabledReason(
account: ResolvedDiscordAccount,
cfg: OpenClawConfig,
): string {
if (!account.enabled) {
return "disabled";
}
const owner = resolveDiscordDuplicateTokenOwner({ cfg, account });
return owner ? `duplicate bot token; using account "${owner}"` : "disabled";
}
export function listEnabledDiscordAccounts(cfg: OpenClawConfig): ResolvedDiscordAccount[] {
return listDiscordAccountIds(cfg)
.map((accountId) => resolveDiscordAccount({ cfg, accountId }))
.filter((account) => isDiscordAccountEnabledForRuntime(account, cfg));
}

View File

@@ -0,0 +1,474 @@
// Discord plugin module implements handle action.guild admin behavior.
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import {
readNonNegativeIntegerParam,
readPositiveIntegerParam,
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/agent-runtime";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { handleDiscordAction } from "../../action-runtime-api.js";
import { isTrustedRequesterGuildAdminAction } from "../trusted-requester-actions.js";
import {
isDiscordModerationAction,
readDiscordModerationCommand,
} from "./runtime.moderation-shared.js";
import {
readDiscordChannelCreateParams,
readDiscordChannelEditParams,
readDiscordChannelMoveParams,
} from "./runtime.shared.js";
type Ctx = Pick<
ChannelMessageActionContext,
| "action"
| "params"
| "cfg"
| "accountId"
| "requesterSenderId"
| "senderIsOwner"
| "toolContext"
| "mediaLocalRoots"
| "mediaReadFile"
>;
function readDiscordRequesterSenderId(ctx: Ctx): string | undefined {
const currentProvider = normalizeOptionalString(ctx.toolContext?.currentChannelProvider);
if (currentProvider?.toLowerCase() === "discord") {
return normalizeOptionalString(ctx.requesterSenderId);
}
if (
isTrustedRequesterGuildAdminAction(ctx.action) &&
(currentProvider || ctx.senderIsOwner !== true)
) {
throw new Error("Discord guild admin actions require a trusted Discord sender identity.");
}
return undefined;
}
function senderParam(senderUserId: string | undefined) {
return senderUserId ? { senderUserId } : {};
}
export async function tryHandleDiscordMessageActionGuildAdmin(params: {
ctx: Ctx;
resolveChannelId: () => string;
}): Promise<AgentToolResult<unknown> | undefined> {
const { ctx, resolveChannelId } = params;
const { action, params: actionParams, cfg } = ctx;
const accountId = ctx.accountId ?? readStringParam(actionParams, "accountId");
const senderUserId = readDiscordRequesterSenderId(ctx);
if (action === "member-info") {
const userId = readStringParam(actionParams, "userId", { required: true });
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "memberInfo", accountId: accountId ?? undefined, guildId, userId },
cfg,
);
}
if (action === "role-info") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "roleInfo", accountId: accountId ?? undefined, guildId },
cfg,
);
}
if (action === "emoji-list") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "emojiList", accountId: accountId ?? undefined, guildId },
cfg,
);
}
if (action === "emoji-upload") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const name = readStringParam(actionParams, "emojiName", { required: true });
const mediaUrl = readStringParam(actionParams, "media", {
required: true,
trim: false,
});
const roleIds = readStringArrayParam(actionParams, "roleIds");
return await handleDiscordAction(
{
action: "emojiUpload",
accountId: accountId ?? undefined,
guildId,
name,
mediaUrl,
roleIds,
...senderParam(senderUserId),
},
cfg,
);
}
if (action === "sticker-upload") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const name = readStringParam(actionParams, "stickerName", {
required: true,
});
const description = readStringParam(actionParams, "stickerDesc", {
required: true,
});
const tags = readStringParam(actionParams, "stickerTags", {
required: true,
});
const mediaUrl = readStringParam(actionParams, "media", {
required: true,
trim: false,
});
return await handleDiscordAction(
{
action: "stickerUpload",
accountId: accountId ?? undefined,
guildId,
name,
description,
tags,
mediaUrl,
...senderParam(senderUserId),
},
cfg,
);
}
if (action === "role-add" || action === "role-remove") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const userId = readStringParam(actionParams, "userId", { required: true });
const roleId = readStringParam(actionParams, "roleId", { required: true });
return await handleDiscordAction(
{
action: action === "role-add" ? "roleAdd" : "roleRemove",
accountId: accountId ?? undefined,
guildId,
userId,
roleId,
...senderParam(senderUserId),
},
cfg,
);
}
if (action === "channel-info") {
const channelId = readStringParam(actionParams, "channelId", {
required: true,
});
return await handleDiscordAction(
{ action: "channelInfo", accountId: accountId ?? undefined, channelId },
cfg,
);
}
if (action === "channel-list") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "channelList", accountId: accountId ?? undefined, guildId },
cfg,
);
}
if (action === "channel-create") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction(
{
action: "channelCreate",
accountId: accountId ?? undefined,
...readDiscordChannelCreateParams({ ...actionParams, guildId }),
...senderParam(senderUserId),
},
cfg,
);
}
if (action === "channel-edit") {
const channelId = readStringParam(actionParams, "channelId", {
required: true,
});
return await handleDiscordAction(
{
action: "channelEdit",
accountId: accountId ?? undefined,
...readDiscordChannelEditParams({ ...actionParams, channelId }),
...senderParam(senderUserId),
},
cfg,
);
}
if (action === "channel-delete") {
const channelId = readStringParam(actionParams, "channelId", {
required: true,
});
return await handleDiscordAction(
{
action: "channelDelete",
accountId: accountId ?? undefined,
channelId,
...senderParam(senderUserId),
},
cfg,
);
}
if (action === "channel-move") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const channelId = readStringParam(actionParams, "channelId", {
required: true,
});
return await handleDiscordAction(
{
action: "channelMove",
accountId: accountId ?? undefined,
...readDiscordChannelMoveParams({ ...actionParams, guildId, channelId }),
...senderParam(senderUserId),
},
cfg,
);
}
if (action === "category-create") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const name = readStringParam(actionParams, "name", { required: true });
const position = readNonNegativeIntegerParam(actionParams, "position");
return await handleDiscordAction(
{
action: "categoryCreate",
accountId: accountId ?? undefined,
guildId,
name,
position: position ?? undefined,
...senderParam(senderUserId),
},
cfg,
);
}
if (action === "category-edit") {
const categoryId = readStringParam(actionParams, "categoryId", {
required: true,
});
const name = readStringParam(actionParams, "name");
const position = readNonNegativeIntegerParam(actionParams, "position");
return await handleDiscordAction(
{
action: "categoryEdit",
accountId: accountId ?? undefined,
categoryId,
name: name ?? undefined,
position: position ?? undefined,
...senderParam(senderUserId),
},
cfg,
);
}
if (action === "category-delete") {
const categoryId = readStringParam(actionParams, "categoryId", {
required: true,
});
return await handleDiscordAction(
{
action: "categoryDelete",
accountId: accountId ?? undefined,
categoryId,
...senderParam(senderUserId),
},
cfg,
);
}
if (action === "voice-status") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const userId = readStringParam(actionParams, "userId", { required: true });
return await handleDiscordAction(
{ action: "voiceStatus", accountId: accountId ?? undefined, guildId, userId },
cfg,
);
}
if (action === "event-list") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "eventList", accountId: accountId ?? undefined, guildId },
cfg,
);
}
if (action === "event-create") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const name = readStringParam(actionParams, "eventName", { required: true });
const startTime = readStringParam(actionParams, "startTime", {
required: true,
});
const endTime = readStringParam(actionParams, "endTime");
const description = readStringParam(actionParams, "desc");
const channelId = readStringParam(actionParams, "channelId");
const location = readStringParam(actionParams, "location");
const entityType = readStringParam(actionParams, "eventType");
const image = readStringParam(actionParams, "image", { trim: false });
return await handleDiscordAction(
{
action: "eventCreate",
accountId: accountId ?? undefined,
guildId,
name,
startTime,
endTime,
description,
channelId,
location,
entityType,
image,
...senderParam(senderUserId),
},
cfg,
{ mediaLocalRoots: ctx.mediaLocalRoots },
);
}
if (isDiscordModerationAction(action)) {
const moderation = readDiscordModerationCommand(action, {
...actionParams,
durationMinutes: readNonNegativeIntegerParam(actionParams, "durationMin"),
deleteMessageDays: readNonNegativeIntegerParam(actionParams, "deleteDays", {
max: 7,
message: "deleteDays must be an integer from 0 to 7",
}),
});
return await handleDiscordAction(
{
action: moderation.action,
accountId: accountId ?? undefined,
guildId: moderation.guildId,
userId: moderation.userId,
durationMinutes: moderation.durationMinutes,
until: moderation.until,
reason: moderation.reason,
deleteMessageDays: moderation.deleteMessageDays,
senderUserId,
},
cfg,
);
}
// Some actions are conceptually "admin", but still act on a resolved channel.
if (action === "thread-list") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const channelId = readStringParam(actionParams, "channelId");
const includeArchived =
typeof actionParams.includeArchived === "boolean" ? actionParams.includeArchived : undefined;
const before = readStringParam(actionParams, "before");
const limit = readPositiveIntegerParam(actionParams, "limit");
return await handleDiscordAction(
{
action: "threadList",
accountId: accountId ?? undefined,
guildId,
channelId,
includeArchived,
before,
limit,
},
cfg,
);
}
if (action === "thread-reply") {
const content = readStringParam(actionParams, "message", {
required: true,
});
const mediaUrl =
readStringParam(actionParams, "media", { trim: false }) ??
readStringParam(actionParams, "path", { trim: false }) ??
readStringParam(actionParams, "filePath", { trim: false });
const replyTo = readStringParam(actionParams, "replyTo");
// `message.thread-reply` (tool) uses `threadId`, while the CLI historically used `to`/`channelId`.
// Prefer `threadId` when present to avoid accidentally replying in the parent channel.
const threadId = readStringParam(actionParams, "threadId");
const channelId = threadId ?? resolveChannelId();
return await handleDiscordAction(
{
action: "threadReply",
accountId: accountId ?? undefined,
channelId,
content,
mediaUrl: mediaUrl ?? undefined,
replyTo: replyTo ?? undefined,
},
cfg,
{ mediaLocalRoots: ctx.mediaLocalRoots, mediaReadFile: ctx.mediaReadFile },
);
}
if (action === "search") {
const guildId = readStringParam(actionParams, "guildId");
const query =
readStringParam(actionParams, "query") ?? readStringParam(actionParams, "content");
if (!query) {
throw new Error("Discord search requires query text. Provide query or content.");
}
// Fall back to the current session channel when no explicit channelId,
// channelIds, or guildId is provided. This lets the runtime resolve
// guildId from the channel without broadening explicitly-filtered or
// explicitly guild-scoped searches.
const explicitChannelIds = readStringArrayParam(actionParams, "channelIds");
const channelId =
readStringParam(actionParams, "channelId") ??
(!guildId &&
!explicitChannelIds?.length &&
ctx.toolContext?.currentChannelProvider?.trim().toLowerCase() === "discord"
? ctx.toolContext?.currentChannelId?.trim() || undefined
: undefined);
return await handleDiscordAction(
{
action: "searchMessages",
accountId: accountId ?? undefined,
...(guildId ? { guildId } : {}),
content: query,
channelId,
channelIds: explicitChannelIds,
authorId: readStringParam(actionParams, "authorId"),
authorIds: readStringArrayParam(actionParams, "authorIds"),
limit: readPositiveIntegerParam(actionParams, "limit"),
},
cfg,
);
}
return undefined;
}

View File

@@ -0,0 +1,672 @@
// Discord tests cover handle action plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runtimeModule = await import("./runtime.js");
const handleDiscordActionMock = vi
.spyOn(runtimeModule, "handleDiscordAction")
.mockResolvedValue({ content: [], details: { ok: true } });
const { handleDiscordMessageAction } = await import("./handle-action.js");
const { beginDiscordInboundEventDeliveryCorrelation } =
await import("../inbound-event-delivery.js");
function discordConfig(actions?: Record<string, boolean>): OpenClawConfig {
return {
channels: { discord: { token: "tok", ...(actions ? { actions } : {}) } },
} as OpenClawConfig;
}
function defaultActionOptions() {
return {
mediaAccess: undefined,
mediaLocalRoots: undefined,
mediaReadFile: undefined,
};
}
function expectDiscordActionCall(params: {
payload: unknown;
cfg: OpenClawConfig;
options?: unknown;
}) {
expect(handleDiscordActionMock).toHaveBeenCalledTimes(1);
const [call] = handleDiscordActionMock.mock.calls;
if (!call) {
throw new Error("expected Discord action call");
}
const [payload, cfg, options] = call;
expect(payload).toEqual(params.payload);
expect(cfg).toBe(params.cfg);
if ("options" in params) {
expect(options).toEqual(params.options);
} else {
expect(options).toBeUndefined();
}
}
describe("handleDiscordMessageAction", () => {
beforeEach(() => {
handleDiscordActionMock.mockClear();
});
it("uses trusted requesterSenderId for moderation and ignores params senderUserId", async () => {
const cfg = discordConfig({ moderation: true });
await handleDiscordMessageAction({
action: "timeout",
params: {
guildId: "guild-1",
userId: "user-2",
durationMin: 5,
senderUserId: "spoofed-admin-id",
},
cfg,
requesterSenderId: "trusted-sender-id",
toolContext: { currentChannelProvider: "discord" },
});
expectDiscordActionCall({
payload: {
action: "timeout",
accountId: undefined,
guildId: "guild-1",
userId: "user-2",
durationMinutes: 5,
until: undefined,
reason: undefined,
deleteMessageDays: undefined,
senderUserId: "trusted-sender-id",
},
cfg,
});
});
it("rejects fractional moderation durations before invoking Discord runtime", async () => {
const cfg = discordConfig({ moderation: true });
await expect(
handleDiscordMessageAction({
action: "timeout",
params: {
guildId: "guild-1",
userId: "user-2",
durationMin: 5.5,
},
cfg,
requesterSenderId: "trusted-sender-id",
toolContext: { currentChannelProvider: "discord" },
}),
).rejects.toThrow("durationMin must be a non-negative integer");
expect(handleDiscordActionMock).not.toHaveBeenCalled();
});
it("uses Discord requesterSenderId for guild admin actions and ignores params senderUserId", async () => {
const cfg = discordConfig({ channels: true });
await handleDiscordMessageAction({
action: "channel-delete",
params: {
channelId: "channel-1",
senderUserId: "spoofed-admin-id",
},
cfg,
requesterSenderId: "trusted-sender-id",
toolContext: { currentChannelProvider: "discord" },
});
expectDiscordActionCall({
payload: {
action: "channelDelete",
accountId: undefined,
channelId: "channel-1",
senderUserId: "trusted-sender-id",
},
cfg,
});
});
it("rejects non-Discord requester ids for Discord guild admin actions", async () => {
const cfg = discordConfig({ channels: true });
await expect(
handleDiscordMessageAction({
action: "channel-delete",
params: {
channelId: "channel-1",
},
cfg,
requesterSenderId: "telegram-user-id",
toolContext: { currentChannelProvider: "telegram" },
}),
).rejects.toThrow("trusted Discord sender identity");
expect(handleDiscordActionMock).not.toHaveBeenCalled();
});
it("keeps no-context Discord guild admin actions on the manual runtime path", async () => {
const cfg = discordConfig({ channels: true });
await handleDiscordMessageAction({
action: "channel-delete",
params: {
channelId: "channel-1",
},
cfg,
senderIsOwner: true,
});
expectDiscordActionCall({
payload: {
action: "channelDelete",
accountId: undefined,
channelId: "channel-1",
},
cfg,
});
});
it("rejects no-context Discord guild admin actions without owner trust", async () => {
const cfg = discordConfig({ channels: true });
await expect(
handleDiscordMessageAction({
action: "channel-delete",
params: {
channelId: "channel-1",
},
cfg,
}),
).rejects.toThrow("trusted Discord sender identity");
expect(handleDiscordActionMock).not.toHaveBeenCalled();
});
it("rejects non-Discord requester ids for Discord moderation actions", async () => {
const cfg = discordConfig({ moderation: true });
await expect(
handleDiscordMessageAction({
action: "timeout",
params: {
guildId: "guild-1",
userId: "user-2",
durationMin: 5,
},
cfg,
requesterSenderId: "telegram-user-id",
toolContext: { currentChannelProvider: "telegram" },
}),
).rejects.toThrow("trusted Discord sender identity");
expect(handleDiscordActionMock).not.toHaveBeenCalled();
});
it("keeps read-only guild lookups available from non-Discord requesters", async () => {
const cfg = discordConfig({ channelInfo: true });
await handleDiscordMessageAction({
action: "channel-info",
params: {
channelId: "channel-1",
},
cfg,
requesterSenderId: "telegram-user-id",
toolContext: { currentChannelProvider: "telegram" },
});
expectDiscordActionCall({
payload: { action: "channelInfo", accountId: undefined, channelId: "channel-1" },
cfg,
});
});
it("falls back to toolContext.currentMessageId for reactions", async () => {
const cfg = discordConfig();
await handleDiscordMessageAction({
action: "react",
params: {
channelId: "123",
emoji: "ok",
},
cfg,
toolContext: { currentMessageId: "9001" },
});
expectDiscordActionCall({
payload: {
action: "react",
accountId: undefined,
channelId: "123",
messageId: "9001",
emoji: "ok",
remove: undefined,
},
cfg,
options: defaultActionOptions(),
});
});
it("falls back to Discord toolContext.currentChannelId for reaction targets", async () => {
const cfg = discordConfig();
await handleDiscordMessageAction({
action: "react",
params: {
emoji: "ok",
},
cfg,
toolContext: {
currentChannelProvider: "discord",
currentChannelId: "user:U1",
currentMessageId: "9001",
},
});
expectDiscordActionCall({
payload: {
action: "react",
accountId: undefined,
channelId: "user:U1",
messageId: "9001",
emoji: "ok",
remove: undefined,
},
cfg,
options: defaultActionOptions(),
});
});
it("falls back to Discord toolContext.currentChannelId for sends", async () => {
const cfg = discordConfig();
await handleDiscordMessageAction({
action: "send",
params: {
message: "hello",
},
cfg,
toolContext: {
currentChannelProvider: "discord",
currentChannelId: "channel:123",
},
});
expectDiscordActionCall({
payload: {
action: "sendMessage",
accountId: undefined,
to: "channel:123",
content: "hello",
mediaUrl: undefined,
filename: undefined,
replyTo: undefined,
components: undefined,
embeds: undefined,
asVoice: false,
silent: false,
__sessionKey: undefined,
__agentId: undefined,
},
cfg,
options: defaultActionOptions(),
});
});
it("forwards threadName on sends", async () => {
const cfg = discordConfig();
await handleDiscordMessageAction({
action: "send",
params: {
target: "channel:thread-1",
message: "hello",
threadName: "Renamed thread",
},
cfg,
});
expectDiscordActionCall({
payload: {
action: "sendMessage",
accountId: undefined,
to: "channel:thread-1",
content: "hello",
threadName: "Renamed thread",
mediaUrl: undefined,
filename: undefined,
replyTo: undefined,
components: undefined,
embeds: undefined,
asVoice: false,
silent: false,
__sessionKey: undefined,
__agentId: undefined,
},
cfg,
options: defaultActionOptions(),
});
});
it("notifies inbound event delivery after message sends", async () => {
const markDelivered = vi.fn();
const end = beginDiscordInboundEventDeliveryCorrelation(
"agent:main:discord:channel:c1",
{
outboundTo: "channel:c1",
outboundAccountId: "default",
markInboundEventDelivered: markDelivered,
},
{ inboundEventKind: "room_event" },
);
try {
await handleDiscordMessageAction({
action: "send",
params: {
to: "channel:c1",
message: "hello",
},
cfg: discordConfig(),
accountId: "default",
sessionKey: "agent:main:discord:channel:c1",
inboundEventKind: "room_event",
});
} finally {
end();
}
expect(markDelivered).toHaveBeenCalledTimes(1);
});
it("notifies inbound event delivery after visible message actions", async () => {
const markDelivered = vi.fn();
const end = beginDiscordInboundEventDeliveryCorrelation(
"agent:main:discord:channel:c1",
{
outboundTo: "channel:c1",
outboundAccountId: "default",
markInboundEventDelivered: markDelivered,
},
{ inboundEventKind: "room_event" },
);
try {
await handleDiscordMessageAction({
action: "upload-file",
params: {
to: "channel:c1",
filePath: "/tmp/image.png",
},
cfg: discordConfig(),
accountId: "default",
sessionKey: "agent:main:discord:channel:c1",
inboundEventKind: "room_event",
});
} finally {
end();
}
expect(markDelivered).toHaveBeenCalledTimes(1);
});
it("maps upload-file to Discord sendMessage with media read context", async () => {
const mediaReadFile = vi.fn(async () => Buffer.from("image"));
const mediaAccess = {
localRoots: ["/tmp/agent-root"],
readFile: mediaReadFile,
};
const cfg = discordConfig();
await handleDiscordMessageAction({
action: "upload-file",
params: {
target: "channel:123",
filePath: "/tmp/agent-root/image.png",
message: "caption",
filename: "image.png",
replyTo: "message-1",
silent: true,
__sessionKey: "session-1",
__agentId: "agent-1",
},
cfg,
mediaAccess,
mediaLocalRoots: ["/tmp/agent-root"],
mediaReadFile,
});
expectDiscordActionCall({
payload: {
action: "sendMessage",
accountId: undefined,
to: "channel:123",
content: "caption",
mediaUrl: "/tmp/agent-root/image.png",
filename: "image.png",
replyTo: "message-1",
silent: true,
__sessionKey: "session-1",
__agentId: "agent-1",
},
cfg,
options: {
mediaAccess,
mediaLocalRoots: ["/tmp/agent-root"],
mediaReadFile,
},
});
});
it("falls back to Discord toolContext.currentChannelId for upload-file", async () => {
const cfg = discordConfig();
await handleDiscordMessageAction({
action: "upload-file",
params: {
path: "/tmp/agent-root/image.png",
},
cfg,
toolContext: {
currentChannelProvider: "discord",
currentChannelId: "channel:123",
},
});
expectDiscordActionCall({
payload: {
action: "sendMessage",
accountId: undefined,
to: "channel:123",
content: "",
mediaUrl: "/tmp/agent-root/image.png",
filename: undefined,
replyTo: undefined,
silent: false,
__sessionKey: undefined,
__agentId: undefined,
},
cfg,
options: defaultActionOptions(),
});
});
it("requires a file path for upload-file", async () => {
await expect(
handleDiscordMessageAction({
action: "upload-file",
params: {
to: "channel:123",
},
cfg: discordConfig(),
}),
).rejects.toThrow(/upload-file requires filePath, path, or media/i);
expect(handleDiscordActionMock).not.toHaveBeenCalled();
});
it("maps thread-reply filePath to Discord threadReply with media read context", async () => {
const mediaReadFile = vi.fn(async () => Buffer.from("report"));
const cfg = discordConfig({ threads: true });
await handleDiscordMessageAction({
action: "thread-reply",
params: {
threadId: "thread-123",
message: "thread update",
filePath: "/tmp/agent-root/report.md",
},
cfg,
mediaLocalRoots: ["/tmp/agent-root"],
mediaReadFile,
});
expectDiscordActionCall({
payload: {
action: "threadReply",
accountId: undefined,
channelId: "thread-123",
content: "thread update",
mediaUrl: "/tmp/agent-root/report.md",
replyTo: undefined,
},
cfg,
options: {
mediaLocalRoots: ["/tmp/agent-root"],
mediaReadFile,
},
});
});
it("forwards top-level components on sends", async () => {
const components = { blocks: [{ type: "text", text: "Pick one" }] };
const cfg = discordConfig();
await handleDiscordMessageAction({
action: "send",
params: {
message: "hello",
components,
},
cfg,
toolContext: {
currentChannelProvider: "discord",
currentChannelId: "channel:123",
},
});
expectDiscordActionCall({
payload: {
action: "sendMessage",
accountId: undefined,
to: "channel:123",
content: "hello",
mediaUrl: undefined,
filename: undefined,
replyTo: undefined,
components,
embeds: undefined,
asVoice: false,
silent: false,
__sessionKey: undefined,
__agentId: undefined,
},
cfg,
options: defaultActionOptions(),
});
});
it("does not use another provider's current target for Discord sends", async () => {
await expect(
handleDiscordMessageAction({
action: "send",
params: {
message: "hello",
},
cfg: discordConfig(),
toolContext: {
currentChannelProvider: "telegram",
currentChannelId: "channel:123",
},
}),
).rejects.toThrow(/channel target is required/i);
expect(handleDiscordActionMock).not.toHaveBeenCalled();
});
it("does not use another provider's current target for Discord reactions", async () => {
await expect(
handleDiscordMessageAction({
action: "react",
params: {
emoji: "ok",
},
cfg: discordConfig(),
toolContext: {
currentChannelProvider: "telegram",
currentChannelId: "user:U1",
currentMessageId: "9001",
},
}),
).rejects.toThrow(/channel target is required/i);
expect(handleDiscordActionMock).not.toHaveBeenCalled();
});
it("rejects reactions when no message id source is available", async () => {
await expect(
handleDiscordMessageAction({
action: "react",
params: {
channelId: "123",
emoji: "ok",
},
cfg: discordConfig(),
}),
).rejects.toThrow(/messageId required/i);
expect(handleDiscordActionMock).not.toHaveBeenCalled();
});
it("does not add session channel to search when explicit channelIds are provided", async () => {
handleDiscordActionMock.mockResolvedValueOnce({ content: [], details: { ok: true } });
await handleDiscordMessageAction({
action: "search",
params: {
query: "test query",
channelIds: ["ch-1", "ch-2"],
guildId: "g1",
},
cfg: discordConfig(),
toolContext: {
currentChannelProvider: "discord",
currentChannelId: "session-ch",
},
});
expect(handleDiscordActionMock).toHaveBeenCalledTimes(1);
const payload = handleDiscordActionMock.mock.calls[0]?.[0];
expect(payload).toMatchObject({
action: "searchMessages",
content: "test query",
guildId: "g1",
channelIds: ["ch-1", "ch-2"],
});
// Session channel must NOT appear as channelId when explicit channelIds exist.
expect(payload.channelId).toBeUndefined();
});
it("does not inject session channel when guildId is explicit and no channel filters are provided", async () => {
handleDiscordActionMock.mockResolvedValueOnce({ content: [], details: { ok: true } });
await handleDiscordMessageAction({
action: "search",
params: {
query: "guild-wide query",
guildId: "g1",
},
cfg: discordConfig(),
toolContext: {
currentChannelProvider: "discord",
currentChannelId: "session-ch",
},
});
expect(handleDiscordActionMock).toHaveBeenCalledTimes(1);
const payload = handleDiscordActionMock.mock.calls[0]?.[0];
expect(payload).toMatchObject({
action: "searchMessages",
content: "guild-wide query",
guildId: "g1",
});
// Guild-wide search must NOT be narrowed to the session channel.
expect(payload.channelId).toBeUndefined();
expect(payload.channelIds).toBeUndefined();
});
});

View File

@@ -0,0 +1,399 @@
// Discord plugin module implements handle action behavior.
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import {
readPositiveIntegerParam,
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/agent-runtime";
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import {
normalizeInteractiveReply,
normalizeMessagePresentation,
} from "openclaw/plugin-sdk/interactive-runtime";
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/string-coerce-runtime";
import { handleDiscordAction } from "../../action-runtime-api.js";
import { notifyDiscordInboundEventOutboundSuccess } from "../inbound-event-delivery.js";
import {
buildDiscordInteractiveComponents,
buildDiscordPresentationComponents,
} from "../shared-interactive.js";
import { resolveDiscordChannelId } from "../targets.js";
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
const providerId = "discord";
function readCurrentDiscordTarget(
toolContext: Pick<ChannelMessageActionContext, "toolContext">["toolContext"],
): string | undefined {
const provider = toolContext?.currentChannelProvider?.trim().toLowerCase();
if (provider && provider !== providerId) {
return undefined;
}
const target = toolContext?.currentChannelId?.trim();
return target || undefined;
}
export async function handleDiscordMessageAction(
ctx: Pick<
ChannelMessageActionContext,
| "action"
| "params"
| "cfg"
| "accountId"
| "requesterSenderId"
| "senderIsOwner"
| "toolContext"
| "mediaAccess"
| "mediaLocalRoots"
| "mediaReadFile"
| "sessionKey"
| "inboundEventKind"
>,
): Promise<AgentToolResult<unknown>> {
const { action, params, cfg } = ctx;
const accountId = ctx.accountId ?? readStringParam(params, "accountId");
const actionOptions = {
mediaAccess: ctx.mediaAccess,
mediaLocalRoots: ctx.mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
} as const;
const notifyVisibleOutbound = (to: string, fallbackSessionKey?: string) =>
notifyDiscordInboundEventOutboundSuccess({
sessionKey: ctx.sessionKey ?? fallbackSessionKey ?? undefined,
to,
accountId,
inboundEventKind: ctx.inboundEventKind,
});
const readTarget = () => {
const target =
readStringParam(params, "channelId") ??
readStringParam(params, "to") ??
readCurrentDiscordTarget(ctx.toolContext);
if (!target) {
throw new Error("Discord channel target is required (use channel:<id>).");
}
return target;
};
const resolveChannelId = () => resolveDiscordChannelId(readTarget());
const readSendTarget = () => {
const target =
readStringParam(params, "to") ??
readStringParam(params, "target") ??
readCurrentDiscordTarget(ctx.toolContext);
if (!target) {
throw new Error("Discord channel target is required (use channel:<id>).");
}
return target;
};
if (action === "send") {
const to = readSendTarget();
const asVoice = readBooleanParam(params, "asVoice") === true;
const rawComponents =
params.components ??
buildDiscordPresentationComponents(normalizeMessagePresentation(params.presentation)) ??
buildDiscordInteractiveComponents(normalizeInteractiveReply(params.interactive));
const hasComponents =
Boolean(rawComponents) &&
(typeof rawComponents === "function" || typeof rawComponents === "object");
const components = hasComponents ? rawComponents : undefined;
// Support media, path, and filePath for media URL
const mediaUrl =
readStringParam(params, "media", { trim: false }) ??
readStringParam(params, "path", { trim: false }) ??
readStringParam(params, "filePath", { trim: false });
const content = readStringParam(params, "message", {
required: !asVoice && !hasComponents && !mediaUrl,
allowEmpty: true,
});
const filename = readStringParam(params, "filename");
const replyTo = readStringParam(params, "replyTo");
const rawEmbeds = params.embeds;
const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined;
const silent = readBooleanParam(params, "silent") === true;
const suppressEmbeds = readBooleanParam(params, "suppressEmbeds");
const sessionKey = readStringParam(params, "__sessionKey");
const agentId = readStringParam(params, "__agentId");
const threadName = readStringParam(params, "threadName");
const result = await handleDiscordAction(
{
action: "sendMessage",
accountId: accountId ?? undefined,
to,
content: content ?? "",
...(threadName ? { threadName } : {}),
mediaUrl: mediaUrl ?? undefined,
filename: filename ?? undefined,
replyTo: replyTo ?? undefined,
components,
embeds,
asVoice,
silent,
...(suppressEmbeds === undefined ? {} : { suppressEmbeds }),
__sessionKey: sessionKey ?? undefined,
__agentId: agentId ?? undefined,
},
cfg,
actionOptions,
);
notifyVisibleOutbound(to, sessionKey);
return result;
}
if (action === "upload-file") {
const to = readSendTarget();
const mediaUrl =
readStringParam(params, "filePath", { trim: false }) ??
readStringParam(params, "path", { trim: false }) ??
readStringParam(params, "media", { trim: false });
if (!mediaUrl) {
throw new Error("upload-file requires filePath, path, or media.");
}
const content =
readStringParam(params, "message", { allowEmpty: true }) ??
readStringParam(params, "content", { allowEmpty: true });
const filename = readStringParam(params, "filename");
const replyTo = readStringParam(params, "replyTo");
const silent = readBooleanParam(params, "silent") === true;
const suppressEmbeds = readBooleanParam(params, "suppressEmbeds");
const sessionKey = readStringParam(params, "__sessionKey");
const agentId = readStringParam(params, "__agentId");
const result = await handleDiscordAction(
{
action: "sendMessage",
accountId: accountId ?? undefined,
to,
content: content ?? "",
mediaUrl,
filename: filename ?? undefined,
replyTo: replyTo ?? undefined,
silent,
...(suppressEmbeds === undefined ? {} : { suppressEmbeds }),
__sessionKey: sessionKey ?? undefined,
__agentId: agentId ?? undefined,
},
cfg,
actionOptions,
);
notifyVisibleOutbound(to, sessionKey);
return result;
}
if (action === "poll") {
const to = readStringParam(params, "to", { required: true });
const question = readStringParam(params, "pollQuestion", {
required: true,
});
const answers = readStringArrayParam(params, "pollOption", { required: true });
const allowMultiselect = readBooleanParam(params, "pollMulti");
const durationHours = readPositiveIntegerParam(params, "pollDurationHours");
const result = await handleDiscordAction(
{
action: "poll",
accountId: accountId ?? undefined,
to,
question,
answers,
allowMultiselect,
durationHours: durationHours ?? undefined,
content: readStringParam(params, "message"),
},
cfg,
actionOptions,
);
notifyVisibleOutbound(to);
return result;
}
if (action === "react") {
const messageIdRaw = resolveReactionMessageId({ args: params, toolContext: ctx.toolContext });
const messageId = normalizeOptionalStringifiedId(messageIdRaw) ?? "";
if (!messageId) {
throw new Error(
"messageId required. Provide messageId explicitly or react to the current inbound message.",
);
}
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = readBooleanParam(params, "remove");
return await handleDiscordAction(
{
action: "react",
accountId: accountId ?? undefined,
channelId: readTarget(),
messageId,
emoji,
remove,
},
cfg,
actionOptions,
);
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", { required: true });
const limit = readPositiveIntegerParam(params, "limit");
return await handleDiscordAction(
{
action: "reactions",
accountId: accountId ?? undefined,
channelId: readTarget(),
messageId,
limit,
},
cfg,
actionOptions,
);
}
if (action === "read") {
const limit = readPositiveIntegerParam(params, "limit");
return await handleDiscordAction(
{
action: "readMessages",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
around: readStringParam(params, "around"),
},
cfg,
actionOptions,
);
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", { required: true });
const content = readStringParam(params, "message", { required: true });
return await handleDiscordAction(
{
action: "editMessage",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
messageId,
content,
},
cfg,
actionOptions,
);
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", { required: true });
return await handleDiscordAction(
{
action: "deleteMessage",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
messageId,
},
cfg,
actionOptions,
);
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins" ? undefined : readStringParam(params, "messageId", { required: true });
return await handleDiscordAction(
{
action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
messageId,
},
cfg,
actionOptions,
);
}
if (action === "permissions") {
return await handleDiscordAction(
{
action: "permissions",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
},
cfg,
actionOptions,
);
}
if (action === "thread-create") {
const name = readStringParam(params, "threadName", { required: true });
const messageId = readStringParam(params, "messageId");
const content = readStringParam(params, "message");
const autoArchiveMinutes = readPositiveIntegerParam(params, "autoArchiveMin");
const appliedTags = readStringArrayParam(params, "appliedTags");
const result = await handleDiscordAction(
{
action: "threadCreate",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
name,
messageId,
content,
autoArchiveMinutes,
appliedTags: appliedTags ?? undefined,
},
cfg,
actionOptions,
);
notifyVisibleOutbound(resolveChannelId());
return result;
}
if (action === "sticker") {
const to = readStringParam(params, "to", { required: true });
const stickerIds =
readStringArrayParam(params, "stickerId", {
required: true,
label: "sticker-id",
}) ?? [];
const result = await handleDiscordAction(
{
action: "sticker",
accountId: accountId ?? undefined,
to,
stickerIds,
content: readStringParam(params, "message"),
},
cfg,
actionOptions,
);
notifyVisibleOutbound(to);
return result;
}
if (action === "set-presence") {
return await handleDiscordAction(
{
action: "setPresence",
accountId: accountId ?? undefined,
status: readStringParam(params, "status"),
activityType: readStringParam(params, "activityType"),
activityName: readStringParam(params, "activityName"),
activityUrl: readStringParam(params, "activityUrl"),
activityState: readStringParam(params, "activityState"),
},
cfg,
actionOptions,
);
}
const adminResult = await tryHandleDiscordMessageActionGuildAdmin({
ctx,
resolveChannelId,
});
if (adminResult !== undefined) {
if (action === "thread-reply") {
notifyVisibleOutbound(readStringParam(params, "threadId") ?? readTarget());
}
return adminResult;
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
}

View File

@@ -0,0 +1,729 @@
// Discord plugin module implements runtime.guild behavior.
import { ChannelType, PermissionFlagsBits } from "discord-api-types/v10";
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import { resolveDefaultDiscordAccountId } from "../accounts.js";
import { getPresence } from "../monitor/presence-cache.js";
import {
type ActionGate,
jsonResult,
readNonNegativeIntegerParam,
readStringArrayParam,
readStringParam,
type DiscordActionConfig,
type OpenClawConfig,
} from "../runtime-api.js";
import {
addRoleDiscord,
canManageGuildRoleDiscord,
canManageGuildMemberRoleDiscord,
createChannelDiscord,
createScheduledEventDiscord,
deleteChannelDiscord,
editChannelDiscord,
fetchChannelInfoDiscord,
fetchMemberInfoDiscord,
hasAnyChannelPermissionDiscord,
hasAnyGuildPermissionDiscord,
fetchRoleInfoDiscord,
fetchVoiceStatusDiscord,
listGuildChannelsDiscord,
listGuildEmojisDiscord,
listScheduledEventsDiscord,
moveChannelDiscord,
removeChannelPermissionDiscord,
removeRoleDiscord,
setChannelPermissionDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
resolveEventCoverImage,
} from "../send.js";
import { createDiscordMessagingActionContext } from "./runtime.messaging.shared.js";
import {
createDiscordActionOptions,
readDiscordChannelCreateParams,
readDiscordChannelEditParams,
readDiscordChannelMoveParams,
} from "./runtime.shared.js";
export const discordGuildActionRuntime = {
addRoleDiscord,
canManageGuildRoleDiscord,
canManageGuildMemberRoleDiscord,
createChannelDiscord,
createScheduledEventDiscord,
resolveEventCoverImage,
deleteChannelDiscord,
editChannelDiscord,
fetchChannelInfoDiscord,
fetchMemberInfoDiscord,
hasAnyChannelPermissionDiscord,
hasAnyGuildPermissionDiscord,
fetchRoleInfoDiscord,
fetchVoiceStatusDiscord,
listGuildChannelsDiscord,
listGuildEmojisDiscord,
listScheduledEventsDiscord,
moveChannelDiscord,
removeChannelPermissionDiscord,
removeRoleDiscord,
setChannelPermissionDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
};
type DiscordRoleMutationOpts = { cfg: OpenClawConfig; accountId?: string };
type DiscordRoleMutation = (
params: {
guildId: string;
userId: string;
roleId: string;
},
options: DiscordRoleMutationOpts,
) => Promise<unknown>;
type GuildAdminActionGuard = {
gate: keyof DiscordActionConfig;
defaultEnabled?: boolean;
disabledMessage: string;
permissions: bigint[];
permissionScope?: "guild" | "channel";
};
const expressionPermissions = [
PermissionFlagsBits.ManageGuildExpressions,
PermissionFlagsBits.CreateGuildExpressions,
PermissionFlagsBits.ManageEmojisAndStickers,
];
const channelGuard = {
gate: "channels",
disabledMessage: "Discord channel management is disabled.",
permissions: [PermissionFlagsBits.ManageChannels],
} satisfies GuildAdminActionGuard;
const existingChannelGuard = {
...channelGuard,
permissionScope: "channel",
} satisfies GuildAdminActionGuard;
const channelPermissionGuard = {
...channelGuard,
permissions: [PermissionFlagsBits.ManageRoles],
permissionScope: "channel",
} satisfies GuildAdminActionGuard;
const guildAdminActionGuards: Partial<Record<string, GuildAdminActionGuard>> = {
emojiUpload: {
gate: "emojiUploads",
disabledMessage: "Discord emoji uploads are disabled.",
permissions: expressionPermissions,
},
stickerUpload: {
gate: "stickerUploads",
disabledMessage: "Discord sticker uploads are disabled.",
permissions: expressionPermissions,
},
roleAdd: {
gate: "roles",
defaultEnabled: false,
disabledMessage: "Discord role changes are disabled.",
permissions: [PermissionFlagsBits.ManageRoles],
},
roleRemove: {
gate: "roles",
defaultEnabled: false,
disabledMessage: "Discord role changes are disabled.",
permissions: [PermissionFlagsBits.ManageRoles],
},
eventCreate: {
gate: "events",
disabledMessage: "Discord events are disabled.",
permissions: [PermissionFlagsBits.ManageEvents, PermissionFlagsBits.CreateEvents],
},
channelCreate: channelGuard,
channelEdit: existingChannelGuard,
channelDelete: existingChannelGuard,
channelMove: existingChannelGuard,
categoryCreate: channelGuard,
categoryEdit: existingChannelGuard,
categoryDelete: existingChannelGuard,
channelPermissionSet: channelPermissionGuard,
channelPermissionRemove: channelPermissionGuard,
};
function isThreadChannelType(channelType: number | undefined) {
return (
channelType === ChannelType.GuildNewsThread ||
channelType === ChannelType.GuildPublicThread ||
channelType === ChannelType.GuildPrivateThread
);
}
function isLockedThreadChannel(channel: unknown) {
if (!channel || typeof channel !== "object") {
return false;
}
const metadata = (channel as { thread_metadata?: { locked?: unknown } }).thread_metadata;
return metadata?.locked === true;
}
function assertGuildAdminActionEnabled(
action: string,
isActionEnabled: ActionGate<DiscordActionConfig>,
) {
const guard = guildAdminActionGuards[action];
if (guard && !isActionEnabled(guard.gate, guard.defaultEnabled)) {
throw new Error(guard.disabledMessage);
}
}
async function resolveGuildIdForGuildAdminAction(params: {
values: Record<string, unknown>;
accountId?: string;
cfg: OpenClawConfig;
}): Promise<string | undefined> {
const guildId = readStringParam(params.values, "guildId");
if (guildId) {
return guildId;
}
const channelLikeId =
readStringParam(params.values, "channelId") ?? readStringParam(params.values, "categoryId");
if (!channelLikeId) {
return undefined;
}
const channel = await discordGuildActionRuntime.fetchChannelInfoDiscord(
channelLikeId,
createDiscordActionOptions({ cfg: params.cfg, accountId: params.accountId }),
);
return "guild_id" in channel ? (channel.guild_id ?? undefined) : undefined;
}
function readChannelScopedPermissionTargetId(action: string, values: Record<string, unknown>) {
if (action === "eventCreate") {
return readStringParam(values, "channelId");
}
if (action === "categoryEdit" || action === "categoryDelete") {
return readStringParam(values, "categoryId");
}
return readStringParam(values, "channelId");
}
async function resolveGuildAdminActionPermissions(params: {
action: string;
values: Record<string, unknown>;
accountId?: string;
cfg: OpenClawConfig;
guard: GuildAdminActionGuard;
}) {
if (params.action !== "channelEdit") {
return params.guard.permissions;
}
const channelId = readStringParam(params.values, "channelId");
if (!channelId) {
return params.guard.permissions;
}
const channel = await discordGuildActionRuntime.fetchChannelInfoDiscord(
channelId,
createDiscordActionOptions({ cfg: params.cfg, accountId: params.accountId }),
);
const channelType = "type" in channel ? channel.type : undefined;
if (!isThreadChannelType(channelType)) {
return params.guard.permissions;
}
const onlyReopen =
params.values.archived === false &&
!("name" in params.values) &&
!("topic" in params.values) &&
!("position" in params.values) &&
!("parentId" in params.values) &&
!("clearParent" in params.values) &&
!("nsfw" in params.values) &&
!("rateLimitPerUser" in params.values) &&
!("locked" in params.values) &&
!("autoArchiveDuration" in params.values) &&
!isLockedThreadChannel(channel);
return onlyReopen
? [PermissionFlagsBits.ManageThreads, PermissionFlagsBits.SendMessagesInThreads]
: [PermissionFlagsBits.ManageThreads];
}
async function verifySenderGuildAdminPermission(params: {
action: string;
values: Record<string, unknown>;
accountId?: string;
cfg: OpenClawConfig;
}) {
const guard = guildAdminActionGuards[params.action];
const senderUserId = readStringParam(params.values, "senderUserId");
if (!guard?.permissions.length || !senderUserId) {
return;
}
const requiredPermissions = await resolveGuildAdminActionPermissions({ ...params, guard });
const guildId = await resolveGuildIdForGuildAdminAction(params);
if (!guildId) {
throw new Error(`Guild id required to authorize Discord guild action: ${params.action}`);
}
const actionOptions = createDiscordActionOptions({
cfg: params.cfg,
accountId: params.accountId,
});
const targetChannelId =
guard?.permissionScope === "channel" || params.action === "eventCreate"
? readChannelScopedPermissionTargetId(params.action, params.values)
: undefined;
const hasPermission = targetChannelId
? await discordGuildActionRuntime.hasAnyChannelPermissionDiscord(
guildId,
targetChannelId,
senderUserId,
requiredPermissions,
actionOptions,
)
: await discordGuildActionRuntime.hasAnyGuildPermissionDiscord(
guildId,
senderUserId,
requiredPermissions,
actionOptions,
);
if (!hasPermission) {
throw new Error("Sender does not have required permissions for this guild action.");
}
if (params.action === "roleAdd" || params.action === "roleRemove") {
const targetUserId = readStringParam(params.values, "userId", { required: true });
const roleId = readStringParam(params.values, "roleId", { required: true });
const canManageRole = await discordGuildActionRuntime.canManageGuildMemberRoleDiscord(
guildId,
senderUserId,
targetUserId,
roleId,
actionOptions,
{ assignablePermissionCeiling: params.action === "roleAdd" },
);
if (!canManageRole) {
throw new Error("Sender cannot manage the requested role or member.");
}
}
if (params.action === "channelPermissionSet" || params.action === "channelPermissionRemove") {
const targetType = readStringParam(params.values, "targetType");
if (targetType === "member") {
return;
}
const targetId = readStringParam(params.values, "targetId", { required: true });
const canManageRole = await discordGuildActionRuntime.canManageGuildRoleDiscord(
guildId,
senderUserId,
targetId,
actionOptions,
);
if (canManageRole === false || (targetType === "role" && canManageRole === null)) {
throw new Error("Sender cannot manage the requested role overwrite.");
}
}
}
async function runRoleMutation(params: {
cfg: OpenClawConfig;
accountId?: string;
values: Record<string, unknown>;
mutate: DiscordRoleMutation;
}) {
const guildId = readStringParam(params.values, "guildId", { required: true });
const userId = readStringParam(params.values, "userId", { required: true });
const roleId = readStringParam(params.values, "roleId", { required: true });
await params.mutate(
{ guildId, userId, roleId },
createDiscordActionOptions({ cfg: params.cfg, accountId: params.accountId }),
);
}
function readChannelPermissionTarget(params: Record<string, unknown>) {
return {
channelId: readStringParam(params, "channelId", { required: true }),
targetId: readStringParam(params, "targetId", { required: true }),
};
}
export async function handleDiscordGuildAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
cfg: OpenClawConfig,
options?: { mediaLocalRoots?: readonly string[] },
): Promise<AgentToolResult<unknown>> {
const accountId = readStringParam(params, "accountId");
if (!cfg) {
throw new Error("Discord guild actions require a resolved runtime config.");
}
assertGuildAdminActionEnabled(action, isActionEnabled);
await verifySenderGuildAdminPermission({ action, values: params, accountId, cfg });
const readTargetGate = createDiscordMessagingActionContext({
action,
input: params,
isActionEnabled,
cfg,
options,
});
const withOpts = (extra?: Record<string, unknown>) =>
createDiscordActionOptions({ cfg, accountId, extra });
const assertGuildMetadataReadAllowed = async (guildId: string) => {
await readTargetGate.assertGuildReadTargetAllowed({
guildId,
channelTargetRequiredMessage:
"Discord guild metadata reads require a wildcard channel allowlist for this guild.",
});
};
switch (action) {
case "memberInfo": {
if (!isActionEnabled("memberInfo")) {
throw new Error("Discord member info is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
await assertGuildMetadataReadAllowed(guildId);
const userId = readStringParam(params, "userId", {
required: true,
});
const effectiveAccountId = accountId ?? resolveDefaultDiscordAccountId(cfg);
const member = await discordGuildActionRuntime.fetchMemberInfoDiscord(
guildId,
userId,
createDiscordActionOptions({ cfg, accountId: effectiveAccountId }),
);
const presence = getPresence(effectiveAccountId, userId);
const activities = presence?.activities ?? undefined;
const status = presence?.status ?? undefined;
return jsonResult({ ok: true, member, ...(presence ? { status, activities } : {}) });
}
case "roleInfo": {
if (!isActionEnabled("roleInfo")) {
throw new Error("Discord role info is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
await assertGuildMetadataReadAllowed(guildId);
const roles = await discordGuildActionRuntime.fetchRoleInfoDiscord(guildId, withOpts());
return jsonResult({ ok: true, roles });
}
case "emojiList": {
if (!isActionEnabled("reactions")) {
throw new Error("Discord reactions are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
await assertGuildMetadataReadAllowed(guildId);
const emojis = await discordGuildActionRuntime.listGuildEmojisDiscord(guildId, withOpts());
return jsonResult({ ok: true, emojis });
}
case "emojiUpload": {
if (!isActionEnabled("emojiUploads")) {
throw new Error("Discord emoji uploads are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const name = readStringParam(params, "name", { required: true });
const mediaUrl = readStringParam(params, "mediaUrl", {
required: true,
});
const roleIds = readStringArrayParam(params, "roleIds");
const emoji = await discordGuildActionRuntime.uploadEmojiDiscord(
{
guildId,
name,
mediaUrl,
roleIds: roleIds?.length ? roleIds : undefined,
},
withOpts(),
);
return jsonResult({ ok: true, emoji });
}
case "stickerUpload": {
if (!isActionEnabled("stickerUploads")) {
throw new Error("Discord sticker uploads are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const name = readStringParam(params, "name", { required: true });
const description = readStringParam(params, "description", {
required: true,
});
const tags = readStringParam(params, "tags", { required: true });
const mediaUrl = readStringParam(params, "mediaUrl", {
required: true,
});
const sticker = await discordGuildActionRuntime.uploadStickerDiscord(
{
guildId,
name,
description,
tags,
mediaUrl,
},
withOpts(),
);
return jsonResult({ ok: true, sticker });
}
case "roleAdd": {
if (!isActionEnabled("roles", false)) {
throw new Error("Discord role changes are disabled.");
}
await runRoleMutation({
cfg,
accountId,
values: params,
mutate: discordGuildActionRuntime.addRoleDiscord,
});
return jsonResult({ ok: true });
}
case "roleRemove": {
if (!isActionEnabled("roles", false)) {
throw new Error("Discord role changes are disabled.");
}
await runRoleMutation({
cfg,
accountId,
values: params,
mutate: discordGuildActionRuntime.removeRoleDiscord,
});
return jsonResult({ ok: true });
}
case "channelInfo": {
if (!isActionEnabled("channelInfo")) {
throw new Error("Discord channel info is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
await readTargetGate.assertReadTargetAllowed({ channelId });
const channel = await discordGuildActionRuntime.fetchChannelInfoDiscord(
channelId,
withOpts(),
);
return jsonResult({ ok: true, channel });
}
case "channelList": {
if (!isActionEnabled("channelInfo")) {
throw new Error("Discord channel info is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
await assertGuildMetadataReadAllowed(guildId);
const channels = await discordGuildActionRuntime.listGuildChannelsDiscord(
guildId,
withOpts(),
);
return jsonResult({ ok: true, channels });
}
case "voiceStatus": {
if (!isActionEnabled("voiceStatus")) {
throw new Error("Discord voice status is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
await assertGuildMetadataReadAllowed(guildId);
const userId = readStringParam(params, "userId", {
required: true,
});
const voice = await discordGuildActionRuntime.fetchVoiceStatusDiscord(
guildId,
userId,
withOpts(),
);
return jsonResult({ ok: true, voice });
}
case "eventList": {
if (!isActionEnabled("events")) {
throw new Error("Discord events are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
await assertGuildMetadataReadAllowed(guildId);
const events = await discordGuildActionRuntime.listScheduledEventsDiscord(
guildId,
withOpts(),
);
return jsonResult({ ok: true, events });
}
case "eventCreate": {
if (!isActionEnabled("events")) {
throw new Error("Discord events are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const name = readStringParam(params, "name", { required: true });
const startTime = readStringParam(params, "startTime", {
required: true,
});
const endTime = readStringParam(params, "endTime");
const description = readStringParam(params, "description");
const channelId = readStringParam(params, "channelId");
const location = readStringParam(params, "location");
const imageUrl = readStringParam(params, "image", { trim: false });
const entityTypeRaw = readStringParam(params, "entityType");
const entityType = entityTypeRaw === "stage" ? 1 : entityTypeRaw === "external" ? 3 : 2;
const image = imageUrl
? await discordGuildActionRuntime.resolveEventCoverImage(imageUrl, {
localRoots: options?.mediaLocalRoots,
})
: undefined;
const payload = {
name,
description,
scheduled_start_time: startTime,
scheduled_end_time: endTime,
entity_type: entityType,
channel_id: channelId,
entity_metadata: entityType === 3 && location ? { location } : undefined,
image,
privacy_level: 2,
};
const event = await discordGuildActionRuntime.createScheduledEventDiscord(
guildId,
payload,
withOpts(),
);
return jsonResult({ ok: true, event });
}
case "channelCreate": {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const channel = await discordGuildActionRuntime.createChannelDiscord(
readDiscordChannelCreateParams(params),
withOpts(),
);
return jsonResult({ ok: true, channel });
}
case "channelEdit": {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const channel = await discordGuildActionRuntime.editChannelDiscord(
readDiscordChannelEditParams(params),
withOpts(),
);
return jsonResult({ ok: true, channel });
}
case "channelDelete": {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const result = await discordGuildActionRuntime.deleteChannelDiscord(channelId, withOpts());
return jsonResult(result);
}
case "channelMove": {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
await discordGuildActionRuntime.moveChannelDiscord(
readDiscordChannelMoveParams(params),
withOpts(),
);
return jsonResult({ ok: true });
}
case "categoryCreate": {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "name", { required: true });
const position = readNonNegativeIntegerParam(params, "position");
const channel = await discordGuildActionRuntime.createChannelDiscord(
{
guildId,
name,
type: 4,
position: position ?? undefined,
},
withOpts(),
);
return jsonResult({ ok: true, category: channel });
}
case "categoryEdit": {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const categoryId = readStringParam(params, "categoryId", {
required: true,
});
const name = readStringParam(params, "name");
const position = readNonNegativeIntegerParam(params, "position");
const channel = await discordGuildActionRuntime.editChannelDiscord(
{
channelId: categoryId,
name: name ?? undefined,
position: position ?? undefined,
},
withOpts(),
);
return jsonResult({ ok: true, category: channel });
}
case "categoryDelete": {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const categoryId = readStringParam(params, "categoryId", {
required: true,
});
const result = await discordGuildActionRuntime.deleteChannelDiscord(categoryId, withOpts());
return jsonResult(result);
}
case "channelPermissionSet": {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const { channelId, targetId } = readChannelPermissionTarget(params);
const targetTypeRaw = readStringParam(params, "targetType", {
required: true,
});
const targetType = targetTypeRaw === "member" ? 1 : 0;
const allow = readStringParam(params, "allow");
const deny = readStringParam(params, "deny");
await discordGuildActionRuntime.setChannelPermissionDiscord(
{
channelId,
targetId,
targetType,
allow: allow ?? undefined,
deny: deny ?? undefined,
},
withOpts(),
);
return jsonResult({ ok: true });
}
case "channelPermissionRemove": {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const { channelId, targetId } = readChannelPermissionTarget(params);
await discordGuildActionRuntime.removeChannelPermissionDiscord(
channelId,
targetId,
withOpts(),
);
return jsonResult({ ok: true });
}
default:
throw new Error(`Unknown action: ${action}`);
}
}

View File

@@ -0,0 +1,270 @@
// Discord plugin module implements runtime.messaging.messages behavior.
import {
jsonResult,
readPositiveIntegerParam,
readStringArrayParam,
readStringParam,
} from "../runtime-api.js";
import { discordMessagingActionRuntime } from "./runtime.messaging.runtime.js";
import type { DiscordMessagingActionContext } from "./runtime.messaging.shared.js";
function parseDiscordMessageLink(link: string) {
const normalized = link.trim();
const match = normalized.match(
/^(?:https?:\/\/)?(?:ptb\.|canary\.)?discord(?:app)?\.com\/channels\/(\d+)\/(\d+)\/(\d+)(?:\/?|\?.*)$/i,
);
if (!match) {
throw new Error(
"Invalid Discord message link. Expected https://discord.com/channels/<guildId>/<channelId>/<messageId>.",
);
}
return {
guildId: match[1],
channelId: match[2],
messageId: match[3],
};
}
function describeDiscordMessageListResult(value: unknown): string {
if (Array.isArray(value)) {
return "array";
}
if (value === null) {
return "null";
}
if (value && typeof value === "object") {
const keys = Object.keys(value).toSorted();
return keys.length ? `object with keys ${keys.join(", ")}` : "object";
}
return typeof value;
}
function assertDiscordMessageListResult(value: unknown): Array<unknown> {
if (Array.isArray(value)) {
return value;
}
throw new Error(
`Discord message read returned ${describeDiscordMessageListResult(value)} instead of an array.`,
);
}
export async function handleDiscordMessageManagementAction(ctx: DiscordMessagingActionContext) {
switch (ctx.action) {
case "permissions": {
if (!ctx.isActionEnabled("permissions")) {
throw new Error("Discord permissions are disabled.");
}
const channelId = ctx.resolveChannelId();
await ctx.assertReadTargetAllowed({ channelId });
const permissions = await discordMessagingActionRuntime.fetchChannelPermissionsDiscord(
channelId,
ctx.withOpts(),
);
return jsonResult({ ok: true, permissions });
}
case "fetchMessage": {
if (!ctx.isActionEnabled("messages")) {
throw new Error("Discord message reads are disabled.");
}
const messageLink = readStringParam(ctx.params, "messageLink");
let guildId = readStringParam(ctx.params, "guildId");
let channelId = readStringParam(ctx.params, "channelId");
let messageId = readStringParam(ctx.params, "messageId");
if (messageLink) {
const parsed = parseDiscordMessageLink(messageLink);
guildId = parsed.guildId;
channelId = parsed.channelId;
messageId = parsed.messageId;
}
if (!guildId || !channelId || !messageId) {
throw new Error(
"Discord message fetch requires guildId, channelId, and messageId (or a valid messageLink).",
);
}
await ctx.assertReadTargetAllowed({ guildId, channelId });
const message = await discordMessagingActionRuntime.fetchMessageDiscord(
channelId,
messageId,
ctx.withOpts(),
);
return jsonResult({
ok: true,
message: ctx.normalizeMessage(message),
guildId,
channelId,
messageId,
});
}
case "readMessages": {
if (!ctx.isActionEnabled("messages")) {
throw new Error("Discord message reads are disabled.");
}
const channelId = ctx.resolveChannelId();
await ctx.assertReadTargetAllowed({ channelId });
const query = {
limit: readPositiveIntegerParam(ctx.params, "limit"),
before: readStringParam(ctx.params, "before"),
after: readStringParam(ctx.params, "after"),
around: readStringParam(ctx.params, "around"),
};
const messages = assertDiscordMessageListResult(
await discordMessagingActionRuntime.readMessagesDiscord(channelId, query, ctx.withOpts()),
);
return jsonResult({
ok: true,
messages: messages.map((message) => ctx.normalizeMessage(message)),
});
}
case "editMessage": {
if (!ctx.isActionEnabled("messages")) {
throw new Error("Discord message edits are disabled.");
}
const channelId = ctx.resolveChannelId();
const messageId = readStringParam(ctx.params, "messageId", {
required: true,
});
const content = readStringParam(ctx.params, "content", {
required: true,
});
const message = await discordMessagingActionRuntime.editMessageDiscord(
channelId,
messageId,
{ content },
ctx.withOpts(),
);
return jsonResult({ ok: true, message });
}
case "deleteMessage": {
if (!ctx.isActionEnabled("messages")) {
throw new Error("Discord message deletes are disabled.");
}
const channelId = ctx.resolveChannelId();
const messageId = readStringParam(ctx.params, "messageId", {
required: true,
});
await discordMessagingActionRuntime.deleteMessageDiscord(
channelId,
messageId,
ctx.withOpts(),
);
return jsonResult({ ok: true });
}
case "pinMessage": {
if (!ctx.isActionEnabled("pins")) {
throw new Error("Discord pins are disabled.");
}
const channelId = ctx.resolveChannelId();
const messageId = readStringParam(ctx.params, "messageId", {
required: true,
});
await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, ctx.withOpts());
return jsonResult({ ok: true });
}
case "unpinMessage": {
if (!ctx.isActionEnabled("pins")) {
throw new Error("Discord pins are disabled.");
}
const channelId = ctx.resolveChannelId();
const messageId = readStringParam(ctx.params, "messageId", {
required: true,
});
await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, ctx.withOpts());
return jsonResult({ ok: true });
}
case "listPins": {
if (!ctx.isActionEnabled("pins")) {
throw new Error("Discord pins are disabled.");
}
const channelId = ctx.resolveChannelId();
await ctx.assertReadTargetAllowed({ channelId });
const pins = await discordMessagingActionRuntime.listPinsDiscord(channelId, ctx.withOpts());
return jsonResult({ ok: true, pins: pins.map((pin) => ctx.normalizeMessage(pin)) });
}
case "searchMessages": {
if (!ctx.isActionEnabled("search")) {
throw new Error("Discord search is disabled.");
}
let guildId = readStringParam(ctx.params, "guildId");
const content =
readStringParam(ctx.params, "content") ?? readStringParam(ctx.params, "query");
if (!content) {
throw new Error("Discord search requires content or query text.");
}
const channelId = readStringParam(ctx.params, "channelId");
const channelIds = readStringArrayParam(ctx.params, "channelIds");
// Resolve guildId from channel info when not explicitly provided.
if (!guildId) {
const rawInferChannelId = channelId ?? channelIds?.[0];
if (rawInferChannelId) {
try {
const inferChannelId =
discordMessagingActionRuntime.resolveDiscordChannelId(rawInferChannelId);
const channelInfo = await discordMessagingActionRuntime.fetchChannelInfoDiscord(
inferChannelId,
ctx.withOpts(),
);
if (channelInfo && typeof channelInfo === "object") {
const record = channelInfo as unknown as Record<string, unknown>;
const resolved = record.guild_id ?? record.guildId;
if (typeof resolved === "string" && resolved.trim()) {
guildId = resolved.trim();
}
}
} catch {
// Channel info fetch failed; fall through to descriptive error.
}
}
}
if (!guildId) {
throw new Error(
"Discord search requires guildId. Provide guildId explicitly, or provide channelId so the guild can be resolved from the channel.",
);
}
const authorId = readStringParam(ctx.params, "authorId");
const authorIds = readStringArrayParam(ctx.params, "authorIds");
const limit = readPositiveIntegerParam(ctx.params, "limit");
const channelIdList = [
...(channelIds ?? []).map((id) =>
discordMessagingActionRuntime.resolveDiscordChannelId(id),
),
...(channelId ? [discordMessagingActionRuntime.resolveDiscordChannelId(channelId)] : []),
];
if (channelIdList.length > 0) {
for (const targetChannelId of channelIdList) {
await ctx.assertReadTargetAllowed({ guildId, channelId: targetChannelId });
}
} else {
await ctx.assertGuildReadTargetAllowed({ guildId });
}
const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])];
const results = await discordMessagingActionRuntime.searchMessagesDiscord(
{
guildId,
content,
channelIds: channelIdList.length ? channelIdList : undefined,
authorIds: authorIdList.length ? authorIdList : undefined,
limit,
},
ctx.withOpts(),
);
if (!results || typeof results !== "object") {
return jsonResult({ ok: true, results });
}
const messages = results.messages;
const normalizedMessages = Array.isArray(messages)
? messages.map((group) =>
Array.isArray(group) ? group.map((msg) => ctx.normalizeMessage(msg)) : group,
)
: messages;
return jsonResult({
ok: true,
results: {
...results,
messages: normalizedMessages,
},
});
}
default:
return undefined;
}
}

View File

@@ -0,0 +1,69 @@
// Discord plugin module implements runtime.messaging.reactions behavior.
import {
jsonResult,
readPositiveIntegerParam,
readReactionParams,
readStringParam,
} from "../runtime-api.js";
import { discordMessagingActionRuntime } from "./runtime.messaging.runtime.js";
import type { DiscordMessagingActionContext } from "./runtime.messaging.shared.js";
export async function handleDiscordReactionMessagingAction(ctx: DiscordMessagingActionContext) {
switch (ctx.action) {
case "react": {
if (!ctx.isActionEnabled("reactions")) {
throw new Error("Discord reactions are disabled.");
}
const channelId = await ctx.resolveReactionChannelId();
const messageId = readStringParam(ctx.params, "messageId", {
required: true,
});
const { emoji, remove, isEmpty } = readReactionParams(ctx.params, {
removeErrorMessage: "Emoji is required to remove a Discord reaction.",
});
if (remove) {
await discordMessagingActionRuntime.removeReactionDiscord(
channelId,
messageId,
emoji,
ctx.withReactionRuntimeOptions(),
);
return jsonResult({ ok: true, removed: emoji });
}
if (isEmpty) {
const removed = await discordMessagingActionRuntime.removeOwnReactionsDiscord(
channelId,
messageId,
ctx.withReactionRuntimeOptions(),
);
return jsonResult({ ok: true, removed: removed.removed });
}
await discordMessagingActionRuntime.reactMessageDiscord(
channelId,
messageId,
emoji,
ctx.withReactionRuntimeOptions(),
);
return jsonResult({ ok: true, added: emoji });
}
case "reactions": {
if (!ctx.isActionEnabled("reactions")) {
throw new Error("Discord reactions are disabled.");
}
const channelId = await ctx.resolveReactionChannelId();
const messageId = readStringParam(ctx.params, "messageId", {
required: true,
});
const limit = readPositiveIntegerParam(ctx.params, "limit");
await ctx.assertReadTargetAllowed({ channelId });
const reactions = await discordMessagingActionRuntime.fetchReactionsDiscord(
channelId,
messageId,
ctx.withReactionRuntimeOptions({ limit }),
);
return jsonResult({ ok: true, reactions });
}
default:
return undefined;
}
}

View File

@@ -0,0 +1,76 @@
// Discord plugin module implements runtime.messaging behavior.
import { readDiscordComponentSpec } from "../components.js";
import type { OpenClawConfig } from "../runtime-api.js";
import { sendDiscordComponentMessage } from "../send.components.js";
import {
createThreadDiscord,
deleteMessageDiscord,
editMessageDiscord,
editChannelDiscord,
fetchChannelInfoDiscord,
fetchGuildInfoDiscord,
fetchChannelPermissionsDiscord,
fetchMessageDiscord,
fetchReactionsDiscord,
listPinsDiscord,
listThreadsDiscord,
pinMessageDiscord,
reactMessageDiscord,
readMessagesDiscord,
removeOwnReactionsDiscord,
removeReactionDiscord,
searchMessagesDiscord,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
sendVoiceMessageDiscord,
unpinMessageDiscord,
} from "../send.js";
import { resolveDiscordTargetChannelId } from "../send.shared.js";
import { resolveDiscordChannelId } from "../targets.js";
export const discordMessagingActionRuntime = {
createThreadDiscord,
deleteMessageDiscord,
editChannelDiscord,
editMessageDiscord,
fetchChannelInfoDiscord,
fetchGuildInfoDiscord,
fetchChannelPermissionsDiscord,
fetchMessageDiscord,
fetchReactionsDiscord,
listPinsDiscord,
listThreadsDiscord,
pinMessageDiscord,
reactMessageDiscord,
readDiscordComponentSpec,
readMessagesDiscord,
removeOwnReactionsDiscord,
removeReactionDiscord,
resolveDiscordReactionTargetChannelId,
resolveDiscordChannelId,
searchMessagesDiscord,
sendDiscordComponentMessage,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
sendVoiceMessageDiscord,
unpinMessageDiscord,
};
async function resolveDiscordReactionTargetChannelId(params: {
target: string;
cfg: OpenClawConfig;
accountId?: string;
}): Promise<string> {
try {
return resolveDiscordChannelId(params.target);
} catch {
return (
await resolveDiscordTargetChannelId(params.target, {
cfg: params.cfg,
accountId: params.accountId,
})
).channelId;
}
}

View File

@@ -0,0 +1,424 @@
// Discord plugin module implements runtime.messaging.send behavior.
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
assertMediaNotDataUrl,
jsonResult,
readBooleanParam,
readPositiveIntegerParam,
readStringArrayParam,
readStringParam,
resolvePollMaxSelections,
} from "../runtime-api.js";
import { DiscordThreadInitialMessageError } from "../send.js";
import { isThreadChannelType } from "../send.permissions.js";
import type { DiscordSendComponents, DiscordSendEmbeds } from "../send.shared.js";
import { discordMessagingActionRuntime } from "./runtime.messaging.runtime.js";
import type { DiscordMessagingActionContext } from "./runtime.messaging.shared.js";
function hasDiscordComponentObjectKeys(value: unknown): value is Record<string, unknown> {
return Boolean(
value &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value as Record<string, unknown>).length > 0,
);
}
function readDiscordThreadArchiveTimestamp(thread: unknown): string | undefined {
if (!thread || typeof thread !== "object" || Array.isArray(thread)) {
return undefined;
}
const record = thread as Record<string, unknown>;
const metadata = record.thread_metadata;
if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
const archiveTimestamp = (metadata as Record<string, unknown>).archive_timestamp;
if (typeof archiveTimestamp === "string" && archiveTimestamp.trim()) {
return archiveTimestamp;
}
}
return undefined;
}
type DiscordThreadListActionResult = {
ok: true;
threads: unknown;
complete: boolean;
hasMore: boolean;
returnedCount: number;
source: "discord.threadList.archived" | "discord.threadList.active";
query: {
guildId: string;
channelId?: string;
includeArchived: boolean;
before?: string;
limit?: number;
};
nextBefore?: string;
};
function normalizeDiscordThreadListActionResult(params: {
value: unknown;
includeArchived: boolean;
channelId?: string;
guildId: string;
limit?: number;
before?: string;
}): DiscordThreadListActionResult {
const record =
params.value && typeof params.value === "object" && !Array.isArray(params.value)
? (params.value as Record<string, unknown>)
: undefined;
const threadItems = Array.isArray(record?.threads) ? record.threads : [];
const hasMore = record?.has_more === true;
const nextBefore =
params.includeArchived && hasMore
? readDiscordThreadArchiveTimestamp(threadItems[threadItems.length - 1])
: undefined;
return {
ok: true,
threads: params.value,
complete: !hasMore,
hasMore,
returnedCount: threadItems.length,
source: params.includeArchived ? "discord.threadList.archived" : "discord.threadList.active",
query: {
guildId: params.guildId,
...(params.channelId ? { channelId: params.channelId } : {}),
includeArchived: params.includeArchived,
...(params.before ? { before: params.before } : {}),
...(params.limit !== undefined ? { limit: params.limit } : {}),
},
...(nextBefore ? { nextBefore } : {}),
};
}
async function appendDiscordThreadRenameResult(
ctx: DiscordMessagingActionContext,
params: {
payload: Record<string, unknown>;
target: string;
threadName?: string;
},
) {
const threadName = params.threadName?.trim();
if (!threadName) {
return params.payload;
}
if (!ctx.isActionEnabled("channels")) {
return {
...params.payload,
warning: "Discord threadName was ignored because Discord channel management is disabled.",
};
}
let channelId: string;
try {
channelId = discordMessagingActionRuntime.resolveDiscordChannelId(params.target);
} catch {
return {
...params.payload,
warning: "Discord threadName was ignored because the send target is not a channel/thread.",
};
}
try {
const channel = await discordMessagingActionRuntime.fetchChannelInfoDiscord(
channelId,
ctx.withOpts(),
);
if (!isThreadChannelType(channel.type)) {
return {
...params.payload,
warning: "Discord threadName was ignored because the send target is not a thread.",
};
}
const renamed = await discordMessagingActionRuntime.editChannelDiscord(
{
channelId,
name: threadName,
},
ctx.withOpts(),
);
return {
...params.payload,
threadRename: {
ok: true,
channelId,
name: renamed.name ?? threadName,
},
};
} catch (error) {
return {
...params.payload,
warning: `Discord message was sent, but thread rename failed: ${formatErrorMessage(error)}`,
};
}
}
export async function handleDiscordMessageSendAction(ctx: DiscordMessagingActionContext) {
switch (ctx.action) {
case "sticker": {
if (!ctx.isActionEnabled("stickers")) {
throw new Error("Discord stickers are disabled.");
}
const to = readStringParam(ctx.params, "to", { required: true });
const content = readStringParam(ctx.params, "content");
const stickerIds = readStringArrayParam(ctx.params, "stickerIds", {
required: true,
label: "stickerIds",
});
await discordMessagingActionRuntime.sendStickerDiscord(
to,
stickerIds,
ctx.withOpts({ content }),
);
return jsonResult({ ok: true });
}
case "poll": {
if (!ctx.isActionEnabled("polls")) {
throw new Error("Discord polls are disabled.");
}
const to = readStringParam(ctx.params, "to", { required: true });
const content = readStringParam(ctx.params, "content");
const question = readStringParam(ctx.params, "question", {
required: true,
});
const answers = readStringArrayParam(ctx.params, "answers", {
required: true,
label: "answers",
});
const allowMultiselect = readBooleanParam(ctx.params, "allowMultiselect");
const durationHours = readPositiveIntegerParam(ctx.params, "durationHours");
const maxSelections = resolvePollMaxSelections(answers.length, allowMultiselect);
await discordMessagingActionRuntime.sendPollDiscord(
to,
{ question, options: answers, maxSelections, durationHours },
ctx.withOpts({ content }),
);
return jsonResult({ ok: true });
}
case "sendMessage": {
if (!ctx.isActionEnabled("messages")) {
throw new Error("Discord message sends are disabled.");
}
const to = readStringParam(ctx.params, "to", { required: true });
const asVoice = ctx.params.asVoice === true;
const silent = ctx.params.silent === true;
const suppressEmbeds =
ctx.params.suppressEmbeds === undefined ? undefined : ctx.params.suppressEmbeds === true;
const rawComponents = ctx.params.components;
const componentSpec = hasDiscordComponentObjectKeys(rawComponents)
? discordMessagingActionRuntime.readDiscordComponentSpec(rawComponents)
: null;
const components: DiscordSendComponents | undefined =
Array.isArray(rawComponents) || typeof rawComponents === "function"
? (rawComponents as DiscordSendComponents)
: undefined;
const mediaUrl =
readStringParam(ctx.params, "mediaUrl", { trim: false }) ??
readStringParam(ctx.params, "path", { trim: false }) ??
readStringParam(ctx.params, "filePath", { trim: false });
const content = readStringParam(ctx.params, "content", {
required: !asVoice && !componentSpec && !components && !mediaUrl,
allowEmpty: true,
});
const filename = readStringParam(ctx.params, "filename");
const replyTo = readStringParam(ctx.params, "replyTo");
const threadName = readStringParam(ctx.params, "threadName");
const rawEmbeds = ctx.params.embeds;
const embeds: DiscordSendEmbeds | undefined = Array.isArray(rawEmbeds)
? (rawEmbeds as DiscordSendEmbeds)
: undefined;
const sessionKey = readStringParam(ctx.params, "__sessionKey");
const agentId = readStringParam(ctx.params, "__agentId");
if (componentSpec) {
if (asVoice) {
throw new Error("Discord components cannot be sent as voice messages.");
}
if (embeds?.length) {
throw new Error("Discord components cannot include embeds.");
}
const normalizedContent = content?.trim() ? content : undefined;
const payload = componentSpec.text
? componentSpec
: { ...componentSpec, text: normalizedContent };
const result = await discordMessagingActionRuntime.sendDiscordComponentMessage(
to,
payload,
{
...ctx.withOpts(),
silent,
replyTo: replyTo ?? undefined,
sessionKey: sessionKey ?? undefined,
agentId: agentId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
filename: filename ?? undefined,
mediaAccess: ctx.options?.mediaAccess,
mediaLocalRoots: ctx.options?.mediaLocalRoots,
mediaReadFile: ctx.options?.mediaReadFile,
...(suppressEmbeds === undefined ? {} : { suppressEmbeds }),
},
);
return jsonResult(
await appendDiscordThreadRenameResult(ctx, {
payload: { ok: true, result, components: true },
target: to,
threadName,
}),
);
}
if (asVoice) {
if (!mediaUrl) {
throw new Error(
"Voice messages require a media file reference (mediaUrl, path, or filePath).",
);
}
if (content && content.trim()) {
throw new Error(
"Voice messages cannot include text content (Discord limitation). Remove the content parameter.",
);
}
assertMediaNotDataUrl(mediaUrl);
const result = await discordMessagingActionRuntime.sendVoiceMessageDiscord(to, mediaUrl, {
...ctx.withOpts(),
replyTo,
silent,
});
return jsonResult(
await appendDiscordThreadRenameResult(ctx, {
payload: { ok: true, result, voiceMessage: true },
target: to,
threadName,
}),
);
}
const result = await discordMessagingActionRuntime.sendMessageDiscord(to, content ?? "", {
...ctx.withOpts(),
mediaAccess: ctx.options?.mediaAccess,
mediaUrl,
filename: filename ?? undefined,
mediaLocalRoots: ctx.options?.mediaLocalRoots,
mediaReadFile: ctx.options?.mediaReadFile,
replyTo,
components,
embeds,
silent,
...(suppressEmbeds === undefined ? {} : { suppressEmbeds }),
});
return jsonResult(
await appendDiscordThreadRenameResult(ctx, {
payload: { ok: true, result },
target: to,
threadName,
}),
);
}
case "threadCreate": {
if (!ctx.isActionEnabled("threads")) {
throw new Error("Discord threads are disabled.");
}
const channelId = ctx.resolveChannelId();
const name = readStringParam(ctx.params, "name", { required: true });
const messageId = readStringParam(ctx.params, "messageId");
const content = readStringParam(ctx.params, "content");
const autoArchiveMinutes = readPositiveIntegerParam(ctx.params, "autoArchiveMinutes");
const appliedTags = readStringArrayParam(ctx.params, "appliedTags");
const payload = {
name,
messageId,
autoArchiveMinutes,
content,
appliedTags: appliedTags ?? undefined,
};
try {
const thread = await discordMessagingActionRuntime.createThreadDiscord(
channelId,
payload,
ctx.withOpts(),
);
return jsonResult({ ok: true, thread });
} catch (error) {
if (error instanceof DiscordThreadInitialMessageError) {
return jsonResult({
ok: true,
partial: true,
thread: error.thread,
warning: "Discord thread was created, but sending the initial message failed.",
initialMessageError: error.initialMessageError,
});
}
throw error;
}
}
case "threadList": {
if (!ctx.isActionEnabled("threads")) {
throw new Error("Discord threads are disabled.");
}
const guildId = readStringParam(ctx.params, "guildId", {
required: true,
});
const channelId = readStringParam(ctx.params, "channelId");
const includeArchived = readBooleanParam(ctx.params, "includeArchived");
const before = readStringParam(ctx.params, "before");
const limit = readPositiveIntegerParam(ctx.params, "limit");
if (channelId && includeArchived === true) {
await ctx.assertReadTargetAllowed({ guildId, channelId });
} else {
await ctx.assertGuildReadTargetAllowed({
guildId,
channelTargetRequiredMessage:
"Discord active thread lists require a wildcard channel allowlist so each read target can be authorized.",
});
}
const threads = await discordMessagingActionRuntime.listThreadsDiscord(
{
guildId,
channelId,
includeArchived,
before,
limit,
},
ctx.withOpts(),
);
return jsonResult(
normalizeDiscordThreadListActionResult({
value: threads,
guildId,
channelId,
includeArchived: includeArchived === true,
before,
limit,
}),
);
}
case "threadReply": {
if (!ctx.isActionEnabled("threads")) {
throw new Error("Discord threads are disabled.");
}
const channelId = ctx.resolveChannelId();
const content = readStringParam(ctx.params, "content", {
required: true,
});
const mediaUrl = readStringParam(ctx.params, "mediaUrl");
const replyTo = readStringParam(ctx.params, "replyTo");
const result = await discordMessagingActionRuntime.sendMessageDiscord(
`channel:${channelId}`,
content,
{
...ctx.withOpts(),
mediaUrl,
mediaLocalRoots: ctx.options?.mediaLocalRoots,
mediaReadFile: ctx.options?.mediaReadFile,
replyTo,
},
);
return jsonResult({ ok: true, result });
}
default:
return undefined;
}
}

View File

@@ -0,0 +1,411 @@
// Discord plugin module implements runtime.messaging.shared behavior.
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId } from "../accounts.js";
import { createDiscordRuntimeAccountContext } from "../client.js";
import {
isDiscordGroupAllowedByPolicy,
normalizeDiscordSlug,
resolveDiscordChannelConfigWithFallback,
type DiscordGuildEntryResolved,
} from "../monitor/allow-list.js";
import {
type ActionGate,
readStringParam,
type DiscordActionConfig,
type OpenClawConfig,
withNormalizedTimestamp,
} from "../runtime-api.js";
import type { DiscordReactOpts } from "../send.types.js";
import { discordMessagingActionRuntime } from "./runtime.messaging.runtime.js";
import { createDiscordActionOptions } from "./runtime.shared.js";
export type DiscordMessagingActionOptions = {
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
workspaceDir?: string;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
};
export type DiscordMessagingActionContext = {
action: string;
params: Record<string, unknown>;
isActionEnabled: ActionGate<DiscordActionConfig>;
cfg: OpenClawConfig;
options?: DiscordMessagingActionOptions;
accountId?: string;
resolveChannelId: () => string;
assertReadTargetAllowed: (params: { guildId?: string; channelId: string }) => Promise<void>;
assertGuildReadTargetAllowed: (params: {
guildId: string;
channelTargetRequiredMessage?: string;
}) => Promise<void>;
resolveReactionChannelId: () => Promise<string>;
withOpts: (extra?: Record<string, unknown>) => { cfg: OpenClawConfig; accountId?: string };
withReactionRuntimeOptions: <T extends Record<string, unknown> = Record<string, never>>(
extra?: T,
) => DiscordReactOpts & T;
normalizeMessage: (message: unknown) => unknown;
};
function hasDiscordGuildEntries(
guilds: DiscordGuildEntryResolved["channels"] | undefined,
): guilds is NonNullable<DiscordGuildEntryResolved["channels"]> {
return Boolean(guilds && Object.keys(guilds).length > 0);
}
function allowsAllDiscordGuildChannels(
channels: DiscordGuildEntryResolved["channels"] | undefined,
): boolean {
const wildcard = channels?.["*"];
if (!wildcard || wildcard.enabled === false) {
return false;
}
return Object.values(channels ?? {}).every((entry) => entry?.enabled !== false);
}
function resolveDiscordActionGuildEntry(params: {
guilds?: Record<string, DiscordGuildEntryResolved | undefined>;
guildId?: string;
guildName?: string;
includeWildcard?: boolean;
}): DiscordGuildEntryResolved | null {
const guildId = params.guildId?.trim();
if (!params.guilds) {
return null;
}
if (guildId && params.guilds[guildId]) {
return { ...params.guilds[guildId], id: guildId };
}
if (guildId) {
const byConfiguredId = Object.values(params.guilds).find((guild) => guild?.id === guildId);
if (byConfiguredId) {
return { ...byConfiguredId, id: guildId };
}
}
const guildSlug = params.guildName ? normalizeDiscordSlug(params.guildName) : "";
if (guildSlug) {
const bySlug =
params.guilds[guildSlug] ??
Object.values(params.guilds).find((guild) => guild?.slug === guildSlug);
if (bySlug) {
return { ...bySlug, id: guildId, slug: guildSlug || bySlug.slug };
}
}
if (params.includeWildcard === false) {
return null;
}
const wildcard = params.guilds["*"];
return wildcard ? { ...wildcard, id: guildId } : null;
}
type DiscordReadTargetContext = {
channelId: string;
guildId?: string;
channelName?: string;
channelSlug: string;
parentId?: string;
parentName?: string;
parentSlug?: string;
scope?: "channel" | "thread";
};
function readDiscordChannelStringField(value: unknown, ...keys: string[]): string | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
for (const key of keys) {
const candidate = record[key];
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
}
return undefined;
}
function readDiscordChannelType(value: unknown): number | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const type = (value as Record<string, unknown>).type;
return typeof type === "number" ? type : undefined;
}
function isDiscordThreadChannel(value: unknown): boolean {
const type = readDiscordChannelType(value);
return type === 10 || type === 11 || type === 12;
}
function isDiscordReadTargetAllowedInGuild(params: {
groupPolicy: "open" | "disabled" | "allowlist";
guildInfo: DiscordGuildEntryResolved | null;
target: DiscordReadTargetContext;
}): boolean {
const channelConfig = resolveDiscordChannelConfigWithFallback({
guildInfo: params.guildInfo,
channelId: params.target.channelId,
channelName: params.target.channelName,
channelSlug: params.target.channelSlug,
parentId: params.target.parentId,
parentName: params.target.parentName,
parentSlug: params.target.parentSlug,
scope: params.target.scope,
});
if (channelConfig?.allowed === false) {
return false;
}
return isDiscordGroupAllowedByPolicy({
groupPolicy: params.groupPolicy,
guildAllowlisted: Boolean(params.guildInfo),
channelAllowlistConfigured: hasDiscordGuildEntries(params.guildInfo?.channels),
channelAllowed: true,
});
}
function isDiscordReadTargetExplicitlyAllowedById(params: {
groupPolicy: "open" | "disabled" | "allowlist";
guildInfo: DiscordGuildEntryResolved | null;
target: DiscordReadTargetContext;
}): boolean {
const channelEntry = params.guildInfo?.channels?.[params.target.channelId];
if (!channelEntry || channelEntry.enabled === false) {
return false;
}
return isDiscordGroupAllowedByPolicy({
groupPolicy: params.groupPolicy,
guildAllowlisted: Boolean(params.guildInfo),
channelAllowlistConfigured: true,
channelAllowed: true,
});
}
export function createDiscordMessagingActionContext(params: {
action: string;
input: Record<string, unknown>;
isActionEnabled: ActionGate<DiscordActionConfig>;
cfg: OpenClawConfig;
options?: DiscordMessagingActionOptions;
}): DiscordMessagingActionContext {
const accountId = readStringParam(params.input, "accountId");
const cfgOptions = { cfg: params.cfg };
const accountConfig = mergeDiscordAccountConfig(
params.cfg,
accountId ?? resolveDefaultDiscordAccountId(params.cfg),
);
const guilds = accountConfig.guilds as Record<string, DiscordGuildEntryResolved | undefined>;
const hasGuildEntries = Object.keys(guilds ?? {}).length > 0;
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: params.cfg.channels?.discord !== undefined,
groupPolicy: accountConfig.groupPolicy,
defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy,
});
const withOpts = (extra?: Record<string, unknown>) =>
createDiscordActionOptions({ cfg: params.cfg, accountId, extra });
const resolvedReactionAccountId = accountId ?? resolveDefaultDiscordAccountId(params.cfg);
const reactionRuntimeOptions = resolvedReactionAccountId
? createDiscordRuntimeAccountContext({
cfg: params.cfg,
accountId: resolvedReactionAccountId,
})
: cfgOptions;
const guildNameById = new Map<string, string | null>();
const resolveGuildName = async (guildId: string): Promise<string | null> => {
if (guildNameById.has(guildId)) {
return guildNameById.get(guildId) ?? null;
}
try {
const guildInfo = await discordMessagingActionRuntime.fetchGuildInfoDiscord(
guildId,
withOpts(),
);
const guildName = readDiscordChannelStringField(guildInfo, "name") ?? null;
guildNameById.set(guildId, guildName);
return guildName;
} catch {
guildNameById.set(guildId, null);
return null;
}
};
const resolveReadGuildEntry = async (
guildId?: string,
): Promise<DiscordGuildEntryResolved | null> => {
const direct = resolveDiscordActionGuildEntry({
guilds,
guildId,
includeWildcard: false,
});
if (direct || !guildId) {
return direct;
}
const guildName = await resolveGuildName(guildId);
const named = resolveDiscordActionGuildEntry({
guilds,
guildId,
guildName: guildName ?? undefined,
includeWildcard: false,
});
if (named) {
return named;
}
return resolveDiscordActionGuildEntry({ guilds, guildId });
};
const resolveReadTargetContext = async (channelId: string): Promise<DiscordReadTargetContext> => {
const fallback: DiscordReadTargetContext = {
channelId,
channelSlug: normalizeDiscordSlug(channelId) || channelId,
};
let channelInfo: unknown;
try {
channelInfo = await discordMessagingActionRuntime.fetchChannelInfoDiscord(
channelId,
withOpts(),
);
} catch {
return fallback;
}
const channelName = readDiscordChannelStringField(channelInfo, "name");
const target: DiscordReadTargetContext = {
channelId,
channelSlug: channelName ? normalizeDiscordSlug(channelName) : fallback.channelSlug,
};
const targetGuildId = readDiscordChannelStringField(channelInfo, "guild_id", "guildId");
if (targetGuildId) {
target.guildId = targetGuildId;
}
if (channelName) {
target.channelName = channelName;
}
if (!isDiscordThreadChannel(channelInfo)) {
return target;
}
target.scope = "thread";
target.parentId = readDiscordChannelStringField(channelInfo, "parent_id", "parentId");
if (!target.parentId) {
return target;
}
try {
const parentInfo = await discordMessagingActionRuntime.fetchChannelInfoDiscord(
target.parentId,
withOpts(),
);
const parentName = readDiscordChannelStringField(parentInfo, "name");
if (parentName) {
target.parentName = parentName;
target.parentSlug = normalizeDiscordSlug(parentName);
}
} catch {
// Parent id fallback is enough for allowlist checks when the parent fetch is unavailable.
}
return target;
};
return {
action: params.action,
params: params.input,
isActionEnabled: params.isActionEnabled,
cfg: params.cfg,
options: params.options,
accountId,
resolveChannelId: () =>
discordMessagingActionRuntime.resolveDiscordChannelId(
readStringParam(params.input, "channelId", {
required: true,
}),
),
assertReadTargetAllowed: async ({ guildId, channelId }) => {
const targetChannelId = discordMessagingActionRuntime.resolveDiscordChannelId(channelId);
if (!hasGuildEntries && groupPolicy !== "disabled" && groupPolicy !== "allowlist") {
return;
}
const target = await resolveReadTargetContext(targetChannelId);
if (guildId) {
if (target.guildId && target.guildId !== guildId) {
throw new Error("Discord read target channel is not allowed.");
}
const guildInfo = await resolveReadGuildEntry(guildId);
if (
!isDiscordReadTargetAllowedInGuild({
groupPolicy,
guildInfo,
target,
})
) {
throw new Error("Discord read target channel is not allowed.");
}
return;
}
if (target.guildId) {
const guildInfo = await resolveReadGuildEntry(target.guildId);
if (
!isDiscordReadTargetAllowedInGuild({
groupPolicy,
guildInfo,
target,
})
) {
throw new Error("Discord read target channel is not allowed.");
}
return;
}
const allowed = Object.values(guilds ?? {}).some((guildInfo) =>
isDiscordReadTargetExplicitlyAllowedById({
groupPolicy,
guildInfo: guildInfo ?? null,
target,
}),
);
if (!allowed) {
throw new Error("Discord read target channel is not allowed.");
}
},
assertGuildReadTargetAllowed: async ({ guildId, channelTargetRequiredMessage }) => {
const guildInfo = await resolveReadGuildEntry(guildId);
if (
!isDiscordGroupAllowedByPolicy({
groupPolicy,
guildAllowlisted: Boolean(guildInfo),
channelAllowlistConfigured: false,
channelAllowed: true,
})
) {
throw new Error("Discord read target channel is not allowed.");
}
if (
hasDiscordGuildEntries(guildInfo?.channels) &&
!allowsAllDiscordGuildChannels(guildInfo.channels)
) {
throw new Error(
channelTargetRequiredMessage ??
"Discord message search requires channelId or channelIds so each read target can be authorized.",
);
}
},
resolveReactionChannelId: async () => {
const target =
readStringParam(params.input, "channelId") ??
readStringParam(params.input, "to", { required: true });
return await discordMessagingActionRuntime.resolveDiscordReactionTargetChannelId({
target,
cfg: params.cfg,
accountId: resolvedReactionAccountId,
});
},
withOpts,
withReactionRuntimeOptions: (extra) =>
({
...(reactionRuntimeOptions ?? cfgOptions),
...extra,
}) as DiscordReactOpts & NonNullable<typeof extra>,
normalizeMessage: (message: unknown) => {
if (!message || typeof message !== "object") {
return message;
}
return withNormalizedTimestamp(
message as Record<string, unknown>,
(message as { timestamp?: unknown }).timestamp,
);
},
};
}

View File

@@ -0,0 +1,38 @@
// Discord plugin module implements runtime.messaging behavior.
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import type { ActionGate, DiscordActionConfig, OpenClawConfig } from "../runtime-api.js";
import { handleDiscordMessageManagementAction } from "./runtime.messaging.messages.js";
import { handleDiscordReactionMessagingAction } from "./runtime.messaging.reactions.js";
import { handleDiscordMessageSendAction } from "./runtime.messaging.send.js";
import {
createDiscordMessagingActionContext,
type DiscordMessagingActionOptions,
} from "./runtime.messaging.shared.js";
export { discordMessagingActionRuntime } from "./runtime.messaging.runtime.js";
export async function handleDiscordMessagingAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
cfg: OpenClawConfig,
options?: DiscordMessagingActionOptions,
): Promise<AgentToolResult<unknown>> {
if (!cfg) {
throw new Error("Discord messaging actions require a resolved runtime config.");
}
const ctx = createDiscordMessagingActionContext({
action,
input: params,
isActionEnabled,
cfg,
options,
});
return (
(await handleDiscordReactionMessagingAction(ctx)) ??
(await handleDiscordMessageSendAction(ctx)) ??
(await handleDiscordMessageManagementAction(ctx)) ??
(() => {
throw new Error(`Unknown action: ${action}`);
})()
);
}

View File

@@ -0,0 +1,52 @@
// Discord plugin module implements runtime.moderation shared behavior.
import { PermissionFlagsBits } from "discord-api-types/v10";
import { readNonNegativeIntegerParam, readStringParam } from "../runtime-api.js";
export type DiscordModerationAction = "timeout" | "kick" | "ban";
export type DiscordModerationCommand = {
action: DiscordModerationAction;
guildId: string;
userId: string;
durationMinutes?: number;
until?: string;
reason?: string;
deleteMessageDays?: number;
};
const moderationPermissions: Record<DiscordModerationAction, bigint> = {
timeout: PermissionFlagsBits.ModerateMembers,
kick: PermissionFlagsBits.KickMembers,
ban: PermissionFlagsBits.BanMembers,
};
export function isDiscordModerationAction(action: string): action is DiscordModerationAction {
return action === "timeout" || action === "kick" || action === "ban";
}
export function requiredGuildPermissionForModerationAction(
action: DiscordModerationAction,
): bigint {
return moderationPermissions[action];
}
export function readDiscordModerationCommand(
action: string,
params: Record<string, unknown>,
): DiscordModerationCommand {
if (!isDiscordModerationAction(action)) {
throw new Error(`Unsupported Discord moderation action: ${action}`);
}
return {
action,
guildId: readStringParam(params, "guildId", { required: true }),
userId: readStringParam(params, "userId", { required: true }),
durationMinutes: readNonNegativeIntegerParam(params, "durationMinutes"),
until: readStringParam(params, "until"),
reason: readStringParam(params, "reason"),
deleteMessageDays: readNonNegativeIntegerParam(params, "deleteMessageDays", {
max: 7,
message: "deleteMessageDays must be an integer from 0 to 7",
}),
};
}

View File

@@ -0,0 +1,152 @@
// Discord tests cover runtime.moderation.authz plugin behavior.
import { PermissionFlagsBits } from "discord-api-types/v10";
import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js";
import {
discordModerationActionRuntime,
handleDiscordModerationAction,
} from "./runtime.moderation.js";
const originalDiscordModerationActionRuntime = { ...discordModerationActionRuntime };
const banMemberDiscord = vi.fn(async () => ({ ok: true }));
const kickMemberDiscord = vi.fn(async () => ({ ok: true }));
const timeoutMemberDiscord = vi.fn(async () => ({ id: "user-1" }));
const hasAnyGuildPermissionDiscord = vi.fn(async () => false);
const enableAllActions = (_key: keyof DiscordActionConfig, _defaultValue = true) => true;
const DISCORD_TEST_CFG = EMPTY_DISCORD_TEST_CONFIG;
function handleModerationAction(action: string, params: Record<string, unknown>) {
return handleDiscordModerationAction(action, params, enableAllActions, DISCORD_TEST_CFG);
}
describe("discord moderation sender authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(discordModerationActionRuntime, originalDiscordModerationActionRuntime, {
banMemberDiscord,
kickMemberDiscord,
timeoutMemberDiscord,
hasAnyGuildPermissionDiscord,
});
});
it("rejects ban when sender lacks BAN_MEMBERS", async () => {
hasAnyGuildPermissionDiscord.mockResolvedValueOnce(false);
await expect(
handleModerationAction("ban", {
guildId: "guild-1",
userId: "user-1",
senderUserId: "sender-1",
}),
).rejects.toThrow("required permissions");
expect(hasAnyGuildPermissionDiscord).toHaveBeenCalledWith(
"guild-1",
"sender-1",
[PermissionFlagsBits.BanMembers],
{ cfg: DISCORD_TEST_CFG },
);
expect(banMemberDiscord).not.toHaveBeenCalled();
});
it("rejects kick when sender lacks KICK_MEMBERS", async () => {
hasAnyGuildPermissionDiscord.mockResolvedValueOnce(false);
await expect(
handleModerationAction("kick", {
guildId: "guild-1",
userId: "user-1",
senderUserId: "sender-1",
}),
).rejects.toThrow("required permissions");
expect(hasAnyGuildPermissionDiscord).toHaveBeenCalledWith(
"guild-1",
"sender-1",
[PermissionFlagsBits.KickMembers],
{ cfg: DISCORD_TEST_CFG },
);
expect(kickMemberDiscord).not.toHaveBeenCalled();
});
it("rejects timeout when sender lacks MODERATE_MEMBERS", async () => {
hasAnyGuildPermissionDiscord.mockResolvedValueOnce(false);
await expect(
handleModerationAction("timeout", {
guildId: "guild-1",
userId: "user-1",
senderUserId: "sender-1",
durationMinutes: 60,
}),
).rejects.toThrow("required permissions");
expect(hasAnyGuildPermissionDiscord).toHaveBeenCalledWith(
"guild-1",
"sender-1",
[PermissionFlagsBits.ModerateMembers],
{ cfg: DISCORD_TEST_CFG },
);
expect(timeoutMemberDiscord).not.toHaveBeenCalled();
});
it("executes moderation action when sender has required permission", async () => {
hasAnyGuildPermissionDiscord.mockResolvedValueOnce(true);
kickMemberDiscord.mockResolvedValueOnce({ ok: true });
await handleModerationAction("kick", {
guildId: "guild-1",
userId: "user-1",
senderUserId: "sender-1",
reason: "rule violation",
});
expect(hasAnyGuildPermissionDiscord).toHaveBeenCalledWith(
"guild-1",
"sender-1",
[PermissionFlagsBits.KickMembers],
{ cfg: DISCORD_TEST_CFG },
);
expect(kickMemberDiscord).toHaveBeenCalledWith(
{
guildId: "guild-1",
userId: "user-1",
reason: "rule violation",
},
{ cfg: DISCORD_TEST_CFG },
);
});
it("forwards accountId into permission check and moderation execution", async () => {
hasAnyGuildPermissionDiscord.mockResolvedValueOnce(true);
timeoutMemberDiscord.mockResolvedValueOnce({ id: "user-1" });
await handleModerationAction("timeout", {
guildId: "guild-1",
userId: "user-1",
senderUserId: "sender-1",
accountId: "ops",
durationMinutes: 5,
});
expect(hasAnyGuildPermissionDiscord).toHaveBeenCalledWith(
"guild-1",
"sender-1",
[PermissionFlagsBits.ModerateMembers],
{ cfg: DISCORD_TEST_CFG, accountId: "ops" },
);
expect(timeoutMemberDiscord).toHaveBeenCalledWith(
{
guildId: "guild-1",
userId: "user-1",
durationMinutes: 5,
until: undefined,
reason: undefined,
},
{ cfg: DISCORD_TEST_CFG, accountId: "ops" },
);
});
});

View File

@@ -0,0 +1,117 @@
// Discord plugin module implements runtime.moderation behavior.
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import {
type ActionGate,
jsonResult,
readStringParam,
type DiscordActionConfig,
type OpenClawConfig,
} from "../runtime-api.js";
import {
banMemberDiscord,
hasAnyGuildPermissionDiscord,
kickMemberDiscord,
timeoutMemberDiscord,
} from "../send.js";
import {
isDiscordModerationAction,
readDiscordModerationCommand,
requiredGuildPermissionForModerationAction,
} from "./runtime.moderation-shared.js";
import { createDiscordActionOptions } from "./runtime.shared.js";
export const discordModerationActionRuntime = {
banMemberDiscord,
hasAnyGuildPermissionDiscord,
kickMemberDiscord,
timeoutMemberDiscord,
};
async function verifySenderModerationPermission(params: {
guildId: string;
senderUserId?: string;
requiredPermission: bigint;
accountId?: string;
cfg: OpenClawConfig;
}) {
// CLI/manual flows may not have sender context; enforce only when present.
if (!params.senderUserId) {
return;
}
const hasPermission = await discordModerationActionRuntime.hasAnyGuildPermissionDiscord(
params.guildId,
params.senderUserId,
[params.requiredPermission],
createDiscordActionOptions({ cfg: params.cfg, accountId: params.accountId }),
);
if (!hasPermission) {
throw new Error("Sender does not have required permissions for this moderation action.");
}
}
export async function handleDiscordModerationAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
cfg: OpenClawConfig,
): Promise<AgentToolResult<unknown>> {
if (!isDiscordModerationAction(action)) {
throw new Error(`Unknown action: ${action}`);
}
if (!isActionEnabled("moderation", false)) {
throw new Error("Discord moderation is disabled.");
}
if (!cfg) {
throw new Error("Discord moderation actions require a resolved runtime config.");
}
const accountId = readStringParam(params, "accountId");
const command = readDiscordModerationCommand(action, params);
const senderUserId = readStringParam(params, "senderUserId");
const withOpts = () => createDiscordActionOptions({ cfg, accountId });
await verifySenderModerationPermission({
guildId: command.guildId,
senderUserId,
requiredPermission: requiredGuildPermissionForModerationAction(command.action),
accountId,
cfg,
});
switch (command.action) {
case "timeout": {
const member = await discordModerationActionRuntime.timeoutMemberDiscord(
{
guildId: command.guildId,
userId: command.userId,
durationMinutes: command.durationMinutes,
until: command.until,
reason: command.reason,
},
withOpts(),
);
return jsonResult({ ok: true, member });
}
case "kick": {
await discordModerationActionRuntime.kickMemberDiscord(
{
guildId: command.guildId,
userId: command.userId,
reason: command.reason,
},
withOpts(),
);
return jsonResult({ ok: true });
}
case "ban": {
await discordModerationActionRuntime.banMemberDiscord(
{
guildId: command.guildId,
userId: command.userId,
reason: command.reason,
deleteMessageDays: command.deleteMessageDays,
},
withOpts(),
);
return jsonResult({ ok: true });
}
}
throw new Error("Unsupported Discord moderation action");
}

View File

@@ -0,0 +1,166 @@
// Discord tests cover runtime.presence plugin behavior.
import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayPlugin } from "../internal/gateway.js";
import { clearGateways, registerGateway } from "../monitor/gateway-registry.js";
import type { ActionGate } from "../runtime-api.js";
import { handleDiscordPresenceAction } from "./runtime.presence.js";
const mockUpdatePresence = vi.fn();
function createMockGateway(connected = true): GatewayPlugin {
return { isConnected: connected, updatePresence: mockUpdatePresence } as unknown as GatewayPlugin;
}
const presenceEnabled: ActionGate<DiscordActionConfig> = (key) => key === "presence";
const presenceDisabled: ActionGate<DiscordActionConfig> = () => false;
describe("handleDiscordPresenceAction", () => {
async function setPresence(
params: Record<string, unknown>,
actionGate: ActionGate<DiscordActionConfig> = presenceEnabled,
) {
return await handleDiscordPresenceAction("setPresence", params, actionGate);
}
beforeEach(() => {
mockUpdatePresence.mockClear();
clearGateways();
registerGateway(undefined, createMockGateway());
});
it("sets playing activity", async () => {
const result = await handleDiscordPresenceAction(
"setPresence",
{ activityType: "playing", activityName: "with fire", status: "online" },
presenceEnabled,
);
expect(mockUpdatePresence).toHaveBeenCalledWith({
since: null,
activities: [{ name: "with fire", type: 0 }],
status: "online",
afk: false,
});
const textBlock = result.content.find((block) => block.type === "text");
const payload = JSON.parse(
(textBlock as { type: "text"; text: string } | undefined)?.text ?? "{}",
);
expect(payload.ok).toBe(true);
expect(payload.activities[0]).toEqual({ type: 0, name: "with fire" });
});
it.each([
{
name: "streaming activity with URL",
params: {
activityType: "streaming",
activityName: "My Stream",
activityUrl: "https://twitch.tv/example",
},
expectedActivities: [{ name: "My Stream", type: 1, url: "https://twitch.tv/example" }],
},
{
name: "streaming activity without URL",
params: { activityType: "streaming", activityName: "My Stream" },
expectedActivities: [{ name: "My Stream", type: 1 }],
},
{
name: "listening activity",
params: { activityType: "listening", activityName: "Spotify" },
expectedActivities: [{ name: "Spotify", type: 2 }],
},
{
name: "watching activity",
params: { activityType: "watching", activityName: "you" },
expectedActivities: [{ name: "you", type: 3 }],
},
{
name: "custom activity using state",
params: { activityType: "custom", activityState: "Vibing" },
expectedActivities: [{ name: "", type: 4, state: "Vibing" }],
},
{
name: "activity with state",
params: { activityType: "playing", activityName: "My Game", activityState: "In the lobby" },
expectedActivities: [{ name: "My Game", type: 0, state: "In the lobby" }],
},
{
name: "default empty activity name when only type provided",
params: { activityType: "playing" },
expectedActivities: [{ name: "", type: 0 }],
},
])("sets $name", async ({ params, expectedActivities }) => {
await setPresence(params);
expect(mockUpdatePresence).toHaveBeenCalledWith({
since: null,
activities: expectedActivities,
status: "online",
afk: false,
});
});
it("sets status-only without activity", async () => {
await setPresence({ status: "idle" });
expect(mockUpdatePresence).toHaveBeenCalledWith({
since: null,
activities: [],
status: "idle",
afk: false,
});
});
it.each([
{ name: "invalid status", params: { status: "offline" }, expectedMessage: /Invalid status/ },
{
name: "invalid activity type",
params: { activityType: "invalid" },
expectedMessage: /Invalid activityType/,
},
])("rejects $name", async ({ params, expectedMessage }) => {
await expect(setPresence(params)).rejects.toThrow(expectedMessage);
});
it("defaults status to online", async () => {
await setPresence({ activityType: "playing", activityName: "test" });
expect(mockUpdatePresence).toHaveBeenCalledWith({
since: null,
activities: [{ name: "test", type: 0 }],
status: "online",
afk: false,
});
});
it("respects presence gating", async () => {
await expect(setPresence({ status: "online" }, presenceDisabled)).rejects.toThrow(/disabled/);
});
it("errors when gateway is not registered", async () => {
clearGateways();
await expect(setPresence({ status: "dnd" })).rejects.toThrow(/not available/);
});
it("errors when gateway is not connected", async () => {
clearGateways();
registerGateway(undefined, createMockGateway(false));
await expect(setPresence({ status: "dnd" })).rejects.toThrow(/not connected/);
});
it("uses accountId to resolve gateway", async () => {
const accountGateway = createMockGateway();
registerGateway("my-account", accountGateway);
await setPresence({ accountId: "my-account", activityType: "playing", activityName: "test" });
expect(mockUpdatePresence).toHaveBeenCalled();
});
it("requires activityType when activityName is provided", async () => {
await expect(setPresence({ activityName: "My Game" })).rejects.toThrow(
/activityType is required/,
);
});
it("rejects unknown presence actions", async () => {
await expect(handleDiscordPresenceAction("unknownAction", {}, presenceEnabled)).rejects.toThrow(
/Unknown presence action/,
);
});
});

View File

@@ -0,0 +1,118 @@
// Discord plugin module implements runtime.presence behavior.
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { Activity, UpdatePresenceData } from "../internal/gateway.js";
import { getGateway } from "../monitor/gateway-registry.js";
import {
type ActionGate,
jsonResult,
readStringParam,
type DiscordActionConfig,
} from "../runtime-api.js";
const ACTIVITY_TYPE_MAP: Record<string, number> = {
playing: 0,
streaming: 1,
listening: 2,
watching: 3,
custom: 4,
competing: 5,
};
const VALID_STATUSES = new Set(["online", "dnd", "idle", "invisible"]);
export async function handleDiscordPresenceAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
): Promise<AgentToolResult<unknown>> {
if (action !== "setPresence") {
throw new Error(`Unknown presence action: ${action}`);
}
if (!isActionEnabled("presence", false)) {
throw new Error("Discord presence changes are disabled.");
}
const accountId = readStringParam(params, "accountId");
const gateway = getGateway(accountId);
if (!gateway) {
throw new Error(
`Discord gateway not available${accountId ? ` for account "${accountId}"` : ""}. The bot may not be connected.`,
);
}
if (!gateway.isConnected) {
throw new Error(
`Discord gateway is not connected${accountId ? ` for account "${accountId}"` : ""}.`,
);
}
const statusRaw = readStringParam(params, "status") ?? "online";
if (!VALID_STATUSES.has(statusRaw)) {
throw new Error(
`Invalid status "${statusRaw}". Must be one of: ${[...VALID_STATUSES].join(", ")}`,
);
}
const status = statusRaw as UpdatePresenceData["status"];
const activityTypeRaw = readStringParam(params, "activityType");
const activityName = readStringParam(params, "activityName");
const activities: Activity[] = [];
if (activityTypeRaw || activityName) {
if (!activityTypeRaw) {
throw new Error(
"activityType is required when activityName is provided. " +
`Valid types: ${Object.keys(ACTIVITY_TYPE_MAP).join(", ")}`,
);
}
const typeNum = ACTIVITY_TYPE_MAP[normalizeLowercaseStringOrEmpty(activityTypeRaw)];
if (typeNum === undefined) {
throw new Error(
`Invalid activityType "${activityTypeRaw}". Must be one of: ${Object.keys(ACTIVITY_TYPE_MAP).join(", ")}`,
);
}
const activity: Activity = {
name: activityName ?? "",
type: typeNum,
};
// Streaming URL (Twitch/YouTube). May not render for bots but is the correct payload shape.
if (typeNum === 1) {
const url = readStringParam(params, "activityUrl");
if (url) {
activity.url = url;
}
}
const state = readStringParam(params, "activityState");
if (state) {
activity.state = state;
}
activities.push(activity);
}
const presenceData: UpdatePresenceData = {
since: null,
activities,
status,
afk: false,
};
gateway.updatePresence(presenceData);
return jsonResult({
ok: true,
status,
activities: activities.map((a) =>
Object.assign(
{ type: a.type, name: a.name },
a.url ? { url: a.url } : {},
a.state ? { state: a.state } : {},
),
),
});
}

View File

@@ -0,0 +1,91 @@
// Discord plugin module implements runtime.shared behavior.
import {
parseAvailableTags,
readNonNegativeIntegerParam,
readPositiveIntegerParam,
readStringParam,
} from "../runtime-api.js";
import type { OpenClawConfig } from "../runtime-api.js";
import type {
DiscordChannelCreate,
DiscordChannelEdit,
DiscordChannelMove,
} from "../send.types.js";
export function readDiscordParentIdParam(
params: Record<string, unknown>,
): string | null | undefined {
if (params.clearParent === true) {
return null;
}
if (params.parentId === null) {
return null;
}
return readStringParam(params, "parentId");
}
function readDiscordBooleanParam(
params: Record<string, unknown>,
key: string,
): boolean | undefined {
return typeof params[key] === "boolean" ? params[key] : undefined;
}
export function createDiscordActionOptions<
T extends Record<string, unknown> = Record<string, never>,
>(params: {
cfg: OpenClawConfig;
accountId?: string;
extra?: T;
}): { cfg: OpenClawConfig; accountId?: string } & T {
return {
cfg: params.cfg,
...(params.accountId ? { accountId: params.accountId } : {}),
...(params.extra ?? ({} as T)),
};
}
export function readDiscordChannelCreateParams(
params: Record<string, unknown>,
): DiscordChannelCreate {
const parentId = readDiscordParentIdParam(params);
return {
guildId: readStringParam(params, "guildId", { required: true }),
name: readStringParam(params, "name", { required: true }),
type:
readNonNegativeIntegerParam(params, "channelType") ??
readNonNegativeIntegerParam(params, "type") ??
undefined,
parentId: parentId ?? undefined,
topic: readStringParam(params, "topic") ?? undefined,
position: readNonNegativeIntegerParam(params, "position") ?? undefined,
nsfw: readDiscordBooleanParam(params, "nsfw"),
};
}
export function readDiscordChannelEditParams(params: Record<string, unknown>): DiscordChannelEdit {
const parentId = readDiscordParentIdParam(params);
return {
channelId: readStringParam(params, "channelId", { required: true }),
name: readStringParam(params, "name") ?? undefined,
topic: readStringParam(params, "topic") ?? undefined,
position: readNonNegativeIntegerParam(params, "position") ?? undefined,
parentId: parentId === undefined ? undefined : parentId,
nsfw: readDiscordBooleanParam(params, "nsfw"),
rateLimitPerUser: readNonNegativeIntegerParam(params, "rateLimitPerUser") ?? undefined,
archived: readDiscordBooleanParam(params, "archived"),
locked: readDiscordBooleanParam(params, "locked"),
autoArchiveDuration: readPositiveIntegerParam(params, "autoArchiveDuration") ?? undefined,
availableTags: parseAvailableTags(params.availableTags),
};
}
export function readDiscordChannelMoveParams(params: Record<string, unknown>): DiscordChannelMove {
const parentId = readDiscordParentIdParam(params);
return {
guildId: readStringParam(params, "guildId", { required: true }),
channelId: readStringParam(params, "channelId", { required: true }),
parentId: parentId === undefined ? undefined : parentId,
position: readNonNegativeIntegerParam(params, "position") ?? undefined,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
// Discord plugin module implements runtime behavior.
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import { createDiscordActionGate } from "../accounts.js";
import { readStringParam, type OpenClawConfig } from "../runtime-api.js";
import { handleDiscordGuildAction } from "./runtime.guild.js";
import { handleDiscordMessagingAction } from "./runtime.messaging.js";
import { handleDiscordModerationAction } from "./runtime.moderation.js";
import { handleDiscordPresenceAction } from "./runtime.presence.js";
const messagingActions = new Set([
"react",
"reactions",
"sticker",
"poll",
"permissions",
"fetchMessage",
"readMessages",
"sendMessage",
"editMessage",
"deleteMessage",
"threadCreate",
"threadList",
"threadReply",
"pinMessage",
"unpinMessage",
"listPins",
"searchMessages",
]);
const guildActions = new Set([
"memberInfo",
"roleInfo",
"emojiList",
"emojiUpload",
"stickerUpload",
"roleAdd",
"roleRemove",
"channelInfo",
"channelList",
"voiceStatus",
"eventList",
"eventCreate",
"channelCreate",
"channelEdit",
"channelDelete",
"channelMove",
"categoryCreate",
"categoryEdit",
"categoryDelete",
"channelPermissionSet",
"channelPermissionRemove",
]);
const moderationActions = new Set(["timeout", "kick", "ban"]);
const presenceActions = new Set(["setPresence"]);
export async function handleDiscordAction(
params: Record<string, unknown>,
cfg: OpenClawConfig,
options?: {
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
workspaceDir?: string;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
},
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const accountId = readStringParam(params, "accountId");
const isActionEnabled = createDiscordActionGate({ cfg, accountId });
if (messagingActions.has(action)) {
return await handleDiscordMessagingAction(action, params, isActionEnabled, cfg, options);
}
if (guildActions.has(action)) {
return await handleDiscordGuildAction(action, params, isActionEnabled, cfg, options);
}
if (moderationActions.has(action)) {
return await handleDiscordModerationAction(action, params, isActionEnabled, cfg);
}
if (presenceActions.has(action)) {
return await handleDiscordPresenceAction(action, params, isActionEnabled);
}
throw new Error(`Unknown action: ${action}`);
}

View File

@@ -0,0 +1,79 @@
// Discord tests cover api barrel plugin behavior.
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { describe, expect, it } from "vitest";
const API_SOURCE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "../api.ts");
function collectExportedNames(): Set<string> {
const source = ts.createSourceFile(
API_SOURCE_PATH,
readFileSync(API_SOURCE_PATH, "utf8"),
ts.ScriptTarget.Latest,
true,
);
const names = new Set<string>();
for (const statement of source.statements) {
if (
ts.isVariableStatement(statement) &&
statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
) {
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
names.add(declaration.name.text);
}
}
continue;
}
if (!ts.isExportDeclaration(statement) || !statement.exportClause) {
continue;
}
if (ts.isNamedExports(statement.exportClause)) {
for (const element of statement.exportClause.elements) {
names.add(element.name.text);
}
}
}
return names;
}
describe("discord API barrel", () => {
it("exports current internal entrypoints", () => {
const exportedNames = collectExportedNames();
for (const exportName of [
"discordPlugin",
"discordSetupPlugin",
"buildDiscordComponentCustomId",
"handleDiscordMessageAction",
"parseDiscordComponentCustomIdForCarbon",
"parseDiscordComponentCustomIdForInteraction",
"parseDiscordModalCustomIdForCarbon",
"parseDiscordModalCustomIdForInteraction",
"fetchDiscordApplicationSummary",
"DiscordSendResult",
]) {
expect(exportedNames).toContain(exportName);
}
});
it("links runtime exports used by bundled Discord wiring", () => {
const exportedNames = collectExportedNames();
for (const exportName of [
"DISCORD_COMPONENT_CUSTOM_ID_KEY",
"buildDiscordComponentMessageFlags",
"createDiscordFormModal",
"handleDiscordMessageAction",
"handleDiscordSubagentSpawning",
"listEnabledDiscordAccounts",
"parseDiscordComponentCustomIdForCarbon",
"parseDiscordModalCustomIdForCarbon",
"resolveDiscordRuntimeGroupPolicy",
"tryHandleDiscordMessageActionGuildAdmin",
]) {
expect(exportedNames).toContain(exportName);
}
});
});

View File

@@ -0,0 +1,433 @@
// Discord tests cover api plugin behavior.
import { createServer, type Server } from "node:http";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DiscordApiError, fetchDiscord, requestDiscord } from "./api.js";
import { jsonResponse } from "./test-http-helpers.js";
const DISCORD_SUCCESS_RESPONSE_LIMIT_BYTES = 4 * 1024 * 1024;
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
async function listenLoopbackServer(server: Server): Promise<number> {
return await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("expected loopback TCP address"));
return;
}
resolve(address.port);
});
});
}
async function closeServer(server: Server): Promise<void> {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
function stubDiscordFetchToLoopback(
baseUrl: string,
onResponse?: (response: Response) => void,
): void {
const realFetch = globalThis.fetch.bind(globalThis);
vi.stubGlobal(
"fetch",
withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => {
const originalUrl = new URL(input instanceof Request ? input.url : String(input));
expect(originalUrl.origin).toBe("https://discord.com");
expect(originalUrl.pathname).toMatch(/^\/api\/v10\//);
const loopbackUrl = new URL(`${originalUrl.pathname}${originalUrl.search}`, baseUrl);
const response = await realFetch(loopbackUrl, init);
onResponse?.(response);
return response;
}),
);
}
describe("fetchDiscord", () => {
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("formats rate limit payloads without raw JSON", async () => {
const fetcher = withFetchPreconnect(async () =>
jsonResponse(
{
message: "You are being rate limited.",
retry_after: 0.631,
global: false,
},
429,
),
);
let error: unknown;
try {
await fetchDiscord("/users/@me/guilds", "test", fetcher, {
retry: { attempts: 1 },
});
} catch (err) {
error = err;
}
const message = String(error);
expect(message).toContain("Discord API /users/@me/guilds failed (429)");
expect(message).toContain("You are being rate limited.");
expect(message).toContain("retry after 0.6s");
expect(message).not.toContain("{");
expect(message).not.toContain("retry_after");
});
it("preserves non-JSON error text", async () => {
const fetcher = withFetchPreconnect(async () => new Response("Not Found", { status: 404 }));
await expect(
fetchDiscord("/users/@me/guilds", "test", fetcher, {
retry: { attempts: 1 },
}),
).rejects.toThrow("Discord API /users/@me/guilds failed (404): Not Found");
});
it("bounds Discord API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"discord api unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetcher = withFetchPreconnect(async () => tracked.response);
let error: unknown;
try {
await fetchDiscord("/users/@me/guilds", "test", fetcher, {
retry: { attempts: 1 },
});
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(DiscordApiError);
expect(String(error)).toContain("Discord API /users/@me/guilds failed (503)");
expect(String(error)).toContain("discord api unavailable");
expect(String(error)).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("sanitizes Cloudflare HTML rate limits and applies a fallback cooldown", async () => {
const fetcher = withFetchPreconnect(
async () =>
new Response(
"<!doctype html><html><head><title>Error 1015</title></head><body><h1>You are being rate limited</h1><script>raw()</script></body></html>",
{ status: 429, headers: { "content-type": "text/html" } },
),
);
let error: unknown;
try {
await fetchDiscord("/users/@me/guilds", "test", fetcher, {
retry: { attempts: 1 },
});
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(DiscordApiError);
expect((error as DiscordApiError).retryAfter).toBe(60);
const message = String(error);
expect(message).toContain("Discord API /users/@me/guilds failed (429)");
expect(message).toContain("rate limited by Discord upstream");
expect(message).toContain("Error 1015");
expect(message).not.toContain("<html");
expect(message).not.toContain("<script");
});
it("honors Retry-After for Cloudflare HTML application lookup rate limits", async () => {
const fetcher = withFetchPreconnect(
async () =>
new Response("<html><title>Error 1015</title><body>rate limited</body></html>", {
status: 429,
headers: { "content-type": "text/html", "retry-after": "7" },
}),
);
let error: unknown;
try {
await fetchDiscord("/oauth2/applications/@me", "test", fetcher, {
retry: { attempts: 1 },
});
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(DiscordApiError);
expect((error as DiscordApiError).retryAfter).toBe(7);
const message = String(error);
expect(message).toContain("Discord API /oauth2/applications/@me failed (429)");
expect(message).toContain("Error 1015");
expect(message).not.toContain("<html");
});
it.each([
["hex", "0x10"],
["fractional", "1.5"],
["unsafe-ms", "9007199254741"],
["unsafe-integer", "9007199254740993"],
["overflow", `1${"0".repeat(309)}`],
])("rejects invalid Retry-After header values: %s", async (_label, header) => {
const fetcher = withFetchPreconnect(
async () =>
new Response("<html><title>Error 1015</title><body>rate limited</body></html>", {
status: 429,
headers: { "content-type": "text/html", "retry-after": header },
}),
);
let error: unknown;
try {
await fetchDiscord("/oauth2/applications/@me", "test", fetcher, {
retry: { attempts: 1 },
});
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(DiscordApiError);
expect((error as DiscordApiError).retryAfter).toBe(60);
});
it("ignores unsafe retry_after body values and falls back to Retry-After", async () => {
const fetcher = withFetchPreconnect(
async () =>
new Response(
JSON.stringify({
message: "You are being rate limited.",
retry_after: 9_007_199_254_741,
global: false,
}),
{ status: 429, headers: { "retry-after": "7" } },
),
);
let error: unknown;
try {
await fetchDiscord("/users/@me/guilds", "test", fetcher, {
retry: { attempts: 1 },
});
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(DiscordApiError);
expect((error as DiscordApiError).retryAfter).toBe(7);
expect(String(error)).not.toContain("retry after");
});
it("retries rate limits before succeeding", async () => {
let calls = 0;
const fetcher = withFetchPreconnect(async () => {
calls += 1;
if (calls === 1) {
return jsonResponse(
{
message: "You are being rate limited.",
retry_after: 0,
global: false,
},
429,
);
}
return jsonResponse([{ id: "1", name: "Guild" }], 200);
});
const result = await fetchDiscord<Array<{ id: string; name: string }>>(
"/users/@me/guilds",
"test",
fetcher,
{ retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 } },
);
expect(result).toHaveLength(1);
expect(calls).toBe(2);
});
it("sends JSON request bodies through the shared retry helper", async () => {
let request: RequestInit | undefined;
const fetcher = withFetchPreconnect(async (_url, init) => {
request = init;
return jsonResponse({ id: "42" }, 200);
});
const result = await requestDiscord<{ id: string }>("/channels/c/messages", "test", {
body: { content: "hello" },
fetcher,
retry: { attempts: 1 },
});
expect(result).toEqual({ id: "42" });
if (!request) {
throw new Error("expected Discord request init");
}
expect(request.method).toBe("POST");
expect(request.body).toBe(JSON.stringify({ content: "hello" }));
expect(new Headers(request.headers).get("content-type")).toBe("application/json");
});
it("caps oversized request timeouts before creating abort signals", async () => {
const timeoutController = new AbortController();
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal);
let request: RequestInit | undefined;
const fetcher = withFetchPreconnect(async (_url, init) => {
request = init;
return jsonResponse({ id: "42" }, 200);
});
await requestDiscord<{ id: string }>("/channels/c/messages", "test", {
fetcher,
retry: { attempts: 1 },
timeoutMs: Number.MAX_SAFE_INTEGER,
});
expect(timeoutSpy).toHaveBeenCalledWith(MAX_TIMER_TIMEOUT_MS);
expect(request?.signal).toBe(timeoutController.signal);
});
it("throws DiscordApiError on malformed JSON success response body", async () => {
const fetcher = withFetchPreconnect(
async () => new Response("NOT JSON {{{", { status: 200 }),
);
let error: unknown;
try {
await fetchDiscord("/users/@me/guilds", "test", fetcher, {
retry: { attempts: 1 },
});
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(DiscordApiError);
expect(String(error)).toContain("Discord API /users/@me/guilds returned malformed JSON");
});
it("returns under-cap requestDiscord responses from a real loopback HTTP server", async () => {
const payload = { id: "channel-42", name: "loopback", type: 0 };
let contentLength: string | null | undefined;
let requestUrl: string | undefined;
let authorization: string | undefined;
const server = createServer((req, res) => {
requestUrl = req.url;
authorization = req.headers.authorization;
const body = JSON.stringify(payload);
res.writeHead(200, { "content-type": "application/json" });
res.write(body.slice(0, 12));
res.end(body.slice(12));
});
const port = await listenLoopbackServer(server);
try {
stubDiscordFetchToLoopback(`http://127.0.0.1:${port}`, (response) => {
contentLength = response.headers.get("content-length");
});
const result = await requestDiscord<typeof payload>("/channels/channel-42", "test-token", {
retry: { attempts: 1 },
});
expect(result).toEqual(payload);
expect(requestUrl).toBe("/api/v10/channels/channel-42");
expect(authorization).toBe("Bot test-token");
expect(contentLength).toBeNull();
console.log(
`[discord requestDiscord loopback proof] normal path: returned=${JSON.stringify(result)} content_length=${contentLength ?? "none"}`,
);
} finally {
await closeServer(server);
}
});
it("rejects oversized valid JSON requestDiscord responses from a real loopback HTTP server", async () => {
const oversizedPayloadBytes = DISCORD_SUCCESS_RESPONSE_LIMIT_BYTES + 256 * 1024;
let contentLength: string | null | undefined;
let requestUrl: string | undefined;
let streamedBytes = 0;
const server = createServer((req, res) => {
requestUrl = req.url;
const chunk = Buffer.alloc(64 * 1024, 0x78);
res.writeHead(200, { "content-type": "application/json" });
res.write('{"id":"');
const writeMore = () => {
while (streamedBytes < oversizedPayloadBytes) {
if (res.destroyed) {
return;
}
streamedBytes += chunk.byteLength;
if (!res.write(chunk)) {
res.once("drain", writeMore);
return;
}
}
res.end('"}');
};
writeMore();
});
const port = await listenLoopbackServer(server);
try {
stubDiscordFetchToLoopback(`http://127.0.0.1:${port}`, (response) => {
contentLength = response.headers.get("content-length");
});
let error: unknown;
try {
await requestDiscord("/channels/123/messages", "test-token", {
retry: { attempts: 1 },
});
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(Error);
expect(String(error)).toContain("Discord API /channels/123/messages response body too large");
expect(String(error)).toContain(`limit: ${DISCORD_SUCCESS_RESPONSE_LIMIT_BYTES} bytes`);
expect(requestUrl).toBe("/api/v10/channels/123/messages");
expect(contentLength).toBeNull();
console.log(
`[discord requestDiscord loopback proof] oversized path: cap=${DISCORD_SUCCESS_RESPONSE_LIMIT_BYTES} streamed>=${streamedBytes} content_length=${contentLength ?? "none"} rejected=${String(error)}`,
);
} finally {
await closeServer(server);
}
});
});

View File

@@ -0,0 +1,231 @@
// Discord API module exposes the plugin public contract.
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import {
resolveRetryConfig,
retryAsync,
type RetryConfig,
} from "openclaw/plugin-sdk/retry-runtime";
import { isDiscordHtmlResponseBody, summarizeDiscordResponseBody } from "./error-body.js";
import { parseDiscordRetryAfterBodySeconds, parseRetryAfterHeaderSeconds } from "./retry-after.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
const DISCORD_API_RETRY_DEFAULTS = {
attempts: 3,
minDelayMs: 500,
maxDelayMs: 5 * 60_000,
jitter: 0.1,
};
const DISCORD_API_429_FALLBACK_RETRY_AFTER_SECONDS = 60;
const DISCORD_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const DISCORD_API_RESPONSE_BODY_LIMIT_BYTES = 4 * 1024 * 1024;
type DiscordApiErrorPayload = {
message?: string;
retry_after?: number;
code?: number;
global?: boolean;
};
function parseDiscordApiErrorPayload(text: string): DiscordApiErrorPayload | null {
const trimmed = text.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return null;
}
try {
const payload = JSON.parse(trimmed);
if (payload && typeof payload === "object") {
return payload as DiscordApiErrorPayload;
}
} catch {
return null;
}
return null;
}
function parseRetryAfterSeconds(text: string, response: Response): number | undefined {
const payload = parseDiscordApiErrorPayload(text);
const retryAfter = parseDiscordRetryAfterBodySeconds(payload?.retry_after);
if (retryAfter !== undefined) {
return retryAfter;
}
const header = response.headers.get("Retry-After");
if (!header) {
return undefined;
}
return parseRetryAfterHeaderSeconds(header);
}
function formatRetryAfterSeconds(value: number | undefined): string | undefined {
if (value === undefined || !Number.isFinite(value) || value < 0) {
return undefined;
}
const rounded = value < 10 ? value.toFixed(1) : Math.round(value).toString();
return `${rounded}s`;
}
function formatDiscordApiErrorText(text: string, response: Response): string | undefined {
const trimmed = text.trim();
if (!trimmed) {
return undefined;
}
const payload = parseDiscordApiErrorPayload(trimmed);
if (!payload) {
const looksJson = trimmed.startsWith("{") && trimmed.endsWith("}");
if (looksJson) {
return "unknown error";
}
const summary = summarizeDiscordResponseBody(trimmed);
if (isDiscordHtmlResponseBody(trimmed, response.headers.get("content-type"))) {
if (!summary) {
return response.status === 429 ? "rate limited by Discord upstream" : undefined;
}
return response.status === 429 ? `rate limited by Discord upstream: ${summary}` : summary;
}
return summary;
}
const message =
typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "unknown error";
const retryAfter = formatRetryAfterSeconds(
parseDiscordRetryAfterBodySeconds(payload.retry_after),
);
return retryAfter ? `${message} (retry after ${retryAfter})` : message;
}
export class DiscordApiError extends Error {
status: number;
retryAfter?: number;
constructor(message: string, status: number, retryAfter?: number) {
super(message);
this.status = status;
this.retryAfter = retryAfter;
}
}
function getDiscordApiRetryAfterMs(
err: unknown,
retryConfig: Required<RetryConfig>,
): number | undefined {
if (!(err instanceof DiscordApiError) || typeof err.retryAfter !== "number") {
return undefined;
}
return Math.min(Math.max(0, err.retryAfter * 1000), retryConfig.maxDelayMs);
}
type DiscordFetchOptions = {
retry?: RetryConfig;
label?: string;
};
type DiscordApiRequestOptions = DiscordFetchOptions & {
body?: unknown;
fetcher?: typeof fetch;
headers?: Record<string, string>;
method?: string;
signal?: AbortSignal;
timeoutMs?: number;
};
function normalizeDiscordRequestBody(body: unknown, headers: Headers): BodyInit | null | undefined {
if (body === undefined) {
return undefined;
}
if (
typeof body === "string" ||
body instanceof Blob ||
body instanceof FormData ||
body instanceof URLSearchParams ||
body instanceof ArrayBuffer
) {
return body;
}
headers.set("Content-Type", headers.get("Content-Type") ?? "application/json");
return JSON.stringify(body);
}
function resolveDiscordRequestSignal(options: DiscordApiRequestOptions) {
if (options.signal || typeof options.timeoutMs !== "number") {
return options.signal;
}
return AbortSignal.timeout(resolveTimerTimeoutMs(options.timeoutMs, 1));
}
export async function requestDiscord<T>(
path: string,
token: string,
options?: DiscordApiRequestOptions,
): Promise<T> {
const fetchImpl = resolveFetch(options?.fetcher ?? fetch);
if (!fetchImpl) {
throw new Error("fetch is not available");
}
const retryConfig = resolveRetryConfig(DISCORD_API_RETRY_DEFAULTS, options?.retry);
return retryAsync(
async () => {
const headers = new Headers(options?.headers);
headers.set("Authorization", `Bot ${token}`);
const body = normalizeDiscordRequestBody(options?.body, headers);
const res = await fetchImpl(`${DISCORD_API_BASE}${path}`, {
method: options?.method ?? (body === undefined ? "GET" : "POST"),
headers,
body,
signal: resolveDiscordRequestSignal(options ?? {}),
});
if (!res.ok) {
const text = await readResponseTextLimited(res, DISCORD_API_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
const detail = formatDiscordApiErrorText(text, res);
const suffix = detail ? `: ${detail}` : "";
const retryAfter =
res.status === 429
? (parseRetryAfterSeconds(text, res) ?? DISCORD_API_429_FALLBACK_RETRY_AFTER_SECONDS)
: undefined;
throw new DiscordApiError(
`Discord API ${path} failed (${res.status})${suffix}`,
res.status,
retryAfter,
);
}
const responseBody = await readResponseWithLimit(res, DISCORD_API_RESPONSE_BODY_LIMIT_BYTES, {
onOverflow: ({ size, maxBytes }) =>
new Error(
`Discord API ${path} response body too large: ${size} bytes (limit: ${maxBytes} bytes)`,
),
});
const text = new TextDecoder().decode(responseBody);
if (!text.trim()) {
return undefined as T;
}
try {
return JSON.parse(text) as T;
} catch {
throw new DiscordApiError(
`Discord API ${path} returned malformed JSON`,
0,
);
}
},
{
...retryConfig,
label: options?.label ?? path,
shouldRetry: (err) => err instanceof DiscordApiError && err.status === 429,
retryAfterMs: (err) => getDiscordApiRetryAfterMs(err, retryConfig),
},
);
}
export async function fetchDiscord<T>(
path: string,
token: string,
fetcher: typeof fetch = fetch,
options?: DiscordFetchOptions,
): Promise<T> {
return await requestDiscord<T>(path, token, { ...options, fetcher, method: "GET" });
}

View File

@@ -0,0 +1,42 @@
// Discord tests cover approval handler plugin behavior.
import { describe, expect, it } from "vitest";
import { discordApprovalNativeRuntime } from "./approval-handler.runtime.js";
describe("discordApprovalNativeRuntime", () => {
it("routes origin approval updates to the Discord thread channel when threadId is present", async () => {
const prepared = await discordApprovalNativeRuntime.transport.prepareTarget({
cfg: {} as never,
accountId: "main",
context: {
token: "discord-token",
config: {} as never,
},
plannedTarget: {
surface: "origin",
reason: "preferred",
target: {
to: "123456789",
threadId: "777888999",
},
},
request: {
id: "req-1",
request: {
command: "hostname",
},
createdAtMs: 0,
expiresAtMs: 1_000,
},
approvalKind: "exec",
view: {} as never,
pendingPayload: {} as never,
});
expect(prepared).toEqual({
dedupeKey: "777888999",
target: {
discordChannelId: "777888999",
},
});
});
});

View File

@@ -0,0 +1,637 @@
// Discord plugin module implements approval handler behavior.
import { ButtonStyle } from "discord-api-types/v10";
import type {
ChannelApprovalCapabilityHandlerContext,
ExecApprovalExpiredView,
ExecApprovalPendingView,
ExecApprovalResolvedView,
PendingApprovalView,
PluginApprovalExpiredView,
PluginApprovalPendingView,
PluginApprovalResolvedView,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import type { ExecApprovalActionDescriptor } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { ExecApprovalDecision } from "openclaw/plugin-sdk/approval-runtime";
import type {
DiscordExecApprovalConfig,
OpenClawConfig,
} from "openclaw/plugin-sdk/config-contracts";
import { logDebug, logError } from "openclaw/plugin-sdk/logging-core";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { shouldHandleDiscordApprovalRequest } from "./approval-shared.js";
import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js";
import {
Button,
createChannelMessage,
createUserDmChannel,
deleteChannelMessage,
editChannelMessage,
Row,
Separator,
TextDisplay,
serializePayload,
type MessagePayloadObject,
type TopLevelComponents,
} from "./internal/discord.js";
import { createDiscordClient, stripUndefinedFields } from "./send.shared.js";
import { DiscordUiContainer } from "./ui.js";
type PendingApproval = {
discordMessageId: string;
discordChannelId: string;
};
type DiscordPendingDelivery = {
body: ReturnType<typeof stripUndefinedFields>;
};
type PreparedDeliveryTarget = {
discordChannelId: string;
recipientUserId?: string;
};
export type DiscordApprovalHandlerContext = {
token: string;
config: DiscordExecApprovalConfig;
};
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
accountId: string;
context: DiscordApprovalHandlerContext;
} | null {
const context = params.context as DiscordApprovalHandlerContext | undefined;
const accountId = normalizeOptionalString(params.accountId) ?? "";
if (!context?.token || !accountId) {
return null;
}
return { accountId, context };
}
class ExecApprovalContainer extends DiscordUiContainer {
constructor(params: {
cfg: OpenClawConfig;
accountId: string;
title: string;
description?: string;
commandPreview: string;
commandSecondaryPreview?: string | null;
metadataLines?: string[];
actionRow?: Row<Button>;
footer?: string;
accentColor?: string;
}) {
const components: Array<TextDisplay | Separator | Row<Button>> = [
new TextDisplay(`## ${params.title}`),
];
if (params.description) {
components.push(new TextDisplay(params.description));
}
components.push(new Separator({ divider: true, spacing: "small" }));
components.push(new TextDisplay(`### Command\n\`\`\`\n${params.commandPreview}\n\`\`\``));
if (params.commandSecondaryPreview) {
components.push(
new TextDisplay(`### Shell Preview\n\`\`\`\n${params.commandSecondaryPreview}\n\`\`\``),
);
}
if (params.metadataLines?.length) {
components.push(new TextDisplay(params.metadataLines.join("\n")));
}
if (params.actionRow) {
components.push(params.actionRow);
}
if (params.footer) {
components.push(new Separator({ divider: false, spacing: "small" }));
components.push(new TextDisplay(`-# ${params.footer}`));
}
super({
cfg: params.cfg,
accountId: params.accountId,
components,
accentColor: params.accentColor,
});
}
}
class ExecApprovalActionButton extends Button {
override customId: string;
override label: string;
override style: ButtonStyle;
constructor(params: { approvalId: string; descriptor: ExecApprovalActionDescriptor }) {
super();
this.customId = buildExecApprovalCustomId(params.approvalId, params.descriptor.decision);
this.label = params.descriptor.label;
this.style =
params.descriptor.style === "success"
? ButtonStyle.Success
: params.descriptor.style === "primary"
? ButtonStyle.Primary
: params.descriptor.style === "danger"
? ButtonStyle.Danger
: ButtonStyle.Secondary;
}
}
class ExecApprovalActionRow extends Row<Button> {
constructor(params: { approvalId: string; actions: readonly ExecApprovalActionDescriptor[] }) {
super(
params.actions.map(
(descriptor) => new ExecApprovalActionButton({ approvalId: params.approvalId, descriptor }),
),
);
}
}
function createApprovalActionRow(view: PendingApprovalView): Row<Button> {
return new ExecApprovalActionRow({
approvalId: view.approvalId,
actions: view.actions,
});
}
function buildApprovalMetadataLines(
metadata: readonly { label: string; value: string }[],
): string[] {
return metadata.map((item) => `- ${item.label}: ${item.value}`);
}
function buildExecApprovalPayload(container: DiscordUiContainer): MessagePayloadObject {
const components: TopLevelComponents[] = [container];
return { components };
}
function formatCommandPreview(commandText: string, maxChars: number): string {
const commandRaw =
commandText.length > maxChars ? `${commandText.slice(0, maxChars)}...` : commandText;
return commandRaw.replace(/`/g, "\u200b`");
}
function formatOptionalCommandPreview(
commandText: string | null | undefined,
maxChars: number,
): string | null {
if (!commandText) {
return null;
}
return formatCommandPreview(commandText, maxChars);
}
function resolveCommandPreviews(
commandText: string,
commandPreview: string | null | undefined,
maxChars: number,
secondaryMaxChars: number,
): { commandPreview: string; commandSecondaryPreview: string | null } {
return {
commandPreview: formatCommandPreview(commandText, maxChars),
commandSecondaryPreview: formatOptionalCommandPreview(commandPreview, secondaryMaxChars),
};
}
function createExecApprovalRequestContainer(params: {
view: ExecApprovalPendingView;
cfg: OpenClawConfig;
accountId: string;
actionRow?: Row<Button>;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveCommandPreviews(
params.view.commandText,
params.view.commandPreview,
1000,
500,
);
const expiresAtSeconds = Math.max(0, Math.floor(params.view.expiresAtMs / 1000));
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Exec Approval Required",
description: "A command needs your approval.",
commandPreview,
commandSecondaryPreview,
metadataLines: buildApprovalMetadataLines(params.view.metadata),
actionRow: params.actionRow,
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.view.approvalId}`,
accentColor: "#FFA500",
});
}
function createPluginApprovalRequestContainer(params: {
view: PluginApprovalPendingView;
cfg: OpenClawConfig;
accountId: string;
actionRow?: Row<Button>;
}): ExecApprovalContainer {
const expiresAtSeconds = Math.max(0, Math.floor(params.view.expiresAtMs / 1000));
const severity = params.view.severity;
const accentColor =
severity === "critical" ? "#ED4245" : severity === "info" ? "#5865F2" : "#FAA61A";
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Plugin Approval Required",
description: "A plugin action needs your approval.",
commandPreview: formatCommandPreview(params.view.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
metadataLines: buildApprovalMetadataLines(params.view.metadata),
actionRow: params.actionRow,
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.view.approvalId}`,
accentColor,
});
}
function createExecResolvedContainer(params: {
view: ExecApprovalResolvedView;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveCommandPreviews(
params.view.commandText,
params.view.commandPreview,
500,
300,
);
const decisionLabel =
params.view.decision === "allow-once"
? "Allowed (once)"
: params.view.decision === "allow-always"
? "Allowed (always)"
: "Denied";
const accentColor =
params.view.decision === "deny"
? "#ED4245"
: params.view.decision === "allow-always"
? "#5865F2"
: "#57F287";
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: `Exec Approval: ${decisionLabel}`,
description: params.view.resolvedBy ? `Resolved by ${params.view.resolvedBy}` : "Resolved",
commandPreview,
commandSecondaryPreview,
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor,
});
}
function createPluginResolvedContainer(params: {
view: PluginApprovalResolvedView;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const decisionLabel =
params.view.decision === "allow-once"
? "Allowed (once)"
: params.view.decision === "allow-always"
? "Allowed (always)"
: "Denied";
const accentColor =
params.view.decision === "deny"
? "#ED4245"
: params.view.decision === "allow-always"
? "#5865F2"
: "#57F287";
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: `Plugin Approval: ${decisionLabel}`,
description: params.view.resolvedBy ? `Resolved by ${params.view.resolvedBy}` : "Resolved",
commandPreview: formatCommandPreview(params.view.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor,
});
}
function createExecExpiredContainer(params: {
view: ExecApprovalExpiredView;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveCommandPreviews(
params.view.commandText,
params.view.commandPreview,
500,
300,
);
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Exec Approval: Expired",
description: "This approval request has expired.",
commandPreview,
commandSecondaryPreview,
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor: "#99AAB5",
});
}
function createPluginExpiredContainer(params: {
view: PluginApprovalExpiredView;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Plugin Approval: Expired",
description: "This approval request has expired.",
commandPreview: formatCommandPreview(params.view.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor: "#99AAB5",
});
}
export function buildExecApprovalCustomId(
approvalId: string,
action: ExecApprovalDecision,
): string {
return [`execapproval:id=${encodeURIComponent(approvalId)}`, `action=${action}`].join(";");
}
async function updateMessage(params: {
cfg: OpenClawConfig;
accountId: string;
token: string;
channelId: string;
messageId: string;
container: DiscordUiContainer;
}): Promise<void> {
try {
const { rest, request: discordRequest } = createDiscordClient({
cfg: params.cfg,
token: params.token,
accountId: params.accountId,
});
const payload = buildExecApprovalPayload(params.container);
await discordRequest(
() =>
editChannelMessage(rest, params.channelId, params.messageId, {
body: stripUndefinedFields(serializePayload(payload)),
}),
"update-approval",
);
} catch (err) {
logError(`discord approvals: failed to update message: ${String(err)}`);
}
}
async function finalizeMessage(params: {
cfg: OpenClawConfig;
accountId: string;
token: string;
cleanupAfterResolve?: boolean;
channelId: string;
messageId: string;
container: DiscordUiContainer;
}): Promise<void> {
if (!params.cleanupAfterResolve) {
await updateMessage(params);
return;
}
try {
const { rest, request: discordRequest } = createDiscordClient({
cfg: params.cfg,
token: params.token,
accountId: params.accountId,
});
await discordRequest(
() => deleteChannelMessage(rest, params.channelId, params.messageId),
"delete-approval",
);
} catch (err) {
logError(`discord approvals: failed to delete message: ${String(err)}`);
await updateMessage(params);
}
}
export const discordApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
DiscordPendingDelivery,
PreparedDeliveryTarget,
PendingApproval,
never
>({
eventKinds: ["exec", "plugin"],
resolveApprovalKind: (request) => (request.id.startsWith("plugin:") ? "plugin" : "exec"),
availability: {
isConfigured: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? isDiscordExecApprovalClientEnabled({
cfg: params.cfg,
accountId: resolved.accountId,
configOverride: resolved.context.config,
})
: false;
},
shouldHandle: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? shouldHandleDiscordApprovalRequest({
cfg: params.cfg,
accountId: resolved.accountId,
request: params.request,
configOverride: resolved.context.config,
})
: false;
},
},
presentation: {
buildPendingPayload: ({ cfg, accountId, context, view }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return { body: {} };
}
const actionRow = createApprovalActionRow(view);
const container =
view.approvalKind === "plugin"
? createPluginApprovalRequestContainer({
view,
cfg,
accountId: resolved.accountId,
actionRow,
})
: createExecApprovalRequestContainer({
view,
cfg,
accountId: resolved.accountId,
actionRow,
});
return {
body: stripUndefinedFields(serializePayload(buildExecApprovalPayload(container))),
};
},
buildResolvedResult: ({ cfg, accountId, context, view }) => {
const resolvedContext = resolveHandlerContext({ cfg, accountId, context });
if (!resolvedContext) {
return { kind: "delete" } as const;
}
const container =
view.approvalKind === "plugin"
? createPluginResolvedContainer({
view,
cfg,
accountId: resolvedContext.accountId,
})
: createExecResolvedContainer({
view,
cfg,
accountId: resolvedContext.accountId,
});
return { kind: "update", payload: container } as const;
},
buildExpiredResult: ({ cfg, accountId, context, view }) => {
const resolvedContext = resolveHandlerContext({ cfg, accountId, context });
if (!resolvedContext) {
return { kind: "delete" } as const;
}
const container =
view.approvalKind === "plugin"
? createPluginExpiredContainer({
view,
cfg,
accountId: resolvedContext.accountId,
})
: createExecExpiredContainer({
view,
cfg,
accountId: resolvedContext.accountId,
});
return { kind: "update", payload: container } as const;
},
},
transport: {
prepareTarget: async ({ cfg, accountId, context, plannedTarget }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
if (plannedTarget.surface === "origin") {
const destinationId =
typeof plannedTarget.target.threadId === "string" &&
plannedTarget.target.threadId.trim().length > 0
? plannedTarget.target.threadId.trim()
: plannedTarget.target.to;
return {
dedupeKey: destinationId,
target: {
discordChannelId: destinationId,
},
};
}
const { rest, request: discordRequest } = createDiscordClient({
cfg,
token: resolved.context.token,
accountId: resolved.accountId,
});
const userId = plannedTarget.target.to;
const dmChannel = (await discordRequest(
() => createUserDmChannel(rest, userId),
"dm-channel",
)) as { id: string };
if (!dmChannel?.id) {
logError(`discord approvals: failed to create DM for user ${userId}`);
return null;
}
return {
dedupeKey: dmChannel.id,
target: {
discordChannelId: dmChannel.id,
recipientUserId: userId,
},
};
},
deliverPending: async ({
cfg,
accountId,
context,
plannedTarget,
preparedTarget,
pendingPayload,
}) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
const { rest, request: discordRequest } = createDiscordClient({
cfg,
token: resolved.context.token,
accountId: resolved.accountId,
});
const message = (await discordRequest(
() =>
createChannelMessage<{ id: string; channel_id: string }>(
rest,
preparedTarget.discordChannelId,
{
body: pendingPayload.body,
},
),
plannedTarget.surface === "origin" ? "send-approval-channel" : "send-approval",
)) as { id: string; channel_id: string };
if (!message?.id) {
if (plannedTarget.surface === "origin") {
logError("discord approvals: failed to send to channel");
} else if (preparedTarget.recipientUserId) {
logError(
`discord approvals: failed to send message to user ${preparedTarget.recipientUserId}`,
);
}
return null;
}
return {
discordMessageId: message.id,
discordChannelId: preparedTarget.discordChannelId,
};
},
updateEntry: async ({ cfg, accountId, context, entry, payload, phase }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const container = payload as DiscordUiContainer;
await finalizeMessage({
cfg,
accountId: resolved.accountId,
token: resolved.context.token,
cleanupAfterResolve:
phase === "resolved" ? resolved.context.config.cleanupAfterResolve : false,
channelId: entry.discordChannelId,
messageId: entry.discordMessageId,
container,
});
},
},
observe: {
onDuplicateSkipped: ({ preparedTarget, request }) => {
logDebug(
`discord approvals: skipping duplicate approval ${request.id} for channel ${preparedTarget.dedupeKey}`,
);
},
onDelivered: ({ plannedTarget, preparedTarget, request }) => {
if (plannedTarget.surface === "origin") {
logDebug(
`discord approvals: sent approval ${request.id} to channel ${preparedTarget.target.discordChannelId}`,
);
return;
}
logDebug(`discord approvals: sent approval ${request.id} to user ${plannedTarget.target.to}`);
},
onDeliveryError: ({ error, plannedTarget }) => {
if (plannedTarget.surface === "origin") {
logError(`discord approvals: failed to send to channel: ${String(error)}`);
return;
}
logError(
`discord approvals: failed to notify user ${plannedTarget.target.to}: ${String(error)}`,
);
},
},
});

View File

@@ -0,0 +1,381 @@
// Discord tests cover approval native plugin behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/session-store-runtime";
import { describe, expect, it } from "vitest";
import {
createDiscordNativeApprovalAdapter,
getDiscordApprovalCapability,
shouldHandleDiscordApprovalRequest,
} from "./approval-native.js";
const STORE_PATH = path.join(os.tmpdir(), "openclaw-discord-approval-native-test.json");
const NATIVE_APPROVAL_CFG = {
commands: {
ownerAllowFrom: ["discord:555555555"],
},
} as const;
const NATIVE_DELIVERY_CFG = {
...NATIVE_APPROVAL_CFG,
channels: {
discord: {
execApprovals: {
enabled: true,
},
},
},
} as const;
function writeStore(store: Record<string, unknown>) {
fs.writeFileSync(STORE_PATH, `${JSON.stringify(store, null, 2)}\n`, "utf8");
clearSessionStoreCacheForTest();
}
describe("createDiscordNativeApprovalAdapter", () => {
it("keeps approval availability enabled when approvers exist but native delivery is off", () => {
const adapter = createDiscordNativeApprovalAdapter({
enabled: false,
approvers: ["555555555"],
target: "channel",
} as never);
expect(
adapter.auth?.getActionAvailabilityState?.({
cfg: NATIVE_APPROVAL_CFG as never,
accountId: "main",
action: "approve",
}),
).toEqual({ kind: "enabled" });
expect(
adapter.native?.describeDeliveryCapabilities({
cfg: NATIVE_APPROVAL_CFG as never,
accountId: "main",
approvalKind: "exec",
request: {
id: "approval-1",
request: {
command: "pwd",
turnSourceChannel: "discord",
turnSourceTo: "channel:123456789",
turnSourceAccountId: "main",
sessionKey: "agent:main:discord:channel:123456789",
},
createdAtMs: 1,
expiresAtMs: 2,
},
}),
).toEqual({
enabled: false,
preferredSurface: "origin",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
it("honors ownerAllowFrom fallback when gating approval requests", () => {
expect(
shouldHandleDiscordApprovalRequest({
cfg: {
commands: {
ownerAllowFrom: ["discord:123"],
},
} as never,
accountId: "main",
configOverride: { enabled: true } as never,
request: {
id: "approval-1",
request: {
command: "pwd",
turnSourceChannel: "discord",
turnSourceTo: "channel:123456789",
turnSourceAccountId: "main",
},
createdAtMs: 1,
expiresAtMs: 2,
},
}),
).toBe(true);
});
it("describes the correct Discord exec-approval setup path", () => {
const text = getDiscordApprovalCapability().describeExecApprovalSetup?.({
channel: "discord",
channelLabel: "Discord",
});
expect(text).toContain("`channels.discord.execApprovals.approvers`");
expect(text).toContain("`commands.ownerAllowFrom`");
expect(text).not.toContain("`channels.discord.dm.allowFrom`");
});
it("describes the named-account Discord exec-approval setup path", () => {
const text = getDiscordApprovalCapability().describeExecApprovalSetup?.({
channel: "discord",
channelLabel: "Discord",
accountId: "work",
});
expect(text).toContain("`channels.discord.accounts.work.execApprovals.approvers`");
expect(text).toContain("`commands.ownerAllowFrom`");
expect(text).not.toContain("`channels.discord.execApprovals.approvers`");
});
it("normalizes prefixed turn-source channel ids", async () => {
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_DELIVERY_CFG as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
turnSourceChannel: "discord",
turnSourceTo: "channel:123456789",
turnSourceAccountId: "main",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toEqual({ to: "123456789" });
});
it("falls back to approver DMs for Discord DM sessions with raw turn-source ids", async () => {
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_DELIVERY_CFG as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
sessionKey: "agent:main:discord:dm:123456789",
turnSourceChannel: "discord",
turnSourceTo: "123456789",
turnSourceAccountId: "main",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toBeNull();
});
it("falls back to approver DMs for canonical Discord direct sessions", async () => {
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_DELIVERY_CFG as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
sessionKey: "agent:main:discord:direct:123456789",
turnSourceChannel: "discord",
turnSourceTo: "123456789",
turnSourceAccountId: "main",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toBeNull();
});
it("falls back to approver DMs for account-scoped Discord direct sessions", async () => {
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_DELIVERY_CFG as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
sessionKey: "agent:main:discord:default:direct:123456789",
turnSourceChannel: "discord",
turnSourceTo: "123456789",
turnSourceAccountId: "main",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toBeNull();
});
it("ignores session-store turn targets for Discord DM sessions", async () => {
writeStore({
"agent:main:discord:dm:123456789": {
sessionId: "sess",
updatedAt: Date.now(),
origin: { provider: "discord", to: "123456789", accountId: "main" },
lastChannel: "discord",
lastTo: "123456789",
lastAccountId: "main",
},
});
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: {
...NATIVE_DELIVERY_CFG,
session: { store: STORE_PATH },
} as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
sessionKey: "agent:main:discord:dm:123456789",
turnSourceChannel: "discord",
turnSourceTo: "123456789",
turnSourceAccountId: "main",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toBeNull();
});
it("accepts raw turn-source ids when a Discord channel session backs them", async () => {
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_DELIVERY_CFG as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
sessionKey: "agent:main:discord:channel:123456789",
turnSourceChannel: "discord",
turnSourceTo: "123456789",
turnSourceAccountId: "main",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toEqual({ to: "123456789", threadId: undefined });
});
it("falls back to extracting the channel id from the session key", async () => {
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_DELIVERY_CFG as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
sessionKey: "agent:main:discord:channel:987654321",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toEqual({ to: "987654321", threadId: undefined });
});
it("preserves explicit turn-source thread ids on origin targets", async () => {
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_DELIVERY_CFG as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
sessionKey: "agent:main:discord:channel:123456789:thread:777888999",
turnSourceChannel: "discord",
turnSourceTo: "channel:123456789",
turnSourceThreadId: "777888999",
turnSourceAccountId: "main",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toEqual({ to: "123456789", threadId: "777888999" });
});
it("falls back to extracting thread ids from the session key", async () => {
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_DELIVERY_CFG as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
sessionKey: "agent:main:discord:channel:987654321:thread:444555666",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toEqual({ to: "987654321", threadId: "444555666" });
});
it("rejects origin delivery for requests bound to another Discord account", async () => {
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_APPROVAL_CFG as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
turnSourceChannel: "discord",
turnSourceTo: "channel:123456789",
turnSourceAccountId: "other",
sessionKey: "agent:main:missing",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toBeNull();
});
});

View File

@@ -0,0 +1,227 @@
// Discord plugin module implements approval native behavior.
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import { resolveApprovalRequestSessionConversation } from "openclaw/plugin-sdk/approval-native-runtime";
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-contracts";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
export { shouldHandleDiscordApprovalRequest } from "./approval-shared.js";
import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js";
import {
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
createApproverRestrictedNativeApprovalCapability,
splitChannelApprovalCapability,
} from "./approval-runtime.js";
import { shouldHandleDiscordApprovalRequest } from "./approval-shared.js";
import {
getDiscordExecApprovalApprovers,
isDiscordExecApprovalApprover,
isDiscordExecApprovalClientEnabled,
} from "./exec-approvals.js";
// Legacy export kept for monitor test/support surfaces; native routing now uses
// the shared session-conversation fallback helper instead.
export function extractDiscordChannelId(sessionKey?: string | null): string | null {
if (!sessionKey) {
return null;
}
const match = sessionKey.match(/discord:(?:channel|group):(\d+)/);
return match ? match[1] : null;
}
function extractDiscordSessionKind(sessionKey?: string | null): "channel" | "group" | "dm" | null {
if (!sessionKey) {
return null;
}
// DM session keys use the `direct` peer kind in the normalized form
// (`agent:<id>:discord[:account]:direct:<userId>`); legacy keys may still use
// `dm`. Treat both as the same logical kind for downstream comparisons.
const match = sessionKey.match(/discord:(?:[^:]+:)?(channel|group|dm|direct):/);
if (!match) {
return null;
}
const raw = match[1];
if (raw === "direct") {
return "dm";
}
return raw as "channel" | "group" | "dm";
}
function normalizeDiscordOriginChannelId(value?: string | null): string | null {
if (!value) {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const prefixed = trimmed.match(/^(?:channel|group):(\d+)$/i);
if (prefixed) {
return prefixed[1];
}
return /^\d+$/.test(trimmed) ? trimmed : null;
}
function normalizeDiscordThreadId(value?: string | number | null): string | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : undefined;
}
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim();
return /^\d+$/.test(normalized) ? normalized : undefined;
}
function createDiscordOriginTargetResolver(configOverride?: DiscordExecApprovalConfig | null) {
return createChannelNativeOriginTargetResolver({
channel: "discord",
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleDiscordApprovalRequest({
cfg,
accountId,
request,
configOverride,
}),
resolveTurnSourceTarget: (request) => {
const sessionConversation = resolveApprovalRequestSessionConversation({
request,
channel: "discord",
bundledFallback: false,
});
const sessionKind = extractDiscordSessionKind(
normalizeOptionalString(request.request.sessionKey) ?? null,
);
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
const rawTurnSourceTo = normalizeOptionalString(request.request.turnSourceTo) ?? "";
const turnSourceTo = normalizeDiscordOriginChannelId(rawTurnSourceTo);
const threadId =
normalizeDiscordThreadId(request.request.turnSourceThreadId) ??
normalizeDiscordThreadId(sessionConversation?.threadId) ??
undefined;
const hasExplicitOriginTarget = /^(?:channel|group):/i.test(rawTurnSourceTo);
if (turnSourceChannel !== "discord" || !turnSourceTo || sessionKind === "dm") {
return null;
}
return hasExplicitOriginTarget || sessionKind === "channel" || sessionKind === "group"
? { to: turnSourceTo, threadId }
: null;
},
resolveSessionTarget: (sessionTarget, request) => {
const sessionConversation = resolveApprovalRequestSessionConversation({
request,
channel: "discord",
bundledFallback: false,
});
const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null);
if (sessionKind === "dm") {
return null;
}
const targetTo = normalizeDiscordOriginChannelId(sessionTarget.to);
return targetTo
? {
to: targetTo,
threadId:
normalizeDiscordThreadId(sessionTarget.threadId) ??
normalizeDiscordThreadId(sessionConversation?.threadId) ??
undefined,
}
: null;
},
resolveFallbackTarget: (request) => {
const sessionConversation = resolveApprovalRequestSessionConversation({
request,
channel: "discord",
bundledFallback: false,
});
const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null);
if (sessionKind === "dm") {
return null;
}
const fallbackChannelId = normalizeDiscordOriginChannelId(sessionConversation?.id);
return fallbackChannelId
? {
to: fallbackChannelId,
threadId: normalizeDiscordThreadId(sessionConversation?.threadId) ?? undefined,
}
: null;
},
});
}
function createDiscordApproverDmTargetResolver(configOverride?: DiscordExecApprovalConfig | null) {
return createChannelApproverDmTargetResolver({
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleDiscordApprovalRequest({
cfg,
accountId,
request,
configOverride,
}),
resolveApprovers: ({ cfg, accountId }) =>
getDiscordExecApprovalApprovers({ cfg, accountId, configOverride }),
mapApprover: (approver) => ({ to: approver }),
});
}
function createDiscordApprovalCapability(configOverride?: DiscordExecApprovalConfig | null) {
return createApproverRestrictedNativeApprovalCapability({
channel: "discord",
channelLabel: "Discord",
describeExecApprovalSetup: ({
accountId,
}: Parameters<NonNullable<ChannelApprovalCapability["describeExecApprovalSetup"]>>[0]) => {
const prefix =
accountId && accountId !== "default"
? `channels.discord.accounts.${accountId}`
: "channels.discord";
return `Approve it from the Web UI or terminal UI for now. Discord supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\` or \`commands.ownerAllowFrom\`; set \`${prefix}.execApprovals.enabled\` to \`auto\` or \`true\`.`;
},
listAccountIds: listDiscordAccountIds,
hasApprovers: ({ cfg, accountId }) =>
getDiscordExecApprovalApprovers({ cfg, accountId, configOverride }).length > 0,
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
isDiscordExecApprovalApprover({ cfg, accountId, senderId, configOverride }),
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
isDiscordExecApprovalClientEnabled({ cfg, accountId, configOverride }),
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
configOverride?.target ??
resolveDiscordAccount({ cfg, accountId }).config.execApprovals?.target ??
"dm",
resolveOriginTarget: createDiscordOriginTargetResolver(configOverride),
resolveApproverDmTargets: createDiscordApproverDmTargetResolver(configOverride),
notifyOriginWhenDmOnly: true,
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec", "plugin"],
isConfigured: ({ cfg, accountId }) =>
isDiscordExecApprovalClientEnabled({ cfg, accountId, configOverride }),
shouldHandle: ({ cfg, accountId, request }) =>
shouldHandleDiscordApprovalRequest({
cfg,
accountId,
request,
configOverride,
}),
load: async () =>
(await import("./approval-handler.runtime.js"))
.discordApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter,
}),
});
}
export function createDiscordNativeApprovalAdapter(
configOverride?: DiscordExecApprovalConfig | null,
) {
return splitChannelApprovalCapability(createDiscordApprovalCapability(configOverride));
}
let cachedDiscordApprovalCapability: ReturnType<typeof createDiscordApprovalCapability> | undefined;
export function getDiscordApprovalCapability() {
cachedDiscordApprovalCapability ??= createDiscordApprovalCapability();
return cachedDiscordApprovalCapability;
}

View File

@@ -0,0 +1,15 @@
// Discord plugin module implements approval runtime behavior.
export {
isChannelExecApprovalClientEnabledFromConfig,
matchesApprovalRequestFilters,
getExecApprovalReplyMetadata,
} from "openclaw/plugin-sdk/approval-client-runtime";
export { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-auth-runtime";
export {
createApproverRestrictedNativeApprovalCapability,
splitChannelApprovalCapability,
} from "openclaw/plugin-sdk/approval-delivery-runtime";
export {
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
} from "openclaw/plugin-sdk/approval-native-runtime";

View File

@@ -0,0 +1,57 @@
// Discord plugin module implements approval shared behavior.
import { doesApprovalRequestMatchChannelAccount } from "openclaw/plugin-sdk/approval-native-runtime";
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import type {
DiscordExecApprovalConfig,
OpenClawConfig,
} from "openclaw/plugin-sdk/config-contracts";
import { resolveDiscordAccount } from "./accounts.js";
import {
isChannelExecApprovalClientEnabledFromConfig,
matchesApprovalRequestFilters,
} from "./approval-runtime.js";
import { getDiscordExecApprovalApprovers } from "./exec-approvals.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
export function shouldHandleDiscordApprovalRequest(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
configOverride?: DiscordExecApprovalConfig | null;
}): boolean {
const config =
params.configOverride ??
resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).config.execApprovals;
const approvers = getDiscordExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
configOverride: params.configOverride,
});
if (
!doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
channel: "discord",
accountId: params.accountId,
})
) {
return false;
}
if (
!isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: approvers.length,
})
) {
return false;
}
return matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
});
}

View File

@@ -0,0 +1,179 @@
// Discord plugin module implements audit core behavior.
import { ChannelType } from "discord-api-types/v10";
import type {
DiscordGuildChannelConfig,
DiscordGuildEntry,
OpenClawConfig,
} from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
type DiscordChannelPermissionsAuditEntry = {
channelId: string;
ok: boolean;
missing?: string[];
error?: string | null;
matchKey?: string;
matchSource?: "id";
};
export type DiscordChannelPermissionsAudit = {
ok: boolean;
checkedChannels: number;
unresolvedChannels: number;
channels: DiscordChannelPermissionsAuditEntry[];
elapsedMs: number;
};
const REQUIRED_TEXT_CHANNEL_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
const REQUIRED_VOICE_CHANNEL_PERMISSIONS = [
"ViewChannel",
"Connect",
"Speak",
"SendMessages",
"ReadMessageHistory",
] as const;
export function resolveRequiredDiscordChannelPermissions(channelType?: number): string[] {
if (channelType === ChannelType.GuildVoice || channelType === ChannelType.GuildStageVoice) {
return [...REQUIRED_VOICE_CHANNEL_PERMISSIONS];
}
return [...REQUIRED_TEXT_CHANNEL_PERMISSIONS];
}
function shouldAuditChannelConfig(config: DiscordGuildChannelConfig | undefined) {
if (!config) {
return true;
}
if (config.enabled === false) {
return false;
}
return true;
}
function listConfiguredGuildChannelKeys(
guilds: Record<string, DiscordGuildEntry> | undefined,
): string[] {
if (!guilds) {
return [];
}
const ids = new Set<string>();
for (const entry of Object.values(guilds)) {
if (!entry || typeof entry !== "object") {
continue;
}
const channelsRaw = (entry as { channels?: unknown }).channels;
if (!isRecord(channelsRaw)) {
continue;
}
for (const [key, value] of Object.entries(channelsRaw)) {
const channelId = normalizeOptionalString(key) ?? "";
if (!channelId) {
continue;
}
if (channelId === "*") {
continue;
}
if (!shouldAuditChannelConfig(value as DiscordGuildChannelConfig | undefined)) {
continue;
}
ids.add(channelId);
}
}
return [...ids].toSorted((a, b) => a.localeCompare(b));
}
export function collectDiscordAuditChannelIdsForGuilds(
guilds: Record<string, DiscordGuildEntry> | undefined,
) {
const keys = listConfiguredGuildChannelKeys(guilds);
const channelIds = keys.filter((key) => /^\d+$/.test(key));
const unresolvedChannels = keys.length - channelIds.length;
return { channelIds, unresolvedChannels };
}
export function collectDiscordAuditChannelIdsForAccount(config: {
guilds?: Record<string, DiscordGuildEntry>;
voice?: { autoJoin?: Array<{ guildId?: string; channelId?: string }> };
}) {
const collected = collectDiscordAuditChannelIdsForGuilds(config.guilds);
const channelIds = new Set(collected.channelIds);
let unresolvedVoiceChannels = 0;
for (const entry of config.voice?.autoJoin ?? []) {
const channelId = normalizeOptionalString(entry?.channelId) ?? "";
if (/^\d+$/.test(channelId)) {
channelIds.add(channelId);
} else if (channelId) {
unresolvedVoiceChannels++;
}
}
return {
channelIds: [...channelIds].toSorted((a, b) => a.localeCompare(b)),
unresolvedChannels: collected.unresolvedChannels + unresolvedVoiceChannels,
};
}
export async function auditDiscordChannelPermissionsWithFetcher(params: {
cfg: OpenClawConfig;
token: string;
accountId?: string | null;
channelIds: string[];
timeoutMs: number;
fetchChannelPermissions: (
channelId: string,
params: { cfg: OpenClawConfig; token: string; accountId?: string },
) => Promise<{
permissions: string[];
channelType?: number;
}>;
}): Promise<DiscordChannelPermissionsAudit> {
const started = Date.now();
const token = normalizeOptionalString(params.token) ?? "";
if (!token || params.channelIds.length === 0) {
return {
ok: true,
checkedChannels: 0,
unresolvedChannels: 0,
channels: [],
elapsedMs: Date.now() - started,
};
}
const channels: DiscordChannelPermissionsAuditEntry[] = [];
for (const channelId of params.channelIds) {
try {
const perms = await params.fetchChannelPermissions(channelId, {
cfg: params.cfg,
token,
accountId: params.accountId ?? undefined,
});
const required = resolveRequiredDiscordChannelPermissions(perms.channelType);
const missing = required.filter((p) => !perms.permissions.includes(p));
channels.push({
channelId,
ok: missing.length === 0,
missing: missing.length ? missing : undefined,
error: null,
matchKey: channelId,
matchSource: "id",
});
} catch (err) {
channels.push({
channelId,
ok: false,
error: formatErrorMessage(err),
matchKey: channelId,
matchSource: "id",
});
}
}
return {
ok: channels.every((c) => c.ok),
checkedChannels: channels.length,
unresolvedChannels: 0,
channels,
elapsedMs: Date.now() - started,
};
}

View File

@@ -0,0 +1,205 @@
// Discord tests cover audit plugin behavior.
import { ChannelType } from "discord-api-types/v10";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
auditDiscordChannelPermissionsWithFetcher,
collectDiscordAuditChannelIdsForAccount,
collectDiscordAuditChannelIdsForGuilds,
} from "./audit-core.js";
const fetchChannelPermissionsDiscordMock = vi.fn();
function readDiscordGuilds(cfg: OpenClawConfig) {
const guilds = cfg.channels?.discord?.guilds;
if (!guilds) {
throw new Error("expected discord guilds config");
}
return guilds;
}
describe("discord audit", () => {
beforeEach(() => {
fetchChannelPermissionsDiscordMock.mockReset();
});
it("collects numeric channel ids even when config uses allow=false and counts unresolved keys", async () => {
const cfg = {
channels: {
discord: {
enabled: true,
token: "t",
groupPolicy: "allowlist",
guilds: {
"123": {
channels: {
"111": { allow: true },
general: { allow: true },
"222": { allow: false },
},
},
},
},
},
} as unknown as OpenClawConfig;
const collected = collectDiscordAuditChannelIdsForGuilds(readDiscordGuilds(cfg));
expect(collected.channelIds).toEqual(["111", "222"]);
expect(collected.unresolvedChannels).toBe(1);
fetchChannelPermissionsDiscordMock.mockResolvedValueOnce({
channelId: "111",
permissions: ["ViewChannel"],
raw: "0",
isDm: false,
});
fetchChannelPermissionsDiscordMock.mockResolvedValueOnce({
channelId: "222",
permissions: ["ViewChannel", "SendMessages"],
raw: "0",
isDm: false,
});
const audit = await auditDiscordChannelPermissionsWithFetcher({
cfg,
token: "t",
accountId: "default",
channelIds: collected.channelIds,
timeoutMs: 1000,
fetchChannelPermissions: fetchChannelPermissionsDiscordMock,
});
expect(audit.ok).toBe(false);
expect(audit.channels).toHaveLength(2);
expect(audit.channels[0]?.channelId).toBe("111");
expect(audit.channels[0]?.missing).toContain("SendMessages");
});
it("does not count '*' wildcard key as unresolved channel", () => {
const cfg = {
channels: {
discord: {
enabled: true,
token: "t",
groupPolicy: "allowlist",
guilds: {
"123": {
channels: {
"111": { allow: true },
"*": { allow: true },
},
},
},
},
},
} as unknown as OpenClawConfig;
const collected = collectDiscordAuditChannelIdsForGuilds(readDiscordGuilds(cfg));
expect(collected.channelIds).toEqual(["111"]);
expect(collected.unresolvedChannels).toBe(0);
});
it("handles guild with only '*' wildcard and no numeric channel ids", () => {
const cfg = {
channels: {
discord: {
enabled: true,
token: "t",
groupPolicy: "allowlist",
guilds: {
"123": {
channels: {
"*": { allow: true },
},
},
},
},
},
} as unknown as OpenClawConfig;
const collected = collectDiscordAuditChannelIdsForGuilds(readDiscordGuilds(cfg));
expect(collected.channelIds).toStrictEqual([]);
expect(collected.unresolvedChannels).toBe(0);
});
it("collects audit channel ids without resolving SecretRef-backed Discord tokens", () => {
const cfg = {
channels: {
discord: {
enabled: true,
token: {
source: "env",
provider: "default",
id: "DISCORD_BOT_TOKEN",
},
guilds: {
"123": {
channels: {
"111": { allow: true },
general: { allow: true },
},
},
},
},
},
} as unknown as OpenClawConfig;
const collected = collectDiscordAuditChannelIdsForGuilds(readDiscordGuilds(cfg));
expect(collected.channelIds).toEqual(["111"]);
expect(collected.unresolvedChannels).toBe(1);
});
it("includes configured voice auto-join channels in permission audits", () => {
const collected = collectDiscordAuditChannelIdsForAccount({
guilds: {
"123": {
channels: {
"111": { enabled: true },
},
},
},
voice: {
autoJoin: [
{ guildId: "123", channelId: "222" },
{ guildId: "123", channelId: "general" },
],
},
});
expect(collected.channelIds).toEqual(["111", "222"]);
expect(collected.unresolvedChannels).toBe(1);
});
it.each([ChannelType.GuildVoice, ChannelType.GuildStageVoice])(
"requires voice permissions for voice channel audit targets of type %s",
async (channelType) => {
const cfg = {
channels: {
discord: {
enabled: true,
token: "t",
},
},
} as unknown as OpenClawConfig;
fetchChannelPermissionsDiscordMock.mockResolvedValueOnce({
channelId: "222",
permissions: ["ViewChannel", "SendMessages"],
channelType,
raw: "0",
isDm: false,
});
const audit = await auditDiscordChannelPermissionsWithFetcher({
cfg,
token: "t",
accountId: "default",
channelIds: ["222"],
timeoutMs: 1000,
fetchChannelPermissions: fetchChannelPermissionsDiscordMock,
});
expect(audit.ok).toBe(false);
expect(audit.channels[0]?.missing).toEqual(["Connect", "Speak", "ReadMessageHistory"]);
},
);
});

View File

@@ -0,0 +1,33 @@
// Discord plugin module implements audit behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { inspectDiscordAccount } from "./account-inspect.js";
import {
auditDiscordChannelPermissionsWithFetcher,
collectDiscordAuditChannelIdsForAccount,
type DiscordChannelPermissionsAudit,
} from "./audit-core.js";
import { fetchChannelPermissionsDiscord } from "./send.js";
export function collectDiscordAuditChannelIds(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) {
const account = inspectDiscordAccount({
cfg: params.cfg,
accountId: params.accountId,
});
return collectDiscordAuditChannelIdsForAccount(account.config);
}
export async function auditDiscordChannelPermissions(params: {
cfg: OpenClawConfig;
token: string;
accountId?: string | null;
channelIds: string[];
timeoutMs: number;
}): Promise<DiscordChannelPermissionsAudit> {
return await auditDiscordChannelPermissionsWithFetcher({
...params,
fetchChannelPermissions: fetchChannelPermissionsDiscord,
});
}

View File

@@ -0,0 +1,46 @@
// Discord tests cover channel actions.contract plugin behavior.
import { installChannelActionsContractSuite } from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe } from "vitest";
import { discordPlugin } from "../api.js";
describe("discord actions contract", () => {
installChannelActionsContractSuite({
plugin: discordPlugin,
cases: [
{
name: "describes configured Discord actions and capabilities",
cfg: {
channels: {
discord: {
token: "Bot token-main",
actions: {
polls: true,
reactions: true,
permissions: false,
messages: false,
pins: false,
threads: false,
search: false,
stickers: false,
memberInfo: false,
roleInfo: false,
emojiUploads: false,
stickerUploads: false,
channelInfo: false,
channels: false,
voiceStatus: false,
events: false,
roles: false,
moderation: false,
presence: false,
},
},
},
} as OpenClawConfig,
expectedActions: ["send", "poll", "react", "reactions", "emoji-list"],
expectedCapabilities: ["presentation"],
},
],
});
});

View File

@@ -0,0 +1,2 @@
// Discord plugin module implements channel actions behavior.
export { handleDiscordMessageAction } from "./actions/handle-action.js";

View File

@@ -0,0 +1,541 @@
// Discord tests cover channel actions plugin behavior.
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { withEnv } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it, vi } from "vitest";
const handleDiscordMessageActionMock = vi.hoisted(() =>
vi.fn(async () => ({ content: [], details: { ok: true } })),
);
const handleActionModule = await import("./actions/handle-action.js");
vi.spyOn(handleActionModule, "handleDiscordMessageAction").mockImplementation(
handleDiscordMessageActionMock,
);
const { discordMessageActions } = await import("./channel-actions.js");
describe("discordMessageActions", () => {
it("returns no tool actions when no token-sourced Discord accounts are enabled", () => {
withEnv({ DISCORD_BOT_TOKEN: undefined }, () => {
const discovery = discordMessageActions.describeMessageTool?.({
cfg: {
channels: {
discord: {
enabled: true,
},
},
} as OpenClawConfig,
});
expect(discovery).toEqual({
actions: [],
capabilities: [],
schema: null,
});
});
});
it("describes enabled Discord actions for token-backed accounts", () => {
const discovery = discordMessageActions.describeMessageTool?.({
cfg: {
channels: {
discord: {
token: "Bot token-main",
actions: {
polls: true,
reactions: true,
permissions: true,
channels: false,
roles: false,
},
},
},
} as OpenClawConfig,
});
expect(discovery?.capabilities).toEqual(["presentation"]);
expect(discovery?.schema).toBeUndefined();
expect(discovery?.actions).toEqual([
"send",
"poll",
"react",
"reactions",
"emoji-list",
"upload-file",
"read",
"edit",
"delete",
"pin",
"unpin",
"list-pins",
"permissions",
"thread-create",
"thread-list",
"thread-reply",
"search",
"sticker",
"member-info",
"role-info",
"emoji-upload",
"sticker-upload",
"channel-info",
"channel-list",
"voice-status",
"event-list",
"event-create",
]);
});
it("describes actions when the Discord token is an unresolved SecretRef", () => {
const discovery = discordMessageActions.describeMessageTool?.({
cfg: {
channels: {
discord: {
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
actions: {
polls: true,
reactions: true,
},
},
},
} as unknown as OpenClawConfig,
});
expect(discovery?.capabilities).toEqual(["presentation"]);
expect(discovery?.actions).toEqual([
"send",
"poll",
"react",
"reactions",
"emoji-list",
"upload-file",
"read",
"edit",
"delete",
"pin",
"unpin",
"list-pins",
"permissions",
"thread-create",
"thread-list",
"thread-reply",
"search",
"sticker",
"member-info",
"role-info",
"emoji-upload",
"sticker-upload",
"channel-info",
"channel-list",
"channel-create",
"channel-edit",
"channel-delete",
"channel-move",
"category-create",
"category-edit",
"category-delete",
"voice-status",
"event-list",
"event-create",
]);
});
it("requires trusted requester sender for privileged guild admin actions from tool contexts", () => {
for (const action of ["channel-delete", "timeout", "kick", "ban"] as const) {
expect(
discordMessageActions.requiresTrustedRequesterSender?.({
action,
toolContext: { currentChannelProvider: "discord" },
}),
).toBe(true);
expect(
discordMessageActions.requiresTrustedRequesterSender?.({
action,
}),
).toBe(false);
}
expect(
discordMessageActions.requiresTrustedRequesterSender?.({
action: "channel-delete",
toolContext: { currentChannelProvider: "telegram" },
}),
).toBe(true);
expect(
discordMessageActions.requiresTrustedRequesterSender?.({
action: "read",
toolContext: { currentChannelProvider: "discord" },
}),
).toBe(false);
});
it("describes scoped account actions when only the account token is an unresolved SecretRef", () => {
const discovery = discordMessageActions.describeMessageTool?.({
cfg: {
channels: {
discord: {
actions: {
polls: true,
reactions: false,
},
accounts: {
ops: {
token: { source: "file", provider: "filemain", id: "/DISCORD_BOT_TOKEN" },
actions: {
polls: false,
reactions: true,
},
},
},
},
},
} as unknown as OpenClawConfig,
accountId: "ops",
});
expect(discovery?.actions).toEqual([
"send",
"react",
"reactions",
"emoji-list",
"upload-file",
"read",
"edit",
"delete",
"pin",
"unpin",
"list-pins",
"permissions",
"thread-create",
"thread-list",
"thread-reply",
"search",
"sticker",
"member-info",
"role-info",
"emoji-upload",
"sticker-upload",
"channel-info",
"channel-list",
"channel-create",
"channel-edit",
"channel-delete",
"channel-move",
"category-create",
"category-edit",
"category-delete",
"voice-status",
"event-list",
"event-create",
]);
});
it("honors account-scoped action gates during discovery", () => {
const cfg = {
channels: {
discord: {
token: "Bot token-main",
actions: {
reactions: false,
polls: true,
},
accounts: {
work: {
token: "Bot token-work",
actions: {
reactions: true,
polls: false,
},
},
},
},
},
} as OpenClawConfig;
const defaultDiscovery = discordMessageActions.describeMessageTool?.({
cfg,
accountId: "default",
});
const workDiscovery = discordMessageActions.describeMessageTool?.({
cfg,
accountId: "work",
});
expect(defaultDiscovery?.actions).toEqual([
"send",
"poll",
"upload-file",
"read",
"edit",
"delete",
"pin",
"unpin",
"list-pins",
"permissions",
"thread-create",
"thread-list",
"thread-reply",
"search",
"sticker",
"member-info",
"role-info",
"emoji-upload",
"sticker-upload",
"channel-info",
"channel-list",
"channel-create",
"channel-edit",
"channel-delete",
"channel-move",
"category-create",
"category-edit",
"category-delete",
"voice-status",
"event-list",
"event-create",
]);
expect(workDiscovery?.actions).toEqual([
"send",
"react",
"reactions",
"emoji-list",
"upload-file",
"read",
"edit",
"delete",
"pin",
"unpin",
"list-pins",
"permissions",
"thread-create",
"thread-list",
"thread-reply",
"search",
"sticker",
"member-info",
"role-info",
"emoji-upload",
"sticker-upload",
"channel-info",
"channel-list",
"channel-create",
"channel-edit",
"channel-delete",
"channel-move",
"category-create",
"category-edit",
"category-delete",
"voice-status",
"event-list",
"event-create",
]);
});
it("hides upload-file when Discord message actions are disabled", () => {
const discovery = discordMessageActions.describeMessageTool?.({
cfg: {
channels: {
discord: {
token: "Bot token-main",
actions: {
messages: false,
},
},
},
} as OpenClawConfig,
});
expect(discovery?.actions).toContain("send");
expect(discovery?.actions).not.toContain("upload-file");
expect(discovery?.actions).not.toContain("read");
expect(discovery?.actions).not.toContain("edit");
expect(discovery?.actions).not.toContain("delete");
});
it("does not expose Discord-native message tool schema", () => {
const discovery = discordMessageActions.describeMessageTool?.({
cfg: {
channels: {
discord: {
token: "Bot token-main",
},
},
} as OpenClawConfig,
});
expect(discovery?.schema).toBeUndefined();
});
it.each(["read", "search", "edit", "delete", "react", "pin", "poll", "channel-info"])(
"routes %s actions through gateway execution mode",
(action) => {
expect(discordMessageActions.resolveExecutionMode?.({ action: action as never })).toBe(
"gateway",
);
},
);
it.each([
"send",
"upload-file",
"thread-reply",
"sticker",
"emoji-upload",
"sticker-upload",
"event-create",
])("keeps %s on local execution mode", (action) => {
expect(discordMessageActions.resolveExecutionMode?.({ action: action as never })).toBe("local");
});
it("extracts send targets for message and thread reply actions", () => {
expect(
discordMessageActions.extractToolSend?.({
args: { action: "sendMessage", to: "channel:123" },
}),
).toEqual({ to: "channel:123" });
expect(
discordMessageActions.extractToolSend?.({
args: { action: "threadReply", channelId: "987" },
}),
).toEqual({ to: "channel:987" });
expect(
discordMessageActions.extractToolSend?.({
args: { action: "threadReply", channelId: " " },
}),
).toBeNull();
});
it("prepares Discord send payload channel data for durable core delivery", async () => {
const prepared = await discordMessageActions.prepareSendPayload?.({
ctx: {
channel: "discord",
action: "send",
cfg: {} as OpenClawConfig,
params: {
components: {
text: "Choose",
blocks: [
{
type: "actions",
buttons: [{ label: "Yes", callbackData: "yes" }],
},
],
},
embeds: undefined,
filename: "photo.png",
},
},
to: "channel:123",
payload: { text: "hello", mediaUrl: "/tmp/photo.png" },
});
expect(prepared).toEqual({
text: "hello",
mediaUrl: "/tmp/photo.png",
channelData: {
discord: {
components: {
text: "Choose",
blocks: [
{
type: "actions",
buttons: [{ label: "Yes", callbackData: "yes" }],
},
],
},
filename: "photo.png",
},
},
});
});
it("prepares inbound event delivery metadata for durable core sends", async () => {
const prepared = await discordMessageActions.prepareSendPayload?.({
ctx: {
channel: "discord",
action: "send",
cfg: {} as OpenClawConfig,
params: {},
sessionKey: "agent:main:discord:channel:c1",
inboundEventKind: "room_event",
},
to: "channel:123",
payload: { text: "hello" },
});
expect(prepared).toEqual({
text: "hello",
channelData: {
discord: {
__openclawInboundEventDelivery: {
sessionKey: "agent:main:discord:channel:c1",
inboundEventKind: "room_event",
},
},
},
});
});
it("keeps non-serializable Discord component sends on the legacy action path", async () => {
const prepared = await discordMessageActions.prepareSendPayload?.({
ctx: {
channel: "discord",
action: "send",
cfg: {} as OpenClawConfig,
params: {
components: () => [],
},
},
to: "channel:123",
payload: { text: "hello" },
});
expect(prepared).toBeNull();
});
it("delegates action handling to the Discord action handler", async () => {
const cfg = {
channels: {
discord: {
token: "Bot token-main",
},
},
} as OpenClawConfig;
const toolContext: ChannelMessageActionContext["toolContext"] = {
currentChannelProvider: "discord",
};
const mediaReadFile = vi.fn(async () => Buffer.from("image"));
const mediaAccess: NonNullable<ChannelMessageActionContext["mediaAccess"]> = {
localRoots: ["/tmp/media"],
readFile: mediaReadFile,
};
const mediaLocalRoots = ["/tmp/media"];
await discordMessageActions.handleAction?.({
channel: "discord",
action: "send",
params: { to: "channel:123", message: "hello" },
cfg,
accountId: "ops",
requesterSenderId: "user-1",
senderIsOwner: true,
toolContext,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
});
expect(handleDiscordMessageActionMock).toHaveBeenCalledWith({
action: "send",
params: { to: "channel:123", message: "hello" },
cfg,
accountId: "ops",
requesterSenderId: "user-1",
senderIsOwner: true,
toolContext,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
});
});
});

View File

@@ -0,0 +1,272 @@
// Discord plugin module implements channel actions behavior.
import { createUnionActionGate } from "openclaw/plugin-sdk/channel-actions";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-contract";
import type { DiscordActionConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import { inspectDiscordAccount } from "./account-inspect.js";
import { createDiscordActionGate, listDiscordAccountIds } from "./accounts.js";
import { readDiscordComponentSpec } from "./components.js";
import { withDiscordInboundEventDeliveryMetadata } from "./inbound-event-delivery.js";
import { isTrustedRequesterGuildAdminAction } from "./trusted-requester-actions.js";
const localExecutionActions = new Set<ChannelMessageActionName>([
"send",
"upload-file",
"thread-reply",
"sticker",
"emoji-upload",
"sticker-upload",
"event-create",
]);
function resolveDiscordActionExecutionMode({ action }: { action: ChannelMessageActionName }) {
return localExecutionActions.has(action) ? "local" : "gateway";
}
const loadDiscordChannelActionsRuntime = createLazyRuntimeModule(
() => import("./channel-actions.runtime.js"),
);
function listDiscoverableDiscordAccounts(cfg: OpenClawConfig) {
return listDiscordAccountIds(cfg)
.map((accountId) => inspectDiscordAccount({ cfg, accountId }))
.filter((account) => account.enabled && account.configured);
}
function resolveDiscordActionDiscovery(cfg: OpenClawConfig) {
const accounts = listDiscoverableDiscordAccounts(cfg);
if (accounts.length === 0) {
return null;
}
const unionGate = createUnionActionGate(accounts, (account) =>
createDiscordActionGate({
cfg,
accountId: account.accountId,
}),
);
return {
isEnabled: (key: keyof DiscordActionConfig, defaultValue = true) =>
unionGate(key, defaultValue),
};
}
function resolveScopedDiscordActionDiscovery(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) {
if (!params.accountId) {
return resolveDiscordActionDiscovery(params.cfg);
}
const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.enabled || !account.configured) {
return null;
}
const gate = createDiscordActionGate({
cfg: params.cfg,
accountId: account.accountId,
});
return {
isEnabled: (key: keyof DiscordActionConfig, defaultValue = true) => gate(key, defaultValue),
};
}
function describeDiscordMessageTool({
cfg,
accountId,
}: Parameters<
NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>
>[0]): ChannelMessageToolDiscovery {
const discovery = resolveScopedDiscordActionDiscovery({ cfg, accountId });
if (!discovery) {
return {
actions: [],
capabilities: [],
schema: null,
};
}
const actions = new Set<ChannelMessageActionName>(["send"]);
if (discovery.isEnabled("polls")) {
actions.add("poll");
}
if (discovery.isEnabled("reactions")) {
actions.add("react");
actions.add("reactions");
actions.add("emoji-list");
}
if (discovery.isEnabled("messages")) {
actions.add("upload-file");
actions.add("read");
actions.add("edit");
actions.add("delete");
}
if (discovery.isEnabled("pins")) {
actions.add("pin");
actions.add("unpin");
actions.add("list-pins");
}
if (discovery.isEnabled("permissions")) {
actions.add("permissions");
}
if (discovery.isEnabled("threads")) {
actions.add("thread-create");
actions.add("thread-list");
actions.add("thread-reply");
}
if (discovery.isEnabled("search")) {
actions.add("search");
}
if (discovery.isEnabled("stickers")) {
actions.add("sticker");
}
if (discovery.isEnabled("memberInfo")) {
actions.add("member-info");
}
if (discovery.isEnabled("roleInfo")) {
actions.add("role-info");
}
if (discovery.isEnabled("emojiUploads")) {
actions.add("emoji-upload");
}
if (discovery.isEnabled("stickerUploads")) {
actions.add("sticker-upload");
}
if (discovery.isEnabled("roles", false)) {
actions.add("role-add");
actions.add("role-remove");
}
if (discovery.isEnabled("channelInfo")) {
actions.add("channel-info");
actions.add("channel-list");
}
if (discovery.isEnabled("channels")) {
actions.add("channel-create");
actions.add("channel-edit");
actions.add("channel-delete");
actions.add("channel-move");
actions.add("category-create");
actions.add("category-edit");
actions.add("category-delete");
}
if (discovery.isEnabled("voiceStatus")) {
actions.add("voice-status");
}
if (discovery.isEnabled("events")) {
actions.add("event-list");
actions.add("event-create");
}
if (discovery.isEnabled("moderation", false)) {
actions.add("timeout");
actions.add("kick");
actions.add("ban");
}
if (discovery.isEnabled("presence", false)) {
actions.add("set-presence");
}
return {
actions: Array.from(actions),
capabilities: ["presentation"],
};
}
export const discordMessageActions: ChannelMessageActionAdapter = {
// Credential-only Discord actions run in the gateway when one is available.
// Send/file-style actions stay local because core owns their thread, media,
// component, and client-local payload semantics.
resolveExecutionMode: resolveDiscordActionExecutionMode,
describeMessageTool: describeDiscordMessageTool,
requiresTrustedRequesterSender: ({ action, toolContext }) =>
Boolean(toolContext) && isTrustedRequesterGuildAdminAction(action),
extractToolSend: ({ args }) => {
const action = normalizeOptionalString(args.action) ?? "";
if (action === "sendMessage") {
return extractToolSend(args, "sendMessage");
}
if (action === "threadReply") {
const channelId = normalizeOptionalString(args.channelId) ?? "";
return channelId ? { to: `channel:${channelId}` } : null;
}
return null;
},
prepareSendPayload: ({ ctx, payload }) => {
if (ctx.action !== "send") {
return null;
}
const payloadWithDeliveryMetadata = withDiscordInboundEventDeliveryMetadata(payload, {
sessionKey: ctx.sessionKey,
inboundEventKind: ctx.inboundEventKind,
});
const rawComponents = ctx.params.components;
if (typeof rawComponents === "function") {
return null;
}
const componentSpec =
rawComponents && typeof rawComponents === "object" && !Array.isArray(rawComponents)
? readDiscordComponentSpec(rawComponents)
: undefined;
const nativeComponents = Array.isArray(rawComponents) ? rawComponents : undefined;
const embeds = Array.isArray(ctx.params.embeds) ? ctx.params.embeds : undefined;
if ((componentSpec || nativeComponents) && embeds?.length) {
return null;
}
const filename = normalizeOptionalString(ctx.params.filename);
if (!componentSpec && !nativeComponents && !embeds?.length && !filename) {
return payloadWithDeliveryMetadata;
}
const discordData =
payloadWithDeliveryMetadata.channelData?.discord &&
typeof payloadWithDeliveryMetadata.channelData.discord === "object" &&
!Array.isArray(payloadWithDeliveryMetadata.channelData.discord)
? (payloadWithDeliveryMetadata.channelData.discord as Record<string, unknown>)
: {};
return {
...payloadWithDeliveryMetadata,
channelData: {
...payloadWithDeliveryMetadata.channelData,
discord: {
...discordData,
...(componentSpec ? { components: componentSpec } : {}),
...(nativeComponents ? { components: nativeComponents } : {}),
...(embeds?.length ? { embeds } : {}),
...(filename ? { filename } : {}),
},
},
};
},
handleAction: async ({
action,
params,
cfg,
accountId,
requesterSenderId,
senderIsOwner,
toolContext,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
sessionKey,
inboundEventKind,
}) => {
return await (
await loadDiscordChannelActionsRuntime()
).handleDiscordMessageAction({
action,
params,
cfg,
accountId,
requesterSenderId,
senderIsOwner,
toolContext,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
...(sessionKey ? { sessionKey } : {}),
...(inboundEventKind ? { inboundEventKind } : {}),
});
},
};

View File

@@ -0,0 +1,30 @@
// Discord API module exposes the plugin public contract.
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
export {
buildTokenChannelStatusSummary,
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
} from "openclaw/plugin-sdk/channel-status";
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
const DISCORD_CHANNEL_META = {
id: "discord",
label: "Discord",
selectionLabel: "Discord (Bot API)",
detailLabel: "Discord Bot",
docsPath: "/channels/discord",
docsLabel: "discord",
blurb: "very well supported right now.",
systemImage: "bubble.left.and.bubble.right",
markdownCapable: true,
preferSessionLookupForAnnounceTarget: true,
} as const;
export function getChatChannelMeta(id: string) {
if (id !== DISCORD_CHANNEL_META.id) {
throw new Error(`Unsupported Discord channel meta lookup: ${id}`);
}
return DISCORD_CHANNEL_META;
}

View File

@@ -0,0 +1,86 @@
// Discord tests cover channel.conversation plugin behavior.
import { describe, expect, it } from "vitest";
import {
matchDiscordAcpConversation,
resolveDiscordInboundConversation,
} from "./channel.conversation.js";
describe("Discord conversation identity", () => {
it("uses raw thread ids with parent channel ids for inbound thread conversations", () => {
expect(
resolveDiscordInboundConversation({
from: "discord:user:570610468352294922",
to: "channel:1510164477642014740",
conversationId: "channel:1510164477642014740",
threadId: "1510164477642014740",
threadParentId: "1510164477642014999",
isGroup: true,
}),
).toEqual({
conversationId: "1510164477642014740",
parentConversationId: "channel:1510164477642014999",
});
});
it("falls back to the current target when inbound thread parent ids are unavailable", () => {
expect(
resolveDiscordInboundConversation({
from: "discord:user:570610468352294922",
to: "channel:1510164477642014740",
conversationId: "channel:1510164477642014740",
threadId: "1510164477642014740",
isGroup: true,
}),
).toEqual({
conversationId: "1510164477642014740",
parentConversationId: "channel:1510164477642014740",
});
});
it("keeps top-level channel conversations prefixed", () => {
expect(
resolveDiscordInboundConversation({
from: "discord:user:570610468352294922",
to: "channel:1510164477642014740",
conversationId: "channel:1510164477642014740",
isGroup: true,
}),
).toEqual({ conversationId: "channel:1510164477642014740" });
});
it("matches configured parent channel bindings for inbound thread conversations", () => {
const resolved = resolveDiscordInboundConversation({
from: "discord:user:570610468352294922",
to: "channel:1510164477642014740",
conversationId: "channel:1510164477642014740",
threadId: "1510164477642014740",
threadParentId: "1510164477642014999",
isGroup: true,
});
expect(
resolved &&
matchDiscordAcpConversation({
bindingConversationId: "channel:1510164477642014999",
conversationId: resolved.conversationId,
parentConversationId: resolved.parentConversationId,
}),
).toEqual({
conversationId: "channel:1510164477642014999",
matchPriority: 1,
});
});
it("prefers exact thread bindings over parent channel fallback", () => {
expect(
matchDiscordAcpConversation({
bindingConversationId: "1510164477642014740",
conversationId: "1510164477642014740",
parentConversationId: "channel:1510164477642014999",
}),
).toEqual({
conversationId: "1510164477642014740",
matchPriority: 2,
});
});
});

View File

@@ -0,0 +1,176 @@
// Discord plugin module implements channel.conversation behavior.
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/string-coerce-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 | number;
threadParentId?: string;
parentSessionKey?: string;
from?: string;
chatType?: string;
originatingTo?: string;
commandTo?: string;
fallbackTo?: string;
}) {
const threadConversation = resolveDiscordThreadConversationRef(params);
if (threadConversation) {
return threadConversation;
}
const conversationId = resolveDiscordCurrentConversationIdentity({
from: params.from,
chatType: params.chatType,
originatingTo: params.originatingTo,
commandTo: params.commandTo,
fallbackTo: params.fallbackTo,
});
return conversationId ? { conversationId } : null;
}
export function resolveDiscordThreadConversationRef(params: {
threadId?: string | number | null;
threadParentId?: string | number | null;
parentSessionKey?: string | null;
originatingTo?: string;
to?: string;
commandTo?: string;
fallbackTo?: string;
conversationId?: string;
}) {
const threadId = normalizeOptionalStringifiedId(params.threadId);
if (!threadId) {
return null;
}
const targets = [
params.originatingTo ?? params.to,
params.commandTo,
params.fallbackTo ?? params.conversationId,
];
const parentConversationId =
normalizeDiscordMessagingTarget(normalizeOptionalStringifiedId(params.threadParentId) ?? "") ||
parseDiscordParentChannelFromSessionKey(params.parentSessionKey) ||
resolveDiscordConversationIdFromTargets(targets);
return {
conversationId: threadId,
...(parentConversationId && parentConversationId !== threadId ? { parentConversationId } : {}),
};
}
export function resolveDiscordInboundConversation(params: {
from?: string;
to?: string;
conversationId?: string;
threadId?: string | number;
threadParentId?: string | number;
isGroup: boolean;
}) {
const threadConversation = resolveDiscordThreadConversationRef({
to: params.to,
conversationId: params.conversationId,
threadId: params.threadId,
threadParentId: params.threadParentId,
});
if (threadConversation) {
return threadConversation;
}
const conversationId = resolveDiscordCurrentConversationIdentity({
from: params.from,
chatType: params.isGroup ? "group" : "direct",
originatingTo: params.to,
fallbackTo: params.conversationId,
});
return conversationId ? { conversationId } : null;
}

View File

@@ -0,0 +1,32 @@
// Discord plugin module implements channel.loaders behavior.
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
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 const loadDiscordTargetResolverModule = createLazyRuntimeModule(
() => import("./target-resolver.js"),
);
export const loadDiscordProviderRuntime = createLazyRuntimeModule(
() => import("./monitor/provider.runtime.js"),
);
export const loadDiscordProbeRuntime = createLazyRuntimeModule(() => import("./probe.runtime.js"));
export const loadDiscordAuditModule = createLazyRuntimeModule(() => import("./audit.js"));
export const loadDiscordSendModule = createLazyRuntimeModule(() => import("./send.js"));
export const loadDiscordDirectoryLiveModule = createLazyRuntimeModule(
() => import("./directory-live.js"),
);

View File

@@ -0,0 +1,269 @@
// Discord tests cover channel.message adapter plugin behavior.
import {
verifyChannelMessageAdapterCapabilityProofs,
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveFinalizerProofs,
} from "openclaw/plugin-sdk/channel-outbound";
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
createDiscordOutboundHoisted,
installDiscordOutboundModuleSpies,
resetDiscordOutboundMocks,
} from "./outbound-adapter.test-harness.js";
const hoisted = createDiscordOutboundHoisted();
await installDiscordOutboundModuleSpies(hoisted);
let discordPlugin: typeof import("./channel.js").discordPlugin;
beforeAll(async () => {
({ discordPlugin } = await import("./channel.js"));
});
type DiscordMessageAdapter = NonNullable<typeof discordPlugin.message>;
type DiscordMessageSender = NonNullable<DiscordMessageAdapter["send"]>;
function requireDiscordMessageAdapter(): DiscordMessageAdapter {
const adapter = discordPlugin.message;
if (!adapter) {
throw new Error("Expected discord plugin to expose a channel message adapter");
}
return adapter;
}
function requireTextSender(
adapter: DiscordMessageAdapter,
): NonNullable<DiscordMessageSender["text"]> {
const text = adapter.send?.text;
if (!text) {
throw new Error("Expected discord message adapter text sender");
}
return text;
}
function requireMediaSender(
adapter: DiscordMessageAdapter,
): NonNullable<DiscordMessageSender["media"]> {
const media = adapter.send?.media;
if (!media) {
throw new Error("Expected discord message adapter media sender");
}
return media;
}
function requirePayloadSender(
adapter: DiscordMessageAdapter,
): NonNullable<DiscordMessageSender["payload"]> {
const payload = adapter.send?.payload;
if (!payload) {
throw new Error("Expected discord message adapter payload sender");
}
return payload;
}
function requirePollSender(
adapter: DiscordMessageAdapter,
): NonNullable<DiscordMessageSender["poll"]> {
const poll = adapter.send?.poll;
if (!poll) {
throw new Error("Expected discord message adapter poll sender");
}
return poll;
}
describe("discord channel message adapter", () => {
beforeEach(() => {
resetDiscordOutboundMocks(hoisted);
});
it("backs declared durable-final capabilities with outbound send proofs", async () => {
const adapter = requireDiscordMessageAdapter();
const sendText = requireTextSender(adapter);
const sendMedia = requireMediaSender(adapter);
const sendPayload = requirePayloadSender(adapter);
const sendPoll = requirePollSender(adapter);
const proveText = async () => {
resetDiscordOutboundMocks(hoisted);
const result = await sendText({
cfg: {},
to: "channel:123456",
text: "hello",
accountId: "default",
});
expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith("channel:123456", "hello", {
verbose: false,
replyTo: undefined,
accountId: "default",
silent: undefined,
cfg: {},
textLimit: undefined,
maxLinesPerMessage: undefined,
tableMode: undefined,
chunkMode: undefined,
});
expect(result.receipt.platformMessageIds).toEqual(["msg-1"]);
expect(result.receipt.parts[0]?.kind).toBe("text");
};
const proveMedia = async () => {
resetDiscordOutboundMocks(hoisted);
const result = await sendMedia({
cfg: {},
to: "channel:123456",
text: "caption",
mediaUrl: "https://example.com/a.png",
accountId: "default",
});
expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith("channel:123456", "caption", {
verbose: false,
mediaUrl: "https://example.com/a.png",
mediaAccess: undefined,
mediaLocalRoots: undefined,
mediaReadFile: undefined,
replyTo: undefined,
accountId: "default",
silent: undefined,
cfg: {},
textLimit: undefined,
maxLinesPerMessage: undefined,
tableMode: undefined,
chunkMode: undefined,
});
expect(result.receipt.parts[0]?.kind).toBe("media");
};
const provePayload = async () => {
resetDiscordOutboundMocks(hoisted);
const result = await sendPayload({
cfg: {},
to: "channel:123456",
text: "payload",
payload: { text: "payload" },
accountId: "default",
});
expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith(
"channel:123456",
"payload",
expect.objectContaining({
verbose: false,
replyTo: undefined,
accountId: "default",
silent: undefined,
cfg: {},
textLimit: undefined,
maxLinesPerMessage: undefined,
tableMode: undefined,
chunkMode: undefined,
onDeliveryResult: expect.any(Function),
}),
);
expect(result.receipt.platformMessageIds).toEqual(["msg-1"]);
};
const provePoll = async () => {
resetDiscordOutboundMocks(hoisted);
const result = await sendPoll({
cfg: {},
to: "channel:123456",
poll: { question: "Ship?", options: ["Yes", "No"] },
accountId: "default",
silent: true,
});
expect(hoisted.sendPollDiscordMock).toHaveBeenLastCalledWith(
"channel:123456",
{ question: "Ship?", options: ["Yes", "No"] },
{
accountId: "default",
silent: true,
cfg: {},
},
);
expect(result.receipt.parts[0]?.kind).toBe("poll");
};
const proveReplyThreadSilent = async () => {
resetDiscordOutboundMocks(hoisted);
const result = await sendText({
cfg: {},
to: "channel:parent-1",
text: "threaded",
accountId: "default",
replyToId: "reply-1",
threadId: "thread-1",
silent: true,
});
expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith(
"channel:thread-1",
"threaded",
{
verbose: false,
accountId: "default",
replyTo: "reply-1",
silent: true,
cfg: {},
textLimit: undefined,
maxLinesPerMessage: undefined,
tableMode: undefined,
chunkMode: undefined,
},
);
expect(result.receipt.threadId).toBe("thread-1");
expect(result.receipt.replyToId).toBe("reply-1");
};
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "discordMessageAdapter",
adapter,
proofs: {
text: proveText,
media: proveMedia,
poll: provePoll,
payload: provePayload,
silent: proveReplyThreadSilent,
replyTo: proveReplyThreadSilent,
thread: proveReplyThreadSilent,
messageSendingHooks: () => {
expect(sendText).toBeTypeOf("function");
},
},
});
});
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
const adapter = requireDiscordMessageAdapter();
const sendText = requireTextSender(adapter);
await verifyChannelMessageLiveCapabilityAdapterProofs({
adapterName: "discordMessageAdapter",
adapter,
proofs: {
draftPreview: () => {
expect(adapter.live?.finalizer?.capabilities?.discardPending).toBe(true);
},
previewFinalization: () => {
expect(adapter.live?.finalizer?.capabilities?.finalEdit).toBe(true);
},
progressUpdates: () => {
expect(adapter.live?.capabilities?.draftPreview).toBe(true);
},
},
});
await verifyChannelMessageLiveFinalizerProofs({
adapterName: "discordMessageAdapter",
adapter,
proofs: {
finalEdit: () => {
expect(adapter.live?.capabilities?.previewFinalization).toBe(true);
},
normalFallback: () => {
expect(sendText).toBeTypeOf("function");
},
discardPending: () => {
expect(adapter.live?.capabilities?.draftPreview).toBe(true);
},
},
});
});
});

View File

@@ -0,0 +1,2 @@
// Discord plugin module implements channel behavior.
export { discordSetupWizard } from "./setup-surface.js";

View File

@@ -0,0 +1,13 @@
// Discord plugin module implements channel.setup behavior.
import type { ResolvedDiscordAccount } from "./accounts.js";
import type { ChannelPlugin } from "./channel-api.js";
import { discordSetupWizard } from "./channel.runtime.js";
import { discordSetupAdapter } from "./setup-adapter.js";
import { createDiscordPluginBase } from "./shared.js";
export const discordSetupPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
...createDiscordPluginBase({
setupWizard: discordSetupWizard,
setup: discordSetupAdapter,
}),
};

View File

@@ -0,0 +1,853 @@
// Discord tests cover channel plugin behavior.
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { ChannelType } from "discord-api-types/v10";
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedDiscordAccount } from "./accounts.js";
import * as directoryLive from "./directory-live.js";
import type { OpenClawConfig } from "./runtime-api.js";
import * as sendModule from "./send.js";
import { createDiscordSendReceipt } from "./send.receipt.js";
import { EMPTY_DISCORD_TEST_CONFIG } from "./test-support/config.js";
let discordPlugin: typeof import("./channel.js").discordPlugin;
let setDiscordRuntime: typeof import("./runtime.js").setDiscordRuntime;
const probeDiscordMock = vi.hoisted(() => vi.fn());
const monitorDiscordProviderMock = vi.hoisted(() => vi.fn());
const auditDiscordChannelPermissionsMock = vi.hoisted(() => vi.fn());
const collectDiscordAuditChannelIdsMock = vi.hoisted(() =>
vi.fn(() => ({ channelIds: [], unresolvedChannels: 0 })),
);
const sleepWithAbortMock = vi.hoisted(() => vi.fn(async () => undefined));
function discordTestSendResult(messageId: string, channelId = "channel:thread-123") {
return {
messageId,
channelId,
receipt: createDiscordSendReceipt({ platformMessageIds: [messageId], channelId, kind: "text" }),
};
}
vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
"openclaw/plugin-sdk/runtime-env",
);
return {
...actual,
sleepWithAbort: sleepWithAbortMock,
};
});
vi.mock("./probe.js", () => {
return {
probeDiscord: probeDiscordMock,
};
});
vi.mock("./monitor/provider.runtime.js", () => {
return {
monitorDiscordProvider: monitorDiscordProviderMock,
};
});
vi.mock("./audit.js", () => {
return {
auditDiscordChannelPermissions: auditDiscordChannelPermissionsMock,
collectDiscordAuditChannelIds: collectDiscordAuditChannelIdsMock,
};
});
function createCfg(): OpenClawConfig {
return {
channels: {
discord: {
enabled: true,
token: "discord-token",
},
},
} as OpenClawConfig;
}
function resolveAccount(cfg: OpenClawConfig, accountId = "default"): ResolvedDiscordAccount {
return discordPlugin.config.resolveAccount(cfg, accountId);
}
function startDiscordAccount(cfg: OpenClawConfig, accountId = "default") {
return discordPlugin.gateway!.startAccount!(
createStartAccountContext({
account: resolveAccount(cfg, accountId),
cfg,
}),
);
}
function installDiscordRuntime(discord: Record<string, unknown>) {
setDiscordRuntime({
channel: {
discord,
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime);
}
async function expectStaleProbeMetadataCleared(statusPatches: Array<Record<string, unknown>>) {
await vi.waitFor(() =>
expect(
statusPatches
.filter(
(patch) =>
"bot" in patch &&
"application" in patch &&
patch.bot === undefined &&
patch.application === undefined,
)
.map((patch) => ({
bot: patch.bot,
application: patch.application,
})),
).toEqual([{ bot: undefined, application: undefined }]),
);
}
type MockWithCalls = {
mock: { calls: unknown[][] };
};
function objectArgAt(
mock: MockWithCalls,
callIndex: number,
argIndex: number,
): Record<string, unknown> {
const value = mock.mock.calls[callIndex]?.[argIndex];
if (value === undefined || value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected call ${callIndex} argument ${argIndex} to be an object`);
}
return value as Record<string, unknown>;
}
function argAt(mock: MockWithCalls, callIndex: number, argIndex: number): unknown {
const call = mock.mock.calls[callIndex];
if (!call || !(argIndex in call)) {
throw new Error(`expected call ${callIndex} argument ${argIndex}`);
}
return call[argIndex];
}
function recordField(value: unknown, field: string): Record<string, unknown> {
if (value === undefined || value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${field} to be an object`);
}
return value as Record<string, unknown>;
}
afterEach(() => {
probeDiscordMock.mockReset();
monitorDiscordProviderMock.mockReset();
auditDiscordChannelPermissionsMock.mockReset();
collectDiscordAuditChannelIdsMock.mockReset();
collectDiscordAuditChannelIdsMock.mockReturnValue({
channelIds: [],
unresolvedChannels: 0,
});
sleepWithAbortMock.mockReset();
sleepWithAbortMock.mockResolvedValue(undefined);
});
beforeEach(async () => {
vi.useRealTimers();
installDiscordRuntime({});
});
beforeAll(async () => {
({ discordPlugin } = await import("./channel.js"));
({ setDiscordRuntime } = await import("./runtime.js"));
});
describe("discordPlugin outbound", () => {
it("avoids local require calls for bundled-only sibling modules", async () => {
const source = await readFile(
resolve(process.cwd(), "extensions/discord/src/channel.ts"),
"utf8",
);
expect(source).not.toContain('require("./ui.js")');
expect(source).not.toContain('require("./channel-actions.js")');
});
it("prefers final assistant text for text-only cron announce delivery", () => {
expect(discordPlugin.outbound?.preferFinalAssistantVisibleText).toBe(true);
});
it("routes Discord message actions through the gateway", () => {
expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "read" as never })).toBe(
"gateway",
);
expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "search" as never })).toBe(
"gateway",
);
expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "send" as never })).toBe(
"local",
);
expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "upload-file" as never })).toBe(
"local",
);
expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "thread-reply" as never })).toBe(
"local",
);
expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "channel-info" as never })).toBe(
"gateway",
);
});
it("adds Discord mention formatting to agent prompt hints", () => {
const hints = discordPlugin.agentPrompt?.messageToolHints?.({} as never) ?? [];
expect(hints).toContain(
"- Discord mentions: use canonical outbound syntax: users `<@USER_ID>`, channels `<#CHANNEL_ID>`, and roles `<@&ROLE_ID>`. Plain `@name` text only pings when a configured `mentionAliases` entry rewrites it; do not use the legacy `<@!USER_ID>` nickname form.",
);
});
it("preserves normalized Discord targets for delivery routing", () => {
const messaging = discordPlugin.messaging;
if (!messaging?.normalizeTarget || !messaging.inferTargetChatType) {
throw new Error("Expected discordPlugin.messaging target helpers to be defined");
}
expect(messaging.normalizeTarget("user:123")).toBe("user:123");
expect(messaging.inferTargetChatType({ to: "user:123" })).toBe("direct");
expect(messaging.normalizeTarget("<@!456>")).toBe("user:456");
expect(messaging.inferTargetChatType({ to: "<@!456>" })).toBe("direct");
expect(messaging.normalizeTarget("channel:789")).toBe("channel:789");
expect(messaging.inferTargetChatType({ to: "channel:789" })).toBe("channel");
expect(messaging.normalizeTarget("1470130713209602050")).toBe("channel:1470130713209602050");
expect(messaging.inferTargetChatType({ to: "1470130713209602050" })).toBe("channel");
});
it("resolves Discord usernames through the messaging target resolver", async () => {
vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive").mockResolvedValueOnce([
{ kind: "user", id: "user:999", name: "Jane" } as const,
]);
const resolveTarget = discordPlugin.messaging?.targetResolver?.resolveTarget;
if (!resolveTarget) {
throw new Error(
"Expected discordPlugin.messaging.targetResolver.resolveTarget to be defined",
);
}
await expect(
resolveTarget({
cfg: createCfg(),
accountId: "default",
input: "jane",
normalized: "channel:jane",
preferredKind: "user",
}),
).resolves.toEqual({
to: "user:999",
kind: "user",
display: "jane",
source: "directory",
});
});
it("honors per-account replyToMode overrides", () => {
const resolveReplyToMode = discordPlugin.threading?.resolveReplyToMode;
if (!resolveReplyToMode) {
throw new Error("Expected discordPlugin.threading.resolveReplyToMode to be defined");
}
const cfg = {
channels: {
discord: {
replyToMode: "all",
token: "discord-token",
accounts: {
work: {
token: "discord-token-work",
replyToMode: "first",
},
},
},
},
} as OpenClawConfig;
expect(resolveReplyToMode({ cfg, accountId: "work" })).toBe("first");
expect(resolveReplyToMode({ cfg, accountId: "default" })).toBe("all");
});
it("inherits Discord gateway READY timeout settings per account", () => {
const cfg = {
channels: {
discord: {
token: "discord-token",
gatewayReadyTimeoutMs: 90_000,
gatewayRuntimeReadyTimeoutMs: 120_000,
accounts: {
work: {
token: "discord-token-work",
gatewayReadyTimeoutMs: 60_000,
},
},
},
},
} as OpenClawConfig;
expect(resolveAccount(cfg).config.gatewayReadyTimeoutMs).toBe(90_000);
expect(resolveAccount(cfg).config.gatewayRuntimeReadyTimeoutMs).toBe(120_000);
expect(resolveAccount(cfg, "work").config.gatewayReadyTimeoutMs).toBe(60_000);
expect(resolveAccount(cfg, "work").config.gatewayRuntimeReadyTimeoutMs).toBe(120_000);
});
it("forwards full media send context to sendMessageDiscord", async () => {
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
const mediaReadFile = vi.fn(async () => Buffer.from("media"));
const result = await discordPlugin.outbound!.sendMedia!({
cfg: EMPTY_DISCORD_TEST_CONFIG,
to: "channel:123",
text: "hi",
mediaUrl: "/tmp/image.png",
mediaLocalRoots: ["/tmp/agent-root"],
mediaReadFile,
accountId: "work",
threadId: "thread-123",
replyToId: "reply-123",
deps: {
discord: sendMessageDiscord,
},
});
expect(argAt(sendMessageDiscord, 0, 0)).toBe("channel:thread-123");
expect(argAt(sendMessageDiscord, 0, 1)).toBe("hi");
const sendOptions = objectArgAt(sendMessageDiscord, 0, 2);
expect(sendOptions.mediaUrl).toBe("/tmp/image.png");
expect(sendOptions.mediaLocalRoots).toEqual(["/tmp/agent-root"]);
expect(sendOptions.mediaReadFile).toBe(mediaReadFile);
expect(sendOptions.replyTo).toBe("reply-123");
expect(result.channel).toBe("discord");
expect(result.messageId).toBe("m1");
});
it("splits text and video into separate sends for attached outbound delivery", async () => {
const sendMessageDiscord = vi
.fn()
.mockResolvedValueOnce(discordTestSendResult("text-1"))
.mockResolvedValueOnce(discordTestSendResult("video-1"));
const result = await discordPlugin.outbound!.sendMedia!({
cfg: EMPTY_DISCORD_TEST_CONFIG,
to: "channel:123",
text: "done - tiny cyber-lobster clip incoming",
mediaUrl: "/tmp/molty.mp4",
accountId: "work",
replyToId: "reply-123",
threadId: "thread-123",
deps: {
discord: sendMessageDiscord,
},
});
expect(sendMessageDiscord).toHaveBeenCalledTimes(2);
expect(argAt(sendMessageDiscord, 0, 0)).toBe("channel:thread-123");
expect(argAt(sendMessageDiscord, 0, 1)).toBe("done - tiny cyber-lobster clip incoming");
expect(objectArgAt(sendMessageDiscord, 0, 2).replyTo).toBe("reply-123");
expect(argAt(sendMessageDiscord, 1, 0)).toBe("channel:thread-123");
expect(argAt(sendMessageDiscord, 1, 1)).toBe("");
expect(objectArgAt(sendMessageDiscord, 1, 2).mediaUrl).toBe("/tmp/molty.mp4");
expect(result.channel).toBe("discord");
expect(result.messageId).toBe("video-1");
});
it("threads poll sends through the thread target", async () => {
const sendPollDiscord = vi.fn(async () => discordTestSendResult("poll-1"));
const sendPollSpy = vi.spyOn(sendModule, "sendPollDiscord").mockImplementation(sendPollDiscord);
try {
const result = await discordPlugin.outbound!.sendPoll!({
cfg: EMPTY_DISCORD_TEST_CONFIG,
to: "channel:123",
poll: {
question: "Best shell?",
options: ["molty", "molter"],
},
accountId: "work",
threadId: "thread-123",
});
expect(argAt(sendPollDiscord, 0, 0)).toBe("channel:thread-123");
expect(argAt(sendPollDiscord, 0, 1)).toEqual({
question: "Best shell?",
options: ["molty", "molter"],
});
expect(objectArgAt(sendPollDiscord, 0, 2).accountId).toBe("work");
const pollResult = result as { channel?: string; messageId?: string };
expect(pollResult.channel).toBe("discord");
expect(pollResult.messageId).toBe("poll-1");
} finally {
sendPollSpy.mockRestore();
}
});
it("forwards heartbeat typing through the run config and attached target", async () => {
const sendTypingDiscord = vi.fn(async () => ({ ok: true, channelId: "thread-123" }));
const sendTypingSpy = vi
.spyOn(sendModule, "sendTypingDiscord")
.mockImplementation(sendTypingDiscord);
try {
const cfg = createCfg();
await discordPlugin.heartbeat!.sendTyping!({
cfg,
to: "channel:123",
accountId: "work",
threadId: "thread-123",
});
expect(sendTypingDiscord).toHaveBeenCalledWith("thread-123", {
cfg,
accountId: "work",
});
} finally {
sendTypingSpy.mockRestore();
}
});
it("uses direct Discord probe helpers for status probes", async () => {
const runtimeProbeDiscord = vi.fn(async () => {
throw new Error("runtime Discord probe should not be used");
});
installDiscordRuntime({
probeDiscord: runtimeProbeDiscord,
});
probeDiscordMock.mockResolvedValue({
ok: true,
bot: { username: "Bob" },
application: {
intents: {
messageContent: "limited",
guildMembers: "disabled",
presence: "disabled",
},
},
elapsedMs: 1,
});
const cfg = createCfg();
const account = resolveAccount(cfg);
await discordPlugin.status!.probeAccount!({
account,
timeoutMs: 5000,
cfg,
});
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 5000, {
includeApplication: true,
});
expect(runtimeProbeDiscord).not.toHaveBeenCalled();
});
it("reports missing voice permissions in targeted capabilities diagnostics", async () => {
const fetchPermissionsSpy = vi
.spyOn(sendModule, "fetchChannelPermissionsDiscord")
.mockResolvedValueOnce({
channelId: "222",
guildId: "123",
permissions: ["ViewChannel", "SendMessages"],
raw: "0",
isDm: false,
channelType: ChannelType.GuildVoice,
});
try {
const cfg = createCfg();
const diagnostics = await discordPlugin.status!.buildCapabilitiesDiagnostics!({
account: resolveAccount(cfg),
timeoutMs: 5000,
cfg,
target: "channel:222",
});
expect(argAt(fetchPermissionsSpy, 0, 0)).toBe("222");
expect(objectArgAt(fetchPermissionsSpy, 0, 1).token).toBe("discord-token");
const permissions = recordField(diagnostics?.details?.permissions, "permissions");
expect(permissions.channelId).toBe("222");
expect(permissions.missingRequired).toEqual(["Connect", "Speak", "ReadMessageHistory"]);
expect(diagnostics?.lines?.map((line) => line.text).join("\n")).toContain(
"Missing required: Connect, Speak, ReadMessageHistory",
);
} finally {
fetchPermissionsSpy.mockRestore();
}
});
it("returns a timeout error when capabilities diagnostics exceed the timeout", async () => {
let diagnosticSignal: AbortSignal | undefined;
const fetchPermissionsSpy = vi
.spyOn(sendModule, "fetchChannelPermissionsDiscord")
.mockImplementation(
async (_channelId, opts) =>
await new Promise<never>((_, reject) => {
diagnosticSignal = opts.signal;
opts.signal?.addEventListener(
"abort",
() => reject(new Error("permission lookup aborted")),
{ once: true },
);
}),
);
try {
const cfg = createCfg();
const diagnostics = await discordPlugin.status!.buildCapabilitiesDiagnostics!({
account: resolveAccount(cfg),
timeoutMs: 10,
cfg,
target: "channel:222",
});
const timeoutPerms = recordField(diagnostics?.details?.permissions, "permissions");
expect(String(timeoutPerms.error)).toContain("timed out");
expect(diagnostics?.lines?.[0]?.tone).toBe("error");
expect(objectArgAt(fetchPermissionsSpy, 0, 1).timeoutMs).toBe(10);
expect(diagnosticSignal?.aborted).toBe(true);
} finally {
fetchPermissionsSpy.mockRestore();
}
});
it("uses direct Discord startup helpers for async startup enrichment", async () => {
const runtimeProbeDiscord = vi.fn(async () => {
throw new Error("runtime Discord probe should not be used");
});
const runtimeMonitorDiscordProvider = vi.fn(async () => {
throw new Error("runtime Discord monitor should not be used");
});
installDiscordRuntime({
probeDiscord: runtimeProbeDiscord,
monitorDiscordProvider: runtimeMonitorDiscordProvider,
});
probeDiscordMock.mockResolvedValue({
ok: true,
bot: { username: "Bob" },
application: {
intents: {
messageContent: "limited",
guildMembers: "disabled",
presence: "disabled",
},
},
elapsedMs: 1,
});
monitorDiscordProviderMock.mockResolvedValue(undefined);
const cfg = createCfg();
await startDiscordAccount(cfg);
await vi.waitFor(() =>
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 2500, {
includeApplication: true,
}),
);
const monitorParams = objectArgAt(monitorDiscordProviderMock, 0, 0);
expect(monitorParams.token).toBe("discord-token");
expect(monitorParams.accountId).toBe("default");
expect(sleepWithAbortMock).not.toHaveBeenCalled();
expect(runtimeProbeDiscord).not.toHaveBeenCalled();
expect(runtimeMonitorDiscordProvider).not.toHaveBeenCalled();
});
it("fails loudly before provider startup when a token SecretRef is configured but unresolved", async () => {
const cfg = {
channels: {
discord: {
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
},
},
} as unknown as OpenClawConfig;
await expect(startDiscordAccount(cfg)).rejects.toThrow(
'Discord bot token configured for account "default" is unavailable',
);
expect(probeDiscordMock).not.toHaveBeenCalled();
expect(monitorDiscordProviderMock).not.toHaveBeenCalled();
});
it("does not block Discord monitor startup on the startup probe", async () => {
let resolveProbe:
| ((value: {
ok: true;
bot: { username: string };
application: { intents: { messageContent: "limited" } };
elapsedMs: number;
}) => void)
| undefined;
probeDiscordMock.mockReturnValue(
new Promise((resolveLocal) => {
resolveProbe = resolveLocal;
}),
);
monitorDiscordProviderMock.mockResolvedValue(undefined);
const cfg = createCfg();
const statusPatches: Array<Record<string, unknown>> = [];
const ctx = createStartAccountContext({
account: resolveAccount(cfg),
cfg,
statusPatchSink: (next) => statusPatches.push({ ...next }),
});
await discordPlugin.gateway!.startAccount!(ctx);
const monitorParams = objectArgAt(monitorDiscordProviderMock, 0, 0);
expect(monitorParams.token).toBe("discord-token");
expect(monitorParams.accountId).toBe("default");
await vi.waitFor(() =>
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 2500, {
includeApplication: true,
}),
);
expect(statusPatches.filter((patch) => "bot" in patch || "application" in patch)).toEqual([]);
if (!resolveProbe) {
throw new Error("Expected Discord startup probe resolver to be initialized");
}
resolveProbe({
ok: true,
bot: { username: "AsyncBob" },
application: { intents: { messageContent: "limited" } },
elapsedMs: 1,
});
await vi.waitFor(() =>
expect(
statusPatches
.filter(
(patch) => (patch.bot as { username?: string } | undefined)?.username === "AsyncBob",
)
.map((patch) => ({
bot: patch.bot,
application: patch.application,
})),
).toEqual([
{
bot: { username: "AsyncBob" },
application: { intents: { messageContent: "limited" } },
},
]),
);
});
it("clears stale Discord probe metadata when the async startup probe degrades", async () => {
probeDiscordMock.mockResolvedValue({
ok: false,
status: 401,
error: "getMe failed (401)",
elapsedMs: 1,
});
monitorDiscordProviderMock.mockResolvedValue(undefined);
const cfg = createCfg();
const statusPatches: Array<Record<string, unknown>> = [];
const ctx = createStartAccountContext({
account: resolveAccount(cfg),
cfg,
statusPatchSink: (next) => statusPatches.push({ ...next }),
});
ctx.setStatus({
accountId: "default",
bot: { username: "OldBot" },
application: { intents: { messageContent: "enabled" } },
});
await discordPlugin.gateway!.startAccount!(ctx);
await expectStaleProbeMetadataCleared(statusPatches);
});
it("clears stale Discord probe metadata when the async startup probe throws", async () => {
probeDiscordMock.mockRejectedValue(new Error("probe timed out"));
monitorDiscordProviderMock.mockResolvedValue(undefined);
const cfg = createCfg();
const statusPatches: Array<Record<string, unknown>> = [];
const ctx = createStartAccountContext({
account: resolveAccount(cfg),
cfg,
statusPatchSink: (next) => statusPatches.push({ ...next }),
});
ctx.setStatus({
accountId: "default",
bot: { username: "OldBot" },
application: { intents: { messageContent: "enabled" } },
});
await discordPlugin.gateway!.startAccount!(ctx);
await expectStaleProbeMetadataCleared(statusPatches);
});
it("stagger starts later accounts in multi-bot setups", async () => {
probeDiscordMock.mockResolvedValue({
ok: true,
bot: { username: "Cherry" },
application: {
intents: {
messageContent: "limited",
guildMembers: "disabled",
presence: "disabled",
},
},
elapsedMs: 1,
});
monitorDiscordProviderMock.mockResolvedValue(undefined);
const cfg = {
channels: {
discord: {
accounts: {
// "alpha" sorts before "zeta" so alpha is index 0, zeta is index 1
alpha: { token: "Bot alpha-token", enabled: true },
zeta: { token: "Bot zeta-token", enabled: true },
},
},
},
} as OpenClawConfig;
// First account (index 0) — no delay
await startDiscordAccount(cfg, "alpha");
expect(sleepWithAbortMock).not.toHaveBeenCalled();
// Second account (index 1) — 10s delay
const zetaContext = createStartAccountContext({
account: resolveAccount(cfg, "zeta"),
cfg,
});
await discordPlugin.gateway!.startAccount!(zetaContext);
expect(sleepWithAbortMock).toHaveBeenCalledWith(10_000, zetaContext.abortSignal);
});
});
describe("discordPlugin bindings", () => {
it("derives DM current conversation ids from direct sender context", () => {
const result = discordPlugin.bindings?.resolveCommandConversation?.({
accountId: "default",
chatType: "direct",
from: "discord:123456789012345678",
originatingTo: "channel:dm-channel-1",
fallbackTo: "channel:dm-channel-1",
});
expect(result).toEqual({
conversationId: "user:123456789012345678",
});
});
it("preserves user-prefixed current conversation ids for DM binds", () => {
const result = discordPlugin.bindings?.resolveCommandConversation?.({
accountId: "default",
originatingTo: "user:123456789012345678",
});
expect(result).toEqual({
conversationId: "user:123456789012345678",
});
});
it("preserves channel-prefixed current conversation ids for channel binds", () => {
const result = discordPlugin.bindings?.resolveCommandConversation?.({
accountId: "default",
originatingTo: "channel:987654321098765432",
});
expect(result).toEqual({
conversationId: "channel:987654321098765432",
});
});
it("preserves channel-prefixed parent ids for thread binds", () => {
const result = discordPlugin.bindings?.resolveCommandConversation?.({
accountId: "default",
originatingTo: "channel:thread-42",
threadId: "thread-42",
threadParentId: "parent-9",
});
expect(result).toEqual({
conversationId: "thread-42",
parentConversationId: "channel:parent-9",
});
});
});
describe("discordPlugin security", () => {
it("normalizes dm allowlist entries with trimmed prefixes and mentions", () => {
const resolveDmPolicy = discordPlugin.security?.resolveDmPolicy;
if (!resolveDmPolicy) {
throw new Error("resolveDmPolicy unavailable");
}
const cfg = {
channels: {
discord: {
token: "discord-token",
dm: { policy: "allowlist", allowFrom: [" discord:<@!123456789> "] },
},
},
} as OpenClawConfig;
const result = resolveDmPolicy({
cfg,
account: discordPlugin.config.resolveAccount(cfg, "default"),
});
if (!result) {
throw new Error("discord resolveDmPolicy returned null");
}
expect(result.policy).toBe("allowlist");
expect(result.allowFrom).toEqual([" discord:<@!123456789> "]);
expect(result.policyPath).toBe("channels.discord.dmPolicy");
expect(result.allowFromPath).toBe("channels.discord.");
expect(result.normalizeEntry?.(" discord:<@!123456789> ")).toBe("123456789");
expect(result.normalizeEntry?.(" user:987654321 ")).toBe("987654321");
});
});
describe("discordPlugin groups", () => {
it("uses plugin-owned group policy resolvers", () => {
const cfg = {
channels: {
discord: {
token: "discord-test",
guilds: {
guild1: {
requireMention: false,
tools: { allow: ["message.guild"] },
channels: {
"123": {
requireMention: true,
tools: { allow: ["message.channel"] },
},
},
},
},
},
},
} as OpenClawConfig;
expect(
discordPlugin.groups?.resolveRequireMention?.({
cfg,
groupSpace: "guild1",
groupId: "123",
}),
).toBe(true);
expect(
discordPlugin.groups?.resolveToolPolicy?.({
cfg,
groupSpace: "guild1",
groupId: "123",
}),
).toEqual({ allow: ["message.channel"] });
});
});

View File

@@ -0,0 +1,759 @@
// Discord plugin module implements channel behavior.
import {
buildLegacyDmAccountAllowlistAdapter,
createAccountScopedAllowlistNameResolver,
createNestedAllowlistOverrideResolver,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import type {
ChannelMessageActionAdapter,
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-contract";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-outbound";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import {
createChannelDirectoryAdapter,
createRuntimeDirectoryLiveAdapter,
} from "openclaw/plugin-sdk/directory-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/target-resolver-runtime";
import {
listDiscordAccountIds,
resolveDiscordAccount,
resolveDiscordAccountAllowFrom,
type ResolvedDiscordAccount,
} from "./accounts.js";
import { getDiscordApprovalCapability } from "./approval-native.js";
import { resolveRequiredDiscordChannelPermissions } from "./audit-core.js";
import { discordMessageActions as discordMessageActionsImpl } from "./channel-actions.js";
import {
buildTokenChannelStatusSummary,
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
type ChannelPlugin,
type OpenClawConfig,
} from "./channel-api.js";
import {
buildDiscordCrossContextPresentation,
matchDiscordAcpConversation,
normalizeDiscordAcpConversationId,
resolveDiscordAttachedOutboundTarget,
resolveDiscordCommandConversation,
resolveDiscordInboundConversation,
} from "./channel.conversation.js";
import {
loadDiscordAuditModule,
loadDiscordDirectoryConfigModule,
loadDiscordDirectoryLiveModule,
loadDiscordProbeRuntime,
loadDiscordProviderRuntime,
loadDiscordResolveChannelsModule,
loadDiscordResolveUsersModule,
loadDiscordSendModule,
loadDiscordTargetResolverModule,
loadDiscordThreadBindingsManagerModule,
} from "./channel.loaders.js";
import { shouldSuppressLocalDiscordExecApprovalPrompt } from "./exec-approvals.js";
import {
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
} from "./group-policy.js";
import {
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
} from "./monitor/thread-bindings.session-updates.js";
import { withAbortTimeout } from "./monitor/timeouts.js";
import { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget } from "./normalize.js";
import { discordOutbound } from "./outbound-adapter.js";
import { resolveDiscordOutboundSessionRoute } from "./outbound-session-route.js";
import type { DiscordProbe } from "./probe.js";
import { getDiscordRuntime } from "./runtime.js";
import { discordSecurityAdapter } from "./security.js";
import { normalizeExplicitDiscordSessionKey } from "./session-key-normalization.js";
import { discordSetupAdapter } from "./setup-adapter.js";
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
import { collectDiscordStatusIssues } from "./status-issues.js";
import { parseDiscordTarget } from "./target-parsing.js";
const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000;
const discordMessageAdapter = createChannelMessageAdapterFromOutbound({
id: "discord",
outbound: discordOutbound,
live: {
capabilities: {
draftPreview: true,
previewFinalization: true,
progressUpdates: true,
},
finalizer: {
capabilities: {
finalEdit: true,
normalFallback: true,
discardPending: true,
},
},
},
});
function startDiscordStartupProbe(params: {
accountId: string;
token: string;
abortSignal: AbortSignal;
setStatus: (patch: { accountId: string; bot?: unknown; application?: unknown }) => void;
log?: {
warn?: (msg: string) => void;
info?: (msg: string) => void;
debug?: (msg: string) => void;
};
}): void {
void (async () => {
try {
const probe = await (
await loadDiscordProbeRuntime()
).probeDiscord(params.token, 2500, {
includeApplication: true,
});
if (params.abortSignal.aborted) {
return;
}
params.setStatus({
accountId: params.accountId,
bot: probe.bot,
application: probe.application,
});
if (probe.ok) {
const username = probe.bot?.username?.trim();
if (username) {
params.log?.info?.(`[${params.accountId}] Discord bot probe resolved @${username}`);
}
} else if (getDiscordRuntime().logging.shouldLogVerbose()) {
params.log?.debug?.(
`[${params.accountId}] bot probe degraded: ${probe.error ?? `status ${probe.status ?? "unknown"}`}`,
);
}
const messageContent = probe.application?.intents?.messageContent;
if (messageContent === "disabled") {
params.log?.warn?.(
`[${params.accountId}] Discord Message Content Intent is disabled; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`,
);
} else if (messageContent === "limited") {
params.log?.info?.(
`[${params.accountId}] Discord Message Content Intent is limited; bots under 100 servers can use it without verification.`,
);
}
} catch (err) {
if (!params.abortSignal.aborted) {
params.setStatus({
accountId: params.accountId,
bot: undefined,
application: undefined,
});
}
if (getDiscordRuntime().logging.shouldLogVerbose()) {
params.log?.debug?.(`[${params.accountId}] bot probe failed: ${String(err)}`);
}
}
})();
}
function shouldTreatDiscordDeliveredTextAsVisible(params: {
kind: "tool" | "block" | "final";
text?: string;
}): boolean {
return (
params.kind === "block" && typeof params.text === "string" && params.text.trim().length > 0
);
}
function resolveRuntimeDiscordMessageActions() {
try {
return getDiscordRuntime().channel?.discord?.messageActions ?? null;
} catch {
return null;
}
}
const discordMessageActions = {
resolveExecutionMode: (
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["resolveExecutionMode"]>>[0],
) =>
resolveRuntimeDiscordMessageActions()?.resolveExecutionMode?.(ctx) ??
discordMessageActionsImpl.resolveExecutionMode?.(ctx) ??
"local",
describeMessageTool: (
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>>[0],
): ChannelMessageToolDiscovery | null =>
resolveRuntimeDiscordMessageActions()?.describeMessageTool?.(ctx) ??
discordMessageActionsImpl.describeMessageTool?.(ctx) ??
null,
extractToolSend: (
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["extractToolSend"]>>[0],
) =>
resolveRuntimeDiscordMessageActions()?.extractToolSend?.(ctx) ??
discordMessageActionsImpl.extractToolSend?.(ctx) ??
null,
prepareSendPayload: (
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["prepareSendPayload"]>>[0],
) =>
resolveRuntimeDiscordMessageActions()?.prepareSendPayload?.(ctx) ??
discordMessageActionsImpl.prepareSendPayload?.(ctx) ??
null,
handleAction: async (
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["handleAction"]>>[0],
) => {
const runtimeHandleAction = resolveRuntimeDiscordMessageActions()?.handleAction;
if (runtimeHandleAction) {
return await runtimeHandleAction(ctx);
}
if (!discordMessageActionsImpl.handleAction) {
throw new Error("Discord message actions not available");
}
return await discordMessageActionsImpl.handleAction(ctx);
},
};
function resolveDiscordStartupDelayMs(cfg: OpenClawConfig, accountId: string): number {
const startupAccountIds = listDiscordAccountIds(cfg).filter((candidateId) => {
const candidate = resolveDiscordAccount({ cfg, accountId: candidateId });
return (
candidate.enabled &&
(resolveConfiguredFromCredentialStatuses(candidate) ??
Boolean(normalizeOptionalString(candidate.token)))
);
});
const startupIndex = startupAccountIds.findIndex((candidateId) => candidateId === accountId);
return startupIndex <= 0 ? 0 : startupIndex * DISCORD_ACCOUNT_STARTUP_STAGGER_MS;
}
function formatDiscordIntents(intents?: {
messageContent?: string;
guildMembers?: string;
presence?: string;
}) {
if (!intents) {
return "unknown";
}
return [
`messageContent=${intents.messageContent ?? "unknown"}`,
`guildMembers=${intents.guildMembers ?? "unknown"}`,
`presence=${intents.presence ?? "unknown"}`,
].join(" ");
}
const resolveDiscordAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({
resolveRecord: (account: ResolvedDiscordAccount) => account.config.guilds,
outerLabel: (guildKey) => `guild ${guildKey}`,
resolveOuterEntries: (guildCfg) => guildCfg?.users,
resolveChildren: (guildCfg) => guildCfg?.channels,
innerLabel: (guildKey, channelKey) => `guild ${guildKey} / channel ${channelKey}`,
resolveInnerEntries: (channelCfg) => channelCfg?.users,
});
const resolveDiscordAllowlistNames = createAccountScopedAllowlistNameResolver({
resolveAccount: resolveDiscordAccount,
resolveToken: (account: ResolvedDiscordAccount) => account.token,
resolveNames: async ({ token, entries }) =>
(await loadDiscordResolveUsersModule()).resolveDiscordUserAllowlist({ token, entries }),
});
function toConversationLifecycleBinding(binding: {
boundAt: number;
lastActivityAt?: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
}) {
return {
boundAt: binding.boundAt,
lastActivityAt:
typeof binding.lastActivityAt === "number" ? binding.lastActivityAt : binding.boundAt,
idleTimeoutMs: typeof binding.idleTimeoutMs === "number" ? binding.idleTimeoutMs : undefined,
maxAgeMs: typeof binding.maxAgeMs === "number" ? binding.maxAgeMs : undefined,
};
}
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe> =
createChatChannelPlugin<ResolvedDiscordAccount, DiscordProbe>({
base: {
...createDiscordPluginBase({
setup: discordSetupAdapter,
}),
allowlist: {
...buildLegacyDmAccountAllowlistAdapter({
channelId: "discord",
resolveAccount: resolveDiscordAccount,
normalize: ({ cfg, accountId, values }) =>
discordConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolveDmAllowFrom: (account, { cfg }) =>
resolveDiscordAccountAllowFrom({ cfg, accountId: account.accountId }),
resolveGroupPolicy: (account) => account.config.groupPolicy,
resolveGroupOverrides: resolveDiscordAllowlistGroupOverrides,
}),
resolveNames: resolveDiscordAllowlistNames,
},
groups: {
resolveRequireMention: resolveDiscordGroupRequireMention,
resolveToolPolicy: resolveDiscordGroupToolPolicy,
},
mentions: {
stripPatterns: () => ["<@!?\\d+>"],
},
agentPrompt: {
messageToolHints: () => [
"- Discord mentions: use canonical outbound syntax: users `<@USER_ID>`, channels `<#CHANNEL_ID>`, and roles `<@&ROLE_ID>`. Plain `@name` text only pings when a configured `mentionAliases` entry rewrites it; do not use the legacy `<@!USER_ID>` nickname form.",
"- Discord components: set `components` when sending messages to include buttons, selects, or v2 containers.",
"- Forms: add `components.modal` (title, fields). OpenClaw adds a trigger button and routes submissions as new messages.",
],
},
messaging: {
targetPrefixes: ["discord"],
normalizeTarget: normalizeDiscordMessagingTarget,
resolveInboundConversation: ({
from,
to,
conversationId,
threadId,
threadParentId,
isGroup,
}) =>
resolveDiscordInboundConversation({
from,
to,
conversationId,
threadId,
threadParentId,
isGroup,
}),
normalizeExplicitSessionKey: ({ sessionKey, ctx }) =>
normalizeExplicitDiscordSessionKey(sessionKey, ctx),
resolveSessionTarget: ({ id }) => normalizeDiscordMessagingTarget(`channel:${id}`),
inferTargetChatType: ({ to }) => {
try {
const parsed = parseDiscordTarget(to, { defaultKind: "channel" });
if (!parsed) {
return undefined;
}
return parsed?.kind === "user" ? "direct" : "channel";
} catch {
return undefined;
}
},
buildCrossContextPresentation: buildDiscordCrossContextPresentation,
resolveOutboundSessionRoute: (params) => resolveDiscordOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeDiscordTargetId,
hint: "<channelId|user:ID|channel:ID>",
resolveTarget: async ({ cfg, accountId, input, normalized, preferredKind }) => {
const resolved = await (
await loadDiscordTargetResolverModule()
).resolveDiscordTarget(
input,
{ cfg, accountId },
preferredKind === "user"
? { defaultKind: "user" }
: preferredKind === "channel" || preferredKind === "group"
? { defaultKind: "channel" }
: {},
);
if (!resolved) {
return null;
}
return {
to: resolved.normalized,
kind: resolved.kind === "user" ? "user" : "channel",
display: resolved.raw,
source: resolved.normalized === normalized ? "normalized" : "directory",
};
},
},
},
approvalCapability: getDiscordApprovalCapability(),
directory: createChannelDirectoryAdapter({
listPeers: async (params) =>
(await loadDiscordDirectoryConfigModule()).listDiscordDirectoryPeersFromConfig(params),
listGroups: async (params) =>
(await loadDiscordDirectoryConfigModule()).listDiscordDirectoryGroupsFromConfig(params),
...createRuntimeDirectoryLiveAdapter({
getRuntime: loadDiscordDirectoryLiveModule,
listPeersLive: (runtime) => runtime.listDiscordDirectoryPeersLive,
listGroupsLive: (runtime) => runtime.listDiscordDirectoryGroupsLive,
}),
}),
message: discordMessageAdapter,
resolver: {
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
const account = resolveDiscordAccount({ cfg, accountId });
if (kind === "group") {
return resolveTargetsWithOptionalToken({
token: account.token,
inputs,
missingTokenNote: "missing Discord token",
resolveWithToken: async ({ token, inputs: inputsValue }) =>
(await loadDiscordResolveChannelsModule()).resolveDiscordChannelAllowlist({
token,
entries: inputsValue,
}),
mapResolved: (entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.channelId ?? entry.guildId,
name:
entry.channelName ??
entry.guildName ??
(entry.guildId && !entry.channelId ? entry.guildId : undefined),
note: entry.note,
}),
});
}
return resolveTargetsWithOptionalToken({
token: account.token,
inputs,
missingTokenNote: "missing Discord token",
resolveWithToken: async ({ token, inputs: inputsLocal }) =>
(await loadDiscordResolveUsersModule()).resolveDiscordUserAllowlist({
token,
entries: inputsLocal,
}),
mapResolved: (entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id,
name: entry.name,
note: entry.note,
}),
});
},
},
actions: discordMessageActions,
bindings: {
compileConfiguredBinding: ({ conversationId }) =>
normalizeDiscordAcpConversationId(conversationId),
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) =>
matchDiscordAcpConversation({
bindingConversationId: compiledBinding.conversationId,
conversationId,
parentConversationId,
}),
resolveCommandConversation: ({
threadId,
threadParentId,
parentSessionKey,
from,
chatType,
originatingTo,
commandTo,
fallbackTo,
}) =>
resolveDiscordCommandConversation({
threadId,
threadParentId,
parentSessionKey,
from,
chatType,
originatingTo,
commandTo,
fallbackTo,
}),
},
conversationBindings: {
supportsCurrentConversationBinding: true,
defaultTopLevelPlacement: "child",
createManager: async ({ cfg, accountId }) =>
(await loadDiscordThreadBindingsManagerModule()).createThreadBindingManager({
cfg,
accountId: accountId ?? undefined,
persist: false,
enableSweeper: false,
}),
setIdleTimeoutBySessionKey: ({ targetSessionKey, accountId, idleTimeoutMs }) =>
setThreadBindingIdleTimeoutBySessionKey({
targetSessionKey,
accountId: accountId ?? undefined,
idleTimeoutMs,
}).map(toConversationLifecycleBinding),
setMaxAgeBySessionKey: ({ targetSessionKey, accountId, maxAgeMs }) =>
setThreadBindingMaxAgeBySessionKey({
targetSessionKey,
accountId: accountId ?? undefined,
maxAgeMs,
}).map(toConversationLifecycleBinding),
},
heartbeat: {
sendTyping: async ({ cfg, to, accountId, threadId }) => {
const resolvedTo = resolveDiscordAttachedOutboundTarget({ to, threadId });
const target = parseDiscordTarget(resolvedTo, { defaultKind: "channel" });
if (!target || target.kind !== "channel") {
return;
}
await (
await loadDiscordSendModule()
).sendTypingDiscord(target.id, {
cfg,
accountId: accountId ?? undefined,
});
},
},
status: createComputedAccountStatusAdapter<ResolvedDiscordAccount, DiscordProbe>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, {
connected: false,
reconnectAttempts: 0,
lastConnectedAt: null,
lastDisconnect: null,
lastEventAt: null,
}),
collectStatusIssues: collectDiscordStatusIssues,
buildChannelSummary: ({ snapshot }) =>
buildTokenChannelStatusSummary(snapshot, { includeMode: false }),
probeAccount: async ({ account, timeoutMs }) =>
(await loadDiscordProbeRuntime()).probeDiscord(account.token, timeoutMs, {
includeApplication: true,
}),
formatCapabilitiesProbe: ({ probe }) => {
const discordProbe = probe as DiscordProbe | undefined;
const lines = [];
if (discordProbe?.bot?.username) {
const botId = discordProbe.bot.id ? ` (${discordProbe.bot.id})` : "";
lines.push({ text: `Bot: @${discordProbe.bot.username}${botId}` });
}
if (discordProbe?.application?.intents) {
lines.push({
text: `Intents: ${formatDiscordIntents(discordProbe.application.intents)}`,
});
}
return lines;
},
buildCapabilitiesDiagnostics: async ({ account, target, timeoutMs }) => {
if (!target?.trim()) {
return undefined;
}
const parsedTarget = parseDiscordTarget(target.trim(), { defaultKind: "channel" });
const details: Record<string, unknown> = {
target: {
raw: target,
normalized: parsedTarget?.normalized,
kind: parsedTarget?.kind,
channelId: parsedTarget?.kind === "channel" ? parsedTarget.id : undefined,
},
};
if (!parsedTarget || parsedTarget.kind !== "channel") {
return {
details,
lines: [
{
text: "Permissions: Target looks like a DM user; pass channel:<id> to audit channel permissions.",
tone: "error",
},
],
};
}
const token = account.token?.trim();
if (!token) {
return {
details,
lines: [
{
text: "Permissions: Discord bot token missing for permission audit.",
tone: "error",
},
],
};
}
const statusCfg: OpenClawConfig = {
channels: {
discord: {
accounts: {
[account.accountId]: {
...account.config,
token,
},
},
},
},
};
try {
const sendModule = await loadDiscordSendModule();
const perms = await withAbortTimeout({
timeoutMs,
createTimeoutError: () =>
new Error(`Capabilities diagnostic timed out after ${timeoutMs}ms`),
run: async (signal) =>
await sendModule.fetchChannelPermissionsDiscord(parsedTarget.id, {
cfg: statusCfg,
token,
accountId: account.accountId ?? undefined,
signal,
timeoutMs,
}),
});
const requiredPermissions = resolveRequiredDiscordChannelPermissions(perms.channelType);
const missingRequired = requiredPermissions.filter(
(permission) => !perms.permissions.includes(permission),
);
details.permissions = {
channelId: perms.channelId,
guildId: perms.guildId,
isDm: perms.isDm,
channelType: perms.channelType,
permissions: perms.permissions,
missingRequired,
raw: perms.raw,
};
return {
details,
lines: [
{
text: `Permissions (${perms.channelId}): ${perms.permissions.length ? perms.permissions.join(", ") : "none"}`,
},
missingRequired.length > 0
? { text: `Missing required: ${missingRequired.join(", ")}`, tone: "warn" }
: { text: "Missing required: none", tone: "success" },
],
};
} catch (err) {
const message = formatErrorMessage(err);
details.permissions = { channelId: parsedTarget.id, error: message };
return {
details,
lines: [{ text: `Permissions: ${message}`, tone: "error" }],
};
}
},
auditAccount: async ({ account, timeoutMs, cfg }) => {
const { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } =
await loadDiscordAuditModule();
const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
cfg,
accountId: account.accountId,
});
if (!channelIds.length && unresolvedChannels === 0) {
return undefined;
}
const botToken = account.token?.trim();
if (!botToken) {
return {
ok: unresolvedChannels === 0,
checkedChannels: 0,
unresolvedChannels,
channels: [],
elapsedMs: 0,
};
}
const audit = await auditDiscordChannelPermissions({
cfg,
token: botToken,
accountId: account.accountId,
channelIds,
timeoutMs,
});
return { ...audit, unresolvedChannels };
},
resolveAccountSnapshot: ({ account, runtime, probe, audit }) => {
const configured =
resolveConfiguredFromCredentialStatuses(account) ?? Boolean(account.token?.trim());
const app = runtime?.application ?? (probe as { application?: unknown })?.application;
const bot = runtime?.bot ?? (probe as { bot?: unknown })?.bot;
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
extra: {
...projectCredentialSnapshotFields(account),
connected: runtime?.connected ?? false,
reconnectAttempts: runtime?.reconnectAttempts,
lastConnectedAt: runtime?.lastConnectedAt ?? null,
lastDisconnect: runtime?.lastDisconnect ?? null,
lastEventAt: runtime?.lastEventAt ?? null,
application: app ?? undefined,
bot: bot ?? undefined,
audit,
},
};
},
}),
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
if (account.tokenStatus === "configured_unavailable") {
throw new Error(
`Discord bot token configured for account "${account.accountId}" is unavailable; resolve SecretRefs against the active runtime snapshot before using this account.`,
);
}
const startupDelayMs = resolveDiscordStartupDelayMs(ctx.cfg, account.accountId);
if (startupDelayMs > 0) {
ctx.log?.info(
`[${account.accountId}] delaying provider startup ${Math.round(startupDelayMs / 1000)}s to reduce Discord startup rate limits`,
);
try {
await sleepWithAbort(startupDelayMs, ctx.abortSignal);
} catch {
return;
}
}
const token = account.token.trim();
startDiscordStartupProbe({
accountId: account.accountId,
token,
abortSignal: ctx.abortSignal,
setStatus: ctx.setStatus,
log: ctx.log,
});
ctx.log?.info(`[${account.accountId}] starting provider`);
return (await loadDiscordProviderRuntime()).monitorDiscordProvider({
token,
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
channelRuntime: ctx.channelRuntime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
historyLimit: account.config.historyLimit,
setStatus: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
});
},
},
},
pairing: {
text: {
idLabel: "discordUserId",
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^(discord|user):/i),
notify: async ({ cfg, id, message, accountId }) => {
await (
await loadDiscordSendModule()
).sendMessageDiscord(`user:${id}`, message, {
cfg,
...(accountId ? { accountId } : {}),
});
},
},
},
security: discordSecurityAdapter,
threading: {
scopedAccountReplyToMode: {
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
resolveReplyToMode: (account) => account.config.replyToMode,
fallback: "off",
},
},
outbound: {
...discordOutbound,
preferFinalAssistantVisibleText: true,
shouldTreatDeliveredTextAsVisible: shouldTreatDiscordDeliveredTextAsVisible,
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload, hint }) =>
shouldSuppressLocalDiscordExecApprovalPrompt({
cfg,
accountId,
payload,
hint,
}),
},
});

View File

@@ -0,0 +1,207 @@
// Discord tests cover chunk plugin behavior.
import { countLines, hasBalancedFences } from "openclaw/plugin-sdk/test-fixtures";
import { describe, expect, it } from "vitest";
import { chunkDiscordText, chunkDiscordTextWithMode } from "./chunk.js";
describe("chunkDiscordText", () => {
it("splits tall messages even when under 2000 chars", () => {
const text = Array.from({ length: 45 }, (_, i) => `line-${i + 1}`).join("\n");
expect(text.length).toBeLessThan(2000);
const chunks = chunkDiscordText(text, { maxChars: 2000, maxLines: 20 });
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
expect(countLines(chunk)).toBeLessThanOrEqual(20);
}
});
it("uses default chunk limits for non-finite options", () => {
const text = "x".repeat(2500);
const chunks = chunkDiscordText(text, {
maxChars: Number.NaN,
maxLines: Number.POSITIVE_INFINITY,
});
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.length <= 2000)).toBe(true);
expect(chunks.join("")).toBe(text);
});
it("keeps fenced code blocks balanced across chunks", () => {
const body = Array.from({ length: 30 }, (_, i) => `console.log(${i});`).join("\n");
const text = `Here is code:\n\n\`\`\`js\n${body}\n\`\`\`\n\nDone.`;
const chunks = chunkDiscordText(text, { maxChars: 2000, maxLines: 10 });
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
expect(hasBalancedFences(chunk)).toBe(true);
expect(chunk.length).toBeLessThanOrEqual(2000);
}
expect(chunks[0]).toContain("```js");
expect(chunks.at(-1)).toContain("Done.");
});
it("keeps fenced blocks intact when chunkMode is newline", () => {
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
const chunks = chunkDiscordTextWithMode(text, {
maxChars: 2000,
maxLines: 50,
chunkMode: "newline",
});
expect(chunks).toEqual([text]);
});
it("uses default newline chunk limits for non-finite max chars", () => {
const text = "x".repeat(2500);
const chunks = chunkDiscordTextWithMode(text, {
maxChars: Number.NaN,
maxLines: 50,
chunkMode: "newline",
});
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.length <= 2000)).toBe(true);
expect(chunks.join("")).toBe(text);
});
it("reserves space for closing fences when chunking", () => {
const body = "a".repeat(120);
const text = `\`\`\`txt\n${body}\n\`\`\``;
const chunks = chunkDiscordText(text, { maxChars: 50, maxLines: 50 });
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
expect(chunk.length).toBeLessThanOrEqual(50);
expect(hasBalancedFences(chunk)).toBe(true);
}
});
it("keeps chunks within maxChars when a closing fence line carries trailing text", () => {
// A line that both closes the fence and carries a long tail must still reserve closing-fence
// space; otherwise a mid-line flush appended "```" and overflowed maxChars (e.g. 2004 > 2000).
for (let pad = 1990; pad <= 2000; pad++) {
const text = "hi\n```lang\n```" + "z".repeat(pad);
for (const chunk of chunkDiscordText(text, { maxChars: 2000, maxLines: 100 })) {
expect(chunk.length).toBeLessThanOrEqual(2000);
}
}
});
it("preserves whitespace when splitting long lines", () => {
const text = Array.from({ length: 40 }, () => "word").join(" ");
const chunks = chunkDiscordText(text, { maxChars: 20, maxLines: 50 });
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.join("")).toBe(text);
});
it("preserves mixed whitespace across chunk boundaries", () => {
const text = "alpha beta\tgamma delta epsilon zeta";
const chunks = chunkDiscordText(text, { maxChars: 12, maxLines: 50 });
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.join("")).toBe(text);
});
it("keeps leading whitespace when splitting long lines", () => {
const text = " indented line with words that force splits";
const chunks = chunkDiscordText(text, { maxChars: 14, maxLines: 50 });
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.join("")).toBe(text);
});
it("uses CJK punctuation as a safe long-line split point", () => {
const text = "一二三四五。六七八九十。甲乙丙丁戊。";
const chunks = chunkDiscordText(text, { maxChars: 10, maxLines: 50 });
expect(chunks).toEqual(["一二三四五。", "六七八九十。", "甲乙丙丁戊。"]);
expect(chunks.join("")).toBe(text);
});
it("still prefers whitespace before CJK punctuation", () => {
const text = "alpha beta。gamma delta";
const chunks = chunkDiscordText(text, { maxChars: 13, maxLines: 50 });
expect(chunks[0]).toBe("alpha");
expect(chunks.join("")).toBe(text);
});
it("does not split surrogate pairs at hard fallback boundaries", () => {
const text = "ab😀cd😀ef";
const chunks = chunkDiscordText(text, { maxChars: 3, maxLines: 50 });
expect(chunks).toEqual(["ab", "😀c", "d😀", "ef"]);
expect(chunks.join("")).toBe(text);
});
it("keeps reasoning italics balanced across chunks", () => {
const body = Array.from({ length: 25 }, (_, i) => `${i + 1}. line`).join("\n");
const text = `Reasoning:\n_${body}_`;
const chunks = chunkDiscordText(text, { maxLines: 10, maxChars: 2000 });
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
// Each chunk should have balanced italics markers (even count).
const count = (chunk.match(/_/g) || []).length;
expect(count % 2).toBe(0);
}
// Ensure italics reopen on subsequent chunks
expect(chunks[0]).toContain("_1. line");
// Second chunk should reopen italics at the start
expect(chunks[1].trimStart().startsWith("_")).toBe(true);
});
it("keeps reasoning italics balanced when chunks split by char limit", () => {
const longLine = "This is a very long reasoning line that forces char splits.";
const body = Array.from({ length: 5 }, () => longLine).join("\n");
const text = `Reasoning:\n_${body}_`;
const chunks = chunkDiscordText(text, { maxChars: 80, maxLines: 50 });
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
const underscoreCount = (chunk.match(/_/g) || []).length;
expect(underscoreCount % 2).toBe(0);
}
});
it("keeps thinking-prefixed reasoning italics balanced across chunks", () => {
const body = Array.from({ length: 25 }, (_, i) => `${i + 1}. line`).join("\n");
const text = `Thinking\n\n_${body}_`;
const chunks = chunkDiscordText(text, { maxLines: 10, maxChars: 2000 });
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
const underscoreCount = (chunk.match(/_/g) || []).length;
expect(underscoreCount % 2).toBe(0);
}
});
it("reopens italics while preserving leading whitespace on following chunk", () => {
const body = [
"1. line",
"2. line",
"3. line",
"4. line",
"5. line",
"6. line",
"7. line",
"8. line",
"9. line",
"10. line",
" 11. indented line",
"12. line",
].join("\n");
const text = `Reasoning:\n_${body}_`;
const chunks = chunkDiscordText(text, { maxLines: 10, maxChars: 2000 });
expect(chunks.length).toBeGreaterThan(1);
const second = chunks[1];
expect(second.startsWith("_")).toBe(true);
expect(second).toContain(" 11. indented line");
});
});

View File

@@ -0,0 +1,331 @@
// Discord plugin module implements chunk behavior.
import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime";
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
type ChunkDiscordTextOpts = {
/** Max characters per Discord message. Default: 2000. */
maxChars?: number;
/**
* Soft max line count per message. Default: 17.
*
* Discord clients can clip/collapse very tall messages in the UI; splitting
* by lines keeps long multi-paragraph replies readable.
*/
maxLines?: number;
};
type OpenFence = {
indent: string;
markerChar: string;
markerLen: number;
openLine: string;
};
const DEFAULT_MAX_CHARS = 2000;
const DEFAULT_MAX_LINES = 17;
const FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
const CJK_PUNCTUATION_BREAK_AFTER_RE = /[]/u;
function resolveDiscordChunkLimit(value: unknown, fallback: number) {
return resolveIntegerOption(value, fallback, { min: 1 });
}
function countLines(text: string) {
if (!text) {
return 0;
}
return text.split("\n").length;
}
function parseFenceLine(line: string): OpenFence | null {
const match = line.match(FENCE_RE);
if (!match) {
return null;
}
const indent = match[1] ?? "";
const marker = match[2] ?? "";
return {
indent,
markerChar: marker[0] ?? "`",
markerLen: marker.length,
openLine: line,
};
}
function closeFenceLine(openFence: OpenFence) {
return `${openFence.indent}${openFence.markerChar.repeat(openFence.markerLen)}`;
}
function closeFenceIfNeeded(text: string, openFence: OpenFence | null) {
if (!openFence) {
return text;
}
const closeLine = closeFenceLine(openFence);
if (!text) {
return closeLine;
}
if (!text.endsWith("\n")) {
return `${text}\n${closeLine}`;
}
return `${text}${closeLine}`;
}
function isHighSurrogate(code: number) {
return code >= 0xd800 && code <= 0xdbff;
}
function isLowSurrogate(code: number) {
return code >= 0xdc00 && code <= 0xdfff;
}
function clampToCodePointBoundary(text: string, index: number) {
const boundary = Math.min(Math.max(0, index), text.length);
if (boundary <= 0 || boundary >= text.length) {
return boundary;
}
const previous = text.charCodeAt(boundary - 1);
const next = text.charCodeAt(boundary);
if (isHighSurrogate(previous) && isLowSurrogate(next)) {
return boundary > 1 ? boundary - 1 : boundary + 1;
}
return boundary;
}
function findWhitespaceBreak(window: string) {
for (let i = window.length - 1; i >= 0; i--) {
if (/\s/.test(window[i])) {
// Return the separator index so whitespace stays with the next segment.
return i;
}
}
return -1;
}
function findCjkPunctuationBreak(window: string) {
for (let end = window.length; end > 0; ) {
const code = window.charCodeAt(end - 1);
const start = isLowSurrogate(code) && end > 1 ? end - 2 : end - 1;
const char = window.slice(start, end);
if (start > 0 && CJK_PUNCTUATION_BREAK_AFTER_RE.test(char)) {
// Return the exclusive end so CJK punctuation stays with the current segment.
return end;
}
end = start;
}
return -1;
}
function splitLongLine(
line: string,
maxChars: number,
opts: { preserveWhitespace: boolean },
): string[] {
const limit = resolveDiscordChunkLimit(maxChars, DEFAULT_MAX_CHARS);
if (line.length <= limit) {
return [line];
}
const out: string[] = [];
let remaining = line;
while (remaining.length > limit) {
if (opts.preserveWhitespace) {
const breakIdx = clampToCodePointBoundary(remaining, limit);
out.push(remaining.slice(0, breakIdx));
remaining = remaining.slice(breakIdx);
continue;
}
const window = remaining.slice(0, limit);
let breakIdx = findWhitespaceBreak(window);
if (breakIdx <= 0) {
breakIdx = findCjkPunctuationBreak(window);
}
if (breakIdx <= 0) {
breakIdx = clampToCodePointBoundary(remaining, limit);
}
out.push(remaining.slice(0, breakIdx));
// Keep the separator for the next segment so words don't get glued together.
remaining = remaining.slice(breakIdx);
}
if (remaining.length) {
out.push(remaining);
}
return out;
}
/**
* Chunks outbound Discord text by both character count and (soft) line count,
* while keeping fenced code blocks balanced across chunks.
*/
export function chunkDiscordText(text: string, opts: ChunkDiscordTextOpts = {}): string[] {
const maxChars = resolveDiscordChunkLimit(opts.maxChars, DEFAULT_MAX_CHARS);
const maxLines = resolveDiscordChunkLimit(opts.maxLines, DEFAULT_MAX_LINES);
const body = text ?? "";
if (!body) {
return [];
}
const alreadyOk = body.length <= maxChars && countLines(body) <= maxLines;
if (alreadyOk) {
return [body];
}
const lines = body.split("\n");
const chunks: string[] = [];
let current = "";
let currentLines = 0;
let openFence: OpenFence | null = null;
const flush = () => {
if (!current) {
return;
}
const payload = closeFenceIfNeeded(current, openFence);
if (payload.trim().length) {
chunks.push(payload);
}
current = "";
currentLines = 0;
if (openFence) {
current = openFence.openLine;
currentLines = 1;
}
};
for (const originalLine of lines) {
const fenceInfo = parseFenceLine(originalLine);
const wasInsideFence = openFence !== null;
let nextOpenFence: OpenFence | null = openFence;
if (fenceInfo) {
if (!openFence) {
nextOpenFence = fenceInfo;
} else if (
openFence.markerChar === fenceInfo.markerChar &&
fenceInfo.markerLen >= openFence.markerLen
) {
nextOpenFence = null;
}
}
// A flush can fire mid-line, before `openFence` advances to `nextOpenFence` below, so it closes
// against the still-open `openFence`. A fence-closing line that also carries trailing text would
// otherwise reserve 0 yet still get a closing fence appended on flush, overflowing maxChars.
const fenceToReserve = nextOpenFence ?? openFence;
const reserveChars = fenceToReserve ? closeFenceLine(fenceToReserve).length + 1 : 0;
const reserveLines = fenceToReserve ? 1 : 0;
const effectiveMaxChars = maxChars - reserveChars;
const effectiveMaxLines = maxLines - reserveLines;
const charLimit = effectiveMaxChars > 0 ? effectiveMaxChars : maxChars;
const lineLimit = effectiveMaxLines > 0 ? effectiveMaxLines : maxLines;
const prefixLen = current.length > 0 ? current.length + 1 : 0;
const segmentLimit = Math.max(1, charLimit - prefixLen);
const segments = splitLongLine(originalLine, segmentLimit, {
preserveWhitespace: wasInsideFence,
});
for (let segIndex = 0; segIndex < segments.length; segIndex++) {
const segment = segments[segIndex];
const isLineContinuation = segIndex > 0;
const delimiter = isLineContinuation ? "" : current.length > 0 ? "\n" : "";
const addition = `${delimiter}${segment}`;
const nextLen = current.length + addition.length;
const nextLines = currentLines + (isLineContinuation ? 0 : 1);
const wouldExceedChars = nextLen > charLimit;
const wouldExceedLines = nextLines > lineLimit;
if ((wouldExceedChars || wouldExceedLines) && current.length > 0) {
flush();
}
if (current.length > 0) {
current += addition;
if (!isLineContinuation) {
currentLines += 1;
}
} else {
current = segment;
currentLines = 1;
}
}
openFence = nextOpenFence;
}
if (current.length) {
const payload = closeFenceIfNeeded(current, openFence);
if (payload.trim().length) {
chunks.push(payload);
}
}
return rebalanceReasoningItalics(text, chunks);
}
export function chunkDiscordTextWithMode(
text: string,
opts: ChunkDiscordTextOpts & { chunkMode?: ChunkMode },
): string[] {
const chunkMode = opts.chunkMode ?? "length";
if (chunkMode !== "newline") {
return chunkDiscordText(text, opts);
}
const lineChunks = chunkMarkdownTextWithMode(
text,
resolveDiscordChunkLimit(opts.maxChars, DEFAULT_MAX_CHARS),
"newline",
);
const chunks: string[] = [];
for (const line of lineChunks) {
const nested = chunkDiscordText(line, opts);
if (!nested.length && line) {
chunks.push(line);
continue;
}
chunks.push(...nested);
}
return chunks;
}
// Keep italics intact for reasoning payloads that are wrapped once with `_…_`.
// When Discord chunking splits the message, we close italics at the end of
// each chunk and reopen at the start of the next so every chunk renders
// consistently.
function rebalanceReasoningItalics(source: string, chunks: string[]): string[] {
if (chunks.length <= 1) {
return chunks;
}
const opensWithReasoningItalics =
/^(?:Reasoning:|Thinking\.{0,3})\n+_/u.test(source) && source.trimEnd().endsWith("_");
if (!opensWithReasoningItalics) {
return chunks;
}
const adjusted = [...chunks];
for (let i = 0; i < adjusted.length; i++) {
const isLast = i === adjusted.length - 1;
const current = adjusted[i];
// Ensure current chunk closes italics so Discord renders it italicized.
const needsClosing = !current.trimEnd().endsWith("_");
if (needsClosing) {
adjusted[i] = `${current}_`;
}
if (isLast) {
break;
}
// Re-open italics on the next chunk if needed.
const next = adjusted[i + 1];
const leadingWhitespaceLen = next.length - next.trimStart().length;
const leadingWhitespace = next.slice(0, leadingWhitespaceLen);
const nextBody = next.slice(leadingWhitespaceLen);
if (!nextBody.startsWith("_")) {
adjusted[i + 1] = `${leadingWhitespace}_${nextBody}`;
}
}
return adjusted;
}

View File

@@ -0,0 +1,276 @@
// Discord tests cover client.proxy plugin behavior.
import http from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { fetch as undiciFetch } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDiscordRestClient } from "./client.js";
import { createDiscordRequestClient } from "./proxy-request-client.js";
const makeProxyFetchMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/fetch-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/fetch-runtime")>(
"openclaw/plugin-sdk/fetch-runtime",
);
makeProxyFetchMock.mockImplementation((proxyUrl: string) => {
if (proxyUrl === "bad-proxy") {
throw new Error("bad proxy");
}
return actual.makeProxyFetch(proxyUrl);
});
return {
...actual,
makeProxyFetch: makeProxyFetchMock,
};
});
describe("createDiscordRestClient proxy support", () => {
beforeEach(() => {
vi.unstubAllEnvs();
makeProxyFetchMock.mockClear();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("injects a custom fetch into RequestClient when a Discord proxy is configured", () => {
const cfg = {
channels: {
discord: {
token: "Bot test-token",
proxy: "http://127.0.0.1:8080",
},
},
} as OpenClawConfig;
const { rest } = createDiscordRestClient({ cfg });
const requestClient = rest as unknown as {
customFetch?: typeof fetch;
options?: { fetch?: typeof fetch };
};
expect(makeProxyFetchMock).toHaveBeenCalledWith("http://127.0.0.1:8080");
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
expect(requestClient.customFetch).toBe(requestClient.options?.fetch);
});
it("accepts configured DNS proxy hosts", () => {
const cfg = {
channels: {
discord: {
token: "Bot test-token",
proxy: "http://mitm-proxy:8080",
},
},
} as OpenClawConfig;
const { rest } = createDiscordRestClient({ cfg });
const requestClient = rest as unknown as {
customFetch?: typeof fetch;
options?: { fetch?: typeof fetch };
};
expect(makeProxyFetchMock).toHaveBeenCalledWith("http://mitm-proxy:8080");
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
expect(requestClient.customFetch).toBe(requestClient.options?.fetch);
});
it("accepts configured HTTPS proxy hosts", () => {
const cfg = {
channels: {
discord: {
token: "Bot test-token",
proxy: "https://proxy.example:8443",
},
},
} as OpenClawConfig;
const { rest } = createDiscordRestClient({ cfg });
const requestClient = rest as unknown as {
customFetch?: typeof fetch;
options?: { fetch?: typeof fetch };
};
expect(makeProxyFetchMock).toHaveBeenCalledWith("https://proxy.example:8443");
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
expect(requestClient.customFetch).toBe(requestClient.options?.fetch);
});
it("accepts configured proxy URLs with credentials", () => {
const cfg = {
channels: {
discord: {
token: "Bot test-token",
proxy: "http://user:secret@mitm-proxy:8080",
},
},
} as OpenClawConfig;
const { rest } = createDiscordRestClient({ cfg });
const requestClient = rest as unknown as {
options?: { fetch?: typeof fetch };
};
expect(makeProxyFetchMock).toHaveBeenCalledWith("http://user:secret@mitm-proxy:8080");
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
});
it("accepts arbitrary configured DNS proxy hosts", () => {
const cfg = {
channels: {
discord: {
token: "Bot test-token",
proxy: "http://proxy.test:8080",
},
},
} as OpenClawConfig;
const { rest } = createDiscordRestClient({ cfg });
const requestClient = rest as unknown as {
options?: { fetch?: typeof fetch };
};
expect(makeProxyFetchMock).toHaveBeenCalledWith("http://proxy.test:8080");
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
});
it("does not inject fetch when no proxy is configured", () => {
const cfg = {
channels: {
discord: {
token: "Bot test-token",
},
},
} as OpenClawConfig;
const { rest } = createDiscordRestClient({ cfg });
const requestClient = rest as unknown as {
options?: { fetch?: typeof fetch };
};
expect(requestClient.options?.fetch).toBeUndefined();
});
it("falls back to direct fetch when the Discord proxy URL is invalid", () => {
const cfg = {
channels: {
discord: {
token: "Bot test-token",
proxy: "bad-proxy",
},
},
} as OpenClawConfig;
const { rest } = createDiscordRestClient({ cfg });
const requestClient = rest as unknown as {
options?: { fetch?: typeof fetch };
};
expect(makeProxyFetchMock).not.toHaveBeenCalledWith("bad-proxy");
expect(requestClient.options?.fetch).toBeUndefined();
});
it("accepts configured non-loopback IP proxy URLs", () => {
const cfg = {
channels: {
discord: {
token: "Bot test-token",
proxy: "http://10.0.0.10:8080",
},
},
} as OpenClawConfig;
const { rest } = createDiscordRestClient({ cfg });
const requestClient = rest as unknown as {
options?: { fetch?: typeof fetch };
};
expect(makeProxyFetchMock).toHaveBeenCalledWith("http://10.0.0.10:8080");
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
});
it("accepts IPv6 loopback Discord proxy URLs", () => {
const cfg = {
channels: {
discord: {
token: "Bot test-token",
proxy: "http://[::1]:8080",
},
},
} as OpenClawConfig;
const { rest } = createDiscordRestClient({ cfg });
const requestClient = rest as unknown as {
options?: { fetch?: typeof fetch };
};
expect(makeProxyFetchMock).toHaveBeenCalledWith("http://[::1]:8080");
expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value);
});
it("serializes multipart media with undici-compatible FormData for proxy fetches", async () => {
const received = await new Promise<{
contentType: string | undefined;
body: string;
}>((resolve, reject) => {
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("error", reject);
req.on("end", () => {
resolve({
contentType: req.headers["content-type"],
body: Buffer.concat(chunks).toString("utf8"),
});
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ id: "message-id", channel_id: "channel-id" }));
server.close();
});
});
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("failed to bind test server"));
server.close();
return;
}
const rest = createDiscordRequestClient("test-token", {
baseUrl: `http://127.0.0.1:${address.port}`,
fetch: undiciFetch as unknown as typeof fetch,
queueRequests: false,
});
void rest
.post("/channels/123/messages", {
body: {
content: "with image",
files: [{ data: Buffer.from("png-data"), name: "image.png" }],
},
})
.catch((err: unknown) => {
reject(toLintErrorObject(err, "Non-Error rejection"));
server.close();
});
});
});
expect(received.contentType).toMatch(/^multipart\/form-data; boundary=/);
expect(received.body).toContain('name="files[0]"; filename="image.png"');
expect(received.body).toContain('name="payload_json"');
expect(received.body).toContain('"attachments":[{"id":0,"filename":"image.png"}]');
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -0,0 +1,92 @@
// Discord tests cover client plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDiscordRestClient } from "./client.js";
import type { RequestClient } from "./internal/discord.js";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("createDiscordRestClient", () => {
const fakeRest = {} as RequestClient;
it("uses explicit token without resolving config token SecretRefs", () => {
const cfg = {
channels: {
discord: {
token: {
source: "exec",
provider: "vault",
id: "discord/bot-token",
},
},
},
} as OpenClawConfig;
const result = createDiscordRestClient({ cfg, token: "Bot explicit-token", rest: fakeRest });
expect(result.token).toBe("explicit-token");
expect(result.rest).toBe(fakeRest);
expect(result.account.accountId).toBe("default");
});
it("keeps account retry config when explicit token is provided", () => {
const cfg = {
channels: {
discord: {
accounts: {
ops: {
token: {
source: "exec",
provider: "vault",
id: "discord/ops-token",
},
retry: {
attempts: 7,
},
},
},
},
},
} as OpenClawConfig;
const result = createDiscordRestClient({
cfg,
accountId: "ops",
token: "Bot explicit-account-token",
rest: fakeRest,
});
expect(result.token).toBe("explicit-account-token");
expect(result.account.accountId).toBe("ops");
expect(result.account.config.retry).toEqual({ attempts: 7 });
});
it("applies a caller timeout to a dedicated REST client", () => {
const cfg = { channels: { discord: { token: "discord-token" } } } as OpenClawConfig;
const result = createDiscordRestClient({ cfg, timeoutMs: 250 });
expect(result.rest.options.timeout).toBe(250);
});
it("still fails closed when no explicit token is provided and config token is unresolved", () => {
vi.stubEnv("DISCORD_BOT_TOKEN", "env-token");
const cfg = {
channels: {
discord: {
token: {
source: "file",
provider: "default",
id: "/discord/token",
},
},
},
} as OpenClawConfig;
expect(() => createDiscordRestClient({ cfg, rest: fakeRest })).toThrow(
/configured for account "default" is unavailable/i,
);
});
});

View File

@@ -0,0 +1,157 @@
// Discord plugin module implements client behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { RetryConfig, RetryRunner } from "openclaw/plugin-sdk/retry-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
mergeDiscordAccountConfig,
resolveDiscordAccount,
type ResolvedDiscordAccount,
} from "./accounts.js";
import { RequestClient } from "./internal/discord.js";
import { resolveDiscordProxyFetchForAccount } from "./proxy-fetch.js";
import { createDiscordRequestClient } from "./proxy-request-client.js";
import { createDiscordRetryRunner } from "./retry.js";
import type { DiscordRuntimeAccountContext } from "./send.types.js";
import { normalizeDiscordToken } from "./token.js";
export type DiscordClientOpts = {
cfg: OpenClawConfig;
token?: string;
accountId?: string;
rest?: RequestClient;
retry?: RetryConfig;
signal?: AbortSignal;
timeoutMs?: number;
verbose?: boolean;
};
export function createDiscordRuntimeAccountContext(params: {
cfg: OpenClawConfig;
accountId: string;
}): DiscordRuntimeAccountContext {
return {
cfg: params.cfg,
accountId: normalizeAccountId(params.accountId),
};
}
export function resolveDiscordClientAccountContext(
opts: Pick<DiscordClientOpts, "cfg" | "accountId">,
runtime?: Pick<RuntimeEnv, "error">,
) {
const resolvedCfg = requireRuntimeConfig(opts.cfg, "Discord client");
const account = resolveAccountWithoutToken({
cfg: resolvedCfg,
accountId: opts.accountId,
});
return {
cfg: resolvedCfg,
account,
proxyFetch: resolveDiscordProxyFetchForAccount(account, resolvedCfg, runtime),
};
}
function resolveToken(params: {
account: ResolvedDiscordAccount;
accountId: string;
fallbackToken?: string;
}) {
const fallback = normalizeDiscordToken(params.fallbackToken, "channels.discord.token");
if (!fallback) {
if (params.account.tokenStatus === "configured_unavailable") {
throw new Error(
`Discord bot token configured for account "${params.accountId}" is unavailable; resolve SecretRefs against the active runtime snapshot before using this account.`,
);
}
throw new Error(
`Discord bot token missing for account "${params.accountId}" (set discord.accounts.${params.accountId}.token or DISCORD_BOT_TOKEN for default).`,
);
}
return fallback;
}
function resolveRest(
token: string,
account: ResolvedDiscordAccount,
cfg: OpenClawConfig,
rest?: RequestClient,
proxyFetch?: typeof fetch,
signal?: AbortSignal,
timeoutMs?: number,
) {
if (rest) {
return rest;
}
const resolvedProxyFetch = proxyFetch ?? resolveDiscordProxyFetchForAccount(account, cfg);
return createDiscordRequestClient(token, {
...(resolvedProxyFetch ? { fetch: resolvedProxyFetch } : {}),
...(signal ? { signal } : {}),
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
});
}
function resolveAccountWithoutToken(params: {
cfg: OpenClawConfig;
accountId?: string;
}): ResolvedDiscordAccount {
const accountId = normalizeAccountId(params.accountId);
const merged = mergeDiscordAccountConfig(params.cfg, accountId);
const baseEnabled = params.cfg.channels?.discord?.enabled !== false;
const accountEnabled = merged.enabled !== false;
return {
accountId,
enabled: baseEnabled && accountEnabled,
name: normalizeOptionalString(merged.name),
token: "",
tokenSource: "none",
tokenStatus: "missing",
config: merged,
};
}
export function createDiscordRestClient(opts: DiscordClientOpts) {
const explicitToken = normalizeDiscordToken(opts.token, "channels.discord.token");
const proxyContext = resolveDiscordClientAccountContext(opts);
const resolvedCfg = proxyContext.cfg;
const account = explicitToken
? proxyContext.account
: resolveDiscordAccount({ cfg: resolvedCfg, accountId: opts.accountId });
const token =
explicitToken ??
resolveToken({
account,
accountId: account.accountId,
fallbackToken: account.token,
});
const rest = resolveRest(
token,
account,
resolvedCfg,
opts.rest,
proxyContext.proxyFetch,
opts.signal,
opts.timeoutMs,
);
return { token, rest, account };
}
export function createDiscordClient(opts: DiscordClientOpts): {
token: string;
rest: RequestClient;
request: RetryRunner;
} {
const { token, rest, account } = createDiscordRestClient(opts);
const request = createDiscordRetryRunner({
retry: opts.retry,
configRetry: account.config.retry,
verbose: opts.verbose,
});
return { token, rest, request };
}
export function resolveDiscordRest(opts: DiscordClientOpts) {
return createDiscordRestClient(opts).rest;
}

View File

@@ -0,0 +1,113 @@
// Discord plugin module implements component custom id behavior.
import { parseCustomId, type ComponentParserResult } from "./internal/discord.js";
export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
const ENCODED_CUSTOM_ID_VERSION = "1";
function encodeCustomIdValue(value: string): string {
return value.replace(/%/g, "%25").replace(/;/g, "%3B");
}
function needsCustomIdEncoding(value: string): boolean {
return /[%;]/.test(value);
}
function decodeCustomIdValue(value: string): string {
return value.replace(/%(25|3B)/gi, (match) => (match.toLowerCase() === "%25" ? "%" : ";"));
}
function decodeParsedCustomIdData(
data: ComponentParserResult["data"],
): ComponentParserResult["data"] {
if (data.e !== ENCODED_CUSTOM_ID_VERSION) {
return data;
}
return Object.fromEntries(
Object.entries(data).map(([key, value]) => [
key,
typeof value === "string" ? decodeCustomIdValue(value) : value,
]),
) as ComponentParserResult["data"];
}
export function buildDiscordComponentCustomId(params: {
componentId: string;
modalId?: string;
}): string {
const encoded =
needsCustomIdEncoding(params.componentId) || needsCustomIdEncoding(params.modalId ?? "");
const componentId = encoded ? encodeCustomIdValue(params.componentId) : params.componentId;
const base = encoded
? `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:e=${ENCODED_CUSTOM_ID_VERSION};cid=${componentId}`
: `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:cid=${componentId}`;
const modalId = params.modalId;
if (!modalId) {
return base;
}
return `${base};mid=${encoded ? encodeCustomIdValue(modalId) : modalId}`;
}
export function buildDiscordModalCustomId(modalId: string): string {
return needsCustomIdEncoding(modalId)
? `${DISCORD_MODAL_CUSTOM_ID_KEY}:e=${ENCODED_CUSTOM_ID_VERSION};mid=${encodeCustomIdValue(modalId)}`
: `${DISCORD_MODAL_CUSTOM_ID_KEY}:mid=${modalId}`;
}
export function parseDiscordComponentCustomId(
id: string,
): { componentId: string; modalId?: string } | null {
const parsed = parseCustomId(id);
if (parsed.key !== DISCORD_COMPONENT_CUSTOM_ID_KEY) {
return null;
}
const data = decodeParsedCustomIdData(parsed.data);
const componentId = data.cid;
if (typeof componentId !== "string" || !componentId.trim()) {
return null;
}
const modalId = data.mid;
return {
componentId,
modalId: typeof modalId === "string" && modalId.trim() ? modalId : undefined,
};
}
export function parseDiscordModalCustomId(id: string): string | null {
const parsed = parseCustomId(id);
if (parsed.key !== DISCORD_MODAL_CUSTOM_ID_KEY) {
return null;
}
const data = decodeParsedCustomIdData(parsed.data);
const modalId = data.mid;
if (typeof modalId !== "string" || !modalId.trim()) {
return null;
}
return modalId;
}
function isDiscordComponentWildcardRegistrationId(id: string): boolean {
return /^__openclaw_discord_component_[a-z_]+_wildcard__$/.test(id);
}
export function parseDiscordComponentCustomIdForInteraction(id: string): ComponentParserResult {
if (id === "*" || isDiscordComponentWildcardRegistrationId(id)) {
return { key: "*", data: {} };
}
const parsed = parseCustomId(id);
if (parsed.key !== DISCORD_COMPONENT_CUSTOM_ID_KEY) {
return parsed;
}
return { key: "*", data: decodeParsedCustomIdData(parsed.data) };
}
export function parseDiscordModalCustomIdForInteraction(id: string): ComponentParserResult {
if (id === "*" || isDiscordComponentWildcardRegistrationId(id)) {
return { key: "*", data: {} };
}
const parsed = parseCustomId(id);
if (parsed.key !== DISCORD_MODAL_CUSTOM_ID_KEY) {
return parsed;
}
return { key: "*", data: decodeParsedCustomIdData(parsed.data) };
}

View File

@@ -0,0 +1,435 @@
// Discord plugin module implements components registry behavior.
import { resolveGlobalMap } from "openclaw/plugin-sdk/global-singleton";
import {
asDateTimestampMs,
isFutureDateTimestampMs,
resolveDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
import { getOptionalDiscordRuntime } from "./runtime.js";
const DEFAULT_COMPONENT_TTL_MS = 30 * 60 * 1000;
const PERSISTENT_COMPONENT_NAMESPACE = "discord.components";
const PERSISTENT_MODAL_NAMESPACE = "discord.modals";
const PERSISTENT_COMPONENT_MAX_ENTRIES = 500;
const PERSISTENT_MODAL_MAX_ENTRIES = 500;
const DISCORD_COMPONENT_ENTRIES_KEY = Symbol.for("openclaw.discord.componentEntries");
const DISCORD_MODAL_ENTRIES_KEY = Symbol.for("openclaw.discord.modalEntries");
type PersistedDiscordRegistryEntry<T extends { id: string }> = {
version: 1;
entry: T;
};
type DiscordPersistentStore<T> = {
register(key: string, value: T, opts?: { ttlMs?: number }): Promise<void>;
lookup(key: string): Promise<T | undefined>;
consume(key: string): Promise<T | undefined>;
delete(key: string): Promise<boolean>;
};
type DiscordRegistryStore<T extends { id: string }> = DiscordPersistentStore<
PersistedDiscordRegistryEntry<T>
>;
let componentEntries: Map<string, DiscordComponentEntry> | undefined;
let modalEntries: Map<string, DiscordModalEntry> | undefined;
let persistentComponentStore: DiscordRegistryStore<DiscordComponentEntry> | undefined;
let persistentModalStore: DiscordRegistryStore<DiscordModalEntry> | undefined;
let persistentRegistryDisabled = false;
function getComponentEntries(): Map<string, DiscordComponentEntry> {
componentEntries ??= resolveGlobalMap<string, DiscordComponentEntry>(
DISCORD_COMPONENT_ENTRIES_KEY,
);
return componentEntries;
}
function getModalEntries(): Map<string, DiscordModalEntry> {
modalEntries ??= resolveGlobalMap<string, DiscordModalEntry>(DISCORD_MODAL_ENTRIES_KEY);
return modalEntries;
}
function reportPersistentComponentRegistryError(error: unknown): void {
try {
getOptionalDiscordRuntime()
?.logging.getChildLogger({ plugin: "discord", feature: "component-registry-state" })
.warn("Discord persistent component registry state failed", formatRegistryError(error));
} catch {
// Best effort only: persistent state must never break Discord interactions.
}
}
function formatRegistryError(error: unknown): Record<string, unknown> {
if (!(error instanceof Error)) {
return { error: formatRegistryErrorValue(error) };
}
const details: Record<string, unknown> = {
error: String(error),
errorName: error.name,
errorMessage: error.message,
};
if (error.stack) {
details.errorStack = error.stack;
}
const cause = (error as { cause?: unknown }).cause;
if (cause instanceof Error) {
details.errorCause = String(cause);
details.errorCauseName = cause.name;
details.errorCauseMessage = cause.message;
if (cause.stack) {
details.errorCauseStack = cause.stack;
}
} else if (cause !== undefined) {
details.errorCause = formatRegistryErrorValue(cause);
}
return details;
}
function formatRegistryErrorValue(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint" ||
typeof value === "symbol"
) {
return String(value);
}
if (value === null) {
return "null";
}
try {
return JSON.stringify(value) ?? Object.prototype.toString.call(value);
} catch {
return Object.prototype.toString.call(value);
}
}
function disablePersistentComponentRegistry(error: unknown): void {
persistentRegistryDisabled = true;
persistentComponentStore = undefined;
persistentModalStore = undefined;
reportPersistentComponentRegistryError(error);
}
function getPersistentComponentStore(): DiscordRegistryStore<DiscordComponentEntry> | undefined {
if (persistentRegistryDisabled) {
return undefined;
}
if (persistentComponentStore) {
return persistentComponentStore;
}
const runtime = getOptionalDiscordRuntime();
if (!runtime) {
return undefined;
}
try {
persistentComponentStore = runtime.state.openKeyedStore<
PersistedDiscordRegistryEntry<DiscordComponentEntry>
>({
namespace: PERSISTENT_COMPONENT_NAMESPACE,
maxEntries: PERSISTENT_COMPONENT_MAX_ENTRIES,
defaultTtlMs: DEFAULT_COMPONENT_TTL_MS,
});
return persistentComponentStore;
} catch (error) {
disablePersistentComponentRegistry(error);
return undefined;
}
}
function getPersistentModalStore(): DiscordRegistryStore<DiscordModalEntry> | undefined {
if (persistentRegistryDisabled) {
return undefined;
}
if (persistentModalStore) {
return persistentModalStore;
}
const runtime = getOptionalDiscordRuntime();
if (!runtime) {
return undefined;
}
try {
persistentModalStore = runtime.state.openKeyedStore<
PersistedDiscordRegistryEntry<DiscordModalEntry>
>({
namespace: PERSISTENT_MODAL_NAMESPACE,
maxEntries: PERSISTENT_MODAL_MAX_ENTRIES,
defaultTtlMs: DEFAULT_COMPONENT_TTL_MS,
});
return persistentModalStore;
} catch (error) {
disablePersistentComponentRegistry(error);
return undefined;
}
}
function isExpired(entry: { expiresAt?: number }, now: number) {
return entry.expiresAt !== undefined && !isFutureDateTimestampMs(entry.expiresAt, { nowMs: now });
}
function normalizeEntryTimestamps<T extends { createdAt?: number; expiresAt?: number }>(
entry: T,
now: number,
ttlMs: number,
): T {
const createdAt = resolveDateTimestampMs(entry.createdAt, now);
const expiresAt =
asDateTimestampMs(entry.expiresAt) ??
resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: createdAt }) ??
0;
return { ...entry, createdAt, expiresAt };
}
function pruneUndefinedRegistryValues<T>(value: T): T {
if (Array.isArray(value)) {
return value
.filter((entry) => entry !== undefined)
.map((entry) => pruneUndefinedRegistryValues(entry)) as T;
}
if (!value || typeof value !== "object") {
return value;
}
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
if (entry === undefined) {
continue;
}
result[key] = pruneUndefinedRegistryValues(entry);
}
return result as T;
}
function registerEntries<
T extends { id: string; messageId?: string; createdAt?: number; expiresAt?: number },
>(
entries: T[],
store: Map<string, T>,
params: { now: number; ttlMs: number; messageId?: string },
): T[] {
const normalizedEntries: T[] = [];
for (const entry of entries) {
const normalized = normalizeEntryTimestamps(
{ ...entry, messageId: params.messageId ?? entry.messageId },
params.now,
params.ttlMs,
);
store.set(entry.id, normalized);
normalizedEntries.push(normalized);
}
return normalizedEntries;
}
function resolveEntry<T extends { expiresAt?: number }>(
store: Map<string, T>,
params: { id: string; consume?: boolean },
): T | null {
const entry = store.get(params.id);
if (!entry) {
return null;
}
const now = Date.now();
if (isExpired(entry, now)) {
store.delete(params.id);
return null;
}
if (params.consume !== false) {
store.delete(params.id);
}
return entry;
}
function readPersistedRegistryEntry<T extends { id: string }>(
persisted: PersistedDiscordRegistryEntry<T> | undefined,
): T | null {
if (persisted?.version !== 1 || typeof persisted.entry?.id !== "string") {
return null;
}
return persisted.entry;
}
function registerPersistentRegistryEntries<T extends { id: string }>(params: {
entries: T[];
ttlMs: number;
openStore: () => DiscordRegistryStore<T> | undefined;
}): void {
if (params.entries.length === 0) {
return;
}
const store = params.openStore();
if (!store) {
return;
}
for (const entry of params.entries) {
const persistedEntry = pruneUndefinedRegistryValues(entry);
void store
.register(entry.id, { version: 1, entry: persistedEntry }, { ttlMs: params.ttlMs })
.catch(disablePersistentComponentRegistry);
}
}
function registerPersistentEntries(params: {
entries: DiscordComponentEntry[];
modals: DiscordModalEntry[];
ttlMs: number;
}): void {
registerPersistentRegistryEntries({
entries: params.entries,
ttlMs: params.ttlMs,
openStore: getPersistentComponentStore,
});
registerPersistentRegistryEntries({
entries: params.modals,
ttlMs: params.ttlMs,
openStore: getPersistentModalStore,
});
}
function deletePersistentEntry<T extends { id: string }>(params: {
id: string;
openStore: () => DiscordRegistryStore<T> | undefined;
}): void {
const store = params.openStore();
if (!store) {
return;
}
void store.delete(params.id).catch(disablePersistentComponentRegistry);
}
function resolveComponentConsumptionIds(entry: DiscordComponentEntry): string[] {
if (!entry.consumptionGroupId) {
return [entry.id];
}
const ids = entry.consumptionGroupEntryIds?.filter((id) => typeof id === "string" && id) ?? [];
return ids.length > 0 ? uniqueStrings(ids) : [entry.id];
}
function deleteComponentConsumptionGroup(entry: DiscordComponentEntry): void {
const store = getComponentEntries();
for (const id of resolveComponentConsumptionIds(entry)) {
store.delete(id);
}
}
function deletePersistentComponentConsumptionGroup(entry: DiscordComponentEntry): void {
const store = getPersistentComponentStore();
if (!store) {
return;
}
for (const id of resolveComponentConsumptionIds(entry)) {
void store.delete(id).catch(disablePersistentComponentRegistry);
}
}
async function resolvePersistentRegistryEntry<T extends { id: string }>(params: {
id: string;
consume?: boolean;
openStore: () => DiscordRegistryStore<T> | undefined;
}): Promise<T | null> {
const store = params.openStore();
if (!store) {
return null;
}
try {
const value =
params.consume === false ? await store.lookup(params.id) : await store.consume(params.id);
return readPersistedRegistryEntry(value);
} catch (error) {
disablePersistentComponentRegistry(error);
return null;
}
}
export function registerDiscordComponentEntries(params: {
entries: DiscordComponentEntry[];
modals: DiscordModalEntry[];
ttlMs?: number;
messageId?: string;
}): void {
const now = Date.now();
const ttlMs = params.ttlMs ?? DEFAULT_COMPONENT_TTL_MS;
const normalizedEntries = registerEntries(params.entries, getComponentEntries(), {
now,
ttlMs,
messageId: params.messageId,
});
const normalizedModals = registerEntries(params.modals, getModalEntries(), {
now,
ttlMs,
messageId: params.messageId,
});
registerPersistentEntries({
entries: normalizedEntries,
modals: normalizedModals,
ttlMs,
});
}
export function resolveDiscordComponentEntry(params: {
id: string;
consume?: boolean;
}): DiscordComponentEntry | null {
const entry = resolveEntry(getComponentEntries(), params);
if (entry && params.consume !== false) {
deleteComponentConsumptionGroup(entry);
}
return entry;
}
export async function resolveDiscordComponentEntryWithPersistence(params: {
id: string;
consume?: boolean;
}): Promise<DiscordComponentEntry | null> {
const inMemory = resolveDiscordComponentEntry(params);
if (inMemory) {
if (params.consume !== false) {
deletePersistentComponentConsumptionGroup(inMemory);
}
return inMemory;
}
const persisted = await resolvePersistentRegistryEntry({
...params,
openStore: getPersistentComponentStore,
});
if (persisted && params.consume !== false) {
deletePersistentComponentConsumptionGroup(persisted);
}
return persisted;
}
export function resolveDiscordModalEntry(params: {
id: string;
consume?: boolean;
}): DiscordModalEntry | null {
return resolveEntry(getModalEntries(), params);
}
export async function resolveDiscordModalEntryWithPersistence(params: {
id: string;
consume?: boolean;
}): Promise<DiscordModalEntry | null> {
const inMemory = resolveDiscordModalEntry(params);
if (inMemory) {
if (params.consume !== false) {
deletePersistentEntry({ ...params, openStore: getPersistentModalStore });
}
return inMemory;
}
return await resolvePersistentRegistryEntry({
...params,
openStore: getPersistentModalStore,
});
}
export function clearDiscordComponentEntries(): void {
getComponentEntries().clear();
getModalEntries().clear();
persistentComponentStore = undefined;
persistentModalStore = undefined;
persistentRegistryDisabled = false;
}

View File

@@ -0,0 +1,423 @@
// Discord plugin module implements components.builders behavior.
import crypto from "node:crypto";
import { ButtonStyle, MessageFlags } from "discord-api-types/v10";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { buildDiscordComponentCustomId as buildDiscordComponentCustomIdImpl } from "./component-custom-id.js";
import { mapButtonStyle, normalizeModalFieldName } from "./components.parse.js";
import type {
DiscordComponentBuildResult,
DiscordComponentButtonSpec,
DiscordComponentEntry,
DiscordComponentMessageSpec,
DiscordComponentSelectSpec,
DiscordComponentSelectType,
DiscordModalEntry,
} from "./components.types.js";
import {
Button,
ChannelSelectMenu,
Container,
File,
LinkButton,
MediaGallery,
MentionableSelectMenu,
RoleSelectMenu,
Row,
Section,
Separator,
StringSelectMenu,
TextDisplay,
Thumbnail,
UserSelectMenu,
type TopLevelComponents,
} from "./internal/discord.js";
function createShortId(prefix: string) {
return `${prefix}${crypto.randomBytes(6).toString("base64url")}`;
}
function buildTextDisplays(text?: string, texts?: string[]): TextDisplay[] {
if (texts && texts.length > 0) {
return texts.map((entry) => new TextDisplay(entry));
}
if (text) {
return [new TextDisplay(text)];
}
return [];
}
function createButtonComponent(params: {
spec: DiscordComponentButtonSpec;
componentId?: string;
modalId?: string;
}): { component: Button | LinkButton; entry?: DiscordComponentEntry } {
const style = mapButtonStyle(params.spec.style);
const isLink = style === ButtonStyle.Link || Boolean(params.spec.url);
if (isLink) {
if (!params.spec.url) {
throw new Error("Link buttons require a url");
}
const linkUrl = params.spec.url;
class DynamicLinkButton extends LinkButton {
label = params.spec.label;
url = linkUrl;
override disabled = params.spec.disabled ?? false;
}
return { component: new DynamicLinkButton() };
}
const componentId = params.componentId ?? createShortId("btn_");
const internalCustomId =
typeof params.spec.internalCustomId === "string" && params.spec.internalCustomId.trim()
? params.spec.internalCustomId.trim()
: undefined;
const customId =
internalCustomId ??
buildDiscordComponentCustomIdImpl({
componentId,
modalId: params.modalId,
});
class DynamicButton extends Button {
label = params.spec.label;
customId = customId;
override style = style;
override emoji = params.spec.emoji;
override disabled = params.spec.disabled ?? false;
}
if (internalCustomId) {
return {
component: new DynamicButton(),
};
}
return {
component: new DynamicButton(),
entry: {
id: componentId,
kind: params.modalId ? "modal-trigger" : "button",
label: params.spec.label,
...(params.spec.callbackData !== undefined ? { callbackData: params.spec.callbackData } : {}),
...(params.spec.callbackDataKind !== undefined
? { callbackDataKind: params.spec.callbackDataKind }
: {}),
...(params.modalId !== undefined ? { modalId: params.modalId } : {}),
...(params.spec.reusable !== undefined ? { reusable: params.spec.reusable } : {}),
...(params.spec.allowedUsers !== undefined ? { allowedUsers: params.spec.allowedUsers } : {}),
},
};
}
function createSelectComponent(params: {
spec: DiscordComponentSelectSpec;
componentId?: string;
}): {
component:
| StringSelectMenu
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu;
entry: DiscordComponentEntry;
} {
const type = normalizeLowercaseStringOrEmpty(
params.spec.type ?? "string",
) as DiscordComponentSelectType;
const componentId = params.componentId ?? createShortId("sel_");
const customId = buildDiscordComponentCustomIdImpl({ componentId });
const createEntry = (
selectType: DiscordComponentSelectType,
label: string,
options?: DiscordComponentEntry["options"],
): DiscordComponentEntry => ({
id: componentId,
kind: "select",
label,
...(params.spec.callbackData !== undefined ? { callbackData: params.spec.callbackData } : {}),
...(params.spec.callbackDataKind !== undefined
? { callbackDataKind: params.spec.callbackDataKind }
: {}),
selectType,
...(options ? { options } : {}),
...(params.spec.allowedUsers !== undefined ? { allowedUsers: params.spec.allowedUsers } : {}),
});
if (type === "string") {
const options = params.spec.options ?? [];
if (options.length === 0) {
throw new Error("String select menus require options");
}
class DynamicStringSelect extends StringSelectMenu {
customId = customId;
override options = options;
override minValues = params.spec.minValues;
override maxValues = params.spec.maxValues;
override placeholder = params.spec.placeholder;
override disabled = false;
}
return {
component: new DynamicStringSelect(),
entry: createEntry(
"string",
params.spec.placeholder ?? "select",
options.map((option) => ({ value: option.value, label: option.label })),
),
};
}
if (type === "user") {
class DynamicUserSelect extends UserSelectMenu {
customId = customId;
override minValues = params.spec.minValues;
override maxValues = params.spec.maxValues;
override placeholder = params.spec.placeholder;
override disabled = false;
}
return {
component: new DynamicUserSelect(),
entry: createEntry("user", params.spec.placeholder ?? "user select"),
};
}
if (type === "role") {
class DynamicRoleSelect extends RoleSelectMenu {
customId = customId;
override minValues = params.spec.minValues;
override maxValues = params.spec.maxValues;
override placeholder = params.spec.placeholder;
override disabled = false;
}
return {
component: new DynamicRoleSelect(),
entry: createEntry("role", params.spec.placeholder ?? "role select"),
};
}
if (type === "mentionable") {
class DynamicMentionableSelect extends MentionableSelectMenu {
customId = customId;
override minValues = params.spec.minValues;
override maxValues = params.spec.maxValues;
override placeholder = params.spec.placeholder;
override disabled = false;
}
return {
component: new DynamicMentionableSelect(),
entry: createEntry("mentionable", params.spec.placeholder ?? "mentionable select"),
};
}
class DynamicChannelSelect extends ChannelSelectMenu {
customId = customId;
override minValues = params.spec.minValues;
override maxValues = params.spec.maxValues;
override placeholder = params.spec.placeholder;
override disabled = false;
}
return {
component: new DynamicChannelSelect(),
entry: createEntry("channel", params.spec.placeholder ?? "channel select"),
};
}
function isSelectComponent(
component: unknown,
): component is
| StringSelectMenu
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu {
return (
component instanceof StringSelectMenu ||
component instanceof UserSelectMenu ||
component instanceof RoleSelectMenu ||
component instanceof MentionableSelectMenu ||
component instanceof ChannelSelectMenu
);
}
export function buildDiscordComponentMessage(params: {
spec: DiscordComponentMessageSpec;
fallbackText?: string;
sessionKey?: string;
agentId?: string;
accountId?: string;
}): DiscordComponentBuildResult {
const entries: DiscordComponentEntry[] = [];
const consumptionGroupId = createShortId("grp_");
const modals: DiscordModalEntry[] = [];
const components: TopLevelComponents[] = [];
const containerChildren: Array<
| Row<
| Button
| LinkButton
| StringSelectMenu
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu
>
| TextDisplay
| Section
| MediaGallery
| Separator
| File
> = [];
const addEntry = (entry: DiscordComponentEntry) => {
const reusable = entry.reusable ?? params.spec.reusable;
entries.push({
...entry,
...(params.sessionKey !== undefined ? { sessionKey: params.sessionKey } : {}),
...(params.agentId !== undefined ? { agentId: params.agentId } : {}),
...(params.accountId !== undefined ? { accountId: params.accountId } : {}),
...(reusable !== undefined ? { reusable } : {}),
consumptionGroupId,
});
};
const text = params.spec.text ?? params.fallbackText;
if (text) {
containerChildren.push(new TextDisplay(text));
}
for (const block of params.spec.blocks ?? []) {
if (block.type === "text") {
containerChildren.push(new TextDisplay(block.text));
continue;
}
if (block.type === "section") {
const displays = buildTextDisplays(block.text, block.texts);
if (displays.length > 3) {
throw new Error("Section blocks support up to 3 text displays");
}
let accessory: Thumbnail | Button | LinkButton | undefined;
if (block.accessory?.type === "thumbnail") {
accessory = new Thumbnail(block.accessory.url);
} else if (block.accessory?.type === "button") {
const { component, entry } = createButtonComponent({ spec: block.accessory.button });
accessory = component;
if (entry) {
addEntry(entry);
}
}
containerChildren.push(new Section(displays, accessory));
continue;
}
if (block.type === "separator") {
containerChildren.push(new Separator({ spacing: block.spacing, divider: block.divider }));
continue;
}
if (block.type === "media-gallery") {
containerChildren.push(new MediaGallery(block.items));
continue;
}
if (block.type === "file") {
containerChildren.push(new File(block.file, block.spoiler));
continue;
}
if (block.type === "actions") {
const rowComponents: Array<
| Button
| LinkButton
| StringSelectMenu
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu
> = [];
if (block.buttons) {
if (block.buttons.length > 5) {
throw new Error("Action rows support up to 5 buttons");
}
for (const button of block.buttons) {
const { component, entry } = createButtonComponent({ spec: button });
rowComponents.push(component);
if (entry) {
addEntry(entry);
}
}
} else if (block.select) {
const { component, entry } = createSelectComponent({ spec: block.select });
rowComponents.push(component);
addEntry(entry);
}
containerChildren.push(new Row(rowComponents));
}
}
if (params.spec.modal) {
const modalId = createShortId("mdl_");
const fields = params.spec.modal.fields.map((field, index) => ({
id: createShortId("fld_"),
name: normalizeModalFieldName(field.name, index),
label: field.label,
type: field.type,
...(field.description !== undefined ? { description: field.description } : {}),
...(field.placeholder !== undefined ? { placeholder: field.placeholder } : {}),
...(field.required !== undefined ? { required: field.required } : {}),
...(field.options !== undefined ? { options: field.options } : {}),
...(field.minValues !== undefined ? { minValues: field.minValues } : {}),
...(field.maxValues !== undefined ? { maxValues: field.maxValues } : {}),
...(field.minLength !== undefined ? { minLength: field.minLength } : {}),
...(field.maxLength !== undefined ? { maxLength: field.maxLength } : {}),
...(field.style !== undefined ? { style: field.style } : {}),
}));
modals.push({
id: modalId,
title: params.spec.modal.title,
fields,
...(params.spec.modal.callbackData !== undefined
? { callbackData: params.spec.modal.callbackData }
: {}),
...(params.sessionKey !== undefined ? { sessionKey: params.sessionKey } : {}),
...(params.agentId !== undefined ? { agentId: params.agentId } : {}),
...(params.accountId !== undefined ? { accountId: params.accountId } : {}),
...(params.spec.reusable !== undefined ? { reusable: params.spec.reusable } : {}),
...(params.spec.modal.allowedUsers !== undefined
? { allowedUsers: params.spec.modal.allowedUsers }
: {}),
});
const triggerSpec: DiscordComponentButtonSpec = {
label: params.spec.modal.triggerLabel ?? "Open form",
style: params.spec.modal.triggerStyle ?? "primary",
allowedUsers: params.spec.modal.allowedUsers,
};
const { component, entry } = createButtonComponent({
spec: triggerSpec,
modalId,
});
if (entry) {
addEntry(entry);
}
const lastChild = containerChildren.at(-1);
if (lastChild instanceof Row) {
const row = lastChild;
const hasSelect = row.components.some((entryLocal) => isSelectComponent(entryLocal));
if (row.components.length < 5 && !hasSelect) {
row.addComponent(component as Button);
} else {
containerChildren.push(new Row([component as Button]));
}
} else {
containerChildren.push(new Row([component as Button]));
}
}
if (containerChildren.length === 0) {
throw new Error("components must include at least one block, text, or modal trigger");
}
const container = new Container(containerChildren, params.spec.container);
components.push(container);
const consumptionGroupEntryIds = entries.map((entry) => entry.id);
for (const entry of entries) {
entry.consumptionGroupEntryIds = consumptionGroupEntryIds;
}
return { components, entries, modals };
}
export function buildDiscordComponentMessageFlags(
components: TopLevelComponents[],
): number | undefined {
const hasV2 = components.some((component) => component.isV2);
return hasV2 ? MessageFlags.IsComponentsV2 : undefined;
}

View File

@@ -0,0 +1,123 @@
// Discord plugin module implements components.modal behavior.
import {
buildDiscordModalCustomId as buildDiscordModalCustomIdImpl,
parseDiscordModalCustomIdForInteraction as parseDiscordModalCustomIdForInteractionImpl,
} from "./component-custom-id.js";
import { mapTextInputStyle } from "./components.parse.js";
import type { DiscordModalEntry, DiscordModalFieldDefinition } from "./components.types.js";
import {
CheckboxGroup,
Label,
Modal,
RadioGroup,
RoleSelectMenu,
StringSelectMenu,
TextDisplay,
TextInput,
UserSelectMenu,
} from "./internal/discord.js";
// Some test-only module graphs partially mock `./internal/discord.js` and can drop `Modal`.
// Keep dynamic form definitions loadable instead of crashing unrelated suites.
const ModalBase: typeof Modal = Modal ?? (function ModalFallback() {} as unknown as typeof Modal);
function createModalFieldComponent(
field: DiscordModalFieldDefinition,
): TextInput | StringSelectMenu | UserSelectMenu | RoleSelectMenu | CheckboxGroup | RadioGroup {
if (field.type === "text") {
class DynamicTextInput extends TextInput {
customId = field.id;
override style = mapTextInputStyle(field.style);
override placeholder = field.placeholder;
override required = field.required;
override minLength = field.minLength;
override maxLength = field.maxLength;
}
return new DynamicTextInput();
}
if (field.type === "select") {
const options = field.options ?? [];
class DynamicModalSelect extends StringSelectMenu {
customId = field.id;
override options = options;
override required = field.required;
override minValues = field.minValues;
override maxValues = field.maxValues;
override placeholder = field.placeholder;
}
return new DynamicModalSelect();
}
if (field.type === "role-select") {
class DynamicModalRoleSelect extends RoleSelectMenu {
customId = field.id;
override required = field.required;
override minValues = field.minValues;
override maxValues = field.maxValues;
override placeholder = field.placeholder;
}
return new DynamicModalRoleSelect();
}
if (field.type === "user-select") {
class DynamicModalUserSelect extends UserSelectMenu {
customId = field.id;
override required = field.required;
override minValues = field.minValues;
override maxValues = field.maxValues;
override placeholder = field.placeholder;
}
return new DynamicModalUserSelect();
}
if (field.type === "checkbox") {
const options = field.options ?? [];
class DynamicCheckboxGroup extends CheckboxGroup {
customId = field.id;
override options = options;
override required = field.required;
override minValues = field.minValues;
override maxValues = field.maxValues;
}
return new DynamicCheckboxGroup();
}
const options = field.options ?? [];
class DynamicRadioGroup extends RadioGroup {
customId = field.id;
override options = options;
override required = field.required;
}
return new DynamicRadioGroup();
}
export class DiscordFormModal extends ModalBase {
override title: string;
override customId: string;
override components: Array<Label | TextDisplay>;
override customIdParser = parseDiscordModalCustomIdForInteractionImpl;
constructor(params: { modalId: string; title: string; fields: DiscordModalFieldDefinition[] }) {
super();
this.title = params.title;
this.customId = buildDiscordModalCustomIdImpl(params.modalId);
this.components = params.fields.map((field) => {
const component = createModalFieldComponent(field);
class DynamicLabel extends Label {
override label = field.label;
override description = field.description;
override component = component;
override customId = field.id;
}
return new DynamicLabel(component);
});
}
async run(): Promise<void> {
throw new Error("Modal handler is not registered for dynamic forms");
}
}
export function createDiscordFormModal(entry: DiscordModalEntry): Modal {
return new DiscordFormModal({
modalId: entry.id,
title: entry.title,
fields: entry.fields,
});
}

View File

@@ -0,0 +1,454 @@
// Discord plugin module implements components.parse behavior.
import { ButtonStyle, TextInputStyle } from "discord-api-types/v10";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import type {
DiscordComponentBlock,
DiscordComponentButtonSpec,
DiscordComponentButtonStyle,
DiscordComponentCallbackDataKind,
DiscordComponentMessageSpec,
DiscordComponentModalFieldType,
DiscordComponentSectionAccessory,
DiscordComponentSelectOption,
DiscordComponentSelectSpec,
DiscordComponentSelectType,
DiscordModalFieldSpec,
DiscordModalSpec,
} from "./components.types.js";
export const DISCORD_COMPONENT_ATTACHMENT_PREFIX = "attachment://";
type DiscordComponentSeparatorSpacing = "small" | "large" | 1 | 2;
const BLOCK_ALIASES = new Map<string, DiscordComponentBlock["type"]>([
["row", "actions"],
["action-row", "actions"],
]);
function requireObject(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function readString(value: unknown, label: string, opts?: { allowEmpty?: boolean }): string {
if (typeof value !== "string") {
throw new Error(`${label} must be a string`);
}
const trimmed = value.trim();
if (!opts?.allowEmpty && !trimmed) {
throw new Error(`${label} cannot be empty`);
}
return opts?.allowEmpty ? value : trimmed;
}
function readOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function readOptionalCallbackDataKind(
value: unknown,
label: string,
): DiscordComponentCallbackDataKind | undefined {
const kind = readOptionalString(value);
if (kind === undefined) {
return undefined;
}
if (kind === "command" || kind === "callback") {
return kind;
}
throw new Error(`${label} must be one of command, callback`);
}
function readOptionalStringArray(value: unknown, label: string): string[] | undefined {
if (value === undefined) {
return undefined;
}
if (!Array.isArray(value)) {
throw new Error(`${label} must be an array`);
}
if (value.length === 0) {
return undefined;
}
return value.map((entry, index) => readString(entry, `${label}[${index}]`));
}
function readOptionalInteger(
value: unknown,
label: string,
bounds?: { min?: number; max?: number },
): number | undefined {
if (value == null) {
return undefined;
}
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) {
throw new Error(`${label} must be an integer`);
}
if (bounds?.min !== undefined && value < bounds.min) {
throw new Error(`${label} must be at least ${bounds.min}`);
}
if (bounds?.max !== undefined && value > bounds.max) {
throw new Error(`${label} must be at most ${bounds.max}`);
}
return value;
}
function readOptionalEmoji(value: unknown, label: string) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const obj = value as { name?: unknown; id?: unknown; animated?: unknown };
return {
name: readString(obj.name, `${label}.name`),
id: readOptionalString(obj.id),
animated: typeof obj.animated === "boolean" ? obj.animated : undefined,
};
}
export function normalizeModalFieldName(value: string | undefined, index: number) {
const trimmed = value?.trim();
if (trimmed) {
return trimmed;
}
return `field_${index + 1}`;
}
function normalizeAttachmentRef(value: string, label: string): `attachment://${string}` {
const trimmed = value.trim();
if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) {
throw new Error(`${label} must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`);
}
const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim();
if (!attachmentName) {
throw new Error(`${label} must include an attachment filename`);
}
return `${DISCORD_COMPONENT_ATTACHMENT_PREFIX}${attachmentName}`;
}
export function resolveDiscordComponentAttachmentName(value: string): string {
const trimmed = value.trim();
if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) {
throw new Error(
`Attachment reference must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`,
);
}
const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim();
if (!attachmentName) {
throw new Error("Attachment reference must include a filename");
}
return attachmentName;
}
export function mapButtonStyle(style?: DiscordComponentButtonStyle): ButtonStyle {
switch (normalizeLowercaseStringOrEmpty(style ?? "primary")) {
case "secondary":
return ButtonStyle.Secondary;
case "success":
return ButtonStyle.Success;
case "danger":
return ButtonStyle.Danger;
case "link":
return ButtonStyle.Link;
default:
return ButtonStyle.Primary;
}
}
export function mapTextInputStyle(style?: DiscordModalFieldSpec["style"]) {
return style === "paragraph" ? TextInputStyle.Paragraph : TextInputStyle.Short;
}
function normalizeBlockType(raw: string) {
const lowered = normalizeLowercaseStringOrEmpty(raw);
return BLOCK_ALIASES.get(lowered) ?? (lowered as DiscordComponentBlock["type"]);
}
function parseSelectOptions(
raw: unknown,
label: string,
): DiscordComponentSelectOption[] | undefined {
if (raw === undefined) {
return undefined;
}
if (!Array.isArray(raw)) {
throw new Error(`${label} must be an array`);
}
return raw.map((entry, index) => {
const obj = requireObject(entry, `${label}[${index}]`);
return {
label: readString(obj.label, `${label}[${index}].label`),
value: readString(obj.value, `${label}[${index}].value`),
description: readOptionalString(obj.description),
emoji: readOptionalEmoji(obj.emoji, `${label}[${index}].emoji`),
default: typeof obj.default === "boolean" ? obj.default : undefined,
};
});
}
function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpec {
const obj = requireObject(raw, label);
const style = readOptionalString(obj.style) as DiscordComponentButtonStyle | undefined;
const url = readOptionalString(obj.url);
if ((style === "link" || url) && !url) {
throw new Error(`${label}.url is required for link buttons`);
}
return {
label: readString(obj.label, `${label}.label`),
style,
url,
callbackData: readOptionalString(obj.callbackData),
callbackDataKind: readOptionalCallbackDataKind(
obj.callbackDataKind,
`${label}.callbackDataKind`,
),
emoji: readOptionalEmoji(obj.emoji, `${label}.emoji`),
disabled: typeof obj.disabled === "boolean" ? obj.disabled : undefined,
allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`),
};
}
function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpec {
const obj = requireObject(raw, label);
const type = readOptionalString(obj.type) as DiscordComponentSelectType | undefined;
const allowedTypes: DiscordComponentSelectType[] = [
"string",
"user",
"role",
"mentionable",
"channel",
];
if (type && !allowedTypes.includes(type)) {
throw new Error(`${label}.type must be one of ${allowedTypes.join(", ")}`);
}
return {
type,
callbackData: readOptionalString(obj.callbackData),
callbackDataKind: readOptionalCallbackDataKind(
obj.callbackDataKind,
`${label}.callbackDataKind`,
),
placeholder: readOptionalString(obj.placeholder),
minValues: readOptionalInteger(obj.minValues, `${label}.minValues`, { min: 0, max: 25 }),
maxValues: readOptionalInteger(obj.maxValues, `${label}.maxValues`, { min: 1, max: 25 }),
options: parseSelectOptions(obj.options, `${label}.options`),
allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`),
};
}
function parseModalField(raw: unknown, label: string, index: number): DiscordModalFieldSpec {
const obj = requireObject(raw, label);
const type = normalizeLowercaseStringOrEmpty(
readString(obj.type, `${label}.type`),
) as DiscordComponentModalFieldType;
const supported: DiscordComponentModalFieldType[] = [
"text",
"checkbox",
"radio",
"select",
"role-select",
"user-select",
];
if (!supported.includes(type)) {
throw new Error(`${label}.type must be one of ${supported.join(", ")}`);
}
const options = parseSelectOptions(obj.options, `${label}.options`);
if (["checkbox", "radio", "select"].includes(type) && (!options || options.length === 0)) {
throw new Error(`${label}.options is required for ${type} fields`);
}
if (type === "radio" && (obj.minValues != null || obj.maxValues != null)) {
throw new Error(`${label}.minValues/maxValues are not supported for radio fields`);
}
const required = typeof obj.required === "boolean" ? obj.required : undefined;
const maxValues = type === "checkbox" ? 10 : 25;
return {
type,
name: normalizeModalFieldName(readOptionalString(obj.name), index),
label: readString(obj.label, `${label}.label`),
description: readOptionalString(obj.description),
placeholder: readOptionalString(obj.placeholder),
required,
options,
minValues: readOptionalInteger(obj.minValues, `${label}.minValues`, {
min: required === false ? 0 : 1,
max: maxValues,
}),
maxValues: readOptionalInteger(obj.maxValues, `${label}.maxValues`, {
min: 1,
max: maxValues,
}),
minLength: readOptionalInteger(obj.minLength, `${label}.minLength`, { min: 0, max: 4000 }),
maxLength: readOptionalInteger(obj.maxLength, `${label}.maxLength`, { min: 1, max: 4000 }),
style: readOptionalString(obj.style) as DiscordModalFieldSpec["style"],
};
}
function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock {
const obj = requireObject(raw, label);
const typeRaw = normalizeLowercaseStringOrEmpty(readString(obj.type, `${label}.type`));
const type = normalizeBlockType(typeRaw);
switch (type) {
case "text":
return {
type: "text",
text: readString(obj.text, `${label}.text`),
};
case "section": {
const text = readOptionalString(obj.text);
const textsRaw = obj.texts;
const texts = Array.isArray(textsRaw)
? textsRaw.map((entry, idx) => readString(entry, `${label}.texts[${idx}]`))
: undefined;
if (!text && (!texts || texts.length === 0)) {
throw new Error(`${label}.text or ${label}.texts is required for section blocks`);
}
let accessory: DiscordComponentSectionAccessory | undefined;
if (obj.accessory !== undefined) {
const accessoryObj = requireObject(obj.accessory, `${label}.accessory`);
const accessoryType = normalizeLowercaseStringOrEmpty(
readString(accessoryObj.type, `${label}.accessory.type`),
);
if (accessoryType === "thumbnail") {
accessory = {
type: "thumbnail",
url: readString(accessoryObj.url, `${label}.accessory.url`),
};
} else if (accessoryType === "button") {
accessory = {
type: "button",
button: parseButtonSpec(accessoryObj.button, `${label}.accessory.button`),
};
} else {
throw new Error(`${label}.accessory.type must be "thumbnail" or "button"`);
}
}
return {
type: "section",
text,
texts,
accessory,
};
}
case "separator": {
const spacingRaw = obj.spacing;
let spacing: DiscordComponentSeparatorSpacing | undefined;
if (spacingRaw === "small" || spacingRaw === "large") {
spacing = spacingRaw;
} else if (spacingRaw === 1 || spacingRaw === 2) {
spacing = spacingRaw;
} else if (spacingRaw !== undefined) {
throw new Error(`${label}.spacing must be "small", "large", 1, or 2`);
}
const divider = typeof obj.divider === "boolean" ? obj.divider : undefined;
return {
type: "separator",
spacing,
divider,
};
}
case "actions": {
const buttonsRaw = obj.buttons;
const buttons = Array.isArray(buttonsRaw)
? buttonsRaw.map((entry, idx) => parseButtonSpec(entry, `${label}.buttons[${idx}]`))
: undefined;
const select = obj.select ? parseSelectSpec(obj.select, `${label}.select`) : undefined;
if ((!buttons || buttons.length === 0) && !select) {
throw new Error(`${label} requires buttons or select`);
}
if (buttons && select) {
throw new Error(`${label} cannot include both buttons and select`);
}
return {
type: "actions",
buttons,
select,
};
}
case "media-gallery": {
const itemsRaw = obj.items;
if (!Array.isArray(itemsRaw) || itemsRaw.length === 0) {
throw new Error(`${label}.items must be a non-empty array`);
}
const items = itemsRaw.map((entry, idx) => {
const itemObj = requireObject(entry, `${label}.items[${idx}]`);
return {
url: readString(itemObj.url, `${label}.items[${idx}].url`),
description: readOptionalString(itemObj.description),
spoiler: typeof itemObj.spoiler === "boolean" ? itemObj.spoiler : undefined,
};
});
return {
type: "media-gallery",
items,
};
}
case "file": {
const file = readString(obj.file, `${label}.file`);
return {
type: "file",
file: normalizeAttachmentRef(file, `${label}.file`),
spoiler: typeof obj.spoiler === "boolean" ? obj.spoiler : undefined,
};
}
default:
throw new Error(`${label}.type must be a supported component block`);
}
}
export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageSpec | null {
if (raw === undefined || raw === null) {
return null;
}
const obj = requireObject(raw, "components");
const blocksRaw = obj.blocks;
const blocks = Array.isArray(blocksRaw)
? blocksRaw.map((entry, idx) => parseComponentBlock(entry, `components.blocks[${idx}]`))
: undefined;
const modalRaw = obj.modal;
const reusable = typeof obj.reusable === "boolean" ? obj.reusable : undefined;
let modal: DiscordModalSpec | undefined;
if (modalRaw !== undefined) {
const modalObj = requireObject(modalRaw, "components.modal");
const fieldsRaw = modalObj.fields;
if (!Array.isArray(fieldsRaw) || fieldsRaw.length === 0) {
throw new Error("components.modal.fields must be a non-empty array");
}
if (fieldsRaw.length > 5) {
throw new Error("components.modal.fields supports up to 5 inputs");
}
const fields = fieldsRaw.map((entry, idx) =>
parseModalField(entry, `components.modal.fields[${idx}]`, idx),
);
modal = {
title: readString(modalObj.title, "components.modal.title"),
callbackData: readOptionalString(modalObj.callbackData),
triggerLabel: readOptionalString(modalObj.triggerLabel),
triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle,
allowedUsers: readOptionalStringArray(modalObj.allowedUsers, "components.modal.allowedUsers"),
fields,
};
}
return {
text: readOptionalString(obj.text),
reusable,
container:
typeof obj.container === "object" && obj.container && !Array.isArray(obj.container)
? {
accentColor: (obj.container as { accentColor?: unknown }).accentColor as
| string
| number
| undefined,
spoiler:
typeof (obj.container as { spoiler?: unknown }).spoiler === "boolean"
? ((obj.container as { spoiler?: boolean }).spoiler as boolean)
: undefined,
}
: undefined,
blocks,
modal,
};
}

View File

@@ -0,0 +1,675 @@
// Discord tests cover components plugin behavior.
import { ButtonStyle, MessageFlags } from "discord-api-types/v10";
import { MAX_DATE_TIMESTAMP_MS } from "openclaw/plugin-sdk/number-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
let clearDiscordComponentEntries: typeof import("./components-registry.js").clearDiscordComponentEntries;
let registerDiscordComponentEntries: typeof import("./components-registry.js").registerDiscordComponentEntries;
let resolveDiscordComponentEntry: typeof import("./components-registry.js").resolveDiscordComponentEntry;
let resolveDiscordComponentEntryWithPersistence: typeof import("./components-registry.js").resolveDiscordComponentEntryWithPersistence;
let resolveDiscordModalEntry: typeof import("./components-registry.js").resolveDiscordModalEntry;
let resolveDiscordModalEntryWithPersistence: typeof import("./components-registry.js").resolveDiscordModalEntryWithPersistence;
let buildDiscordComponentCustomId: typeof import("./components.js").buildDiscordComponentCustomId;
let buildDiscordComponentMessage: typeof import("./components.js").buildDiscordComponentMessage;
let buildDiscordComponentMessageFlags: typeof import("./components.js").buildDiscordComponentMessageFlags;
let buildDiscordModalCustomId: typeof import("./components.js").buildDiscordModalCustomId;
let parseDiscordComponentCustomId: typeof import("./components.js").parseDiscordComponentCustomId;
let parseDiscordComponentCustomIdForInteraction: typeof import("./components.js").parseDiscordComponentCustomIdForInteraction;
let parseDiscordModalCustomId: typeof import("./components.js").parseDiscordModalCustomId;
let parseDiscordModalCustomIdForInteraction: typeof import("./components.js").parseDiscordModalCustomIdForInteraction;
let readDiscordComponentSpec: typeof import("./components.js").readDiscordComponentSpec;
beforeAll(async () => {
({
clearDiscordComponentEntries,
registerDiscordComponentEntries,
resolveDiscordComponentEntry,
resolveDiscordComponentEntryWithPersistence,
resolveDiscordModalEntry,
resolveDiscordModalEntryWithPersistence,
} = await import("./components-registry.js"));
({
buildDiscordComponentCustomId,
buildDiscordComponentMessage,
buildDiscordComponentMessageFlags,
buildDiscordModalCustomId,
parseDiscordComponentCustomId,
parseDiscordComponentCustomIdForInteraction,
parseDiscordModalCustomId,
parseDiscordModalCustomIdForInteraction,
readDiscordComponentSpec,
} = await import("./components.js"));
});
describe("discord components", () => {
it("round-trips custom id values that contain separators", () => {
const componentId = "button=a;two space%3B";
const modalId = "modal=x;y space%3D";
const componentCustomId = buildDiscordComponentCustomId({ componentId, modalId });
expect(componentCustomId).not.toContain(componentId);
expect(componentCustomId).toContain("space");
expect(parseDiscordComponentCustomId(componentCustomId)).toEqual({ componentId, modalId });
expect(parseDiscordComponentCustomIdForInteraction(componentCustomId).data).toMatchObject({
cid: componentId,
mid: modalId,
});
const modalCustomId = buildDiscordModalCustomId(modalId);
expect(modalCustomId).not.toContain(modalId);
expect(modalCustomId).toContain("space");
expect(parseDiscordModalCustomId(modalCustomId)).toBe(modalId);
expect(parseDiscordModalCustomIdForInteraction(modalCustomId).data).toMatchObject({
mid: modalId,
});
});
it("keeps legacy percent-like custom id values raw", () => {
expect(buildDiscordComponentCustomId({ componentId: "button_v1" })).toBe(
"occomp:cid=button_v1",
);
expect(buildDiscordComponentCustomId({ componentId: "button=v1" })).toBe(
"occomp:cid=button=v1",
);
expect(buildDiscordModalCustomId("modal_v1")).toBe("ocmodal:mid=modal_v1");
expect(buildDiscordModalCustomId("modal=v1")).toBe("ocmodal:mid=modal=v1");
expect(parseDiscordComponentCustomId("occomp:cid=button%3Bv1")).toEqual({
componentId: "button%3Bv1",
});
expect(parseDiscordModalCustomId("ocmodal:mid=modal%3Dv1")).toBe("modal%3Dv1");
});
it("builds v2 containers with modal trigger", () => {
const spec = readDiscordComponentSpec({
text: "Choose a path",
blocks: [
{
type: "actions",
buttons: [{ label: "Approve", style: "success", callbackData: "codex:approve" }],
},
],
modal: {
title: "Details",
callbackData: "codex:modal",
allowedUsers: ["discord:user-1"],
fields: [{ type: "text", label: "Requester" }],
},
});
if (!spec) {
throw new Error("Expected component spec to be parsed");
}
const result = buildDiscordComponentMessage({ spec });
expect(result.components).toHaveLength(1);
expect(result.components[0]?.isV2).toBe(true);
expect(buildDiscordComponentMessageFlags(result.components)).toBe(MessageFlags.IsComponentsV2);
expect(result.modals).toHaveLength(1);
const trigger = result.entries.find((entry) => entry.kind === "modal-trigger");
expect(trigger?.modalId).toBe(result.modals[0]?.id);
expect(result.entries.find((entry) => entry.kind === "button")?.callbackData).toBe(
"codex:approve",
);
expect(result.modals[0]?.callbackData).toBe("codex:modal");
expect(result.modals[0]?.allowedUsers).toEqual(["discord:user-1"]);
});
it("serializes disabled link buttons", () => {
const spec = readDiscordComponentSpec({
blocks: [
{
type: "actions",
buttons: [
{
label: "Open docs",
style: "link",
url: "https://example.com/docs",
disabled: true,
},
],
},
],
});
if (!spec) {
throw new Error("Expected component spec to be parsed");
}
const result = buildDiscordComponentMessage({ spec });
const serialized = result.components[0]?.serialize() as
| { components?: Array<{ components?: Array<Record<string, unknown>> }> }
| undefined;
const button = serialized?.components?.[0]?.components?.[0];
expect(button).toMatchObject({
label: "Open docs",
style: ButtonStyle.Link,
url: "https://example.com/docs",
disabled: true,
});
expect(result.entries).toHaveLength(0);
});
it("omits unset optional fields from persisted button entries", () => {
const spec = readDiscordComponentSpec({
blocks: [
{
type: "actions",
buttons: [{ label: "Allow Once", style: "success" }],
},
],
});
if (!spec) {
throw new Error("Expected component spec to be parsed");
}
const result = buildDiscordComponentMessage({ spec });
const entry = result.entries[0];
if (!entry) {
throw new Error("Expected button entry");
}
expect(Object.entries(entry).filter(([, value]) => value === undefined)).toEqual([]);
});
it("requires options for modal select fields", () => {
expect(() =>
readDiscordComponentSpec({
modal: {
title: "Details",
fields: [{ type: "select", label: "Priority" }],
},
}),
).toThrow("options");
});
it("rejects malformed component count and length limits", () => {
expect(() =>
readDiscordComponentSpec({
blocks: [
{
type: "actions",
select: {
type: "string",
minValues: -1,
options: [{ label: "One", value: "one" }],
},
},
],
}),
).toThrow("components.blocks[0].select.minValues");
expect(() =>
readDiscordComponentSpec({
modal: {
title: "Details",
fields: [{ type: "text", label: "Name", maxLength: 0 }],
},
}),
).toThrow("components.modal.fields[0].maxLength");
expect(() =>
readDiscordComponentSpec({
modal: {
title: "Details",
fields: [
{
type: "select",
label: "Priority",
minValues: 0,
options: [{ label: "High", value: "high" }],
},
],
},
}),
).toThrow("components.modal.fields[0].minValues");
expect(() =>
readDiscordComponentSpec({
modal: {
title: "Details",
fields: [
{
type: "checkbox",
label: "Choices",
maxValues: 25,
options: [{ label: "One", value: "one" }],
},
],
},
}),
).toThrow("components.modal.fields[0].maxValues");
expect(() =>
readDiscordComponentSpec({
blocks: [
{
type: "actions",
select: {
type: "string",
maxValues: 0,
options: [{ label: "One", value: "one" }],
},
},
],
}),
).toThrow("components.blocks[0].select.maxValues");
expect(() =>
readDiscordComponentSpec({
modal: {
title: "Details",
fields: [
{
type: "radio",
label: "Choice",
minValues: 1,
options: [{ label: "One", value: "one" }],
},
],
},
}),
).toThrow("components.modal.fields[0].minValues/maxValues");
});
it("requires attachment references for file blocks", () => {
expect(() =>
readDiscordComponentSpec({
blocks: [{ type: "file", file: "https://example.com/report.pdf" }],
}),
).toThrow("attachment://");
expect(() =>
readDiscordComponentSpec({
blocks: [{ type: "file", file: "attachment://" }],
}),
).toThrow("filename");
});
});
describe("discord component registry", () => {
beforeEach(() => {
clearDiscordComponentEntries();
vi.restoreAllMocks();
});
const componentsRegistryModuleUrl = new URL("./components-registry.ts", import.meta.url).href;
it("registers and consumes component entries", () => {
registerDiscordComponentEntries({
entries: [{ id: "btn_1", kind: "button", label: "Confirm" }],
modals: [
{
id: "mdl_1",
title: "Details",
fields: [{ id: "fld_1", name: "name", label: "Name", type: "text" }],
},
],
messageId: "msg_1",
ttlMs: 1000,
});
const entry = resolveDiscordComponentEntry({ id: "btn_1", consume: false });
expect(entry?.messageId).toBe("msg_1");
const modal = resolveDiscordModalEntry({ id: "mdl_1", consume: false });
expect(modal?.messageId).toBe("msg_1");
const consumed = resolveDiscordComponentEntry({ id: "btn_1" });
expect(consumed?.id).toBe("btn_1");
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
});
it("consumes sibling entries from the same non-reusable component message", () => {
const result = buildDiscordComponentMessage({
spec: {
text: "Confirm action",
blocks: [
{
type: "actions",
buttons: [
{ label: "Confirm", callbackData: "confirm" },
{ label: "Cancel", callbackData: "cancel" },
],
},
],
},
});
const confirm = result.entries.find((entry) => entry.label === "Confirm");
const cancel = result.entries.find((entry) => entry.label === "Cancel");
if (!confirm?.consumptionGroupId) {
throw new Error("expected confirm entry to carry a consumption group id");
}
if (!cancel) {
throw new Error("expected cancel entry");
}
expect(cancel.consumptionGroupId).toBe(confirm.consumptionGroupId);
expect(confirm.consumptionGroupEntryIds).toEqual([confirm.id, cancel.id]);
registerDiscordComponentEntries({
entries: result.entries,
modals: [],
messageId: "msg_1",
ttlMs: 1000,
});
const consumed = resolveDiscordComponentEntry({ id: confirm?.id ?? "" });
expect(consumed?.label).toBe("Confirm");
expect(resolveDiscordComponentEntry({ id: cancel?.id ?? "", consume: false })).toBeNull();
});
it("shares registry state across duplicate module instances", async () => {
const first = (await import(
`${componentsRegistryModuleUrl}?t=first-${Date.now()}`
)) as typeof import("./components-registry.js");
const second = (await import(
`${componentsRegistryModuleUrl}?t=second-${Date.now()}`
)) as typeof import("./components-registry.js");
first.clearDiscordComponentEntries();
first.registerDiscordComponentEntries({
entries: [{ id: "btn_shared", kind: "button", label: "Shared" }],
modals: [],
});
const sharedEntry = second.resolveDiscordComponentEntry({ id: "btn_shared", consume: false });
expect(sharedEntry?.id).toBe("btn_shared");
expect(sharedEntry?.kind).toBe("button");
expect(sharedEntry?.label).toBe("Shared");
expect(typeof sharedEntry?.createdAt).toBe("number");
expect(typeof sharedEntry?.expiresAt).toBe("number");
second.clearDiscordComponentEntries();
});
it("expires component entries registered while the process clock is invalid", () => {
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
try {
registerDiscordComponentEntries({
entries: [{ id: "btn_invalid_clock", kind: "button", label: "Invalid clock" }],
modals: [],
ttlMs: 1000,
});
expect(resolveDiscordComponentEntry({ id: "btn_invalid_clock", consume: false })).toBeNull();
} finally {
dateNowSpy.mockRestore();
}
});
it("expires component entries whose calculated expiry exceeds the Date range", () => {
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(MAX_DATE_TIMESTAMP_MS);
try {
registerDiscordComponentEntries({
entries: [{ id: "btn_overflow", kind: "button", label: "Overflow" }],
modals: [],
ttlMs: 1000,
});
} finally {
dateNowSpy.mockRestore();
}
expect(resolveDiscordComponentEntry({ id: "btn_overflow", consume: false })).toBeNull();
});
it("persists component and modal entries when runtime state is available", async () => {
const componentRegister = vi.fn().mockResolvedValue(undefined);
const modalRegister = vi.fn().mockResolvedValue(undefined);
const componentLookup = vi.fn().mockResolvedValue({
version: 1,
entry: { id: "btn_persisted", kind: "button", label: "Persisted" },
});
const modalLookup = vi.fn().mockResolvedValue({
version: 1,
entry: { id: "mdl_persisted", title: "Persisted", fields: [] },
});
const componentStore = {
register: componentRegister,
lookup: componentLookup,
consume: vi.fn(),
delete: vi.fn(),
entries: vi.fn(),
clear: vi.fn(),
};
const modalStore = {
register: modalRegister,
lookup: modalLookup,
consume: vi.fn(),
delete: vi.fn(),
entries: vi.fn(),
clear: vi.fn(),
};
const openKeyedStore = vi.fn((opts: { namespace: string }) =>
opts.namespace === "discord.components" ? componentStore : modalStore,
);
const { setDiscordRuntime } = await import("./runtime.js");
setDiscordRuntime({
state: { openKeyedStore },
logging: { getChildLogger: () => ({ warn: vi.fn() }) },
} as never);
const now = 1_700_000_000_000;
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(now);
try {
registerDiscordComponentEntries({
entries: [{ id: "btn_1", kind: "button", label: "Confirm" }],
modals: [{ id: "mdl_1", title: "Details", fields: [] }],
ttlMs: 1000,
});
} finally {
dateNowSpy.mockRestore();
}
await vi.waitFor(() => expect(componentRegister).toHaveBeenCalledTimes(1));
expect(componentRegister).toHaveBeenCalledWith(
"btn_1",
{
version: 1,
entry: {
id: "btn_1",
kind: "button",
label: "Confirm",
createdAt: now,
expiresAt: now + 1000,
},
},
{ ttlMs: 1000 },
);
expect(modalRegister).toHaveBeenCalledWith(
"mdl_1",
{
version: 1,
entry: {
id: "mdl_1",
title: "Details",
fields: [],
createdAt: now,
expiresAt: now + 1000,
},
},
{ ttlMs: 1000 },
);
clearDiscordComponentEntries();
await expect(
resolveDiscordComponentEntryWithPersistence({ id: "btn_persisted", consume: false }),
).resolves.toStrictEqual({ id: "btn_persisted", kind: "button", label: "Persisted" });
await expect(
resolveDiscordModalEntryWithPersistence({ id: "mdl_persisted", consume: false }),
).resolves.toStrictEqual({ id: "mdl_persisted", title: "Persisted", fields: [] });
expect(componentLookup).toHaveBeenCalledWith("btn_persisted");
expect(modalLookup).toHaveBeenCalledWith("mdl_persisted");
expect(openKeyedStore).toHaveBeenCalledTimes(4);
});
it("omits undefined component fields before persisting registry state", async () => {
const componentRegister = vi.fn().mockResolvedValue(undefined);
const modalRegister = vi.fn().mockResolvedValue(undefined);
const componentStore = {
register: componentRegister,
lookup: vi.fn(),
consume: vi.fn(),
delete: vi.fn(),
entries: vi.fn(),
clear: vi.fn(),
};
const modalStore = {
register: modalRegister,
lookup: vi.fn(),
consume: vi.fn(),
delete: vi.fn(),
entries: vi.fn(),
clear: vi.fn(),
};
const openKeyedStore = vi.fn((opts: { namespace: string }) =>
opts.namespace === "discord.components" ? componentStore : modalStore,
);
const { setDiscordRuntime } = await import("./runtime.js");
setDiscordRuntime({
state: { openKeyedStore },
logging: { getChildLogger: () => ({ warn: vi.fn() }) },
} as never);
const componentEntry = Object.assign(
{
id: "btn_undefined",
kind: "button",
label: "Approve",
callbackData: "approve",
} satisfies DiscordComponentEntry,
{ modalId: undefined, sessionKey: undefined },
);
const modalEntry = Object.assign(
{
id: "mdl_undefined",
title: "Details",
fields: [
Object.assign(
{
id: "fld_undefined",
name: "reason",
label: "Reason",
type: "text",
} satisfies DiscordModalEntry["fields"][number],
{ description: undefined, placeholder: undefined },
),
],
} satisfies DiscordModalEntry,
{ sessionKey: undefined },
);
registerDiscordComponentEntries({
entries: [componentEntry],
modals: [modalEntry],
ttlMs: 1000,
});
await vi.waitFor(() => expect(componentRegister).toHaveBeenCalledTimes(1));
expect(modalRegister).toHaveBeenCalledTimes(1);
const persistedComponent = componentRegister.mock.calls[0]?.[1] as
| { entry: Record<string, unknown> }
| undefined;
expect(persistedComponent?.entry.callbackData).toBe("approve");
expect(persistedComponent?.entry).not.toHaveProperty("modalId");
expect(persistedComponent?.entry).not.toHaveProperty("sessionKey");
expect(persistedComponent?.entry).not.toHaveProperty("messageId");
const modalPayload = modalRegister.mock.calls[0]?.[1] as
| { entry: { fields?: Array<Record<string, unknown>> } }
| undefined;
expect(modalPayload?.entry.fields?.[0]).not.toHaveProperty("description");
expect(modalPayload?.entry.fields?.[0]).not.toHaveProperty("placeholder");
expect(modalPayload?.entry).not.toHaveProperty("sessionKey");
expect(modalPayload?.entry).not.toHaveProperty("messageId");
const inMemoryComponent = resolveDiscordComponentEntry({ id: "btn_undefined", consume: false });
expect(inMemoryComponent).toHaveProperty("modalId", undefined);
expect(inMemoryComponent).toHaveProperty("sessionKey", undefined);
});
it("deletes sibling persistent component entries when a group entry is consumed", async () => {
const componentDelete = vi.fn().mockResolvedValue(true);
const componentStore = {
register: vi.fn(),
lookup: vi.fn(),
consume: vi.fn().mockResolvedValue({
version: 1,
entry: {
id: "btn_confirm",
kind: "button",
label: "Confirm",
consumptionGroupId: "grp_1",
consumptionGroupEntryIds: ["btn_confirm", "btn_cancel"],
},
}),
delete: componentDelete,
};
const modalStore = {
register: vi.fn(),
lookup: vi.fn(),
consume: vi.fn(),
delete: vi.fn(),
};
const openKeyedStore = vi.fn((opts: { namespace: string }) =>
opts.namespace === "discord.components" ? componentStore : modalStore,
);
const { setDiscordRuntime } = await import("./runtime.js");
setDiscordRuntime({
state: { openKeyedStore },
logging: { getChildLogger: () => ({ warn: vi.fn() }) },
} as never);
clearDiscordComponentEntries();
await expect(
resolveDiscordComponentEntryWithPersistence({ id: "btn_confirm" }),
).resolves.toStrictEqual({
id: "btn_confirm",
kind: "button",
label: "Confirm",
consumptionGroupId: "grp_1",
consumptionGroupEntryIds: ["btn_confirm", "btn_cancel"],
});
await vi.waitFor(() => expect(componentDelete).toHaveBeenCalledWith("btn_cancel"));
expect(componentDelete).toHaveBeenCalledWith("btn_confirm");
});
it("falls back to the in-memory registry when persistent state cannot open", async () => {
const warn = vi.fn();
const cause = new TypeError("disk busy");
const { setDiscordRuntime } = await import("./runtime.js");
setDiscordRuntime({
state: {
openKeyedStore: vi.fn(() => {
const error = new Error("sqlite unavailable") as Error & { cause?: unknown };
error.cause = cause;
throw error;
}),
},
logging: { getChildLogger: () => ({ warn }) },
} as never);
registerDiscordComponentEntries({
entries: [{ id: "btn_fallback", kind: "button", label: "Fallback" }],
modals: [],
});
const fallbackEntry = resolveDiscordComponentEntry({ id: "btn_fallback", consume: false });
expect(fallbackEntry?.id).toBe("btn_fallback");
expect(fallbackEntry?.kind).toBe("button");
expect(fallbackEntry?.label).toBe("Fallback");
expect(typeof fallbackEntry?.createdAt).toBe("number");
expect(typeof fallbackEntry?.expiresAt).toBe("number");
expect(warn).toHaveBeenCalledWith(
"Discord persistent component registry state failed",
expect.objectContaining({
error: "Error: sqlite unavailable",
errorName: "Error",
errorMessage: "sqlite unavailable",
errorCause: "TypeError: disk busy",
errorCauseName: "TypeError",
errorCauseMessage: "disk busy",
}),
);
});
});

View File

@@ -0,0 +1,56 @@
// Discord plugin module implements components behavior.
export {
DISCORD_COMPONENT_CUSTOM_ID_KEY,
DISCORD_MODAL_CUSTOM_ID_KEY,
buildDiscordComponentCustomId,
buildDiscordModalCustomId,
parseDiscordComponentCustomId,
parseDiscordComponentCustomIdForInteraction,
parseDiscordModalCustomId,
parseDiscordModalCustomIdForInteraction,
} from "./component-custom-id.js";
export {
buildDiscordComponentMessage,
buildDiscordComponentMessageFlags,
} from "./components.builders.js";
export {
DISCORD_COMPONENT_ATTACHMENT_PREFIX,
readDiscordComponentSpec,
resolveDiscordComponentAttachmentName,
} from "./components.parse.js";
export { DiscordFormModal, createDiscordFormModal } from "./components.modal.js";
export type {
DiscordComponentBlock,
DiscordComponentBuildResult,
DiscordComponentButtonSpec,
DiscordComponentButtonStyle,
DiscordComponentCallbackDataKind,
DiscordComponentEntry,
DiscordComponentMessageSpec,
DiscordComponentModalFieldType,
DiscordComponentSectionAccessory,
DiscordComponentSelectOption,
DiscordComponentSelectSpec,
DiscordComponentSelectType,
DiscordModalEntry,
DiscordModalFieldDefinition,
DiscordModalFieldSpec,
DiscordModalSpec,
} from "./components.types.js";
export { buildDiscordInteractiveComponents } from "./shared-interactive.js";
export { Modal, type ComponentData } from "./internal/discord.js";
export function formatDiscordComponentEventText(params: {
kind: "button" | "select";
label: string;
values?: string[];
}): string {
if (params.kind === "button") {
return `Clicked "${params.label}".`;
}
const values = params.values ?? [];
if (values.length === 0) {
return `Updated "${params.label}".`;
}
return `Selected ${values.join(", ")} from "${params.label}".`;
}

View File

@@ -0,0 +1,194 @@
// Discord type declarations define plugin contracts.
import type { TopLevelComponents } from "./internal/discord.js";
export type DiscordComponentButtonStyle = "primary" | "secondary" | "success" | "danger" | "link";
export type DiscordComponentSelectType = "string" | "user" | "role" | "mentionable" | "channel";
export type DiscordComponentCallbackDataKind = "command" | "callback";
export type DiscordComponentModalFieldType =
| "text"
| "checkbox"
| "radio"
| "select"
| "role-select"
| "user-select";
export type DiscordComponentButtonSpec = {
label: string;
style?: DiscordComponentButtonStyle;
url?: string;
callbackData?: string;
callbackDataKind?: DiscordComponentCallbackDataKind;
/** Internal use only: bypass dynamic component ids with a fixed custom id. */
internalCustomId?: string;
emoji?: {
name: string;
id?: string;
animated?: boolean;
};
disabled?: boolean;
/** Keep this action available after a successful interaction. */
reusable?: boolean;
/** Optional allowlist of users who can interact with this button (ids or names). */
allowedUsers?: string[];
};
export type DiscordComponentSelectOption = {
label: string;
value: string;
description?: string;
emoji?: {
name: string;
id?: string;
animated?: boolean;
};
default?: boolean;
};
export type DiscordComponentSelectSpec = {
type?: DiscordComponentSelectType;
callbackData?: string;
callbackDataKind?: DiscordComponentCallbackDataKind;
placeholder?: string;
minValues?: number;
maxValues?: number;
options?: DiscordComponentSelectOption[];
allowedUsers?: string[];
};
export type DiscordComponentSectionAccessory =
| {
type: "thumbnail";
url: string;
}
| {
type: "button";
button: DiscordComponentButtonSpec;
};
type DiscordComponentSeparatorSpacing = "small" | "large" | 1 | 2;
export type DiscordComponentBlock =
| {
type: "text";
text: string;
}
| {
type: "section";
text?: string;
texts?: string[];
accessory?: DiscordComponentSectionAccessory;
}
| {
type: "separator";
spacing?: DiscordComponentSeparatorSpacing;
divider?: boolean;
}
| {
type: "actions";
buttons?: DiscordComponentButtonSpec[];
select?: DiscordComponentSelectSpec;
}
| {
type: "media-gallery";
items: Array<{ url: string; description?: string; spoiler?: boolean }>;
}
| {
type: "file";
file: `attachment://${string}`;
spoiler?: boolean;
};
export type DiscordModalFieldSpec = {
type: DiscordComponentModalFieldType;
name?: string;
label: string;
description?: string;
placeholder?: string;
required?: boolean;
options?: DiscordComponentSelectOption[];
minValues?: number;
maxValues?: number;
minLength?: number;
maxLength?: number;
style?: "short" | "paragraph";
};
export type DiscordModalSpec = {
title: string;
callbackData?: string;
triggerLabel?: string;
triggerStyle?: DiscordComponentButtonStyle;
allowedUsers?: string[];
fields: DiscordModalFieldSpec[];
};
export type DiscordComponentMessageSpec = {
text?: string;
reusable?: boolean;
container?: {
accentColor?: string | number;
spoiler?: boolean;
};
blocks?: DiscordComponentBlock[];
modal?: DiscordModalSpec;
};
export type DiscordComponentEntry = {
id: string;
kind: "button" | "select" | "modal-trigger";
label: string;
callbackData?: string;
callbackDataKind?: DiscordComponentCallbackDataKind;
selectType?: DiscordComponentSelectType;
options?: Array<{ value: string; label: string }>;
modalId?: string;
sessionKey?: string;
agentId?: string;
accountId?: string;
reusable?: boolean;
consumptionGroupId?: string;
consumptionGroupEntryIds?: string[];
allowedUsers?: string[];
messageId?: string;
createdAt?: number;
expiresAt?: number;
};
export type DiscordModalFieldDefinition = {
id: string;
name: string;
label: string;
type: DiscordComponentModalFieldType;
description?: string;
placeholder?: string;
required?: boolean;
options?: DiscordComponentSelectOption[];
minValues?: number;
maxValues?: number;
minLength?: number;
maxLength?: number;
style?: "short" | "paragraph";
};
export type DiscordModalEntry = {
id: string;
title: string;
callbackData?: string;
fields: DiscordModalFieldDefinition[];
sessionKey?: string;
agentId?: string;
accountId?: string;
reusable?: boolean;
messageId?: string;
createdAt?: number;
expiresAt?: number;
allowedUsers?: string[];
};
export type DiscordComponentBuildResult = {
components: TopLevelComponents[];
entries: DiscordComponentEntry[];
modals: DiscordModalEntry[];
};

View File

@@ -0,0 +1,499 @@
// Discord tests cover config schema plugin behavior.
import { describe, expect, it } from "vitest";
import { DiscordConfigSchema } from "../config-api.js";
function expectValidDiscordConfig(config: unknown) {
const res = DiscordConfigSchema.safeParse(config);
expect(res.success).toBe(true);
if (!res.success) {
throw new Error("expected Discord config to be valid");
}
return res.data;
}
function expectInvalidDiscordConfig(config: unknown) {
const res = DiscordConfigSchema.safeParse(config);
expect(res.success).toBe(false);
if (res.success) {
throw new Error("expected Discord config to be invalid");
}
return res.error.issues;
}
describe("discord config schema", () => {
it('rejects dmPolicy="open" without allowFrom "*"', () => {
const issues = expectInvalidDiscordConfig({
dmPolicy: "open",
allowFrom: ["123"],
});
expect(issues[0]?.path.join(".")).toBe("allowFrom");
});
it('rejects dmPolicy="open" with empty allowFrom', () => {
const issues = expectInvalidDiscordConfig({
dmPolicy: "open",
allowFrom: [],
});
expect(issues[0]?.path.join(".")).toBe("allowFrom");
});
it('rejects legacy dm.policy="open" with empty dm.allowFrom', () => {
const issues = expectInvalidDiscordConfig({
dm: { policy: "open", allowFrom: [] },
});
expect(issues[0]?.path.join(".")).toBe("dm.allowFrom");
});
it('accepts legacy dm.policy="open" with top-level allowFrom alias', () => {
expectValidDiscordConfig({
dm: { policy: "open", allowFrom: ["123"] },
allowFrom: ["*"],
});
});
it("accepts textChunkLimit without reviving legacy message limits", () => {
const cfg = expectValidDiscordConfig({
enabled: true,
textChunkLimit: 1999,
maxLinesPerMessage: 17,
});
expect(cfg.textChunkLimit).toBe(1999);
expect(cfg.maxLinesPerMessage).toBe(17);
});
it("defaults groupPolicy to allowlist", () => {
const cfg = expectValidDiscordConfig({});
expect(cfg.groupPolicy).toBe("allowlist");
});
it("accepts historyLimit", () => {
const cfg = expectValidDiscordConfig({ historyLimit: 3 });
expect(cfg.historyLimit).toBe(3);
});
it("accepts suppressEmbeds at top-level and account scope", () => {
const cfg = expectValidDiscordConfig({
suppressEmbeds: true,
accounts: {
noisy: {
suppressEmbeds: false,
},
},
});
expect(cfg.suppressEmbeds).toBe(true);
expect(cfg.accounts?.noisy?.suppressEmbeds).toBe(false);
});
it("rejects unknown preview config keys", () => {
const issues = expectInvalidDiscordConfig({
streaming: {
preview: {
unknownPreviewFlag: true,
},
},
});
expect(issues[0]?.path.join(".")).toBe("streaming.preview");
});
it("accepts Discord application IDs at top-level and account scope", () => {
const cfg = expectValidDiscordConfig({
applicationId: "123456789012345678",
accounts: {
work: {
applicationId: 234567890123456,
},
},
});
expect(cfg.applicationId).toBe("123456789012345678");
expect(cfg.accounts?.work?.applicationId).toBe("234567890123456");
});
it("rejects unsafe numeric Discord application IDs", () => {
const issues = expectInvalidDiscordConfig({
applicationId: 106232522769186816,
});
expect(
issues.some((issue) => issue.message.includes("not a valid non-negative safe integer")),
).toBe(true);
});
it("loads guild map and dm group settings", () => {
const cfg = expectValidDiscordConfig({
enabled: true,
dm: {
enabled: true,
allowFrom: ["steipete"],
groupEnabled: true,
groupChannels: ["openclaw-dm"],
},
actions: {
emojiUploads: true,
stickerUploads: false,
channels: true,
},
guilds: {
"123": {
slug: "friends-of-openclaw",
requireMention: false,
users: ["steipete"],
channels: {
general: { enabled: true, autoThread: true },
},
},
},
});
expect(cfg.enabled).toBe(true);
expect(cfg.dm?.groupEnabled).toBe(true);
expect(cfg.dm?.groupChannels).toEqual(["openclaw-dm"]);
expect(cfg.actions?.emojiUploads).toBe(true);
expect(cfg.actions?.stickerUploads).toBe(false);
expect(cfg.actions?.channels).toBe(true);
expect(cfg.guilds?.["123"]?.slug).toBe("friends-of-openclaw");
expect(cfg.guilds?.["123"]?.channels?.general?.enabled).toBe(true);
expect(cfg.guilds?.["123"]?.channels?.general?.autoThread).toBe(true);
});
it("accepts voice model override field", () => {
const cfg = expectValidDiscordConfig({
voice: {
model: "openai/gpt-5.4-mini",
},
});
expect(cfg.voice?.model).toBe("openai/gpt-5.4-mini");
});
it("accepts voice agent session target routing", () => {
const cfg = expectValidDiscordConfig({
voice: {
agentSession: {
mode: "target",
target: "channel:123456789012345678",
},
},
});
expect(cfg.voice?.agentSession).toEqual({
mode: "target",
target: "channel:123456789012345678",
});
});
it("accepts Discord realtime voice modes", () => {
const cfg = expectValidDiscordConfig({
voice: {
mode: "agent-proxy",
model: "openai/gpt-5.5",
followUsersEnabled: true,
followUsers: ["58398277829140480"],
realtime: {
provider: "openai",
model: "gpt-realtime-2",
speakerVoice: "cedar",
speakerVoiceId: "voice-123",
toolPolicy: "safe-read-only",
consultPolicy: "always",
requireWakeName: true,
wakeNames: ["Molty"],
bootstrapContextFiles: ["IDENTITY.md", "USER.md", "SOUL.md"],
bargeIn: true,
minBargeInAudioEndMs: 500,
providers: {
openai: {
apiKey: "sk-test",
voice: "marin",
},
},
},
},
});
expect(cfg.voice?.mode).toBe("agent-proxy");
expect(cfg.voice?.model).toBe("openai/gpt-5.5");
expect(cfg.voice?.followUsersEnabled).toBe(true);
expect(cfg.voice?.followUsers).toEqual(["58398277829140480"]);
expect(cfg.voice?.realtime?.provider).toBe("openai");
expect(cfg.voice?.realtime?.model).toBe("gpt-realtime-2");
expect(cfg.voice?.realtime?.speakerVoice).toBe("cedar");
expect(cfg.voice?.realtime?.speakerVoiceId).toBe("voice-123");
expect(cfg.voice?.realtime?.toolPolicy).toBe("safe-read-only");
expect(cfg.voice?.realtime?.consultPolicy).toBe("always");
expect(cfg.voice?.realtime?.requireWakeName).toBe(true);
expect(cfg.voice?.realtime?.wakeNames).toEqual(["Molty"]);
expect(cfg.voice?.realtime?.bootstrapContextFiles).toEqual([
"IDENTITY.md",
"USER.md",
"SOUL.md",
]);
expect(cfg.voice?.realtime?.bargeIn).toBe(true);
expect(cfg.voice?.realtime?.minBargeInAudioEndMs).toBe(500);
});
it("rejects invalid Discord realtime voice modes", () => {
for (const voice of [
{ mode: "realtime" },
{ mode: "talk-buffer" },
{ mode: "bidi", realtime: { toolPolicy: "dangerous" } },
{ mode: "agent-proxy", realtime: { consultPolicy: "substantive" } },
{ mode: "bidi", realtime: { bootstrapContextFiles: ["AGENTS.md"] } },
{ mode: "agent-proxy", realtime: { wakeNames: [] } },
{ mode: "agent-proxy", realtime: { wakeNames: [""] } },
{ mode: "agent-proxy", realtime: { wakeNames: ["Claw Bot Helper"] } },
{ mode: "agent-proxy", realtime: { debounceMs: 10_001 } },
{ mode: "agent-proxy", realtime: { minBargeInAudioEndMs: -1 } },
{ mode: "agent-proxy", realtime: { minBargeInAudioEndMs: 10_001 } },
{ agentSession: { mode: "target" } },
{ followUsers: [""] },
]) {
expectInvalidDiscordConfig({ voice });
}
});
it("accepts Discord voice timing overrides", () => {
const cfg = expectValidDiscordConfig({
voice: {
connectTimeoutMs: 45_000,
reconnectGraceMs: 20_000,
captureSilenceGraceMs: 3_500,
},
});
expect(cfg.voice?.connectTimeoutMs).toBe(45_000);
expect(cfg.voice?.reconnectGraceMs).toBe(20_000);
expect(cfg.voice?.captureSilenceGraceMs).toBe(3_500);
});
it("accepts Discord voice allowed channels", () => {
const cfg = expectValidDiscordConfig({
voice: {
allowedChannels: [{ guildId: "123", channelId: "456" }],
},
});
expect(cfg.voice?.allowedChannels).toEqual([{ guildId: "123", channelId: "456" }]);
});
it("rejects invalid Discord voice allowed channels", () => {
for (const voice of [
{ allowedChannels: [{ guildId: "", channelId: "456" }] },
{ allowedChannels: [{ guildId: "123", channelId: "" }] },
]) {
expectInvalidDiscordConfig({ voice });
}
});
it("rejects invalid Discord voice timing overrides", () => {
for (const voice of [
{ connectTimeoutMs: 0 },
{ connectTimeoutMs: 120_001 },
{ reconnectGraceMs: -1 },
{ reconnectGraceMs: 1.5 },
{ captureSilenceGraceMs: 0 },
{ captureSilenceGraceMs: 30_001 },
]) {
expectInvalidDiscordConfig({ voice });
}
});
it("coerces safe-integer numeric allowlist entries to strings", () => {
const cfg = expectValidDiscordConfig({
allowFrom: [123],
dm: { allowFrom: [456], groupChannels: [789] },
guilds: {
"123": {
users: [111],
roles: [222],
channels: {
general: { users: [333], roles: [444] },
},
},
},
execApprovals: { approvers: [555] },
});
expect(cfg.allowFrom).toEqual(["123"]);
expect(cfg.dm?.allowFrom).toEqual(["456"]);
expect(cfg.dm?.groupChannels).toEqual(["789"]);
expect(cfg.guilds?.["123"]?.users).toEqual(["111"]);
expect(cfg.guilds?.["123"]?.roles).toEqual(["222"]);
expect(cfg.guilds?.["123"]?.channels?.general?.users).toEqual(["333"]);
expect(cfg.guilds?.["123"]?.channels?.general?.roles).toEqual(["444"]);
expect(cfg.execApprovals?.approvers).toEqual(["555"]);
});
it.each([true, false, "auto"] as const)("accepts execApprovals.enabled=%s", (enabled) => {
const cfg = expectValidDiscordConfig({ execApprovals: { enabled } });
expect(cfg.execApprovals?.enabled).toBe(enabled);
});
it("rejects execApprovals.enabled with other string values", () => {
expectInvalidDiscordConfig({ execApprovals: { enabled: "on" } });
});
it("rejects numeric IDs that are not valid non-negative safe integers", () => {
const cases = [106232522769186816, -1, 123.45];
for (const id of cases) {
const issues = expectInvalidDiscordConfig({ allowFrom: [id] });
expect(
issues.some((issue) => issue.message.includes("not a valid non-negative safe integer")),
).toBe(true);
}
});
it.each([
{ name: "status-only presence", config: { status: "idle" } },
{
name: "custom activity when type is omitted",
config: { activity: "Focus time" },
},
{
name: "custom activity type",
config: { activity: "Chilling", activityType: 4 },
},
{
name: "auto presence config",
config: {
autoPresence: {
enabled: true,
intervalMs: 30000,
minUpdateIntervalMs: 15000,
exhaustedText: "token exhausted",
},
},
},
] as const)("accepts $name", ({ config }) => {
expect(DiscordConfigSchema.safeParse(config).success).toBe(true);
});
it.each([
{
name: "streaming activity without url",
config: { activity: "Live", activityType: 1 },
},
{
name: "activityUrl without streaming type",
config: { activity: "Live", activityUrl: "https://twitch.tv/openclaw" },
},
{
name: "auto presence min update interval above check interval",
config: {
autoPresence: {
enabled: true,
intervalMs: 5000,
minUpdateIntervalMs: 6000,
},
},
},
] as const)("rejects $name", ({ config }) => {
expect(DiscordConfigSchema.safeParse(config).success).toBe(false);
});
it("accepts agentComponents.enabled at channel scope", () => {
const res = DiscordConfigSchema.safeParse({
agentComponents: {
enabled: true,
},
});
expect(res.success).toBe(true);
});
it("accepts agentComponents.ttlMs at channel and account scope", () => {
const res = DiscordConfigSchema.safeParse({
agentComponents: {
ttlMs: 86_400_000,
},
accounts: {
work: {
agentComponents: {
ttlMs: 120_000,
},
},
},
});
expect(res.success).toBe(true);
});
it("rejects invalid agentComponents.ttlMs values", () => {
for (const ttlMs of [0, -1, 1.5, 86_400_001]) {
const res = DiscordConfigSchema.safeParse({
agentComponents: {
ttlMs,
},
});
expect(res.success).toBe(false);
}
});
it("accepts agentComponents.enabled at account scope", () => {
const res = DiscordConfigSchema.safeParse({
accounts: {
work: {
agentComponents: {
enabled: false,
},
},
},
});
expect(res.success).toBe(true);
});
it("accepts thread.inheritParent at top-level and account scope", () => {
const cases = [
{
thread: {
inheritParent: true,
},
},
{
accounts: {
work: {
thread: {
inheritParent: true,
},
},
},
},
] as const;
for (const config of cases) {
const res = DiscordConfigSchema.safeParse(config);
expect(res.success).toBe(true);
}
});
it("rejects unknown fields under agentComponents", () => {
const res = DiscordConfigSchema.safeParse({
agentComponents: {
enabled: true,
invalidField: true,
},
});
expect(res.success).toBe(false);
if (!res.success) {
expect(
res.error.issues.some(
(issue) =>
issue.path.join(".") === "agentComponents" &&
issue.message.toLowerCase().includes("unrecognized"),
),
).toBe(true);
}
});
});

View File

@@ -0,0 +1,7 @@
// Discord helper module supports config schema behavior.
import { buildChannelConfigSchema, DiscordConfigSchema } from "../config-api.js";
import { discordChannelConfigUiHints } from "./config-ui-hints.js";
export const DiscordChannelConfigSchema = buildChannelConfigSchema(DiscordConfigSchema, {
uiHints: discordChannelConfigUiHints,
});

View File

@@ -0,0 +1,407 @@
// Discord helper module supports config ui hints behavior.
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/channel-core";
export const discordChannelConfigUiHints = {
"": {
label: "Discord",
help: "Discord channel provider configuration for bot auth, retry policy, streaming, thread bindings, and optional voice capabilities. Keep privileged intents and advanced features disabled unless needed.",
},
dmPolicy: {
label: "Discord DM Policy",
help: 'Direct message access control ("pairing" recommended). "open" requires channels.discord.allowFrom=["*"].',
},
"dm.policy": {
label: "Discord DM Policy",
help: 'Direct message access control ("pairing" recommended). "open" requires channels.discord.allowFrom=["*"] (legacy: channels.discord.dm.allowFrom).',
},
configWrites: {
label: "Discord Config Writes",
help: "Allow Discord to write config in response to channel events/commands (default: true).",
},
mentionPatterns: {
label: "Discord Mention Pattern Policy",
help: "Scopes configured groupChat mentionPatterns to selected Discord channel IDs. Native Discord @mentions still trigger even when regex patterns are denied.",
},
"mentionPatterns.mode": {
label: "Discord Mention Pattern Mode",
help: '"allow" enables configured regex mention patterns unless denyIn matches; "deny" disables them unless allowIn matches.',
},
"mentionPatterns.allowIn": {
label: "Discord Mention Pattern Allowlist",
help: "Discord channel IDs where configured regex mention patterns are enabled when mode is deny.",
},
"mentionPatterns.denyIn": {
label: "Discord Mention Pattern Denylist",
help: "Discord channel IDs where configured regex mention patterns are disabled. Native @mentions still trigger.",
},
proxy: {
label: "Discord Proxy URL",
help: "Proxy URL for Discord gateway + API requests (app-id lookup and allowlist resolution). Set per account via channels.discord.accounts.<id>.proxy.",
},
"commands.native": {
label: "Discord Native Commands",
help: 'Override native commands for Discord (bool or "auto").',
},
"commands.nativeSkills": {
label: "Discord Native Skill Commands",
help: 'Override native skill commands for Discord (bool or "auto").',
},
streaming: {
label: "Discord Streaming Mode",
help: 'Unified Discord stream preview mode: "off" | "partial" | "block" | "progress". "progress" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are auto-mapped.',
},
"streaming.mode": {
label: "Discord Streaming Mode",
help: 'Canonical Discord preview mode: "off" | "partial" | "block" | "progress".',
},
"streaming.chunkMode": {
label: "Discord Chunk Mode",
help: 'Chunking mode for outbound Discord text delivery: "length" (default) or "newline".',
},
"streaming.block.enabled": {
label: "Discord Block Streaming Enabled",
help: 'Enable chunked block-style Discord preview delivery when channels.discord.streaming.mode="block".',
},
"streaming.block.coalesce": {
label: "Discord Block Streaming Coalesce",
help: "Merge streamed Discord block replies before final delivery.",
},
"streaming.preview.chunk.minChars": {
label: "Discord Draft Chunk Min Chars",
help: 'Minimum chars before emitting a Discord stream preview update when channels.discord.streaming.mode="block" (default: 200).',
},
"streaming.preview.chunk.maxChars": {
label: "Discord Draft Chunk Max Chars",
help: 'Target max size for a Discord stream preview chunk when channels.discord.streaming.mode="block" (default: 800; clamped to channels.discord.textChunkLimit).',
},
"streaming.preview.chunk.breakPreference": {
label: "Discord Draft Chunk Break Preference",
help: "Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph.",
},
"streaming.preview.toolProgress": {
label: "Discord Draft Tool Progress",
help: "Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active.",
},
"streaming.preview.commandText": {
label: "Discord Draft Command Text",
help: 'Command/exec detail in preview tool-progress lines: "raw" preserves released behavior; "status" shows only the tool label.',
},
"streaming.progress.label": {
label: "Discord Progress Label",
help: 'Initial progress draft title. Use "auto" for built-in single-word labels, a custom string, or false to hide the title.',
},
"streaming.progress.labels": {
label: "Discord Progress Label Pool",
help: 'Candidate labels for streaming.progress.label="auto". Leave unset to use OpenClaw built-in progress labels.',
},
"streaming.progress.maxLines": {
label: "Discord Progress Max Lines",
help: "Maximum number of compact progress lines to keep below the draft label (default: 8).",
},
"streaming.progress.maxLineChars": {
label: "Discord Progress Max Line Chars",
help: "Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes.",
},
"streaming.progress.toolProgress": {
label: "Discord Progress Tool Lines",
help: "Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery.",
},
"streaming.progress.commentary": {
label: "Discord Progress Commentary",
help: "Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged.",
},
"streaming.progress.commandText": {
label: "Discord Progress Command Text",
help: 'Command/exec detail in progress draft lines: "raw" preserves released behavior; "status" shows only the tool label.',
},
"retry.attempts": {
label: "Discord Retry Attempts",
help: "Max retry attempts for outbound Discord API calls (default: 3).",
},
"retry.minDelayMs": {
label: "Discord Retry Min Delay (ms)",
help: "Minimum retry delay in ms for Discord outbound calls.",
},
"retry.maxDelayMs": {
label: "Discord Retry Max Delay (ms)",
help: "Maximum retry delay cap in ms for Discord outbound calls.",
},
"retry.jitter": {
label: "Discord Retry Jitter",
help: "Jitter factor (0-1) applied to Discord retry delays.",
},
maxLinesPerMessage: {
label: "Discord Max Lines Per Message",
help: "Soft max line count per Discord message (default: 17).",
},
suppressEmbeds: {
label: "Discord Suppress Link Embeds",
help: "Suppress Discord-generated link embeds on outbound messages by default. Explicit embeds still send normally. Default: true.",
},
"thread.inheritParent": {
label: "Discord Thread Parent Inheritance",
help: "If true, Discord thread sessions inherit the parent channel transcript (default: false).",
},
"eventQueue.listenerTimeout": {
label: "Discord EventQueue Listener Timeout (ms)",
help: "Canonical Discord listener timeout control in ms for gateway normalization/enqueue handlers. Default is 120000 in OpenClaw; set per account via channels.discord.accounts.<id>.eventQueue.listenerTimeout.",
},
"eventQueue.maxQueueSize": {
label: "Discord EventQueue Max Queue Size",
help: "Optional Discord EventQueue capacity override (max queued events before backpressure). Set per account via channels.discord.accounts.<id>.eventQueue.maxQueueSize.",
},
"eventQueue.maxConcurrency": {
label: "Discord EventQueue Max Concurrency",
help: "Optional Discord EventQueue concurrency override (max concurrent handler executions). Set per account via channels.discord.accounts.<id>.eventQueue.maxConcurrency.",
},
"threadBindings.enabled": {
label: "Discord Thread Binding Enabled",
help: "Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set.",
},
"threadBindings.idleHours": {
label: "Discord Thread Binding Idle Timeout (hours)",
help: "Inactivity window in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.",
},
"threadBindings.maxAgeHours": {
label: "Discord Thread Binding Max Age (hours)",
help: "Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.",
},
"threadBindings.spawnSessions": {
label: "Discord Thread-Bound Session Spawn",
help: "Allow sessions_spawn(thread=true) and ACP thread spawns to auto-create and bind Discord threads (default: true). Set false to disable for this account/channel.",
},
"threadBindings.defaultSpawnContext": {
label: "Discord Thread Spawn Context",
help: 'Default native subagent context for thread-bound spawns. "fork" starts from the requester transcript; "isolated" starts clean. Default: "fork".',
},
"ui.components.accentColor": {
label: "Discord Component Accent Color",
help: "Accent color for Discord component containers (hex). Set per account via channels.discord.accounts.<id>.ui.components.accentColor.",
},
"agentComponents.ttlMs": {
label: "Discord Component TTL (ms)",
help: "How long sent Discord component callbacks remain registered. Default is 1800000 (30 minutes); maximum is 86400000 (24 hours).",
},
"intents.presence": {
label: "Discord Presence Intent",
help: "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.",
},
"intents.guildMembers": {
label: "Discord Guild Members Intent",
help: "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.",
},
"intents.voiceStates": {
label: "Discord Voice States Intent",
help: "Enable the Guild Voice States intent. Defaults to the effective Discord voice setting; set true only for Discord voice channel conversations.",
},
gatewayInfoTimeoutMs: {
label: "Discord Gateway Metadata Timeout (ms)",
help: "Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default is 30000; OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS can override when config is unset.",
},
gatewayReadyTimeoutMs: {
label: "Discord Gateway READY Timeout (ms)",
help: "Startup wait for the Discord gateway READY event before restarting the socket. Default is 15000; OPENCLAW_DISCORD_READY_TIMEOUT_MS can override when config is unset.",
},
gatewayRuntimeReadyTimeoutMs: {
label: "Discord Gateway Runtime READY Timeout (ms)",
help: "Runtime reconnect wait for the Discord gateway READY event before force-stopping the lifecycle. Default is 30000; OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS can override when config is unset.",
},
"voice.enabled": {
label: "Discord Voice Enabled",
help: "Enable Discord voice channel conversations. Text-only Discord configs leave voice off by default; set true to enable /vc commands and the Guild Voice States intent.",
},
"voice.model": {
label: "Discord Voice Model",
help: "Optional LLM model override for Discord voice channel responses and realtime agent consults (for example openai/gpt-5.5). Leave unset to inherit the routed agent model.",
},
"voice.mode": {
label: "Discord Voice Mode",
help: "Conversation mode: agent-proxy (default) uses realtime voice as the microphone/speaker for the routed OpenClaw agent, stt-tts uses batch speech-to-text plus TTS, and bidi lets the realtime provider converse directly with the OpenClaw consult tool.",
},
"voice.agentSession": {
label: "Discord Voice Agent Session",
help: 'Controls which OpenClaw conversation receives voice turns. Leave unset for the voice channel session, or set mode="target" with a Discord target such as channel:123 to make voice an extension of an existing text channel session.',
},
"voice.agentSession.target": {
label: "Discord Voice Agent Session Target",
help: 'Discord target used when voice.agentSession.mode="target", for example channel:123.',
},
"voice.followUsersEnabled": {
label: "Discord Voice Follow Users Enabled",
help: "Toggle Discord voice follow-users behavior without removing the saved voice.followUsers list. Defaults to true when followUsers is configured.",
},
"voice.followUsers": {
label: "Discord Voice Follow Users",
help: "Discord user IDs to follow into voice channels. The bot joins when a followed user joins or moves, and leaves when that user disconnects.",
},
"voice.realtime.provider": {
label: "Discord Realtime Provider",
help: "Realtime voice provider for agent-proxy or bidi Discord voice modes, such as openai.",
},
"voice.realtime.model": {
label: "Discord Realtime Model",
help: "Provider realtime session model, such as gpt-realtime-2. This is separate from voice.model, which remains the OpenClaw agent brain model.",
},
"voice.realtime.speakerVoice": {
label: "Discord Realtime Speaker Voice",
help: "Provider realtime output voice name, such as cedar.",
},
"voice.realtime.speakerVoiceId": {
label: "Discord Realtime Speaker Voice ID",
help: "Provider realtime output voice id.",
},
"voice.realtime.voice": {
label: "Discord Realtime Voice",
help: "Deprecated provider realtime output voice. Use voice.realtime.speakerVoice.",
},
"voice.realtime.toolPolicy": {
label: "Discord Realtime Tool Policy",
help: "Tool policy for the OpenClaw agent consult tool in realtime voice modes: safe-read-only, owner, or none. Default is owner for agent-proxy and safe-read-only for bidi.",
},
"voice.realtime.consultPolicy": {
label: "Discord Realtime Consult Policy",
help: "Use always to strongly prefer the OpenClaw agent brain for substantive realtime turns. agent-proxy defaults to always.",
},
"voice.realtime.requireWakeName": {
label: "Discord Realtime Require Wake Name",
help: "Require a configured wake name before OpenAI agent-proxy Discord realtime voice responds. If wakeNames is unset, the routed agent name is used, falling back to the agent id.",
},
"voice.realtime.wakeNames": {
label: "Discord Realtime Wake Names",
help: "One- or two-word activation names that allow OpenAI agent-proxy Discord realtime voice to respond when requireWakeName is enabled.",
},
"voice.realtime.bootstrapContextFiles": {
label: "Discord Realtime Bootstrap Context Files",
help: "Agent profile bootstrap files included in realtime provider instructions for direct voice identity/persona grounding. Defaults to IDENTITY.md, USER.md, and SOUL.md; set [] to disable.",
},
"voice.realtime.bargeIn": {
label: "Discord Realtime Barge-In",
help: "Allow Discord speaker-start events to interrupt active realtime playback. Set true to keep manual interruption when provider input-audio interruption is disabled for echo control.",
},
"voice.realtime.minBargeInAudioEndMs": {
label: "Discord Realtime Minimum Barge-In Audio (ms)",
help: "Minimum assistant playback duration before a Discord barge-in truncates realtime audio. Default: 250; set 0 for immediate interruption in low-echo rooms.",
},
"voice.realtime.providers": {
label: "Discord Realtime Provider Settings",
help: "Provider-specific realtime voice settings keyed by provider id.",
advanced: true,
},
"voice.autoJoin": {
label: "Discord Voice Auto-Join",
help: "Voice channels to auto-join on startup (list of guildId/channelId entries).",
},
"voice.allowedChannels": {
label: "Discord Voice Allowed Channels",
help: "Optional voice channel residency allowlist. When set, /vc join, auto-join, and bot voice-state moves are restricted to these guildId/channelId entries. Leave unset to allow any voice channel.",
},
"voice.daveEncryption": {
label: "Discord Voice DAVE Encryption",
help: "Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this).",
},
"voice.decryptionFailureTolerance": {
label: "Discord Voice Decrypt Failure Tolerance",
help: "Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24).",
},
"voice.connectTimeoutMs": {
label: "Discord Voice Connect Timeout (ms)",
help: "Initial @discordjs/voice Ready wait before a join is treated as failed. Default: 30000.",
},
"voice.reconnectGraceMs": {
label: "Discord Voice Reconnect Grace (ms)",
help: "Grace period for a disconnected Discord voice session to enter Signalling or Connecting before OpenClaw destroys it. Default: 15000.",
},
"voice.captureSilenceGraceMs": {
label: "Discord Voice Capture Silence Grace (ms)",
help: "Silence window after Discord reports a speaker ended before OpenClaw finalizes the audio segment for transcription. Default: 2000.",
},
"voice.tts": {
label: "Discord Voice Text-to-Speech",
help: "Optional TTS overrides for Discord voice playback (merged with messages.tts).",
},
"pluralkit.enabled": {
label: "Discord PluralKit Enabled",
help: "Resolve PluralKit proxied messages and treat system members as distinct senders.",
},
"pluralkit.token": {
label: "Discord PluralKit Token",
help: "Optional PluralKit token for resolving private systems or members.",
},
activity: {
label: "Discord Presence Activity",
help: "Discord presence activity text (defaults to custom status).",
},
status: {
label: "Discord Presence Status",
help: "Discord presence status (online, dnd, idle, invisible).",
},
"autoPresence.enabled": {
label: "Discord Auto Presence Enabled",
help: "Enable automatic Discord bot presence updates based on runtime/model availability signals. When enabled: healthy=>online, degraded/unknown=>idle, exhausted/unavailable=>dnd.",
},
"autoPresence.intervalMs": {
label: "Discord Auto Presence Check Interval (ms)",
help: "How often to evaluate Discord auto-presence state in milliseconds (default: 30000).",
},
"autoPresence.minUpdateIntervalMs": {
label: "Discord Auto Presence Min Update Interval (ms)",
help: "Minimum time between actual Discord presence update calls in milliseconds (default: 15000). Prevents status spam on noisy state changes.",
},
"autoPresence.healthyText": {
label: "Discord Auto Presence Healthy Text",
help: "Optional custom status text while runtime is healthy (online). If omitted, falls back to static channels.discord.activity when set.",
},
"autoPresence.degradedText": {
label: "Discord Auto Presence Degraded Text",
help: "Optional custom status text while runtime/model availability is degraded or unknown (idle).",
},
"autoPresence.exhaustedText": {
label: "Discord Auto Presence Exhausted Text",
help: "Optional custom status text while runtime detects exhausted/unavailable model quota (dnd). Supports {reason} template placeholder.",
},
activityType: {
label: "Discord Presence Activity Type",
help: "Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing).",
},
activityUrl: {
label: "Discord Presence Activity URL",
help: "Discord presence streaming URL (required for activityType=1).",
},
allowBots: {
label: "Discord Allow Bot Messages",
help: 'Allow bot-authored messages to trigger Discord replies (default: false). Set "mentions" to only accept bot messages that mention the bot.',
},
botLoopProtection: {
label: "Discord Bot Loop Protection",
help: "Sliding-window guard for bot-to-bot Discord loops. Default is enabled whenever allowBots lets bot-authored messages reach dispatch.",
},
"botLoopProtection.enabled": {
label: "Discord Bot Loop Protection Enabled",
help: 'Enable the bot-pair loop guard. Defaults to true when allowBots is true or "mentions", and false when bot messages are ignored.',
},
"botLoopProtection.maxEventsPerWindow": {
label: "Discord Bot Pair Events Per Window",
help: "Maximum messages a single Discord bot pair may exchange in the configured window before suppression starts. Default: 20.",
},
"botLoopProtection.windowSeconds": {
label: "Discord Bot Loop Window Seconds",
help: "Sliding window length in seconds for Discord bot-pair loop budgets. Default: 60.",
},
"botLoopProtection.cooldownSeconds": {
label: "Discord Bot Loop Cooldown Seconds",
help: "Seconds to suppress a Discord bot pair after it exceeds the loop budget. Default: 60.",
},
mentionAliases: {
label: "Discord Mention Aliases",
help: "Map outbound @handle text to stable Discord user IDs before sending. Set per account via channels.discord.accounts.<id>.mentionAliases.",
},
token: {
label: "Discord Bot Token",
help: "Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.",
sensitive: true,
},
applicationId: {
label: "Discord Application ID",
help: "Optional Discord application/client ID. Set this when hosted environments cannot reach Discord's application lookup endpoint during startup.",
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -0,0 +1,59 @@
// Discord plugin module implements conversation identity behavior.
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { parseDiscordTarget } from "./target-parsing.js";
function normalizeDiscordTarget(
raw: string | null | undefined,
defaultKind: "user" | "channel",
): string | undefined {
const trimmed = normalizeOptionalString(raw);
if (!trimmed) {
return undefined;
}
return parseDiscordTarget(trimmed, { defaultKind })?.normalized;
}
function buildDiscordConversationIdentity(
kind: "user" | "channel",
rawId: string | null | undefined,
): string | undefined {
const trimmed = normalizeOptionalString(rawId);
return trimmed ? `${kind}:${trimmed}` : undefined;
}
export function resolveDiscordConversationIdentity(params: {
isDirectMessage: boolean;
userId?: string | null;
channelId?: string | null;
}): string | undefined {
return params.isDirectMessage
? buildDiscordConversationIdentity("user", params.userId)
: buildDiscordConversationIdentity("channel", params.channelId);
}
export function resolveDiscordCurrentConversationIdentity(params: {
chatType?: string | null;
from?: string | null;
originatingTo?: string | null;
commandTo?: string | null;
fallbackTo?: string | null;
}): string | undefined {
if (normalizeOptionalLowercaseString(params.chatType) === "direct") {
const senderTarget = normalizeDiscordTarget(params.from, "user");
if (senderTarget?.startsWith("user:")) {
return senderTarget;
}
}
for (const candidate of [params.originatingTo, params.commandTo, params.fallbackTo]) {
const target = normalizeDiscordTarget(candidate, "channel");
if (target) {
return target;
}
}
return undefined;
}

View File

@@ -0,0 +1,56 @@
// Discord plugin module implements delivery retry behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
resolveRetryConfig,
retryAsync,
type RetryConfig,
} from "openclaw/plugin-sdk/retry-runtime";
import { resolveDiscordAccount } from "./accounts.js";
import { DiscordError } from "./internal/discord.js";
import { parseDiscordRetryAfterBodySeconds } from "./retry-after.js";
const DISCORD_DELIVERY_RETRY_DEFAULTS = {
attempts: 3,
minDelayMs: 1000,
maxDelayMs: 30_000,
jitter: 0,
} satisfies Required<RetryConfig>;
export function isRetryableDiscordDeliveryError(err: unknown): boolean {
if (err instanceof DiscordError) {
return false;
}
const status = (err as { status?: number }).status ?? (err as { statusCode?: number }).statusCode;
return status === 429 || (status !== undefined && status >= 500);
}
export function getDiscordDeliveryRetryAfterMs(err: unknown): number | undefined {
if (!err || typeof err !== "object") {
return undefined;
}
const retryAfterSeconds =
"retryAfter" in err ? parseDiscordRetryAfterBodySeconds(err.retryAfter) : undefined;
if (retryAfterSeconds !== undefined) {
return retryAfterSeconds * 1000;
}
const retryAfterRaw = (err as { headers?: Record<string, string> }).headers?.["retry-after"];
if (!retryAfterRaw) {
return undefined;
}
const headerSeconds = parseDiscordRetryAfterBodySeconds(retryAfterRaw);
return headerSeconds === undefined ? undefined : headerSeconds * 1000;
}
export async function withDiscordDeliveryRetry<T>(params: {
cfg: OpenClawConfig;
accountId?: string | null;
fn: () => Promise<T>;
}): Promise<T> {
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const retryConfig = resolveRetryConfig(DISCORD_DELIVERY_RETRY_DEFAULTS, account.config.retry);
return await retryAsync(params.fn, {
...retryConfig,
shouldRetry: (err) => isRetryableDiscordDeliveryError(err),
retryAfterMs: getDiscordDeliveryRetryAfterMs,
});
}

View File

@@ -0,0 +1,117 @@
// Discord plugin module implements directory cache behavior.
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/string-coerce-runtime";
const DISCORD_DIRECTORY_CACHE_MAX_ENTRIES = 4000;
const DISCORD_DISCRIMINATOR_SUFFIX = /#\d{4}$/;
const DIRECTORY_HANDLE_CACHE = new Map<string, Map<string, string>>();
function normalizeAccountCacheKey(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId ?? DEFAULT_ACCOUNT_ID);
return normalized || DEFAULT_ACCOUNT_ID;
}
function normalizeSnowflake(value: string | number | bigint): string | null {
const text = normalizeOptionalStringifiedId(value) ?? "";
if (!/^\d+$/.test(text)) {
return null;
}
return text;
}
function normalizeHandleKey(raw: string): string | null {
let handle = normalizeOptionalString(raw) ?? "";
if (!handle) {
return null;
}
if (handle.startsWith("@")) {
handle = normalizeOptionalString(handle.slice(1)) ?? "";
}
if (!handle || /\s/.test(handle)) {
return null;
}
return normalizeLowercaseStringOrEmpty(handle);
}
function ensureAccountCache(accountId?: string | null): Map<string, string> {
const cacheKey = normalizeAccountCacheKey(accountId);
const existing = DIRECTORY_HANDLE_CACHE.get(cacheKey);
if (existing) {
return existing;
}
const created = new Map<string, string>();
DIRECTORY_HANDLE_CACHE.set(cacheKey, created);
return created;
}
function setCacheEntry(cache: Map<string, string>, key: string, userId: string): void {
if (cache.has(key)) {
cache.delete(key);
}
cache.set(key, userId);
if (cache.size <= DISCORD_DIRECTORY_CACHE_MAX_ENTRIES) {
return;
}
const oldest = cache.keys().next();
if (!oldest.done) {
cache.delete(oldest.value);
}
}
export function rememberDiscordDirectoryUser(params: {
accountId?: string | null;
userId: string | number | bigint;
handles: Array<string | null | undefined>;
}): void {
const userId = normalizeSnowflake(params.userId);
if (!userId) {
return;
}
const cache = ensureAccountCache(params.accountId);
for (const candidate of params.handles) {
if (typeof candidate !== "string") {
continue;
}
const handle = normalizeHandleKey(candidate);
if (!handle) {
continue;
}
setCacheEntry(cache, handle, userId);
const withoutDiscriminator = handle.replace(DISCORD_DISCRIMINATOR_SUFFIX, "");
if (withoutDiscriminator && withoutDiscriminator !== handle) {
setCacheEntry(cache, withoutDiscriminator, userId);
}
}
}
export function resolveDiscordDirectoryUserId(params: {
accountId?: string | null;
handle: string;
}): string | undefined {
const cache = DIRECTORY_HANDLE_CACHE.get(normalizeAccountCacheKey(params.accountId));
if (!cache) {
return undefined;
}
const handle = normalizeHandleKey(params.handle);
if (!handle) {
return undefined;
}
const direct = cache.get(handle);
if (direct) {
return direct;
}
const withoutDiscriminator = handle.replace(DISCORD_DISCRIMINATOR_SUFFIX, "");
if (!withoutDiscriminator || withoutDiscriminator === handle) {
return undefined;
}
return cache.get(withoutDiscriminator);
}
export function resetDiscordDirectoryCacheForTest(): void {
DIRECTORY_HANDLE_CACHE.clear();
}

Some files were not shown because too many files have changed in this diff Show More