mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-01 15:40:21 +00:00
* fix(telegram): auto-wrap file references with TLD extensions to prevent URL previews Telegram's auto-linker aggressively treats filenames like HEARTBEAT.md, README.md, main.go, script.py as URLs and generates domain registrar previews. This fix adds comprehensive protection for file extensions that share TLDs: - High priority: .md, .go, .py, .pl, .ai, .sh - Medium priority: .io, .tv, .fm, .am, .at, .be, .cc, .co Implementation: - Added wrapFileReferencesInHtml() in format.ts - Runs AFTER markdown→HTML conversion - Tokenizes HTML to respect tag boundaries - Skips content inside <code>, <pre>, <a> tags (no nesting issues) - Applied to all rendering paths: renderTelegramHtmlText, markdownToTelegramHtml, markdownToTelegramChunks, and delivery.ts fallback Addresses review comments: - P1: Now handles chunked rendering paths correctly - P2: No longer wraps inside existing code blocks (token-based parsing) - No lookbehinds used (broad Node compatibility) Includes comprehensive test suite in format.wrap-md.test.ts AI-assisted: true * fix(telegram): prevent URL previews for file refs with TLD extensions Two layers were causing spurious link previews for file references like `README.md`, `backup.sh`, `main.go`: 1. **markdown-it linkify** converts `README.md` to `<a href="http://README.md">README.md</a>` (.md = Moldova TLD) 2. **Telegram auto-linker** treats remaining bare text as URLs ## Changes ### Primary fix: suppress auto-linkified file refs in buildTelegramLink - Added `isAutoLinkedFileRef()` helper that detects when linkify auto- generated a link from a bare filename (href = "http://" + label) - Rejects paths with domain-like segments (dots in non-final path parts) - Modified `buildTelegramLink()` to return null for these, so file refs stay as plain text and get wrapped in `<code>` by the wrapper ### Safety-net: de-linkify in wrapFileReferencesInHtml - Added pre-pass that catches auto-linkified anchors in pre-rendered HTML - Handles edge cases where HTML is passed directly (textMode: "html") - Reuses `isAutoLinkedFileRef()` logic — no duplication ### Bug fixes discovered during review - **Fixed `isClosing` bug (line 169)**: the check `match[1] === "/"` was wrong — the regex `(<\/?)}` captures `<` or `</`, so closing tags were never detected. Changed to `match[1] === "</"`. This was causing `inCode/inPre/inAnchor` to stay stuck at true after any opening tag, breaking file ref wrapping after closing tags. - **Removed double `wrapFileReferencesInHtml` call**: `renderTelegramHtmlText` was calling `markdownToTelegramHtml` (which wraps) then wrapping again. ### Test coverage (+12 tests, 26 total) - `.sh` filenames (original issue #6932 mentioned backup.sh) - Auto-linkified anchor replacement - Auto-linkified path anchor replacement - Explicit link preservation (different label) - File ref after closing anchor tag (exercises isClosing fix) - Multiple file types in single message - Real URL preservation - Explicit markdown link preservation - File ref after real URL in same message - Chunked output file ref wrapping Closes #6932 * test(telegram): add comprehensive edge case coverage for file ref wrapping Add 16 edge case tests covering: - File refs inside bold/italic tags - Fenced code blocks (no double-wrap) - Domain-like paths preserved as links (example.com/README.md) - GitHub URLs with file paths - wrapFileRefs: false behavior - All TLD extensions (.ai, .io, .tv, .fm) - Non-TLD extensions not wrapped (.png, .css, .js) - File ref position (start, end, multiple in sequence) - Nested paths without domain segments - Version-like paths (v1.0/README.md wraps, example.com/v1.0/README.md links) - Hyphens and underscores in filenames - Uppercase extensions * fix(telegram): use regex literal and depth counters for tag tracking Code review fixes: 1. Replace RegExp constructor with regex literal for autoLinkedAnchor - Avoids double-escaping issues with \s - Uses backreference \1 to match href=label pattern directly 2. Replace boolean toggles with depth counters for tag nesting - codeDepth, preDepth, anchorDepth track nesting levels - Correctly handles nested tags like <pre><code>...</code></pre> - Prevents wrapping inside any level of protected tags Add 4 tests for edge cases: - Nested code tags (depth tracking) - Multiple anchor tags in sequence - Auto-linked anchor with backreference match - Anchor with different href/label (no match) * fix(telegram): add escapeHtml and escapeRegex for defense in depth Code review fixes: 1. Escape filename with escapeHtml() before inserting into <code> tags - Prevents HTML injection if regex ever matches unsafe chars - Defense in depth (current regex already limits to safe chars) 2. Escape extensions with escapeRegex() before joining into pattern - Prevents regex breakage if extensions contain metacharacters - Future-proofs against extensions like 'c++' or 'd.ts' Add tests documenting regex safety boundaries: - Filenames with special chars (&, <, >) don't match - Only [a-zA-Z0-9_.\-./] chars are captured * fix(telegram): catch orphaned single-letter TLD patterns When text like 'R&D.md' doesn't match the main file pattern (because & breaks the character class), the 'D.md' part can still be auto-linked by Telegram as a domain (https://d.md/). Add second pass to catch orphaned TLD patterns like 'D.md', 'R.io', 'X.ai' that follow non-alphanumeric characters and wrap them in <code> tags. Pattern: ([^a-zA-Z0-9]|^)([A-Za-z]\.(?:extensions))(?=[^a-zA-Z0-9/]|$) Tests added: - 'wraps orphaned TLD pattern after special character' (R&D.md → R&<code>D.md</code>) - 'wraps orphaned single-letter TLD patterns' (X.ai, R.io) * refactor(telegram): remove popular domain TLDs from file extension list Remove .ai, .io, .tv, .fm from FILE_EXTENSIONS_WITH_TLD because: - These are commonly used as real domains (x.ai, vercel.io, github.io) - Rarely used as actual file extensions - Users are more likely referring to websites than files Keep: md, sh, py, go, pl (common file extensions, rarely intentional domains) Keep: am, at, be, cc, co (less common as intentional domain references) Update tests to reflect the change: - Add test for supported extensions (.am, .at, .be, .cc, .co) - Add test verifying popular TLDs stay as links * fix(telegram): prevent orphaned TLD wrapping inside HTML tags Code review fixes: 1. Orphaned TLD pass now checks if match is inside HTML tag - Uses lastIndexOf('<') vs lastIndexOf('>') to detect tag context - Skips wrapping when between < and > (inside attributes) - Prevents invalid HTML like <a href="...&<code>D.md</code>"> 2. textMode: 'html' now trusts caller markup - Returns text unchanged instead of wrapping - Caller owns HTML structure in this mode Tests added: - 'does not wrap orphaned TLD inside href attributes' - 'does not wrap orphaned TLD inside any HTML attribute' - 'does not wrap in HTML mode (trusts caller markup)' * refactor(telegram): use snapshot for orphaned TLD offset clarity Use explicit snapshot variable when checking tag positions in orphaned TLD pass. While JavaScript's replace() doesn't mutate during iteration, this makes intent explicit and adds test coverage for multi-TLD HTML. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(telegram): prevent orphaned TLD wrapping inside code/pre tags - Add depth tracking for code/pre tags in orphaned TLD pass - Fix test to expect valid HTML output - 55 tests now covering nested tag scenarios Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(telegram): clamp depth counters and add anchor tracking to orphaned pass - Clamp depth counters at 0 for malformed HTML with stray closing tags - Add anchor depth tracking to orphaned TLD pass to prevent wrapping inside link text (e.g., <a href="...">R&D.md</a>) - 57 tests covering all edge cases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(telegram): keep .co domains linked and wrap punctuated file refs --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
555 lines
19 KiB
TypeScript
555 lines
19 KiB
TypeScript
import { type Bot, GrammyError, InputFile } from "grammy";
|
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
|
import type { ReplyToMode } from "../../config/config.js";
|
|
import type { MarkdownTableMode } from "../../config/types.base.js";
|
|
import type { RuntimeEnv } from "../../runtime.js";
|
|
import type { StickerMetadata, TelegramContext } from "./types.js";
|
|
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js";
|
|
import { danger, logVerbose } from "../../globals.js";
|
|
import { formatErrorMessage } from "../../infra/errors.js";
|
|
import { mediaKindFromMime } from "../../media/constants.js";
|
|
import { fetchRemoteMedia } from "../../media/fetch.js";
|
|
import { isGifMedia } from "../../media/mime.js";
|
|
import { saveMediaBuffer } from "../../media/store.js";
|
|
import { loadWebMedia } from "../../web/media.js";
|
|
import { withTelegramApiErrorLogging } from "../api-logging.js";
|
|
import { splitTelegramCaption } from "../caption.js";
|
|
import {
|
|
markdownToTelegramChunks,
|
|
markdownToTelegramHtml,
|
|
renderTelegramHtmlText,
|
|
wrapFileReferencesInHtml,
|
|
} from "../format.js";
|
|
import { buildInlineKeyboard } from "../send.js";
|
|
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
|
|
import { resolveTelegramVoiceSend } from "../voice.js";
|
|
import {
|
|
buildTelegramThreadParams,
|
|
resolveTelegramReplyId,
|
|
type TelegramThreadSpec,
|
|
} from "./helpers.js";
|
|
|
|
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
|
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
|
|
|
|
export async function deliverReplies(params: {
|
|
replies: ReplyPayload[];
|
|
chatId: string;
|
|
token: string;
|
|
runtime: RuntimeEnv;
|
|
bot: Bot;
|
|
replyToMode: ReplyToMode;
|
|
textLimit: number;
|
|
thread?: TelegramThreadSpec | null;
|
|
tableMode?: MarkdownTableMode;
|
|
chunkMode?: ChunkMode;
|
|
/** Callback invoked before sending a voice message to switch typing indicator. */
|
|
onVoiceRecording?: () => Promise<void> | void;
|
|
/** Controls whether link previews are shown. Default: true (previews enabled). */
|
|
linkPreview?: boolean;
|
|
/** Optional quote text for Telegram reply_parameters. */
|
|
replyQuoteText?: string;
|
|
}): Promise<{ delivered: boolean }> {
|
|
const {
|
|
replies,
|
|
chatId,
|
|
runtime,
|
|
bot,
|
|
replyToMode,
|
|
textLimit,
|
|
thread,
|
|
linkPreview,
|
|
replyQuoteText,
|
|
} = params;
|
|
const chunkMode = params.chunkMode ?? "length";
|
|
let hasReplied = false;
|
|
let hasDelivered = false;
|
|
const markDelivered = () => {
|
|
hasDelivered = true;
|
|
};
|
|
const chunkText = (markdown: string) => {
|
|
const markdownChunks =
|
|
chunkMode === "newline"
|
|
? chunkMarkdownTextWithMode(markdown, textLimit, chunkMode)
|
|
: [markdown];
|
|
const chunks: ReturnType<typeof markdownToTelegramChunks> = [];
|
|
for (const chunk of markdownChunks) {
|
|
const nested = markdownToTelegramChunks(chunk, textLimit, { tableMode: params.tableMode });
|
|
if (!nested.length && chunk) {
|
|
chunks.push({
|
|
html: wrapFileReferencesInHtml(
|
|
markdownToTelegramHtml(chunk, { tableMode: params.tableMode, wrapFileRefs: false }),
|
|
),
|
|
text: chunk,
|
|
});
|
|
continue;
|
|
}
|
|
chunks.push(...nested);
|
|
}
|
|
return chunks;
|
|
};
|
|
for (const reply of replies) {
|
|
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
|
if (!reply?.text && !hasMedia) {
|
|
if (reply?.audioAsVoice) {
|
|
logVerbose("telegram reply has audioAsVoice without media/text; skipping");
|
|
continue;
|
|
}
|
|
runtime.error?.(danger("reply missing text/media"));
|
|
continue;
|
|
}
|
|
const replyToId = replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId);
|
|
const mediaList = reply.mediaUrls?.length
|
|
? reply.mediaUrls
|
|
: reply.mediaUrl
|
|
? [reply.mediaUrl]
|
|
: [];
|
|
const telegramData = reply.channelData?.telegram as
|
|
| { buttons?: Array<Array<{ text: string; callback_data: string }>> }
|
|
| undefined;
|
|
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
|
|
if (mediaList.length === 0) {
|
|
const chunks = chunkText(reply.text || "");
|
|
for (let i = 0; i < chunks.length; i += 1) {
|
|
const chunk = chunks[i];
|
|
if (!chunk) {
|
|
continue;
|
|
}
|
|
// Only attach buttons to the first chunk.
|
|
const shouldAttachButtons = i === 0 && replyMarkup;
|
|
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
|
replyToMessageId:
|
|
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined,
|
|
replyQuoteText,
|
|
thread,
|
|
textMode: "html",
|
|
plainText: chunk.text,
|
|
linkPreview,
|
|
replyMarkup: shouldAttachButtons ? replyMarkup : undefined,
|
|
});
|
|
markDelivered();
|
|
if (replyToId && !hasReplied) {
|
|
hasReplied = true;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
// media with optional caption on first item
|
|
let first = true;
|
|
// Track if we need to send a follow-up text message after media
|
|
// (when caption exceeds Telegram's 1024-char limit)
|
|
let pendingFollowUpText: string | undefined;
|
|
for (const mediaUrl of mediaList) {
|
|
const isFirstMedia = first;
|
|
const media = await loadWebMedia(mediaUrl);
|
|
const kind = mediaKindFromMime(media.contentType ?? undefined);
|
|
const isGif = isGifMedia({
|
|
contentType: media.contentType,
|
|
fileName: media.fileName,
|
|
});
|
|
const fileName = media.fileName ?? (isGif ? "animation.gif" : "file");
|
|
const file = new InputFile(media.buffer, fileName);
|
|
// Caption only on first item; if text exceeds limit, defer to follow-up message.
|
|
const { caption, followUpText } = splitTelegramCaption(
|
|
isFirstMedia ? (reply.text ?? undefined) : undefined,
|
|
);
|
|
const htmlCaption = caption
|
|
? renderTelegramHtmlText(caption, { tableMode: params.tableMode })
|
|
: undefined;
|
|
if (followUpText) {
|
|
pendingFollowUpText = followUpText;
|
|
}
|
|
first = false;
|
|
const replyToMessageId =
|
|
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
|
|
const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText;
|
|
const mediaParams: Record<string, unknown> = {
|
|
caption: htmlCaption,
|
|
...(htmlCaption ? { parse_mode: "HTML" } : {}),
|
|
...(shouldAttachButtonsToMedia ? { reply_markup: replyMarkup } : {}),
|
|
...buildTelegramSendParams({
|
|
replyToMessageId,
|
|
thread,
|
|
}),
|
|
};
|
|
if (isGif) {
|
|
await withTelegramApiErrorLogging({
|
|
operation: "sendAnimation",
|
|
runtime,
|
|
fn: () => bot.api.sendAnimation(chatId, file, { ...mediaParams }),
|
|
});
|
|
markDelivered();
|
|
} else if (kind === "image") {
|
|
await withTelegramApiErrorLogging({
|
|
operation: "sendPhoto",
|
|
runtime,
|
|
fn: () => bot.api.sendPhoto(chatId, file, { ...mediaParams }),
|
|
});
|
|
markDelivered();
|
|
} else if (kind === "video") {
|
|
await withTelegramApiErrorLogging({
|
|
operation: "sendVideo",
|
|
runtime,
|
|
fn: () => bot.api.sendVideo(chatId, file, { ...mediaParams }),
|
|
});
|
|
markDelivered();
|
|
} else if (kind === "audio") {
|
|
const { useVoice } = resolveTelegramVoiceSend({
|
|
wantsVoice: reply.audioAsVoice === true, // default false (backward compatible)
|
|
contentType: media.contentType,
|
|
fileName,
|
|
logFallback: logVerbose,
|
|
});
|
|
if (useVoice) {
|
|
// Voice message - displays as round playable bubble (opt-in via [[audio_as_voice]])
|
|
// Switch typing indicator to record_voice before sending.
|
|
await params.onVoiceRecording?.();
|
|
try {
|
|
await withTelegramApiErrorLogging({
|
|
operation: "sendVoice",
|
|
runtime,
|
|
shouldLog: (err) => !isVoiceMessagesForbidden(err),
|
|
fn: () => bot.api.sendVoice(chatId, file, { ...mediaParams }),
|
|
});
|
|
markDelivered();
|
|
} catch (voiceErr) {
|
|
// Fall back to text if voice messages are forbidden in this chat.
|
|
// This happens when the recipient has Telegram Premium privacy settings
|
|
// that block voice messages (Settings > Privacy > Voice Messages).
|
|
if (isVoiceMessagesForbidden(voiceErr)) {
|
|
const fallbackText = reply.text;
|
|
if (!fallbackText || !fallbackText.trim()) {
|
|
throw voiceErr;
|
|
}
|
|
logVerbose(
|
|
"telegram sendVoice forbidden (recipient has voice messages blocked in privacy settings); falling back to text",
|
|
);
|
|
hasReplied = await sendTelegramVoiceFallbackText({
|
|
bot,
|
|
chatId,
|
|
runtime,
|
|
text: fallbackText,
|
|
chunkText,
|
|
replyToId,
|
|
replyToMode,
|
|
hasReplied,
|
|
thread,
|
|
linkPreview,
|
|
replyMarkup,
|
|
replyQuoteText,
|
|
});
|
|
markDelivered();
|
|
// Skip this media item; continue with next.
|
|
continue;
|
|
}
|
|
throw voiceErr;
|
|
}
|
|
} else {
|
|
// Audio file - displays with metadata (title, duration) - DEFAULT
|
|
await withTelegramApiErrorLogging({
|
|
operation: "sendAudio",
|
|
runtime,
|
|
fn: () => bot.api.sendAudio(chatId, file, { ...mediaParams }),
|
|
});
|
|
markDelivered();
|
|
}
|
|
} else {
|
|
await withTelegramApiErrorLogging({
|
|
operation: "sendDocument",
|
|
runtime,
|
|
fn: () => bot.api.sendDocument(chatId, file, { ...mediaParams }),
|
|
});
|
|
markDelivered();
|
|
}
|
|
if (replyToId && !hasReplied) {
|
|
hasReplied = true;
|
|
}
|
|
// Send deferred follow-up text right after the first media item.
|
|
// Chunk it in case it's extremely long (same logic as text-only replies).
|
|
if (pendingFollowUpText && isFirstMedia) {
|
|
const chunks = chunkText(pendingFollowUpText);
|
|
for (let i = 0; i < chunks.length; i += 1) {
|
|
const chunk = chunks[i];
|
|
const replyToMessageIdFollowup =
|
|
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
|
|
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
|
replyToMessageId: replyToMessageIdFollowup,
|
|
thread,
|
|
textMode: "html",
|
|
plainText: chunk.text,
|
|
linkPreview,
|
|
replyMarkup: i === 0 ? replyMarkup : undefined,
|
|
});
|
|
markDelivered();
|
|
if (replyToId && !hasReplied) {
|
|
hasReplied = true;
|
|
}
|
|
}
|
|
pendingFollowUpText = undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { delivered: hasDelivered };
|
|
}
|
|
|
|
export async function resolveMedia(
|
|
ctx: TelegramContext,
|
|
maxBytes: number,
|
|
token: string,
|
|
proxyFetch?: typeof fetch,
|
|
): Promise<{
|
|
path: string;
|
|
contentType?: string;
|
|
placeholder: string;
|
|
stickerMetadata?: StickerMetadata;
|
|
} | null> {
|
|
const msg = ctx.message;
|
|
|
|
// Handle stickers separately - only static stickers (WEBP) are supported
|
|
if (msg.sticker) {
|
|
const sticker = msg.sticker;
|
|
// Skip animated (TGS) and video (WEBM) stickers - only static WEBP supported
|
|
if (sticker.is_animated || sticker.is_video) {
|
|
logVerbose("telegram: skipping animated/video sticker (only static stickers supported)");
|
|
return null;
|
|
}
|
|
if (!sticker.file_id) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const file = await ctx.getFile();
|
|
if (!file.file_path) {
|
|
logVerbose("telegram: getFile returned no file_path for sticker");
|
|
return null;
|
|
}
|
|
const fetchImpl = proxyFetch ?? globalThis.fetch;
|
|
if (!fetchImpl) {
|
|
logVerbose("telegram: fetch not available for sticker download");
|
|
return null;
|
|
}
|
|
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
|
|
const fetched = await fetchRemoteMedia({
|
|
url,
|
|
fetchImpl,
|
|
filePathHint: file.file_path,
|
|
});
|
|
const originalName = fetched.fileName ?? file.file_path;
|
|
const saved = await saveMediaBuffer(
|
|
fetched.buffer,
|
|
fetched.contentType,
|
|
"inbound",
|
|
maxBytes,
|
|
originalName,
|
|
);
|
|
|
|
// Check sticker cache for existing description
|
|
const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;
|
|
if (cached) {
|
|
logVerbose(`telegram: sticker cache hit for ${sticker.file_unique_id}`);
|
|
const fileId = sticker.file_id ?? cached.fileId;
|
|
const emoji = sticker.emoji ?? cached.emoji;
|
|
const setName = sticker.set_name ?? cached.setName;
|
|
if (fileId !== cached.fileId || emoji !== cached.emoji || setName !== cached.setName) {
|
|
// Refresh cached sticker metadata on hits so sends/searches use latest file_id.
|
|
cacheSticker({
|
|
...cached,
|
|
fileId,
|
|
emoji,
|
|
setName,
|
|
});
|
|
}
|
|
return {
|
|
path: saved.path,
|
|
contentType: saved.contentType,
|
|
placeholder: "<media:sticker>",
|
|
stickerMetadata: {
|
|
emoji,
|
|
setName,
|
|
fileId,
|
|
fileUniqueId: sticker.file_unique_id,
|
|
cachedDescription: cached.description,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Cache miss - return metadata for vision processing
|
|
return {
|
|
path: saved.path,
|
|
contentType: saved.contentType,
|
|
placeholder: "<media:sticker>",
|
|
stickerMetadata: {
|
|
emoji: sticker.emoji ?? undefined,
|
|
setName: sticker.set_name ?? undefined,
|
|
fileId: sticker.file_id,
|
|
fileUniqueId: sticker.file_unique_id,
|
|
},
|
|
};
|
|
} catch (err) {
|
|
logVerbose(`telegram: failed to process sticker: ${String(err)}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const m =
|
|
msg.photo?.[msg.photo.length - 1] ??
|
|
msg.video ??
|
|
msg.video_note ??
|
|
msg.document ??
|
|
msg.audio ??
|
|
msg.voice;
|
|
if (!m?.file_id) {
|
|
return null;
|
|
}
|
|
const file = await ctx.getFile();
|
|
if (!file.file_path) {
|
|
throw new Error("Telegram getFile returned no file_path");
|
|
}
|
|
const fetchImpl = proxyFetch ?? globalThis.fetch;
|
|
if (!fetchImpl) {
|
|
throw new Error("fetch is not available; set channels.telegram.proxy in config");
|
|
}
|
|
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
|
|
const fetched = await fetchRemoteMedia({
|
|
url,
|
|
fetchImpl,
|
|
filePathHint: file.file_path,
|
|
});
|
|
const originalName = fetched.fileName ?? file.file_path;
|
|
const saved = await saveMediaBuffer(
|
|
fetched.buffer,
|
|
fetched.contentType,
|
|
"inbound",
|
|
maxBytes,
|
|
originalName,
|
|
);
|
|
let placeholder = "<media:document>";
|
|
if (msg.photo) {
|
|
placeholder = "<media:image>";
|
|
} else if (msg.video) {
|
|
placeholder = "<media:video>";
|
|
} else if (msg.video_note) {
|
|
placeholder = "<media:video>";
|
|
} else if (msg.audio || msg.voice) {
|
|
placeholder = "<media:audio>";
|
|
}
|
|
return { path: saved.path, contentType: saved.contentType, placeholder };
|
|
}
|
|
|
|
function isVoiceMessagesForbidden(err: unknown): boolean {
|
|
if (err instanceof GrammyError) {
|
|
return VOICE_FORBIDDEN_RE.test(err.description);
|
|
}
|
|
return VOICE_FORBIDDEN_RE.test(formatErrorMessage(err));
|
|
}
|
|
|
|
async function sendTelegramVoiceFallbackText(opts: {
|
|
bot: Bot;
|
|
chatId: string;
|
|
runtime: RuntimeEnv;
|
|
text: string;
|
|
chunkText: (markdown: string) => ReturnType<typeof markdownToTelegramChunks>;
|
|
replyToId?: number;
|
|
replyToMode: ReplyToMode;
|
|
hasReplied: boolean;
|
|
thread?: TelegramThreadSpec | null;
|
|
linkPreview?: boolean;
|
|
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
|
replyQuoteText?: string;
|
|
}): Promise<boolean> {
|
|
const chunks = opts.chunkText(opts.text);
|
|
let hasReplied = opts.hasReplied;
|
|
for (let i = 0; i < chunks.length; i += 1) {
|
|
const chunk = chunks[i];
|
|
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
|
|
replyToMessageId:
|
|
opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined,
|
|
replyQuoteText: opts.replyQuoteText,
|
|
thread: opts.thread,
|
|
textMode: "html",
|
|
plainText: chunk.text,
|
|
linkPreview: opts.linkPreview,
|
|
replyMarkup: i === 0 ? opts.replyMarkup : undefined,
|
|
});
|
|
if (opts.replyToId && !hasReplied) {
|
|
hasReplied = true;
|
|
}
|
|
}
|
|
return hasReplied;
|
|
}
|
|
|
|
function buildTelegramSendParams(opts?: {
|
|
replyToMessageId?: number;
|
|
thread?: TelegramThreadSpec | null;
|
|
}): Record<string, unknown> {
|
|
const threadParams = buildTelegramThreadParams(opts?.thread);
|
|
const params: Record<string, unknown> = {};
|
|
if (opts?.replyToMessageId) {
|
|
params.reply_to_message_id = opts.replyToMessageId;
|
|
}
|
|
if (threadParams) {
|
|
params.message_thread_id = threadParams.message_thread_id;
|
|
}
|
|
return params;
|
|
}
|
|
|
|
async function sendTelegramText(
|
|
bot: Bot,
|
|
chatId: string,
|
|
text: string,
|
|
runtime: RuntimeEnv,
|
|
opts?: {
|
|
replyToMessageId?: number;
|
|
replyQuoteText?: string;
|
|
thread?: TelegramThreadSpec | null;
|
|
textMode?: "markdown" | "html";
|
|
plainText?: string;
|
|
linkPreview?: boolean;
|
|
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
|
},
|
|
): Promise<number | undefined> {
|
|
const baseParams = buildTelegramSendParams({
|
|
replyToMessageId: opts?.replyToMessageId,
|
|
thread: opts?.thread,
|
|
});
|
|
// Add link_preview_options when link preview is disabled.
|
|
const linkPreviewEnabled = opts?.linkPreview ?? true;
|
|
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
|
|
const textMode = opts?.textMode ?? "markdown";
|
|
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
|
|
try {
|
|
const res = await withTelegramApiErrorLogging({
|
|
operation: "sendMessage",
|
|
runtime,
|
|
shouldLog: (err) => !PARSE_ERR_RE.test(formatErrorMessage(err)),
|
|
fn: () =>
|
|
bot.api.sendMessage(chatId, htmlText, {
|
|
parse_mode: "HTML",
|
|
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
|
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
|
...baseParams,
|
|
}),
|
|
});
|
|
return res.message_id;
|
|
} catch (err) {
|
|
const errText = formatErrorMessage(err);
|
|
if (PARSE_ERR_RE.test(errText)) {
|
|
runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`);
|
|
const fallbackText = opts?.plainText ?? text;
|
|
const res = await withTelegramApiErrorLogging({
|
|
operation: "sendMessage",
|
|
runtime,
|
|
fn: () =>
|
|
bot.api.sendMessage(chatId, fallbackText, {
|
|
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
|
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
|
...baseParams,
|
|
}),
|
|
});
|
|
return res.message_id;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|