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
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:
11
extensions/discord/README.md
Normal file
11
extensions/discord/README.md
Normal 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.
|
||||
7
extensions/discord/account-inspect-api.ts
Normal file
7
extensions/discord/account-inspect-api.ts
Normal 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 });
|
||||
}
|
||||
2
extensions/discord/action-runtime-api.ts
Normal file
2
extensions/discord/action-runtime-api.ts
Normal 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
131
extensions/discord/api.ts
Normal 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";
|
||||
2
extensions/discord/channel-config-api.ts
Normal file
2
extensions/discord/channel-config-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Discord API module exposes the plugin public contract.
|
||||
export { DiscordChannelConfigSchema } from "./src/config-schema.js";
|
||||
3
extensions/discord/channel-plugin-api.ts
Normal file
3
extensions/discord/channel-plugin-api.ts
Normal 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";
|
||||
5
extensions/discord/config-api.ts
Normal file
5
extensions/discord/config-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Discord API module exposes the plugin public contract.
|
||||
export {
|
||||
buildChannelConfigSchema,
|
||||
DiscordConfigSchema,
|
||||
} from "openclaw/plugin-sdk/bundled-channel-config-schema";
|
||||
7
extensions/discord/configured-state.ts
Normal file
7
extensions/discord/configured-state.ts
Normal 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
|
||||
);
|
||||
}
|
||||
6
extensions/discord/contract-api.ts
Normal file
6
extensions/discord/contract-api.ts
Normal 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";
|
||||
15
extensions/discord/directory-contract-api.ts
Normal file
15
extensions/discord/directory-contract-api.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
2
extensions/discord/doctor-contract-api.ts
Normal file
2
extensions/discord/doctor-contract-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Discord API module exposes the plugin public contract.
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
14
extensions/discord/index.test.ts
Normal file
14
extensions/discord/index.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
27
extensions/discord/index.ts
Normal file
27
extensions/discord/index.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
2
extensions/discord/legacy-state-migrations-api.ts
Normal file
2
extensions/discord/legacy-state-migrations-api.ts
Normal 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
438
extensions/discord/npm-shrinkwrap.json
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
extensions/discord/openclaw.plugin.json
Normal file
22
extensions/discord/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
80
extensions/discord/package.json
Normal file
80
extensions/discord/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
16
extensions/discord/runtime-api.actions.ts
Normal file
16
extensions/discord/runtime-api.actions.ts
Normal 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";
|
||||
23
extensions/discord/runtime-api.lookup.ts
Normal file
23
extensions/discord/runtime-api.lookup.ts
Normal 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";
|
||||
55
extensions/discord/runtime-api.monitor.ts
Normal file
55
extensions/discord/runtime-api.monitor.ts
Normal 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";
|
||||
80
extensions/discord/runtime-api.send.ts
Normal file
80
extensions/discord/runtime-api.send.ts
Normal 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";
|
||||
32
extensions/discord/runtime-api.threads.ts
Normal file
32
extensions/discord/runtime-api.threads.ts
Normal 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";
|
||||
186
extensions/discord/runtime-api.ts
Normal file
186
extensions/discord/runtime-api.ts
Normal 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";
|
||||
3
extensions/discord/runtime-setter-api.ts
Normal file
3
extensions/discord/runtime-setter-api.ts
Normal 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";
|
||||
5
extensions/discord/secret-contract-api.ts
Normal file
5
extensions/discord/secret-contract-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Discord API module exposes the plugin public contract.
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-config-contract.js";
|
||||
2
extensions/discord/security-audit-contract-api.ts
Normal file
2
extensions/discord/security-audit-contract-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Discord API module exposes the plugin public contract.
|
||||
export { collectDiscordSecurityAuditFindings } from "./src/security-audit.js";
|
||||
5
extensions/discord/security-contract-api.ts
Normal file
5
extensions/discord/security-contract-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Discord API module exposes the plugin public contract.
|
||||
export {
|
||||
collectUnsupportedSecretRefConfigCandidates,
|
||||
unsupportedSecretRefSurfacePatterns,
|
||||
} from "./src/security-contract.js";
|
||||
3
extensions/discord/session-binding-contract-api.ts
Normal file
3
extensions/discord/session-binding-contract-api.ts
Normal 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";
|
||||
2
extensions/discord/session-key-api.ts
Normal file
2
extensions/discord/session-key-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Discord API module exposes the plugin public contract.
|
||||
export { normalizeExplicitDiscordSessionKey } from "./src/session-key-normalization.js";
|
||||
11
extensions/discord/setup-entry.test.ts
Normal file
11
extensions/discord/setup-entry.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
17
extensions/discord/setup-entry.ts
Normal file
17
extensions/discord/setup-entry.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
3
extensions/discord/setup-plugin-api.ts
Normal file
3
extensions/discord/setup-plugin-api.ts
Normal 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";
|
||||
136
extensions/discord/skills/discord/SKILL.md
Normal file
136
extensions/discord/skills/discord/SKILL.md
Normal 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"
|
||||
}
|
||||
```
|
||||
127
extensions/discord/src/account-inspect.test.ts
Normal file
127
extensions/discord/src/account-inspect.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
106
extensions/discord/src/account-inspect.ts
Normal file
106
extensions/discord/src/account-inspect.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
33
extensions/discord/src/account-token-inspect.ts
Normal file
33
extensions/discord/src/account-token-inspect.ts
Normal 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;
|
||||
}
|
||||
441
extensions/discord/src/accounts.test.ts
Normal file
441
extensions/discord/src/accounts.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
206
extensions/discord/src/accounts.ts
Normal file
206
extensions/discord/src/accounts.ts
Normal 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));
|
||||
}
|
||||
474
extensions/discord/src/actions/handle-action.guild-admin.ts
Normal file
474
extensions/discord/src/actions/handle-action.guild-admin.ts
Normal 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;
|
||||
}
|
||||
672
extensions/discord/src/actions/handle-action.test.ts
Normal file
672
extensions/discord/src/actions/handle-action.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
399
extensions/discord/src/actions/handle-action.ts
Normal file
399
extensions/discord/src/actions/handle-action.ts
Normal 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}.`);
|
||||
}
|
||||
729
extensions/discord/src/actions/runtime.guild.ts
Normal file
729
extensions/discord/src/actions/runtime.guild.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
270
extensions/discord/src/actions/runtime.messaging.messages.ts
Normal file
270
extensions/discord/src/actions/runtime.messaging.messages.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
76
extensions/discord/src/actions/runtime.messaging.runtime.ts
Normal file
76
extensions/discord/src/actions/runtime.messaging.runtime.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
424
extensions/discord/src/actions/runtime.messaging.send.ts
Normal file
424
extensions/discord/src/actions/runtime.messaging.send.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
411
extensions/discord/src/actions/runtime.messaging.shared.ts
Normal file
411
extensions/discord/src/actions/runtime.messaging.shared.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
38
extensions/discord/src/actions/runtime.messaging.ts
Normal file
38
extensions/discord/src/actions/runtime.messaging.ts
Normal 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}`);
|
||||
})()
|
||||
);
|
||||
}
|
||||
52
extensions/discord/src/actions/runtime.moderation-shared.ts
Normal file
52
extensions/discord/src/actions/runtime.moderation-shared.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
152
extensions/discord/src/actions/runtime.moderation.authz.test.ts
Normal file
152
extensions/discord/src/actions/runtime.moderation.authz.test.ts
Normal 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" },
|
||||
);
|
||||
});
|
||||
});
|
||||
117
extensions/discord/src/actions/runtime.moderation.ts
Normal file
117
extensions/discord/src/actions/runtime.moderation.ts
Normal 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");
|
||||
}
|
||||
166
extensions/discord/src/actions/runtime.presence.test.ts
Normal file
166
extensions/discord/src/actions/runtime.presence.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
118
extensions/discord/src/actions/runtime.presence.ts
Normal file
118
extensions/discord/src/actions/runtime.presence.ts
Normal 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 } : {},
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
91
extensions/discord/src/actions/runtime.shared.ts
Normal file
91
extensions/discord/src/actions/runtime.shared.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
2611
extensions/discord/src/actions/runtime.test.ts
Normal file
2611
extensions/discord/src/actions/runtime.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
88
extensions/discord/src/actions/runtime.ts
Normal file
88
extensions/discord/src/actions/runtime.ts
Normal 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}`);
|
||||
}
|
||||
79
extensions/discord/src/api-barrel.test.ts
Normal file
79
extensions/discord/src/api-barrel.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
433
extensions/discord/src/api.test.ts
Normal file
433
extensions/discord/src/api.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
231
extensions/discord/src/api.ts
Normal file
231
extensions/discord/src/api.ts
Normal 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" });
|
||||
}
|
||||
42
extensions/discord/src/approval-handler.runtime.test.ts
Normal file
42
extensions/discord/src/approval-handler.runtime.test.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
637
extensions/discord/src/approval-handler.runtime.ts
Normal file
637
extensions/discord/src/approval-handler.runtime.ts
Normal 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)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
381
extensions/discord/src/approval-native.test.ts
Normal file
381
extensions/discord/src/approval-native.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
227
extensions/discord/src/approval-native.ts
Normal file
227
extensions/discord/src/approval-native.ts
Normal 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;
|
||||
}
|
||||
15
extensions/discord/src/approval-runtime.ts
Normal file
15
extensions/discord/src/approval-runtime.ts
Normal 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";
|
||||
57
extensions/discord/src/approval-shared.ts
Normal file
57
extensions/discord/src/approval-shared.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
179
extensions/discord/src/audit-core.ts
Normal file
179
extensions/discord/src/audit-core.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
205
extensions/discord/src/audit.test.ts
Normal file
205
extensions/discord/src/audit.test.ts
Normal 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"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
33
extensions/discord/src/audit.ts
Normal file
33
extensions/discord/src/audit.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
46
extensions/discord/src/channel-actions.contract.test.ts
Normal file
46
extensions/discord/src/channel-actions.contract.test.ts
Normal 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"],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
2
extensions/discord/src/channel-actions.runtime.ts
Normal file
2
extensions/discord/src/channel-actions.runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Discord plugin module implements channel actions behavior.
|
||||
export { handleDiscordMessageAction } from "./actions/handle-action.js";
|
||||
541
extensions/discord/src/channel-actions.test.ts
Normal file
541
extensions/discord/src/channel-actions.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
272
extensions/discord/src/channel-actions.ts
Normal file
272
extensions/discord/src/channel-actions.ts
Normal 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 } : {}),
|
||||
});
|
||||
},
|
||||
};
|
||||
30
extensions/discord/src/channel-api.ts
Normal file
30
extensions/discord/src/channel-api.ts
Normal 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;
|
||||
}
|
||||
86
extensions/discord/src/channel.conversation.test.ts
Normal file
86
extensions/discord/src/channel.conversation.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
176
extensions/discord/src/channel.conversation.ts
Normal file
176
extensions/discord/src/channel.conversation.ts
Normal 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;
|
||||
}
|
||||
32
extensions/discord/src/channel.loaders.ts
Normal file
32
extensions/discord/src/channel.loaders.ts
Normal 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"),
|
||||
);
|
||||
269
extensions/discord/src/channel.message-adapter.test.ts
Normal file
269
extensions/discord/src/channel.message-adapter.test.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
2
extensions/discord/src/channel.runtime.ts
Normal file
2
extensions/discord/src/channel.runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Discord plugin module implements channel behavior.
|
||||
export { discordSetupWizard } from "./setup-surface.js";
|
||||
13
extensions/discord/src/channel.setup.ts
Normal file
13
extensions/discord/src/channel.setup.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
853
extensions/discord/src/channel.test.ts
Normal file
853
extensions/discord/src/channel.test.ts
Normal 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"] });
|
||||
});
|
||||
});
|
||||
759
extensions/discord/src/channel.ts
Normal file
759
extensions/discord/src/channel.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
207
extensions/discord/src/chunk.test.ts
Normal file
207
extensions/discord/src/chunk.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
331
extensions/discord/src/chunk.ts
Normal file
331
extensions/discord/src/chunk.ts
Normal 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;
|
||||
}
|
||||
276
extensions/discord/src/client.proxy.test.ts
Normal file
276
extensions/discord/src/client.proxy.test.ts
Normal 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;
|
||||
}
|
||||
92
extensions/discord/src/client.test.ts
Normal file
92
extensions/discord/src/client.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
157
extensions/discord/src/client.ts
Normal file
157
extensions/discord/src/client.ts
Normal 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;
|
||||
}
|
||||
113
extensions/discord/src/component-custom-id.ts
Normal file
113
extensions/discord/src/component-custom-id.ts
Normal 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) };
|
||||
}
|
||||
435
extensions/discord/src/components-registry.ts
Normal file
435
extensions/discord/src/components-registry.ts
Normal 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;
|
||||
}
|
||||
423
extensions/discord/src/components.builders.ts
Normal file
423
extensions/discord/src/components.builders.ts
Normal 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;
|
||||
}
|
||||
123
extensions/discord/src/components.modal.ts
Normal file
123
extensions/discord/src/components.modal.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
454
extensions/discord/src/components.parse.ts
Normal file
454
extensions/discord/src/components.parse.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
675
extensions/discord/src/components.test.ts
Normal file
675
extensions/discord/src/components.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
56
extensions/discord/src/components.ts
Normal file
56
extensions/discord/src/components.ts
Normal 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}".`;
|
||||
}
|
||||
194
extensions/discord/src/components.types.ts
Normal file
194
extensions/discord/src/components.types.ts
Normal 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[];
|
||||
};
|
||||
499
extensions/discord/src/config-schema.test.ts
Normal file
499
extensions/discord/src/config-schema.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
7
extensions/discord/src/config-schema.ts
Normal file
7
extensions/discord/src/config-schema.ts
Normal 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,
|
||||
});
|
||||
407
extensions/discord/src/config-ui-hints.ts
Normal file
407
extensions/discord/src/config-ui-hints.ts
Normal 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>;
|
||||
59
extensions/discord/src/conversation-identity.ts
Normal file
59
extensions/discord/src/conversation-identity.ts
Normal 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;
|
||||
}
|
||||
56
extensions/discord/src/delivery-retry.ts
Normal file
56
extensions/discord/src/delivery-retry.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
117
extensions/discord/src/directory-cache.ts
Normal file
117
extensions/discord/src/directory-cache.ts
Normal 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
Reference in New Issue
Block a user