diff --git a/src/base/callback-command.ts b/src/base/callback-command.ts index 2e8769b..17a9c38 100644 --- a/src/base/callback-command.ts +++ b/src/base/callback-command.ts @@ -3,6 +3,7 @@ import {CallbackQuery, InlineKeyboardButton} from "typescript-telegram-bot-api"; import {Requirements} from "./requirements"; import {bot} from "../index"; import {logError} from "../util/utils"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; export abstract class CallbackCommand { @@ -13,7 +14,7 @@ export abstract class CallbackCommand { abstract execute(query: CallbackQuery): Promise; // eslint-disable-next-line @typescript-eslint/no-unused-vars - afterExecute(query: CallbackQuery): Promise { + afterExecute(_query: CallbackQuery): Promise { return Promise.resolve(); } @@ -23,7 +24,10 @@ export abstract class CallbackCommand { } async answerCallbackQuery(query: CallbackQuery): Promise { - bot.answerCallbackQuery(this.getOptions(query)).catch(logError); + enqueueTelegramApiCall( + () => bot.answerCallbackQuery(this.getOptions(query)), + {method: "answerCallbackQuery", skipPerChatLimit: true} + ).catch(logError); } asButton(): InlineKeyboardButton { @@ -40,4 +44,4 @@ export interface AnswerCallbackQueryOptions { show_alert?: boolean; url?: string; cache_time?: number; -} \ No newline at end of file +} diff --git a/src/base/command.ts b/src/base/command.ts index b416a0a..6cf6a86 100644 --- a/src/base/command.ts +++ b/src/base/command.ts @@ -51,8 +51,8 @@ export function createCommandRegExp( argsMode === "none" ? "\\s*$" : argsMode === "required" - ? "\\s+([\\s\\S]+)\\s*$" // (3)=args обязателен - : "(?:\\s+([\\s\\S]+))?\\s*$"; // (3)=args опционален + ? "\\s+([\\s\\S]+)\\s*$" // (3)=args required + : "(?:\\s+([\\s\\S]+))?\\s*$"; // (3)=args optional return new RegExp(base + tail, "i"); } diff --git a/src/common/message-part.ts b/src/common/message-part.ts index e6e4958..364e74e 100644 --- a/src/common/message-part.ts +++ b/src/common/message-part.ts @@ -1,6 +1,24 @@ +export type MessageImagePart = { + data: string; + mimeType: string; +} + +export type MessageAudioPart = { + data: string; + mimeType: string; +} + export type MessagePart = { bot: boolean; name?: string; + langCode?: string; + userName?: string; content: string; - images: string[]; -} \ No newline at end of file + images?: string[]; + imageParts?: MessageImagePart[]; + audios?: string[]; + audioParts?: MessageAudioPart[]; + documents?: string[]; + videos?: string[]; + videoNotes?: string[]; +} diff --git a/src/util/async-lock.ts b/src/util/async-lock.ts new file mode 100644 index 0000000..0794e40 --- /dev/null +++ b/src/util/async-lock.ts @@ -0,0 +1,76 @@ +export class AsyncSemaphore { + private active = 0; + private readonly waiters: Array<() => void> = []; + + constructor(private readonly maxActive: number) { + if (!Number.isInteger(maxActive) || maxActive < 1) { + throw new Error("AsyncSemaphore maxActive must be a positive integer."); + } + } + + async runExclusive(task: () => Promise | T): Promise { + await this.acquire(); + try { + return await task(); + } finally { + this.release(); + } + } + + private async acquire(): Promise { + if (this.active < this.maxActive) { + this.active++; + return; + } + + await new Promise(resolve => { + this.waiters.push(resolve); + }); + this.active++; + } + + private release(): void { + this.active--; + const next = this.waiters.shift(); + if (next) { + next(); + } + } +} + +export class KeyedAsyncLock { + private readonly chains = new Map>(); + + async runExclusive(key: string, task: () => Promise | T): Promise { + const previous = this.chains.get(key) ?? Promise.resolve(); + + let release!: () => void; + const current = new Promise(resolve => { + release = resolve; + }); + + const tail = previous.then(() => current, () => current); + this.chains.set(key, tail); + + await previous.catch(() => undefined); + + try { + return await task(); + } finally { + release(); + if (this.chains.get(key) === tail) { + this.chains.delete(key); + } + } + } +} + +export function createQueuedFunction() { + let chain = Promise.resolve(); + + return async function enqueue(task: () => Promise | T): Promise { + const run = chain.then(task, task); + chain = run.then(() => undefined, () => undefined); + return run; + }; +} diff --git a/src/util/html-utils.ts b/src/util/html-utils.ts new file mode 100644 index 0000000..f0b54bd --- /dev/null +++ b/src/util/html-utils.ts @@ -0,0 +1,9 @@ +export class HtmlUtils { + static escape(input: string): string { + return input + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } +} diff --git a/src/util/lru-map.ts b/src/util/lru-map.ts new file mode 100644 index 0000000..195ea5e --- /dev/null +++ b/src/util/lru-map.ts @@ -0,0 +1,27 @@ +export function getLruMapValue(map: Map, key: K): V | undefined { + if (!map.has(key)) return undefined; + + const value = map.get(key)!; + map.delete(key); + map.set(key, value); + return value; +} + +export function setLruMapValue(map: Map, key: K, value: V, maxSize: number): void { + if (maxSize < 1) { + map.clear(); + return; + } + + if (map.has(key)) { + map.delete(key); + } + + map.set(key, value); + + while (map.size > maxSize) { + const oldestKey = map.keys().next(); + if (oldestKey.done) return; + map.delete(oldestKey.value); + } +} diff --git a/src/util/markdown-v2-renderer.ts b/src/util/markdown-v2-renderer.ts new file mode 100644 index 0000000..d10cbb6 --- /dev/null +++ b/src/util/markdown-v2-renderer.ts @@ -0,0 +1,728 @@ +export type TelegramRenderMode = "draft" | "final"; + +export interface TelegramMarkdownV2RenderOptions { + /** + * draft: + * - useful for streaming/editMessageText + * - temporarily closes unfinished code blocks / inline code / bold + * + * final: + * - use after LLM finished generation + */ + mode?: TelegramRenderMode; + + /** + * Used when the rendered message is empty. + */ + fallbackText?: string; +} + +/** + * Main function. + * + * Flow: + * LLM Markdown-lite + * -> draft safety, if needed + * -> normalize unsupported Markdown + * -> parse Markdown-lite + * -> render valid Telegram MarkdownV2 + */ +export function prepareTelegramMarkdownV2( + input: string, + options: TelegramMarkdownV2RenderOptions = {}, +): string { + const mode = options.mode ?? "final"; + const fallbackText = options.fallbackText ?? "…"; + + try { + const safeInput = mode === "draft" + ? makePartialMarkdownLiteSafe(input) + : input; + + const normalized = normalizeUnsupportedMarkdown(safeInput); + const ast = parseMarkdownLite(normalized); + const rendered = renderMarkdownV2(ast).trim(); + + return rendered || escapeMarkdownV2Text(fallbackText); + } catch { + const fallback = escapeMarkdownV2Text(input).trim(); + return fallback || escapeMarkdownV2Text(fallbackText); + } +} + +/** + * Useful for editMessageText fallback. + */ +export function prepareTelegramPlainMarkdownV2(input: string, fallbackText = "…"): string { + const escaped = escapeMarkdownV2Text(input).trim(); + return escaped || escapeMarkdownV2Text(fallbackText); +} + +/** + * Draft-safe mode for streaming. + * + * Fixes cases like: + * + * ```ts + * const x = + * + * or: + * + * *partial bold + * + * or: + * + * `partial code + */ +export function makePartialMarkdownLiteSafe(input: string): string { + let text = input.replace(/\r\n?/g, "\n"); + + if (isInsideFencedCodeBlock(text)) { + return closeUnclosedFencedCodeBlock(text); + } + + return transformOutsideFencedCode(text, (outside) => { + let result = outside; + result = closeUnclosedInlineCode(result); + result = closeUnclosedBold(result); + return result; + }); +} + +/** + * Converts unsupported / annoying Markdown into simpler Markdown-lite. + * + * Does not transform fenced code blocks. + */ +export function normalizeUnsupportedMarkdown(input: string): string { + const text = input.replace(/\r\n?/g, "\n").trim(); + + return transformOutsideFencedCode(text, (raw) => { + let result = raw; + + result = normalizeMarkdownTables(result); + + result = result + // Images: ![alt](url) -> [alt](url) + .replace(/!\[([^\]\n]*)]\(([^)\n]+)\)/g, "[$1]($2)") + + // Common Markdown bold -> Markdown-lite bold + .replace(/\*\*([^*\n]+)\*\*/g, "*$1*") + .replace(/__([^_\n]+)__/g, "*$1*") + + .replace(/^`([^`\n]+)$/gm, (_, title: string) => { + const cleanTitle = title.trim(); + return cleanTitle ? `*${cleanTitle}*` : ""; + }) + + // Headings -> bold labels + .replace(/^#{1,6}\s+(.+)$/gm, (_, title: string) => { + const cleanTitle = title + .replace(/[*_`[\]()~>#+\-=|{}.!]/g, "") + .trim(); + + return cleanTitle ? `*${cleanTitle}*` : ""; + }) + + // Horizontal rules + .replace(/^\s*(-{3,}|\*{3,}|_{3,})\s*$/gm, "") + + // Task lists -> normal bullets + .replace(/^(\s*)[-*]\s+\[[ xX]]\s+/gm, "$1- ") + + // HTML line breaks -> newline + .replace(//gi, "\n") + + // Strip simple raw HTML tags, keep content + .replace(/<\/?(?:p|div|span|strong|b|em|i|u|s|del|code|pre)[^>]*>/gi, "") + + // Too many blank lines + .replace(/\n{3,}/g, "\n\n"); + + return result.trim(); + }); +} + +/** + * AST + */ + +type InlineNode = + | { type: "text"; value: string } + | { type: "bold"; children: InlineNode[] } + | { type: "code"; value: string } + | { type: "link"; text: string; url: string }; + +type BlockNode = + | { type: "paragraph"; children: InlineNode[] } + | { type: "pre"; lang?: string; value: string } + | { type: "quote"; lines: InlineNode[][] }; + +/** + * Block parser: + * - fenced code blocks + * - quotes + * - paragraphs + */ +export function parseMarkdownLite(input: string): BlockNode[] { + const lines = input.replace(/\r\n?/g, "\n").split("\n"); + const blocks: BlockNode[] = []; + + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (!line.trim()) { + i++; + continue; + } + + const fenceStart = line.match(/^```\s*([^`]*)\s*$/); + + if (fenceStart) { + const lang = sanitizeCodeLanguage(fenceStart[1]); + const body: string[] = []; + + i++; + + while (i < lines.length && !/^```\s*$/.test(lines[i])) { + body.push(lines[i]); + i++; + } + + if (i < lines.length) { + i++; + } + + blocks.push({ + type: "pre", + lang, + value: body.join("\n"), + }); + + continue; + } + + if (/^\s*>\s?/.test(line)) { + const quoteLines: InlineNode[][] = []; + + while (i < lines.length && /^\s*>\s?/.test(lines[i])) { + const quoteLine = lines[i].replace(/^\s*>\s?/, ""); + quoteLines.push(parseInlineMarkdownLite(quoteLine)); + i++; + } + + blocks.push({ + type: "quote", + lines: quoteLines, + }); + + continue; + } + + const paragraphLines: string[] = []; + + while ( + i < lines.length && + lines[i].trim() && + !/^```\s*([^`]*)\s*$/.test(lines[i]) && + !/^\s*>\s?/.test(lines[i]) + ) { + paragraphLines.push(lines[i]); + i++; + } + + if (paragraphLines.length === 0) { + paragraphLines.push(lines[i]); + i++; + } + + blocks.push({ + type: "paragraph", + children: parseInlineMarkdownLite(paragraphLines.join("\n")), + }); + } + + return blocks; +} + +/** + * Inline parser: + * - *bold* + * - `code` + * - [text](url) + * + * This is intentionally not a full Markdown parser. + */ +export function parseInlineMarkdownLite(source: string): InlineNode[] { + const nodes: InlineNode[] = []; + let buffer = ""; + let i = 0; + + const flushText = () => { + if (buffer) { + nodes.push({ type: "text", value: buffer }); + buffer = ""; + } + }; + + while (i < source.length) { + const ch = source[i]; + + if (ch === "`") { + const end = findNextUnescaped(source, "`", i + 1); + + if (end !== -1) { + flushText(); + + nodes.push({ + type: "code", + value: source.slice(i + 1, end), + }); + + i = end + 1; + continue; + } + } + + if (ch === "[") { + const labelEnd = findNextUnescaped(source, "]", i + 1); + + if (labelEnd !== -1 && source[labelEnd + 1] === "(") { + const urlStart = labelEnd + 2; + const urlEnd = findMarkdownLinkEnd(source, urlStart); + + if (urlEnd !== -1) { + const text = source.slice(i + 1, labelEnd).trim(); + const url = source.slice(urlStart, urlEnd).trim(); + + if (text && isSafeUrl(url)) { + flushText(); + + nodes.push({ + type: "link", + text, + url, + }); + + i = urlEnd + 1; + continue; + } + } + } + } + + if (ch === "*" && canStartBold(source, i)) { + const end = findBoldEnd(source, i + 1); + + if (end !== -1 && canEndBold(source, end)) { + const content = source.slice(i + 1, end); + + if (content.trim()) { + flushText(); + + nodes.push({ + type: "bold", + children: parseInlineMarkdownLite(content), + }); + + i = end + 1; + continue; + } + } + } + + buffer += ch; + i++; + } + + flushText(); + + return nodes; +} + +/** + * MarkdownV2 renderer + */ + +export function renderMarkdownV2(blocks: BlockNode[]): string { + return blocks + .map(renderBlockMarkdownV2) + .filter(Boolean) + .join("\n\n") + .trim(); +} + +function renderBlockMarkdownV2(block: BlockNode): string { + switch (block.type) { + case "paragraph": + return renderInlineMarkdownV2(block.children); + + case "pre": { + const lang = block.lang ? block.lang : ""; + const code = escapeMarkdownV2Code(block.value); + + if (lang) { + return "```" + lang + "\n" + code + "\n```"; + } + + return "```\n" + code + "\n```"; + } + + case "quote": + return block.lines + .map((line) => ">" + renderInlineMarkdownV2(line)) + .join("\n"); + } +} + +function renderInlineMarkdownV2(nodes: InlineNode[]): string { + return nodes.map(renderInlineNodeMarkdownV2).join(""); +} + +function renderInlineNodeMarkdownV2(node: InlineNode): string { + switch (node.type) { + case "text": + return escapeMarkdownV2Text(node.value); + + case "bold": + return "*" + renderInlineMarkdownV2(node.children) + "*"; + + case "code": + return "`" + escapeMarkdownV2Code(node.value) + "`"; + + case "link": + return `[${escapeMarkdownV2Text(node.text)}](${escapeMarkdownV2LinkUrl(node.url)})`; + } +} + +/** + * Telegram MarkdownV2 escaping + */ + +export function escapeMarkdownV2Text(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/([_*\[\]()~`>#+\-=|{}.!])/g, "\\$1"); +} + +export function escapeMarkdownV2Code(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/`/g, "\\`"); +} + +export function escapeMarkdownV2LinkUrl(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\)/g, "\\)"); +} + +/** + * Draft safety helpers + */ + +function closeUnclosedFencedCodeBlock(input: string): string { + if (!isInsideFencedCodeBlock(input)) { + return input; + } + + return input.endsWith("\n") + ? input + "```" + : input + "\n```"; +} + +function isInsideFencedCodeBlock(input: string): boolean { + const fenceMatches = [...input.matchAll(/^```/gm)]; + return fenceMatches.length % 2 === 1; +} + +function closeUnclosedInlineCode(input: string): string { + let count = 0; + let escaped = false; + + for (const ch of input) { + if (escaped) { + escaped = false; + continue; + } + + if (ch === "\\") { + escaped = true; + continue; + } + + if (ch === "`") { + count++; + } + } + + return count % 2 === 1 ? input + "`" : input; +} + +function closeUnclosedBold(input: string): string { + let count = 0; + let escaped = false; + + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + + if (escaped) { + escaped = false; + continue; + } + + if (ch === "\\") { + escaped = true; + continue; + } + + if (ch !== "*") { + continue; + } + + if (isLikelyListMarker(input, i)) { + continue; + } + + count++; + } + + return count % 2 === 1 ? input + "*" : input; +} + +function isLikelyListMarker(input: string, index: number): boolean { + const prev = input[index - 1]; + const next = input[index + 1]; + + const isLineStart = index === 0 || prev === "\n"; + return isLineStart && next === " "; +} + +/** + * Generic helpers + */ + +function findNextUnescaped(source: string, target: string, from: number): number { + for (let i = from; i < source.length; i++) { + if (source[i] === "\\" && i + 1 < source.length) { + i++; + continue; + } + + if (source[i] === target) { + return i; + } + } + + return -1; +} + +function findBoldEnd(source: string, from: number): number { + for (let i = from; i < source.length; i++) { + if (source[i] === "\\" && i + 1 < source.length) { + i++; + continue; + } + + if (source[i] === "*") { + return i; + } + } + + return -1; +} + +function findMarkdownLinkEnd(source: string, from: number): number { + let depth = 0; + + for (let i = from; i < source.length; i++) { + const ch = source[i]; + + if (ch === "\\" && i + 1 < source.length) { + i++; + continue; + } + + if (ch === "\n") { + return -1; + } + + if (ch === "(") { + depth++; + continue; + } + + if (ch === ")") { + if (depth === 0) { + return i; + } + + depth--; + } + } + + return -1; +} + +function canStartBold(source: string, index: number): boolean { + const prev = source[index - 1]; + const next = source[index + 1]; + + if (!next || /\s/.test(next)) { + return false; + } + + if (prev && /\w/.test(prev) && /\w/.test(next)) { + return false; + } + + return true; +} + +function canEndBold(source: string, index: number): boolean { + const prev = source[index - 1]; + const next = source[index + 1]; + + if (!prev || /\s/.test(prev)) { + return false; + } + + if (next && /\w/.test(prev) && /\w/.test(next)) { + return false; + } + + return true; +} + +function sanitizeCodeLanguage(value: string | undefined): string | undefined { + if (!value) return undefined; + + const lang = value.trim(); + + if (!lang) return undefined; + + // Telegram language hint after ``` can be used as a visual label too. + // Keep it permissive, but reject dangerous/newline/weird marker chars. + if (!/^[^\s`\\]{1,32}$/.test(lang)) { + return undefined; + } + + return lang; +} + +function isSafeUrl(url: string): boolean { + return /^(https?:\/\/|tg:\/\/|mailto:)/i.test(url); +} + +/** + * Applies transform only outside fenced code blocks. + */ +function transformOutsideFencedCode( + input: string, + transform: (text: string) => string, +): string { + const fences: string[] = []; + const fenceRegex = /```[^\n]*\n[\s\S]*?(?:\n```|$)/g; + + const protectedText = input.replace(fenceRegex, (match) => { + const index = fences.push(match) - 1; + return `\uE000FENCE_${index}\uE001`; + }); + + const transformed = transform(protectedText); + + return transformed.replace(/\uE000FENCE_(\d+)\uE001/g, (_, index: string) => { + return fences[Number(index)] ?? ""; + }); +} + +/** + * Converts Markdown tables into simple list rows. + * + * Example: + * | A | B | + * |---|---| + * | 1 | 2 | + * + * -> + * - A: 1; B: 2 + */ +function normalizeMarkdownTables(input: string): string { + const lines = input.split("\n"); + const output: string[] = []; + + let i = 0; + + while (i < lines.length) { + const current = lines[i]; + const next = lines[i + 1]; + + if (next && isMarkdownTableSeparator(next) && current.includes("|")) { + const headers = parseTableRow(current); + const rows: string[][] = []; + + i += 2; + + while (i < lines.length && lines[i].includes("|") && lines[i].trim()) { + rows.push(parseTableRow(lines[i])); + i++; + } + + if (rows.length === 0) { + output.push(headers.join(" / ")); + continue; + } + + for (const row of rows) { + const cells = row + .map((cell, index) => { + const header = headers[index]; + + if (!cell) return ""; + if (!header) return cell; + + return `${header}: ${cell}`; + }) + .filter(Boolean); + + output.push(`- ${cells.join("; ")}`); + } + + continue; + } + + output.push(current); + i++; + } + + return output.join("\n"); +} + +function isMarkdownTableSeparator(line: string): boolean { + const cells = parseTableRow(line); + + return ( + cells.length >= 2 && + cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim())) + ); +} + +function parseTableRow(line: string): string[] { + return line + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((cell) => cell.trim()); +} + +/** + * Optional helper for streaming/editing. + * + * You can adapt this to your own bot wrapper. + */ +export function shouldEditRenderedMessage(previous: string, next: string): boolean { + return previous !== next && next.trim().length > 0; +} \ No newline at end of file diff --git a/src/util/random-utils.ts b/src/util/random-utils.ts new file mode 100644 index 0000000..90503a4 --- /dev/null +++ b/src/util/random-utils.ts @@ -0,0 +1,14 @@ +export class RandomUtils { + static int(max: number): number { + return Math.floor(Math.random() * Math.floor(max)); + } + + static rangedInt(from: number, to: number): number { + return RandomUtils.int(to - from) + from; + } + + static value(list: readonly T[]): T | undefined { + if (!list.length) return undefined; + return list[RandomUtils.int(list.length)]; + } +} diff --git a/src/util/shell-command-runner.ts b/src/util/shell-command-runner.ts new file mode 100644 index 0000000..fec5b04 --- /dev/null +++ b/src/util/shell-command-runner.ts @@ -0,0 +1,90 @@ +import {exec} from "node:child_process"; +import {promisify} from "node:util"; + +const execAsync = promisify(exec); + +export type ShellCommandResult = { + stdout: string | null | undefined; + stderr: string | null | undefined; +}; + +export class ShellCommandRunner { + private static readonly forbiddenPatterns = [ + /\bsudo\b/, + /\bsu\b/, + /\brm\b/, + /\brmdir\b/, + /\bchmod\b/, + /\bchown\b/, + /\bdd\b/, + /\bmkfs\b/, + /\bmount\b/, + /\bumount\b/, + /\breboot\b/, + /\bshutdown\b/, + /\bkill\b/, + /\bdel\b/i, + /\berase\b/i, + /\brd\b/i, + /\bformat\b/i, + /\btaskkill\b/i, + /\bRemove-Item\b/i, + /\bMove-Item\b/i, + /\bStop-Process\b/i, + /\bRestart-Computer\b/i, + /\bStop-Computer\b/i, + /\bcurl\b/, + /\bwget\b/, + /\bInvoke-WebRequest\b/i, + /\bInvoke-RestMethod\b/i, + /\bssh\b/, + /\bscp\b/, + /\brsync\b/, + /\bnc\b/, + /\bnmap\b/, + /\.\./, + /\/etc\/?/, + /\/home\/?/, + /\/root\/?/, + /~\//, + /\.ssh/, + /\.env/, + ]; + + static async run(command: string): Promise { + ShellCommandRunner.assertSafe(command); + + try { + const {stdout, stderr} = await execAsync(command, { + timeout: 15_000, + maxBuffer: 64 * 1024, + }); + if (stdout) { + console.log("COMMAND: ", command, "\n", "Output:", stdout); + } + + if (stderr) { + console.error("COMMAND: ", command, "\n", "Error:", stderr); + } + + return {stdout, stderr}; + } catch (error: any) { + console.error("Error code:", error.code); + console.error("Stderr:", error.stderr); + + return {stdout: error.stdout ?? null, stderr: error.stderr ?? error.message}; + } + } + + private static assertSafe(command: string): void { + if (command.length > 500) { + throw new Error("Command is too long"); + } + + for (const pattern of ShellCommandRunner.forbiddenPatterns) { + if (pattern.test(command)) { + throw new Error(`Forbidden shell command pattern: ${pattern}`); + } + } + } +} diff --git a/src/util/telegram-api-queue.ts b/src/util/telegram-api-queue.ts new file mode 100644 index 0000000..dd3fa72 --- /dev/null +++ b/src/util/telegram-api-queue.ts @@ -0,0 +1,700 @@ +/** + * Conservative Telegram Bot API promise queue. + * + * Defaults intentionally prefer safety over throughput: + * - global bot limit: 30 requests / second; + * - per-chat limit: 1 request / second; + * - likely group/channel chats: 20 requests / minute; + * - edit methods: 6 requests / second. + * + * Telegram can still return 429 for dynamic flood limits. In that case the + * queue always honors `parameters.retry_after` and requeues the task. + */ + +export type TelegramChatId = number | string; + +export type TelegramChatType = string; + +export type TelegramApiTaskContext = { + attempt: number; + signal?: AbortSignal; +}; + +export type TelegramApiTask = (context: TelegramApiTaskContext) => Promise; + +export type RateLimitConfig = { + maxRequests: number; + intervalMs: number; +}; + +export type TelegramApiQueueTaskOptions = { + chatId?: TelegramChatId; + chatType?: TelegramChatType; + method?: string; + priority?: number; + maxAttempts?: number; + signal?: AbortSignal; + skipPerChatLimit?: boolean; +}; + +export type TelegramApiRetryEvent = { + taskId: number; + method?: string; + chatId?: TelegramChatId; + attempt: number; + delayMs: number; + reason: "telegram_retry_after" | "transient_error"; + error: unknown; +}; + +export type TelegramApiQueueOptions = { + globalLimit?: Partial; + perChatLimit?: Partial; + groupChatLimit?: Partial; + editLimit?: Partial; + maxConcurrent?: number; + maxAttempts?: number; + baseRetryDelayMs?: number; + maxRetryDelayMs?: number; + retryJitterMs?: number; + retryAfterSafetyMs?: number; + maxQueueSize?: number; + onRetry?: (event: TelegramApiRetryEvent) => void; +}; + +export type TelegramApiQueueStats = { + queued: number; + running: number; + closed: boolean; +}; + +type RetryDecision = { + delayMs: number; + reason: TelegramApiRetryEvent["reason"]; +}; + +type QueueEntryState = "queued" | "running" | "settled" | "cancelled"; + +type QueueEntry = { + id: number; + sequence: number; + task: TelegramApiTask; + options: TelegramApiQueueTaskOptions; + attempt: number; + notBefore: number; + state: QueueEntryState; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; + abortHandler?: () => void; +}; + +type ResolvedTelegramApiQueueOptions = { + globalLimit: RateLimitConfig; + perChatLimit: RateLimitConfig; + groupChatLimit: RateLimitConfig; + editLimit: RateLimitConfig; + maxConcurrent: number; + maxAttempts: number; + baseRetryDelayMs: number; + maxRetryDelayMs: number; + retryJitterMs: number; + retryAfterSafetyMs: number; + maxQueueSize: number; + onRetry?: (event: TelegramApiRetryEvent) => void; +}; + +const DEFAULT_OPTIONS: ResolvedTelegramApiQueueOptions = { + globalLimit: {maxRequests: 30, intervalMs: 1000}, + perChatLimit: {maxRequests: 1, intervalMs: 1000}, + groupChatLimit: {maxRequests: 20, intervalMs: 60_000}, + editLimit: {maxRequests: 6, intervalMs: 1000}, + maxConcurrent: 8, + maxAttempts: 5, + baseRetryDelayMs: 500, + maxRetryDelayMs: 30_000, + retryJitterMs: 250, + retryAfterSafetyMs: 250, + maxQueueSize: 10_000, +}; + +function mergeLimitConfig(base: RateLimitConfig, override?: Partial): RateLimitConfig { + return { + maxRequests: override?.maxRequests ?? base.maxRequests, + intervalMs: override?.intervalMs ?? base.intervalMs, + }; +} + +function resolveOptions(options: TelegramApiQueueOptions): ResolvedTelegramApiQueueOptions { + return { + globalLimit: mergeLimitConfig(DEFAULT_OPTIONS.globalLimit, options.globalLimit), + perChatLimit: mergeLimitConfig(DEFAULT_OPTIONS.perChatLimit, options.perChatLimit), + groupChatLimit: mergeLimitConfig(DEFAULT_OPTIONS.groupChatLimit, options.groupChatLimit), + editLimit: mergeLimitConfig(DEFAULT_OPTIONS.editLimit, options.editLimit), + maxConcurrent: options.maxConcurrent ?? DEFAULT_OPTIONS.maxConcurrent, + maxAttempts: options.maxAttempts ?? DEFAULT_OPTIONS.maxAttempts, + baseRetryDelayMs: options.baseRetryDelayMs ?? DEFAULT_OPTIONS.baseRetryDelayMs, + maxRetryDelayMs: options.maxRetryDelayMs ?? DEFAULT_OPTIONS.maxRetryDelayMs, + retryJitterMs: options.retryJitterMs ?? DEFAULT_OPTIONS.retryJitterMs, + retryAfterSafetyMs: options.retryAfterSafetyMs ?? DEFAULT_OPTIONS.retryAfterSafetyMs, + maxQueueSize: options.maxQueueSize ?? DEFAULT_OPTIONS.maxQueueSize, + onRetry: options.onRetry, + }; +} + +function createAbortError(): Error { + const error = new Error("Telegram API queue task aborted"); + error.name = "AbortError"; + return error; +} + +function createClosedError(): Error { + return new Error("Telegram API queue is closed"); +} + +function createQueueOverflowError(maxQueueSize: number): Error { + return new Error(`Telegram API queue overflow: maxQueueSize=${maxQueueSize}`); +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readPath(source: unknown, pathParts: readonly string[]): unknown { + let current = source; + for (const part of pathParts) { + if (!isRecord(current)) return undefined; + current = current[part]; + } + return current; +} + +function readNumber(source: unknown, paths: readonly (readonly string[])[]): number | undefined { + for (const pathParts of paths) { + const value = readPath(source, pathParts); + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + } + return undefined; +} + +function readString(source: unknown, paths: readonly (readonly string[])[]): string | undefined { + for (const pathParts of paths) { + const value = readPath(source, pathParts); + if (typeof value === "string") return value; + } + return undefined; +} + +function extractRetryAfterMs(error: unknown, safetyMs: number): number | undefined { + const retryAfterSeconds = readNumber(error, [ + ["parameters", "retry_after"], + ["response", "parameters", "retry_after"], + ["response", "body", "parameters", "retry_after"], + ["body", "parameters", "retry_after"], + ]); + + if (retryAfterSeconds === undefined) return undefined; + return Math.max(0, Math.ceil(retryAfterSeconds * 1000) + safetyMs); +} + +function extractStatusCode(error: unknown): number | undefined { + return readNumber(error, [ + ["error_code"], + ["errorCode"], + ["status"], + ["statusCode"], + ["response", "error_code"], + ["response", "status"], + ["response", "statusCode"], + ["response", "body", "error_code"], + ["body", "error_code"], + ]); +} + +function extractErrorCode(error: unknown): string | undefined { + return readString(error, [ + ["code"], + ["errno"], + ["cause", "code"], + ]); +} + +function extractErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + const message = readString(error, [ + ["message"], + ["description"], + ["response", "description"], + ["response", "body", "description"], + ["body", "description"], + ]); + return message ?? ""; +} + +function isTelegramTooManyRequests(error: unknown): boolean { + return extractStatusCode(error) === 429 || /too many requests|retry after/i.test(extractErrorMessage(error)); +} + +function isTransientError(error: unknown): boolean { + const statusCode = extractStatusCode(error); + if (statusCode !== undefined) { + if (statusCode === 408) return true; + if (statusCode >= 500 && statusCode <= 599) return true; + if (statusCode >= 400 && statusCode <= 499) return false; + } + + const code = extractErrorCode(error); + if (code && ["ETIMEDOUT", "ECONNRESET", "ECONNABORTED", "EAI_AGAIN", "ENOTFOUND", "EPIPE"].includes(code)) { + return true; + } + + return /timeout|socket hang up|network error|econnreset|econnaborted|eai_again/i.test(extractErrorMessage(error)); +} + +function isLikelyGroupChatId(chatId: TelegramChatId | undefined): boolean { + if (typeof chatId === "number") return chatId < 0; + if (typeof chatId === "string") return chatId.startsWith("-"); + return false; +} + +function isGroupLikeChat(chatType: TelegramChatType | undefined, chatId: TelegramChatId | undefined): boolean { + if (chatType === "group" || chatType === "supergroup" || chatType === "channel") return true; + if (chatType === "private") return false; + return isLikelyGroupChatId(chatId); +} + +function isEditMethod(method: string | undefined): boolean { + return !!method && method.toLowerCase().startsWith("edit"); +} + +function normalizeBucketKey(value: TelegramChatId): string { + return String(value); +} + +class SlidingWindowRateLimit { + private timestamps: number[] = []; + private pausedUntil = 0; + private lastTouched = Date.now(); + + constructor(private readonly config: RateLimitConfig) { + } + + nextDelay(now: number): number { + this.lastTouched = now; + this.prune(now); + + const pauseDelay = Math.max(0, this.pausedUntil - now); + if (pauseDelay > 0) return pauseDelay; + if (this.timestamps.length < this.config.maxRequests) return 0; + + const oldest = this.timestamps[0] ?? now; + return Math.max(0, oldest + this.config.intervalMs - now); + } + + record(now: number): void { + this.lastTouched = now; + this.prune(now); + this.timestamps.push(now); + } + + pause(delayMs: number, now: number): void { + this.lastTouched = now; + this.pausedUntil = Math.max(this.pausedUntil, now + delayMs); + } + + isIdle(now: number, idleMs: number): boolean { + this.prune(now); + return this.timestamps.length === 0 + && this.pausedUntil <= now + && now - this.lastTouched >= idleMs; + } + + private prune(now: number): void { + const minTime = now - this.config.intervalMs; + while (this.timestamps.length && (this.timestamps[0] ?? now) <= minTime) { + this.timestamps.shift(); + } + } +} + +export class TelegramApiQueue { + private readonly options: ResolvedTelegramApiQueueOptions; + private readonly globalBucket: SlidingWindowRateLimit; + private readonly editBucket: SlidingWindowRateLimit; + private readonly chatBuckets = new Map(); + private readonly groupChatBuckets = new Map(); + private readonly idleResolvers: Array<() => void> = []; + private readonly bucketIdleMs: number; + private queue: Array> = []; + private timer: NodeJS.Timeout | null = null; + private running = 0; + private nextId = 1; + private nextSequence = 1; + private closed = false; + + constructor(options: TelegramApiQueueOptions = {}) { + this.options = resolveOptions(options); + this.globalBucket = new SlidingWindowRateLimit(this.options.globalLimit); + this.editBucket = new SlidingWindowRateLimit(this.options.editLimit); + this.bucketIdleMs = Math.max(this.options.perChatLimit.intervalMs, this.options.groupChatLimit.intervalMs) * 2; + } + + get stats(): TelegramApiQueueStats { + return { + queued: this.queue.length, + running: this.running, + closed: this.closed, + }; + } + + enqueue(task: TelegramApiTask, options: TelegramApiQueueTaskOptions = {}): Promise { + if (this.closed) return Promise.reject(createClosedError()); + if (this.queue.length >= this.options.maxQueueSize) return Promise.reject(createQueueOverflowError(this.options.maxQueueSize)); + if (options.signal?.aborted) return Promise.reject(createAbortError()); + + return new Promise((resolve, reject) => { + const entry: QueueEntry = { + id: this.nextId++, + sequence: this.nextSequence++, + task, + options, + attempt: 1, + notBefore: Date.now(), + state: "queued", + resolve, + reject, + }; + + this.attachAbortHandler(entry); + + this.insertEntry(entry as QueueEntry); + this.pump(); + }); + } + + waitUntilIdle(): Promise { + if (this.queue.length === 0 && this.running === 0) return Promise.resolve(); + + return new Promise(resolve => { + this.idleResolvers.push(resolve); + }); + } + + close(reason: unknown = createClosedError()): void { + this.closed = true; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + const queued = this.queue; + this.queue = []; + for (const entry of queued) { + this.cleanupAbortHandler(entry); + entry.state = "cancelled"; + entry.reject(reason); + } + this.chatBuckets.clear(); + this.groupChatBuckets.clear(); + this.resolveIdleIfNeeded(); + } + + clear(reason: unknown = new Error("Telegram API queue was cleared")): void { + const queued = this.queue; + this.queue = []; + for (const entry of queued) { + this.cleanupAbortHandler(entry); + entry.state = "cancelled"; + entry.reject(reason); + } + this.resolveIdleIfNeeded(); + } + + private insertEntry(entry: QueueEntry): void { + this.queue.push(entry); + this.queue.sort((left, right) => { + const priorityDiff = (right.options.priority ?? 0) - (left.options.priority ?? 0); + return priorityDiff || left.sequence - right.sequence; + }); + } + + private abortQueuedEntry(taskId: number): void { + const index = this.queue.findIndex(entry => entry.id === taskId); + if (index < 0) return; + + const entry = this.queue.splice(index, 1)[0]; + if (!entry) return; + + this.cleanupAbortHandler(entry); + entry.state = "cancelled"; + entry.reject(createAbortError()); + this.resolveIdleIfNeeded(); + } + + private pump(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + if (this.closed) return; + this.cleanupIdleBuckets(); + + while (this.running < this.options.maxConcurrent) { + const selection = this.selectNextEntry(Date.now()); + if (!selection) { + this.resolveIdleIfNeeded(); + return; + } + + if (selection.delayMs > 0) { + this.schedule(selection.delayMs); + return; + } + + const entry = this.queue.splice(selection.index, 1)[0]; + if (!entry) continue; + this.startEntry(entry); + } + } + + private selectNextEntry(now: number): { index: number; delayMs: number } | null { + let bestBlockedIndex = -1; + let bestBlockedDelay = Number.POSITIVE_INFINITY; + + for (let index = 0; index < this.queue.length; index++) { + const entry = this.queue[index]; + if (!entry) continue; + + if (entry.options.signal?.aborted) { + this.abortQueuedEntry(entry.id); + index--; + continue; + } + + const delayMs = this.nextDelayFor(entry, now); + if (delayMs === 0) return {index, delayMs}; + if (delayMs < bestBlockedDelay) { + bestBlockedDelay = delayMs; + bestBlockedIndex = index; + } + } + + if (bestBlockedIndex < 0) return null; + return {index: bestBlockedIndex, delayMs: bestBlockedDelay}; + } + + private startEntry(entry: QueueEntry): void { + entry.state = "running"; + this.cleanupAbortHandler(entry); + this.recordStart(entry, Date.now()); + this.running++; + void this.runEntry(entry); + } + + private async runEntry(entry: QueueEntry): Promise { + try { + if (entry.options.signal?.aborted) throw createAbortError(); + + const result = await entry.task({ + attempt: entry.attempt, + signal: entry.options.signal, + }); + entry.state = "settled"; + entry.resolve(result); + } catch (error) { + const retry = this.getRetryDecision(error, entry); + if (retry && !this.closed) { + this.applyRetryPause(entry, retry); + entry.attempt++; + entry.notBefore = Date.now() + retry.delayMs; + entry.state = "queued"; + if (entry.options.signal?.aborted) { + entry.state = "cancelled"; + entry.reject(createAbortError()); + } else { + this.attachAbortHandler(entry); + this.insertEntry(entry); + this.options.onRetry?.({ + taskId: entry.id, + method: entry.options.method, + chatId: entry.options.chatId, + attempt: entry.attempt - 1, + delayMs: retry.delayMs, + reason: retry.reason, + error, + }); + } + } else { + entry.state = "settled"; + entry.reject(this.closed ? createClosedError() : error); + } + } finally { + this.running--; + this.pump(); + } + } + + private nextDelayFor(entry: QueueEntry, now: number): number { + const explicitDelay = Math.max(0, entry.notBefore - now); + const bucketDelay = this.bucketsFor(entry).reduce((maxDelay, bucket) => { + return Math.max(maxDelay, bucket.nextDelay(now)); + }, 0); + + return Math.max(explicitDelay, bucketDelay); + } + + private recordStart(entry: QueueEntry, now: number): void { + for (const bucket of this.bucketsFor(entry)) { + bucket.record(now); + } + } + + private bucketsFor(entry: QueueEntry): SlidingWindowRateLimit[] { + const buckets = [this.globalBucket]; + const chatId = entry.options.chatId; + + if (chatId !== undefined && !entry.options.skipPerChatLimit) { + buckets.push(this.getChatBucket(chatId)); + if (isGroupLikeChat(entry.options.chatType, chatId)) { + buckets.push(this.getGroupChatBucket(chatId)); + } + } + + if (isEditMethod(entry.options.method)) { + buckets.push(this.editBucket); + } + + return buckets; + } + + private getChatBucket(chatId: TelegramChatId): SlidingWindowRateLimit { + const key = normalizeBucketKey(chatId); + let bucket = this.chatBuckets.get(key); + if (!bucket) { + bucket = new SlidingWindowRateLimit(this.options.perChatLimit); + this.chatBuckets.set(key, bucket); + } + return bucket; + } + + private getGroupChatBucket(chatId: TelegramChatId): SlidingWindowRateLimit { + const key = normalizeBucketKey(chatId); + let bucket = this.groupChatBuckets.get(key); + if (!bucket) { + bucket = new SlidingWindowRateLimit(this.options.groupChatLimit); + this.groupChatBuckets.set(key, bucket); + } + return bucket; + } + + private getRetryDecision(error: unknown, entry: QueueEntry): RetryDecision | null { + if (entry.options.signal?.aborted) return null; + + const maxAttempts = entry.options.maxAttempts ?? this.options.maxAttempts; + if (entry.attempt >= maxAttempts) return null; + + const retryAfterMs = extractRetryAfterMs(error, this.options.retryAfterSafetyMs); + if (retryAfterMs !== undefined || isTelegramTooManyRequests(error)) { + return { + delayMs: retryAfterMs ?? this.backoffDelay(entry.attempt), + reason: "telegram_retry_after", + }; + } + + if (!isTransientError(error)) return null; + + return { + delayMs: this.backoffDelay(entry.attempt), + reason: "transient_error", + }; + } + + private backoffDelay(attempt: number): number { + const exponential = this.options.baseRetryDelayMs * (2 ** Math.max(0, attempt - 1)); + const capped = Math.min(this.options.maxRetryDelayMs, exponential); + const jitter = this.options.retryJitterMs > 0 ? Math.floor(Math.random() * this.options.retryJitterMs) : 0; + return capped + jitter; + } + + private applyRetryPause(entry: QueueEntry, retry: RetryDecision): void { + if (retry.reason !== "telegram_retry_after") return; + + const now = Date.now(); + for (const bucket of this.bucketsFor(entry)) { + bucket.pause(retry.delayMs, now); + } + } + + private schedule(delayMs: number): void { + const safeDelay = Math.max(0, Math.min(delayMs, 2_147_483_647)); + this.timer = setTimeout(() => { + this.timer = null; + this.pump(); + }, safeDelay); + } + + private attachAbortHandler(entry: QueueEntry): void { + if (!entry.options.signal || entry.abortHandler) return; + entry.abortHandler = () => this.abortQueuedEntry(entry.id); + entry.options.signal.addEventListener("abort", entry.abortHandler, {once: true}); + } + + private cleanupAbortHandler(entry: QueueEntry): void { + if (!entry.abortHandler) return; + entry.options.signal?.removeEventListener("abort", entry.abortHandler); + entry.abortHandler = undefined; + } + + private resolveIdleIfNeeded(): void { + if (this.queue.length !== 0 || this.running !== 0) return; + + this.cleanupIdleBuckets(); + const resolvers = this.idleResolvers.splice(0); + for (const resolve of resolvers) { + resolve(); + } + } + + private cleanupIdleBuckets(now = Date.now()): void { + for (const [key, bucket] of this.chatBuckets) { + if (bucket.isIdle(now, this.bucketIdleMs)) { + this.chatBuckets.delete(key); + } + } + + for (const [key, bucket] of this.groupChatBuckets) { + if (bucket.isIdle(now, this.bucketIdleMs)) { + this.groupChatBuckets.delete(key); + } + } + } +} + +export const telegramApiQueue = new TelegramApiQueue(); + +export async function enqueueTelegramApi( + task: TelegramApiTask, + options?: TelegramApiQueueTaskOptions +): Promise { + return telegramApiQueue.enqueue(task, options); +} + +export async function enqueueTelegramApiCall( + task: () => Promise, + options?: TelegramApiQueueTaskOptions +): Promise { + return telegramApiQueue.enqueue(() => task(), options); +} + +export async function sleepForTelegramRetry(ms: number): Promise { + await delay(ms); +} diff --git a/src/util/utils.ts b/src/util/utils.ts index a72b56f..9ac85cc 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -1,6 +1,5 @@ import * as si from "systeminformation"; import {Command} from "../base/command"; -import ffmpeg from "fluent-ffmpeg"; import {CallbackCommand} from "../base/callback-command"; import { CallbackQuery, @@ -17,37 +16,38 @@ import { } from "typescript-telegram-bot-api"; import {Environment} from "../common/environment"; import {TelegramError} from "typescript-telegram-bot-api/dist/errors"; -import {bot, botUser, callbackCommands, commands, messageDao, ollama, photoDir} from "../index"; +import {bot, botUser, callbackCommands, commands, messageDao, photoDir} from "../index"; import os from "os"; import axios from "axios"; -import {MessagePart} from "../common/message-part"; +import {MessageAudioPart, MessageImagePart, MessagePart} from "../common/message-part"; import {StoredMessage} from "../model/stored-message"; import sharp from "sharp"; import {UserStore} from "../common/user-store"; -import * as orm from "drizzle-orm"; -import {sql, type SQL} from "drizzle-orm"; +import {getTableColumns, sql, type SQL} from "drizzle-orm"; import fs from "node:fs"; import path from "node:path"; import {MessageStore} from "../common/message-store"; import {SystemInfo} from "../commands/system-info"; import {PrefixResponse} from "../commands/prefix-response"; -import {OllamaChat} from "../commands/ollama-chat"; import {ChatCommand} from "../base/chat-command"; -import {WebSearchResponse} from "../model/web-search-response"; -import {GeminiChat} from "../commands/gemini-chat"; -import {MistralChat} from "../commands/mistral-chat"; -import {OpenAIChat} from "../commands/openai-chat"; import {AiProvider} from "../model/ai-provider"; -import {AiModelCapabilities} from "../model/ai-model-capabilities"; -import {OllamaGetModel} from "../commands/ollama-get-model"; -import {GeminiGetModel} from "../commands/gemini-get-model"; -import {MistralGetModel} from "../commands/mistral-get-model"; -import {OpenAIGetModel} from "../commands/openai-get-model"; import {SendOptions} from "../model/send-options"; import {EditOptions} from "../model/edit-options"; import {StoredUser} from "../model/stored-user"; -import {performFFmpeg} from "./ffmpeg"; -import {exec} from "node:child_process"; +import {StoredAttachment} from "../model/stored-attachment"; +import {AiDownloadedFile, cacheMessageAttachments} from "../ai/telegram-attachments"; +import {runUnifiedAi} from "../ai/unified-ai-runner"; +import {enqueueTelegramApiCall} from "./telegram-api-queue"; +import {AsyncSemaphore, KeyedAsyncLock} from "./async-lock"; +import {resolveEffectiveAiProviderForUser, resolveInterfaceLocaleForUser} from "../common/user-ai-settings"; +import {Localization} from "../common/localization"; +import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target"; +import {RandomUtils} from "./random-utils"; +import {HtmlUtils} from "./html-utils"; +import {ShellCommandResult, ShellCommandRunner} from "./shell-command-runner"; + +const imageProcessingSemaphore = new AsyncSemaphore(2); +const fileWriteLocks = new KeyedAsyncLock(); export const ignore = () => { }; @@ -134,7 +134,7 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m const cbId = cb?.id; const chatId = msg?.chat?.id || cb?.message?.chat?.id || -1; - const messageId = msg?.message_id || (cb && cb.message && "reply_to_message" in cb.message ? cb.message.reply_to_message?.message_id : null) || -1; + const messageId = msg?.message_id || cb?.message?.message_id || -1; const fromId = msg?.from?.id || cb?.from?.id || -1; const chatType = msg?.chat?.type || cb?.message?.chat?.type || null; @@ -157,30 +157,33 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m if (msg) { await replyToMessage({chat_id: chatId, message_id: messageId, text: text}); } else if (cb) { - await bot.answerCallbackQuery({ - callback_query_id: cbId || "", - text: text, - cache_time: 0, - show_alert: true - }).catch(logError); + await enqueueTelegramApiCall( + () => bot.answerCallbackQuery({ + callback_query_id: cbId || "", + text: text, + cache_time: 0, + show_alert: true + }), + {method: "answerCallbackQuery", skipPerChatLimit: true} + ).catch(logError); } }; if (reqs.isRequiresBotCreator() && fromId !== Environment.CREATOR_ID) { console.log(`${title}: creatorId is bad`); - await notifyUser("Вы не являетесь создателем бота."); + await notifyUser(Environment.notBotCreatorText); return false; } if (reqs.isRequiresBotAdmin() && !Environment.ADMIN_IDS.has(fromId)) { console.log(`${title}: adminId is bad`); - await notifyUser("Вы не являетесь администратором бота."); + await notifyUser(Environment.notBotAdministratorText); return false; } if (reqs.isRequiresChat() && msg?.chat?.type === "private") { console.log(`${title}: chatId is bad`); - await notifyUser("Тут Вам не чат."); + await notifyUser(Environment.notAChatText); return false; } @@ -189,7 +192,7 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m if (!isMemberAdmin(member)) { console.log(`${title}: chatAdminId is bad`); - await notifyUser("Вы не являетесь администратором чата."); + await notifyUser(Environment.notChatAdministratorText); return false; } } @@ -199,30 +202,46 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m if (!isMemberAdmin(member)) { console.log(`${title}: botChatAdminId is bad`); - await notifyUser("Бот не является администратором чата."); + await notifyUser(Environment.botNotChatAdministratorText); return false; } } if (reqs.isRequiresReply() && !msg?.reply_to_message) { console.log(`${title}: replyMessage is bad`); - await notifyUser("Отсутствует ответ на сообщение."); + await notifyUser(Environment.replyRequiredText); return false; } if (reqs.isRequiresSameUser()) { let originalFromId: number | undefined; try { - const originalMessage = await MessageStore.get(chatId, messageId); - originalFromId = originalMessage?.fromId; + if (cb?.message) { + const replyMessage = "reply_to_message" in cb.message ? cb.message.reply_to_message : undefined; + originalFromId = replyMessage?.from?.id; + + if (!originalFromId && replyMessage?.message_id) { + const originalMessage = await MessageStore.get(chatId, replyMessage.message_id); + originalFromId = originalMessage?.fromId; + } + + if (!originalFromId) { + const callbackMessage = await MessageStore.get(chatId, cb.message.message_id); + const originalMessage = await MessageStore.get(chatId, callbackMessage?.replyToMessageId); + originalFromId = originalMessage?.fromId; + } + } else { + const originalMessage = await MessageStore.get(chatId, messageId); + originalFromId = originalMessage?.fromId; + } } catch (e) { logError(e); originalFromId = undefined; } - if (originalFromId && fromId !== originalFromId && fromId !== Environment.CREATOR_ID) { + if (!originalFromId || (fromId !== originalFromId && fromId !== Environment.CREATOR_ID)) { console.log(`${title}: sameUser is bad`); - await notifyUser("Только автор оригинального сообщения может выполнить это действие."); + await notifyUser(Environment.onlyOriginalAuthorText); return false; } } @@ -264,35 +283,42 @@ export async function oldEditMessageText(chatId: number, messageId: number, mess }); } -export async function editMessageText(options: EditOptions) { +export async function editMessageText(options: EditOptions, retries = 1) { if (options.text.trim().length === 0) return Promise.resolve(false); try { - const message = await bot.editMessageText({ - chat_id: "message" in options ? options.message.chat.id : options.chat_id, - message_id: "message" in options ? options.message.message_id : options.message_id, - text: options.text, - parse_mode: options.parse_mode, - reply_markup: options.reply_markup, - link_preview_options: options.link_preview_options, - }); + const chatId = "message" in options ? options.message.chat.id : options.chat_id; + const chatType = "message" in options ? options.message.chat.type : undefined; + const messageId = "message" in options ? options.message.message_id : options.message_id; + const message = await enqueueTelegramApiCall( + () => bot.editMessageText({ + chat_id: chatId, + message_id: messageId, + text: options.text, + parse_mode: options.parse_mode, + reply_markup: options.reply_markup, + link_preview_options: options.link_preview_options, + }), + { + method: "editMessageText", + chatId, + chatType, + } + ); return Promise.resolve(message); } catch (e: any) { logError(e); if (isMarkupFailed(e)) { return Promise.resolve(true); - } else if (isTooManyRequests(e)) { - const delay = Number(e.message.split("retry after ")[1]) || 30; - setTimeout(() => { - return Promise.resolve(); - }, delay * 1000); + } else if (isTooManyRequests(e) && retries > 0) { + const retryAfter = Number(e.message.split("retry after ")[1]) || 30; + await delay(retryAfter * 1000); + return editMessageText(options, retries - 1); } else { return Promise.reject(e); } } - - return Promise.resolve(false); } export async function oldSendMessage(message: Message, text: string, parseMode?: ParseMode): Promise { @@ -304,13 +330,22 @@ export async function oldSendMessage(message: Message, text: string, parseMode?: } export async function sendMessage(options: SendOptions): Promise { - const response = await bot.sendMessage({ - chat_id: "message" in options ? options.message.chat.id : options.chat_id, - text: options.text, - parse_mode: options.parse_mode, - link_preview_options: options.link_preview_options, - reply_markup: options.reply_markup, - }); + const chatId = "message" in options ? options.message.chat.id : options.chat_id; + const chatType = "message" in options ? options.message.chat.type : undefined; + const response = await enqueueTelegramApiCall( + () => bot.sendMessage({ + chat_id: chatId, + text: options.text, + parse_mode: options.parse_mode, + link_preview_options: options.link_preview_options, + reply_markup: options.reply_markup, + }), + { + method: "sendMessage", + chatId, + chatType, + } + ); await MessageStore.put(response); @@ -330,15 +365,25 @@ export async function replyToMessage(options: SendOptions): Promise { return Promise.reject("for reply there must be message or message_id"); } - const response = await bot.sendMessage({ - chat_id: "message" in options ? options.message.chat.id : options.chat_id, - text: options.text, - parse_mode: options.parse_mode, - reply_parameters: { - message_id: ("message" in options ? options.message.message_id : options.message_id) - }, - link_preview_options: options.link_preview_options - }); + const chatId = "message" in options ? options.message.chat.id : options.chat_id; + const chatType = "message" in options ? options.message.chat.type : undefined; + const response = await enqueueTelegramApiCall( + () => bot.sendMessage({ + chat_id: chatId, + text: options.text, + parse_mode: options.parse_mode, + reply_parameters: { + message_id: ("message" in options ? options.message.message_id : options.message_id) + }, + link_preview_options: options.link_preview_options, + reply_markup: options.reply_markup, + }), + { + method: "sendMessage", + chatId, + chatType, + } + ); await MessageStore.put(response); @@ -346,7 +391,7 @@ export async function replyToMessage(options: SendOptions): Promise { } export async function sendErrorPlaceholder(message: Message): Promise { - return await sendMessage({message: message, text: "Произошла ошибка ⚠️"}).catch(logError) as Message; + return await sendMessage({message: message, text: Environment.getErrorText()}).catch(logError) as Message; } export async function initSystemSpecs(): Promise { @@ -356,14 +401,13 @@ export async function initSystemSpecs(): Promise { const ramSize = (mem.total / 1024 / 1024 / 1024).toFixed(2); - const text = - `OS: ${os.distro}\n` + - `RUNTIME: ${run.runtime} ${run.version}\n` + - `DOCKER: ${Environment.IS_DOCKER}\n` + - `CPU: ${cpu.manufacturer} ${cpu.brand} ${cpu.physicalCores} cores ${cpu.cores} threads\n` + - `RAM: ${ramSize} GB`; - - SystemInfo.setSystemInfo(text); + SystemInfo.setSystemInfo({ + os: os.distro, + runtime: `${run.runtime} ${run.version}`, + docker: Environment.IS_DOCKER, + cpu: `${cpu.manufacturer} ${cpu.brand} ${cpu.physicalCores} ${Environment.systemInfoCpuCoresText} ${cpu.cores} ${Environment.systemInfoCpuThreadsText}`, + ramGb: ramSize, + }); return Promise.resolve(); } catch (e) { return Promise.reject(e); @@ -371,27 +415,41 @@ export async function initSystemSpecs(): Promise { } export function getRandomInt(max: number) { - return Math.floor(Math.random() * Math.floor(max)); + return RandomUtils.int(max); } export function getRangedRandomInt(from: number, to: number): number { - return getRandomInt(to - from) + from; + return RandomUtils.rangedInt(from, to); } -export function randomValue(list: T[]): T { - return list[Math.floor(Math.random() * list.length)]; +export function randomValue(list: readonly T[]): T | undefined { + return RandomUtils.value(list); } export function chatCommandToString(cmd: Command): string { - if (!cmd.title && !cmd.description) { + const description = getLocalizedCommandDescription(cmd); + + if (!cmd.title && !description) { return ""; } - if (cmd.title && cmd.description) { - return `${cmd.title}: ${cmd.description}`; + if (cmd.title && description) { + return `${cmd.title}: ${description}`; } - return `${cmd.title ? `${cmd.title}: ` : ""}${cmd.description ? `${cmd.description}` : ""}`; + return `${cmd.title ? `${cmd.title}: ` : ""}${description ? `${description}` : ""}`; +} + +function getLocalizedCommandDescription(cmd: Command): string | undefined { + if (!cmd.title) return cmd.description; + + const entry = Object.entries(Environment.commandTitles) + .find(([, title]) => title === cmd.title); + + if (!entry) return cmd.description; + + const [key] = entry as [keyof typeof Environment.commandDescriptions, string]; + return Environment.commandDescriptions[key] ?? cmd.description; } export function fullName(from: User | StoredUser): string { @@ -418,10 +476,10 @@ export function getUptime(): string { const processMinutes = Math.floor((processUptime % 3600) / 60); const processSeconds = Math.floor(processUptime % 60); - const processUptimeText = `${processDays > 0 ? `${processDays} д. ` : ""}` + - `${processHours > 0 ? `${processHours} ч. ` : ""}` + - `${processMinutes > 0 ? `${processMinutes} м. ` : ""}` + - `${processSeconds > 0 ? `${processSeconds} с.` : ""}`; + const processUptimeText = `${processDays > 0 ? `${processDays} d ` : ""}` + + `${processHours > 0 ? `${processHours} h ` : ""}` + + `${processMinutes > 0 ? `${processMinutes} m ` : ""}` + + `${processSeconds > 0 ? `${processSeconds} s` : ""}`; const osUptime = Math.ceil(os.uptime()); @@ -430,12 +488,12 @@ export function getUptime(): string { const osMinutes = Math.floor((osUptime % 3600) / 60); const osSeconds = Math.floor(osUptime % 60); - const osUptimeText = `${osDays > 0 ? `${osDays} д. ` : ""}` + - `${osHours > 0 ? `${osHours} ч. ` : ""}` + - `${osMinutes > 0 ? `${osMinutes} м. ` : ""}` + - `${osSeconds > 0 ? `${osSeconds} с.` : ""}`; + const osUptimeText = `${osDays > 0 ? `${osDays} d ` : ""}` + + `${osHours > 0 ? `${osHours} h ` : ""}` + + `${osMinutes > 0 ? `${osMinutes} m ` : ""}` + + `${osSeconds > 0 ? `${osSeconds} s` : ""}`; - return `${Environment.IS_DOCKER ? "Docker контейнер" : "Процесс"}:\n${processUptimeText}\n\nСистема:\n${osUptimeText}`; + return Environment.getUptimeText(processUptimeText, osUptimeText); } export const delay = (ms: number, signal?: AbortSignal): Promise => @@ -445,11 +503,25 @@ export const delay = (ms: number, signal?: AbortSignal): Promise => return; } - const id = setTimeout(resolve, ms); + let onAbort: (() => void) | undefined; + let id: NodeJS.Timeout; + + const cleanup = () => { + clearTimeout(id); + if (onAbort) { + signal?.removeEventListener("abort", onAbort); + onAbort = undefined; + } + }; + + id = setTimeout(() => { + cleanup(); + resolve(); + }, ms); if (signal) { - const onAbort = () => { - clearTimeout(id); + onAbort = () => { + cleanup(); reject(new DOMException("Aborted", "AbortError")); }; signal.addEventListener("abort", onAbort, {once: true}); @@ -458,67 +530,67 @@ export const delay = (ms: number, signal?: AbortSignal): Promise => const MARKDOWN_V2_RESERVED_RE = /([\\_*\[\]()~`>#+\-=|{}.!])/g; -const TOKEN_PREFIX = "\uE000TG_MD_V2_"; -const TOKEN_SUFFIX = "\uE001"; -const TOKEN_RE = /\uE000TG_MD_V2_(\d+)\uE001/g; +// const TOKEN_PREFIX = "\uE000TG_MD_V2_"; +// const TOKEN_SUFFIX = "\uE001"; +// const TOKEN_RE = /\uE000TG_MD_V2_(\d+)\uE001/g; -type TokenHit = { - key: string; - end: number; -}; +// type TokenHit = { +// key: string; +// end: number; +// }; -type InlineStyleKind = - | "bold" - | "italic" - | "underline" - | "strikethrough" - | "spoiler"; +// type InlineStyleKind = +// | "bold" +// | "italic" +// | "underline" +// | "strikethrough" +// | "spoiler"; -type InlineStyle = { - inputDelimiter: string; - outputDelimiter: string; - kind: InlineStyleKind; -}; +// type InlineStyle = { +// inputDelimiter: string; +// outputDelimiter: string; +// kind: InlineStyleKind; +// }; -class TelegramMarkdownV2TokenStore { - private readonly tokens: string[] = []; - - add(value: string): string { - const key = `${TOKEN_PREFIX}${this.tokens.length}${TOKEN_SUFFIX}`; - this.tokens.push(value); - return key; - } - - readAt(s: string, index: number): TokenHit | null { - if (!s.startsWith(TOKEN_PREFIX, index)) { - return null; - } - - const idStart = index + TOKEN_PREFIX.length; - const idEnd = s.indexOf(TOKEN_SUFFIX, idStart); - - if (idEnd === -1) { - return null; - } - - const rawId = s.slice(idStart, idEnd); - - if (!/^\d+$/.test(rawId)) { - return null; - } - - return { - key: s.slice(index, idEnd + TOKEN_SUFFIX.length), - end: idEnd + TOKEN_SUFFIX.length, - }; - } - - restore(s: string): string { - return s.replace(TOKEN_RE, (match, rawId) => { - return this.tokens[Number(rawId)] ?? match; - }); - } -} +// class TelegramMarkdownV2TokenStore { +// private readonly tokens: string[] = []; +// +// add(value: string): string { +// const key = `${TOKEN_PREFIX}${this.tokens.length}${TOKEN_SUFFIX}`; +// this.tokens.push(value); +// return key; +// } +// +// readAt(s: string, index: number): TokenHit | null { +// if (!s.startsWith(TOKEN_PREFIX, index)) { +// return null; +// } +// +// const idStart = index + TOKEN_PREFIX.length; +// const idEnd = s.indexOf(TOKEN_SUFFIX, idStart); +// +// if (idEnd === -1) { +// return null; +// } +// +// const rawId = s.slice(idStart, idEnd); +// +// if (!/^\d+$/.test(rawId)) { +// return null; +// } +// +// return { +// key: s.slice(index, idEnd + TOKEN_SUFFIX.length), +// end: idEnd + TOKEN_SUFFIX.length, +// }; +// } +// +// restore(s: string): string { +// return s.replace(TOKEN_RE, (match, rawId) => { +// return this.tokens[Number(rawId)] ?? match; +// }); +// } +// } export function escapePlainMarkdownV2(s: string): string { return s.replace(MARKDOWN_V2_RESERVED_RE, "\\$1"); @@ -528,619 +600,617 @@ export function escapeCodeMarkdownV2(s: string): string { return s.replace(/[\\`]/g, "\\$&"); } +export function buildCancelledGenerationText(baseText: string, provider: string, limit: number = 4096): string { + const cancellationBlock = `\`\`\`${Environment.getCancelledText(provider)}\n\`\`\``; + const separator = "\n\n"; + const trimmedBase = baseText.trim(); + + // Return regular Markdown, not already escaped MarkdownV2. + // Final escaping must happen exactly once right before sending to Telegram. + if (!trimmedBase.length) { + return cancellationBlock; + } + + const fullText = `${trimmedBase}${separator}${cancellationBlock}`; + if (fullText.length <= limit) { + return fullText; + } + + const maxBaseLength = Math.max(0, limit - cancellationBlock.length - separator.length - 3); + const truncatedBase = trimmedBase.slice(0, maxBaseLength).trimEnd(); + + return `${truncatedBase}...${separator}${cancellationBlock}`; +} + + export function escapeLinkUrlMarkdownV2(s: string): string { return s.replace(/[\\)]/g, "\\$&"); } -function normalizeLineEndings(s: string): string { - return s.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); -} - -function stripOneOuterNewline(s: string): string { - return s.replace(/^\n/, "").replace(/\n$/, ""); -} - -function normalizeCodeLanguage(lang: string | undefined): string { - const trimmed = lang?.trim() ?? ""; - return /^[a-zA-Z0-9_-]+$/.test(trimmed) ? trimmed : ""; -} - -function renderCodeBlockMarkdownV2(code: string, lang?: string): string { - const safeLang = normalizeCodeLanguage(lang); - const safeCode = escapeCodeMarkdownV2(stripOneOuterNewline(code)); - return "```" + safeLang + "\n" + safeCode + "\n```"; -} - -function renderInlineCodeMarkdownV2(code: string): string { - return "`" + escapeCodeMarkdownV2(code) + "`"; -} - -function protectFencedCodeBlocks( - s: string, - store: TelegramMarkdownV2TokenStore, -): string { - return s.replace(/```([a-zA-Z0-9_-]*)[^\S\n]*\n?([\s\S]*?)```/g, (_full, lang: string, code: string) => { - return store.add(renderCodeBlockMarkdownV2(code, lang)); - }); -} - -function findClosingSquareBracket(s: string, from: number): number { - for (let i = from; i < s.length; i++) { - if (s[i] === "\\") { - i++; - continue; - } - - if (s[i] === "\n") { - return -1; - } - - if (s[i] === "]") { - return i; - } - } - - return -1; -} - -function findClosingParen(s: string, from: number): number { - let depth = 1; - - for (let i = from; i < s.length; i++) { - const ch = s[i]; - - if (ch === "\\") { - i++; - continue; - } - - if (ch === "\n") { - return -1; - } - - if (ch === "(") { - depth++; - continue; - } - - if (ch === ")") { - depth--; - - if (depth === 0) { - return i; - } - } - } - - return -1; -} - -function parseBracketParen( - s: string, - openBracketIndex: number, -): { label: string; url: string; end: number } | null { - if (s[openBracketIndex] !== "[") { - return null; - } - - const closeBracket = findClosingSquareBracket(s, openBracketIndex + 1); - - if (closeBracket === -1 || s[closeBracket + 1] !== "(") { - return null; - } - - const closeParen = findClosingParen(s, closeBracket + 2); - - if (closeParen === -1) { - return null; - } - - return { - label: s.slice(openBracketIndex + 1, closeBracket), - url: s.slice(closeBracket + 2, closeParen), - end: closeParen + 1, - }; -} - -function unescapeMarkdownLabel(s: string): string { - return s.replace(/\\([\\\[\]])/g, "$1"); -} - -function unescapeMarkdownUrl(s: string): string { - return s.replace(/\\([\\)])/g, "$1"); -} - -function parseQueryParam(query: string, key: string): string | undefined { - for (const part of query.split("&")) { - const eq = part.indexOf("="); - - if (eq === -1) { - if (part === key) { - return ""; - } - - continue; - } - - const paramKey = part.slice(0, eq); - const paramValue = part.slice(eq + 1); - - if (paramKey === key) { - return paramValue; - } - } - - return undefined; -} - -export function isValidTelegramDateTimeFormat(format: string): boolean { - return /^(?:r|w?[dD]?[tT]?)$/.test(format); -} - -function isValidTelegramTimeUrl(url: string): boolean { - const match = /^tg:\/\/time\?(.+)$/i.exec(url.trim()); - - if (!match) { - return false; - } - - const query = match[1]; - const unix = parseQueryParam(query, "unix"); - const format = parseQueryParam(query, "format"); - - if (!unix || !/^-?\d+$/.test(unix)) { - return false; - } - - return format === undefined || isValidTelegramDateTimeFormat(format); -} - -function isValidTelegramEmojiUrl(url: string): boolean { - return /^tg:\/\/emoji\?id=\d+$/i.test(url.trim()); -} - -function isTelegramSpecialEntityUrl(url: string): boolean { - return isValidTelegramEmojiUrl(url) || isValidTelegramTimeUrl(url); -} - -function renderTelegramSpecialEntityMarkdownV2(label: string, url: string): string { - return `![${escapePlainMarkdownV2(label)}](${escapeLinkUrlMarkdownV2(url)})`; -} - -function renderInlineLinkMarkdownV2(label: string, url: string): string { - const safeLabel = label.trim().length > 0 ? label : url; - return `[${escapePlainMarkdownV2(safeLabel)}](${escapeLinkUrlMarkdownV2(url)})`; -} - -function findInlineCodeEnd(s: string, from: number): number { - for (let i = from; i < s.length; i++) { - if (s[i] === "\n") { - return -1; - } - - if (s[i] === "`") { - return i; - } - } - - return -1; -} - -function protectInlineEntities( - s: string, - store: TelegramMarkdownV2TokenStore, -): string { - let result = ""; - let i = 0; - - while (i < s.length) { - const token = store.readAt(s, i); - - if (token) { - result += token.key; - i = token.end; - continue; - } - - if (s.startsWith("![", i)) { - const parsed = parseBracketParen(s, i + 1); - - if (parsed) { - const label = unescapeMarkdownLabel(parsed.label); - const url = unescapeMarkdownUrl(parsed.url.trim()); - - if (isTelegramSpecialEntityUrl(url)) { - result += store.add(renderTelegramSpecialEntityMarkdownV2(label, url)); - } else { - result += label.trim().length > 0 ? `${label}: ${url}` : url; - } - - i = parsed.end; - continue; - } - } - - if (s[i] === "[") { - const parsed = parseBracketParen(s, i); - - if (parsed) { - const label = unescapeMarkdownLabel(parsed.label); - const url = unescapeMarkdownUrl(parsed.url.trim()); - - if (url.length > 0) { - result += store.add(renderInlineLinkMarkdownV2(label, url)); - i = parsed.end; - continue; - } - } - } - - if (s[i] === "`") { - const end = findInlineCodeEnd(s, i + 1); - - if (end !== -1) { - result += store.add(renderInlineCodeMarkdownV2(s.slice(i + 1, end))); - i = end + 1; - continue; - } - } - - result += s[i]; - i++; - } - - return result; -} - -function isMarkdownTableSeparator(line: string): boolean { - return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line); -} - -function looksLikeMarkdownTableRow(line: string): boolean { - const trimmed = line.trim(); - - if (!trimmed.includes("|")) { - return false; - } - - return !(trimmed.startsWith("||") && trimmed.endsWith("||")); -} - -function splitMarkdownTableRow(line: string): string[] { - const normalized = line.trim().replace(/^\|/, "").replace(/\|$/, ""); - const cells: string[] = []; - let current = ""; - - for (let i = 0; i < normalized.length; i++) { - const ch = normalized[i]; - - if (ch === "\\") { - current += ch; - - if (i + 1 < normalized.length) { - current += normalized[i + 1]; - i++; - } - - continue; - } - - if (ch === "|") { - cells.push(current.trim()); - current = ""; - continue; - } - - current += ch; - } - - cells.push(current.trim()); - return cells.filter(Boolean); -} - -function normalizeMarkdownTables(s: string): string { - const lines = s.split("\n"); - const result: string[] = []; - let i = 0; - - while (i < lines.length) { - const current = lines[i]; - const next = lines[i + 1]; - - if ( - next !== undefined && - looksLikeMarkdownTableRow(current) && - isMarkdownTableSeparator(next) - ) { - const tableRows = [current]; - i += 2; - - while ( - i < lines.length && - looksLikeMarkdownTableRow(lines[i]) && - !isMarkdownTableSeparator(lines[i]) - ) { - tableRows.push(lines[i]); - i++; - } - - for (const row of tableRows) { - const cells = splitMarkdownTableRow(row); - - if (cells.length > 0) { - result.push(cells.join(" — ")); - } - } - - continue; - } - - result.push(current); - i++; - } - - return result.join("\n"); -} - -function normalizeUnsupportedMarkdownLine(line: string): string { - const headingMatch = /^\s*#{1,6}\s+(.+?)\s*#*\s*$/.exec(line); - - if (headingMatch) { - return `*${headingMatch[1].trim()}*`; - } - - if (/^\s*([-*_])(?:\s*\1){2,}\s*$/.test(line)) { - return "— — —"; - } - - line = line.replace(/^(\s*)[-*+]\s+\[\s]\s+(?=\S)/i, "$1☐ "); - line = line.replace(/^(\s*)[-*+]\s+\[[xX]]\s+(?=\S)/, "$1☑ "); - line = line.replace(/^(\s*)[-*+]\s+(?=\S)/, "$1• "); - line = line.replace(/^(\s*)(\d+)[.)]\s+(?=\S)/, "$1$2) "); - - return line; -} - -function normalizeUnsupportedMarkdown(s: string): string { - return normalizeMarkdownTables(s) - .split("\n") - .map(normalizeUnsupportedMarkdownLine) - .join("\n"); -} - -function isWhitespace(ch: string | undefined): boolean { - return ch !== undefined && /\s/.test(ch); -} - -function isWordChar(ch: string | undefined): boolean { - return ch !== undefined && /[\p{L}\p{N}]/u.test(ch); -} - -function canOpenDelimiter( - s: string, - index: number, - delimiter: string, - kind: InlineStyleKind, -): boolean { - const before = s[index - 1]; - const after = s[index + delimiter.length]; - - if (after === undefined || isWhitespace(after)) { - return false; - } - - return !((kind === "bold" || kind === "italic" || kind === "strikethrough") && - isWordChar(before) && - isWordChar(after)); -} - -function canCloseDelimiter( - s: string, - index: number, - delimiter: string, - kind: InlineStyleKind, -): boolean { - const before = s[index - 1]; - const after = s[index + delimiter.length]; - - if (before === undefined || isWhitespace(before)) { - return false; - } - - return !((kind === "bold" || kind === "italic" || kind === "strikethrough") && - isWordChar(before) && - isWordChar(after)); -} - -function findClosingDelimiter( - s: string, - delimiter: string, - from: number, - kind: InlineStyleKind, - store: TelegramMarkdownV2TokenStore, -): number { - for (let i = from; i < s.length; i++) { - const token = store.readAt(s, i); - - if (token) { - i = token.end - 1; - continue; - } - - if (s[i] === "\\") { - i++; - continue; - } - - if (s.startsWith(delimiter, i) && canCloseDelimiter(s, i, delimiter, kind)) { - return i; - } - } - - return -1; -} - -function formatInlineMarkdownV2( - s: string, - store: TelegramMarkdownV2TokenStore, -): string { - const styles: InlineStyle[] = [ - {inputDelimiter: "||", outputDelimiter: "||", kind: "spoiler"}, - {inputDelimiter: "__", outputDelimiter: "__", kind: "underline"}, - {inputDelimiter: "**", outputDelimiter: "*", kind: "bold"}, - {inputDelimiter: "~~", outputDelimiter: "~", kind: "strikethrough"}, - {inputDelimiter: "*", outputDelimiter: "*", kind: "bold"}, - {inputDelimiter: "_", outputDelimiter: "_", kind: "italic"}, - {inputDelimiter: "~", outputDelimiter: "~", kind: "strikethrough"}, - ]; - - let result = ""; - let i = 0; - - while (i < s.length) { - const token = store.readAt(s, i); - - if (token) { - result += token.key; - i = token.end; - continue; - } - - if (s[i] === "\\" && i + 1 < s.length) { - result += escapePlainMarkdownV2(s[i + 1]); - i += 2; - continue; - } - - let handled = false; - - for (const style of styles) { - const delimiter = style.inputDelimiter; - - if (!s.startsWith(delimiter, i)) { - continue; - } - - if (!canOpenDelimiter(s, i, delimiter, style.kind)) { - continue; - } - - const end = findClosingDelimiter( - s, - delimiter, - i + delimiter.length, - style.kind, - store, - ); - - if (end === -1) { - continue; - } - - const content = s.slice(i + delimiter.length, end); - - if (content.length === 0) { - continue; - } - - result += - style.outputDelimiter + - formatInlineMarkdownV2(content, store) + - style.outputDelimiter; - - i = end + delimiter.length; - handled = true; - break; - } - - if (handled) { - continue; - } - - result += escapePlainMarkdownV2(s[i]); - i++; - } - - return result; -} - -function renderMarkdownV2Line( - line: string, - store: TelegramMarkdownV2TokenStore, -): string { - if (line.startsWith("**>")) { - let content = line.slice(3).replace(/^\s?/, ""); - const isExpandableEnd = content.endsWith("||"); - - if (isExpandableEnd) { - content = content.slice(0, -2); - } - - return `**>${formatInlineMarkdownV2(content, store)}${isExpandableEnd ? "||" : ""}`; - } - - if (line.startsWith(">")) { - const content = line.slice(1).replace(/^\s?/, ""); - - if (!content.trim()) { - return ">"; - } - - return ">" + formatInlineMarkdownV2(content, store); - } - - return formatInlineMarkdownV2(line, store); -} - -function renderMarkdownV2( - s: string, - store: TelegramMarkdownV2TokenStore, -): string { - return s - .split("\n") - .map(line => renderMarkdownV2Line(line, store)) - .join("\n"); -} - -// TODO: 01/05/2026, Danil Nikolaev: use in tools -export function createTelegramTimeEntityInput( - text: string, - unix: number, - format?: string, -): string { - if (format !== undefined && !isValidTelegramDateTimeFormat(format)) { - throw new Error( - `Invalid Telegram date_time format: "${format}". Expected "r" or pattern "w?[dD]?[tT]?".`, - ); - } - - const safeText = text.replace(/\\/g, "\\\\").replace(/]/g, "\\]"); - const safeUnix = Math.trunc(unix); - const formatPart = format !== undefined ? `&format=${format}` : ""; - - return `![${safeText}](tg://time?unix=${safeUnix}${formatPart})`; -} - -// TODO: 01/05/2026, Danil Nikolaev: use in tools -export function createTelegramEmojiEntityInput(text: string, emojiId: string | number): string { - const safeText = text.replace(/\\/g, "\\\\").replace(/]/g, "\\]"); - return `![${safeText}](tg://emoji?id=${emojiId})`; -} - -export function escapeMarkdownV2Text(input: string): string { - const store = new TelegramMarkdownV2TokenStore(); - - let s = normalizeLineEndings(input); - - s = protectFencedCodeBlocks(s, store); - s = protectInlineEntities(s, store); - s = normalizeUnsupportedMarkdown(s); - s = renderMarkdownV2(s, store); - s = s.replace(/\n{3,}/g, "\n\n").trim(); - s = store.restore(s); - - return s.trim(); -} +// function normalizeLineEndings(s: string): string { +// return s.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +// } + +// function stripOneOuterNewline(s: string): string { +// return s.replace(/^\n/, "").replace(/\n$/, ""); +// } + +// function normalizeCodeLanguage(lang: string | undefined): string { +// const trimmed = lang?.trim() ?? ""; +// return /^[a-zA-Z0-9_-]+$/.test(trimmed) ? trimmed : ""; +// } + +// function renderCodeBlockMarkdownV2(code: string, lang?: string): string { +// const safeLang = normalizeCodeLanguage(lang); +// const safeCode = escapeCodeMarkdownV2(stripOneOuterNewline(code)); +// return "```" + safeLang + "\n" + safeCode + "\n```"; +// } + +// function renderInlineCodeMarkdownV2(code: string): string { +// return "`" + escapeCodeMarkdownV2(code) + "`"; +// } + +// function protectFencedCodeBlocks( +// s: string, +// store: TelegramMarkdownV2TokenStore, +// ): string { +// return s.replace(/```([a-zA-Z0-9_-]*)[^\S\n]*\n?([\s\S]*?)```/g, (_full, lang: string, code: string) => { +// return store.add(renderCodeBlockMarkdownV2(code, lang)); +// }); +// } + +// function findClosingSquareBracket(s: string, from: number): number { +// for (let i = from; i < s.length; i++) { +// if (s[i] === "\\") { +// i++; +// continue; +// } +// +// if (s[i] === "\n") { +// return -1; +// } +// +// if (s[i] === "]") { +// return i; +// } +// } +// +// return -1; +// } + +// function findClosingParen(s: string, from: number): number { +// let depth = 1; +// +// for (let i = from; i < s.length; i++) { +// const ch = s[i]; +// +// if (ch === "\\") { +// i++; +// continue; +// } +// +// if (ch === "\n") { +// return -1; +// } +// +// if (ch === "(") { +// depth++; +// continue; +// } +// +// if (ch === ")") { +// depth--; +// +// if (depth === 0) { +// return i; +// } +// } +// } +// +// return -1; +// } + +// function parseBracketParen( +// s: string, +// openBracketIndex: number, +// ): { label: string; url: string; end: number } | null { +// if (s[openBracketIndex] !== "[") { +// return null; +// } +// +// const closeBracket = findClosingSquareBracket(s, openBracketIndex + 1); +// +// if (closeBracket === -1 || s[closeBracket + 1] !== "(") { +// return null; +// } +// +// const closeParen = findClosingParen(s, closeBracket + 2); +// +// if (closeParen === -1) { +// return null; +// } +// +// return { +// label: s.slice(openBracketIndex + 1, closeBracket), +// url: s.slice(closeBracket + 2, closeParen), +// end: closeParen + 1, +// }; +// } + +// function unescapeMarkdownLabel(s: string): string { +// return s.replace(/\\([\\\[\]])/g, "$1"); +// } + +// function unescapeMarkdownUrl(s: string): string { +// return s.replace(/\\([\\)])/g, "$1"); +// } + +// function parseQueryParam(query: string, key: string): string | undefined { +// for (const part of query.split("&")) { +// const eq = part.indexOf("="); +// +// if (eq === -1) { +// if (part === key) { +// return ""; +// } +// +// continue; +// } +// +// const paramKey = part.slice(0, eq); +// const paramValue = part.slice(eq + 1); +// +// if (paramKey === key) { +// return paramValue; +// } +// } +// +// return undefined; +// } + +// export function isValidTelegramDateTimeFormat(format: string): boolean { +// return /^(?:r|w?[dD]?[tT]?)$/.test(format); +// } + +// function isValidTelegramTimeUrl(url: string): boolean { +// const match = /^tg:\/\/time\?(.+)$/i.exec(url.trim()); +// +// if (!match) { +// return false; +// } +// +// const query = match[1]; +// const unix = parseQueryParam(query, "unix"); +// const format = parseQueryParam(query, "format"); +// +// if (!unix || !/^-?\d+$/.test(unix)) { +// return false; +// } +// +// return format === undefined || isValidTelegramDateTimeFormat(format); +// } + +// function isValidTelegramEmojiUrl(url: string): boolean { +// return /^tg:\/\/emoji\?id=\d+$/i.test(url.trim()); +// } + +// function isTelegramSpecialEntityUrl(url: string): boolean { +// return isValidTelegramEmojiUrl(url) || isValidTelegramTimeUrl(url); +// } + +// function renderTelegramSpecialEntityMarkdownV2(label: string, url: string): string { +// return `![${escapePlainMarkdownV2(label)}](${escapeLinkUrlMarkdownV2(url)})`; +// } + +// function renderInlineLinkMarkdownV2(label: string, url: string): string { +// const safeLabel = label.trim().length > 0 ? label : url; +// return `[${escapePlainMarkdownV2(safeLabel)}](${escapeLinkUrlMarkdownV2(url)})`; +// } + +// function findInlineCodeEnd(s: string, from: number): number { +// for (let i = from; i < s.length; i++) { +// if (s[i] === "\n") { +// return -1; +// } +// +// if (s[i] === "`") { +// return i; +// } +// } +// +// return -1; +// } + +// function protectInlineEntities( +// s: string, +// store: TelegramMarkdownV2TokenStore, +// ): string { +// let result = ""; +// let i = 0; +// +// while (i < s.length) { +// const token = store.readAt(s, i); +// +// if (token) { +// result += token.key; +// i = token.end; +// continue; +// } +// +// if (s.startsWith("![", i)) { +// const parsed = parseBracketParen(s, i + 1); +// +// if (parsed) { +// const label = unescapeMarkdownLabel(parsed.label); +// const url = unescapeMarkdownUrl(parsed.url.trim()); +// +// if (isTelegramSpecialEntityUrl(url)) { +// result += store.add(renderTelegramSpecialEntityMarkdownV2(label, url)); +// } else { +// result += label.trim().length > 0 ? `${label}: ${url}` : url; +// } +// +// i = parsed.end; +// continue; +// } +// } +// +// if (s[i] === "[") { +// const parsed = parseBracketParen(s, i); +// +// if (parsed) { +// const label = unescapeMarkdownLabel(parsed.label); +// const url = unescapeMarkdownUrl(parsed.url.trim()); +// +// if (url.length > 0) { +// result += store.add(renderInlineLinkMarkdownV2(label, url)); +// i = parsed.end; +// continue; +// } +// } +// } +// +// if (s[i] === "`") { +// const end = findInlineCodeEnd(s, i + 1); +// +// if (end !== -1) { +// result += store.add(renderInlineCodeMarkdownV2(s.slice(i + 1, end))); +// i = end + 1; +// continue; +// } +// } +// +// result += s[i]; +// i++; +// } +// +// return result; +// } + +// function isMarkdownTableSeparator(line: string): boolean { +// return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line); +// } + +// function looksLikeMarkdownTableRow(line: string): boolean { +// const trimmed = line.trim(); +// +// if (!trimmed.includes("|")) { +// return false; +// } +// +// return !(trimmed.startsWith("||") && trimmed.endsWith("||")); +// } + +// function splitMarkdownTableRow(line: string): string[] { +// const normalized = line.trim().replace(/^\|/, "").replace(/\|$/, ""); +// const cells: string[] = []; +// let current = ""; +// +// for (let i = 0; i < normalized.length; i++) { +// const ch = normalized[i]; +// +// if (ch === "\\") { +// current += ch; +// +// if (i + 1 < normalized.length) { +// current += normalized[i + 1]; +// i++; +// } +// +// continue; +// } +// +// if (ch === "|") { +// cells.push(current.trim()); +// current = ""; +// continue; +// } +// +// current += ch; +// } +// +// cells.push(current.trim()); +// return cells.filter(Boolean); +// } + +// function normalizeMarkdownTables(s: string): string { +// const lines = s.split("\n"); +// const result: string[] = []; +// let i = 0; +// +// while (i < lines.length) { +// const current = lines[i]; +// const next = lines[i + 1]; +// +// if ( +// next !== undefined && +// looksLikeMarkdownTableRow(current) && +// isMarkdownTableSeparator(next) +// ) { +// const tableRows = [current]; +// i += 2; +// +// while ( +// i < lines.length && +// looksLikeMarkdownTableRow(lines[i]) && +// !isMarkdownTableSeparator(lines[i]) +// ) { +// tableRows.push(lines[i]); +// i++; +// } +// +// for (const row of tableRows) { +// const cells = splitMarkdownTableRow(row); +// +// if (cells.length > 0) { +// result.push(cells.join(" — ")); +// } +// } +// +// continue; +// } +// +// result.push(current); +// i++; +// } +// +// return result.join("\n"); +// } + +// function normalizeUnsupportedMarkdownLine(line: string): string { +// const headingMatch = /^\s*#{1,6}\s+(.+?)\s*#*\s*$/.exec(line); +// +// if (headingMatch) { +// return `*${headingMatch[1].trim()}*`; +// } +// +// if (/^\s*([-*_])(?:\s*\1){2,}\s*$/.test(line)) { +// return "— — —"; +// } +// +// line = line.replace(/^(\s*)[-*+]\s+\[\s]\s+(?=\S)/i, "$1☐ "); +// line = line.replace(/^(\s*)[-*+]\s+\[[xX]]\s+(?=\S)/, "$1☑ "); +// line = line.replace(/^(\s*)[-*+]\s+(?=\S)/, "$1• "); +// line = line.replace(/^(\s*)(\d+)[.)]\s+(?=\S)/, "$1$2) "); +// +// return line; +// } + +// function normalizeUnsupportedMarkdown(s: string): string { +// return normalizeMarkdownTables(s) +// .split("\n") +// .map(normalizeUnsupportedMarkdownLine) +// .join("\n"); +// } + +// function isWhitespace(ch: string | undefined): boolean { +// return ch !== undefined && /\s/.test(ch); +// } + +// function isWordChar(ch: string | undefined): boolean { +// return ch !== undefined && /[\p{L}\p{N}]/u.test(ch); +// } + +// function canOpenDelimiter( +// s: string, +// index: number, +// delimiter: string, +// kind: InlineStyleKind, +// ): boolean { +// const before = s[index - 1]; +// const after = s[index + delimiter.length]; +// +// if (after === undefined || isWhitespace(after)) { +// return false; +// } +// +// return !((kind === "bold" || kind === "italic" || kind === "strikethrough") && +// isWordChar(before) && +// isWordChar(after)); +// } + +// function canCloseDelimiter( +// s: string, +// index: number, +// delimiter: string, +// kind: InlineStyleKind, +// ): boolean { +// const before = s[index - 1]; +// const after = s[index + delimiter.length]; +// +// if (before === undefined || isWhitespace(before)) { +// return false; +// } +// +// return !((kind === "bold" || kind === "italic" || kind === "strikethrough") && +// isWordChar(before) && +// isWordChar(after)); +// } + +// function findClosingDelimiter( +// s: string, +// delimiter: string, +// from: number, +// kind: InlineStyleKind, +// store: TelegramMarkdownV2TokenStore, +// ): number { +// for (let i = from; i < s.length; i++) { +// const token = store.readAt(s, i); +// +// if (token) { +// i = token.end - 1; +// continue; +// } +// +// if (s[i] === "\\") { +// i++; +// continue; +// } +// +// if (s.startsWith(delimiter, i) && canCloseDelimiter(s, i, delimiter, kind)) { +// return i; +// } +// } +// +// return -1; +// } + +// function formatInlineMarkdownV2( +// s: string, +// store: TelegramMarkdownV2TokenStore, +// ): string { +// const styles: InlineStyle[] = [ +// {inputDelimiter: "||", outputDelimiter: "||", kind: "spoiler"}, +// {inputDelimiter: "__", outputDelimiter: "__", kind: "underline"}, +// {inputDelimiter: "**", outputDelimiter: "*", kind: "bold"}, +// {inputDelimiter: "~~", outputDelimiter: "~", kind: "strikethrough"}, +// {inputDelimiter: "*", outputDelimiter: "*", kind: "bold"}, +// {inputDelimiter: "_", outputDelimiter: "_", kind: "italic"}, +// {inputDelimiter: "~", outputDelimiter: "~", kind: "strikethrough"}, +// ]; +// +// let result = ""; +// let i = 0; +// +// while (i < s.length) { +// const token = store.readAt(s, i); +// +// if (token) { +// result += token.key; +// i = token.end; +// continue; +// } +// +// if (s[i] === "\\" && i + 1 < s.length) { +// result += escapePlainMarkdownV2(s[i + 1]); +// i += 2; +// continue; +// } +// +// let handled = false; +// +// for (const style of styles) { +// const delimiter = style.inputDelimiter; +// +// if (!s.startsWith(delimiter, i)) { +// continue; +// } +// +// if (!canOpenDelimiter(s, i, delimiter, style.kind)) { +// continue; +// } +// +// const end = findClosingDelimiter( +// s, +// delimiter, +// i + delimiter.length, +// style.kind, +// store, +// ); +// +// if (end === -1) { +// continue; +// } +// +// const content = s.slice(i + delimiter.length, end); +// +// if (content.length === 0) { +// continue; +// } +// +// result += +// style.outputDelimiter + +// formatInlineMarkdownV2(content, store) + +// style.outputDelimiter; +// +// i = end + delimiter.length; +// handled = true; +// break; +// } +// +// if (handled) { +// continue; +// } +// +// result += escapePlainMarkdownV2(s[i]); +// i++; +// } +// +// return result; +// } + +// function renderMarkdownV2Line( +// line: string, +// store: TelegramMarkdownV2TokenStore, +// ): string { +// if (line.startsWith("**>")) { +// let content = line.slice(3).replace(/^\s?/, ""); +// const isExpandableEnd = content.endsWith("||"); +// +// if (isExpandableEnd) { +// content = content.slice(0, -2); +// } +// +// return `**>${formatInlineMarkdownV2(content, store)}${isExpandableEnd ? "||" : ""}`; +// } +// +// if (line.startsWith(">")) { +// const content = line.slice(1).replace(/^\s?/, ""); +// +// if (!content.trim()) { +// return ">"; +// } +// +// return ">" + formatInlineMarkdownV2(content, store); +// } +// +// return formatInlineMarkdownV2(line, store); +// } + +// function renderMarkdownV2( +// s: string, +// store: TelegramMarkdownV2TokenStore, +// ): string { +// return s +// .split("\n") +// .map(line => renderMarkdownV2Line(line, store)) +// .join("\n"); +// } + +// export function escapeMarkdownV2Text(input: string): string { +// const store = new TelegramMarkdownV2TokenStore(); +// +// let s = normalizeLineEndings(input); +// +// s = protectFencedCodeBlocks(s, store); +// s = protectInlineEntities(s, store); +// s = normalizeUnsupportedMarkdown(s); +// s = renderMarkdownV2(s, store); +// s = s.replace(/\n{3,}/g, "\n\n").trim(); +// s = store.restore(s); +// +// return s.trim(); +// } export async function getFileUrl(fileId: string): Promise { const file = await bot.getFile({file_id: fileId}); @@ -1171,6 +1241,12 @@ export async function getUserAvatar(userId: number): Promise { return Buffer.from(res.data); } +export function extractMessageQuote(msg: Message | StoredMessage | null | undefined): string | undefined | null { + if (!msg) return null; + + return isStoredMessage(msg) ? msg.quoteText : msg.quote?.text; +} + export function extractTextMessage(msg: Message | StoredMessage | string): string | null { if (!msg) return null; if (typeof msg === "string") return msg; @@ -1180,11 +1256,15 @@ export function extractTextMessage(msg: Message | StoredMessage | string): strin return text; } +export function escapeHtml(input: string): string { + return HtmlUtils.escape(input); +} + export function cutPrefixes(msg: Message | StoredMessage | string | null): string | null { if (!msg) return null; const chatCommands = commands.filter(c => c instanceof ChatCommand); - const prefixes = [Environment.BOT_PREFIX]; + const prefixes = Environment.BOT_PREFIX ? [Environment.BOT_PREFIX] : []; const pushPrefix = (c: string) => { prefixes.push(`/${c}@${botUser.username}`); prefixes.push(`/${c}`); @@ -1220,6 +1300,56 @@ export function isStoredMessage(msg: Message | StoredMessage | null): msg is Sto return !!msg && "id" in msg; } +function mimeTypeFromImagePath(filePath: string, fallback = "image/jpeg"): string { + switch (path.extname(filePath).toLowerCase()) { + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".png": + return "image/png"; + case ".webp": + return "image/webp"; + case ".gif": + return "image/gif"; + default: + return fallback; + } +} + +function mimeTypeFromImageAttachment(attachment: StoredAttachment): string { + const mimeType = attachment.mimeType?.toLowerCase(); + if (mimeType?.startsWith("image/")) return mimeType; + return mimeTypeFromImagePath(attachment.cachePath); +} + +function mimeTypeFromAudioPath(filePath: string, fallback = "audio/wav"): string { + switch (path.extname(filePath).toLowerCase()) { + case ".mp3": + return "audio/mpeg"; + case ".m4a": + return "audio/m4a"; + case ".ogg": + case ".oga": + return "audio/ogg"; + case ".opus": + return "audio/opus"; + case ".flac": + return "audio/flac"; + case ".aac": + return "audio/aac"; + case ".wav": + return "audio/wav"; + default: + return fallback; + } +} + +function mimeTypeFromAudioDownload(download: AiDownloadedFile): string { + const mimeType = download.mimeType?.toLowerCase(); + if (mimeType?.startsWith("audio/")) return mimeType; + return mimeTypeFromAudioPath(download.path); +} + export async function loadImagesIfExists(msg: Message | StoredMessage): Promise { if (isStoredMessage(msg)) { return msg.photoMaxSizeFilePath; @@ -1229,26 +1359,33 @@ export async function loadImagesIfExists(msg: Message | StoredMessage): Promise< const imageFilePaths: string[] = []; - for (const size of msg.photo) { - const exists = fs.existsSync(photoPathByUniqueId(size.file_unique_id)); - if (exists) { - return [size.file_unique_id]; - } + const maxSize = getPhotoMaxSize(msg.photo); + if (!maxSize) return []; + + const exists = fs.existsSync(photoPathByUniqueId(maxSize.file_unique_id)); + if (exists) { + return [maxSize.file_unique_id]; } - const maxSize = await mapPhotoSizeToMax(getPhotoMaxSize(msg.photo)); - if (maxSize) { - let imageFilePath: string | null = path.join(photoDir, maxSize.unique_file_id + ".jpg"); + const photoMaxSize = await mapPhotoSizeToMax(maxSize); + if (photoMaxSize) { + let imageFilePath: string | null = photoPathByUniqueId(maxSize.file_unique_id); if (!fs.existsSync(imageFilePath)) { - const res = await axios.get(maxSize.url, {responseType: "arraybuffer"}); - const src = Buffer.from(res.data); + await fileWriteLocks.runExclusive(imageFilePath, async () => { + if (fs.existsSync(imageFilePath!)) return; - try { - fs.writeFileSync(imageFilePath, src); - } catch (e) { - logError(e); - imageFilePath = null; - } + const res = await axios.get(photoMaxSize.url, {responseType: "arraybuffer"}); + const src = Buffer.from(res.data); + + try { + const tempPath = `${imageFilePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, src); + fs.renameSync(tempPath, imageFilePath!); + } catch (e) { + logError(e); + imageFilePath = null; + } + }); } if (imageFilePath) { @@ -1276,50 +1413,136 @@ export async function loadImagesFromFileIds(sizes: PhotoSize[]): Promise { + const paths = await Promise.all(responses.map((res, index) => { try { const uniqueFileId = maxSizes[index].unique_file_id; const imageFilePath = path.join(photoDir, uniqueFileId + ".jpg"); const src = Buffer.from(res.data); - fs.writeFileSync(imageFilePath, src); - return uniqueFileId; + return fileWriteLocks.runExclusive(imageFilePath, async () => { + if (!fs.existsSync(imageFilePath)) { + const tempPath = `${imageFilePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, src); + fs.renameSync(tempPath, imageFilePath); + } + return uniqueFileId; + }); } catch (e) { logError(e); return null; } - }); + })); const finalPaths = existing.concat(...paths.filter(p => !!p).map(p => p)); return finalPaths; } -export async function collectReplyChainText(triggerMsg: Message | StoredMessage | null, limit: number = 40, includeTrigger = true, cutPrefix: boolean = true): Promise { +export type ReplyChainOptions = { + triggerMsg: Message | StoredMessage | null | undefined, + limit?: number, + includeTrigger?: boolean; + cutPrefix?: boolean, + downloads?: AiDownloadedFile[] +} + +export async function collectReplyChainText(options: ReplyChainOptions): Promise { + const triggerMsg = options.triggerMsg; + const limit = options.limit ?? 40; + const includeTrigger = options.includeTrigger ?? true; + const cutPrefix = options.cutPrefix ?? true; + const downloads = options.downloads ?? []; + if (!triggerMsg) return []; const parts: MessagePart[] = []; - const pushPart = async (msg: Message | StoredMessage | undefined | null, textRequired: boolean = false) => { + const pushPart = async (msg: Message | StoredMessage | undefined | null, textRequired: boolean = false, includeDownloads: boolean = false) => { if (msg) { + const quoteText = extractMessageQuote(msg); const rawText = extractTextMessage(msg); const cleanText = cutPrefix ? cutPrefixes(rawText) : rawText; const imageNames = await loadImagesIfExists(msg); + const messageDownloads = includeDownloads ? downloads : []; + const storedImageAttachments = isStoredMessage(msg) + ? (msg.attachments ?? []).filter(attachment => attachment.kind === "image" && fs.existsSync(attachment.cachePath)) + : []; - if (!cleanText && textRequired) return; - if (!cleanText && !imageNames?.length) return; + if (!cleanText && !quoteText && textRequired) return; + if (!cleanText && !quoteText && !imageNames?.length && !storedImageAttachments.length && !messageDownloads.length) return; const fromId = isStoredMessage(msg) ? msg.fromId : msg.from?.id; - const firstName = isStoredMessage(msg) ? - (await UserStore.get(msg.fromId))?.firstName : msg.from?.first_name; + const user = await UserStore.get(isStoredMessage(msg) ? msg.fromId : msg.from?.id ?? -1); - const images = imageNames ? imageNames.map(n => { + const firstName = isStoredMessage(msg) ? user?.firstName : msg.from?.first_name; + + const photoImageParts: MessageImagePart[] = imageNames ? imageNames.map(n => { const filePath = photoPathByUniqueId(n); - return Buffer.from(fs.readFileSync(filePath)).toString("base64"); - }) : null; + return { + data: Buffer.from(fs.readFileSync(filePath)).toString("base64"), + mimeType: mimeTypeFromImagePath(filePath), + }; + }) : []; + const imageNameSet = new Set(imageNames ?? []); + const cachedImageAttachments = storedImageAttachments.filter(attachment => { + if (attachment.fileUniqueId && imageNameSet.has(attachment.fileUniqueId)) return false; + return !imageNameSet.has(path.basename(attachment.cachePath, path.extname(attachment.cachePath))); + }); + const cachedImageParts: MessageImagePart[] = cachedImageAttachments.map(attachment => { + return { + data: Buffer.from(fs.readFileSync(attachment.cachePath)).toString("base64"), + mimeType: mimeTypeFromImageAttachment(attachment), + }; + }); + const imageParts = [...photoImageParts, ...cachedImageParts]; + + const audios: string[] = []; + const audioParts: MessageAudioPart[] = []; + const documents: string[] = []; + const videos: string[] = []; + const videoNotes: string[] = []; + + if (messageDownloads.length) { + messageDownloads + .filter(d => d.kind === "audio") + .forEach(a => { + const data = a.buffer.toString("base64"); + audios.push(data); + audioParts.push({data, mimeType: mimeTypeFromAudioDownload(a)}); + }); + + messageDownloads + .filter(d => d.kind === "document") + .forEach(d => documents.push(d.buffer.toString("base64"))); + + messageDownloads + .filter(d => d.kind === "video") + .forEach(v => videos.push(v.buffer.toString("base64"))); + + messageDownloads + .filter(d => d.kind === "video-note") + .forEach(v => { + const data = v.buffer.toString("base64"); + videoNotes.push(data); + audioParts.push({data, mimeType: mimeTypeFromAudioDownload(v)}); + }); + } + + const content = [ + quoteText ? `[citation]:\n${quoteText}\n\n[message]:\n` : "", + cleanText ?? "" + ].join("\n").trim(); parts.push({ bot: fromId === botUser.id, - content: cleanText ? cleanText : "", + content: content, name: firstName, - images: images ? images : [] + langCode: user?.langCode, + userName: user?.userName, + images: imageParts.map(image => image.data), + imageParts: imageParts.length ? imageParts : undefined, + audios: audios.length ? audios : undefined, + audioParts: audioParts.length ? audioParts : undefined, + documents: documents.length ? documents : undefined, + videos: videos.length ? videos : undefined, + videoNotes: videoNotes.length ? videoNotes : undefined, }); } }; @@ -1327,7 +1550,7 @@ export async function collectReplyChainText(triggerMsg: Message | StoredMessage const chatId = isStoredMessage(triggerMsg) ? triggerMsg.chatId as number : triggerMsg.chat.id; if (includeTrigger) { - await pushPart(triggerMsg); + await pushPart(triggerMsg, false, true); } const first = isStoredMessage(triggerMsg) ? @@ -1382,54 +1605,56 @@ export async function waveDistortSharp( wavelength = 72, maxSide = 1024 ): Promise { - amp = clamp(amp, 2, 60); - wavelength = clamp(wavelength, 16, 300); + return imageProcessingSemaphore.runExclusive(async () => { + amp = clamp(amp, 2, 60); + wavelength = clamp(wavelength, 16, 300); - const phase1 = Math.random() * Math.PI * 2; - const phase2 = Math.random() * Math.PI * 2; - const amp2 = Math.max(6, Math.floor(amp * 0.6)); - const wavelength2 = Math.max(32, Math.floor(wavelength * 1.4)); + const phase1 = Math.random() * Math.PI * 2; + const phase2 = Math.random() * Math.PI * 2; + const amp2 = Math.max(6, Math.floor(amp * 0.6)); + const wavelength2 = Math.max(32, Math.floor(wavelength * 1.4)); - const {data, info} = await sharp(input) - .resize({width: maxSide, height: maxSide, fit: "inside", withoutEnlargement: true}) - .ensureAlpha() - .raw() - .toBuffer({resolveWithObject: true}); + const {data, info} = await sharp(input) + .resize({width: maxSide, height: maxSide, fit: "inside", withoutEnlargement: true}) + .ensureAlpha() + .raw() + .toBuffer({resolveWithObject: true}); - const width = info.width!; - const height = info.height!; - const channels = info.channels!; // обычно 4 (RGBA) + const width = info.width!; + const height = info.height!; + const channels = info.channels!; // usually 4 (RGBA) - const out = Buffer.alloc(data.length); + const out = Buffer.alloc(data.length); - for (let y = 0; y < height; y++) { - const dx = amp * Math.sin((2 * Math.PI * y) / wavelength + phase1); + for (let y = 0; y < height; y++) { + const dx = amp * Math.sin((2 * Math.PI * y) / wavelength + phase1); - for (let x = 0; x < width; x++) { - const dy = amp2 * Math.sin((2 * Math.PI * x) / wavelength2 + phase2); + for (let x = 0; x < width; x++) { + const dy = amp2 * Math.sin((2 * Math.PI * x) / wavelength2 + phase2); - const sx = Math.round(x + dx); - const sy = Math.round(y + dy); + const sx = Math.round(x + dx); + const sy = Math.round(y + dy); - const di = (y * width + x) * channels; + const di = (y * width + x) * channels; - if (sx < 0 || sx >= width || sy < 0 || sy >= height) { - // прозрачный пиксель - out[di] = 0; - out[di + 1] = 0; - out[di + 2] = 0; - out[di + 3] = 0; - continue; + if (sx < 0 || sx >= width || sy < 0 || sy >= height) { + // transparent pixel + out[di] = 0; + out[di + 1] = 0; + out[di + 2] = 0; + out[di + 3] = 0; + continue; + } + + const si = (sy * width + sx) * channels; + data.copy(out, di, si, si + channels); } - - const si = (sy * width + sx) * channels; - data.copy(out, di, si, si + channels); } - } - return await sharp(out, {raw: {width, height, channels}}) - .png() - .toBuffer(); + return await sharp(out, {raw: {width, height, channels}}) + .png() + .toBuffer(); + }); } export async function downloadTelegramFile(filePath?: string | null): Promise { @@ -1442,11 +1667,11 @@ export async function downloadTelegramFile(filePath?: string | null): Promise - + - + @@ -1542,7 +1767,7 @@ export async function makeDarkGradientBgFancy( return sharp(svg) .resize(width, height) - .blur(0.6) // чуть сгладить градиент/свечение (зерно тоже мягче) + .blur(0.6) // slightly smooth the gradient/glow (grain gets softer too) .png() .toBuffer(); } @@ -1600,14 +1825,13 @@ export function startIntervalEditor(params: { }) { let lastSent = ""; let stopped = false; + let inFlight: Promise = Promise.resolve(); - const tick = async () => { + const runTick = async () => { if (stopped /*|| (params.uuid && getOllamaRequest(params.uuid)?.done)*/) return; const next = params.getText(); if (!next || next === lastSent) return; - console.log("tick"); - try { await params.editFn(next); lastSent = next; @@ -1617,13 +1841,21 @@ export function startIntervalEditor(params: { } }; - const timer = setInterval(async () => await tick(), params.intervalMs); + const tick = async () => { + inFlight = inFlight.then(runTick, runTick); + return inFlight; + }; + + const timer = setInterval(() => { + tick().catch(logError); + }, params.intervalMs); return { tick, stop: async () => { stopped = true; clearInterval(timer); + await inFlight; await params.onStop?.(); }, }; @@ -1644,13 +1876,13 @@ export function buildExcludedSet< K extends keyof T["_"]["columns"] & string, E extends readonly K[] = readonly [] >(table: T, exclude: E = [] as unknown as E): Record, SQL> { - const cols = orm.getColumns(table as never) as T["_"]["columns"]; + const cols = getTableColumns(table as never) as T["_"]["columns"]; const excludeSet = new Set(exclude as readonly string[]); const entries = Object.keys(cols) .filter((key) => !excludeSet.has(key)) .map((key) => { - const realName = (cols as any)[key].name; // actual DB column name + const realName = cols[key].name; // actual DB column name return [key, sql.raw(`excluded.${realName}`)] as const; }); @@ -1664,7 +1896,7 @@ type RuntimeInfo = export function getRuntimeInfo(): RuntimeInfo { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const v = (process as any).versions ?? {}; + const v = process.versions ?? {}; if (typeof v.bun === "string") { return {runtime: "bun", version: v.bun}; @@ -1674,7 +1906,7 @@ export function getRuntimeInfo(): RuntimeInfo { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {runtime: "unknown", version: String((process as any).version ?? "")}; + return {runtime: "unknown", version: String(process.version ?? "")}; } export type PhotoMaxSize = { width: number, height: number, url: string; file_id: string; unique_file_id: string; }; @@ -1725,20 +1957,75 @@ export async function imageToBase64(filePath: string, withMimeType: boolean = fa } // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function ifTrue(exp?: any): boolean { +export function ifTrue(exp?: string | number | boolean): boolean { if (!exp) return false; - return ["true", "t", "y", 1, "1"].includes(exp); + if (typeof exp === "boolean") return exp; + + const normalized = exp.toString().toLowerCase().trim(); + return ["true", "t", "y", "1"].includes(normalized); } + export function boolToEmoji(bool: boolean | undefined): string { return !!bool ? "✅" : "❌"; } -export const albumCache = new Map(); +type AlbumCacheEntry = { + messages: Message[]; + timer: NodeJS.Timeout; + resolve: (value: boolean) => void; + storedMsg: StoredMessage | null; +}; -async function processAlbum(groupId: string): Promise { - const entry = albumCache.get(groupId); +export const albumCache = new Map(); + +type AlbumProcessingResult = { + photoUniqueIds?: string[] | null; + attachments: StoredAttachment[]; + text?: string | null; +}; + +function attachmentKey(attachment: StoredAttachment): string { + return [ + attachment.kind, + attachment.fileUniqueId || attachment.fileId, + attachment.cachePath, + ].join(":"); +} + +function uniqueAttachments(attachments: StoredAttachment[]): StoredAttachment[] { + const seen = new Set(); + const result: StoredAttachment[] = []; + + for (const attachment of attachments) { + const key = attachmentKey(attachment); + if (seen.has(key)) continue; + seen.add(key); + result.push(attachment); + } + + return result; +} + +async function collectAlbumStoredAttachments(entry: AlbumCacheEntry): Promise { + const storedMessages = await Promise.all( + entry.messages.map(message => MessageStore.get(message.chat.id, message.message_id)) + ); + + return uniqueAttachments(storedMessages.flatMap(message => message?.attachments ?? [])); +} + +function collectAlbumText(messages: Message[]): string | null { + const parts = messages + .map(message => extractTextMessage(message)) + .filter((text): text is string => !!text?.trim()); + + return parts.length ? parts.join("\n").trim() : null; +} + +async function processAlbum(albumKey: string): Promise { + const entry = albumCache.get(albumKey); if (!entry) return; const allPhotos = entry.messages @@ -1747,12 +2034,55 @@ async function processAlbum(groupId: string): Promise getPhotoMaxSize(photo)).filter(s => !!s)); const ids = await loadImagesFromFileIds(allPhotoMaxSizes); + const attachments = await collectAlbumStoredAttachments(entry); + const text = collectAlbumText(entry.messages); - console.log(`Received album ${groupId} with ${ids?.length} photos.`); - console.log("File IDs:", ids); + albumCache.delete(albumKey); + return {photoUniqueIds: ids, attachments, text}; +} - albumCache.delete(groupId); - return ids; +function scheduleAlbumProcessing(albumKey: string, delayMs = 1000): NodeJS.Timeout { + return setTimeout(async () => { + const entry = albumCache.get(albumKey); + try { + const album = await processAlbum(albumKey); + if (entry?.storedMsg) { + entry.storedMsg.photoMaxSizeFilePath = album?.photoUniqueIds; + entry.storedMsg.attachments = uniqueAttachments([ + ...(entry.storedMsg.attachments ?? []), + ...(album?.attachments ?? []), + ]); + if (album?.text) { + entry.storedMsg.text = album.text; + } + await MessageStore.put(entry.storedMsg).catch(logError); + } + + if (entry && album?.attachments.length) { + await Promise.all(entry.messages.map(async message => { + const stored = await MessageStore.get(message.chat.id, message.message_id); + if (!stored) return; + + stored.attachments = uniqueAttachments([ + ...(stored.attachments ?? []), + ...album.attachments, + ]); + if (album.photoUniqueIds?.length) { + stored.photoMaxSizeFilePath = album.photoUniqueIds; + } + if (album.text) { + stored.text = album.text; + } + await MessageStore.put(stored).catch(logError); + })); + } + } catch (e) { + logError(e); + } finally { + albumCache.delete(albumKey); + entry?.resolve(true); + } + }, delayMs); } export function photoPathByUniqueId(uniqueId: string): string { @@ -1762,7 +2092,7 @@ export function photoPathByUniqueId(uniqueId: string): string { export function getCurrentModel(): string | undefined { switch (Environment.DEFAULT_AI_PROVIDER) { case AiProvider.OLLAMA: - return Environment.OLLAMA_MODEL; + return Environment.OLLAMA_CHAT_MODEL; case AiProvider.GEMINI: return Environment.GEMINI_MODEL; case AiProvider.MISTRAL: @@ -1772,77 +2102,41 @@ export function getCurrentModel(): string | undefined { } } -export async function getCurrentModelCapabilities(): Promise { - let promise: Promise | null | undefined = null; - switch (Environment.DEFAULT_AI_PROVIDER) { - case AiProvider.OLLAMA: { - const ollamaGetModel = commands.find(c => c instanceof OllamaGetModel); - if (!ollamaGetModel) break; - - // eslint-disable-next-line no-async-promise-executor - promise = new Promise(async (resolve, reject) => { - try { - const defaultModelCapabilities = await ollamaGetModel.getModelCapabilities(); - const imageModelCapabilities = await ollamaGetModel.loadImageModelInfo(); - - const result = { - vision: imageModelCapabilities?.vision, - ocr: imageModelCapabilities?.ocr, - thinking: (await ollamaGetModel.loadThinkModelInfo())?.thinking, - tools: defaultModelCapabilities?.tools, - audio: defaultModelCapabilities?.audio - }; - resolve(result); - } catch (e) { - reject(e); - } - }); - break; - } - case AiProvider.GEMINI: { - promise = commands.find(c => c instanceof GeminiGetModel)?.getModelCapabilities(); - break; - } - case AiProvider.MISTRAL: { - promise = commands.find(c => c instanceof MistralGetModel)?.getModelCapabilities(); - break; - } - case AiProvider.OPENAI: { - promise = commands.find(c => c instanceof OpenAIGetModel)?.getModelCapabilities(); - break; - } - } - - if (!promise) return null; - - try { - return await promise; - } catch (e) { - logError(e); - return null; - } -} - export async function processMyChatMember(u: ChatMemberUpdated): Promise { console.log("my_chat_member", u); } -export async function processNewMessage(msg: Message): Promise { - console.log("New Message", msg); +export async function processGuestMessage(msg: Message): Promise { + // return processNewMessage(msg, true); + console.log("NEW_GUEST_MESSAGE", msg); +} + +export async function processNewMessage(msg: Message, isGuest?: boolean): Promise { + console.log(isGuest ? "NEW_GUEST_MESSAGE" : "NEW_MESSAGE", msg); + if (!msg.from) return; + const from = msg.from; Environment.reloadRuntimeConfigIfChanged(); let storedMsg: StoredMessage | null = null; + let locale = Localization.resolveLocale(undefined, from.language_code); try { const results = await Promise.all([ MessageStore.put(msg), - UserStore.put(msg.from) + UserStore.put(from) ] ); storedMsg = results[0]; + locale = await resolveInterfaceLocaleForUser(from.id, from.language_code); + const attachments = await cacheMessageAttachments(msg); + if (attachments.length) { + storedMsg.attachments = attachments; + await MessageStore.put(storedMsg); + } + if (!msg.media_group_id && storedMsg.photoMaxSizeFilePath) { await loadImagesIfExists(msg); } @@ -1850,135 +2144,119 @@ export async function processNewMessage(msg: Message): Promise { logError(e); } - if ((msg.new_chat_members?.length)) { - await bot.sendMessage({chat_id: msg.chat.id, text: randomValue(Environment.ANSWERS.invite)}).catch(logError); - return; - } - - if (msg.left_chat_member && msg.left_chat_member.id !== botUser.id) { - await bot.sendMessage({chat_id: msg.chat.id, text: randomValue(Environment.ANSWERS.kick)}).catch(logError); - return; - } - - if (Environment.MUTED_IDS.has(msg.from.id)) return; - - if (msg.forward_origin) return; - - const groupId = msg.media_group_id; - if (groupId) { - await new Promise(resolve => { - if (!albumCache.has(groupId)) { - albumCache.set(groupId, { - messages: [msg], - timer: setTimeout(async () => { - const photos = await processAlbum(groupId); - console.log("processedAlbum", photos); - - if (storedMsg) { - storedMsg.photoMaxSizeFilePath = photos; - await MessageStore.put(storedMsg).catch(logError); - } - resolve(true); - }, 1000) - }); - } else { - const entry = albumCache.get(groupId); - entry?.messages?.push(msg); + await Localization.runWithLocale(locale, async () => { + if ((msg.new_chat_members?.length)) { + const text = randomValue(Environment.ANSWERS.invite); + if (text) { + await enqueueTelegramApiCall( + () => bot.sendMessage({chat_id: msg.chat.id, text}), + {method: "sendMessage", chatId: msg.chat.id, chatType: msg.chat.type} + ).catch(logError); } - }); - } - - const cmdText = msg.text || msg.caption || ""; - - const then = Date.now(); - - const cmd = searchChatCommand(commands, cmdText); - const executed = await executeChatCommand(cmd, msg, cmdText); - - const now = Date.now(); - const diff = now - then; - console.log("diff", diff); - - if (executed || (!cmdText && !msg.voice)) return; - - if (Environment.ONLY_FOR_CREATOR_MODE && msg.from?.id !== Environment.CREATOR_ID) { - return; - } - - const startsWithPrefix = cmdText.toLowerCase().startsWith(Environment.BOT_PREFIX.toLowerCase()); - const messageWithoutPrefix = cmdText.substring(Environment.BOT_PREFIX.length).trim(); - - if (startsWithPrefix && messageWithoutPrefix.length === 0) { - const prefixResponse = new PrefixResponse(); - if (await checkRequirements(prefixResponse, msg)) { - await prefixResponse.execute(msg); + return; } - return; - } - const textToCheck = startsWithPrefix ? messageWithoutPrefix : cmdText; + if (msg.left_chat_member && msg.left_chat_member.id !== botUser.id) { + const text = randomValue(Environment.ANSWERS.kick); + if (text) { + await enqueueTelegramApiCall( + () => bot.sendMessage({chat_id: msg.chat.id, text}), + {method: "sendMessage", chatId: msg.chat.id, chatType: msg.chat.type} + ).catch(logError); + } + return; + } - if (msg.chat.type !== "private" && (!msg.reply_to_message || msg.reply_to_message.from?.id !== botUser.id) && !startsWithPrefix && !msg.voice) return; + if (Environment.MUTED_IDS.has(from.id)) return; - if (msg.chat.type === "private" && !Environment.ADMIN_IDS.has(msg.chat.id)) return; + if (msg.forward_origin) return; - let voiceB64: string | null = null; + const groupId = msg.media_group_id; + if (groupId) { + const albumKey = `${msg.chat.id}:${groupId}`; + const shouldContinue = await new Promise(resolve => { + if (!albumCache.has(albumKey)) { + albumCache.set(albumKey, { + messages: [msg], + timer: scheduleAlbumProcessing(albumKey), + resolve, + storedMsg, + }); + } else { + const entry = albumCache.get(albumKey); + if (entry) { + entry.messages.push(msg); + clearTimeout(entry.timer); + entry.timer = scheduleAlbumProcessing(albumKey); + } + resolve(false); + } + }); - const modelInfo = await commands.find(c => c instanceof OllamaGetModel)?.getModelCapabilities(); + if (!shouldContinue) return; - if (msg.voice && modelInfo?.audio?.supported) { - const filePath = (await bot.getFile({file_id: msg.voice.file_id})).file_path; - let fileBuffer = await downloadTelegramFile(filePath); - const input = path.join(Environment.DATA_PATH, "input.ogg"); - const output = path.join(Environment.DATA_PATH, "output.wav") + storedMsg = await MessageStore.get(msg.chat.id, msg.message_id) ?? storedMsg; + } - if (fileBuffer) { - try { - fs.writeFileSync(input, fileBuffer); - await performFFmpeg(() => - ffmpeg(input) - .toFormat("wav") - .save(output) - .on("progress", (progress) => { - console.log("progress", progress); - }) - ); + const cmdText = storedMsg?.text || msg.text || msg.caption || ""; - fileBuffer = fs.readFileSync(output); - voiceB64 = fileBuffer.toString("base64"); - fs.rmSync(input); - fs.rmSync(output); + const cmd = searchChatCommand(commands, cmdText); + const executed = await executeChatCommand(cmd, msg, cmdText); - } catch (e) { - logError(e); + const hasAudioAttachment = !!msg.voice || !!msg.audio || !!msg.document?.mime_type?.startsWith("audio/") + || !!msg.video_note; + const hasImageAttachment = !!msg.photo?.length || !!msg.document?.mime_type?.startsWith("image/"); + if (executed || (!cmdText && !hasAudioAttachment && !hasImageAttachment)) return; + + const hasConfiguredPrefix = Environment.BOT_PREFIX.length > 0; + const startsWithPrefix = hasConfiguredPrefix && cmdText.toLowerCase().startsWith(Environment.BOT_PREFIX.toLowerCase()); + const messageWithoutPrefix = startsWithPrefix ? cmdText.substring(Environment.BOT_PREFIX.length).trim() : cmdText.trim(); + + if (startsWithPrefix && messageWithoutPrefix.length === 0) { + const prefixResponse = new PrefixResponse(); + if (await checkRequirements(prefixResponse, msg)) { + await prefixResponse.execute(msg); + } + return; + } + + const textToCheck = startsWithPrefix ? messageWithoutPrefix : cmdText; + + if (msg.chat.type !== "private") { + if (Environment.ONLY_FOR_CREATOR_MODE && from.id !== Environment.CREATOR_ID) { + return; + } + + const isReplyToBot = !!msg.reply_to_message && msg.reply_to_message.from?.id === botUser.id; + const hasPrefix = startsWithPrefix; + const hasBotMention = !!msg.entities?.some(entity => { + if (entity.type !== "mention") return false; + const mention = msg.text?.slice(entity.offset, entity.offset + entity.length) ?? msg.caption?.slice(entity.offset, entity.offset + entity.length) ?? ""; + return mention.toLowerCase() === `@${botUser.username?.toLowerCase()}`; + }); + + if (!isReplyToBot && !hasPrefix && !hasBotMention && !hasAudioAttachment) { + return; } } - } - switch (Environment.DEFAULT_AI_PROVIDER) { - case AiProvider.OLLAMA: { - await commands.find(e => e instanceof OllamaChat)?.executeOllama(msg, textToCheck, false, voiceB64); - break; - } - case AiProvider.GEMINI: { - await commands.find(e => e instanceof GeminiChat)?.executeGemini(msg, textToCheck); - break; - } - case AiProvider.MISTRAL: { - await commands.find(e => e instanceof MistralChat)?.executeMistral(msg, textToCheck); - break; - } - case AiProvider.OPENAI: { - await commands.find(e => e instanceof OpenAIChat)?.executeOpenAI(msg, textToCheck); - break; - } - } + const provider = await resolveEffectiveAiProviderForUser(from.id); + + void runUnifiedAi({ + provider: provider, + msg: msg, + isGuestMsg: !!isGuest, + text: textToCheck, + stream: true, + }).catch(logError); + }); } export async function processEditedMessage(msg: Message): Promise { - console.log("Edited Message", msg); if (!msg.from) return; + Environment.reloadRuntimeConfigIfChanged(); + await UserStore.put(msg.from); if (!extractTextMessage(msg) || msg.from.id === botUser.id) return; @@ -1987,121 +2265,84 @@ export async function processEditedMessage(msg: Message): Promise { } export async function processInlineQuery(query: InlineQuery): Promise { - console.log("InlineQuery", query); + Environment.reloadRuntimeConfigIfChanged(); + const locale = await resolveInterfaceLocaleForUser(query.from.id, query.from.language_code); - if (Environment.CREATOR_ID !== query.from.id) { - await bot.answerInlineQuery({ - inline_query_id: query.id, - results: [], - button: { - text: "No access", - start_parameter: "nope" - } - }).catch(logError); - return; - } - - if (query.query.trim().length !== 0) { - try { - const queryResults: InlineQueryResult[] = []; - const results = await ollama.webSearch({query: query.query}); - - console.log("results", results); - - results.results.forEach((result, i) => { - const r = result as WebSearchResponse; - queryResults.push({ - type: "article", - id: `${i}`, - title: `${r.title}`, - input_message_content: { - message_text: `${r.title}\n\n${r.url}` + await Localization.runWithLocale(locale, async () => { + if (Environment.CREATOR_ID !== query.from.id) { + await enqueueTelegramApiCall( + () => bot.answerInlineQuery({ + inline_query_id: query.id, + results: [], + button: { + text: Environment.noAccessText, + start_parameter: "nope" } - }); - }); - - await bot.answerInlineQuery({ - inline_query_id: query.id, - results: queryResults, - }); - } catch (e) { - logError(e); + }), + {method: "answerInlineQuery", skipPerChatLimit: true} + ).catch(logError); + return; } - } else { - await bot.answerInlineQuery({ - inline_query_id: query.id, - results: [], - }).catch(logError); - } + + if (query.query.trim().length !== 0) { + try { + const target = resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"); + const results = await createOllamaClient(target).webSearch({query: query.query, maxResults: 10}); + const queryResults: InlineQueryResult[] = (results.results ?? []).map((result, index) => { + const content = result.content.trim(); + const [firstLine] = content.split("\n"); + const title = firstLine?.trim().slice(0, 128) || query.query; + + return { + type: "article" as const, + id: `ollama-search-${index}`, + title, + description: content.slice(0, 256), + input_message_content: { + message_text: content, + } + }; + }); + + await enqueueTelegramApiCall( + () => bot.answerInlineQuery({ + inline_query_id: query.id, + results: queryResults, + cache_time: 60, + is_personal: true, + }), + {method: "answerInlineQuery", skipPerChatLimit: true} + ); + } catch (e) { + logError(e); + await enqueueTelegramApiCall( + () => bot.answerInlineQuery({ + inline_query_id: query.id, + results: [], + cache_time: 0, + is_personal: true, + }), + {method: "answerInlineQuery", skipPerChatLimit: true} + ).catch(logError); + } + } else { + await enqueueTelegramApiCall( + () => bot.answerInlineQuery({ + inline_query_id: query.id, + results: [], + }), + {method: "answerInlineQuery", skipPerChatLimit: true} + ).catch(logError); + } + }); } export async function processCallbackQuery(query: CallbackQuery): Promise { - console.log("CallbackQuery", query); - await findAndExecuteCallbackCommand(callbackCommands, query); + Environment.reloadRuntimeConfigIfChanged(); + const locale = await resolveInterfaceLocaleForUser(query.from.id, query.from.language_code); + await Localization.runWithLocale(locale, () => findAndExecuteCallbackCommand(callbackCommands, query)); } -export async function runCommand(cmd: string): - Promise<{ - stdout: string | null | undefined; - stderr: string | null | undefined - }> { - - if (cmd.length > 500) { - throw new Error("Command is too long"); - } - - const forbiddenPatterns = [ - /\bsudo\b/, - /\bsu\b/, - /\brm\b/, - /\brmdir\b/, - /\bchmod\b/, - /\bchown\b/, - /\bdd\b/, - /\bmkfs\b/, - /\bmount\b/, - /\bumount\b/, - /\breboot\b/, - /\bshutdown\b/, - /\bkill\b/, - /\bcurl\b/, - /\bwget\b/, - /\bssh\b/, - /\bscp\b/, - /\brsync\b/, - /\bnc\b/, - /\bnmap\b/, - /\.\./, - /\/etc\/?/, - /\/home\/?/, - /\/root\/?/, - /~\//, - /\.ssh/, - /\.env/, - ]; - - for (const pattern of forbiddenPatterns) { - if (pattern.test(cmd)) { - throw new Error(`Forbidden shell command pattern: ${pattern}`); - } - } - - - try { - const {stdout, stderr} = exec(cmd); - if (stdout) { - console.log("COMMAND: ", cmd, "\n", 'Output:', stdout); - } - - if (stderr) { - console.error("COMMAND: ", cmd, "\n", 'Error:', stderr); - } - - return {stdout: (await stdout?.toArray())?.join(""), stderr: (await stderr?.toArray())?.join("")} - } catch (error: any) { - console.error('Error code:', error.code); - console.error('Stderr:', error.stderr); - - return {stdout: null, stderr: error.stderr}; - } -} \ No newline at end of file +export async function runCommand(cmd: string): Promise { + return ShellCommandRunner.run(cmd); +}