@gguf/claw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,460 lines (1,441 loc) • 71.4 kB
JavaScript
import { t as __exportAll } from "./rolldown-runtime-Cbj13DAv.js";
import { a as resolveOAuthDir, s as resolveStateDir, u as resolveRequiredHomeDir } from "./paths-Bp5uKvNR.js";
import { F as safeParseJson, K as logVerbose, U as danger } from "./registry-dD2_jBuv.js";
import { t as createSubsystemLogger } from "./subsystem-CGx2ESmP.js";
import { K as withFileLock$1 } from "./model-selection-Xwi9H_jm.js";
import { t as isTruthyEnvValue } from "./env-MtDjQbRJ.js";
import { o as resolveTelegramAccount } from "./normalize-BHlVpmSR.js";
import { n as listChannelPlugins, t as getChannelPlugin } from "./plugins-C2vFjSxX.js";
import { g as isGifMedia, p as getFileExtension, v as normalizeMimeType, x as mediaKindFromMime } from "./fs-safe-GrTh3Ydq.js";
import { n as loadConfig } from "./config-42hNXHap.js";
import { t as redactSensitiveText } from "./redact-DAKoy-tb.js";
import { n as formatErrorMessage, r as formatUncaughtError, t as extractErrorCode } from "./errors-BxR4oB-s.js";
import { n as resolveMarkdownTableMode } from "./markdown-tables-CNKgA0te.js";
import { a as loadWebMedia, n as markdownToIR, t as chunkMarkdownIR } from "./ir-CEvNPW62.js";
import { t as renderMarkdownWithMarkers } from "./render-B1VqYyvo.js";
import { t as resolveFetch } from "./fetch-BMa0enEg.js";
import { n as normalizePollInput } from "./polls-BloyMqd2.js";
import { i as createTelegramRetryRunner, n as recordChannelActivity } from "./channel-activity-BQwrvw91.js";
import { t as makeProxyFetch } from "./proxy-BaSHbVlA.js";
import path from "node:path";
import fs from "node:fs";
import os from "node:os";
import crypto from "node:crypto";
import process$1 from "node:process";
import * as net$1 from "node:net";
import { Bot, HttpError, InputFile } from "grammy";
//#region src/telegram/targets.ts
function stripTelegramInternalPrefixes(to) {
let trimmed = to.trim();
let strippedTelegramPrefix = false;
while (true) {
const next = (() => {
if (/^(telegram|tg):/i.test(trimmed)) {
strippedTelegramPrefix = true;
return trimmed.replace(/^(telegram|tg):/i, "").trim();
}
if (strippedTelegramPrefix && /^group:/i.test(trimmed)) return trimmed.replace(/^group:/i, "").trim();
return trimmed;
})();
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)
*/
function resolveTelegramChatType(chatId) {
const trimmed = chatId.trim();
if (!trimmed) return "unknown";
if (/^-?\d+$/.test(trimmed)) return trimmed.startsWith("-") ? "group" : "direct";
return "unknown";
}
function parseTelegramTarget(to) {
const normalized = stripTelegramInternalPrefixes(to);
const topicMatch = /^(.+?):topic:(\d+)$/.exec(normalized);
if (topicMatch) return {
chatId: topicMatch[1],
messageThreadId: Number.parseInt(topicMatch[2], 10),
chatType: resolveTelegramChatType(topicMatch[1])
};
const colonMatch = /^(.+):(\d+)$/.exec(normalized);
if (colonMatch) return {
chatId: colonMatch[1],
messageThreadId: Number.parseInt(colonMatch[2], 10),
chatType: resolveTelegramChatType(colonMatch[1])
};
return {
chatId: normalized,
chatType: resolveTelegramChatType(normalized)
};
}
function resolveTelegramTargetChatType(target) {
return parseTelegramTarget(target).chatType;
}
//#endregion
//#region src/media/audio.ts
const TELEGRAM_VOICE_AUDIO_EXTENSIONS = new Set([
".oga",
".ogg",
".opus",
".mp3",
".m4a"
]);
/**
* MIME types compatible with voice messages.
* Telegram sendVoice supports OGG/Opus, MP3, and M4A.
* https://core.telegram.org/bots/api#sendvoice
*/
const TELEGRAM_VOICE_MIME_TYPES = new Set([
"audio/ogg",
"audio/opus",
"audio/mpeg",
"audio/mp3",
"audio/mp4",
"audio/x-m4a",
"audio/m4a"
]);
function isTelegramVoiceCompatibleAudio(opts) {
const mime = normalizeMimeType(opts.contentType);
if (mime && TELEGRAM_VOICE_MIME_TYPES.has(mime)) return true;
const fileName = opts.fileName?.trim();
if (!fileName) return false;
const ext = getFileExtension(fileName);
if (!ext) return false;
return TELEGRAM_VOICE_AUDIO_EXTENSIONS.has(ext);
}
/**
* Backward-compatible alias used across plugin/runtime call sites.
* Keeps existing behavior while making Telegram-specific policy explicit.
*/
function isVoiceCompatibleAudio(opts) {
return isTelegramVoiceCompatibleAudio(opts);
}
//#endregion
//#region src/channels/plugins/pairing.ts
function listPairingChannels() {
return listChannelPlugins().filter((plugin) => plugin.pairing).map((plugin) => plugin.id);
}
function getPairingAdapter(channelId) {
return getChannelPlugin(channelId)?.pairing ?? null;
}
//#endregion
//#region src/plugin-sdk/json-store.ts
async function readJsonFileWithFallback(filePath, fallback) {
try {
const parsed = safeParseJson(await fs.promises.readFile(filePath, "utf-8"));
if (parsed == null) return {
value: fallback,
exists: true
};
return {
value: parsed,
exists: true
};
} catch (err) {
if (err.code === "ENOENT") return {
value: fallback,
exists: false
};
return {
value: fallback,
exists: false
};
}
}
async function writeJsonFileAtomically(filePath, value) {
const dir = path.dirname(filePath);
await fs.promises.mkdir(dir, {
recursive: true,
mode: 448
});
const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`);
await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf-8" });
await fs.promises.chmod(tmp, 384);
await fs.promises.rename(tmp, filePath);
}
//#endregion
//#region src/pairing/pairing-store.ts
const PAIRING_CODE_LENGTH = 8;
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const PAIRING_PENDING_TTL_MS = 3600 * 1e3;
const PAIRING_PENDING_MAX = 3;
const PAIRING_STORE_LOCK_OPTIONS = {
retries: {
retries: 10,
factor: 2,
minTimeout: 100,
maxTimeout: 1e4,
randomize: true
},
stale: 3e4
};
function resolveCredentialsDir(env = process.env) {
return resolveOAuthDir(env, resolveStateDir(env, () => resolveRequiredHomeDir(env, os.homedir)));
}
/** Sanitize channel ID for use in filenames (prevent path traversal). */
function safeChannelKey(channel) {
const raw = String(channel).trim().toLowerCase();
if (!raw) throw new Error("invalid pairing channel");
const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_");
if (!safe || safe === "_") throw new Error("invalid pairing channel");
return safe;
}
function resolvePairingPath(channel, env = process.env) {
return path.join(resolveCredentialsDir(env), `${safeChannelKey(channel)}-pairing.json`);
}
function safeAccountKey(accountId) {
const raw = String(accountId).trim().toLowerCase();
if (!raw) throw new Error("invalid pairing account id");
const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_");
if (!safe || safe === "_") throw new Error("invalid pairing account id");
return safe;
}
function resolveAllowFromPath(channel, env = process.env, accountId) {
const base = safeChannelKey(channel);
const normalizedAccountId = typeof accountId === "string" ? accountId.trim() : "";
if (!normalizedAccountId) return path.join(resolveCredentialsDir(env), `${base}-allowFrom.json`);
return path.join(resolveCredentialsDir(env), `${base}-${safeAccountKey(normalizedAccountId)}-allowFrom.json`);
}
async function readJsonFile(filePath, fallback) {
return await readJsonFileWithFallback(filePath, fallback);
}
async function writeJsonFile(filePath, value) {
await writeJsonFileAtomically(filePath, value);
}
async function readPairingRequests(filePath) {
const { value } = await readJsonFile(filePath, {
version: 1,
requests: []
});
return Array.isArray(value.requests) ? value.requests : [];
}
async function ensureJsonFile(filePath, fallback) {
try {
await fs.promises.access(filePath);
} catch {
await writeJsonFile(filePath, fallback);
}
}
async function withFileLock(filePath, fallback, fn) {
await ensureJsonFile(filePath, fallback);
return await withFileLock$1(filePath, PAIRING_STORE_LOCK_OPTIONS, async () => {
return await fn();
});
}
function parseTimestamp(value) {
if (!value) return null;
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) return null;
return parsed;
}
function isExpired(entry, nowMs) {
const createdAt = parseTimestamp(entry.createdAt);
if (!createdAt) return true;
return nowMs - createdAt > PAIRING_PENDING_TTL_MS;
}
function pruneExpiredRequests(reqs, nowMs) {
const kept = [];
let removed = false;
for (const req of reqs) {
if (isExpired(req, nowMs)) {
removed = true;
continue;
}
kept.push(req);
}
return {
requests: kept,
removed
};
}
function resolveLastSeenAt(entry) {
return parseTimestamp(entry.lastSeenAt) ?? parseTimestamp(entry.createdAt) ?? 0;
}
function pruneExcessRequests(reqs, maxPending) {
if (maxPending <= 0 || reqs.length <= maxPending) return {
requests: reqs,
removed: false
};
return {
requests: reqs.slice().toSorted((a, b) => resolveLastSeenAt(a) - resolveLastSeenAt(b)).slice(-maxPending),
removed: true
};
}
function randomCode() {
let out = "";
for (let i = 0; i < PAIRING_CODE_LENGTH; i++) {
const idx = crypto.randomInt(0, 32);
out += PAIRING_CODE_ALPHABET[idx];
}
return out;
}
function generateUniqueCode(existing) {
for (let attempt = 0; attempt < 500; attempt += 1) {
const code = randomCode();
if (!existing.has(code)) return code;
}
throw new Error("failed to generate unique pairing code");
}
function normalizeId(value) {
return String(value).trim();
}
function normalizeAllowEntry(channel, entry) {
const trimmed = entry.trim();
if (!trimmed) return "";
if (trimmed === "*") return "";
const adapter = getPairingAdapter(channel);
const normalized = adapter?.normalizeAllowEntry ? adapter.normalizeAllowEntry(trimmed) : trimmed;
return String(normalized).trim();
}
function normalizeAllowFromList(channel, store) {
return (Array.isArray(store.allowFrom) ? store.allowFrom : []).map((v) => normalizeAllowEntry(channel, String(v))).filter(Boolean);
}
function normalizeAllowFromInput(channel, entry) {
return normalizeAllowEntry(channel, normalizeId(entry));
}
function dedupePreserveOrder(entries) {
const seen = /* @__PURE__ */ new Set();
const out = [];
for (const entry of entries) {
const normalized = String(entry).trim();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
out.push(normalized);
}
return out;
}
async function readAllowFromStateForPath(channel, filePath) {
const { value } = await readJsonFile(filePath, {
version: 1,
allowFrom: []
});
return normalizeAllowFromList(channel, value);
}
async function readAllowFromState(params) {
const { value } = await readJsonFile(params.filePath, {
version: 1,
allowFrom: []
});
return {
current: normalizeAllowFromList(params.channel, value),
normalized: normalizeAllowFromInput(params.channel, params.entry) || null
};
}
async function writeAllowFromState(filePath, allowFrom) {
await writeJsonFile(filePath, {
version: 1,
allowFrom
});
}
async function updateAllowFromStoreEntry(params) {
const env = params.env ?? process.env;
const filePath = resolveAllowFromPath(params.channel, env, params.accountId);
return await withFileLock(filePath, {
version: 1,
allowFrom: []
}, async () => {
const { current, normalized } = await readAllowFromState({
channel: params.channel,
entry: params.entry,
filePath
});
if (!normalized) return {
changed: false,
allowFrom: current
};
const next = params.apply(current, normalized);
if (!next) return {
changed: false,
allowFrom: current
};
await writeAllowFromState(filePath, next);
return {
changed: true,
allowFrom: next
};
});
}
async function readChannelAllowFromStore(channel, env = process.env, accountId) {
if (!(accountId?.trim().toLowerCase() ?? "")) return await readAllowFromStateForPath(channel, resolveAllowFromPath(channel, env));
const scopedEntries = await readAllowFromStateForPath(channel, resolveAllowFromPath(channel, env, accountId));
const legacyEntries = await readAllowFromStateForPath(channel, resolveAllowFromPath(channel, env));
return dedupePreserveOrder([...scopedEntries, ...legacyEntries]);
}
async function updateChannelAllowFromStore(params) {
return await updateAllowFromStoreEntry({
channel: params.channel,
entry: params.entry,
accountId: params.accountId,
env: params.env,
apply: params.apply
});
}
async function addChannelAllowFromStoreEntry(params) {
return await updateChannelAllowFromStore({
...params,
apply: (current, normalized) => {
if (current.includes(normalized)) return null;
return [...current, normalized];
}
});
}
async function removeChannelAllowFromStoreEntry(params) {
return await updateChannelAllowFromStore({
...params,
apply: (current, normalized) => {
const next = current.filter((entry) => entry !== normalized);
if (next.length === current.length) return null;
return next;
}
});
}
async function upsertChannelPairingRequest(params) {
const env = params.env ?? process.env;
const filePath = resolvePairingPath(params.channel, env);
return await withFileLock(filePath, {
version: 1,
requests: []
}, async () => {
const now = (/* @__PURE__ */ new Date()).toISOString();
const nowMs = Date.now();
const id = normalizeId(params.id);
const normalizedAccountId = params.accountId?.trim();
const baseMeta = params.meta && typeof params.meta === "object" ? Object.fromEntries(Object.entries(params.meta).map(([k, v]) => [k, String(v ?? "").trim()]).filter(([_, v]) => Boolean(v))) : void 0;
const meta = normalizedAccountId ? {
...baseMeta,
accountId: normalizedAccountId
} : baseMeta;
let reqs = await readPairingRequests(filePath);
const { requests: prunedExpired, removed: expiredRemoved } = pruneExpiredRequests(reqs, nowMs);
reqs = prunedExpired;
const existingIdx = reqs.findIndex((r) => r.id === id);
const existingCodes = new Set(reqs.map((req) => String(req.code ?? "").trim().toUpperCase()));
if (existingIdx >= 0) {
const existing = reqs[existingIdx];
const code = (existing && typeof existing.code === "string" ? existing.code.trim() : "") || generateUniqueCode(existingCodes);
const next = {
id,
code,
createdAt: existing?.createdAt ?? now,
lastSeenAt: now,
meta: meta ?? existing?.meta
};
reqs[existingIdx] = next;
const { requests: capped } = pruneExcessRequests(reqs, PAIRING_PENDING_MAX);
await writeJsonFile(filePath, {
version: 1,
requests: capped
});
return {
code,
created: false
};
}
const { requests: capped, removed: cappedRemoved } = pruneExcessRequests(reqs, PAIRING_PENDING_MAX);
reqs = capped;
if (PAIRING_PENDING_MAX > 0 && reqs.length >= PAIRING_PENDING_MAX) {
if (expiredRemoved || cappedRemoved) await writeJsonFile(filePath, {
version: 1,
requests: reqs
});
return {
code: "",
created: false
};
}
const code = generateUniqueCode(existingCodes);
const next = {
id,
code,
createdAt: now,
lastSeenAt: now,
...meta ? { meta } : {}
};
await writeJsonFile(filePath, {
version: 1,
requests: [...reqs, next]
});
return {
code,
created: true
};
});
}
//#endregion
//#region src/channels/location.ts
function resolveLocation(location) {
const source = location.source ?? (location.isLive ? "live" : location.name || location.address ? "place" : "pin");
const isLive = Boolean(location.isLive ?? source === "live");
return {
...location,
source,
isLive
};
}
function formatAccuracy(accuracy) {
if (!Number.isFinite(accuracy)) return "";
return ` ±${Math.round(accuracy ?? 0)}m`;
}
function formatCoords(latitude, longitude) {
return `${latitude.toFixed(6)}, ${longitude.toFixed(6)}`;
}
function formatLocationText(location) {
const resolved = resolveLocation(location);
const coords = formatCoords(resolved.latitude, resolved.longitude);
const accuracy = formatAccuracy(resolved.accuracy);
const caption = resolved.caption?.trim();
let header = "";
if (resolved.source === "live" || resolved.isLive) header = `🛰 Live location: ${coords}${accuracy}`;
else if (resolved.name || resolved.address) header = `📍 ${[resolved.name, resolved.address].filter(Boolean).join(" — ")} (${coords}${accuracy})`;
else header = `📍 ${coords}${accuracy}`;
return caption ? `${header}\n${caption}` : header;
}
function toLocationContext(location) {
const resolved = resolveLocation(location);
return {
LocationLat: resolved.latitude,
LocationLon: resolved.longitude,
LocationAccuracy: resolved.accuracy,
LocationName: resolved.name,
LocationAddress: resolved.address,
LocationSource: resolved.source,
LocationIsLive: resolved.isLive
};
}
//#endregion
//#region src/channels/allow-from.ts
function mergeAllowFromSources(params) {
return [...params.allowFrom ?? [], ...params.storeAllowFrom ?? []].map((value) => String(value).trim()).filter(Boolean);
}
function firstDefined(...values) {
for (const value of values) if (typeof value !== "undefined") return value;
}
function isSenderIdAllowed(allow, senderId, allowWhenEmpty) {
if (!allow.hasEntries) return allowWhenEmpty;
if (allow.hasWildcard) return true;
if (!senderId) return false;
return allow.entries.includes(senderId);
}
//#endregion
//#region src/telegram/bot-access.ts
const warnedInvalidEntries = /* @__PURE__ */ new Set();
function warnInvalidAllowFromEntries(entries) {
if (process.env.VITEST || false) return;
for (const entry of entries) {
if (warnedInvalidEntries.has(entry)) continue;
warnedInvalidEntries.add(entry);
console.warn([
"[telegram] Invalid allowFrom entry:",
JSON.stringify(entry),
"- allowFrom/groupAllowFrom authorization requires numeric Telegram sender IDs only.",
"If you had \"@username\" entries, re-run onboarding (it resolves @username to IDs) or replace them manually."
].join(" "));
}
}
const normalizeAllowFrom = (list) => {
const entries = (list ?? []).map((value) => String(value).trim()).filter(Boolean);
const hasWildcard = entries.includes("*");
const normalized = entries.filter((value) => value !== "*").map((value) => value.replace(/^(telegram|tg):/i, ""));
const invalidEntries = normalized.filter((value) => !/^\d+$/.test(value));
if (invalidEntries.length > 0) warnInvalidAllowFromEntries([...new Set(invalidEntries)]);
return {
entries: normalized.filter((value) => /^\d+$/.test(value)),
hasWildcard,
hasEntries: entries.length > 0,
invalidEntries
};
};
const normalizeAllowFromWithStore = (params) => normalizeAllowFrom(mergeAllowFromSources(params));
const isSenderAllowed = (params) => {
const { allow, senderId } = params;
return isSenderIdAllowed(allow, senderId, true);
};
const resolveSenderAllowMatch = (params) => {
const { allow, senderId } = params;
if (allow.hasWildcard) return {
allowed: true,
matchKey: "*",
matchSource: "wildcard"
};
if (!allow.hasEntries) return { allowed: false };
if (senderId && allow.entries.includes(senderId)) return {
allowed: true,
matchKey: senderId,
matchSource: "id"
};
return { allowed: false };
};
//#endregion
//#region src/telegram/bot/helpers.ts
const TELEGRAM_GENERAL_TOPIC_ID = 1;
async function resolveTelegramGroupAllowFromContext(params) {
const resolvedThreadId = resolveTelegramForumThreadId({
isForum: params.isForum,
messageThreadId: params.messageThreadId
});
const storeAllowFrom = await readChannelAllowFromStore("telegram", process.env, params.accountId).catch(() => []);
const { groupConfig, topicConfig } = params.resolveTelegramGroupConfig(params.chatId, resolvedThreadId);
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
return {
resolvedThreadId,
storeAllowFrom,
groupConfig,
topicConfig,
groupAllowOverride,
effectiveGroupAllow: normalizeAllowFromWithStore({
allowFrom: groupAllowOverride ?? params.groupAllowFrom,
storeAllowFrom
}),
hasGroupAllowOverride: typeof groupAllowOverride !== "undefined"
};
}
/**
* Resolve the thread ID for Telegram forum topics.
* For non-forum groups, returns undefined even if messageThreadId is present
* (reply threads in regular groups should not create separate sessions).
* For forum groups, returns the topic ID (or General topic ID=1 if unspecified).
*/
function resolveTelegramForumThreadId(params) {
if (!params.isForum) return;
if (params.messageThreadId == null) return TELEGRAM_GENERAL_TOPIC_ID;
return params.messageThreadId;
}
function resolveTelegramThreadSpec(params) {
if (params.isGroup) return {
id: resolveTelegramForumThreadId({
isForum: params.isForum,
messageThreadId: params.messageThreadId
}),
scope: params.isForum ? "forum" : "none"
};
if (params.messageThreadId == null) return { scope: "dm" };
return {
id: params.messageThreadId,
scope: "dm"
};
}
/**
* Build thread params for Telegram API calls (messages, media).
*
* IMPORTANT: Thread IDs behave differently based on chat type:
* - DMs (private chats): Include message_thread_id when present (DM topics)
* - Forum topics: Skip thread_id=1 (General topic), include others
* - Regular groups: Thread IDs are ignored by Telegram
*
* General forum topic (id=1) must be treated like a regular supergroup send:
* Telegram rejects sendMessage/sendMedia with message_thread_id=1 ("thread not found").
*
* @param thread - Thread specification with ID and scope
* @returns API params object or undefined if thread_id should be omitted
*/
function buildTelegramThreadParams(thread) {
if (thread?.id == null) return;
const normalized = Math.trunc(thread.id);
if (thread.scope === "dm") return normalized > 0 ? { message_thread_id: normalized } : void 0;
if (normalized === TELEGRAM_GENERAL_TOPIC_ID) return;
return { message_thread_id: normalized };
}
/**
* Build thread params for typing indicators (sendChatAction).
* Empirically, General topic (id=1) needs message_thread_id for typing to appear.
*/
function buildTypingThreadParams(messageThreadId) {
if (messageThreadId == null) return;
return { message_thread_id: Math.trunc(messageThreadId) };
}
function resolveTelegramStreamMode(telegramCfg) {
const raw = telegramCfg?.streamMode?.trim().toLowerCase();
if (raw === "off" || raw === "partial" || raw === "block") return raw;
return "partial";
}
function buildTelegramGroupPeerId(chatId, messageThreadId) {
return messageThreadId != null ? `${chatId}:topic:${messageThreadId}` : String(chatId);
}
function buildTelegramGroupFrom(chatId, messageThreadId) {
return `telegram:group:${buildTelegramGroupPeerId(chatId, messageThreadId)}`;
}
/**
* Build parentPeer for forum topic binding inheritance.
* When a message comes from a forum topic, the peer ID includes the topic suffix
* (e.g., `-1001234567890:topic:99`). To allow bindings configured for the base
* group ID to match, we provide the parent group as `parentPeer` so the routing
* layer can fall back to it when the exact peer doesn't match.
*/
function buildTelegramParentPeer(params) {
if (!params.isGroup || params.resolvedThreadId == null) return;
return {
kind: "group",
id: String(params.chatId)
};
}
function buildSenderName(msg) {
return [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() || msg.from?.username || void 0;
}
function resolveTelegramMediaPlaceholder(msg) {
if (!msg) return;
if (msg.photo) return "<media:image>";
if (msg.video || msg.video_note) return "<media:video>";
if (msg.audio || msg.voice) return "<media:audio>";
if (msg.document) return "<media:document>";
if (msg.sticker) return "<media:sticker>";
}
function buildSenderLabel(msg, senderId) {
const name = buildSenderName(msg);
const username = msg.from?.username ? `@${msg.from.username}` : void 0;
let label = name;
if (name && username) label = `${name} (${username})`;
else if (!name && username) label = username;
const fallbackId = (senderId != null && `${senderId}`.trim() ? `${senderId}`.trim() : void 0) ?? (msg.from?.id != null ? String(msg.from.id) : void 0);
const idPart = fallbackId ? `id:${fallbackId}` : void 0;
if (label && idPart) return `${label} ${idPart}`;
if (label) return label;
return idPart ?? "id:unknown";
}
function buildGroupLabel(msg, chatId, messageThreadId) {
const title = msg.chat?.title;
const topicSuffix = messageThreadId != null ? ` topic:${messageThreadId}` : "";
if (title) return `${title} id:${chatId}${topicSuffix}`;
return `group:${chatId}${topicSuffix}`;
}
function hasBotMention(msg, botUsername) {
if ((msg.text ?? msg.caption ?? "").toLowerCase().includes(`@${botUsername}`)) return true;
const entities = msg.entities ?? msg.caption_entities ?? [];
for (const ent of entities) {
if (ent.type !== "mention") continue;
if ((msg.text ?? msg.caption ?? "").slice(ent.offset, ent.offset + ent.length).toLowerCase() === `@${botUsername}`) return true;
}
return false;
}
function expandTextLinks(text, entities) {
if (!text || !entities?.length) return text;
const textLinks = entities.filter((entity) => entity.type === "text_link" && Boolean(entity.url)).toSorted((a, b) => b.offset - a.offset);
if (textLinks.length === 0) return text;
let result = text;
for (const entity of textLinks) {
const markdown = `[${text.slice(entity.offset, entity.offset + entity.length)}](${entity.url})`;
result = result.slice(0, entity.offset) + markdown + result.slice(entity.offset + entity.length);
}
return result;
}
function resolveTelegramReplyId(raw) {
if (!raw) return;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return;
return parsed;
}
function describeReplyTarget(msg) {
const reply = msg.reply_to_message;
const externalReply = msg.external_reply;
const quoteText = msg.quote?.text ?? externalReply?.quote?.text;
let body = "";
let kind = "reply";
if (typeof quoteText === "string") {
body = quoteText.trim();
if (body) kind = "quote";
}
const replyLike = reply ?? externalReply;
if (!body && replyLike) {
body = (replyLike.text ?? replyLike.caption ?? "").trim();
if (!body) {
body = resolveTelegramMediaPlaceholder(replyLike) ?? "";
if (!body) {
const locationData = extractTelegramLocation(replyLike);
if (locationData) body = formatLocationText(locationData);
}
}
}
if (!body) return null;
const senderLabel = (replyLike ? buildSenderName(replyLike) : void 0) ?? "unknown sender";
return {
id: replyLike?.message_id ? String(replyLike.message_id) : void 0,
sender: senderLabel,
body,
kind
};
}
function normalizeForwardedUserLabel(user) {
const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
const username = user.username?.trim() || void 0;
const id = String(user.id);
return {
display: (name && username ? `${name} (@${username})` : name || (username ? `@${username}` : void 0)) || `user:${id}`,
name: name || void 0,
username,
id
};
}
function normalizeForwardedChatLabel(chat, fallbackKind) {
const title = chat.title?.trim() || void 0;
const username = chat.username?.trim() || void 0;
const id = String(chat.id);
return {
display: title || (username ? `@${username}` : void 0) || `${fallbackKind}:${id}`,
title,
username,
id
};
}
function buildForwardedContextFromUser(params) {
const { display, name, username, id } = normalizeForwardedUserLabel(params.user);
if (!display) return null;
return {
from: display,
date: params.date,
fromType: params.type,
fromId: id,
fromUsername: username,
fromTitle: name
};
}
function buildForwardedContextFromHiddenName(params) {
const trimmed = params.name?.trim();
if (!trimmed) return null;
return {
from: trimmed,
date: params.date,
fromType: params.type,
fromTitle: trimmed
};
}
function buildForwardedContextFromChat(params) {
const fallbackKind = params.type === "channel" ? "channel" : "chat";
const { display, title, username, id } = normalizeForwardedChatLabel(params.chat, fallbackKind);
if (!display) return null;
const signature = params.signature?.trim() || void 0;
const from = signature ? `${display} (${signature})` : display;
const chatType = params.chat.type?.trim() || void 0;
return {
from,
date: params.date,
fromType: params.type,
fromId: id,
fromUsername: username,
fromTitle: title,
fromSignature: signature,
fromChatType: chatType,
fromMessageId: params.messageId
};
}
function resolveForwardOrigin(origin) {
switch (origin.type) {
case "user": return buildForwardedContextFromUser({
user: origin.sender_user,
date: origin.date,
type: "user"
});
case "hidden_user": return buildForwardedContextFromHiddenName({
name: origin.sender_user_name,
date: origin.date,
type: "hidden_user"
});
case "chat": return buildForwardedContextFromChat({
chat: origin.sender_chat,
date: origin.date,
type: "chat",
signature: origin.author_signature
});
case "channel": return buildForwardedContextFromChat({
chat: origin.chat,
date: origin.date,
type: "channel",
signature: origin.author_signature,
messageId: origin.message_id
});
default: return null;
}
}
/** Extract forwarded message origin info from Telegram message. */
function normalizeForwardedContext(msg) {
if (!msg.forward_origin) return null;
return resolveForwardOrigin(msg.forward_origin);
}
function extractTelegramLocation(msg) {
const { venue, location } = msg;
if (venue) return {
latitude: venue.location.latitude,
longitude: venue.location.longitude,
accuracy: venue.location.horizontal_accuracy,
name: venue.title,
address: venue.address,
source: "place",
isLive: false
};
if (location) {
const isLive = typeof location.live_period === "number" && location.live_period > 0;
return {
latitude: location.latitude,
longitude: location.longitude,
accuracy: location.horizontal_accuracy,
source: isLive ? "live" : "pin",
isLive
};
}
return null;
}
//#endregion
//#region src/infra/diagnostic-flags.ts
const DIAGNOSTICS_ENV = "OPENCLAW_DIAGNOSTICS";
function normalizeFlag(value) {
return value.trim().toLowerCase();
}
function parseEnvFlags(raw) {
if (!raw) return [];
const trimmed = raw.trim();
if (!trimmed) return [];
const lowered = trimmed.toLowerCase();
if ([
"0",
"false",
"off",
"none"
].includes(lowered)) return [];
if ([
"1",
"true",
"all",
"*"
].includes(lowered)) return ["*"];
return trimmed.split(/[,\s]+/).map(normalizeFlag).filter(Boolean);
}
function uniqueFlags(flags) {
const seen = /* @__PURE__ */ new Set();
const out = [];
for (const flag of flags) {
const normalized = normalizeFlag(flag);
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
out.push(normalized);
}
return out;
}
function resolveDiagnosticFlags(cfg, env = process.env) {
const configFlags = Array.isArray(cfg?.diagnostics?.flags) ? cfg?.diagnostics?.flags : [];
const envFlags = parseEnvFlags(env[DIAGNOSTICS_ENV]);
return uniqueFlags([...configFlags, ...envFlags]);
}
function matchesDiagnosticFlag(flag, enabledFlags) {
const target = normalizeFlag(flag);
if (!target) return false;
for (const raw of enabledFlags) {
const enabled = normalizeFlag(raw);
if (!enabled) continue;
if (enabled === "*" || enabled === "all") return true;
if (enabled.endsWith(".*")) {
const prefix = enabled.slice(0, -2);
if (target === prefix || target.startsWith(`${prefix}.`)) return true;
}
if (enabled.endsWith("*")) {
const prefix = enabled.slice(0, -1);
if (target.startsWith(prefix)) return true;
}
if (enabled === target) return true;
}
return false;
}
function isDiagnosticFlagEnabled(flag, cfg, env = process.env) {
return matchesDiagnosticFlag(flag, resolveDiagnosticFlags(cfg, env));
}
//#endregion
//#region src/telegram/api-logging.ts
const fallbackLogger = createSubsystemLogger("telegram/api");
function resolveTelegramApiLogger(runtime, logger) {
if (logger) return logger;
if (runtime?.error) return runtime.error;
return (message) => fallbackLogger.error(message);
}
async function withTelegramApiErrorLogging({ operation, fn, runtime, logger, shouldLog }) {
try {
return await fn();
} catch (err) {
if (!shouldLog || shouldLog(err)) {
const errText = formatErrorMessage(err);
resolveTelegramApiLogger(runtime, logger)(danger(`telegram ${operation} failed: ${errText}`));
}
throw err;
}
}
//#endregion
//#region src/telegram/caption.ts
const TELEGRAM_MAX_CAPTION_LENGTH = 1024;
function splitTelegramCaption(text) {
const trimmed = text?.trim() ?? "";
if (!trimmed) return {
caption: void 0,
followUpText: void 0
};
if (trimmed.length > TELEGRAM_MAX_CAPTION_LENGTH) return {
caption: void 0,
followUpText: trimmed
};
return {
caption: trimmed,
followUpText: void 0
};
}
//#endregion
//#region src/telegram/network-config.ts
const TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV = "OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY";
const TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV = "OPENCLAW_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY";
function resolveTelegramAutoSelectFamilyDecision(params) {
const env = params?.env ?? process$1.env;
const nodeMajor = typeof params?.nodeMajor === "number" ? params.nodeMajor : Number(process$1.versions.node.split(".")[0]);
if (isTruthyEnvValue(env[TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV])) return {
value: true,
source: `env:${TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV}`
};
if (isTruthyEnvValue(env[TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV])) return {
value: false,
source: `env:${TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV}`
};
if (typeof params?.network?.autoSelectFamily === "boolean") return {
value: params.network.autoSelectFamily,
source: "config"
};
if (Number.isFinite(nodeMajor) && nodeMajor >= 22) return {
value: true,
source: "default-node22"
};
return { value: null };
}
//#endregion
//#region src/telegram/fetch.ts
let appliedAutoSelectFamily = null;
const log = createSubsystemLogger("telegram/network");
function applyTelegramNetworkWorkarounds(network) {
const decision = resolveTelegramAutoSelectFamilyDecision({ network });
if (decision.value === null || decision.value === appliedAutoSelectFamily) return;
appliedAutoSelectFamily = decision.value;
if (typeof net$1.setDefaultAutoSelectFamily === "function") try {
net$1.setDefaultAutoSelectFamily(decision.value);
const label = decision.source ? ` (${decision.source})` : "";
log.info(`telegram: autoSelectFamily=${decision.value}${label}`);
} catch {}
}
function resolveTelegramFetch(proxyFetch, options) {
applyTelegramNetworkWorkarounds(options?.network);
if (proxyFetch) return resolveFetch(proxyFetch);
const fetchImpl = resolveFetch();
if (!fetchImpl) throw new Error("fetch is not available; set channels.telegram.proxy in config");
return fetchImpl;
}
//#endregion
//#region src/telegram/format.ts
function escapeHtml(text) {
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
function escapeHtmlAttr(text) {
return escapeHtml(text).replace(/"/g, """);
}
/**
* File extensions that share TLDs and commonly appear in code/documentation.
* These are wrapped in <code> tags to prevent Telegram from generating
* spurious domain registrar previews.
*
* Only includes extensions that are:
* 1. Commonly used as file extensions in code/docs
* 2. Rarely used as intentional domain references
*
* Excluded: .ai, .io, .tv, .fm (popular domain TLDs like x.ai, vercel.io, github.io)
*/
const FILE_EXTENSIONS_WITH_TLD = new Set([
"md",
"go",
"py",
"pl",
"sh",
"am",
"at",
"be",
"cc"
]);
/** Detects when markdown-it linkify auto-generated a link from a bare filename (e.g. README.md → http://README.md) */
function isAutoLinkedFileRef(href, label) {
if (href.replace(/^https?:\/\//i, "") !== label) return false;
const dotIndex = label.lastIndexOf(".");
if (dotIndex < 1) return false;
const ext = label.slice(dotIndex + 1).toLowerCase();
if (!FILE_EXTENSIONS_WITH_TLD.has(ext)) return false;
const segments = label.split("/");
if (segments.length > 1) {
for (let i = 0; i < segments.length - 1; i++) if (segments[i].includes(".")) return false;
}
return true;
}
function buildTelegramLink(link, text) {
const href = link.href.trim();
if (!href) return null;
if (link.start === link.end) return null;
if (isAutoLinkedFileRef(href, text.slice(link.start, link.end))) return null;
const safeHref = escapeHtmlAttr(href);
return {
start: link.start,
end: link.end,
open: `<a href="${safeHref}">`,
close: "</a>"
};
}
function renderTelegramHtml(ir) {
return renderMarkdownWithMarkers(ir, {
styleMarkers: {
bold: {
open: "<b>",
close: "</b>"
},
italic: {
open: "<i>",
close: "</i>"
},
strikethrough: {
open: "<s>",
close: "</s>"
},
code: {
open: "<code>",
close: "</code>"
},
code_block: {
open: "<pre><code>",
close: "</code></pre>"
},
spoiler: {
open: "<tg-spoiler>",
close: "</tg-spoiler>"
},
blockquote: {
open: "<blockquote>",
close: "</blockquote>"
}
},
escapeText: escapeHtml,
buildLink: buildTelegramLink
});
}
function markdownToTelegramHtml(markdown, options = {}) {
const html = renderTelegramHtml(markdownToIR(markdown ?? "", {
linkify: true,
enableSpoilers: true,
headingStyle: "none",
blockquotePrefix: "",
tableMode: options.tableMode
}));
if (options.wrapFileRefs !== false) return wrapFileReferencesInHtml(html);
return html;
}
/**
* Wraps standalone file references (with TLD extensions) in <code> tags.
* This prevents Telegram from treating them as URLs and generating
* irrelevant domain registrar previews.
*
* Runs AFTER markdown→HTML conversion to avoid modifying HTML attributes.
* Skips content inside <code>, <pre>, and <a> tags to avoid nesting issues.
*/
/** Escape regex metacharacters in a string */
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const FILE_EXTENSIONS_PATTERN = Array.from(FILE_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
const AUTO_LINKED_ANCHOR_PATTERN = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\1<\/a>/gi;
const FILE_REFERENCE_PATTERN = new RegExp(`(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=$|[^a-zA-Z0-9_\\-/])`, "gi");
const ORPHANED_TLD_PATTERN = new RegExp(`([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=[^a-zA-Z0-9/]|$)`, "g");
const HTML_TAG_PATTERN = /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/gi;
function wrapStandaloneFileRef(match, prefix, filename) {
if (filename.startsWith("//")) return match;
if (/https?:\/\/$/i.test(prefix)) return match;
return `${prefix}<code>${escapeHtml(filename)}</code>`;
}
function wrapSegmentFileRefs(text, codeDepth, preDepth, anchorDepth) {
if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) return text;
return text.replace(FILE_REFERENCE_PATTERN, wrapStandaloneFileRef).replace(ORPHANED_TLD_PATTERN, (match, prefix, tld) => prefix === ">" ? match : `${prefix}<code>${escapeHtml(tld)}</code>`);
}
function wrapFileReferencesInHtml(html) {
AUTO_LINKED_ANCHOR_PATTERN.lastIndex = 0;
const deLinkified = html.replace(AUTO_LINKED_ANCHOR_PATTERN, (_match, label) => {
if (!isAutoLinkedFileRef(`http://${label}`, label)) return _match;
return `<code>${escapeHtml(label)}</code>`;
});
let codeDepth = 0;
let preDepth = 0;
let anchorDepth = 0;
let result = "";
let lastIndex = 0;
HTML_TAG_PATTERN.lastIndex = 0;
let match;
while ((match = HTML_TAG_PATTERN.exec(deLinkified)) !== null) {
const tagStart = match.index;
const tagEnd = HTML_TAG_PATTERN.lastIndex;
const isClosing = match[1] === "</";
const tagName = match[2].toLowerCase();
const textBefore = deLinkified.slice(lastIndex, tagStart);
result += wrapSegmentFileRefs(textBefore, codeDepth, preDepth, anchorDepth);
if (tagName === "code") codeDepth = isClosing ? Math.max(0, codeDepth - 1) : codeDepth + 1;
else if (tagName === "pre") preDepth = isClosing ? Math.max(0, preDepth - 1) : preDepth + 1;
else if (tagName === "a") anchorDepth = isClosing ? Math.max(0, anchorDepth - 1) : anchorDepth + 1;
result += deLinkified.slice(tagStart, tagEnd);
lastIndex = tagEnd;
}
const remainingText = deLinkified.slice(lastIndex);
result += wrapSegmentFileRefs(remainingText, codeDepth, preDepth, anchorDepth);
return result;
}
function renderTelegramHtmlText(text, options = {}) {
if ((options.textMode ?? "markdown") === "html") return text;
return markdownToTelegramHtml(text, { tableMode: options.tableMode });
}
function markdownToTelegramChunks(markdown, limit, options = {}) {
return chunkMarkdownIR(markdownToIR(markdown ?? "", {
linkify: true,
enableSpoilers: true,
headingStyle: "none",
blockquotePrefix: "",
tableMode: options.tableMode
}), limit).map((chunk) => ({
html: wrapFileReferencesInHtml(renderTelegramHtml(chunk)),
text: chunk.text
}));
}
//#endregion
//#region src/telegram/network-errors.ts
const RECOVERABLE_ERROR_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
"EPIPE",
"ETIMEDOUT",
"ESOCKETTIMEDOUT",
"ENETUNREACH",
"EHOSTUNREACH",
"ENOTFOUND",
"EAI_AGAIN",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_HEADERS_TIMEOUT",
"UND_ERR_BODY_TIMEOUT",
"UND_ERR_SOCKET",
"UND_ERR_ABORTED",
"ECONNABORTED",
"ERR_NETWORK"
]);
const RECOVERABLE_ERROR_NAMES = new Set([
"AbortError",
"TimeoutError",
"ConnectTimeoutError",
"HeadersTimeoutError",
"BodyTimeoutError"
]);
const RECOVERABLE_MESSAGE_SNIPPETS = [
"fetch failed",
"typeerror: fetch failed",
"undici",
"network error",
"network request",
"client network socket disconnected",
"socket hang up",
"getaddrinfo",
"timeout",
"timed out"
];
function normalizeCode(code) {
return code?.trim().toUpperCase() ?? "";
}
function getErrorName(err) {
if (!err || typeof err !== "object") return "";
return "name" in err ? String(err.name) : "";
}
function getErrorCode(err) {
const direct = extractErrorCode(err);
if (direct) return direct;
if (!err || typeof err !== "object") return;
const errno = err.errno;
if (typeof errno === "string") return errno;
if (typeof errno === "number") return String(errno);
}
function collectErrorCandidates(err) {
const queue = [err];
const seen = /* @__PURE__ */ new Set();
const candidates = [];
while (queue.length > 0) {
const current = queue.shift();
if (current == null || seen.has(current)) continue;
seen.add(current);
candidates.push(current);
if (typeof current === "object") {
const cause = current.cause;
if (cause && !seen.has(cause)) queue.push(cause);
const reason = current.reason;
if (reason && !seen.has(reason)) queue.push(reason);
const errors = current.errors;
if (Array.isArray(errors)) {
for (const nested of errors) if (nested && !seen.has(nested)) queue.push(nested);
}
if (getErrorName(current) === "HttpError") {
const wrappedError = current.error;
if (wrappedError && !seen.has(wrappedError)) queue.push(wrappedError);
}
}
}
return candidates;
}
function isRecoverableTelegramNetworkError(err, options = {}) {
if (!err) return false;
const allowMessageMatch = typeof options.allowMessageMatch === "boolean" ? options.allowMessageMatch : options.context !== "send";
for (const candidate of collectErrorCandidates(err)) {
const code = normalizeCode(getErrorCode(candidate));
if (code && RECOVERABLE_ERROR_CODES.has(code)) return true;
const name = getErrorName(candidate);
if (name && RECOVERABLE_ERROR_NAMES.has(name)) return true;
if (allowMessageMatch) {
const message = formatErrorMessage(candidate).toLowerCase();
if (message && RECOVERABLE_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) return true;
}
}
return false;
}
//#endregion
//#region src/telegram/sent-message-cache.ts
/**
* In-memory cache of sent message IDs per chat.
* Used to identify bot's own messages for reaction filtering ("own" mode).
*/
const TTL_MS = 1440 * 60 * 1e3;
const sentMessages = /* @__PURE__ */ new Map();
function getChatKey(chatId) {
return String(chatId);
}
function cleanupExpired(entry) {
const now = Date.now();
for (const [msgId, timestamp] of entry.timestamps) if (now - timestamp > TTL_MS) entry.timestamps.delete(msgId);
}
/**
* Record a message ID as sent by the bot.
*/
function recordSentMessage(chatId, messageId) {
const key = getChatKey(chatId);
let entry = sentMessages.get(key);
if (!entry) {
entry = { timestamps: /* @__PURE__ */ new Map() };
sentMessages.set(key, entry);
}
entry.timestamps.set(messageId, Date.now());
if (entry.timestamps.size > 100) cleanupExpired(entry);
}
/**
* Check if a message was sent by the bot.
*/
function wasSentByBot(chatId, messageId) {
const key = getChatKey(chatId);
const entry = sentMessages.get(key);
if (!entry) return false;
cleanupExpired(entry);
return entry.timestamps.has(messageId);
}
//#endregion
//#region src/telegram/voice.ts
function resolveTelegramVoiceDecision(opts) {
if (!opts.wantsVoice) return { useVoice: false };
if (isTelegramVoiceCompatibleAudio(opts)) return { useVoice: true };
return {
useVoice: false,
reason: `media is ${opts.contentType ?? "unknown"} (${opts.fileName ?? "unknown"})`
};
}
function resolveTelegramVoiceSend(opts) {
const decision = resolveTelegramVoiceDecision(opts);
if (decision.reason && opts.logFallback) opts.logFallback(`Telegram voice requested but ${decision.reason}; sending as audio file instead.`);
return { useVoice: decision.useVoice };
}
//#endregion
//#region src/telegram/send.ts
var send_exports = /* @__PURE__ */ __exportAll({
buildInlineKeyboard: () => buildInlineKeyboard,
createForumTopicTelegram: () => createForumTopicTelegram,
deleteMessageTelegram: () => deleteMessageTelegram,
editMessageTelegram: () => editMessageTelegram,
reactMessageTelegram: () => reactMessageTelegram,
sendMessageTelegram: () => sendMessageTelegram,
sendPollTelegram: () => sendPollTelegram,
sendStickerTelegram: () => sendStickerTelegram
});
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const THREAD_NOT_FOUND_RE = /400:\s*Bad Request:\s*message thread not found/i;
const MESSAGE_NOT_MODIFIED_RE = /400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i;
const CHAT_NOT_FOUND_RE = /400: Bad Request: chat not found/i;
const diagLogger = createSubsystemLogger("telegram/diagnostic");
function createTelegramHttpLogger(cfg) {
if (!isDiagnosticFlagEnabled("telegram.http", cfg)) return () => {};
return (label, err) => {
if (!(err instanceof HttpError)) return;
const detail = redactSensitiveText(formatUncaughtError(err.error ?? err));
diagLogger.warn(`telegram http error (${label}): ${detail}`);
};
}
function resolveTelegramClientOptions(account) {
const proxyUrl = account.config.proxy?.trim();
const fetchImpl = resolveTelegramFetch(proxyUrl ? makeProxyFetch(proxyUrl) : void 0, { network: account.config.network });
const timeoutSeconds = typeof account.config.timeoutSeconds === "number" && Number.isFinite(account.config.timeoutSeconds) ? Math.max(1, Math.floor(account.config.timeoutSeconds)) : void 0;
return fetchImpl || timeoutSeconds ? {
...fetchImpl ? { fetch: fetchImpl } : {},
...timeoutSeconds ? { timeoutSeconds } : {}
} : void 0;
}
function resolveToken(explicit, params) {
if (explicit?.trim()) return explicit.trim();
if (!params.token) throw new Error(`Telegram bot token missing for account "${params.accountId}" (set channels.telegram.accounts.${params.accountId}.botToken/tokenFile or TELEGRAM_BOT_TOKEN for default).`);
return params.token.trim();
}
function normalizeChatId(to) {
const trimmed = to.trim();
if (!trimmed) throw new Error("Recipient is required for Telegram sends");
let normalized = stripTelegramInternalPrefixes(trimmed);
const m = /^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ?? /^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized);
if (m?.[1]) normalized = `@${m[1]}`;
if (!normalized) throw new Error("Recipient is required for Telegram sends");
if (normalized.startsWith("@")) return normalized;
if (/^-?\d+$/.test(normalized)) return normalized;
if (/^[A-Za-z0-9_]{5,}$/i.test(normalized)) return `@${normalized}`;
return normalized;
}
function normalizeMessageId(raw) {
if (typeof raw === "number" && Number.isFinite(raw)) return Math.trunc(raw);
if (typeof raw === "string") {
const value = raw.trim();
if (!value) throw new Error("Message id is required for Telegram actions");
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) return parsed;
}
throw new Error("Message id is required for Telegram actions");
}
function isTelegramThreadNotFoundError(err) {
return THREAD_NOT_FOUND_RE.test(formatErrorMessage(err));
}
function isTelegramMessageNotModifiedError(err) {
return MESSAGE_NOT_MODIFIED_RE.test(formatErrorMessage(err));
}
function hasMessageThreadIdParam(params) {
if (!params) return false;
const value = params.message_thread_id;
if (typeof value === "number") return Number.isFinite(value);
if (typeof value === "string") return value.trim().length > 0;
return false;
}
function removeMessageThreadIdParam(params) {
if (!params || !hasMessageThreadIdParam(params)) return params;
const next = { ...params };
delete next.message_thread_id;
return Object.keys(next).length > 0 ? next : void 0;
}
function isTelegramHtmlParseError(err) {
return PARSE_ERR_RE.test(formatErrorMessage(err));
}
function buildTelegramThreadReplyParams(params) {
const messageThreadId = params.messageThreadId != null ? params.messageThreadId : params.targetMessageThreadId;
const threadScope = params.chatType === "direct" ? "dm" : "forum";
const threadIdParams = buildTelegramThreadParams(messageThreadId != null ? {
id: messageThreadId,
scope: threadScope
} : void 0);
const threadParams = threadIdParams ? { ...threadIdParams } : {};
if (params.replyToMessageI