feat(status): warn on Discord message content intent
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
42
src/discord/probe.intents.test.ts
Normal file
42
src/discord/probe.intents.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
90
src/infra/providers-status-issues.ts
Normal file
90
src/infra/providers-status-issues.ts
Normal 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user