feat(status): warn on Discord message content intent

This commit is contained in:
Peter Steinberger
2026-01-08 23:07:24 +01:00
parent a6c309824e
commit 7392387ec1
9 changed files with 314 additions and 3 deletions

View File

@@ -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

View File

@@ -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<Record<string, unknown>>({
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 });

View File

@@ -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/);
});
});

View File

@@ -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.`,
);

View File

@@ -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",
});
});
});

View File

@@ -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<DiscordApplicationSummary | undefined> {
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<DiscordProbe> {
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 {

View File

@@ -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,

View File

@@ -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(

View File

@@ -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<string, unknown> {
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<string, unknown>,
): 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;
}