From 7392387ec1c422ce7e1eed615cad308fc449d3c7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 23:07:24 +0100 Subject: [PATCH] feat(status): warn on Discord message content intent --- CHANGELOG.md | 1 + src/commands/doctor.ts | 26 +++++++ src/commands/providers.test.ts | 16 +++++ src/commands/providers/status.ts | 23 +++++++ src/discord/probe.intents.test.ts | 42 ++++++++++++ src/discord/probe.ts | 91 ++++++++++++++++++++++++- src/gateway/server-methods/providers.ts | 6 +- src/gateway/server-providers.ts | 22 +++++- src/infra/providers-status-issues.ts | 90 ++++++++++++++++++++++++ 9 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 src/discord/probe.intents.test.ts create mode 100644 src/infra/providers-status-issues.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 28c68d07a..5b0ccb8ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ - Daemon runtime: remove Bun from selection options. - CLI: restore hidden `gateway-daemon` alias for legacy launchd configs. - Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf +- Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings. ## 2026.1.8 diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index a415609a7..feefad70f 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -64,6 +64,8 @@ import { printWizardHeader, } from "./onboard-helpers.js"; import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; +import { callGateway } from "../gateway/call.js"; +import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js"; function resolveMode(cfg: ClawdbotConfig): "local" | "remote" { return cfg.gateway?.mode === "remote" ? "remote" : "local"; @@ -237,6 +239,30 @@ export async function doctorCommand( } } + if (healthOk) { + try { + const status = await callGateway>({ + method: "providers.status", + params: { probe: false, timeoutMs: 5000 }, + timeoutMs: 6000, + }); + const issues = collectProvidersStatusIssues(status); + if (issues.length > 0) { + note( + issues + .map( + (issue) => + `- ${issue.provider} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`, + ) + .join("\n"), + "Provider warnings", + ); + } + } catch { + // ignore: doctor already reported gateway health + } + } + if (!healthOk) { const service = resolveGatewayService(); const loaded = await service.isLoaded({ env: process.env }); diff --git a/src/commands/providers.test.ts b/src/commands/providers.test.ts index f48eeaf11..1ade6cfc7 100644 --- a/src/commands/providers.test.ts +++ b/src/commands/providers.test.ts @@ -323,4 +323,20 @@ describe("providers command", () => { expect(whatsappIndex).toBeGreaterThan(-1); expect(telegramIndex).toBeLessThan(whatsappIndex); }); + + it("surfaces Discord privileged intent issues in providers status output", () => { + const lines = formatGatewayProvidersStatusLines({ + discordAccounts: [ + { + accountId: "default", + enabled: true, + configured: true, + application: { intents: { messageContent: "limited" } }, + }, + ], + }); + expect(lines.join("\n")).toMatch(/Warnings:/); + expect(lines.join("\n")).toMatch(/Message Content Intent is limited/i); + expect(lines.join("\n")).toMatch(/Run: clawdbot doctor/); + }); }); diff --git a/src/commands/providers/status.ts b/src/commands/providers/status.ts index 4fd169b5e..7c4831c13 100644 --- a/src/commands/providers/status.ts +++ b/src/commands/providers/status.ts @@ -13,6 +13,7 @@ import { resolveIMessageAccount, } from "../../imessage/accounts.js"; import { formatAge } from "../../infra/provider-summary.js"; +import { collectProvidersStatusIssues } from "../../infra/providers-status-issues.js"; import { listChatProviders } from "../../providers/registry.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { @@ -98,6 +99,17 @@ export function formatGatewayProvidersStatusLines( ) { bits.push(`app:${account.appTokenSource}`); } + const application = account.application as + | { intents?: { messageContent?: string } } + | undefined; + const messageContent = application?.intents?.messageContent; + if ( + typeof messageContent === "string" && + messageContent.length > 0 && + messageContent !== "enabled" + ) { + bits.push(`intents:content=${messageContent}`); + } if (typeof account.baseUrl === "string" && account.baseUrl) { bits.push(`url:${account.baseUrl}`); } @@ -150,6 +162,17 @@ export function formatGatewayProvidersStatusLines( } lines.push(""); + const issues = collectProvidersStatusIssues(payload); + if (issues.length > 0) { + lines.push(theme.warn("Warnings:")); + for (const issue of issues) { + lines.push( + `- ${issue.provider} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`, + ); + } + lines.push(`- Run: clawdbot doctor`); + lines.push(""); + } lines.push( `Tip: ${formatDocsLink("/cli#status", "status --deep")} runs local probes without a gateway.`, ); diff --git a/src/discord/probe.intents.test.ts b/src/discord/probe.intents.test.ts new file mode 100644 index 000000000..1b58657ac --- /dev/null +++ b/src/discord/probe.intents.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { resolveDiscordPrivilegedIntentsFromFlags } from "./probe.js"; + +describe("resolveDiscordPrivilegedIntentsFromFlags", () => { + it("reports disabled when no bits set", () => { + expect(resolveDiscordPrivilegedIntentsFromFlags(0)).toEqual({ + presence: "disabled", + guildMembers: "disabled", + messageContent: "disabled", + }); + }); + + it("reports enabled when full intent bits set", () => { + const flags = (1 << 12) | (1 << 14) | (1 << 18); + expect(resolveDiscordPrivilegedIntentsFromFlags(flags)).toEqual({ + presence: "enabled", + guildMembers: "enabled", + messageContent: "enabled", + }); + }); + + it("reports limited when limited intent bits set", () => { + const flags = (1 << 13) | (1 << 15) | (1 << 19); + expect(resolveDiscordPrivilegedIntentsFromFlags(flags)).toEqual({ + presence: "limited", + guildMembers: "limited", + messageContent: "limited", + }); + }); + + it("prefers enabled over limited when both set", () => { + const flags = + (1 << 12) | (1 << 13) | (1 << 14) | (1 << 15) | (1 << 18) | (1 << 19); + expect(resolveDiscordPrivilegedIntentsFromFlags(flags)).toEqual({ + presence: "enabled", + guildMembers: "enabled", + messageContent: "enabled", + }); + }); +}); + diff --git a/src/discord/probe.ts b/src/discord/probe.ts index 523169f32..ccea3c410 100644 --- a/src/discord/probe.ts +++ b/src/discord/probe.ts @@ -8,8 +8,89 @@ export type DiscordProbe = { error?: string | null; elapsedMs: number; bot?: { id?: string | null; username?: string | null }; + application?: DiscordApplicationSummary; }; +export type DiscordPrivilegedIntentStatus = "enabled" | "limited" | "disabled"; + +export type DiscordPrivilegedIntentsSummary = { + messageContent: DiscordPrivilegedIntentStatus; + guildMembers: DiscordPrivilegedIntentStatus; + presence: DiscordPrivilegedIntentStatus; +}; + +export type DiscordApplicationSummary = { + id?: string | null; + flags?: number | null; + intents?: DiscordPrivilegedIntentsSummary; +}; + +const DISCORD_APP_FLAG_GATEWAY_PRESENCE = 1 << 12; +const DISCORD_APP_FLAG_GATEWAY_PRESENCE_LIMITED = 1 << 13; +const DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS = 1 << 14; +const DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS_LIMITED = 1 << 15; +const DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT = 1 << 18; +const DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT_LIMITED = 1 << 19; + +export function resolveDiscordPrivilegedIntentsFromFlags( + flags: number, +): DiscordPrivilegedIntentsSummary { + const resolve = (enabledBit: number, limitedBit: number) => { + if ((flags & enabledBit) !== 0) return "enabled"; + if ((flags & limitedBit) !== 0) return "limited"; + return "disabled"; + }; + return { + presence: resolve( + DISCORD_APP_FLAG_GATEWAY_PRESENCE, + DISCORD_APP_FLAG_GATEWAY_PRESENCE_LIMITED, + ), + guildMembers: resolve( + DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS, + DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS_LIMITED, + ), + messageContent: resolve( + DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT, + DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT_LIMITED, + ), + }; +} + +export async function fetchDiscordApplicationSummary( + token: string, + timeoutMs: number, + fetcher: typeof fetch = fetch, +): Promise { + const normalized = normalizeDiscordToken(token); + if (!normalized) return undefined; + try { + const res = await fetchWithTimeout( + `${DISCORD_API_BASE}/oauth2/applications/@me`, + timeoutMs, + fetcher, + { + Authorization: `Bot ${normalized}`, + }, + ); + if (!res.ok) return undefined; + const json = (await res.json()) as { id?: string; flags?: number }; + const flags = + typeof json.flags === "number" && Number.isFinite(json.flags) + ? json.flags + : undefined; + return { + id: json.id ?? null, + flags: flags ?? null, + intents: + typeof flags === "number" + ? resolveDiscordPrivilegedIntentsFromFlags(flags) + : undefined, + }; + } catch { + return undefined; + } +} + async function fetchWithTimeout( url: string, timeoutMs: number, @@ -28,8 +109,11 @@ async function fetchWithTimeout( export async function probeDiscord( token: string, timeoutMs: number, + opts?: { fetcher?: typeof fetch; includeApplication?: boolean }, ): Promise { const started = Date.now(); + const fetcher = opts?.fetcher ?? fetch; + const includeApplication = opts?.includeApplication === true; const normalized = normalizeDiscordToken(token); const result: DiscordProbe = { ok: false, @@ -48,7 +132,7 @@ export async function probeDiscord( const res = await fetchWithTimeout( `${DISCORD_API_BASE}/users/@me`, timeoutMs, - fetch, + fetcher, { Authorization: `Bot ${normalized}`, }, @@ -64,6 +148,11 @@ export async function probeDiscord( id: json.id ?? null, username: json.username ?? null, }; + if (includeApplication) { + result.application = + (await fetchDiscordApplicationSummary(normalized, timeoutMs, fetcher)) ?? + undefined; + } return { ...result, elapsedMs: Date.now() - started }; } catch (err) { return { diff --git a/src/gateway/server-methods/providers.ts b/src/gateway/server-methods/providers.ts index a9318a0ee..3d84e77e6 100644 --- a/src/gateway/server-methods/providers.ts +++ b/src/gateway/server-methods/providers.ts @@ -130,7 +130,9 @@ export const providersHandlers: GatewayRequestHandlers = { let discordProbe: DiscordProbe | undefined; let lastProbeAt: number | null = null; if (probe && configured && account.enabled) { - discordProbe = await probeDiscord(account.token, timeoutMs); + discordProbe = await probeDiscord(account.token, timeoutMs, { + includeApplication: true, + }); lastProbeAt = Date.now(); } return { @@ -139,6 +141,8 @@ export const providersHandlers: GatewayRequestHandlers = { enabled: account.enabled, configured, tokenSource: account.tokenSource, + bot: rt?.bot ?? null, + application: rt?.application ?? null, running: rt?.running ?? false, lastStartAt: rt?.lastStartAt ?? null, lastStopAt: rt?.lastStopAt ?? null, diff --git a/src/gateway/server-providers.ts b/src/gateway/server-providers.ts index 846c3ec1d..20a8ac90c 100644 --- a/src/gateway/server-providers.ts +++ b/src/gateway/server-providers.ts @@ -5,6 +5,7 @@ import { resolveDiscordAccount, } from "../discord/accounts.js"; import { monitorDiscordProvider } from "../discord/index.js"; +import type { DiscordApplicationSummary, DiscordProbe } from "../discord/probe.js"; import { probeDiscord } from "../discord/probe.js"; import { shouldLogVerbose } from "../globals.js"; import { @@ -56,6 +57,8 @@ export type DiscordRuntimeStatus = { lastStartAt?: number | null; lastStopAt?: number | null; lastError?: string | null; + bot?: DiscordProbe["bot"]; + application?: DiscordApplicationSummary; }; export type SlackRuntimeStatus = { @@ -194,6 +197,8 @@ export function createProviderManager( lastStartAt: null, lastStopAt: null, lastError: null, + bot: undefined, + application: undefined, }); const defaultSlackStatus = (): SlackRuntimeStatus => ({ running: false, @@ -544,9 +549,24 @@ export function createProviderManager( } let discordBotLabel = ""; try { - const probe = await probeDiscord(token, 2500); + const probe = await probeDiscord(token, 2500, { + includeApplication: true, + }); const username = probe.ok ? probe.bot?.username?.trim() : null; if (username) discordBotLabel = ` (@${username})`; + const latest = + discordRuntimes.get(account.accountId) ?? defaultDiscordStatus(); + discordRuntimes.set(account.accountId, { + ...latest, + bot: probe.bot, + application: probe.application, + }); + const messageContent = probe.application?.intents?.messageContent; + if (messageContent && messageContent !== "enabled") { + logDiscord.warn( + `[${account.accountId}] Discord Message Content Intent is ${messageContent}; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`, + ); + } } catch (err) { if (shouldLogVerbose()) { logDiscord.debug( diff --git a/src/infra/providers-status-issues.ts b/src/infra/providers-status-issues.ts new file mode 100644 index 000000000..8281a0bdf --- /dev/null +++ b/src/infra/providers-status-issues.ts @@ -0,0 +1,90 @@ +export type ProviderStatusIssue = { + provider: "discord"; + accountId: string; + kind: "intent" | "permissions" | "config"; + message: string; + fix?: string; +}; + +type DiscordIntentSummary = { + messageContent?: "enabled" | "limited" | "disabled"; +}; + +type DiscordApplicationSummary = { + intents?: DiscordIntentSummary; +}; + +type DiscordAccountStatus = { + accountId?: unknown; + enabled?: unknown; + configured?: unknown; + application?: unknown; +}; + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function readDiscordAccountStatus(value: unknown): DiscordAccountStatus | null { + if (!isRecord(value)) return null; + return { + accountId: value.accountId, + enabled: value.enabled, + configured: value.configured, + application: value.application, + }; +} + +function readDiscordApplicationSummary(value: unknown): DiscordApplicationSummary { + if (!isRecord(value)) return {}; + const intentsRaw = value.intents; + if (!isRecord(intentsRaw)) return {}; + return { + intents: { + messageContent: + intentsRaw.messageContent === "enabled" || + intentsRaw.messageContent === "limited" || + intentsRaw.messageContent === "disabled" + ? intentsRaw.messageContent + : undefined, + }, + }; +} + +export function collectProvidersStatusIssues( + payload: Record, +): ProviderStatusIssue[] { + const issues: ProviderStatusIssue[] = []; + const discordAccountsRaw = payload.discordAccounts; + if (!Array.isArray(discordAccountsRaw)) return issues; + + for (const entry of discordAccountsRaw) { + const account = readDiscordAccountStatus(entry); + if (!account) continue; + const accountId = asString(account.accountId) ?? "default"; + const enabled = account.enabled !== false; + const configured = account.configured === true; + if (!enabled || !configured) continue; + + const app = readDiscordApplicationSummary(account.application); + const messageContent = app.intents?.messageContent; + if (messageContent && messageContent !== "enabled") { + issues.push({ + provider: "discord", + accountId, + kind: "intent", + message: `Message Content Intent is ${messageContent}. Bot may not see normal channel messages.`, + fix: "Enable Message Content Intent in Discord Dev Portal → Bot → Privileged Gateway Intents, or require mention-only operation.", + }); + } + } + + return issues; +} +