Merge branch 'main' into tobias-sync

This commit is contained in:
Peter Steinberger
2026-01-09 13:42:34 +01:00
436 changed files with 27171 additions and 5489 deletions

View File

@@ -0,0 +1,68 @@
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");
});
});

147
src/telegram/audit.ts Normal file
View File

@@ -0,0 +1,147 @@
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;
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

@@ -284,6 +284,9 @@ describe("createTelegramBot", () => {
expect(replySpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy.mock.calls[0]?.[0]).toBe(1234);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain(
"Your Telegram user id: 999",
);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain(
"Pairing code:",
);

View File

@@ -38,6 +38,7 @@ import {
} from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import { recordProviderActivity } from "../infra/provider-activity.js";
import { getChildLogger } from "../logging.js";
import { mediaKindFromMime } from "../media/constants.js";
import { detectMime, isGifMedia } from "../media/mime.js";
@@ -152,8 +153,12 @@ export function createTelegramBot(opts: TelegramBotOptions) {
},
};
const fetchImpl = resolveTelegramFetch(opts.proxyFetch);
const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun);
const shouldProvideFetch = Boolean(opts.proxyFetch) || isBun;
const client: ApiClientOptions | undefined = fetchImpl
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
? shouldProvideFetch
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
: undefined
: undefined;
const bot = new Bot(opts.token, client ? { client } : undefined);
@@ -225,7 +230,6 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const mediaMaxBytes =
(opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 5) * 1024 * 1024;
const logger = getChildLogger({ module: "telegram-auto-reply" });
const mentionRegexes = buildMentionRegexes(cfg);
let botHasTopicsEnabled: boolean | undefined;
const resolveBotTopicsEnabled = async (ctx?: TelegramContext) => {
const fromCtx = ctx?.me as { has_topics_enabled?: boolean } | undefined;
@@ -301,6 +305,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 })
@@ -322,6 +331,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
id: peerId,
},
});
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const effectiveDmAllow = normalizeAllowFrom([
...(allowFrom ?? []),
...storeAllowFrom,
@@ -384,8 +394,10 @@ export function createTelegramBot(opts: TelegramBotOptions) {
first_name?: string;
last_name?: string;
username?: string;
id?: number;
}
| undefined;
const telegramUserId = from?.id ? String(from.id) : candidate;
const { code, created } = await upsertTelegramPairingRequest({
chatId: candidate,
username: from?.username,
@@ -407,6 +419,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
[
"Clawdbot: access not configured.",
"",
`Your Telegram user id: ${telegramUserId}`,
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
@@ -476,6 +490,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(msg.text ?? msg.caption ?? "");
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
if (isGroup && requireMention && canDetectMention) {
if (!wasMentioned && !shouldBypassMention) {
@@ -582,7 +597,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
ReplyToBody: replyTarget?.body,
ReplyToSender: replyTarget?.sender,
Timestamp: msg.date ? msg.date * 1000 : undefined,
WasMentioned: isGroup ? wasMentioned : undefined,
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
MediaPath: allMedia[0]?.path,
MediaType: allMedia[0]?.contentType,
MediaUrl: allMedia[0]?.path,

View File

@@ -302,6 +302,31 @@ describe("sendMessageTelegram", () => {
});
});
it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => {
const chatId = "-1001234567890";
const sendMessage = vi.fn().mockResolvedValue({
message_id: 55,
chat: { id: chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await sendMessageTelegram(
`telegram:group:${chatId}:topic:271`,
"hello forum",
{
token: "tok",
api,
},
);
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello forum", {
parse_mode: "HTML",
message_thread_id: 271,
});
});
it("includes reply_to_message_id for threaded replies", async () => {
const chatId = "123";
const sendMessage = vi.fn().mockResolvedValue({

View File

@@ -1,7 +1,8 @@
import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types";
import { Bot, InputFile, type ApiClientOptions } from "grammy";
import { type ApiClientOptions, Bot, InputFile } from "grammy";
import { loadConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
import { recordProviderActivity } from "../infra/provider-activity.js";
import type { RetryConfig } from "../infra/retry.js";
import { createTelegramRetryRunner } from "../infra/retry-policy.js";
import { mediaKindFromMime } from "../media/constants.js";
@@ -10,6 +11,10 @@ import { loadWebMedia } from "../web/media.js";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramFetch } from "./fetch.js";
import { markdownToTelegramHtml } from "./format.js";
import {
parseTelegramTarget,
stripTelegramInternalPrefixes,
} from "./targets.js";
type TelegramSendOpts = {
token?: string;
@@ -64,7 +69,7 @@ function normalizeChatId(to: string): string {
// Common internal prefixes that sometimes leak into outbound sends.
// - ctx.To uses `telegram:<id>`
// - group sessions often use `telegram:group:<id>`
let normalized = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
let normalized = stripTelegramInternalPrefixes(trimmed);
// Accept t.me links for public chats/channels.
// (Invite links like `t.me/+...` are not resolvable via Bot API.)
@@ -109,7 +114,8 @@ export async function sendMessageTelegram(
accountId: opts.accountId,
});
const token = resolveToken(opts.token, account);
const chatId = normalizeChatId(to);
const target = parseTelegramTarget(to);
const chatId = normalizeChatId(target.chatId);
// Use provided api or create a new Bot instance. The nullish coalescing
// operator ensures api is always defined (Bot.api is always non-null).
const fetchImpl = resolveTelegramFetch();
@@ -124,8 +130,12 @@ export async function sendMessageTelegram(
// Build optional params for forum topics and reply threading.
// Only include these if actually provided to keep API calls clean.
const threadParams: Record<string, number> = {};
if (opts.messageThreadId != null) {
threadParams.message_thread_id = Math.trunc(opts.messageThreadId);
const messageThreadId =
opts.messageThreadId != null
? opts.messageThreadId
: target.messageThreadId;
if (messageThreadId != null) {
threadParams.message_thread_id = Math.trunc(messageThreadId);
}
if (opts.replyToMessageId != null) {
threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId);
@@ -219,6 +229,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) };
}
@@ -255,6 +270,11 @@ export async function sendMessageTelegram(
throw wrapChatNotFound(err);
});
const messageId = String(res?.message_id ?? "unknown");
recordProviderActivity({
provider: "telegram",
accountId: account.accountId,
direction: "outbound",
});
return { messageId, chatId: String(res?.chat?.id ?? chatId) };
}

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
parseTelegramTarget,
stripTelegramInternalPrefixes,
} from "./targets.js";
describe("stripTelegramInternalPrefixes", () => {
it("strips telegram prefix", () => {
expect(stripTelegramInternalPrefixes("telegram:123")).toBe("123");
});
it("strips telegram+group prefixes", () => {
expect(stripTelegramInternalPrefixes("telegram:group:-100123")).toBe(
"-100123",
);
});
it("is idempotent", () => {
expect(stripTelegramInternalPrefixes("@mychannel")).toBe("@mychannel");
});
});
describe("parseTelegramTarget", () => {
it("parses plain chatId", () => {
expect(parseTelegramTarget("-1001234567890")).toEqual({
chatId: "-1001234567890",
});
});
it("parses @username", () => {
expect(parseTelegramTarget("@mychannel")).toEqual({
chatId: "@mychannel",
});
});
it("parses chatId:topicId format", () => {
expect(parseTelegramTarget("-1001234567890:123")).toEqual({
chatId: "-1001234567890",
messageThreadId: 123,
});
});
it("parses chatId:topic:topicId format", () => {
expect(parseTelegramTarget("-1001234567890:topic:456")).toEqual({
chatId: "-1001234567890",
messageThreadId: 456,
});
});
it("trims whitespace", () => {
expect(parseTelegramTarget(" -1001234567890:99 ")).toEqual({
chatId: "-1001234567890",
messageThreadId: 99,
});
});
it("does not treat non-numeric suffix as topicId", () => {
expect(parseTelegramTarget("-1001234567890:abc")).toEqual({
chatId: "-1001234567890:abc",
});
});
it("strips internal prefixes before parsing", () => {
expect(
parseTelegramTarget("telegram:group:-1001234567890:topic:456"),
).toEqual({
chatId: "-1001234567890",
messageThreadId: 456,
});
});
});

43
src/telegram/targets.ts Normal file
View File

@@ -0,0 +1,43 @@
export type TelegramTarget = {
chatId: string;
messageThreadId?: number;
};
export function stripTelegramInternalPrefixes(to: string): string {
let trimmed = to.trim();
while (true) {
const next = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
if (next === trimmed) return trimmed;
trimmed = next;
}
}
/**
* Parse a Telegram delivery target into chatId and optional topic/thread ID.
*
* Supported formats:
* - `chatId` (plain chat ID, t.me link, @username, or internal prefixes like `telegram:...`)
* - `chatId:topicId` (numeric topic/thread ID)
* - `chatId:topic:topicId` (explicit topic marker; preferred)
*/
export function parseTelegramTarget(to: string): TelegramTarget {
const normalized = stripTelegramInternalPrefixes(to);
const topicMatch = /^(.+?):topic:(\d+)$/.exec(normalized);
if (topicMatch) {
return {
chatId: topicMatch[1],
messageThreadId: Number.parseInt(topicMatch[2], 10),
};
}
const colonMatch = /^(.+):(\d+)$/.exec(normalized);
if (colonMatch) {
return {
chatId: colonMatch[1],
messageThreadId: Number.parseInt(colonMatch[2], 10),
};
}
return { chatId: normalized };
}

View File

@@ -1,4 +1,4 @@
import { Bot, type ApiClientOptions } from "grammy";
import { type ApiClientOptions, Bot } from "grammy";
import { resolveTelegramFetch } from "./fetch.js";
export async function setTelegramWebhook(opts: {