From 0f91e43ea0820acc032330cfb67d4b2f93a2b8e3 Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Fri, 1 May 2026 05:09:10 +0300 Subject: [PATCH] feat: add Ollama audio transcription and runtime config reload - add audio capability reporting for Ollama models - support Telegram voice messages via ffmpeg conversion and Ollama transcription - add USE_SYSTEM_PROMPT toggle and runtime reloading of .env/system prompt settings - support ollama_options.json for custom Ollama request options - improve Telegram MarkdownV2 escaping and formatting preservation - add environment setters for AI provider credentials and models - show audio capability in info/model commands --- src/commands/gemini-chat.ts | 2 +- src/commands/info.ts | 3 +- src/commands/mistral-chat.ts | 2 +- src/commands/ollama-chat.ts | 76 ++- src/commands/ollama-get-model.ts | 9 +- src/commands/openai-chat.ts | 2 +- src/common/environment.ts | 83 ++- src/db/database.ts | 14 - src/index.ts | 8 +- src/model/ai-model-capabilities.ts | 1 + src/util/utils.ts | 954 ++++++++++++++++++++++++----- 11 files changed, 937 insertions(+), 217 deletions(-) diff --git a/src/commands/gemini-chat.ts b/src/commands/gemini-chat.ts index 9a0b53f..5f83381 100644 --- a/src/commands/gemini-chat.ts +++ b/src/commands/gemini-chat.ts @@ -45,7 +45,7 @@ export class GeminiChat extends ChatCommand { }); chatMessages.reverse(); - if (Environment.SYSTEM_PROMPT) { + if (Environment.SYSTEM_PROMPT && Environment.USE_SYSTEM_PROMPT) { chatMessages.unshift({role: "system", content: Environment.SYSTEM_PROMPT}); } diff --git a/src/commands/info.ts b/src/commands/info.ts index e89bb25..f3c20d3 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -36,7 +36,8 @@ export class Info extends Command { `vision${aiModelCapabilities.vision?.external ? "(ext)" : ""}: ${boolToEmoji(aiModelCapabilities.vision?.supported)}\n` + `ocr${aiModelCapabilities.ocr?.external ? "(ext)" : ""}: ${boolToEmoji(aiModelCapabilities.ocr?.supported)}\n` + `thinking${aiModelCapabilities.thinking?.external ? "(ext)" : ""}: ${boolToEmoji(aiModelCapabilities.thinking?.supported)}\n` + - `tools${aiModelCapabilities.tools?.external ? "(ext)" : ""}: ${boolToEmoji(aiModelCapabilities.tools?.supported)}` + + `tools${aiModelCapabilities.tools?.external ? "(ext)" : ""}: ${boolToEmoji(aiModelCapabilities.tools?.supported)}\n` + + `audio${aiModelCapabilities.audio?.external ? "(ext)": ""}: ${boolToEmoji(aiModelCapabilities.audio?.supported)}` + "```"; const cmds = commands.filter(c => !(c instanceof ChatCommand)); diff --git a/src/commands/mistral-chat.ts b/src/commands/mistral-chat.ts index 4a49523..83f5cd5 100644 --- a/src/commands/mistral-chat.ts +++ b/src/commands/mistral-chat.ts @@ -59,7 +59,7 @@ export class MistralChat extends ChatCommand { }); chatMessages.reverse(); - if (Environment.SYSTEM_PROMPT) { + if (Environment.SYSTEM_PROMPT && Environment.USE_SYSTEM_PROMPT) { chatMessages.unshift({role: "system", content: [{type: "text", text: Environment.SYSTEM_PROMPT}]}); } diff --git a/src/commands/ollama-chat.ts b/src/commands/ollama-chat.ts index 8f0962d..5644b6d 100644 --- a/src/commands/ollama-chat.ts +++ b/src/commands/ollama-chat.ts @@ -14,6 +14,10 @@ import {Cancel} from "../callback_commands/cancel"; import {OllamaCancel} from "../callback_commands/ollama-cancel"; import {OllamaGetModel} from "./ollama-get-model"; import {ChatCommand} from "../base/chat-command"; +import {MessagePart} from "../common/message-part"; +import {Options} from "ollama"; +import fs from "node:fs"; +import path from "node:path"; export class OllamaChat extends ChatCommand { command = ["ollamaThink", "ollama"]; @@ -27,25 +31,38 @@ export class OllamaChat extends ChatCommand { return this.executeOllama(msg, match?.[3], match?.[1]?.toLowerCase()?.startsWith("ollamathink")); } - async executeOllama(msg: Message, text: string, think: boolean = false): Promise { - if (!text || text.trim().length === 0) return; + async executeOllama(msg: Message, text: string, think: boolean = false, voiceB64?: string): Promise { + if ((!text || text.trim().length === 0) && !voiceB64) return; const chatId = msg.chat.id; const storedMsg = await MessageStore.get(chatId, msg.message_id); - const messageParts = await collectReplyChainText(storedMsg); - console.log("MESSAGE PARTS", messageParts); - const chatMessages = messageParts.map(part => { + let messageParts: MessagePart[] = []; + + if (!voiceB64) { + messageParts = await collectReplyChainText(storedMsg); + console.log("MESSAGE PARTS", messageParts); + } + + const chatMessages = !voiceB64 ? messageParts.map(part => { return { role: part.bot ? "assistant" : "user", - content: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `MESSAGE FROM USER "${part.name}":\n` : "") + part.content, + content: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `"${part.name}":\n` : "") + part.content, images: part.images }; - }); + }) : [ + { + role: "user", + content: "Transcribe this audio file", + images: [voiceB64] + } + ]; chatMessages.reverse(); - if (Environment.SYSTEM_PROMPT) { + console.log("PARTS", chatMessages); + + if (Environment.SYSTEM_PROMPT && !voiceB64 && Environment.USE_SYSTEM_PROMPT) { chatMessages.unshift({role: "system", content: Environment.SYSTEM_PROMPT, images: []}); } @@ -97,16 +114,28 @@ export class OllamaChat extends ChatCommand { waitMessage = await replyToMessage({ message: msg, - text: (!think && imagesCount) ? + text: (!think && imagesCount && !voiceB64) ? imagesCount > 1 ? Environment.analyzingPicturesText : Environment.analyzingPictureText - : Environment.waitThinkText + : voiceB64 ? Environment.transcribingAudioText : Environment.waitThinkText }); + let options: Partial | null = null + try { + const optionsPath = path.join(Environment.DATA_PATH, "ollama_options.json"); + + if (fs.existsSync(optionsPath)) { + options = JSON.parse(fs.readFileSync(optionsPath).toString()); + } + } catch (e) { + logError(e); + } + const stream = await ollama.chat({ model: think ? Environment.OLLAMA_THINK_MODEL : imagesCount ? Environment.OLLAMA_IMAGE_MODEL : Environment.OLLAMA_MODEL, stream: true, think: think, messages: chatMessages, + options: options }); const newRequest = { @@ -171,7 +200,7 @@ export class OllamaChat extends ChatCommand { chat_id: chatId, message_id: waitMessage.message_id, text: "🤔 Размышляю...", - parse_mode: "Markdown", + parse_mode: "MarkdownV2", reply_markup: cancelMarkup }).catch(logError); } @@ -210,29 +239,32 @@ export class OllamaChat extends ChatCommand { console.log("ended", true); } - const diff = Math.abs(Date.now() - startTime) / 1000; - - await editor.tick(); - await editor.stop(); - - console.log(`aborted request ${uuid}:`, abortOllamaRequest(uuid)); - waitMessage.reply_to_message = msg; waitMessage.text = currentText; await MessageStore.put(waitMessage); - - if (Environment.SEND_TIME_TOOK) { - await replyToMessage({message: waitMessage, text: `⏱️ ${diff}s`}); - } break; } } } finally { + const diff = Math.abs(Date.now() - startTime) / 1000; + + await editor.tick(); + await editor.stop(); + + waitMessage.reply_to_message = msg; + waitMessage.text = currentText; + await MessageStore.put(waitMessage); + + if (Environment.SEND_TIME_TOOK) { + await replyToMessage({message: waitMessage, text: `⏱️ ${diff}s`}); + } + await bot.editMessageReplyMarkup({ chat_id: chatId, message_id: waitMessage.message_id, reply_markup: {inline_keyboard: []} }).catch(logError); + console.log(`aborted request ${uuid}:`, abortOllamaRequest(uuid)); } } catch (error) { if (error.message.toLowerCase().includes("aborted")) return; diff --git a/src/commands/ollama-get-model.ts b/src/commands/ollama-get-model.ts index a334a38..bf3c3f1 100644 --- a/src/commands/ollama-get-model.ts +++ b/src/commands/ollama-get-model.ts @@ -65,8 +65,10 @@ export class OllamaGetModel extends Command { private getModelText(model: string, info: AiModelCapabilities): string { return `model: ${model}\n\n` + `vision: ${boolToEmoji(info.vision?.supported)}\n` + + `ocr: ${boolToEmoji(info.ocr?.supported)}\n` + `thinking: ${boolToEmoji(info.thinking?.supported)}\n` + - `tools: ${boolToEmoji(info.tools?.supported)}`; + `tools: ${boolToEmoji(info.tools?.supported)}\n` + + `audio: ${boolToEmoji(info.audio?.supported)}`; } async getModelCapabilities(model: string = Environment.OLLAMA_MODEL): Promise { @@ -95,6 +97,11 @@ export class OllamaGetModel extends Command { external: model !== Environment.OLLAMA_MODEL, model: model }, + audio: { + supported: info.capabilities.includes("audio"), + external: model !== Environment.OLLAMA_MODEL, + model: model + } }; } catch (e) { logError(e); diff --git a/src/commands/openai-chat.ts b/src/commands/openai-chat.ts index 17b2ae9..0d096ee 100644 --- a/src/commands/openai-chat.ts +++ b/src/commands/openai-chat.ts @@ -59,7 +59,7 @@ export class OpenAIChat extends ChatCommand { }); chatMessages.reverse(); - if (Environment.SYSTEM_PROMPT) { + if (Environment.SYSTEM_PROMPT && Environment.USE_SYSTEM_PROMPT) { chatMessages.unshift({ role: "system", content: [{type: "input_text", text: Environment.SYSTEM_PROMPT}], diff --git a/src/common/environment.ts b/src/common/environment.ts index 452cb15..a14fcaa 100644 --- a/src/common/environment.ts +++ b/src/common/environment.ts @@ -24,8 +24,6 @@ export class Environment { static ANSWERS: Answers; - static USE_NAMES_IN_PROMPT: boolean; - static MAX_PHOTO_SIZE: number; static PROCESS_LINKS: boolean; @@ -37,13 +35,15 @@ export class Environment { static IMAGE_HANDLE_FALLBACK_POLICY: ImageHandleFallbackPolicy; static SYSTEM_PROMPT?: string; + static USE_NAMES_IN_PROMPT: boolean; + static USE_SYSTEM_PROMPT: boolean; static SEND_TIME_TOOK: boolean; + static OLLAMA_API_KEY?: string; static OLLAMA_ADDRESS?: string; static OLLAMA_MODEL?: string; static OLLAMA_IMAGE_MODEL?: string; static OLLAMA_THINK_MODEL?: string; - static OLLAMA_API_KEY?: string; static GEMINI_API_KEY?: string; static GEMINI_MODEL: string; @@ -62,6 +62,7 @@ export class Environment { static waitThinkText = "⏳ Дайте-ка подумать..."; static analyzingPictureText = "🔍 Внимательно изучаю изображение..."; static analyzingPicturesText = "🔍 Внимательно изучаю изображения..."; + static transcribingAudioText = "🦻 Внимательно слушаю аудио..."; static genImageText = "👨‍🎨 Генерирую изображение..."; static ollamaCancelledText = "```Ollama\n❌ Отменено```"; @@ -79,8 +80,6 @@ export class Environment { Environment.ENABLE_UNSAFE_EVAL = ifTrue(process.env.ENABLE_UNSAFE_EVAL); - Environment.USE_NAMES_IN_PROMPT = ifTrue(process.env.USE_NAMES_IN_PROMPT); - Environment.MAX_PHOTO_SIZE = Number(process.env.MAX_PHOTO_SIZE || "1280"); Environment.PROCESS_LINKS = ifTrue(process.env.PROCESS_LINKS); @@ -113,13 +112,15 @@ export class Environment { Environment.IMAGE_HANDLE_FALLBACK_POLICY = ImageHandleFallbackPolicy.NOTIFY_USER; } - Environment.SEND_TIME_TOOK = ifTrue(process.env.SEND_TOOK_TIME || false); + Environment.USE_NAMES_IN_PROMPT = ifTrue(process.env.USE_NAMES_IN_PROMPT); + Environment.USE_SYSTEM_PROMPT = ifTrue(process.env.USE_SYSTEM_PROMPT || "true"); + Environment.SEND_TIME_TOOK = ifTrue(process.env.SEND_TOOK_TIME || "false"); + Environment.OLLAMA_API_KEY = process.env.OLLAMA_API_KEY; Environment.OLLAMA_ADDRESS = process.env.OLLAMA_ADDRESS; Environment.OLLAMA_MODEL = process.env.OLLAMA_MODEL || "gemma3:4b"; Environment.OLLAMA_IMAGE_MODEL = process.env.OLLAMA_IMAGE_MODEL || Environment.OLLAMA_MODEL; Environment.OLLAMA_THINK_MODEL = process.env.OLLAMA_THINK_MODEL || Environment.OLLAMA_MODEL; - Environment.OLLAMA_API_KEY = process.env.OLLAMA_API_KEY; Environment.GEMINI_API_KEY = process.env.GEMINI_API_KEY; Environment.GEMINI_MODEL = process.env.GEMINI_MODEL || "gemini-2.5-flash-lite"; @@ -134,10 +135,26 @@ export class Environment { Environment.OPENAI_IMAGE_MODEL = process.env.OPENAI_IMAGE_MODEL || "gpt-image-1-mini"; } - static setSystemPrompt(prompt: string) { + static setOnlyForCreatorMode(enable: boolean) { + this.ONLY_FOR_CREATOR_MODE = enable; + } + + static setSystemPrompt(prompt: string | undefined) { this.SYSTEM_PROMPT = prompt; } + static setUseNamesInPrompt(use: boolean) { + this.USE_NAMES_IN_PROMPT = use; + } + + static setUseSystemPrompt(use: boolean) { + this.USE_SYSTEM_PROMPT = use; + } + + static setSendTimeTook(send: boolean) { + this.SEND_TIME_TOOK = send; + } + static setAdmins(admins: Set) { this.ADMIN_IDS = admins; } @@ -185,19 +202,59 @@ export class Environment { this.ANSWERS = answers; } - static setOllamaModel(newModel: string) { - Environment.OLLAMA_MODEL = newModel; + static setOllamaApiKey(key: string) { + this.OLLAMA_API_KEY = key; + } + + static setOllamaAddress(address: string) { + this.OLLAMA_ADDRESS = address; + } + + static setOllamaModel(ollamaModel: string) { + this.OLLAMA_MODEL = ollamaModel; + } + + static setOllamaThinkModel(ollamaThinkModel: string) { + this.OLLAMA_THINK_MODEL = ollamaThinkModel; + } + + static setOllamaImageModel(ollamaImageModel: string) { + this.OLLAMA_IMAGE_MODEL = ollamaImageModel; + } + + static setGeminiApiKey(geminiApiKey: string) { + this.GEMINI_API_KEY = geminiApiKey; } static setGeminiModel(newModel: string) { - Environment.GEMINI_MODEL = newModel; + this.GEMINI_MODEL = newModel; + } + + static setGeminiImageModel(newImageModel: string) { + this.GEMINI_IMAGE_MODEL = newImageModel; + } + + static setMistralApiKey(newMistralApiKey: string) { + this.MISTRAL_API_KEY = newMistralApiKey; } static setMistralModel(newModel: string) { - Environment.MISTRAL_MODEL = newModel; + this.MISTRAL_MODEL = newModel; + } + + static setOpenAIBaseUrl(newAIBaseUrl: string) { + this.OPENAI_BASE_URL = newAIBaseUrl; + } + + static setOpenAIApiKey(newAIApiKey: string) { + this.OPENAI_API_KEY = newAIApiKey; } static setOpenAIModel(newModel: string) { - Environment.OPENAI_MODEL = newModel; + this.OPENAI_MODEL = newModel; + } + + static setOpenAIImageModel(newImageModel: string) { + this.OPENAI_IMAGE_MODEL = newImageModel; } } \ No newline at end of file diff --git a/src/db/database.ts b/src/db/database.ts index b9d894c..f913b18 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -2,7 +2,6 @@ import * as fs from "fs"; import {Environment} from "../common/environment"; import {logError} from "../util/utils"; import {Answers} from "../model/answers"; -import path from "node:path"; type DataJsonFile = { admins: number[] @@ -28,19 +27,6 @@ export async function readData(): Promise { } } -export async function readPrompts(): Promise { - try { - const prompt = fs.readFileSync(path.join(Environment.DATA_PATH, "system_prompt.txt")).toString().trim(); - if (prompt.length) { - Environment.setSystemPrompt(prompt); - } - } catch (e) { - logError(e); - } - - return Promise.resolve(); -} - export async function saveData(): Promise { const adminIds: number[] = []; Environment.ADMIN_IDS.forEach(id => adminIds.push(id)); diff --git a/src/index.ts b/src/index.ts index 13f8229..3c1f9d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ import {Ping} from "./commands/ping"; import {RandomString} from "./commands/random-string"; import {SystemInfo} from "./commands/system-info"; import {Test} from "./commands/test"; -import {readData, readPrompts, retrieveAnswers} from "./db/database"; +import {readData, retrieveAnswers} from "./db/database"; import {Uptime} from "./commands/uptime"; import {WhatBetter} from "./commands/what-better"; import {When} from "./commands/when"; @@ -183,7 +183,7 @@ export const callbackCommands: CallbackCommand[] = [ new YtInfo() ]; -if (Environment.OLLAMA_ADDRESS && Environment.OLLAMA_MODEL && Environment.SYSTEM_PROMPT) { +if (Environment.OLLAMA_ADDRESS && Environment.OLLAMA_MODEL) { commands.push( new OllamaChat(), new OllamaPrompt(), @@ -252,10 +252,8 @@ async function shutdown(signal: NodeJS.Signals) { async function main() { const start = Date.now(); - await readPrompts(); - console.log(Environment.SYSTEM_PROMPT); - + console.log( `TEST_ENVIRONMENT: ${Environment.TEST_ENVIRONMENT}\n` + `DATA_PATH: ${Environment.DATA_PATH}\n` + diff --git a/src/model/ai-model-capabilities.ts b/src/model/ai-model-capabilities.ts index ca540fb..40ab9e6 100644 --- a/src/model/ai-model-capabilities.ts +++ b/src/model/ai-model-capabilities.ts @@ -5,4 +5,5 @@ export class AiModelCapabilities { ocr?: AiCapabilityInfo; thinking?: AiCapabilityInfo; tools?: AiCapabilityInfo; + audio?: AiCapabilityInfo; } \ No newline at end of file diff --git a/src/util/utils.ts b/src/util/utils.ts index b12167e..4341a0d 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -1,5 +1,6 @@ import * as si from "systeminformation"; import {Command} from "../base/command"; +import ffmpeg from "fluent-ffmpeg"; import {CallbackCommand} from "../base/callback-command"; import { CallbackQuery, @@ -50,6 +51,7 @@ import VideoInfo from "youtubei.js/dist/src/parser/youtube/VideoInfo"; import {DownloadYtVideo} from "../callback_commands/download-yt-video"; import {TryAgain} from "../callback_commands/try-again"; import {StoredUser} from "../model/stored-user"; +import {performFFmpeg} from "./ffmpeg"; export const ignore = () => { }; @@ -458,105 +460,603 @@ export const delay = (ms: number, signal?: AbortSignal): Promise => const MARKDOWN_V2_RESERVED_RE = /([\\_*\[\]()~`>#+\-=|{}.!])/g; -function escapePlainMarkdownV2(s: string): string { +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 InlineStyleKind = + | "bold" + | "italic" + | "underline" + | "strikethrough" + | "spoiler"; + +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; + }); + } +} + +export function escapePlainMarkdownV2(s: string): string { return s.replace(MARKDOWN_V2_RESERVED_RE, "\\$1"); } -function escapeCodeMarkdownV2(s: string): string { +export function escapeCodeMarkdownV2(s: string): string { return s.replace(/[\\`]/g, "\\$&"); } -function escapeLinkUrlMarkdownV2(s: string): string { +export function escapeLinkUrlMarkdownV2(s: string): string { return s.replace(/[\\)]/g, "\\$&"); } -function escapeMarkdownV2PreservingAllowedFormatting(s: string): string { +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) { - // links: [text](url) + 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 linkMatch = s.slice(i).match(/^\[([^\]\n]+)]\(([^)\n]+)\)/); + const parsed = parseBracketParen(s, i); - if (linkMatch) { - const [, text, url] = linkMatch; - result += `[${escapePlainMarkdownV2(text)}](${escapeLinkUrlMarkdownV2(url)})`; - i += linkMatch[0].length; - continue; + 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; + } } } - // monospace: `text` if (s[i] === "`") { - const end = s.indexOf("`", i + 1); + const end = findInlineCodeEnd(s, i + 1); if (end !== -1) { - const content = s.slice(i + 1, end); - result += "`" + escapeCodeMarkdownV2(content) + "`"; + result += store.add(renderInlineCodeMarkdownV2(s.slice(i + 1, end))); i = end + 1; continue; } } - // spoiler: ||text|| - if (s.startsWith("||", i)) { - const end = s.indexOf("||", i + 2); + result += s[i]; + i++; + } - if (end !== -1) { - const content = s.slice(i + 2, end); - result += "||" + escapeMarkdownV2PreservingAllowedFormatting(content) + "||"; - i = end + 2; - continue; + 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; } - // underline: __text__ - if (s.startsWith("__", i)) { - const end = s.indexOf("__", i + 2); - - if (end !== -1) { - const content = s.slice(i + 2, end); - result += "__" + escapeMarkdownV2PreservingAllowedFormatting(content) + "__"; - i = end + 2; - continue; - } + if (ch === "|") { + cells.push(current.trim()); + current = ""; + continue; } - // bold: *text* - if (s[i] === "*") { - const end = s.indexOf("*", i + 1); + current += ch; + } - if (end !== -1) { - const content = s.slice(i + 1, end); - result += "*" + escapeMarkdownV2PreservingAllowedFormatting(content) + "*"; - i = end + 1; - continue; + 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; } - // italic: _text_ - if (s[i] === "_") { - const end = s.indexOf("_", i + 1); + result.push(current); + i++; + } - if (end !== -1) { - const content = s.slice(i + 1, end); - result += "_" + escapeMarkdownV2PreservingAllowedFormatting(content) + "_"; - i = end + 1; - continue; - } + 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; } - // strikethrough: ~text~ - if (s[i] === "~") { - const end = s.indexOf("~", i + 1); + if (s[i] === "\\") { + i++; + continue; + } - if (end !== -1) { - const content = s.slice(i + 1, end); - result += "~" + escapeMarkdownV2PreservingAllowedFormatting(content) + "~"; - i = end + 1; + 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]); @@ -566,116 +1066,80 @@ function escapeMarkdownV2PreservingAllowedFormatting(s: string): string { return result; } -function unescapeAccidentalMarkdownV2(s: string): string { - let prev: string; +function renderMarkdownV2Line( + line: string, + store: TelegramMarkdownV2TokenStore, +): string { + if (line.startsWith("**>")) { + let content = line.slice(3).replace(/^\s?/, ""); + const isExpandableEnd = content.endsWith("||"); - do { - prev = s; - s = s.replace(/\\([_*\[\]()~`>#+\-=|{}.!\\])/g, "$1"); - } while (s !== prev); + if (isExpandableEnd) { + content = content.slice(0, -2); + } - return s; -} - -function escapeTelegramQuoteLine(line: string): string { - const content = line.replace(/^>\s*/, ""); - - if (!content.trim()) { - return ">"; + return `**>${formatInlineMarkdownV2(content, store)}${isExpandableEnd ? "||" : ""}`; } - return ">" + escapeMarkdownV2PreservingAllowedFormatting(content); + if (line.startsWith(">")) { + const content = line.slice(1).replace(/^\s?/, ""); + + if (!content.trim()) { + return ">"; + } + + return ">" + formatInlineMarkdownV2(content, store); + } + + return formatInlineMarkdownV2(line, store); } -function normalizeTelegramQuoteLines(s: string): string { +function renderMarkdownV2( + s: string, + store: TelegramMarkdownV2TokenStore, +): string { return s .split("\n") - .map(line => { - if (!line.startsWith(">")) return line; - - return line.replace(/^>\s+/, ">"); - }) + .map(line => renderMarkdownV2Line(line, store)) .join("\n"); } -function looksLikeMarkdownTableRow(line: string): boolean { - const trimmed = line.trim(); - - if (trimmed.startsWith("||") && trimmed.endsWith("||")) { - return false; +// 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 pipeCount = (trimmed.match(/\|/g) ?? []).length; + const safeText = text.replace(/\\/g, "\\\\").replace(/]/g, "\\]"); + const safeUnix = Math.trunc(unix); + const formatPart = format !== undefined ? `&format=${format}` : ""; - if (pipeCount < 2) { - return false; - } - - return trimmed.startsWith("|") || trimmed.endsWith("|") || pipeCount >= 2; + return `![${safeText}](tg://time?unix=${safeUnix}${formatPart})`; } -function isMarkdownTableSeparator(line: string): boolean { - return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line); +// 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})`; } -function normalizeMarkdownTables(s: string): string { - return s - .split("\n") - .filter(line => !isMarkdownTableSeparator(line)) - .map(line => { - if (!looksLikeMarkdownTableRow(line)) { - return line; - } +export function escapeMarkdownV2Text(input: string): string { + const store = new TelegramMarkdownV2TokenStore(); - return line - .replace(/^\s*\|/, "") - .replace(/\|\s*$/, "") - .split("|") - .map(cell => cell.trim()) - .filter(Boolean) - .join(" — "); - }) - .join("\n"); -} + let s = normalizeLineEndings(input); -export function escapeMarkdownV2Text(s: string): string { - s = unescapeAccidentalMarkdownV2(s); - s = normalizeTelegramQuoteLines(s); - - s = s.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - - s = s.replace(/^\s*[-*_]{3,}\s*$/gm, "— — —"); - s = s.replace(/^\s*[-*+]\s+(?=\S)/gm, "• "); - s = s.replace(/\*\*(.+?)\*\*/gs, "*$1*"); - s = s.replace(/~~(.+?)~~/gs, "~$1~"); - s = s.replace(/^#{1,6}\s+/gm, ""); - - s = s.replace(/```[a-zA-Z0-9_-]*\n?([\s\S]*?)```/g, (_, code) => { - return code.trim(); - }); - - s = s.replace(/!\[([^\]]*)]\(([^)]+)\)/g, (_, alt, url) => { - return alt ? `${alt}: ${url}` : url; - }); - - s = normalizeMarkdownTables(s); - - s = s - .split("\n") - .map(line => { - if (line.startsWith(">")) { - return escapeTelegramQuoteLine(line); - } - - if (line === ">") { - return ">"; - } - - return escapeMarkdownV2PreservingAllowedFormatting(line); - }) - .join("\n"); - - s = s.replace(/\n{3,}/g, "\n\n"); + 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(); } @@ -751,8 +1215,8 @@ export function cutPrefixes(msg: Message | StoredMessage | string): string { return newText; } -export function isStoredMessage(msg: Message | StoredMessage): msg is StoredMessage { - return "id" in msg; +export function isStoredMessage(msg: Message | StoredMessage | null): msg is StoredMessage { + return msg && "id" in msg; } export async function loadImagesIfExists(msg: Message | StoredMessage): Promise { @@ -1263,8 +1727,8 @@ export function ifTrue(exp?: any): boolean { return ["true", "t", "y", 1, "1"].includes(exp); } -export function boolToEmoji(bool: boolean): string { - return bool ? "✅" : "❌"; +export function boolToEmoji(bool: boolean | undefined): string { + return !!bool ? "✅" : "❌"; } export const albumCache = new Map(); @@ -1309,15 +1773,19 @@ export async function getCurrentModelCapabilities(): Promise 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 result = { vision: (await ollamaGetModel.loadImageModelInfo()).vision, ocr: null, thinking: (await ollamaGetModel.loadThinkModelInfo()).thinking, - tools: (await ollamaGetModel.getModelCapabilities()).tools + tools: defaultModelCapabilities.tools, + audio: defaultModelCapabilities.audio }; resolve(result); } catch (e) { @@ -1327,15 +1795,15 @@ export async function getCurrentModelCapabilities(): Promise c instanceof GeminiGetModel).getModelCapabilities(); + promise = commands.find(c => c instanceof GeminiGetModel)?.getModelCapabilities(); break; } case AiProvider.MISTRAL: { - promise = commands.find(c => c instanceof MistralGetModel).getModelCapabilities(); + promise = commands.find(c => c instanceof MistralGetModel)?.getModelCapabilities(); break; } case AiProvider.OPENAI: { - promise = commands.find(c => c instanceof OpenAIGetModel).getModelCapabilities(); + promise = commands.find(c => c instanceof OpenAIGetModel)?.getModelCapabilities(); break; } } @@ -1357,6 +1825,142 @@ export async function processMyChatMember(u: ChatMemberUpdated): Promise { export async function processNewMessage(msg: Message): Promise { console.log("New Message", msg); + const envFile: string = fs.readFileSync(".env").toString(); + const env = new Map( + envFile + .split(/\r?\n/) + .filter(line => line.trim()) + .map(line => { + const [key, value = ""] = line.split("="); + return [key.trim(), value] as const; + }) + ); + + const getEnv = (key: string): string | undefined => { + return env.get(key)?.trim(); + }; + + const onlyForCreatorMode = getEnv("ONLY_FOR_CREATOR_MODE"); + + const defaultAiProvider = getEnv("DEFAULT_AI_PROVIDER"); + + let systemPrompt: string | null = null; + try { + const promptPath = path.join(Environment.DATA_PATH, "system_prompt.txt"); + if (fs.existsSync(promptPath)) { + systemPrompt = fs.readFileSync(promptPath).toString().trim(); + } else { + Environment.setSystemPrompt(undefined); + } + } catch (e) { + logError(e); + } + const useNamesInPrompt = getEnv("USE_NAMES_IN_PROMPT"); + const useSystemPrompt = getEnv("USE_SYSTEM_PROMPT"); + const sendTimeTook = getEnv("SEND_TIME_TOOK"); + + const ollamaApiKey = getEnv("OLLAMA_API_KEY"); + const ollamaAddress = getEnv("OLLAMA_ADDRESS"); + const ollamaModel = getEnv("OLLAMA_MODEL"); + const ollamaImageModel = getEnv("OLLAMA_IMAGE_MODEL"); + const ollamaThinkModel = getEnv("OLLAMA_THINK_MODEL"); + + const geminiApiKey = getEnv("GEMINI_API_KEY"); + const geminiModel = getEnv("GEMINI_MODEL"); + const geminiImageModel = getEnv("GEMINI_IMAGE_MODEL"); + + const mistralApiKey = getEnv("MISTRAL_API_KEY"); + const mistralModel = getEnv("MISTRAL_MODEL"); + + const openAiBaseUrl = getEnv("OPENAI_BASE_URL"); + const openAiApiKey = getEnv("OPENAI_API_KEY"); + const openAiModel = getEnv("OPENAI_MODEL"); + const openAiImageModel = getEnv("OPENAI_IMAGE_MODEL"); + + if (onlyForCreatorMode) { + Environment.setOnlyForCreatorMode(ifTrue(onlyForCreatorMode)); + } + + if (defaultAiProvider) { + if (Object.values(AiProvider).includes(defaultAiProvider as AiProvider)) { + Environment.DEFAULT_AI_PROVIDER = defaultAiProvider as AiProvider; + } else { + Environment.DEFAULT_AI_PROVIDER = AiProvider.OLLAMA; + } + } + + if (systemPrompt) { + Environment.setSystemPrompt(systemPrompt); + } + + if (useNamesInPrompt) { + Environment.setUseNamesInPrompt(ifTrue(useNamesInPrompt)) + } + + if (useSystemPrompt) { + Environment.setUseSystemPrompt(ifTrue(useSystemPrompt)); + } + + if (sendTimeTook) { + Environment.setSendTimeTook(ifTrue(sendTimeTook)); + } + + if (ollamaApiKey) { + Environment.setOllamaApiKey(ollamaApiKey); + } + + if (ollamaAddress) { + Environment.setOllamaAddress(ollamaAddress); + } + + if (ollamaModel) { + Environment.setOllamaModel(ollamaModel); + } + + if (ollamaImageModel) { + Environment.setOllamaImageModel(ollamaImageModel); + } + + if (ollamaThinkModel) { + Environment.setOllamaThinkModel(ollamaThinkModel); + } + + if (geminiApiKey) { + Environment.setGeminiApiKey(geminiApiKey); + } + + if (geminiModel) { + Environment.setGeminiModel(geminiModel); + } + + if (geminiImageModel) { + Environment.setGeminiImageModel(geminiImageModel); + } + + if (mistralApiKey) { + Environment.setMistralApiKey(mistralApiKey); + } + + if (mistralModel) { + Environment.setMistralModel(mistralModel); + } + + if (openAiBaseUrl) { + Environment.setOpenAIBaseUrl(openAiBaseUrl); + } + + if (openAiApiKey) { + Environment.setOpenAIApiKey(openAiApiKey); + } + + if (openAiModel) { + Environment.setOpenAIModel(openAiModel); + } + + if (openAiImageModel) { + Environment.setOpenAIImageModel(openAiImageModel); + } + let storedMsg: StoredMessage | null = null; try { @@ -1421,7 +2025,11 @@ export async function processNewMessage(msg: Message): Promise { const diff = now - then; console.log("diff", diff); - if (executed || !cmdText) return; + 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(); @@ -1438,13 +2046,43 @@ export async function processNewMessage(msg: Message): Promise { if (Environment.PROCESS_LINKS && await processYouTubeLink(msg, getFirstLink(msg))) return; - if (msg.chat.type !== "private" && (!msg.reply_to_message || msg.reply_to_message.from.id !== botUser.id) && !startsWithPrefix) return; + if (msg.chat.type !== "private" && (!msg.reply_to_message || msg.reply_to_message.from.id !== botUser.id) && !startsWithPrefix && !msg.voice) return; if (msg.chat.type === "private" && !Environment.ADMIN_IDS.has(msg.chat.id)) return; + let voiceB64: string | null = null; + + const modelInfo = await commands.find(c => c instanceof OllamaGetModel).getModelCapabilities(); + + 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") + + try { + fs.writeFileSync(input, fileBuffer); + await performFFmpeg(() => + ffmpeg(input) + .toFormat("wav") + .save(output) + .on("progress", (progress) => { + console.log("progress", progress); + }) + ); + + fileBuffer = fs.readFileSync(output); + voiceB64 = fileBuffer.toString("base64"); + fs.rmSync(input); + fs.rmSync(output); + } catch (e) { + logError(e); + } + } + switch (Environment.DEFAULT_AI_PROVIDER) { case AiProvider.OLLAMA: { - await commands.find(e => e instanceof OllamaChat).executeOllama(msg, textToCheck); + await commands.find(e => e instanceof OllamaChat).executeOllama(msg, textToCheck, false, voiceB64); break; } case AiProvider.GEMINI: {