-
Notifications
You must be signed in to change notification settings - Fork 62
feat(discord): full cloud/local plugin parity #1749
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: cloud/main
Are you sure you want to change the base?
Changes from all commits
caab765
f6f0174
41a248c
16e2652
8f5f4aa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| # Discord Plugin: Cloud / Local Parity | ||
|
|
||
| Status of feature parity between locally-run Milady agents and cloud-provisioned agents using `@elizaos/plugin-discord`. | ||
|
|
||
| ## Environment Variables | ||
|
|
||
| | Env Var | Local | Cloud | Notes | | ||
| |---------|-------|-------|-------| | ||
| | `DISCORD_API_TOKEN` | Config or `.env` | Injected at provisioning | Primary token used by the plugin | | ||
| | `DISCORD_BOT_TOKEN` | Mirrored from `DISCORD_API_TOKEN` | Mirrored from `DISCORD_API_TOKEN` | Legacy alias; both paths set it | | ||
| | `DISCORD_APPLICATION_ID` | Config, `.env`, or auto-resolved | Injected or auto-resolved | `autoResolveDiscordAppId()` fetches from Discord API if only bot token is set | | ||
|
|
||
| Both `applyConnectorSecretsToEnv` (runtime) and `collectConnectorEnvVars` (config/cloud provisioning) produce identical env var sets. No gaps. | ||
|
|
||
| ## Managed Discord OAuth (Cloud) | ||
|
|
||
| The cloud dashboard provides a managed Discord OAuth flow: | ||
|
|
||
| 1. **Init**: `POST /api/cloud/v1/milady/agents/:id/discord/oauth` returns `authorizeUrl` + `applicationId` | ||
| 2. **Browser**: User authorizes the shared Milady Discord app and selects a server | ||
| 3. **Callback**: Redirect back with `?discord=connected&managed=1&agentId=...&guildId=...&guildName=...` | ||
| 4. **Consume**: `consumeManagedDiscordCallbackUrl()` parses the callback, updates UI state | ||
| 5. **Disconnect**: `DELETE /api/cloud/v1/milady/agents/:id/discord` revokes the connection | ||
|
|
||
| The managed flow uses a shared Discord application owned by Eliza Cloud. The user who completes setup becomes the admin-locked Discord connector admin for role-gated actions. | ||
|
|
||
| **Local agents** use their own bot token directly (no OAuth flow needed). | ||
|
|
||
| ## Plugin Auto-Enable | ||
|
|
||
| Discord is auto-enabled when `connectors.discord` has a `token` or `botToken` field set. This works identically in cloud and local via `applyPluginAutoEnable()`. Cloud-provisioned agents also get `@elizaos/plugin-edge-tts` auto-enabled for voice output. | ||
|
|
||
| ## Connector Health Monitor | ||
|
|
||
| The health monitor (`ConnectorHealthMonitor`) now covers all 19 connectors including Discord, matching the full `CONNECTOR_PLUGINS` map. Cloud and local agents get identical health check coverage. | ||
|
|
||
| ## Known Limitations | ||
|
|
||
| ### Voice Support in Cloud Containers | ||
|
|
||
| Cloud container images (`Dockerfile.cloud`, `Dockerfile.ci`, `Dockerfile.cloud-slim`) use slim base images (`node:22-slim` or `node:22-bookworm-slim`) that do **not** include: | ||
|
|
||
| - `ffmpeg` (audio transcoding) | ||
| - `libopus-dev` / `@discordjs/opus` (Opus codec for Discord voice) | ||
| - `libsodium-dev` / `sodium-native` (encryption for voice connections) | ||
|
|
||
| **Impact**: Discord voice features (`joinChannel`, `leaveChannel`, `AudioMonitor`, voice transcription) will fail silently or throw at runtime in cloud containers. | ||
|
|
||
| **Workaround**: The plugin's voice features degrade gracefully - text-based Discord features (messages, reactions, threads, embeds, file attachments) work without voice dependencies. If voice is required in cloud, the container image must be extended with: | ||
|
|
||
| ```dockerfile | ||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | ||
| ffmpeg libopus-dev && rm -rf /var/lib/apt/lists/* | ||
| ``` | ||
|
|
||
| ### Advanced Discord Configuration | ||
|
|
||
| The following advanced config options are supported by the plugin but **not exposed** in the cloud dashboard UI. They can be passed through `agentConfig` or `environmentVars` at provisioning time: | ||
|
|
||
| | Feature | Config Path | Cloud Dashboard | | ||
| |---------|------------|-----------------| | ||
| | Per-guild settings | `connectors.discord.guilds.*` | Not exposed | | ||
| | Per-channel settings | `connectors.discord.channels.*` | Not exposed | | ||
| | DM policies | `connectors.discord.dmPolicy` | Not exposed | | ||
| | PluralKit support | `connectors.discord.pluralKit` | Not exposed | | ||
| | Exec approval flow | `connectors.discord.execApprovals` | Not exposed | | ||
| | Custom intents | `connectors.discord.intents` | Not exposed | | ||
| | Action gating | `connectors.discord.actions.*` | Not exposed | | ||
| | Bot nickname | `connectors.discord.botNickname` | Exposed (input field) | | ||
|
|
||
| These settings pass through correctly if included in the agent config at creation time via `createCloudCompatAgent({ agentConfig: { connectors: { discord: { ... } } } })`. | ||
|
|
||
| ### Multi-Account Discord | ||
|
|
||
| Local Milady supports multi-account Discord via `connectors.discord.accounts`. This is **not tested** in cloud containers and the managed OAuth flow only supports a single Discord connection per agent. Multi-account would require multiple bot tokens injected into the container environment, which the current provisioning API does not support. | ||
|
|
||
| ### Action Gating | ||
|
|
||
| The `DiscordActionConfig` (enabling/disabling specific actions like `sendMessage`, `addReaction`, `createThread`, etc.) works identically in cloud and local - it is handled entirely within the plugin based on the agent config. The cloud dashboard does not expose a UI for toggling individual actions, but the config is respected if passed at provisioning. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| #!/bin/sh | ||
| command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } | ||
| git lfs post-checkout "$@" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| #!/bin/sh | ||
| command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } | ||
| git lfs post-commit "$@" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| #!/bin/sh | ||
| command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } | ||
| git lfs post-merge "$@" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| #!/bin/sh | ||
| command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } | ||
| git lfs pre-push "$@" | ||
|
Comment on lines
+1
to
+3
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The four files |
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,92 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Discord voice capability detection. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Checks whether the runtime environment has the system-level dependencies | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * (ffmpeg, opus bindings) required by @discordjs/voice and prism-media. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * When deps are missing the discord plugin still loads — voice actions | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * return a user-friendly error instead of crashing. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { execFile } from "node:child_process"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Cached result so we only probe once per process. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| let cachedResult: VoiceCapability | undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface VoiceCapability { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| supported: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ffmpeg: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| opus: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| details: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Check if ffmpeg is available on PATH. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| function checkFfmpeg(): Promise<boolean> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return new Promise((resolve) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| execFile("ffmpeg", ["-version"], { timeout: 5_000 }, (err) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| resolve(!err); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Check if an opus binding can be loaded. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| function checkOpus(): boolean { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Try @discordjs/opus first (native, fastest), then opusscript (wasm fallback). | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const pkg of ["@discordjs/opus", "opusscript"]) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| require(pkg); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // not available | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+32
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Replace
Suggested change
Then update |
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Probe the environment for voice support. Result is cached after first call. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function detectVoiceCapability(): Promise<VoiceCapability> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (cachedResult) return cachedResult; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ffmpeg = await checkFfmpeg(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const opus = checkOpus(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const supported = ffmpeg && opus; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const missing: string[] = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!ffmpeg) missing.push("ffmpeg"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!opus) missing.push("opus bindings (@discordjs/opus or opusscript)"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const details = supported | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? "Voice dependencies available" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| : `Missing: ${missing.join(", ")}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| cachedResult = { supported, ffmpeg, opus, details }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return cachedResult; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Synchronous check after detection has run at least once. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function isVoiceSupported(): boolean { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return cachedResult?.supported ?? false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Get the cached capability result (undefined if detectVoiceCapability hasn't been called). */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function getVoiceCapability(): VoiceCapability | undefined { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return cachedResult; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Reset cached result (for testing). */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function resetVoiceCapabilityCache(): void { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| cachedResult = undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Guard for voice channel actions. Returns an error string when voice is | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * unavailable, or `null` when the action can proceed. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function voiceActionGuard(): string | null { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (cachedResult && !cachedResult.supported) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return `Voice is not available in this environment. ${cachedResult.details}. The Discord bot will continue to work for text channels.`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!cachedResult) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return "Voice capability has not been checked yet. Call detectVoiceCapability() first."; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
libopus-devinstalls headers; preferlibopus0for runtime-onlylibopus-devis the development package (headers + static libs for compiling). The runtime image only needs the shared library. Usinglibopus-devwastes a few MB and signals wrong intent.Same applies to
Dockerfile.ciline 77.