diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6fa3f49d9..f5daf6a6b99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:` 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:` 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. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 49232edd9e4..60af6f6d41b 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -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: +``` + Auto-join example: ```json5 diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 92c2d4b472d..eab3ef8314f 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -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: 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:` or a raw numeric channel id and only applies to Discord. +- `--target` accepts `channel:` 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 diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index f3e395a5d7c..be306f071f1 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -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: +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 diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index e24cf58c1fb..75b1d7e1d3b 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -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:`. - 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. diff --git a/extensions/discord/src/audit-core.ts b/extensions/discord/src/audit-core.ts index 341cb6bc0ce..aae78919080 100644 --- a/extensions/discord/src/audit-core.ts +++ b/extensions/discord/src/audit-core.ts @@ -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; + 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 { 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, diff --git a/extensions/discord/src/audit.test.ts b/extensions/discord/src/audit.test.ts index 8b078092524..66746698bb8 100644 --- a/extensions/discord/src/audit.test.ts +++ b/extensions/discord/src/audit.test.ts @@ -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"]); + }, + ); }); diff --git a/extensions/discord/src/audit.ts b/extensions/discord/src/audit.ts index 1893c34c134..a3a6eaa3b07 100644 --- a/extensions/discord/src/audit.ts +++ b/extensions/discord/src/audit.ts @@ -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: { diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index ac397d4acb7..b9f1ea85d94 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -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"); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 503403fd73a..ce393579e9d 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -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 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 = {