Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions DISCORD_CLOUD_PARITY.md
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.
2 changes: 1 addition & 1 deletion Dockerfile.ci
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ LABEL org.opencontainers.image.title="${OCI_TITLE}" \
org.opencontainers.image.version="${VERSION_CLEAN}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.licenses="${OCI_LICENSES}"
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl ffmpeg libopus-dev \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g tsx

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.cloud
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ LABEL org.opencontainers.image.title="${OCI_TITLE}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.licenses="${OCI_LICENSES}"

RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl ffmpeg libopus-dev \
&& rm -rf /var/lib/apt/lists/*
Comment on lines +133 to 134
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 libopus-dev installs headers; prefer libopus0 for runtime-only

libopus-dev is the development package (headers + static libs for compiling). The runtime image only needs the shared library. Using libopus-dev wastes a few MB and signals wrong intent.

Suggested change
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl ffmpeg libopus-dev \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl ffmpeg libopus0 \
&& rm -rf /var/lib/apt/lists/*

Same applies to Dockerfile.ci line 77.

Fix in Claude Code Fix in Cursor Fix in Codex


WORKDIR /app
Expand Down
3 changes: 3 additions & 0 deletions git-hooks/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-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; }
git lfs post-checkout "$@"
3 changes: 3 additions & 0 deletions git-hooks/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-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; }
git lfs post-commit "$@"
3 changes: 3 additions & 0 deletions git-hooks/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 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; }
git lfs post-merge "$@"
3 changes: 3 additions & 0 deletions git-hooks/pre-push
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Git LFS hook scripts unrelated to Discord parity feature

The four files git-hooks/post-checkout, git-hooks/post-commit, git-hooks/post-merge, git-hooks/pre-push are standard Git LFS hook scripts unrelated to the Discord cloud/local parity work and appear to have been accidentally staged. If the intent is to commit these for use with git config core.hookspath git-hooks, that should be a separate documented commit.

Fix in Claude Code Fix in Cursor Fix in Codex

25 changes: 23 additions & 2 deletions packages/agent/src/api/connector-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,33 @@ export interface ConnectorHealthMonitorOptions {

const DEFAULT_INTERVAL_MS = 60_000;

const CONNECTOR_PLUGIN_MAP: Record<string, string> = {
/**
* Maps connector config keys to the service/client name the plugin registers.
*
* Kept aligned with CONNECTOR_PLUGINS in plugin-auto-enable.ts — every
* connector that can be configured should be probeable here so that cloud
* and local agents get the same health monitoring coverage.
*/
export const CONNECTOR_PLUGIN_MAP: Record<string, string> = {
discord: "discord",
telegram: "telegram",
telegramAccount: "telegram-account",
twitter: "twitter",
slack: "slack",
farcaster: "farcaster",
lens: "lens",
whatsapp: "whatsapp",
signal: "signal",
imessage: "imessage",
msteams: "msteams",
feishu: "feishu",
matrix: "matrix",
nostr: "nostr",
blooio: "blooio",
twitch: "twitch",
mattermost: "mattermost",
googlechat: "google-chat",
wechat: "wechat",
};

export class ConnectorHealthMonitor {
Expand Down Expand Up @@ -89,7 +110,7 @@ export class ConnectorHealthMonitor {
}

private async probeConnector(name: string): Promise<ConnectorStatus> {
const pluginName = CONNECTOR_PLUGIN_MAP[name.toLowerCase()];
const pluginName = CONNECTOR_PLUGIN_MAP[name];
if (!pluginName) return "unknown";

const service = this.runtime.getService(pluginName);
Expand Down
92 changes: 92 additions & 0 deletions packages/agent/src/plugins/discord-voice-capability.ts
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 require() unavailable in Node.js ESM — opus always reports false

checkOpus() uses CommonJS require() to probe for @discordjs/opus and opusscript. Both Dockerfiles run the agent via node --import tsx milady.mjs — where milady.mjs forces ESM. Under Node.js ESM, require is not defined; the try/catch catches the ReferenceError, but this means checkOpus() always returns false in cloud containers regardless of whether the packages are installed. The graceful-degradation guarantee holds, but voice would be permanently misreported as unavailable even when deps are present.

Replace require() with a dynamic import() to work in both ESM and CJS:

Suggested change
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;
}
/** Check if an opus binding can be loaded. */
async function checkOpus(): Promise<boolean> {
// Try @discordjs/opus first (native, fastest), then opusscript (wasm fallback).
for (const pkg of ["@discordjs/opus", "opusscript"]) {
try {
await import(pkg);
return true;
} catch {
// not available
}
}
return false;
}

Then update detectVoiceCapability to await checkOpus() instead of calling it synchronously.

Fix in Claude Code Fix in Cursor Fix in Codex


/** 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;
}
Loading
Loading