feat(providers): improve doctor + status probes

This commit is contained in:
Peter Steinberger
2026-01-08 23:48:07 +01:00
parent 41d484d239
commit 69f8af530d
22 changed files with 860 additions and 13 deletions

52
src/discord/audit.test.ts Normal file
View File

@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./send.js", () => ({
fetchChannelPermissionsDiscord: vi.fn(),
}));
describe("discord audit", () => {
it("collects numeric channel ids and counts unresolved keys", async () => {
const { collectDiscordAuditChannelIds, auditDiscordChannelPermissions } =
await import("./audit.js");
const { fetchChannelPermissionsDiscord } = await import("./send.js");
const cfg = {
discord: {
enabled: true,
token: "t",
groupPolicy: "allowlist",
guilds: {
"123": {
channels: {
"111": { allow: true },
general: { allow: true },
"222": { allow: false },
},
},
},
},
} as unknown as import("../config/config.js").ClawdbotConfig;
const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" });
expect(collected.channelIds).toEqual(["111"]);
expect(collected.unresolvedChannels).toBe(1);
(fetchChannelPermissionsDiscord as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
channelId: "111",
permissions: ["ViewChannel"],
raw: "0",
isDm: false,
});
const audit = await auditDiscordChannelPermissions({
token: "t",
accountId: "default",
channelIds: collected.channelIds,
timeoutMs: 1000,
});
expect(audit.ok).toBe(false);
expect(audit.channels[0]?.channelId).toBe("111");
expect(audit.channels[0]?.missing).toContain("SendMessages");
});
});

122
src/discord/audit.ts Normal file
View File

@@ -0,0 +1,122 @@
import type { ClawdbotConfig } from "../config/config.js";
import type {
DiscordGuildChannelConfig,
DiscordGuildEntry,
} from "../config/types.js";
import { resolveDiscordAccount } from "./accounts.js";
import { fetchChannelPermissionsDiscord } from "./send.js";
export type DiscordChannelPermissionsAuditEntry = {
channelId: string;
ok: boolean;
missing?: string[];
error?: string | null;
};
export type DiscordChannelPermissionsAudit = {
ok: boolean;
checkedChannels: number;
unresolvedChannels: number;
channels: DiscordChannelPermissionsAuditEntry[];
elapsedMs: number;
};
const REQUIRED_CHANNEL_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function shouldAuditChannelConfig(config: DiscordGuildChannelConfig | undefined) {
if (!config) return true;
if (config.allow === false) return false;
if (config.enabled === false) return false;
return true;
}
function listConfiguredGuildChannelKeys(
guilds: Record<string, DiscordGuildEntry> | undefined,
): string[] {
if (!guilds) return [];
const ids = new Set<string>();
for (const entry of Object.values(guilds)) {
if (!entry || typeof entry !== "object") continue;
const channelsRaw = (entry as { channels?: unknown }).channels;
if (!isRecord(channelsRaw)) continue;
for (const [key, value] of Object.entries(channelsRaw)) {
const channelId = String(key).trim();
if (!channelId) continue;
if (!shouldAuditChannelConfig(value as DiscordGuildChannelConfig | undefined))
continue;
ids.add(channelId);
}
}
return [...ids].sort((a, b) => a.localeCompare(b));
}
export function collectDiscordAuditChannelIds(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}) {
const account = resolveDiscordAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const keys = listConfiguredGuildChannelKeys(account.config.guilds);
const channelIds = keys.filter((key) => /^\d+$/.test(key));
const unresolvedChannels = keys.length - channelIds.length;
return { channelIds, unresolvedChannels };
}
export async function auditDiscordChannelPermissions(params: {
token: string;
accountId?: string | null;
channelIds: string[];
timeoutMs: number;
}): Promise<DiscordChannelPermissionsAudit> {
const started = Date.now();
const token = params.token?.trim() ?? "";
if (!token || params.channelIds.length === 0) {
return {
ok: true,
checkedChannels: 0,
unresolvedChannels: 0,
channels: [],
elapsedMs: Date.now() - started,
};
}
const required = [...REQUIRED_CHANNEL_PERMISSIONS];
const channels: DiscordChannelPermissionsAuditEntry[] = [];
for (const channelId of params.channelIds) {
try {
const perms = await fetchChannelPermissionsDiscord(channelId, {
token,
accountId: params.accountId ?? undefined,
});
const missing = required.filter((p) => !perms.permissions.includes(p));
channels.push({
channelId,
ok: missing.length === 0,
missing: missing.length ? missing : undefined,
error: null,
});
} catch (err) {
channels.push({
channelId,
ok: false,
error: err instanceof Error ? err.message : String(err),
});
}
}
return {
ok: channels.every((c) => c.ok),
checkedChannels: channels.length,
unresolvedChannels: 0,
channels,
elapsedMs: Date.now() - started,
};
}

View File

@@ -45,6 +45,7 @@ import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { formatDurationSeconds } from "../infra/format-duration.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { recordProviderActivity } from "../infra/provider-activity.js";
import { getChildLogger } from "../logging.js";
import { detectMime } from "../media/mime.js";
import { saveMediaBuffer } from "../media/store.js";
@@ -575,6 +576,11 @@ export function createDiscordMessageHandler(params: {
}
const botId = botUserId;
const baseText = resolveDiscordMessageText(message);
recordProviderActivity({
provider: "discord",
accountId,
direction: "inbound",
});
const route = resolveAgentRoute({
cfg,
provider: "discord",

View File

@@ -32,6 +32,7 @@ import { loadWebMedia, loadWebMediaRaw } from "../web/media.js";
import { resolveDiscordAccount } from "./accounts.js";
import { chunkDiscordText } from "./chunk.js";
import { normalizeDiscordToken } from "./token.js";
import { recordProviderActivity } from "../infra/provider-activity.js";
const DISCORD_TEXT_LIMIT = 2000;
const DISCORD_MAX_STICKERS = 3;
@@ -589,6 +590,11 @@ export async function sendMessageDiscord(
});
}
recordProviderActivity({
provider: "discord",
accountId: accountInfo.accountId,
direction: "outbound",
});
return {
messageId: result.id ? String(result.id) : "unknown",
channelId: String(result.channel_id ?? channelId),