feat(providers): improve doctor + status probes
This commit is contained in:
66
src/telegram/audit.test.ts
Normal file
66
src/telegram/audit.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("telegram audit", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("collects unmentioned numeric group ids and flags wildcard", async () => {
|
||||
const { collectTelegramUnmentionedGroupIds } = await import("./audit.js");
|
||||
const res = collectTelegramUnmentionedGroupIds({
|
||||
"*": { requireMention: false },
|
||||
"-1001": { requireMention: false },
|
||||
"@group": { requireMention: false },
|
||||
"-1002": { requireMention: true },
|
||||
"-1003": { requireMention: false, enabled: false },
|
||||
});
|
||||
expect(res.hasWildcardUnmentionedGroups).toBe(true);
|
||||
expect(res.groupIds).toEqual(["-1001"]);
|
||||
expect(res.unresolvedGroups).toBe(1);
|
||||
});
|
||||
|
||||
it("audits membership via getChatMember", async () => {
|
||||
const { auditTelegramGroupMembership } = await import("./audit.js");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ ok: true, result: { status: "member" } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const res = await auditTelegramGroupMembership({
|
||||
token: "t",
|
||||
botId: 123,
|
||||
groupIds: ["-1001"],
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.groups[0]?.chatId).toBe("-1001");
|
||||
expect(res.groups[0]?.status).toBe("member");
|
||||
});
|
||||
|
||||
it("reports bot not in group when status is left", async () => {
|
||||
const { auditTelegramGroupMembership } = await import("./audit.js");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ ok: true, result: { status: "left" } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const res = await auditTelegramGroupMembership({
|
||||
token: "t",
|
||||
botId: 123,
|
||||
groupIds: ["-1001"],
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.groups[0]?.ok).toBe(false);
|
||||
expect(res.groups[0]?.status).toBe("left");
|
||||
});
|
||||
});
|
||||
|
||||
140
src/telegram/audit.ts
Normal file
140
src/telegram/audit.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { TelegramGroupConfig } from "../config/types.js";
|
||||
import { makeProxyFetch } from "./proxy.js";
|
||||
|
||||
const TELEGRAM_API_BASE = "https://api.telegram.org";
|
||||
|
||||
export type TelegramGroupMembershipAuditEntry = {
|
||||
chatId: string;
|
||||
ok: boolean;
|
||||
status?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export type TelegramGroupMembershipAudit = {
|
||||
ok: boolean;
|
||||
checkedGroups: number;
|
||||
unresolvedGroups: number;
|
||||
hasWildcardUnmentionedGroups: boolean;
|
||||
groups: TelegramGroupMembershipAuditEntry[];
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
type TelegramApiOk<T> = { ok: true; result: T };
|
||||
type TelegramApiErr = { ok: false; description?: string };
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
fetcher: typeof fetch,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetcher(url, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function collectTelegramUnmentionedGroupIds(
|
||||
groups: Record<string, TelegramGroupConfig> | undefined,
|
||||
) {
|
||||
if (!groups || typeof groups !== "object") {
|
||||
return {
|
||||
groupIds: [] as string[],
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
};
|
||||
}
|
||||
const hasWildcardUnmentionedGroups =
|
||||
Boolean(groups["*"]?.requireMention === false) &&
|
||||
groups["*"]?.enabled !== false;
|
||||
const groupIds: string[] = [];
|
||||
let unresolvedGroups = 0;
|
||||
for (const [key, value] of Object.entries(groups)) {
|
||||
if (key === "*") continue;
|
||||
if (!value || typeof value !== "object") continue;
|
||||
if ((value as TelegramGroupConfig).enabled === false) continue;
|
||||
if ((value as TelegramGroupConfig).requireMention !== false) continue;
|
||||
const id = String(key).trim();
|
||||
if (!id) continue;
|
||||
if (/^-?\d+$/.test(id)) {
|
||||
groupIds.push(id);
|
||||
} else {
|
||||
unresolvedGroups += 1;
|
||||
}
|
||||
}
|
||||
groupIds.sort((a, b) => a.localeCompare(b));
|
||||
return { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups };
|
||||
}
|
||||
|
||||
export async function auditTelegramGroupMembership(params: {
|
||||
token: string;
|
||||
botId: number;
|
||||
groupIds: string[];
|
||||
proxyUrl?: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<TelegramGroupMembershipAudit> {
|
||||
const started = Date.now();
|
||||
const token = params.token?.trim() ?? "";
|
||||
if (!token || params.groupIds.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
checkedGroups: 0,
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
groups: [],
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
const fetcher = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : fetch;
|
||||
const base = `${TELEGRAM_API_BASE}/bot${token}`;
|
||||
const groups: TelegramGroupMembershipAuditEntry[] = [];
|
||||
|
||||
for (const chatId of params.groupIds) {
|
||||
try {
|
||||
const url = `${base}/getChatMember?chat_id=${encodeURIComponent(chatId)}&user_id=${encodeURIComponent(String(params.botId))}`;
|
||||
const res = await fetchWithTimeout(url, params.timeoutMs, fetcher);
|
||||
const json = (await res.json()) as
|
||||
| TelegramApiOk<{ status?: string }>
|
||||
| TelegramApiErr
|
||||
| unknown;
|
||||
if (!res.ok || !isRecord(json) || json.ok !== true) {
|
||||
const desc =
|
||||
isRecord(json) && json.ok === false && typeof json.description === "string"
|
||||
? json.description
|
||||
: `getChatMember failed (${res.status})`;
|
||||
groups.push({ chatId, ok: false, status: null, error: desc });
|
||||
continue;
|
||||
}
|
||||
const status = isRecord((json as TelegramApiOk<unknown>).result)
|
||||
? (json as TelegramApiOk<{ status?: string }>).result.status ?? null
|
||||
: null;
|
||||
const ok =
|
||||
status === "creator" || status === "administrator" || status === "member";
|
||||
groups.push({ chatId, ok, status, error: ok ? null : "bot not in group" });
|
||||
} catch (err) {
|
||||
groups.push({
|
||||
chatId,
|
||||
ok: false,
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: groups.every((g) => g.ok),
|
||||
checkedGroups: groups.length,
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
groups,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { recordProviderActivity } from "../infra/provider-activity.js";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||
import { resolveTelegramFetch } from "./fetch.js";
|
||||
@@ -300,6 +301,11 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
storeAllowFrom: string[],
|
||||
) => {
|
||||
const msg = primaryCtx.message;
|
||||
recordProviderActivity({
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
const chatId = msg.chat.id;
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
const messageThreadId = (msg as { message_thread_id?: number })
|
||||
|
||||
@@ -5,6 +5,7 @@ import { loadConfig } from "../config/config.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import type { RetryConfig } from "../infra/retry.js";
|
||||
import { createTelegramRetryRunner } from "../infra/retry-policy.js";
|
||||
import { recordProviderActivity } from "../infra/provider-activity.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { isGifMedia } from "../media/mime.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
@@ -227,6 +228,11 @@ export async function sendMessageTelegram(
|
||||
});
|
||||
}
|
||||
const messageId = String(result?.message_id ?? "unknown");
|
||||
recordProviderActivity({
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return { messageId, chatId: String(result?.chat?.id ?? chatId) };
|
||||
}
|
||||
|
||||
@@ -263,6 +269,11 @@ export async function sendMessageTelegram(
|
||||
throw wrapChatNotFound(err);
|
||||
});
|
||||
const messageId = String(res?.message_id ?? "unknown");
|
||||
recordProviderActivity({
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return { messageId, chatId: String(res?.chat?.id ?? chatId) };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user