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

View File

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

View File

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

View File

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

View File

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

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

View 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();
}

View File

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

View 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
View 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,
};
}

View File

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

View File

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

View File

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