fix(discord): audit voice channel permissions

This commit is contained in:
Peter Steinberger
2026-05-07 04:46:03 +01:00
parent db82380819
commit a4d7206558
10 changed files with 157 additions and 7 deletions

View File

@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Telegram: treat successful same-chat `message` tool outbound sends during an inbound telegram turn as delivered when deciding whether to emit the rewritten silent reply fallback (#78685). Thanks @neeravmakwana.
- Discord/voice: make `openclaw channels capabilities --channel discord --target channel:<id>` and `channels status --probe` audit voice-channel permissions, including auto-join targets, so missing Connect/Speak/Read Message History permissions show up before `/vc join`.
- Docs/iMessage: deprecate BlueBubbles for new OpenClaw setups, document the upstream server-release rationale, and point new iMessage deployments toward the native `imsg` path while keeping BlueBubbles as a supported legacy fallback.
- Discord/streaming: default Discord replies to progress draft previews so tool/work activity appears in one edited Discord message unless `channels.discord.streaming.mode` is set to `off`.
- Plugins/install: add `npm-pack:<path.tgz>` installs so local npm pack artifacts run through the same managed npm-root install, lockfile verification, dependency scan, and install-record path as registry npm plugins.

View File

@@ -1157,6 +1157,12 @@ Use `/vc join|leave|status` to control sessions. The command uses the account de
/vc leave
```
To inspect the bot's effective permissions before joining, run:
```bash
openclaw channels capabilities --channel discord --target channel:<voice-channel-id>
```
Auto-join example:
```json5

View File

@@ -22,6 +22,7 @@ openclaw channels list
openclaw channels status
openclaw channels capabilities
openclaw channels capabilities --channel discord --target channel:123
openclaw channels capabilities --channel discord --target channel:<voice-channel-id>
openclaw channels resolve --channel slack "#general" "@jane"
openclaw channels logs --channel all
```
@@ -124,7 +125,7 @@ Notes:
- `--channel` is optional; omit it to list every channel (including extensions).
- `--account` is only valid with `--channel`.
- `--target` accepts `channel:<id>` or a raw numeric channel id and only applies to Discord.
- `--target` accepts `channel:<id>` or a raw numeric channel id and only applies to Discord. For Discord voice channels, the permission check flags missing `ViewChannel`, `Connect`, `Speak`, `SendMessages`, and `ReadMessageHistory`.
- Probes are provider-specific: Discord intents + optional channel permissions; Slack bot + user scopes; Telegram bot flags + webhook; Signal daemon version; Microsoft Teams app token + Graph roles/scopes (annotated where known). Channels without probes report `Probe: unavailable`.
## Resolve names to IDs

View File

@@ -25,6 +25,15 @@ openclaw doctor --repair --non-interactive
openclaw doctor --generate-gateway-token
```
For channel-specific permissions, use the channel probes instead of `doctor`:
```bash
openclaw channels capabilities --channel discord --target channel:<channel-id>
openclaw channels status --probe
```
The targeted Discord capabilities probe reports the bot's effective channel permissions; the status probe audits configured Discord channels and voice auto-join targets.
## Options
- `--no-workspace-suggestions`: disable workspace memory/search suggestions

View File

@@ -107,6 +107,7 @@ cat ~/.openclaw/openclaw.json
- Matrix channel legacy state migration (in `--fix` / `--repair` mode).
- Gateway runtime checks (service installed but not running; cached launchd label).
- Channel status warnings (probed from the running gateway).
- Channel-specific permission checks live under `openclaw channels capabilities`; for example, Discord voice channel permissions are audited with `openclaw channels capabilities --channel discord --target channel:<channel-id>`.
- WhatsApp responsiveness checks for degraded Gateway event-loop health with local TUI clients still running; `--fix` stops only verified local TUI clients.
- Codex route repair for legacy `openai-codex/*` model refs in primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and session route pins; `--fix` rewrites them to `openai/*` and selects `agentRuntime.id: "codex"` only when the Codex plugin is installed, enabled, contributes the `codex` harness, and has usable OAuth. Otherwise it selects `agentRuntime.id: "pi"`.
- Supervisor config audit (launchd/systemd/schtasks) with optional repair.

View File

@@ -1,3 +1,4 @@
import { ChannelType } from "discord-api-types/v10";
import type {
DiscordGuildChannelConfig,
DiscordGuildEntry,
@@ -23,7 +24,21 @@ export type DiscordChannelPermissionsAudit = {
elapsedMs: number;
};
const REQUIRED_CHANNEL_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
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) {
@@ -76,6 +91,27 @@ export function collectDiscordAuditChannelIdsForGuilds(
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;
@@ -87,6 +123,7 @@ export async function auditDiscordChannelPermissionsWithFetcher(params: {
params: { cfg: OpenClawConfig; token: string; accountId?: string },
) => Promise<{
permissions: string[];
channelType?: number;
}>;
}): Promise<DiscordChannelPermissionsAudit> {
const started = Date.now();
@@ -101,7 +138,6 @@ export async function auditDiscordChannelPermissionsWithFetcher(params: {
};
}
const required = [...REQUIRED_CHANNEL_PERMISSIONS];
const channels: DiscordChannelPermissionsAuditEntry[] = [];
for (const channelId of params.channelIds) {
@@ -111,6 +147,7 @@ export async function auditDiscordChannelPermissionsWithFetcher(params: {
token,
accountId: params.accountId ?? undefined,
});
const required = resolveRequiredDiscordChannelPermissions(perms.channelType);
const missing = required.filter((p) => !perms.permissions.includes(p));
channels.push({
channelId,

View File

@@ -1,7 +1,9 @@
import { ChannelType } from "discord-api-types/v10";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
auditDiscordChannelPermissionsWithFetcher,
collectDiscordAuditChannelIdsForAccount,
collectDiscordAuditChannelIdsForGuilds,
} from "./audit-core.js";
@@ -142,4 +144,59 @@ describe("discord audit", () => {
expect(collected.channelIds).toEqual(["111"]);
expect(collected.unresolvedChannels).toBe(1);
});
it("includes configured voice auto-join channels in permission audits", () => {
const collected = collectDiscordAuditChannelIdsForAccount({
guilds: {
"123": {
channels: {
"111": { enabled: true },
},
},
},
voice: {
autoJoin: [
{ guildId: "123", channelId: "222" },
{ guildId: "123", channelId: "general" },
],
},
});
expect(collected.channelIds).toEqual(["111", "222"]);
expect(collected.unresolvedChannels).toBe(1);
});
it.each([ChannelType.GuildVoice, ChannelType.GuildStageVoice])(
"requires voice permissions for voice channel audit targets of type %s",
async (channelType) => {
const cfg = {
channels: {
discord: {
enabled: true,
token: "t",
},
},
} as unknown as OpenClawConfig;
fetchChannelPermissionsDiscordMock.mockResolvedValueOnce({
channelId: "222",
permissions: ["ViewChannel", "SendMessages"],
channelType,
raw: "0",
isDm: false,
});
const audit = await auditDiscordChannelPermissionsWithFetcher({
cfg,
token: "t",
accountId: "default",
channelIds: ["222"],
timeoutMs: 1000,
fetchChannelPermissions: fetchChannelPermissionsDiscordMock,
});
expect(audit.ok).toBe(false);
expect(audit.channels[0]?.missing).toEqual(["Connect", "Speak", "ReadMessageHistory"]);
},
);
});

View File

@@ -2,7 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { inspectDiscordAccount } from "./account-inspect.js";
import {
auditDiscordChannelPermissionsWithFetcher,
collectDiscordAuditChannelIdsForGuilds,
collectDiscordAuditChannelIdsForAccount,
type DiscordChannelPermissionsAudit,
} from "./audit-core.js";
import { fetchChannelPermissionsDiscord } from "./send.js";
@@ -15,7 +15,7 @@ export function collectDiscordAuditChannelIds(params: {
cfg: params.cfg,
accountId: params.accountId,
});
return collectDiscordAuditChannelIdsForGuilds(account.config.guilds);
return collectDiscordAuditChannelIdsForAccount(account.config);
}
export async function auditDiscordChannelPermissions(params: {

View File

@@ -1,5 +1,6 @@
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";
@@ -412,6 +413,42 @@ describe("discordPlugin outbound", () => {
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(fetchPermissionsSpy).toHaveBeenCalledWith(
"222",
expect.objectContaining({ token: "discord-token" }),
);
expect(diagnostics?.details?.permissions).toMatchObject({
channelId: "222",
missingRequired: ["Connect", "Speak", "ReadMessageHistory"],
});
expect(diagnostics?.lines?.map((line) => line.text).join("\n")).toContain(
"Missing required: Connect, Speak, ReadMessageHistory",
);
} 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");

View File

@@ -29,6 +29,7 @@ import {
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,
@@ -81,7 +82,6 @@ import { collectDiscordStatusIssues } from "./status-issues.js";
import { parseDiscordTarget } from "./target-parsing.js";
import { resolveDiscordTarget } from "./target-resolver.js";
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000;
const discordMessageAdapter = createChannelMessageAdapterFromOutbound({
id: "discord",
@@ -555,7 +555,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
token,
accountId: account.accountId ?? undefined,
});
const missingRequired = REQUIRED_DISCORD_PERMISSIONS.filter(
const requiredPermissions = resolveRequiredDiscordChannelPermissions(perms.channelType);
const missingRequired = requiredPermissions.filter(
(permission) => !perms.permissions.includes(permission),
);
details.permissions = {