diff --git a/CHANGELOG.md b/CHANGELOG.md index 7232298af6c..fdb5cd040f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - 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: 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 - Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj diff --git a/docs/cli/index.md b/docs/cli/index.md index b5a261368fb..455e7cf3f37 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -202,7 +202,7 @@ Manage chat provider accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage). Subcommands: - `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`). - `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. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index e6cd507fe53..2c046fc3a06 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -9,6 +9,8 @@ When Clawdbot misbehaves, here's how to fix it. Start with the FAQ’s [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 ### Service Installed but Nothing is Running diff --git a/docs/providers/discord.md b/docs/providers/discord.md index 142ee73bc1c..85d77f1be2f 100644 --- a/docs/providers/discord.md +++ b/docs/providers/discord.md @@ -147,12 +147,14 @@ Notes: 3. If nothing happens: check **Troubleshooting** below. ### 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. - **Bot connects but never replies in a guild channel**: - Missing **Message Content Intent**, or - The bot lacks channel permissions (View/Send/Read History), or - Your config requires mentions and you didn’t mention it, or - 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 can’t verify permissions. - **DMs don’t work**: `discord.dm.enabled=false`, `discord.dm.policy="disabled"`, or you haven’t been approved yet (`discord.dm.policy="pairing"`). ## Capabilities & limits diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md index 5026eaefcfb..403f7ae6ab8 100644 --- a/docs/providers/telegram.md +++ b/docs/providers/telegram.md @@ -232,6 +232,7 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti - If you set `telegram.groups.*.requireMention=false`, Telegram’s Bot API **privacy mode** must be disabled. - 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 --probe` can additionally check membership for explicit numeric group IDs (it can’t audit wildcard `"*"` rules). - Quick test: `/activation always` (session-only; use config for persistence) **Bot not seeing group messages at all:** diff --git a/docs/providers/troubleshooting.md b/docs/providers/troubleshooting.md new file mode 100644 index 00000000000..909948a45e1 --- /dev/null +++ b/docs/providers/troubleshooting.md @@ -0,0 +1,22 @@ +--- +summary: "Provider-specific troubleshooting shortcuts (Discord/Telegram/WhatsApp)" +read_when: + - A provider connects but messages don’t 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) + diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 496619412a5..2ee645e8da3 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -240,15 +240,15 @@ export async function doctorCommand( } } - if (healthOk) { - try { - const status = await callGateway>({ - 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>({ + method: "providers.status", + params: { probe: true, timeoutMs: 5000 }, + timeoutMs: 6000, + }); + const issues = collectProvidersStatusIssues(status); + if (issues.length > 0) { note( issues .map( diff --git a/src/commands/providers.test.ts b/src/commands/providers.test.ts index a2fcf8ac85e..ad1cca796c6 100644 --- a/src/commands/providers.test.ts +++ b/src/commands/providers.test.ts @@ -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: [ diff --git a/src/commands/providers/status.ts b/src/commands/providers/status.ts index 1b4206933c2..a8db9c63583 100644 --- a/src/commands/providers/status.ts +++ b/src/commands/providers/status.ts @@ -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}`); } diff --git a/src/discord/audit.test.ts b/src/discord/audit.test.ts new file mode 100644 index 00000000000..ae0cf91ecd5 --- /dev/null +++ b/src/discord/audit.test.ts @@ -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).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"); + }); +}); + diff --git a/src/discord/audit.ts b/src/discord/audit.ts new file mode 100644 index 00000000000..76883e70e1b --- /dev/null +++ b/src/discord/audit.ts @@ -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 { + 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 | undefined, +): string[] { + if (!guilds) return []; + const ids = new Set(); + 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 { + 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, + }; +} + diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 4a548b7feaf..2a4597a3087 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -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", diff --git a/src/discord/send.ts b/src/discord/send.ts index 063b8093779..d582c9fb108 100644 --- a/src/discord/send.ts +++ b/src/discord/send.ts @@ -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), diff --git a/src/gateway/server-methods/providers.ts b/src/gateway/server-methods/providers.ts index e4b3cb434d3..9bf9c782c91 100644 --- a/src/gateway/server-methods/providers.ts +++ b/src/gateway/server-methods/providers.ts @@ -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 | undefined, + ); + let audit: + | Awaited> + | 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> + | 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, }; }), ); diff --git a/src/infra/provider-activity.test.ts b/src/infra/provider-activity.test.ts new file mode 100644 index 00000000000..5c8e0838721 --- /dev/null +++ b/src/infra/provider-activity.test.ts @@ -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, + }); + }); +}); diff --git a/src/infra/provider-activity.ts b/src/infra/provider-activity.ts new file mode 100644 index 00000000000..46609340ada --- /dev/null +++ b/src/infra/provider-activity.ts @@ -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(); + +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(); +} + diff --git a/src/infra/providers-status-issues.ts b/src/infra/providers-status-issues.ts index 01f923e5668..54904078866 100644 --- a/src/infra/providers-status-issues.ts +++ b/src/infra/providers-status-issues.ts @@ -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, ): 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; } + diff --git a/src/telegram/audit.test.ts b/src/telegram/audit.test.ts new file mode 100644 index 00000000000..1be55fe6728 --- /dev/null +++ b/src/telegram/audit.test.ts @@ -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"); + }); +}); + diff --git a/src/telegram/audit.ts b/src/telegram/audit.ts new file mode 100644 index 00000000000..7224fba8699 --- /dev/null +++ b/src/telegram/audit.ts @@ -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 = { ok: true; result: T }; +type TelegramApiErr = { ok: false; description?: string }; + +async function fetchWithTimeout( + url: string, + timeoutMs: number, + fetcher: typeof fetch, +): Promise { + 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 { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function collectTelegramUnmentionedGroupIds( + groups: Record | 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 { + 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).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, + }; +} + diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 42d601ed70c..91f8f1bc961 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -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 }) diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 4a72747fa69..c63b50d4bd1 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -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) }; } diff --git a/src/web/inbound.ts b/src/web/inbound.ts index dcd97224c91..1bdce198cb3 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -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" }; }, /**