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

@@ -37,6 +37,7 @@
- Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf - 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. - Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings.
- Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints. - Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints.
- Providers/Doctor: add last inbound/outbound activity timestamps in `providers status` and extend `--probe` with Discord channel permission + Telegram group membership audits.
- Apps: refresh iOS/Android/macOS app icons for Clawdbot branding. (#521) — thanks @fishfisher - Apps: refresh iOS/Android/macOS app icons for Clawdbot branding. (#521) — thanks @fishfisher
- Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj - Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj

View File

@@ -202,7 +202,7 @@ Manage chat provider accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage).
Subcommands: Subcommands:
- `providers list`: show configured chat providers and auth profiles (Claude Code + Codex CLI OAuth sync included). - `providers list`: show configured chat providers and auth profiles (Claude Code + Codex CLI OAuth sync included).
- `providers status`: check gateway reachability and provider health (`--probe` to verify credentials; use `status --deep` for local-only probes). - `providers status`: check gateway reachability and provider health (`--probe` to verify credentials and run small provider audits; use `status --deep` for local-only probes).
- Tip: `providers status` prints warnings with suggested fixes when it can detect common misconfigurations (then points you to `clawdbot doctor`). - Tip: `providers status` prints warnings with suggested fixes when it can detect common misconfigurations (then points you to `clawdbot doctor`).
- `providers add`: wizard-style setup when no flags are passed; flags switch to non-interactive mode. - `providers add`: wizard-style setup when no flags are passed; flags switch to non-interactive mode.
- `providers remove`: disable by default; pass `--delete` to remove config entries without prompts. - `providers remove`: disable by default; pass `--delete` to remove config entries without prompts.

View File

@@ -9,6 +9,8 @@ When Clawdbot misbehaves, here's how to fix it.
Start with the FAQs [First 60 seconds](/start/faq#first-60-seconds-if-somethings-broken) if you just want a quick triage recipe. This page goes deeper on runtime failures and diagnostics. Start with the FAQs [First 60 seconds](/start/faq#first-60-seconds-if-somethings-broken) if you just want a quick triage recipe. This page goes deeper on runtime failures and diagnostics.
Provider-specific shortcuts: [/providers/troubleshooting](/providers/troubleshooting)
## Common Issues ## Common Issues
### Service Installed but Nothing is Running ### Service Installed but Nothing is Running

View File

@@ -147,12 +147,14 @@ Notes:
3. If nothing happens: check **Troubleshooting** below. 3. If nothing happens: check **Troubleshooting** below.
### Troubleshooting ### Troubleshooting
- First: run `clawdbot doctor` and `clawdbot providers status --probe` (actionable warnings + quick audits).
- **“Used disallowed intents”**: enable **Message Content Intent** (and likely **Server Members Intent**) in the Developer Portal, then restart the gateway. - **“Used disallowed intents”**: enable **Message Content Intent** (and likely **Server Members Intent**) in the Developer Portal, then restart the gateway.
- **Bot connects but never replies in a guild channel**: - **Bot connects but never replies in a guild channel**:
- Missing **Message Content Intent**, or - Missing **Message Content Intent**, or
- The bot lacks channel permissions (View/Send/Read History), or - The bot lacks channel permissions (View/Send/Read History), or
- Your config requires mentions and you didnt mention it, or - Your config requires mentions and you didnt mention it, or
- Your guild/channel allowlist denies the channel/user. - Your guild/channel allowlist denies the channel/user.
- **Permission audits** (`providers status --probe`) only check numeric channel IDs. If you use slugs/names as `discord.guilds.*.channels` keys, the audit cant verify permissions.
- **DMs dont work**: `discord.dm.enabled=false`, `discord.dm.policy="disabled"`, or you havent been approved yet (`discord.dm.policy="pairing"`). - **DMs dont work**: `discord.dm.enabled=false`, `discord.dm.policy="disabled"`, or you havent been approved yet (`discord.dm.policy="pairing"`).
## Capabilities & limits ## Capabilities & limits

View File

@@ -232,6 +232,7 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
- If you set `telegram.groups.*.requireMention=false`, Telegrams Bot API **privacy mode** must be disabled. - If you set `telegram.groups.*.requireMention=false`, Telegrams Bot API **privacy mode** must be disabled.
- BotFather: `/setprivacy`**Disable** (then remove + re-add the bot to the group) - BotFather: `/setprivacy`**Disable** (then remove + re-add the bot to the group)
- `clawdbot providers status` shows a warning when config expects unmentioned group messages. - `clawdbot providers status` shows a warning when config expects unmentioned group messages.
- `clawdbot providers status --probe` can additionally check membership for explicit numeric group IDs (it cant audit wildcard `"*"` rules).
- Quick test: `/activation always` (session-only; use config for persistence) - Quick test: `/activation always` (session-only; use config for persistence)
**Bot not seeing group messages at all:** **Bot not seeing group messages at all:**

View File

@@ -0,0 +1,22 @@
---
summary: "Provider-specific troubleshooting shortcuts (Discord/Telegram/WhatsApp)"
read_when:
- A provider connects but messages dont flow
- Investigating provider misconfiguration (intents, permissions, privacy mode)
---
# Provider troubleshooting
Start with:
```bash
clawdbot doctor
clawdbot providers status --probe
```
`providers status --probe` prints warnings when it can detect common provider misconfigurations, and includes small live checks (credentials, some permissions/membership).
## Providers
- Discord: [/providers/discord#troubleshooting](/providers/discord#troubleshooting)
- Telegram: [/providers/telegram#troubleshooting](/providers/telegram#troubleshooting)
- WhatsApp: [/providers/whatsapp#troubleshooting-quick](/providers/whatsapp#troubleshooting-quick)

View File

@@ -240,15 +240,15 @@ export async function doctorCommand(
} }
} }
if (healthOk) { if (healthOk) {
try { try {
const status = await callGateway<Record<string, unknown>>({ const status = await callGateway<Record<string, unknown>>({
method: "providers.status", method: "providers.status",
params: { probe: false, timeoutMs: 5000 }, params: { probe: true, timeoutMs: 5000 },
timeoutMs: 6000, timeoutMs: 6000,
}); });
const issues = collectProvidersStatusIssues(status); const issues = collectProvidersStatusIssues(status);
if (issues.length > 0) { if (issues.length > 0) {
note( note(
issues issues
.map( .map(

View File

@@ -340,6 +340,31 @@ describe("providers command", () => {
expect(lines.join("\n")).toMatch(/Run: clawdbot doctor/); 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", () => { it("surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled", () => {
const lines = formatGatewayProvidersStatusLines({ const lines = formatGatewayProvidersStatusLines({
telegramAccounts: [ telegramAccounts: [
@@ -355,6 +380,28 @@ describe("providers command", () => {
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i); 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", () => { it("surfaces WhatsApp auth/runtime hints when unlinked or disconnected", () => {
const unlinked = formatGatewayProvidersStatusLines({ const unlinked = formatGatewayProvidersStatusLines({
whatsappAccounts: [ whatsappAccounts: [

View File

@@ -78,6 +78,16 @@ export function formatGatewayProvidersStatusLines(
if (typeof account.connected === "boolean") { if (typeof account.connected === "boolean") {
bits.push(account.connected ? "connected" : "disconnected"); 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) { if (typeof account.mode === "string" && account.mode.length > 0) {
bits.push(`mode:${account.mode}`); bits.push(`mode:${account.mode}`);
} }
@@ -123,6 +133,10 @@ export function formatGatewayProvidersStatusLines(
if (probe && typeof probe.ok === "boolean") { if (probe && typeof probe.ok === "boolean") {
bits.push(probe.ok ? "works" : "probe failed"); 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) { if (typeof account.lastError === "string" && account.lastError) {
bits.push(`error:${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 { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { formatDurationSeconds } from "../infra/format-duration.js"; import { formatDurationSeconds } from "../infra/format-duration.js";
import { enqueueSystemEvent } from "../infra/system-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js";
import { recordProviderActivity } from "../infra/provider-activity.js";
import { getChildLogger } from "../logging.js"; import { getChildLogger } from "../logging.js";
import { detectMime } from "../media/mime.js"; import { detectMime } from "../media/mime.js";
import { saveMediaBuffer } from "../media/store.js"; import { saveMediaBuffer } from "../media/store.js";
@@ -575,6 +576,11 @@ export function createDiscordMessageHandler(params: {
} }
const botId = botUserId; const botId = botUserId;
const baseText = resolveDiscordMessageText(message); const baseText = resolveDiscordMessageText(message);
recordProviderActivity({
provider: "discord",
accountId,
direction: "inbound",
});
const route = resolveAgentRoute({ const route = resolveAgentRoute({
cfg, cfg,
provider: "discord", provider: "discord",

View File

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

View File

@@ -1,4 +1,5 @@
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import type { TelegramGroupConfig } from "../../config/types.js";
import { import {
loadConfig, loadConfig,
readConfigFileSnapshot, readConfigFileSnapshot,
@@ -51,6 +52,15 @@ import {
} from "../protocol/index.js"; } from "../protocol/index.js";
import { formatForLog } from "../ws-log.js"; import { formatForLog } from "../ws-log.js";
import type { GatewayRequestHandlers } from "./types.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 = { export const providersHandlers: GatewayRequestHandlers = {
"providers.status": async ({ params, respond, context }) => { "providers.status": async ({ params, respond, context }) => {
@@ -89,6 +99,16 @@ export const providersHandlers: GatewayRequestHandlers = {
const configured = Boolean(account.token); const configured = Boolean(account.token);
let telegramProbe: TelegramProbe | undefined; let telegramProbe: TelegramProbe | undefined;
let lastProbeAt: number | null = null; 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) { if (probe && configured && account.enabled) {
telegramProbe = await probeTelegram( telegramProbe = await probeTelegram(
account.token, account.token,
@@ -96,10 +116,34 @@ export const providersHandlers: GatewayRequestHandlers = {
account.config.proxy, account.config.proxy,
); );
lastProbeAt = Date.now(); 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 = const allowUnmentionedGroups =
Boolean( Boolean(
groups?.["*"] && groups?.["*"] &&
@@ -126,7 +170,16 @@ export const providersHandlers: GatewayRequestHandlers = {
lastError: rt?.lastError ?? null, lastError: rt?.lastError ?? null,
probe: telegramProbe, probe: telegramProbe,
lastProbeAt, lastProbeAt,
audit,
allowUnmentionedGroups, 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); const configured = Boolean(account.token);
let discordProbe: DiscordProbe | undefined; let discordProbe: DiscordProbe | undefined;
let lastProbeAt: number | null = null; 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) { if (probe && configured && account.enabled) {
discordProbe = await probeDiscord(account.token, timeoutMs, { discordProbe = await probeDiscord(account.token, timeoutMs, {
includeApplication: true, includeApplication: true,
}); });
lastProbeAt = Date.now(); 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 { return {
accountId: account.accountId, accountId: account.accountId,
@@ -166,6 +233,15 @@ export const providersHandlers: GatewayRequestHandlers = {
lastError: rt?.lastError ?? null, lastError: rt?.lastError ?? null,
probe: discordProbe, probe: discordProbe,
lastProbeAt, 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, lastMessageAt: rt.lastMessageAt ?? null,
lastEventAt: rt.lastEventAt ?? null, lastEventAt: rt.lastEventAt ?? null,
lastError: rt.lastError ?? 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; enabled?: unknown;
configured?: unknown; configured?: unknown;
application?: unknown; application?: unknown;
audit?: unknown;
}; };
type TelegramAccountStatus = { type TelegramAccountStatus = {
@@ -26,6 +27,7 @@ type TelegramAccountStatus = {
enabled?: unknown; enabled?: unknown;
configured?: unknown; configured?: unknown;
allowUnmentionedGroups?: unknown; allowUnmentionedGroups?: unknown;
audit?: unknown;
}; };
type WhatsAppAccountStatus = { type WhatsAppAccountStatus = {
@@ -55,6 +57,7 @@ function readDiscordAccountStatus(value: unknown): DiscordAccountStatus | null {
enabled: value.enabled, enabled: value.enabled,
configured: value.configured, configured: value.configured,
application: value.application, 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( function readTelegramAccountStatus(
value: unknown, value: unknown,
): TelegramAccountStatus | null { ): TelegramAccountStatus | null {
@@ -85,9 +131,51 @@ function readTelegramAccountStatus(
enabled: value.enabled, enabled: value.enabled,
configured: value.configured, configured: value.configured,
allowUnmentionedGroups: value.allowUnmentionedGroups, 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( function readWhatsAppAccountStatus(
value: unknown, value: unknown,
): WhatsAppAccountStatus | null { ): WhatsAppAccountStatus | null {
@@ -107,6 +195,7 @@ export function collectProvidersStatusIssues(
payload: Record<string, unknown>, payload: Record<string, unknown>,
): ProviderStatusIssue[] { ): ProviderStatusIssue[] {
const issues: ProviderStatusIssue[] = []; const issues: ProviderStatusIssue[] = [];
const discordAccountsRaw = payload.discordAccounts; const discordAccountsRaw = payload.discordAccounts;
if (Array.isArray(discordAccountsRaw)) { if (Array.isArray(discordAccountsRaw)) {
for (const entry of 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.", 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 enabled = account.enabled !== false;
const configured = account.configured === true; const configured = account.configured === true;
if (!enabled || !configured) continue; if (!enabled || !configured) continue;
if (account.allowUnmentionedGroups === true) { if (account.allowUnmentionedGroups === true) {
issues.push({ issues.push({
provider: "telegram", provider: "telegram",
@@ -150,6 +265,39 @@ export function collectProvidersStatusIssues(
fix: "In BotFather run /setprivacy → Disable for this bot (then restart the gateway).", 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; 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 { resolveAgentRoute } from "../routing/resolve-route.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { loadWebMedia } from "../web/media.js"; import { loadWebMedia } from "../web/media.js";
import { recordProviderActivity } from "../infra/provider-activity.js";
import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAccount } from "./accounts.js";
import { createTelegramDraftStream } from "./draft-stream.js"; import { createTelegramDraftStream } from "./draft-stream.js";
import { resolveTelegramFetch } from "./fetch.js"; import { resolveTelegramFetch } from "./fetch.js";
@@ -300,6 +301,11 @@ export function createTelegramBot(opts: TelegramBotOptions) {
storeAllowFrom: string[], storeAllowFrom: string[],
) => { ) => {
const msg = primaryCtx.message; const msg = primaryCtx.message;
recordProviderActivity({
provider: "telegram",
accountId: account.accountId,
direction: "inbound",
});
const chatId = msg.chat.id; const chatId = msg.chat.id;
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number }) 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 { formatErrorMessage } from "../infra/errors.js";
import type { RetryConfig } from "../infra/retry.js"; import type { RetryConfig } from "../infra/retry.js";
import { createTelegramRetryRunner } from "../infra/retry-policy.js"; import { createTelegramRetryRunner } from "../infra/retry-policy.js";
import { recordProviderActivity } from "../infra/provider-activity.js";
import { mediaKindFromMime } from "../media/constants.js"; import { mediaKindFromMime } from "../media/constants.js";
import { isGifMedia } from "../media/mime.js"; import { isGifMedia } from "../media/mime.js";
import { loadWebMedia } from "../web/media.js"; import { loadWebMedia } from "../web/media.js";
@@ -227,6 +228,11 @@ export async function sendMessageTelegram(
}); });
} }
const messageId = String(result?.message_id ?? "unknown"); const messageId = String(result?.message_id ?? "unknown");
recordProviderActivity({
provider: "telegram",
accountId: account.accountId,
direction: "outbound",
});
return { messageId, chatId: String(result?.chat?.id ?? chatId) }; return { messageId, chatId: String(result?.chat?.id ?? chatId) };
} }
@@ -263,6 +269,11 @@ export async function sendMessageTelegram(
throw wrapChatNotFound(err); throw wrapChatNotFound(err);
}); });
const messageId = String(res?.message_id ?? "unknown"); const messageId = String(res?.message_id ?? "unknown");
recordProviderActivity({
provider: "telegram",
accountId: account.accountId,
direction: "outbound",
});
return { messageId, chatId: String(res?.chat?.id ?? chatId) }; return { messageId, chatId: String(res?.chat?.id ?? chatId) };
} }

View File

@@ -14,6 +14,7 @@ import {
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { logVerbose, shouldLogVerbose } from "../globals.js"; import { logVerbose, shouldLogVerbose } from "../globals.js";
import { recordProviderActivity } from "../infra/provider-activity.js";
import { createSubsystemLogger, getChildLogger } from "../logging.js"; import { createSubsystemLogger, getChildLogger } from "../logging.js";
import { saveMediaBuffer } from "../media/store.js"; import { saveMediaBuffer } from "../media/store.js";
import { import {
@@ -171,6 +172,11 @@ export async function monitorWebInbox(options: {
}) => { }) => {
if (upsert.type !== "notify" && upsert.type !== "append") return; if (upsert.type !== "notify" && upsert.type !== "append") return;
for (const msg of upsert.messages ?? []) { for (const msg of upsert.messages ?? []) {
recordProviderActivity({
provider: "whatsapp",
accountId: options.accountId,
direction: "inbound",
});
const id = msg.key?.id ?? undefined; const id = msg.key?.id ?? undefined;
// De-dupe on message id; Baileys can emit retries. // De-dupe on message id; Baileys can emit retries.
if (id && seen.has(id)) continue; if (id && seen.has(id)) continue;
@@ -573,6 +579,11 @@ export async function monitorWebInbox(options: {
payload = { text }; payload = { text };
} }
const result = await sock.sendMessage(jid, payload); const result = await sock.sendMessage(jid, payload);
recordProviderActivity({
provider: "whatsapp",
accountId: options.accountId,
direction: "outbound",
});
return { messageId: result?.key?.id ?? "unknown" }; return { messageId: result?.key?.id ?? "unknown" };
}, },
/** /**
@@ -591,6 +602,11 @@ export async function monitorWebInbox(options: {
selectableCount: poll.maxSelections ?? 1, selectableCount: poll.maxSelections ?? 1,
}, },
}); });
recordProviderActivity({
provider: "whatsapp",
accountId: options.accountId,
direction: "outbound",
});
return { messageId: result?.key?.id ?? "unknown" }; return { messageId: result?.key?.id ?? "unknown" };
}, },
/** /**