feat(providers): improve doctor + status probes
This commit is contained in:
@@ -240,15 +240,15 @@ 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) {
|
||||
if (healthOk) {
|
||||
try {
|
||||
const status = await callGateway<Record<string, unknown>>({
|
||||
method: "providers.status",
|
||||
params: { probe: true, timeoutMs: 5000 },
|
||||
timeoutMs: 6000,
|
||||
});
|
||||
const issues = collectProvidersStatusIssues(status);
|
||||
if (issues.length > 0) {
|
||||
note(
|
||||
issues
|
||||
.map(
|
||||
|
||||
@@ -340,6 +340,31 @@ describe("providers command", () => {
|
||||
expect(lines.join("\n")).toMatch(/Run: clawdbot doctor/);
|
||||
});
|
||||
|
||||
it("surfaces Discord permission audit issues in providers status output", () => {
|
||||
const lines = formatGatewayProvidersStatusLines({
|
||||
discordAccounts: [
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
audit: {
|
||||
unresolvedChannels: 1,
|
||||
channels: [
|
||||
{
|
||||
channelId: "111",
|
||||
ok: false,
|
||||
missing: ["ViewChannel", "SendMessages"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
||||
expect(lines.join("\n")).toMatch(/permission audit/i);
|
||||
expect(lines.join("\n")).toMatch(/Channel 111/i);
|
||||
});
|
||||
|
||||
it("surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled", () => {
|
||||
const lines = formatGatewayProvidersStatusLines({
|
||||
telegramAccounts: [
|
||||
@@ -355,6 +380,28 @@ describe("providers command", () => {
|
||||
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
|
||||
});
|
||||
|
||||
it("surfaces Telegram group membership audit issues in providers status output", () => {
|
||||
const lines = formatGatewayProvidersStatusLines({
|
||||
telegramAccounts: [
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
audit: {
|
||||
hasWildcardUnmentionedGroups: true,
|
||||
unresolvedGroups: 1,
|
||||
groups: [
|
||||
{ chatId: "-1001", ok: false, status: "left", error: "not in group" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
||||
expect(lines.join("\n")).toMatch(/membership probing is not possible/i);
|
||||
expect(lines.join("\n")).toMatch(/Group -1001/i);
|
||||
});
|
||||
|
||||
it("surfaces WhatsApp auth/runtime hints when unlinked or disconnected", () => {
|
||||
const unlinked = formatGatewayProvidersStatusLines({
|
||||
whatsappAccounts: [
|
||||
|
||||
@@ -78,6 +78,16 @@ export function formatGatewayProvidersStatusLines(
|
||||
if (typeof account.connected === "boolean") {
|
||||
bits.push(account.connected ? "connected" : "disconnected");
|
||||
}
|
||||
const inboundAt =
|
||||
typeof account.lastInboundAt === "number" && Number.isFinite(account.lastInboundAt)
|
||||
? account.lastInboundAt
|
||||
: null;
|
||||
const outboundAt =
|
||||
typeof account.lastOutboundAt === "number" && Number.isFinite(account.lastOutboundAt)
|
||||
? account.lastOutboundAt
|
||||
: null;
|
||||
if (inboundAt) bits.push(`in:${formatAge(Date.now() - inboundAt)}`);
|
||||
if (outboundAt) bits.push(`out:${formatAge(Date.now() - outboundAt)}`);
|
||||
if (typeof account.mode === "string" && account.mode.length > 0) {
|
||||
bits.push(`mode:${account.mode}`);
|
||||
}
|
||||
@@ -123,6 +133,10 @@ export function formatGatewayProvidersStatusLines(
|
||||
if (probe && typeof probe.ok === "boolean") {
|
||||
bits.push(probe.ok ? "works" : "probe failed");
|
||||
}
|
||||
const audit = account.audit as { ok?: boolean } | undefined;
|
||||
if (audit && typeof audit.ok === "boolean") {
|
||||
bits.push(audit.ok ? "audit ok" : "audit failed");
|
||||
}
|
||||
if (typeof account.lastError === "string" && account.lastError) {
|
||||
bits.push(`error:${account.lastError}`);
|
||||
}
|
||||
|
||||
52
src/discord/audit.test.ts
Normal file
52
src/discord/audit.test.ts
Normal 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
122
src/discord/audit.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { TelegramGroupConfig } from "../../config/types.js";
|
||||
import {
|
||||
loadConfig,
|
||||
readConfigFileSnapshot,
|
||||
@@ -51,6 +52,15 @@ import {
|
||||
} from "../protocol/index.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { getProviderActivity } from "../../infra/provider-activity.js";
|
||||
import {
|
||||
auditDiscordChannelPermissions,
|
||||
collectDiscordAuditChannelIds,
|
||||
} from "../../discord/audit.js";
|
||||
import {
|
||||
auditTelegramGroupMembership,
|
||||
collectTelegramUnmentionedGroupIds,
|
||||
} from "../../telegram/audit.js";
|
||||
|
||||
export const providersHandlers: GatewayRequestHandlers = {
|
||||
"providers.status": async ({ params, respond, context }) => {
|
||||
@@ -89,6 +99,16 @@ export const providersHandlers: GatewayRequestHandlers = {
|
||||
const configured = Boolean(account.token);
|
||||
let telegramProbe: TelegramProbe | undefined;
|
||||
let lastProbeAt: number | null = null;
|
||||
const groups =
|
||||
cfg.telegram?.accounts?.[account.accountId]?.groups ??
|
||||
cfg.telegram?.groups;
|
||||
const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } =
|
||||
collectTelegramUnmentionedGroupIds(
|
||||
groups as Record<string, TelegramGroupConfig> | undefined,
|
||||
);
|
||||
let audit:
|
||||
| Awaited<ReturnType<typeof auditTelegramGroupMembership>>
|
||||
| undefined;
|
||||
if (probe && configured && account.enabled) {
|
||||
telegramProbe = await probeTelegram(
|
||||
account.token,
|
||||
@@ -96,10 +116,34 @@ export const providersHandlers: GatewayRequestHandlers = {
|
||||
account.config.proxy,
|
||||
);
|
||||
lastProbeAt = Date.now();
|
||||
const botId =
|
||||
telegramProbe.ok && telegramProbe.bot?.id != null
|
||||
? telegramProbe.bot.id
|
||||
: null;
|
||||
if (botId && (groupIds.length > 0 || unresolvedGroups > 0)) {
|
||||
const auditRes = await auditTelegramGroupMembership({
|
||||
token: account.token,
|
||||
botId,
|
||||
groupIds,
|
||||
proxyUrl: account.config.proxy,
|
||||
timeoutMs,
|
||||
});
|
||||
audit = {
|
||||
...auditRes,
|
||||
unresolvedGroups,
|
||||
hasWildcardUnmentionedGroups,
|
||||
};
|
||||
} else if (unresolvedGroups > 0 || hasWildcardUnmentionedGroups) {
|
||||
audit = {
|
||||
ok: unresolvedGroups === 0 && !hasWildcardUnmentionedGroups,
|
||||
checkedGroups: 0,
|
||||
unresolvedGroups,
|
||||
hasWildcardUnmentionedGroups,
|
||||
groups: [],
|
||||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
const groups =
|
||||
cfg.telegram?.accounts?.[account.accountId]?.groups ??
|
||||
cfg.telegram?.groups;
|
||||
const allowUnmentionedGroups =
|
||||
Boolean(
|
||||
groups?.["*"] &&
|
||||
@@ -126,7 +170,16 @@ export const providersHandlers: GatewayRequestHandlers = {
|
||||
lastError: rt?.lastError ?? null,
|
||||
probe: telegramProbe,
|
||||
lastProbeAt,
|
||||
audit,
|
||||
allowUnmentionedGroups,
|
||||
lastInboundAt: getProviderActivity({
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
}).inboundAt,
|
||||
lastOutboundAt: getProviderActivity({
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
}).outboundAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -146,11 +199,25 @@ export const providersHandlers: GatewayRequestHandlers = {
|
||||
const configured = Boolean(account.token);
|
||||
let discordProbe: DiscordProbe | undefined;
|
||||
let lastProbeAt: number | null = null;
|
||||
const { channelIds: auditChannelIds, unresolvedChannels } =
|
||||
collectDiscordAuditChannelIds({ cfg, accountId: account.accountId });
|
||||
let audit:
|
||||
| Awaited<ReturnType<typeof auditDiscordChannelPermissions>>
|
||||
| undefined;
|
||||
if (probe && configured && account.enabled) {
|
||||
discordProbe = await probeDiscord(account.token, timeoutMs, {
|
||||
includeApplication: true,
|
||||
});
|
||||
lastProbeAt = Date.now();
|
||||
if (auditChannelIds.length > 0 || unresolvedChannels > 0) {
|
||||
const auditRes = await auditDiscordChannelPermissions({
|
||||
token: account.token,
|
||||
accountId: account.accountId,
|
||||
channelIds: auditChannelIds,
|
||||
timeoutMs,
|
||||
});
|
||||
audit = { ...auditRes, unresolvedChannels };
|
||||
}
|
||||
}
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
@@ -166,6 +233,15 @@ export const providersHandlers: GatewayRequestHandlers = {
|
||||
lastError: rt?.lastError ?? null,
|
||||
probe: discordProbe,
|
||||
lastProbeAt,
|
||||
audit,
|
||||
lastInboundAt: getProviderActivity({
|
||||
provider: "discord",
|
||||
accountId: account.accountId,
|
||||
}).inboundAt,
|
||||
lastOutboundAt: getProviderActivity({
|
||||
provider: "discord",
|
||||
accountId: account.accountId,
|
||||
}).outboundAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -323,6 +399,14 @@ export const providersHandlers: GatewayRequestHandlers = {
|
||||
lastMessageAt: rt.lastMessageAt ?? null,
|
||||
lastEventAt: rt.lastEventAt ?? null,
|
||||
lastError: rt.lastError ?? null,
|
||||
lastInboundAt: getProviderActivity({
|
||||
provider: "whatsapp",
|
||||
accountId: account.accountId,
|
||||
}).inboundAt,
|
||||
lastOutboundAt: getProviderActivity({
|
||||
provider: "whatsapp",
|
||||
accountId: account.accountId,
|
||||
}).outboundAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
47
src/infra/provider-activity.test.ts
Normal file
47
src/infra/provider-activity.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
getProviderActivity,
|
||||
recordProviderActivity,
|
||||
resetProviderActivityForTest,
|
||||
} from "./provider-activity.js";
|
||||
|
||||
describe("provider activity", () => {
|
||||
beforeEach(() => {
|
||||
resetProviderActivityForTest();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-08T00:00:00Z"));
|
||||
});
|
||||
|
||||
it("records inbound/outbound separately", () => {
|
||||
recordProviderActivity({ provider: "telegram", direction: "inbound" });
|
||||
vi.advanceTimersByTime(1000);
|
||||
recordProviderActivity({ provider: "telegram", direction: "outbound" });
|
||||
const res = getProviderActivity({ provider: "telegram" });
|
||||
expect(res.inboundAt).toBe(1767830400000);
|
||||
expect(res.outboundAt).toBe(1767830401000);
|
||||
});
|
||||
|
||||
it("isolates accounts", () => {
|
||||
recordProviderActivity({
|
||||
provider: "whatsapp",
|
||||
accountId: "a",
|
||||
direction: "inbound",
|
||||
at: 1,
|
||||
});
|
||||
recordProviderActivity({
|
||||
provider: "whatsapp",
|
||||
accountId: "b",
|
||||
direction: "inbound",
|
||||
at: 2,
|
||||
});
|
||||
expect(getProviderActivity({ provider: "whatsapp", accountId: "a" })).toEqual({
|
||||
inboundAt: 1,
|
||||
outboundAt: null,
|
||||
});
|
||||
expect(getProviderActivity({ provider: "whatsapp", accountId: "b" })).toEqual({
|
||||
inboundAt: 2,
|
||||
outboundAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
53
src/infra/provider-activity.ts
Normal file
53
src/infra/provider-activity.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export type ProviderId = "discord" | "telegram" | "whatsapp";
|
||||
export type ProviderDirection = "inbound" | "outbound";
|
||||
|
||||
type ActivityEntry = {
|
||||
inboundAt: number | null;
|
||||
outboundAt: number | null;
|
||||
};
|
||||
|
||||
const activity = new Map<string, ActivityEntry>();
|
||||
|
||||
function keyFor(provider: ProviderId, accountId: string) {
|
||||
return `${provider}:${accountId || "default"}`;
|
||||
}
|
||||
|
||||
function ensureEntry(provider: ProviderId, accountId: string): ActivityEntry {
|
||||
const key = keyFor(provider, accountId);
|
||||
const existing = activity.get(key);
|
||||
if (existing) return existing;
|
||||
const created: ActivityEntry = { inboundAt: null, outboundAt: null };
|
||||
activity.set(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
export function recordProviderActivity(params: {
|
||||
provider: ProviderId;
|
||||
accountId?: string | null;
|
||||
direction: ProviderDirection;
|
||||
at?: number;
|
||||
}) {
|
||||
const at = typeof params.at === "number" ? params.at : Date.now();
|
||||
const accountId = params.accountId?.trim() || "default";
|
||||
const entry = ensureEntry(params.provider, accountId);
|
||||
if (params.direction === "inbound") entry.inboundAt = at;
|
||||
if (params.direction === "outbound") entry.outboundAt = at;
|
||||
}
|
||||
|
||||
export function getProviderActivity(params: {
|
||||
provider: ProviderId;
|
||||
accountId?: string | null;
|
||||
}): ActivityEntry {
|
||||
const accountId = params.accountId?.trim() || "default";
|
||||
return (
|
||||
activity.get(keyFor(params.provider, accountId)) ?? {
|
||||
inboundAt: null,
|
||||
outboundAt: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function resetProviderActivityForTest() {
|
||||
activity.clear();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type DiscordAccountStatus = {
|
||||
enabled?: unknown;
|
||||
configured?: unknown;
|
||||
application?: unknown;
|
||||
audit?: unknown;
|
||||
};
|
||||
|
||||
type TelegramAccountStatus = {
|
||||
@@ -26,6 +27,7 @@ type TelegramAccountStatus = {
|
||||
enabled?: unknown;
|
||||
configured?: unknown;
|
||||
allowUnmentionedGroups?: unknown;
|
||||
audit?: unknown;
|
||||
};
|
||||
|
||||
type WhatsAppAccountStatus = {
|
||||
@@ -55,6 +57,7 @@ function readDiscordAccountStatus(value: unknown): DiscordAccountStatus | null {
|
||||
enabled: value.enabled,
|
||||
configured: value.configured,
|
||||
application: value.application,
|
||||
audit: value.audit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,6 +79,49 @@ function readDiscordApplicationSummary(
|
||||
};
|
||||
}
|
||||
|
||||
type DiscordPermissionsAuditSummary = {
|
||||
unresolvedChannels?: number;
|
||||
channels?: Array<{
|
||||
channelId: string;
|
||||
ok?: boolean;
|
||||
missing?: string[];
|
||||
error?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
function readDiscordPermissionsAuditSummary(
|
||||
value: unknown,
|
||||
): DiscordPermissionsAuditSummary {
|
||||
if (!isRecord(value)) return {};
|
||||
const unresolvedChannels =
|
||||
typeof value.unresolvedChannels === "number" &&
|
||||
Number.isFinite(value.unresolvedChannels)
|
||||
? value.unresolvedChannels
|
||||
: undefined;
|
||||
const channelsRaw = value.channels;
|
||||
const channels = Array.isArray(channelsRaw)
|
||||
? (channelsRaw
|
||||
.map((entry) => {
|
||||
if (!isRecord(entry)) return null;
|
||||
const channelId = asString(entry.channelId);
|
||||
if (!channelId) return null;
|
||||
const ok = typeof entry.ok === "boolean" ? entry.ok : undefined;
|
||||
const missing = Array.isArray(entry.missing)
|
||||
? entry.missing.map((v) => asString(v)).filter(Boolean)
|
||||
: undefined;
|
||||
const error = asString(entry.error) ?? null;
|
||||
return {
|
||||
channelId,
|
||||
ok,
|
||||
missing: missing?.length ? missing : undefined,
|
||||
error,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as DiscordPermissionsAuditSummary["channels"])
|
||||
: undefined;
|
||||
return { unresolvedChannels, channels };
|
||||
}
|
||||
|
||||
function readTelegramAccountStatus(
|
||||
value: unknown,
|
||||
): TelegramAccountStatus | null {
|
||||
@@ -85,9 +131,51 @@ function readTelegramAccountStatus(
|
||||
enabled: value.enabled,
|
||||
configured: value.configured,
|
||||
allowUnmentionedGroups: value.allowUnmentionedGroups,
|
||||
audit: value.audit,
|
||||
};
|
||||
}
|
||||
|
||||
type TelegramGroupMembershipAuditSummary = {
|
||||
unresolvedGroups?: number;
|
||||
hasWildcardUnmentionedGroups?: boolean;
|
||||
groups?: Array<{
|
||||
chatId: string;
|
||||
ok?: boolean;
|
||||
status?: string | null;
|
||||
error?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
function readTelegramGroupMembershipAuditSummary(
|
||||
value: unknown,
|
||||
): TelegramGroupMembershipAuditSummary {
|
||||
if (!isRecord(value)) return {};
|
||||
const unresolvedGroups =
|
||||
typeof value.unresolvedGroups === "number" &&
|
||||
Number.isFinite(value.unresolvedGroups)
|
||||
? value.unresolvedGroups
|
||||
: undefined;
|
||||
const hasWildcardUnmentionedGroups =
|
||||
typeof value.hasWildcardUnmentionedGroups === "boolean"
|
||||
? value.hasWildcardUnmentionedGroups
|
||||
: undefined;
|
||||
const groupsRaw = value.groups;
|
||||
const groups = Array.isArray(groupsRaw)
|
||||
? (groupsRaw
|
||||
.map((entry) => {
|
||||
if (!isRecord(entry)) return null;
|
||||
const chatId = asString(entry.chatId);
|
||||
if (!chatId) return null;
|
||||
const ok = typeof entry.ok === "boolean" ? entry.ok : undefined;
|
||||
const status = asString(entry.status) ?? null;
|
||||
const error = asString(entry.error) ?? null;
|
||||
return { chatId, ok, status, error };
|
||||
})
|
||||
.filter(Boolean) as TelegramGroupMembershipAuditSummary["groups"])
|
||||
: undefined;
|
||||
return { unresolvedGroups, hasWildcardUnmentionedGroups, groups };
|
||||
}
|
||||
|
||||
function readWhatsAppAccountStatus(
|
||||
value: unknown,
|
||||
): WhatsAppAccountStatus | null {
|
||||
@@ -107,6 +195,7 @@ export function collectProvidersStatusIssues(
|
||||
payload: Record<string, unknown>,
|
||||
): ProviderStatusIssue[] {
|
||||
const issues: ProviderStatusIssue[] = [];
|
||||
|
||||
const discordAccountsRaw = payload.discordAccounts;
|
||||
if (Array.isArray(discordAccountsRaw)) {
|
||||
for (const entry of discordAccountsRaw) {
|
||||
@@ -128,6 +217,31 @@ export function collectProvidersStatusIssues(
|
||||
fix: "Enable Message Content Intent in Discord Dev Portal → Bot → Privileged Gateway Intents, or require mention-only operation.",
|
||||
});
|
||||
}
|
||||
|
||||
const audit = readDiscordPermissionsAuditSummary(account.audit);
|
||||
if (audit.unresolvedChannels && audit.unresolvedChannels > 0) {
|
||||
issues.push({
|
||||
provider: "discord",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: `Some configured guild channels are not numeric IDs (unresolvedChannels=${audit.unresolvedChannels}). Permission audit can only check numeric channel IDs.`,
|
||||
fix: "Use numeric channel IDs as keys in discord.guilds.*.channels (then rerun providers status --probe).",
|
||||
});
|
||||
}
|
||||
for (const channel of audit.channels ?? []) {
|
||||
if (channel.ok === true) continue;
|
||||
const missing = channel.missing?.length
|
||||
? ` missing ${channel.missing.join(", ")}`
|
||||
: "";
|
||||
const error = channel.error ? `: ${channel.error}` : "";
|
||||
issues.push({
|
||||
provider: "discord",
|
||||
accountId,
|
||||
kind: "permissions",
|
||||
message: `Channel ${channel.channelId} permission check failed.${missing}${error}`,
|
||||
fix: "Ensure the bot role can view + send in this channel (and that channel overrides don't deny it).",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +254,7 @@ export function collectProvidersStatusIssues(
|
||||
const enabled = account.enabled !== false;
|
||||
const configured = account.configured === true;
|
||||
if (!enabled || !configured) continue;
|
||||
|
||||
if (account.allowUnmentionedGroups === true) {
|
||||
issues.push({
|
||||
provider: "telegram",
|
||||
@@ -150,6 +265,39 @@ export function collectProvidersStatusIssues(
|
||||
fix: "In BotFather run /setprivacy → Disable for this bot (then restart the gateway).",
|
||||
});
|
||||
}
|
||||
|
||||
const audit = readTelegramGroupMembershipAuditSummary(account.audit);
|
||||
if (audit.hasWildcardUnmentionedGroups === true) {
|
||||
issues.push({
|
||||
provider: "telegram",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message:
|
||||
'Telegram groups config uses "*" with requireMention=false; membership probing is not possible without explicit group IDs.',
|
||||
fix: "Add explicit numeric group ids under telegram.groups (or per-account groups) to enable probing.",
|
||||
});
|
||||
}
|
||||
if (audit.unresolvedGroups && audit.unresolvedGroups > 0) {
|
||||
issues.push({
|
||||
provider: "telegram",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: `Some configured Telegram groups are not numeric IDs (unresolvedGroups=${audit.unresolvedGroups}). Membership probe can only check numeric group IDs.`,
|
||||
fix: "Use numeric chat IDs (e.g. -100...) as keys in telegram.groups for requireMention=false groups.",
|
||||
});
|
||||
}
|
||||
for (const group of audit.groups ?? []) {
|
||||
if (group.ok === true) continue;
|
||||
const status = group.status ? ` status=${group.status}` : "";
|
||||
const err = group.error ? `: ${group.error}` : "";
|
||||
issues.push({
|
||||
provider: "telegram",
|
||||
accountId,
|
||||
kind: "runtime",
|
||||
message: `Group ${group.chatId} not reachable by bot.${status}${err}`,
|
||||
fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,3 +343,4 @@ export function collectProvidersStatusIssues(
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
|
||||
66
src/telegram/audit.test.ts
Normal file
66
src/telegram/audit.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("telegram audit", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("collects unmentioned numeric group ids and flags wildcard", async () => {
|
||||
const { collectTelegramUnmentionedGroupIds } = await import("./audit.js");
|
||||
const res = collectTelegramUnmentionedGroupIds({
|
||||
"*": { requireMention: false },
|
||||
"-1001": { requireMention: false },
|
||||
"@group": { requireMention: false },
|
||||
"-1002": { requireMention: true },
|
||||
"-1003": { requireMention: false, enabled: false },
|
||||
});
|
||||
expect(res.hasWildcardUnmentionedGroups).toBe(true);
|
||||
expect(res.groupIds).toEqual(["-1001"]);
|
||||
expect(res.unresolvedGroups).toBe(1);
|
||||
});
|
||||
|
||||
it("audits membership via getChatMember", async () => {
|
||||
const { auditTelegramGroupMembership } = await import("./audit.js");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ ok: true, result: { status: "member" } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const res = await auditTelegramGroupMembership({
|
||||
token: "t",
|
||||
botId: 123,
|
||||
groupIds: ["-1001"],
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.groups[0]?.chatId).toBe("-1001");
|
||||
expect(res.groups[0]?.status).toBe("member");
|
||||
});
|
||||
|
||||
it("reports bot not in group when status is left", async () => {
|
||||
const { auditTelegramGroupMembership } = await import("./audit.js");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ ok: true, result: { status: "left" } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const res = await auditTelegramGroupMembership({
|
||||
token: "t",
|
||||
botId: 123,
|
||||
groupIds: ["-1001"],
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.groups[0]?.ok).toBe(false);
|
||||
expect(res.groups[0]?.status).toBe("left");
|
||||
});
|
||||
});
|
||||
|
||||
140
src/telegram/audit.ts
Normal file
140
src/telegram/audit.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { TelegramGroupConfig } from "../config/types.js";
|
||||
import { makeProxyFetch } from "./proxy.js";
|
||||
|
||||
const TELEGRAM_API_BASE = "https://api.telegram.org";
|
||||
|
||||
export type TelegramGroupMembershipAuditEntry = {
|
||||
chatId: string;
|
||||
ok: boolean;
|
||||
status?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export type TelegramGroupMembershipAudit = {
|
||||
ok: boolean;
|
||||
checkedGroups: number;
|
||||
unresolvedGroups: number;
|
||||
hasWildcardUnmentionedGroups: boolean;
|
||||
groups: TelegramGroupMembershipAuditEntry[];
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
type TelegramApiOk<T> = { ok: true; result: T };
|
||||
type TelegramApiErr = { ok: false; description?: string };
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
fetcher: typeof fetch,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetcher(url, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function collectTelegramUnmentionedGroupIds(
|
||||
groups: Record<string, TelegramGroupConfig> | undefined,
|
||||
) {
|
||||
if (!groups || typeof groups !== "object") {
|
||||
return {
|
||||
groupIds: [] as string[],
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
};
|
||||
}
|
||||
const hasWildcardUnmentionedGroups =
|
||||
Boolean(groups["*"]?.requireMention === false) &&
|
||||
groups["*"]?.enabled !== false;
|
||||
const groupIds: string[] = [];
|
||||
let unresolvedGroups = 0;
|
||||
for (const [key, value] of Object.entries(groups)) {
|
||||
if (key === "*") continue;
|
||||
if (!value || typeof value !== "object") continue;
|
||||
if ((value as TelegramGroupConfig).enabled === false) continue;
|
||||
if ((value as TelegramGroupConfig).requireMention !== false) continue;
|
||||
const id = String(key).trim();
|
||||
if (!id) continue;
|
||||
if (/^-?\d+$/.test(id)) {
|
||||
groupIds.push(id);
|
||||
} else {
|
||||
unresolvedGroups += 1;
|
||||
}
|
||||
}
|
||||
groupIds.sort((a, b) => a.localeCompare(b));
|
||||
return { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups };
|
||||
}
|
||||
|
||||
export async function auditTelegramGroupMembership(params: {
|
||||
token: string;
|
||||
botId: number;
|
||||
groupIds: string[];
|
||||
proxyUrl?: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<TelegramGroupMembershipAudit> {
|
||||
const started = Date.now();
|
||||
const token = params.token?.trim() ?? "";
|
||||
if (!token || params.groupIds.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
checkedGroups: 0,
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
groups: [],
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
const fetcher = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : fetch;
|
||||
const base = `${TELEGRAM_API_BASE}/bot${token}`;
|
||||
const groups: TelegramGroupMembershipAuditEntry[] = [];
|
||||
|
||||
for (const chatId of params.groupIds) {
|
||||
try {
|
||||
const url = `${base}/getChatMember?chat_id=${encodeURIComponent(chatId)}&user_id=${encodeURIComponent(String(params.botId))}`;
|
||||
const res = await fetchWithTimeout(url, params.timeoutMs, fetcher);
|
||||
const json = (await res.json()) as
|
||||
| TelegramApiOk<{ status?: string }>
|
||||
| TelegramApiErr
|
||||
| unknown;
|
||||
if (!res.ok || !isRecord(json) || json.ok !== true) {
|
||||
const desc =
|
||||
isRecord(json) && json.ok === false && typeof json.description === "string"
|
||||
? json.description
|
||||
: `getChatMember failed (${res.status})`;
|
||||
groups.push({ chatId, ok: false, status: null, error: desc });
|
||||
continue;
|
||||
}
|
||||
const status = isRecord((json as TelegramApiOk<unknown>).result)
|
||||
? (json as TelegramApiOk<{ status?: string }>).result.status ?? null
|
||||
: null;
|
||||
const ok =
|
||||
status === "creator" || status === "administrator" || status === "member";
|
||||
groups.push({ chatId, ok, status, error: ok ? null : "bot not in group" });
|
||||
} catch (err) {
|
||||
groups.push({
|
||||
chatId,
|
||||
ok: false,
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: groups.every((g) => g.ok),
|
||||
checkedGroups: groups.length,
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
groups,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { recordProviderActivity } from "../infra/provider-activity.js";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||
import { resolveTelegramFetch } from "./fetch.js";
|
||||
@@ -300,6 +301,11 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
storeAllowFrom: string[],
|
||||
) => {
|
||||
const msg = primaryCtx.message;
|
||||
recordProviderActivity({
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
const chatId = msg.chat.id;
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
const messageThreadId = (msg as { message_thread_id?: number })
|
||||
|
||||
@@ -5,6 +5,7 @@ import { loadConfig } from "../config/config.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import type { RetryConfig } from "../infra/retry.js";
|
||||
import { createTelegramRetryRunner } from "../infra/retry-policy.js";
|
||||
import { recordProviderActivity } from "../infra/provider-activity.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { isGifMedia } from "../media/mime.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
@@ -227,6 +228,11 @@ export async function sendMessageTelegram(
|
||||
});
|
||||
}
|
||||
const messageId = String(result?.message_id ?? "unknown");
|
||||
recordProviderActivity({
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return { messageId, chatId: String(result?.chat?.id ?? chatId) };
|
||||
}
|
||||
|
||||
@@ -263,6 +269,11 @@ export async function sendMessageTelegram(
|
||||
throw wrapChatNotFound(err);
|
||||
});
|
||||
const messageId = String(res?.message_id ?? "unknown");
|
||||
recordProviderActivity({
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return { messageId, chatId: String(res?.chat?.id ?? chatId) };
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import { recordProviderActivity } from "../infra/provider-activity.js";
|
||||
import { createSubsystemLogger, getChildLogger } from "../logging.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
import {
|
||||
@@ -171,6 +172,11 @@ export async function monitorWebInbox(options: {
|
||||
}) => {
|
||||
if (upsert.type !== "notify" && upsert.type !== "append") return;
|
||||
for (const msg of upsert.messages ?? []) {
|
||||
recordProviderActivity({
|
||||
provider: "whatsapp",
|
||||
accountId: options.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
const id = msg.key?.id ?? undefined;
|
||||
// De-dupe on message id; Baileys can emit retries.
|
||||
if (id && seen.has(id)) continue;
|
||||
@@ -573,6 +579,11 @@ export async function monitorWebInbox(options: {
|
||||
payload = { text };
|
||||
}
|
||||
const result = await sock.sendMessage(jid, payload);
|
||||
recordProviderActivity({
|
||||
provider: "whatsapp",
|
||||
accountId: options.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return { messageId: result?.key?.id ?? "unknown" };
|
||||
},
|
||||
/**
|
||||
@@ -591,6 +602,11 @@ export async function monitorWebInbox(options: {
|
||||
selectableCount: poll.maxSelections ?? 1,
|
||||
},
|
||||
});
|
||||
recordProviderActivity({
|
||||
provider: "whatsapp",
|
||||
accountId: options.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return { messageId: result?.key?.id ?? "unknown" };
|
||||
},
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user