mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 21:50:42 +00:00
fix(discord): audit voice channel permissions
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user