27 Commits

Author SHA1 Message Date
melod1n 78932e82af shitton 2026-05-13 16:07:47 +03:00
melod1n a411c6874a shitton 2026-05-13 14:58:53 +03:00
melod1n bd548a9f43 shitton 2026-05-13 13:36:51 +03:00
melod1n 674c3cbd44 shitton 2026-05-13 12:05:55 +03:00
melod1n c5b61ee3d8 shitton 2026-05-13 10:18:54 +03:00
melod1n cd8d2683c0 shitton 2026-05-13 05:10:51 +03:00
melod1n 3848dd82d9 feat(ai): improve runtime capability reporting and context settings
Add explicit chat capability tracking, expose formatted runtime capabilities
in the info command, and support a max context size option for user AI settings.

Also update Ollama base URL resolution to use OLLAMA_ADDRESS and simplify
provider chat command execution.
2026-05-11 02:01:44 +03:00
melod1n d2464b9b21 app: wire new commands and update docs 2026-05-10 22:53:43 +03:00
melod1n 94d695e008 commands: localize generic bot commands 2026-05-10 22:53:32 +03:00
melod1n 3d14e3c0d5 commands: switch AI commands to unified runtime 2026-05-10 22:53:22 +03:00
melod1n 1b94760b21 ai: add RAG, speech-to-text and text-to-speech 2026-05-10 22:53:07 +03:00
melod1n 355ae8e5da ai: add common tool runtime and built-in tools 2026-05-10 22:52:48 +03:00
melod1n 32c35f54aa ai: add unified runtime and provider adapters 2026-05-10 22:52:35 +03:00
melod1n 4c2a5471df utils: add shared locks, queues, rendering and message helpers 2026-05-10 22:52:25 +03:00
melod1n d666244863 storage: persist message attachments and user AI settings 2026-05-10 22:52:10 +03:00
melod1n 28f67aefc2 config: add env schema and localization foundation 2026-05-10 22:51:52 +03:00
melod1n 986d4aca46 build: update dependencies and project config 2026-05-10 22:50:04 +03:00
melod1n 35354a86de refactor: centralize runtime config loading
- Move .env parsing and runtime config reload logic into Environment
- Reload runtime config and system prompt when source files change
- Gate unsafe eval and file tools behind explicit environment flags
- Rename datetime tool to get_datetime and improve tool prompts
- Return structured weather tool responses
- Preserve assistant thinking and aggregate tool calls across stream chunks
2026-05-03 19:45:18 +03:00
melod1n 2fc60806ff feat(ollama): add tool calling support
Add Ollama tool integration for web search, weather, datetime, filesystem operations, and shell evaluation. Implement multi-round tool call handling, streaming updates, thinking cleanup, and safer path validation for file tools.

Also add related environment configuration, command execution helper, MarkdownV2 cancel handling fixes, and remove unused TryAgain callback command.
2026-05-03 15:16:14 +03:00
melod1n 86b26813e2 update tsconfig.build.json 2026-05-01 07:11:10 +03:00
melod1n ca7caf7a51 update tsconfig.build.json 2026-05-01 07:08:57 +03:00
melod1n 13b41c3026 bump libs
migrate to typescript 6
remove ytdl feature
2026-05-01 07:05:17 +03:00
melod1n ac51702f00 update @mistralai lib 2026-05-01 05:35:37 +03:00
melod1n 0a34e15a22 fix gemini image analyze text 2026-05-01 05:27:03 +03:00
melod1n c24bc8394b fix 2026-05-01 05:18:39 +03:00
melod1n 0f91e43ea0 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
2026-05-01 05:09:10 +03:00
melod1n 382e00ce31 bump libs 2026-05-01 04:54:11 +03:00
159 changed files with 21433 additions and 8419 deletions
+63 -3
View File
@@ -31,25 +31,85 @@ ONLY_FOR_CREATOR_MODE=false
# Use user names in AI prompts
USE_NAMES_IN_PROMPT=true
# Custom system prompt for AI (or put it into data/SYSTEM_PROMPT.md)
SYSTEM_PROMPT=
# Maximum photo size in pixels
MAX_PHOTO_SIZE=1280
# Directory with localization JSON files
LOCALES_DIR=locales
# ============================================
# AI MODELS CONFIGURATION (Optional)
# ============================================
# Google Gemini
GEMINI_API_KEY=
# google: official Gemini API via @google/genai; openai: OpenAI-compatible Gemini endpoint; auto: infer from GEMINI_BASE_URL
GEMINI_API_MODE=google
GEMINI_MODEL=gemini-2.5-flash
GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
GEMINI_TRANSCRIPTION_MODEL=gemini-2.5-flash
GEMINI_TTS_MODEL=gemini-2.5-flash-preview-tts
GEMINI_TTS_VOICE=Kore
GEMINI_MAX_CONCURRENT_REQUESTS=3
# Mistral AI
MISTRAL_API_KEY=
MISTRAL_MODEL=mistral-small-latest
MISTRAL_TRANSCRIPTION_MODEL=voxtral-mini-latest
MISTRAL_TTS_MODEL=
MISTRAL_TTS_VOICE_ID=
MISTRAL_MAX_CONCURRENT_REQUESTS=3
# Ollama (Local AI Model)
OLLAMA_ADDRESS=
OLLAMA_MODEL=
OLLAMA_CHAT_MODEL=
OLLAMA_IMAGE_MODEL=
OLLAMA_THINK_MODEL=
OLLAMA_AUDIO_MODEL=gemma4:e2b
OLLAMA_API_KEY=
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
OLLAMA_RAG_CHUNK_SIZE=1400
OLLAMA_RAG_CHUNK_OVERLAP=220
OLLAMA_RAG_TOP_K=8
OLLAMA_RAG_MAX_CONTEXT_CHARS=14000
OLLAMA_RAG_MIN_SCORE=0.12
OLLAMA_RAG_MAX_ARCHIVE_FILES=200
OLLAMA_RAG_MAX_ARCHIVE_BYTES=52428800
OLLAMA_RAG_MAX_ARCHIVE_DEPTH=2
OLLAMA_MAX_CONCURRENT_REQUESTS=1
# Custom system prompt for AI
SYSTEM_PROMPT=
# OpenAI
OPENAI_API_KEY=
OPENAI_BASE_URL=
OPENAI_MODEL=gpt-4.1-nano
OPENAI_IMAGE_MODEL=gpt-image-1-mini
OPENAI_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe
OPENAI_TTS_MODEL=gpt-4o-mini-tts
OPENAI_TTS_VOICE=alloy
OPENAI_TTS_INSTRUCTIONS=
OPENAI_MAX_CONCURRENT_REQUESTS=3
# Per-capability AI endpoint overrides
# Pattern:
# <PROVIDER>_<CAPABILITY>_MODEL=
# <PROVIDER>_<CAPABILITY>_BASE_URL=
# <PROVIDER>_<CAPABILITY>_API_KEY=
#
# Providers: OLLAMA, GEMINI, MISTRAL, OPENAI
# Capabilities: CHAT, VISION, OCR, THINKING, EXTENDED_THINKING, TOOLS, AUDIO,
# DOCUMENTS, OUTPUT_IMAGES, SPEECH_TO_TEXT, TEXT_TO_SPEECH
# Endpoint aliases are also supported: *_ENDPOINT and *_ADDRESS.
# Provider-wide fallback endpoints: OPENAI_BASE_URL, MISTRAL_BASE_URL,
# GEMINI_BASE_URL, OLLAMA_ADDRESS or OLLAMA_BASE_URL.
# Capability aliases are also supported: IMAGE, THINK, RAG, EMBEDDING,
# TRANSCRIPTION, STT, TTS.
#
# Examples:
# OPENAI_SPEECH_TO_TEXT_MODEL=gpt-4o-mini-transcribe
# OPENAI_SPEECH_TO_TEXT_BASE_URL=https://api.openai.com/v1
# OPENAI_SPEECH_TO_TEXT_API_KEY=
# MISTRAL_TTS_BASE_URL=
# OLLAMA_DOCUMENTS_ADDRESS=http://localhost:11434
-30
View File
@@ -1,30 +0,0 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"overrides": [
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"quotes": [
"error",
"double"
],
"semi": [
"error",
"always"
]
}
}
+1
View File
@@ -25,5 +25,6 @@ USER node
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/assets ./assets
COPY --chown=node:node locales ./locales
CMD ["node", "dist/index.js"]
+1
View File
@@ -23,5 +23,6 @@ RUN bun install --frozen-lockfile --production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/assets ./assets
COPY locales ./locales
CMD [ "bun", "dist/index.js" ]
+9 -1
View File
@@ -9,6 +9,13 @@ cp .env.example .env
# Edit .env: add BOT_TOKEN, CREATOR_ID and configure optional AI models (GEMINI_API_KEY, MISTRAL_API_KEY, OLLAMA_ADDRESS)
```
For local Ollama document RAG, install an embedding model locally and set it in `.env`:
```bash
ollama pull nomic-embed-text
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
```
**With Bun (Recommended):**
```bash
bun install
@@ -42,13 +49,14 @@ docker run -d --env-file .env -v $(pwd)/data:/config/data tg-bot-bun
## Requirements
- Node.js >= 18 OR Bun >= 1.0
- Node.js >= 20 OR Bun >= 1.0
- Docker (optional)
## Features
- AI chat (Gemini, Mistral, Ollama)
- Local document RAG for Ollama without third-party providers
- Custom answers and commands
- Admin management
- User blocking (mute/unmute)
+106 -586
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -3,6 +3,8 @@ services:
container_name: tgchatbot
image: ghcr.io/melod1n/tg-chat-bot:latest
restart: unless-stopped
env_file:
- .env
environment:
- PUID=1000
- PGID=1000
+7 -1
View File
@@ -1,11 +1,17 @@
import "dotenv/config";
import {defineConfig} from "drizzle-kit";
import path from "node:path";
const dataPath = process.env.DATA_PATH
?? (process.env.IS_DOCKER === "true" ? "/" + path.join("config", "data") : "data");
const dbFileName = process.env.DB_FILE_NAME ?? "database.db";
export default defineConfig({
out: "./drizzle",
schema: "./src/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: process.env.DB_FILE_NAME,
url: process.env.DB_FILE_NAME
// url: process.env.DB_PATH ? "file:" + process.env.DB_PATH : "file:" + path.join(dataPath, dbFileName),
},
});
+31
View File
@@ -0,0 +1,31 @@
const tsParser = require("@typescript-eslint/parser");
const tsPlugin = require("@typescript-eslint/eslint-plugin");
module.exports = [
{
ignores: [
"dist/**",
"node_modules/**",
],
},
{
files: ["**/*.ts"],
languageOptions: {
parser: tsParser,
ecmaVersion: "latest",
sourceType: "module",
},
linterOptions: {
reportUnusedDisableDirectives: "off",
},
plugins: {
"@typescript-eslint": tsPlugin,
},
rules: {
"@typescript-eslint/no-unused-vars": "off",
"no-unused-vars": "off",
"quotes": "warn",
"semi": "error",
},
},
];
+223
View File
@@ -0,0 +1,223 @@
{
"language": {
"default": "Default",
"en": "English",
"ru": "Russian",
"ua": "Ukrainian",
"instructionName": "English"
},
"providerChoice.default": "Default",
"errorText": "⚠️ An error occurred.",
"waitThinkText": "⏳ Let me think...",
"analyzingPictureText": "🔍 Analyzing the image...",
"analyzingPicturesText": "🔍 Analyzing the images...",
"reasoningText": "🤔 Reasoning...",
"transcribingAudioText": "🦻 Transcribing audio...",
"genImageText": "👨‍🎨 Generating an image...",
"cancelText": "❌ Cancel",
"regenerateText": "🔄 Regenerate",
"aiCancelCallbackText": "Cancel AI generation",
"aiRegenerateCallbackText": "Regenerate AI response",
"userSettingsCallbackText": "User settings",
"noAccessText": "No access",
"notBotCreatorText": "You are not the bot creator.",
"notBotAdministratorText": "You are not a bot administrator.",
"notAChatText": "This is not a chat.",
"notChatAdministratorText": "You are not a chat administrator.",
"botNotChatAdministratorText": "The bot is not a chat administrator.",
"replyRequiredText": "A reply to a message is required.",
"onlyOriginalAuthorText": "Only the author of the original message can perform this action.",
"commandsHeaderText": "Commands:\n\n",
"sentCommandsInDmText": "Sent commands in DM 😎",
"couldNotSendCommandsInDmText": "Could not send commands in DM ☹️\nSending them here instead",
"administratorsHeaderText": "*Administrators*:\n\n",
"noUserInfoText": "No user information",
"useLeaveCommandText": "Use /leave",
"databaseBackupCaption": "Database backup",
"databaseBackupSentText": "Successfully sent to the creator in DM!",
"noChoicesText": "Nothing to choose from",
"qrCodeMissingTextText": "No text found for QR code generation.",
"quoteMissingTextText": "Could not find text in the message 😢",
"quoteBuildFailedText": "Could not build the quote 😢",
"speechToTextInstructionText": "Send audio/voice/video-note or reply with /stt to a message containing audio.",
"speechToTextEmptyResultText": "Speech-to-text did not return transcription text.",
"textToSpeechInstructionText": "Send text after the command or reply with /tts to a message containing text.",
"titleMissingText": "Could not find a title...",
"betterFallbackText": "Better",
"pongText": "pong",
"modelListHeaderText": "Available models:\n\n",
"modelListLoadFailedText": "Could not load the model list",
"noCurrentModelText": "Model is not set. Use one of the listed values.",
"unsupportedAttachmentText": "This attachment type is not supported.",
"attachmentMissingFromCacheText": "Attachment file is missing from cache.",
"noSupportedTranscriptionProviderText": "No supported speech-to-text provider is configured.",
"noSupportedTextToSpeechProviderText": "No supported text-to-speech provider is configured.",
"noSpeechToTextProviderForAccessText": "No speech-to-text providers are configured for your access level.",
"noTextToSpeechProviderForAccessText": "No text-to-speech providers are configured for your access level.",
"noTextToSynthesizeText": "No text to synthesize.",
"speechFileTooLargeText": "The speech file is larger than 50 MB and cannot be sent.",
"userSettingsTitle": "User Settings",
"userSettingsAiProviderSelectionTitle": "AI Provider Selection",
"userSettingsInterfaceLanguageSelectionTitle": "Interface Language Selection",
"userSettingsResponseLanguageSelectionTitle": "Response Language Selection",
"userSettingsContextSizeSelectionTitle": "Context Size Selection",
"userSettingsVoiceModeSelectionTitle": "Voice Message Mode Selection",
"userSettingsTierLabel": "Tier",
"userSettingsAiProviderLabel": "AI provider",
"userSettingsInterfaceLanguageLabel": "Interface language",
"userSettingsResponseLanguageLabel": "LLM response language",
"userSettingsContextSizeLabel": "Context size",
"userSettingsVoiceModeLabel": "Voice messages",
"userSettingsBackButtonText": "Back",
"userSettingsAiProviderButtonPrefix": "AI provider",
"userSettingsInterfaceLanguageButtonPrefix": "Interface language",
"userSettingsResponseLanguageButtonPrefix": "Response language",
"userSettingsContextSizeButtonPrefix": "Context",
"userSettingsVoiceModeButtonPrefix": "Voice",
"userSettingsCreatorTierText": "Creator",
"userSettingsAdminTierText": "Admin",
"userSettingsUserTierText": "User",
"userSettingsSelectedPrefix": "✓ ",
"userSettingsContextSizeDefaultText": "Default",
"userSettingsVoiceModeExecuteText": "Run through AI",
"userSettingsVoiceModeTranscriptText": "Show transcript only",
"startingImageGenText": "🌈 Starting image generation...",
"imageGenText": "🌈 Generating image...",
"finalizingImageGenText": "🌈 Finalizing image generation...",
"botCannotMakeItselfAdminText": "The bot cannot make itself an admin",
"botCreatorAlreadyAdminText": "The bot creator is already an admin",
"botCannotRemoveItselfFromAdminsText": "The bot cannot remove itself from admins",
"botCreatorCannotStopBeingAdminText": "The bot creator cannot stop being an admin",
"botWillNotBanCreatorText": "The bot will not ban its creator.",
"botWillNotBanAdminsText": "The bot will not ban its administrators.",
"botIsNotBannedByItselfText": "The bot is not banned by itself anyway.",
"botCreatorNeverBannedText": "The bot creator is not banned and never will be.",
"botAdminsNotBannedText": "Bot administrators are not banned anyway.",
"botWillNotIgnoreItselfText": "The bot will not ignore itself.",
"botWillNotIgnoreCreatorText": "The bot will not ignore its creator.",
"botWillNotIgnoreAdminsText": "The bot will not ignore its administrators.",
"botIsNotIgnoredByItselfText": "The bot is not ignored by itself anyway.",
"botCreatorNotIgnoredText": "The bot creator is not ignored and never will be.",
"botAdminsNotIgnoredText": "Bot administrators are not ignored anyway.",
"botAlreadyAlwaysListensToItselfText": "The bot already always listens to itself",
"botAlwaysListensToCreatorText": "The bot always listens to its creator",
"coinHeadsText": "Heads",
"coinTailsText": "Tails",
"distortReplyInstructionText": "Reply with /distort to a message containing an image (photo, document, or sticker).\nExample: /distort 16 80",
"distortMissingImageText": "I do not see an image in the reply. Send a photo or image file.",
"shutdownFallbackText": "...",
"shutdownSequenceTexts": [
"well then, everyone",
"it was nice talking to you",
"but it is time for me to rest",
"all the best"
],
"shutdownDoneText": "*R.I.P*",
"whenNowText": "right now",
"whenNeverText": "never",
"whenYearUnitText": "year",
"whenDayUnitText": "day",
"whenWeekUnitText": "week",
"whenMonthUnitText": "month",
"whenHourUnitText": "hour",
"whenMinuteUnitText": "minute",
"whenSecondUnitText": "second",
"getCancelledText": "{provider}\n❌ Generation cancelled.",
"getPartialImageGenText": "🌈 Generating image ({iteration}/{total})...",
"getImageGenDoneText.withModel": "👨‍🎨 Image generated. Model: `{model}`.",
"getImageGenDoneText.default": "👨‍🎨 Image generated.",
"getErrorText.withReason": "{errorText} Reason:\n{reason}",
"getUseToolText.python": "👨‍💻 Running `Python`",
"getUseToolText.default": "🔧 Using tool `{name}`",
"getAnalyzingDocumentText.default": "🔍 Analyzing the document...",
"getAnalyzingDocumentText.single": "🔍 Analyzing document: `{name}`",
"getAnalyzingDocumentText.many": "🔍 Analyzing documents: {names}",
"getPreparingRAGText.default": "🔍 Preparing RAG for the document...",
"getPreparingRAGText.single": "🔍 Preparing RAG for document: `{name}`",
"getPreparingRAGText.many": "🔍 Preparing RAG for documents: {names}",
"getBuildingRAGIndexText.default": "🧠 Building RAG index...",
"getBuildingRAGIndexText.withModel": "🧠 Building RAG index: `{modelName}`.",
"queueNoneText": "none",
"getAiQueueText.queued": "⏳ Request to {provider} is queued.",
"getAiQueueText.ahead": "Requests ahead: {count}.",
"getTelegramFileTooLargeText": "File {fileName} is larger than {maxSizeMb} MB and cannot be sent.",
"getUserIsNowAdminText": "{name} is now an admin!",
"getUserAlreadyAdminText": "{name} is already an admin 🤔",
"getUserNoLongerAdminText": "{name} is no longer an admin!",
"getUserWasNotAdminText": "{name} was not an admin 🤔",
"getUserBannedText": "{name} banned 🚫",
"getUserBanFailedText": "Could not ban {name} ☹️",
"getUserUnbannedText": "{name} unbanned ⛓️‍💥",
"getUserUnbanFailedText": "Could not unban {name} ☹️",
"getUserIgnoredText": "{name} is muted! 🔇",
"getUserAlreadyIgnoredText": "{name} is already muted 🤔",
"getUserIgnoreFailedText": "Could not mute {name} ☹️",
"getUserUnignoredText": "{name} is no longer muted! 🔈",
"getUserWasNotIgnoredText": "{name} was not muted 🤔",
"getUserUnignoreFailedText": "Could not unmute {name} ☹️",
"getChoiceText": "Chose *{choice}*",
"getCoinResultText": "It landed on *{result}*",
"getLoadedModelsText": "Loaded models: {models}",
"getSelectedModelText": "Selected model: `{model}`",
"getCurrentModelText": "Current model: `{model}`",
"getLoadingModelText": "Loading model `{model}`...",
"getUserSettingsContextSizeText": "{size} tokens",
"getQrCodeTextTooLongText": "Text is too long for QR ({actualLength} characters). It will be trimmed to {maxLength} characters.",
"getQrCodeReadyText": "QR code ready ✅\nContent:\n<blockquote expandable>{content}</blockquote>",
"getQrCodeFailedText": "Could not generate QR: {reason}",
"getWhenPrefixText": "in ",
"getWhenPluralUnitText": "{unit}s",
"getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": {
"ae": "evaluation",
"adminsAdd": "Add user to admins",
"adminsRemove": "Remove user from admins",
"ban": "ban user from chat",
"choice": "Choose a random value",
"coin": "Heads or tails",
"debug": "Returns msg (or reply) as json",
"dice": "Sends random or specific dice",
"distort": "Distortion of picture",
"geminiChat": "Chat with AI (Gemini)",
"geminiGetModel": "Get current Gemini model",
"geminiListModels": "List all Gemini models",
"geminiSetModel": "Set Gemini model",
"help": "Show list of commands",
"id": "ID of chat, user and reply (if replied to any message)",
"ignore": "Bot will ignore user",
"info": "Info about bot",
"leave": "Bot will leave current chat",
"mistralChat": "Chat with AI (Mistral)",
"mistralGetModel": "Get current Mistral model",
"mistralListModels": "List all Mistral models",
"mistralSetModel": "Set Mistral model",
"ollamaChat": "Chat with AI (Ollama)",
"ollamaGetModel": "Get current Ollama model",
"ollamaListModels": "List all Ollama models",
"ollamaSearch": "Web search via Ollama",
"ollamaSetModel": "Set Ollama model",
"openAiChat": "Chat with AI (OpenAI)",
"openAiGetModel": "Get current OpenAI model",
"openAiListModels": "List all OpenAI models",
"openAiSetModel": "Set OpenAI model",
"ping": "Ping between received and sent message",
"qr": "Generates QR-code from text you sent or replied to.",
"quote": "Make quote from text (or quote)",
"randomInt": "Ranged random integer from parameters",
"randomString": "literally random string (up to 4096 symbols)",
"settings": "User settings",
"shutdown": "Self-destruction sequence for bot (shutdown)",
"speechToText": "Transcribe speech to text",
"start": "Start the bot",
"systemInfo": "System information",
"textToSpeech": "Generate speech from text",
"title": "Change group title",
"test": "System functionality check",
"transliteration": "Transliteration EN <--> RU",
"unban": "unban user from chat",
"unignore": "Bot will start responding to the user",
"uptime": "Bot's uptime",
"whatBetter": "either a or b randomly (50% chance)",
"when": "random date"
}
}
+249
View File
@@ -0,0 +1,249 @@
{
"language": {
"default": "По умолчанию",
"en": "Английский",
"ru": "Русский",
"ua": "Украинский",
"instructionName": "Russian"
},
"providerChoice.default": "По умолчанию",
"errorText": "⚠️ Произошла ошибка.",
"waitThinkText": "⏳ Думаю...",
"analyzingPictureText": "🔍 Анализирую изображение...",
"analyzingPicturesText": "🔍 Анализирую изображения...",
"reasoningText": "🤔 Рассуждаю...",
"transcribingAudioText": "🦻 Распознаю аудио...",
"genImageText": "👨‍🎨 Генерирую изображение...",
"cancelText": "❌ Отмена",
"regenerateText": "🔄 Сгенерировать заново",
"aiCancelCallbackText": "Отменить генерацию ИИ",
"aiRegenerateCallbackText": "Сгенерировать ответ ИИ заново",
"userSettingsCallbackText": "Настройки пользователя",
"noAccessText": "Нет доступа",
"notBotCreatorText": "Вы не создатель бота.",
"notBotAdministratorText": "Вы не администратор бота.",
"notAChatText": "Это не чат.",
"notChatAdministratorText": "Вы не администратор чата.",
"botNotChatAdministratorText": "Бот не является администратором чата.",
"replyRequiredText": "Нужно ответить на сообщение.",
"onlyOriginalAuthorText": "Это действие доступно только автору исходного сообщения.",
"dockerContainerLabelText": "Docker-контейнер",
"processLabelText": "Процесс",
"systemLabelText": "Система",
"systemInfoOsLabelText": "ОС",
"systemInfoRuntimeLabelText": "RUNTIME",
"systemInfoDockerLabelText": "DOCKER",
"systemInfoCpuLabelText": "CPU",
"systemInfoRamLabelText": "RAM",
"systemInfoCpuCoresText": "ядер",
"systemInfoCpuThreadsText": "потоков",
"idChatLabelText": "id чата",
"idFromLabelText": "id пользователя",
"idReplyLabelText": "id ответа",
"runtimeProviderLabelText": "провайдер",
"runtimeProviderCurrentLabelText": "текущий",
"runtimeModelLabelText": "модель",
"runtimeCapabilitiesLabelText": "возможности",
"runtimeExternalLabelText": "внешний",
"infoAiBlockLabelText": "AI",
"infoSupportedProvidersLabelText": "провайдеры",
"infoToolsBlockLabelText": "инструменты",
"infoCommandsBlockLabelText": "команды",
"infoPublicLabelText": "публичные",
"infoPrivateLabelText": "приватные",
"infoChatLabelText": "чат",
"infoCallbackLabelText": "колбэки",
"commandsHeaderText": "Команды:\n\n",
"sentCommandsInDmText": "Отправил команды в личные сообщения 😎",
"couldNotSendCommandsInDmText": "Не получилось отправить команды в личные сообщения ☹️\nОтправляю их сюда",
"administratorsHeaderText": "*Администраторы*:\n\n",
"noUserInfoText": "Нет информации о пользователе",
"useLeaveCommandText": "Используйте /leave",
"databaseBackupCaption": "Резервная копия базы данных",
"databaseBackupSentText": "Успешно отправил создателю в личные сообщения!",
"noChoicesText": "Не из чего выбирать",
"qrCodeMissingTextText": "Не найден текст для генерации QR-кода.",
"quoteMissingTextText": "Не удалось найти текст в сообщении 😢",
"quoteBuildFailedText": "Не удалось собрать цитату 😢",
"speechToTextInstructionText": "Отправьте аудио/voice/video-note или ответьте /stt на сообщение с аудио.",
"speechToTextEmptyResultText": "Распознавание речи не вернуло текст.",
"textToSpeechInstructionText": "Отправьте текст после команды или ответьте /tts на сообщение с текстом.",
"titleMissingText": "Не удалось найти заголовок...",
"betterFallbackText": "Лучше",
"pongText": "понг",
"modelListHeaderText": "Доступные модели:\n\n",
"modelListLoadFailedText": "Не удалось загрузить список моделей",
"noCurrentModelText": "Модель не задана. Используйте одно из значений из списка.",
"unsupportedAttachmentText": "Этот тип вложения не поддерживается.",
"attachmentMissingFromCacheText": "Файл вложения отсутствует в кэше.",
"noSupportedTranscriptionProviderText": "Не настроен ни один провайдер распознавания речи.",
"noSupportedTextToSpeechProviderText": "Не настроен ни один провайдер синтеза речи.",
"noSpeechToTextProviderForAccessText": "Для вашего уровня доступа не настроены провайдеры распознавания речи.",
"noTextToSpeechProviderForAccessText": "Для вашего уровня доступа не настроены провайдеры синтеза речи.",
"noTextToSynthesizeText": "Нет текста для синтеза речи.",
"speechFileTooLargeText": "Файл речи больше 50 МБ и не может быть отправлен.",
"userSettingsTitle": "Настройки пользователя",
"userSettingsAiProviderSelectionTitle": "Выбор AI-провайдера",
"userSettingsInterfaceLanguageSelectionTitle": "Выбор языка интерфейса",
"userSettingsResponseLanguageSelectionTitle": "Выбор языка ответов",
"userSettingsContextSizeSelectionTitle": "Выбор размера контекста",
"userSettingsVoiceModeSelectionTitle": "Режим голосовых сообщений",
"userSettingsTierLabel": "Уровень",
"userSettingsAiProviderLabel": "AI-провайдер",
"userSettingsInterfaceLanguageLabel": "Язык интерфейса",
"userSettingsResponseLanguageLabel": "Язык ответов LLM",
"userSettingsContextSizeLabel": "Размер контекста",
"userSettingsVoiceModeLabel": "Голосовые сообщения",
"userSettingsBackButtonText": "Назад",
"userSettingsAiProviderButtonPrefix": "AI-провайдер",
"userSettingsInterfaceLanguageButtonPrefix": "Язык интерфейса",
"userSettingsResponseLanguageButtonPrefix": "Язык ответов",
"userSettingsContextSizeButtonPrefix": "Контекст",
"userSettingsVoiceModeButtonPrefix": "Голосовые",
"userSettingsCreatorTierText": "Создатель",
"userSettingsAdminTierText": "Админ",
"userSettingsUserTierText": "Пользователь",
"userSettingsSelectedPrefix": "✓ ",
"userSettingsContextSizeDefaultText": "По умолчанию",
"userSettingsVoiceModeExecuteText": "Выполнять через ИИ",
"userSettingsVoiceModeTranscriptText": "Только расшифровка",
"startingImageGenText": "🌈 Запускаю генерацию изображения...",
"imageGenText": "🌈 Генерирую изображение...",
"finalizingImageGenText": "🌈 Завершаю генерацию изображения...",
"botCannotMakeItselfAdminText": "Бот не может назначить себя администратором",
"botCreatorAlreadyAdminText": "Создатель бота уже администратор",
"botCannotRemoveItselfFromAdminsText": "Бот не может удалить себя из администраторов",
"botCreatorCannotStopBeingAdminText": "Создатель бота не может перестать быть администратором",
"botWillNotBanCreatorText": "Бот не будет банить своего создателя.",
"botWillNotBanAdminsText": "Бот не будет банить своих администраторов.",
"botIsNotBannedByItselfText": "Бот и так не забанен сам собой.",
"botCreatorNeverBannedText": "Создатель бота не забанен и никогда не будет.",
"botAdminsNotBannedText": "Администраторы бота и так не забанены.",
"botWillNotIgnoreItselfText": "Бот не будет игнорировать себя.",
"botWillNotIgnoreCreatorText": "Бот не будет игнорировать своего создателя.",
"botWillNotIgnoreAdminsText": "Бот не будет игнорировать своих администраторов.",
"botIsNotIgnoredByItselfText": "Бот и так не игнорирует сам себя.",
"botCreatorNotIgnoredText": "Создатель бота не игнорируется и никогда не будет.",
"botAdminsNotIgnoredText": "Администраторы бота и так не игнорируются.",
"botAlreadyAlwaysListensToItselfText": "Бот и так всегда слушает сам себя",
"botAlwaysListensToCreatorText": "Бот всегда слушает своего создателя",
"coinHeadsText": "Орёл",
"coinTailsText": "Решка",
"distortReplyInstructionText": "Ответьте /distort на сообщение с изображением (фото, документ или стикер).\nПример: /distort 16 80",
"distortMissingImageText": "Не вижу изображения в ответе. Отправьте фото или файл изображения.",
"shutdownFallbackText": "...",
"shutdownSequenceTexts": [
"ну что ж, народ",
"было приятно пообщаться",
"но мне пора отдохнуть",
"всем добра"
],
"shutdownDoneText": "*R.I.P*",
"whenNowText": "прямо сейчас",
"whenNeverText": "никогда",
"whenYearUnitText": "год",
"whenDayUnitText": "день",
"whenWeekUnitText": "неделя",
"whenMonthUnitText": "месяц",
"whenHourUnitText": "час",
"whenMinuteUnitText": "минута",
"whenSecondUnitText": "секунда",
"getCancelledText": "{provider}\n❌ Генерация отменена.",
"getPartialImageGenText": "🌈 Генерирую изображение ({iteration}/{total})...",
"getImageGenDoneText.withModel": "👨‍🎨 Изображение сгенерировано. Модель: `{model}`.",
"getImageGenDoneText.default": "👨‍🎨 Изображение сгенерировано.",
"getErrorText.withReason": "{errorText} Причина:\n{reason}",
"getUseToolText.python": "👨‍💻 Запускаю `Python`",
"getUseToolText.default": "🔧 Использую инструмент `{name}`",
"getAnalyzingDocumentText.default": "🔍 Анализирую документ...",
"getAnalyzingDocumentText.single": "🔍 Анализирую документ: `{name}`",
"getAnalyzingDocumentText.many": "🔍 Анализирую документы: {names}",
"getPreparingRAGText.default": "🔍 Готовлю RAG для документа...",
"getPreparingRAGText.single": "🔍 Готовлю RAG для документа: `{name}`",
"getPreparingRAGText.many": "🔍 Готовлю RAG для документов: {names}",
"getBuildingRAGIndexText.default": "🧠 Строю RAG-индекс...",
"getBuildingRAGIndexText.withModel": "🧠 Строю RAG-индекс: `{modelName}`.",
"queueNoneText": "нет",
"getAiQueueText.queued": "⏳ Запрос к {provider} поставлен в очередь.",
"getAiQueueText.ahead": "Запросов впереди: {count}.",
"getTelegramFileTooLargeText": "Файл {fileName} больше {maxSizeMb} МБ и не может быть отправлен.",
"getUserIsNowAdminText": "{name} теперь администратор!",
"getUserAlreadyAdminText": "{name} уже администратор 🤔",
"getUserNoLongerAdminText": "{name} больше не администратор!",
"getUserWasNotAdminText": "{name} не был администратором 🤔",
"getUserBannedText": "{name} забанен 🚫",
"getUserBanFailedText": "Не удалось забанить {name} ☹️",
"getUserUnbannedText": "{name} разбанен ⛓️‍💥",
"getUserUnbanFailedText": "Не удалось разбанить {name} ☹️",
"getUserIgnoredText": "{name} заглушён! 🔇",
"getUserAlreadyIgnoredText": "{name} уже заглушён 🤔",
"getUserIgnoreFailedText": "Не удалось заглушить {name} ☹️",
"getUserUnignoredText": "{name} больше не заглушён! 🔈",
"getUserWasNotIgnoredText": "{name} не был заглушён 🤔",
"getUserUnignoreFailedText": "Не удалось включить {name} обратно ☹️",
"getChoiceText": "Выбрал *{choice}*",
"getCoinResultText": "Выпало: *{result}*",
"getLoadedModelsText": "Загруженные модели: {models}",
"getSelectedModelText": "Выбрана модель: `{model}`",
"getCurrentModelText": "Текущая модель: `{model}`",
"getLoadingModelText": "Загружаю модель `{model}`...",
"getUserSettingsContextSizeText": "{size} токенов",
"getQrCodeTextTooLongText": "Текст слишком длинный для QR ({actualLength} символов). Обрежу до {maxLength} символов.",
"getQrCodeReadyText": "QR-код готов ✅\nСодержимое:\n<blockquote expandable>{content}</blockquote>",
"getQrCodeFailedText": "Не удалось сгенерировать QR: {reason}",
"getWhenPrefixText": "через ",
"getWhenPluralUnitText": "{unit}",
"getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": {
"ae": "вычисление",
"adminsAdd": "Добавить пользователя в администраторы",
"adminsRemove": "Удалить пользователя из администраторов",
"ban": "забанить пользователя в чате",
"choice": "Выбрать случайное значение",
"coin": "Орёл или решка",
"debug": "Вернуть msg или reply в JSON",
"dice": "Отправить случайный или конкретный дайс",
"distort": "Искажение изображения",
"geminiChat": "Чат с AI (Gemini)",
"geminiGetModel": "Показать текущую модель Gemini",
"geminiListModels": "Показать все модели Gemini",
"geminiSetModel": "Установить модель Gemini",
"help": "Показать список команд",
"id": "ID чата, пользователя и ответа",
"ignore": "Бот будет игнорировать пользователя",
"info": "Информация о боте",
"leave": "Бот покинет текущий чат",
"mistralChat": "Чат с AI (Mistral)",
"mistralGetModel": "Показать текущую модель Mistral",
"mistralListModels": "Показать все модели Mistral",
"mistralSetModel": "Установить модель Mistral",
"ollamaChat": "Чат с AI (Ollama)",
"ollamaGetModel": "Показать текущую модель Ollama",
"ollamaListModels": "Показать все модели Ollama",
"ollamaSearch": "Веб-поиск через Ollama",
"ollamaSetModel": "Установить модель Ollama",
"openAiChat": "Чат с AI (OpenAI)",
"openAiGetModel": "Показать текущую модель OpenAI",
"openAiListModels": "Показать все модели OpenAI",
"openAiSetModel": "Установить модель OpenAI",
"ping": "Задержка между получением и отправкой сообщения",
"qr": "Сгенерировать QR-код из текста",
"quote": "Сделать цитату из текста",
"randomInt": "Случайное число из диапазона",
"randomString": "Случайная строка до 4096 символов",
"settings": "Настройки пользователя",
"shutdown": "Выключить бота",
"speechToText": "Распознать речь в текст",
"start": "Запустить бота",
"systemInfo": "Информация о системе",
"textToSpeech": "Сгенерировать речь из текста",
"title": "Изменить название группы",
"test": "Проверка системной функциональности",
"transliteration": "Транслитерация EN <--> RU",
"unban": "разбанить пользователя в чате",
"unignore": "Бот снова будет отвечать пользователю",
"uptime": "Время работы бота",
"whatBetter": "случайно выбрать a или b",
"when": "случайная дата"
}
}
+211
View File
@@ -0,0 +1,211 @@
{
"language": {
"default": "За замовчуванням",
"en": "Англійська",
"ru": "Російська",
"ua": "Українська",
"instructionName": "Ukrainian"
},
"providerChoice.default": "За замовчуванням",
"errorText": "⚠️ Сталася помилка.",
"waitThinkText": "⏳ Думаю...",
"analyzingPictureText": "🔍 Аналізую зображення...",
"analyzingPicturesText": "🔍 Аналізую зображення...",
"reasoningText": "🤔 Міркую...",
"transcribingAudioText": "🦻 Розпізнаю аудіо...",
"genImageText": "👨‍🎨 Генерую зображення...",
"cancelText": "❌ Скасувати",
"regenerateText": "🔄 Згенерувати заново",
"aiCancelCallbackText": "Скасувати генерацію AI",
"aiRegenerateCallbackText": "Згенерувати відповідь AI заново",
"userSettingsCallbackText": "Налаштування користувача",
"noAccessText": "Немає доступу",
"notBotCreatorText": "Ви не творець бота.",
"notBotAdministratorText": "Ви не адміністратор бота.",
"notAChatText": "Це не чат.",
"notChatAdministratorText": "Ви не адміністратор чату.",
"botNotChatAdministratorText": "Бот не є адміністратором чату.",
"replyRequiredText": "Потрібно відповісти на повідомлення.",
"onlyOriginalAuthorText": "Ця дія доступна лише автору початкового повідомлення.",
"dockerContainerLabelText": "Docker-контейнер",
"processLabelText": "Процес",
"systemLabelText": "Система",
"systemInfoOsLabelText": "ОС",
"systemInfoRuntimeLabelText": "RUNTIME",
"systemInfoDockerLabelText": "DOCKER",
"systemInfoCpuLabelText": "CPU",
"systemInfoRamLabelText": "RAM",
"systemInfoCpuCoresText": "ядер",
"systemInfoCpuThreadsText": "потоків",
"idChatLabelText": "id чату",
"idFromLabelText": "id користувача",
"idReplyLabelText": "id відповіді",
"runtimeProviderLabelText": "провайдер",
"runtimeModelLabelText": "модель",
"runtimeCapabilitiesLabelText": "можливості",
"runtimeExternalLabelText": "зовнішній",
"infoAiBlockLabelText": "AI",
"infoSupportedProvidersLabelText": "провайдери",
"infoToolsBlockLabelText": "інструменти",
"infoCommandsBlockLabelText": "команди",
"infoPublicLabelText": "публічні",
"infoPrivateLabelText": "приватні",
"infoChatLabelText": "чат",
"infoCallbackLabelText": "колбеки",
"commandsHeaderText": "Команди:\n\n",
"sentCommandsInDmText": "Надіслав команди в особисті повідомлення 😎",
"couldNotSendCommandsInDmText": "Не вдалося надіслати команди в особисті повідомлення ☹️\nНадсилаю їх сюди",
"administratorsHeaderText": "*Адміністратори*:\n\n",
"noUserInfoText": "Немає інформації про користувача",
"useLeaveCommandText": "Використайте /leave",
"databaseBackupCaption": "Резервна копія бази даних",
"databaseBackupSentText": "Успішно надіслав творцю в особисті повідомлення!",
"noChoicesText": "Немає з чого вибирати",
"qrCodeMissingTextText": "Не знайдено текст для генерації QR-коду.",
"quoteMissingTextText": "Не вдалося знайти текст у повідомленні 😢",
"quoteBuildFailedText": "Не вдалося створити цитату 😢",
"speechToTextInstructionText": "Надішліть аудіо/voice/video-note або відповідайте /stt на повідомлення з аудіо.",
"speechToTextEmptyResultText": "Розпізнавання мовлення не повернуло текст.",
"textToSpeechInstructionText": "Надішліть текст після команди або відповідайте /tts на повідомлення з текстом.",
"titleMissingText": "Не вдалося знайти заголовок...",
"betterFallbackText": "Краще",
"pongText": "понг",
"modelListHeaderText": "Доступні моделі:\n\n",
"modelListLoadFailedText": "Не вдалося завантажити список моделей",
"noCurrentModelText": "Модель не задана. Використайте одне зі значень зі списку.",
"unsupportedAttachmentText": "Цей тип вкладення не підтримується.",
"attachmentMissingFromCacheText": "Файл вкладення відсутній у кеші.",
"noSupportedTranscriptionProviderText": "Не налаштовано жодного провайдера розпізнавання мовлення.",
"noSupportedTextToSpeechProviderText": "Не налаштовано жодного провайдера синтезу мовлення.",
"noSpeechToTextProviderForAccessText": "Для вашого рівня доступу не налаштовано провайдери розпізнавання мовлення.",
"noTextToSpeechProviderForAccessText": "Для вашого рівня доступу не налаштовано провайдери синтезу мовлення.",
"noTextToSynthesizeText": "Немає тексту для синтезу мовлення.",
"speechFileTooLargeText": "Файл мовлення більший за 50 МБ і не може бути надісланий.",
"userSettingsTitle": "Налаштування користувача",
"userSettingsAiProviderSelectionTitle": "Вибір AI-провайдера",
"userSettingsInterfaceLanguageSelectionTitle": "Вибір мови інтерфейсу",
"userSettingsResponseLanguageSelectionTitle": "Вибір мови відповідей",
"userSettingsContextSizeSelectionTitle": "Вибір розміру контексту",
"userSettingsVoiceModeSelectionTitle": "Режим голосових повідомлень",
"userSettingsTierLabel": "Рівень",
"userSettingsAiProviderLabel": "AI-провайдер",
"userSettingsInterfaceLanguageLabel": "Мова інтерфейсу",
"userSettingsResponseLanguageLabel": "Мова відповідей LLM",
"userSettingsContextSizeLabel": "Розмір контексту",
"userSettingsVoiceModeLabel": "Голосові повідомлення",
"userSettingsBackButtonText": "Назад",
"userSettingsAiProviderButtonPrefix": "AI-провайдер",
"userSettingsInterfaceLanguageButtonPrefix": "Мова інтерфейсу",
"userSettingsResponseLanguageButtonPrefix": "Мова відповідей",
"userSettingsContextSizeButtonPrefix": "Контекст",
"userSettingsVoiceModeButtonPrefix": "Голосові",
"userSettingsCreatorTierText": "Творець",
"userSettingsAdminTierText": "Адмін",
"userSettingsUserTierText": "Користувач",
"userSettingsSelectedPrefix": "✓ ",
"userSettingsContextSizeDefaultText": "За замовчуванням",
"userSettingsVoiceModeExecuteText": "Виконувати через AI",
"userSettingsVoiceModeTranscriptText": "Лише розшифровка",
"startingImageGenText": "🌈 Запускаю генерацію зображення...",
"imageGenText": "🌈 Генерую зображення...",
"finalizingImageGenText": "🌈 Завершую генерацію зображення...",
"botCannotMakeItselfAdminText": "Бот не може призначити себе адміністратором",
"botCreatorAlreadyAdminText": "Творець бота вже адміністратор",
"botCannotRemoveItselfFromAdminsText": "Бот не може видалити себе з адміністраторів",
"botCreatorCannotStopBeingAdminText": "Творець бота не може перестати бути адміністратором",
"botWillNotBanCreatorText": "Бот не банитиме свого творця.",
"botWillNotBanAdminsText": "Бот не банитиме своїх адміністраторів.",
"botIsNotBannedByItselfText": "Бот і так не забанений сам собою.",
"botCreatorNeverBannedText": "Творець бота не забанений і ніколи не буде.",
"botAdminsNotBannedText": "Адміністратори бота і так не забанені.",
"botWillNotIgnoreItselfText": "Бот не ігноруватиме себе.",
"botWillNotIgnoreCreatorText": "Бот не ігноруватиме свого творця.",
"botWillNotIgnoreAdminsText": "Бот не ігноруватиме своїх адміністраторів.",
"botIsNotIgnoredByItselfText": "Бот і так не ігнорує сам себе.",
"botCreatorNotIgnoredText": "Творець бота не ігнорується і ніколи не буде.",
"botAdminsNotIgnoredText": "Адміністратори бота і так не ігноруються.",
"botAlreadyAlwaysListensToItselfText": "Бот і так завжди слухає сам себе",
"botAlwaysListensToCreatorText": "Бот завжди слухає свого творця",
"coinHeadsText": "Орел",
"coinTailsText": "Решка",
"distortReplyInstructionText": "Відповідайте /distort на повідомлення із зображенням (фото, документ або стікер).\nПриклад: /distort 16 80",
"distortMissingImageText": "Не бачу зображення у відповіді. Надішліть фото або файл зображення.",
"shutdownFallbackText": "...",
"shutdownSequenceTexts": [
"ну що ж, усі",
"було приємно поспілкуватися",
"але мені час відпочити",
"усього доброго"
],
"shutdownDoneText": "*R.I.P*",
"whenNowText": "прямо зараз",
"whenNeverText": "ніколи",
"whenYearUnitText": "рік",
"whenDayUnitText": "день",
"whenWeekUnitText": "тиждень",
"whenMonthUnitText": "місяць",
"whenHourUnitText": "година",
"whenMinuteUnitText": "хвилина",
"whenSecondUnitText": "секунда",
"getCancelledText": "{provider}\n❌ Генерацію скасовано.",
"getPartialImageGenText": "🌈 Генерую зображення ({iteration}/{total})...",
"getImageGenDoneText.withModel": "👨‍🎨 Зображення згенеровано. Модель: `{model}`.",
"getImageGenDoneText.default": "👨‍🎨 Зображення згенеровано.",
"getErrorText.withReason": "{errorText} Причина:\n{reason}",
"getUseToolText.python": "👨‍💻 Запускаю `Python`",
"getUseToolText.default": "🔧 Використовую інструмент `{name}`",
"getAnalyzingDocumentText.default": "🔍 Аналізую документ...",
"getAnalyzingDocumentText.single": "🔍 Аналізую документ: `{name}`",
"getAnalyzingDocumentText.many": "🔍 Аналізую документи: {names}",
"getPreparingRAGText.default": "🔍 Готую RAG для документа...",
"getPreparingRAGText.single": "🔍 Готую RAG для документа: `{name}`",
"getPreparingRAGText.many": "🔍 Готую RAG для документів: {names}",
"getBuildingRAGIndexText.default": "🧠 Будую RAG-індекс...",
"getBuildingRAGIndexText.withModel": "🧠 Будую RAG-індекс: `{modelName}`.",
"queueNoneText": "немає",
"getAiQueueText.queued": "⏳ Запит до {provider} поставлено в чергу.",
"getAiQueueText.ahead": "Запитів попереду: {count}.",
"getTelegramFileTooLargeText": "Файл {fileName} більший за {maxSizeMb} МБ і не може бути надісланий.",
"getUserIsNowAdminText": "{name} тепер адміністратор!",
"getUserAlreadyAdminText": "{name} вже адміністратор 🤔",
"getUserNoLongerAdminText": "{name} більше не адміністратор!",
"getUserWasNotAdminText": "{name} не був адміністратором 🤔",
"getUserBannedText": "{name} забанений 🚫",
"getUserBanFailedText": "Не вдалося забанити {name} ☹️",
"getUserUnbannedText": "{name} розбанений ⛓️‍💥",
"getUserUnbanFailedText": "Не вдалося розбанити {name} ☹️",
"getUserIgnoredText": "{name} заглушений! 🔇",
"getUserAlreadyIgnoredText": "{name} вже заглушений 🤔",
"getUserIgnoreFailedText": "Не вдалося заглушити {name} ☹️",
"getUserUnignoredText": "{name} більше не заглушений! 🔈",
"getUserWasNotIgnoredText": "{name} не був заглушений 🤔",
"getUserUnignoreFailedText": "Не вдалося увімкнути {name} назад ☹️",
"getChoiceText": "Вибрав *{choice}*",
"getCoinResultText": "Випало: *{result}*",
"getLoadedModelsText": "Завантажені моделі: {models}",
"getSelectedModelText": "Обрано модель: `{model}`",
"getCurrentModelText": "Поточна модель: `{model}`",
"getLoadingModelText": "Завантажую модель `{model}`...",
"getUserSettingsContextSizeText": "{size} токенів",
"getQrCodeTextTooLongText": "Текст занадто довгий для QR ({actualLength} символів). Обріжу до {maxLength} символів.",
"getQrCodeReadyText": "QR-код готовий ✅\nВміст:\n<blockquote expandable>{content}</blockquote>",
"getQrCodeFailedText": "Не вдалося згенерувати QR: {reason}",
"getWhenPrefixText": "через ",
"getWhenPluralUnitText": "{unit}",
"getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": {
"help": "Показати список команд",
"settings": "Налаштування користувача",
"start": "Запустити бота",
"ping": "Затримка між отриманням і надсиланням повідомлення",
"info": "Інформація про бота",
"systemInfo": "Інформація про систему",
"speechToText": "Розпізнати мовлення в текст",
"textToSpeech": "Згенерувати мовлення з тексту",
"qr": "Згенерувати QR-код з тексту",
"quote": "Створити цитату з тексту",
"choice": "Вибрати випадкове значення",
"coin": "Орел або решка",
"when": "випадкова дата"
}
}
+953 -4402
View File
File diff suppressed because it is too large Load Diff
+17 -24
View File
@@ -2,44 +2,37 @@
"name": "tg-chat-bot",
"main": "src/index.ts",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"build": "tsgo -p tsconfig.json",
"start": "node dist/index.js",
"bun:start": "bun run dist/index.js"
"bun:start": "bun run src/index.ts"
},
"dependencies": {
"@google/genai": "^1.50.1",
"@google/genai": "^2.0.0",
"@mistralai/mistralai": "^2.2.1",
"openai": "^6.37.0",
"ollama": "^0.6.3",
"typescript-telegram-bot-api": "^0.16.0",
"@libsql/client": "^0.17.3",
"@mistralai/mistralai": "^1.15.1",
"@napi-rs/canvas": "^0.1.100",
"axios": "^1.15.2",
"@napi-rs/canvas": "^1.0.0",
"axios": "^1.16.0",
"dotenv": "^17.4.2",
"drizzle-orm": "1.0.0-beta.21",
"drizzle-orm": "0.45.2",
"emoji-regex": "^10.6.0",
"fluent-ffmpeg": "^2.1.3",
"ollama": "^0.6.3",
"openai": "^6.35.0",
"puppeteer": "^24.42.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"qrcode": "^1.5.4",
"sharp": "^0.34.5",
"systeminformation": "^5.31.5",
"systeminformation": "^5.31.6",
"twemoji": "^14.0.2",
"typescript-telegram-bot-api": "^0.11.0",
"youtubei.js": "^16.0.1",
"zod": "^4.3.6"
"zod": "^4.4.3"
},
"devDependencies": {
"@types/bun": "^1.3.13",
"@types/node": "^25.6.0",
"@types/qrcode": "^1.5.6",
"@types/fluent-ffmpeg": "^2.1.28",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"drizzle-kit": "^1.0.0-beta.9-e89174b",
"eslint": "^9.39.4",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
"@types/node": "^25.6.1",
"@types/qrcode": "^1.5.6",
"drizzle-kit": "^0.31.10",
"@typescript/native-preview": "^7.0.0-beta"
}
}
+1
View File
@@ -0,0 +1 @@
export * from "../logging/ai-logger";
+215
View File
@@ -0,0 +1,215 @@
import {Mistral} from "@mistralai/mistralai";
import {GoogleGenAI} from "@google/genai";
import {Ollama} from "ollama";
import {OpenAI} from "openai";
import {Environment} from "../common/environment";
import {AiModelCapabilities} from "../model/ai-model-capabilities";
import {AiProvider} from "../model/ai-provider";
export type AiCapabilityName = keyof AiModelCapabilities;
export type AiRuntimePurpose = AiCapabilityName | "chat";
export type AiRuntimeTarget = {
provider: AiProvider;
purpose: AiRuntimePurpose;
model: string;
baseUrl?: string;
apiKey?: string;
};
export type GeminiApiMode = "google" | "openai";
const GEMINI_OPENAI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/";
const PURPOSE_SUFFIXES: Record<AiRuntimePurpose, string[]> = {
chat: ["CHAT"],
vision: ["VISION", "IMAGE"],
ocr: ["OCR", "VISION", "IMAGE"],
thinking: ["THINKING", "THINK"],
extendedThinking: ["EXTENDED_THINKING", "THINKING", "THINK"],
tools: ["TOOLS", "CHAT"],
audio: ["AUDIO"],
documents: ["DOCUMENTS", "RAG", "EMBEDDING"],
outputImages: ["OUTPUT_IMAGES", "IMAGE"],
speechToText: ["SPEECH_TO_TEXT", "TRANSCRIPTION", "STT", "AUDIO"],
textToSpeech: ["TEXT_TO_SPEECH", "TTS"],
};
function providerPrefix(provider: AiProvider): string {
return provider.toString();
}
function env(name: string): string | undefined {
return Environment.getOptionalConfigValue(name);
}
function firstEnv(names: string[]): string | undefined {
for (const name of names) {
const value = env(name);
if (value) return value;
}
return undefined;
}
function endpointEnvNames(provider: AiProvider, purpose: AiRuntimePurpose): string[] {
const prefix = providerPrefix(provider);
return PURPOSE_SUFFIXES[purpose].flatMap(suffix => [
`${prefix}_${suffix}_BASE_URL`,
`${prefix}_${suffix}_ENDPOINT`,
`${prefix}_${suffix}_ADDRESS`,
]);
}
function apiKeyEnvNames(provider: AiProvider, purpose: AiRuntimePurpose): string[] {
const prefix = providerPrefix(provider);
return PURPOSE_SUFFIXES[purpose].map(suffix => `${prefix}_${suffix}_API_KEY`);
}
function modelEnvNames(provider: AiProvider, purpose: AiRuntimePurpose): string[] {
const prefix = providerPrefix(provider);
return PURPOSE_SUFFIXES[purpose].map(suffix => `${prefix}_${suffix}_MODEL`);
}
export function getProviderBaseUrl(provider: AiProvider): string | undefined {
switch (provider) {
case AiProvider.OLLAMA:
return env("OLLAMA_ADDRESS");
case AiProvider.GEMINI:
return env("GEMINI_BASE_URL") ?? env("GEMINI_ENDPOINT")
?? (Environment.GEMINI_API_MODE === "openai" ? GEMINI_OPENAI_BASE_URL : undefined);
case AiProvider.MISTRAL:
return env("MISTRAL_BASE_URL") ?? env("MISTRAL_ENDPOINT");
case AiProvider.OPENAI:
return env("OPENAI_BASE_URL") ?? env("OPENAI_ENDPOINT");
}
}
export function getProviderApiKey(provider: AiProvider): string | undefined {
switch (provider) {
case AiProvider.OLLAMA:
return Environment.OLLAMA_API_KEY;
case AiProvider.GEMINI:
return Environment.GEMINI_API_KEY;
case AiProvider.MISTRAL:
return Environment.MISTRAL_API_KEY;
case AiProvider.OPENAI:
return Environment.OPENAI_API_KEY;
}
}
export function getDefaultModelForPurpose(provider: AiProvider, purpose: AiRuntimePurpose): string {
switch (provider) {
case AiProvider.OLLAMA:
switch (purpose) {
case "vision":
case "ocr":
case "outputImages":
return Environment.OLLAMA_IMAGE_MODEL;
case "thinking":
case "extendedThinking":
return Environment.OLLAMA_THINK_MODEL;
case "audio":
case "speechToText":
return Environment.OLLAMA_AUDIO_MODEL;
case "documents":
return Environment.OLLAMA_EMBEDDING_MODEL;
default:
return Environment.OLLAMA_CHAT_MODEL;
}
case AiProvider.GEMINI:
switch (purpose) {
case "vision":
case "ocr":
case "outputImages":
return Environment.GEMINI_IMAGE_MODEL;
case "speechToText":
return Environment.GEMINI_TRANSCRIPTION_MODEL;
case "textToSpeech":
return Environment.GEMINI_TTS_MODEL;
default:
return Environment.GEMINI_MODEL;
}
case AiProvider.MISTRAL:
switch (purpose) {
case "speechToText":
return Environment.MISTRAL_TRANSCRIPTION_MODEL;
case "textToSpeech":
return Environment.MISTRAL_TTS_MODEL || Environment.MISTRAL_MODEL;
default:
return Environment.MISTRAL_MODEL;
}
case AiProvider.OPENAI:
switch (purpose) {
case "outputImages":
return Environment.OPENAI_IMAGE_MODEL;
case "speechToText":
return Environment.OPENAI_TRANSCRIPTION_MODEL;
case "textToSpeech":
return Environment.OPENAI_TTS_MODEL;
default:
return Environment.OPENAI_MODEL;
}
}
}
export function resolveAiRuntimeTarget(
provider: AiProvider,
purpose: AiRuntimePurpose,
modelOverride?: string,
): AiRuntimeTarget {
const model = modelOverride
?? firstEnv(modelEnvNames(provider, purpose))
?? getDefaultModelForPurpose(provider, purpose);
const baseUrl = firstEnv(endpointEnvNames(provider, purpose)) ?? getProviderBaseUrl(provider);
const apiKey = firstEnv(apiKeyEnvNames(provider, purpose)) ?? getProviderApiKey(provider);
return {provider, purpose, model, baseUrl, apiKey};
}
export function sameRuntimeEndpoint(left: AiRuntimeTarget, right: AiRuntimeTarget): boolean {
return left.provider === right.provider
&& (left.baseUrl ?? "") === (right.baseUrl ?? "")
&& (left.apiKey ?? "") === (right.apiKey ?? "");
}
export function createOpenAiClient(target: AiRuntimeTarget): OpenAI {
return new OpenAI({
apiKey: target.apiKey,
baseURL: target.baseUrl,
});
}
export function getGeminiApiMode(target?: AiRuntimeTarget): GeminiApiMode {
if (Environment.GEMINI_API_MODE === "openai") return "openai";
if (Environment.GEMINI_API_MODE === "google") return "google";
if ((target?.baseUrl ?? "").includes("/openai")) return "openai";
return "google";
}
export function createGeminiOpenAiClient(target: AiRuntimeTarget): OpenAI {
return createOpenAiClient({
...target,
baseUrl: target.baseUrl ?? GEMINI_OPENAI_BASE_URL,
});
}
export function createGoogleGenAiClient(target: AiRuntimeTarget): GoogleGenAI {
return new GoogleGenAI({
apiKey: target.apiKey,
});
}
export function createMistralClient(target: AiRuntimeTarget): Mistral {
return new Mistral({
apiKey: target.apiKey,
serverURL: target.baseUrl,
});
}
export function createOllamaClient(target: AiRuntimeTarget): Ollama {
return new Ollama({
host: target.baseUrl,
headers: target.apiKey ? {"Authorization": `Bearer ${target.apiKey}`} : undefined,
});
}
+55
View File
@@ -0,0 +1,55 @@
import {randomUUID} from "node:crypto";
export type AiCancelRequest = {
id: string;
chatId: number;
messageId?: number;
fromId: number;
provider: string;
controller: AbortController;
onCancel?: () => Promise<void> | void;
};
const requests = new Map<string, AiCancelRequest>();
export function createAiCancelRequest(params: Omit<AiCancelRequest, "id" | "controller"> & { controller?: AbortController }): AiCancelRequest {
const request: AiCancelRequest = {
id: randomUUID(),
controller: params.controller ?? new AbortController(),
chatId: params.chatId,
messageId: params.messageId,
fromId: params.fromId,
provider: params.provider,
onCancel: params.onCancel,
};
requests.set(request.id, request);
return request;
}
export function setAiCancelMessageId(id: string, messageId: number): void {
const request = requests.get(id);
if (request) request.messageId = messageId;
}
export function getAiCancelRequest(id: string): AiCancelRequest | undefined {
return requests.get(id);
}
export async function abortAiRequest(id: string): Promise<boolean> {
const request = requests.get(id);
if (!request) return false;
request.controller.abort();
try {
await request.onCancel?.();
} finally {
requests.delete(id);
}
return true;
}
export function finishAiRequest(id: string): void {
requests.delete(id);
}
+69
View File
@@ -0,0 +1,69 @@
import {AiToolCall} from "./tool-types";
import {OllamaChatMessage} from "./ollama-chat-message";
import {GeminiMessage} from "./gemini-chat-message";
import {MistralChatMessage} from "./mistral-chat-message";
import {MessageAudioPart, MessageImagePart} from "../common/message-part";
import {OpenAIChatMessage} from "./openai-chat-message";
export type ChatMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string;
images?: string[];
imageParts?: MessageImagePart[];
documents?: string[];
audios?: string[];
audioParts?: MessageAudioPart[];
videos?: string[];
videoNotes?: string[];
thinking?: string;
tool_calls?: AiToolCall[];
tool_name?: string;
}
export function asOllamaChatMessage(message: ChatMessage): OllamaChatMessage {
return {
role: message.role,
content: message.content,
thinking: message.thinking,
images: message.images,
tool_calls: message.tool_calls,
tool_name: message.tool_name
};
}
// export function asGeminiChatMessage(message: ChatMessage): GeminiMessage {
// if (message.images) {
// return {
// role: message.role,
// content: message.images.map(() => {
// return {
// type: "image",
// };
// })
// };
// }
//
// return {
// role: message.role,
// content: {
// type: "text",
// text: message.content,
// },
// };
// }
export function asMistralChatMessage(message: ChatMessage): MistralChatMessage {
return {
role: message.role,
content: message.content,
};
}
// export function asOpenAIChatMessage(message: ChatMessage): OpenAIChatMessage {
// return {
//
// }
// }
export type AiChatMessage = | OpenAIChatMessage | OllamaChatMessage | MistralChatMessage | GeminiMessage;
+84
View File
@@ -0,0 +1,84 @@
export type GeminiUserInputStep = {
type: "user_input";
content?: Array<GeminiContent>;
}
export type GeminiModelOutputStep = {
type: "model_output";
content?: Array<GeminiContent>;
}
export type GeminiFunctionCallStep = {
id: string;
arguments: {
[key: string]: unknown;
};
name: string;
type: "function_call";
signature?: string;
}
export type GeminiFunctionResultStep = {
call_id: string;
result: unknown | Array<GeminiTextContent | GeminiImageContent> | string;
type: "function_result";
is_error?: boolean;
name?: string;
signature?: string;
}
export type GeminiStep =
| GeminiUserInputStep
| GeminiModelOutputStep
| GeminiFunctionCallStep
| GeminiFunctionResultStep;
export type GeminiTextContent = {
text: string;
}
export type GeminiInlineContent = {
inlineData: {
data: string;
mimeType: string;
};
}
export type GeminiImageContent = GeminiInlineContent;
export type GeminiAudioContent = GeminiInlineContent;
export type GeminiDocumentContent = GeminiInlineContent;
export type GeminiVideoContent = GeminiInlineContent;
export type GeminiFunctionCallContent = {
functionCall: {
id?: string;
name?: string;
args?: Record<string, unknown>;
};
}
export type GeminiFunctionResponseContent = {
functionResponse: {
id?: string;
name?: string;
response: Record<string, unknown>;
};
}
export type GeminiContent =
| GeminiTextContent
| GeminiInlineContent
| GeminiFunctionCallContent
| GeminiFunctionResponseContent;
export type GeminiTurn = {
content?: Array<GeminiContent> | GeminiContent;
role?: string;
}
export type GeminiInput = string | Array<GeminiStep> | Array<GeminiContent> | GeminiContent;
export type GeminiMessage = {
role: "user" | "model";
parts: GeminiContent[];
};
+112
View File
@@ -0,0 +1,112 @@
export const MistralImageDetail = {
Low: "low",
Auto: "auto",
High: "high",
} as const;
export type MistralImageDetail = OpenEnum<typeof MistralImageDetail>;
declare const __brand: unique symbol;
export type Unrecognized<T> = T & { [__brand]: "unrecognized" };
export type OpenEnum<T extends Readonly<Record<string, string | number>>> =
| T[keyof T]
| Unrecognized<T[keyof T] extends number ? number : string>;
export const BuiltInConnectors = {
WebSearch: "web_search",
WebSearchPremium: "web_search_premium",
CodeInterpreter: "code_interpreter",
ImageGeneration: "image_generation",
DocumentLibrary: "document_library",
} as const;
export type BuiltInConnectors = OpenEnum<typeof BuiltInConnectors>;
export type MistralTextChunk = {
type: "text";
text: string;
};
export type MistralToolReferenceChunk = {
type: "tool_reference" | undefined;
tool: BuiltInConnectors | string;
title: string;
url?: string | null | undefined;
favicon?: string | null | undefined;
description?: string | null | undefined;
};
export type MistralThinkChunk = {
type: "thinking";
thinking: Array<MistralToolReferenceChunk | MistralTextChunk>;
signature?: string | null | undefined;
closed?: boolean | undefined;
};
export type MistralImageURLChunk = {
type: "image_url";
imageUrl: string | {
url: string;
detail?: MistralImageDetail | null | undefined;
};
}
export type MistralContentChunk =
| MistralTextChunk
| MistralThinkChunk
| MistralImageURLChunk
/*
| (ImageURLChunk & { type: "image_url" })
| (DocumentURLChunk & { type: "document_url" })
| (TextChunk & { type: "text" })
| (ReferenceChunk & { type: "reference" })
| (FileChunk & { type: "file" })
| (ThinkChunk & { type: "thinking" })
| AudioChunk
*/
export type MistralFunctionCall = {
name: string;
arguments: Record<string, unknown> | string;
};
export type MistralToolCall = {
id?: string | undefined;
type?: string | undefined;
function: MistralFunctionCall;
index?: number | undefined;
};
export type MistralAssistantMessage = {
role: "assistant";
content?: string | Array<MistralContentChunk> | null | undefined;
toolCalls?: Array<MistralToolCall> | null | undefined;
prefix?: boolean | undefined;
}
export type MistralSystemMessageContentChunks =
| MistralTextChunk
| MistralThinkChunk;
export type MistralSystemMessage = {
role: "system";
content: string;
}
export type MistralToolMessage = {
role: "tool";
content: string | Array<MistralContentChunk> | null;
toolCallId?: string | null | undefined;
name?: string | null | undefined;
};
export type MistralUserMessage = {
role: "user";
content: string | Array<MistralContentChunk> | null;
};
export type MistralChatMessage =
| MistralAssistantMessage
| MistralSystemMessage
| MistralToolMessage
| MistralUserMessage
+3
View File
@@ -0,0 +1,3 @@
import {Message} from "ollama";
export type OllamaChatMessage = Message;
+1360
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
import type {ResponseInputMessageContentList} from "openai/resources/responses/responses";
export type OpenAIChatMessage = {
type: "message";
role: "system" | "user" | "assistant";
content: string | ResponseInputMessageContentList;
};
+348
View File
@@ -0,0 +1,348 @@
import {AiProvider} from "../model/ai-provider";
import {AiModelCapabilities} from "../model/ai-model-capabilities";
import {Environment} from "../common/environment";
import {logError} from "../util/utils";
import {AiCapabilityInfo} from "../model/ai-capability-info";
import {isOllamaSpeechToTextModel} from "./speech-to-text-models";
import {
AiCapabilityName,
AiRuntimeTarget,
createGeminiOpenAiClient,
createGoogleGenAiClient,
createMistralClient,
createOllamaClient,
createOpenAiClient,
getGeminiApiMode,
resolveAiRuntimeTarget,
sameRuntimeEndpoint,
} from "./ai-runtime-target";
const CAPABILITY_NAMES: AiCapabilityName[] = [
"chat",
"vision",
"ocr",
"thinking",
"extendedThinking",
"tools",
"audio",
"documents",
"outputImages",
"speechToText",
"textToSpeech",
];
export function getRuntimeModel(provider: AiProvider): string {
switch (provider) {
case AiProvider.OLLAMA:
return Environment.OLLAMA_CHAT_MODEL;
case AiProvider.GEMINI:
return Environment.GEMINI_MODEL;
case AiProvider.MISTRAL:
return Environment.MISTRAL_MODEL;
case AiProvider.OPENAI:
return Environment.OPENAI_MODEL;
}
}
export function setRuntimeModel(provider: AiProvider, model: string): void {
switch (provider) {
case AiProvider.OLLAMA:
Environment.OLLAMA_CHAT_MODEL = model;
break;
case AiProvider.GEMINI:
Environment.GEMINI_MODEL = model;
break;
case AiProvider.MISTRAL:
Environment.MISTRAL_MODEL = model;
break;
case AiProvider.OPENAI:
Environment.OPENAI_MODEL = model;
break;
}
}
function capability(supported: boolean, target?: AiRuntimeTarget, runtimeTarget?: AiRuntimeTarget): AiCapabilityInfo {
const result: AiCapabilityInfo = {supported};
if (target?.model) result.model = target.model;
if (target) {
result.endpoint = {
provider: target.provider,
baseUrl: target.baseUrl,
external: runtimeTarget ? !sameRuntimeEndpoint(target, runtimeTarget) : false,
};
}
if (target && runtimeTarget && (target.model !== runtimeTarget.model || !sameRuntimeEndpoint(target, runtimeTarget))) {
result.external = true;
}
return result;
}
function buildCapabilities(overrides: Partial<Record<AiCapabilityName, AiCapabilityInfo>>): AiModelCapabilities {
return Object.assign(new AiModelCapabilities(), {
chat: {supported: false},
vision: {supported: false},
ocr: {supported: false},
thinking: {supported: false},
extendedThinking: {supported: false},
tools: {supported: false},
audio: {supported: false},
documents: {supported: false},
outputImages: {supported: false},
speechToText: {supported: false},
textToSpeech: {supported: false},
...overrides,
});
}
function lowerModelName(model: string): string {
return model.toLowerCase();
}
function isOpenAiTextModel(model: string): boolean {
const name = lowerModelName(model);
if (!name) return false;
if (/^(gpt-image|dall-e|tts-|whisper|text-embedding|text-moderation|omni-moderation)/.test(name)) return false;
if (name.includes("transcribe")) return false;
return /^(gpt-|o\d|chatgpt-|codex-|computer-use)/.test(name);
}
function isOpenAiReasoningModel(model: string): boolean {
const name = lowerModelName(model);
return /^o\d/.test(name) || name.startsWith("gpt-5");
}
function isOpenAiVisionModel(model: string): boolean {
const name = lowerModelName(model);
if (!isOpenAiTextModel(model)) return false;
if (name.startsWith("gpt-3.5")) return false;
if (name.includes("audio-preview") || name.includes("search-preview")) return false;
return true;
}
function isGeminiNonChatModel(model: string): boolean {
const name = lowerModelName(model);
return name.includes("lyria") || name.includes("-tts") || name.includes("image-preview") || name.endsWith("-image");
}
function geminiSupportsAudioInput(model: string): boolean {
const name = lowerModelName(model);
return name.startsWith("gemini-") && !isGeminiNonChatModel(model);
}
export async function getModelCapabilities(
provider: AiProvider,
model: string,
purpose: AiCapabilityName | "chat" = "chat",
): Promise<AiModelCapabilities | undefined> {
if (!model) return undefined;
try {
const runtimeTarget = resolveAiRuntimeTarget(provider, "chat", getRuntimeModel(provider));
const target = resolveAiRuntimeTarget(provider, purpose, model);
switch (provider) {
case AiProvider.OLLAMA: {
const ollama = createOllamaClient(target);
const info = await ollama.show({model});
const modelCapabilities = Array.isArray(info.capabilities) ? info.capabilities : [];
const has = (cap: string): boolean => modelCapabilities.includes(cap);
const audioSupported = isOllamaSpeechToTextModel(model);
const documentsTarget = resolveAiRuntimeTarget(provider, "documents");
return buildCapabilities({
chat: capability(true, target, runtimeTarget),
vision: capability(has("vision"), target, runtimeTarget),
ocr: capability(has("ocr"), target, runtimeTarget),
thinking: capability(has("thinking"), target, runtimeTarget),
extendedThinking: capability(has("thinking") && model.includes("gpt-oss"), target, runtimeTarget),
tools: capability(has("tools"), target, runtimeTarget),
audio: capability(audioSupported, target, runtimeTarget),
documents: capability(!!documentsTarget.model, documentsTarget, runtimeTarget),
speechToText: capability(audioSupported, target, runtimeTarget),
});
}
case AiProvider.GEMINI: {
const chatLike = lowerModelName(model).startsWith("gemini-") && !isGeminiNonChatModel(model);
const reasoningModel = lowerModelName(model).includes("2.5") || lowerModelName(model).includes("thinking");
const imageTarget = resolveAiRuntimeTarget(provider, "vision");
const speechTarget = resolveAiRuntimeTarget(provider, "speechToText");
const ttsTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
return buildCapabilities({
chat: capability(true, target, runtimeTarget),
vision: capability(!!imageTarget.apiKey && !!imageTarget.model, imageTarget, runtimeTarget),
ocr: capability(!!imageTarget.apiKey && !!imageTarget.model, imageTarget, runtimeTarget),
thinking: capability(reasoningModel, target, runtimeTarget),
extendedThinking: capability(reasoningModel, target, runtimeTarget),
tools: capability(chatLike, target, runtimeTarget),
audio: capability(geminiSupportsAudioInput(model), target, runtimeTarget),
speechToText: capability(!!speechTarget.apiKey && geminiSupportsAudioInput(speechTarget.model), speechTarget, runtimeTarget),
outputImages: capability(!!imageTarget.apiKey && !!imageTarget.model, imageTarget, runtimeTarget),
textToSpeech: capability(!!ttsTarget.apiKey && !!ttsTarget.model, ttsTarget, runtimeTarget),
});
}
case AiProvider.MISTRAL: {
const mistral = createMistralClient(target);
const info = await mistral.models.retrieve({modelId: model});
const caps = info.type !== "UNKNOWN" ? info.capabilities : undefined;
const speechTarget = resolveAiRuntimeTarget(provider, "speechToText");
const ttsTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
return buildCapabilities({
chat: capability(true, target, runtimeTarget),
vision: capability(!!caps?.vision, target, runtimeTarget),
ocr: capability(!!caps?.ocr, target, runtimeTarget),
thinking: capability(!!caps?.reasoning, target, runtimeTarget),
tools: capability(!!caps?.functionCalling, target, runtimeTarget),
audio: capability(!!caps?.audio, target, runtimeTarget),
documents: capability(true, target, runtimeTarget),
speechToText: capability(!!speechTarget.model || !!caps?.audioTranscription, speechTarget, runtimeTarget),
textToSpeech: capability(!!ttsTarget.apiKey && !!ttsTarget.model, ttsTarget, runtimeTarget),
});
}
case AiProvider.OPENAI: {
const textModel = isOpenAiTextModel(model);
const reasoningModel = isOpenAiReasoningModel(model);
const imageTarget = resolveAiRuntimeTarget(provider, "outputImages");
const speechTarget = resolveAiRuntimeTarget(provider, "speechToText");
const ttsTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
return buildCapabilities({
chat: capability(true, target, runtimeTarget),
vision: capability(isOpenAiVisionModel(model), target, runtimeTarget),
ocr: capability(isOpenAiVisionModel(model), target, runtimeTarget),
thinking: capability(reasoningModel, target, runtimeTarget),
extendedThinking: capability(reasoningModel, target, runtimeTarget),
tools: capability(textModel, target, runtimeTarget),
outputImages: capability(!!imageTarget.model, imageTarget, runtimeTarget),
speechToText: capability(!!speechTarget.model, speechTarget, runtimeTarget),
textToSpeech: capability(!!ttsTarget.apiKey && !!ttsTarget.model, ttsTarget, runtimeTarget),
});
}
}
} catch (e) {
logError(e);
return undefined;
}
}
export async function getRuntimeCapabilities(
provider: AiProvider = Environment.DEFAULT_AI_PROVIDER,
model: string | undefined = getRuntimeModel(provider),
target?: AiRuntimeTarget
): Promise<AiModelCapabilities> {
const runtimeTarget = target ?? resolveAiRuntimeTarget(provider, "chat", model ?? getRuntimeModel(provider));
const result = await getModelCapabilities(provider, runtimeTarget.model, target?.purpose ?? "chat") ?? buildCapabilities({});
for (const capabilityName of CAPABILITY_NAMES) {
const target = resolveAiRuntimeTarget(provider, capabilityName);
if (target.model === runtimeTarget.model && sameRuntimeEndpoint(target, runtimeTarget)) continue;
const targetCapabilities = await getModelCapabilities(provider, target.model, capabilityName);
const capabilityInfo = targetCapabilities?.[capabilityName];
if (capabilityInfo) {
result[capabilityName] = capabilityInfo;
}
}
return result;
}
export async function getFormattedCapabilities(
provider: AiProvider = Environment.DEFAULT_AI_PROVIDER,
model: string | undefined = getRuntimeModel(provider),
caps?: AiModelCapabilities,
): Promise<string[]> {
if (!caps) caps = await getRuntimeCapabilities(provider, model);
const line = (title: string, value?: AiCapabilityInfo) => {
const state = value?.supported ? "✅" : "❌";
const external = value?.external ?? (!!value?.model && value.model !== model);
return Environment.getRuntimeCapabilityLineText({
state,
title,
model: value?.model,
endpointBaseUrl: value?.endpoint?.baseUrl,
external,
});
};
return [
line(Environment.runtimeCapabilityChatText, caps.chat),
line(Environment.runtimeCapabilityVisionText, caps.vision),
line(Environment.runtimeCapabilityOcrText, caps.ocr),
line(Environment.runtimeCapabilityThinkingText, caps.thinking),
line(Environment.runtimeCapabilityExtendedThinkingText, caps.extendedThinking),
line(Environment.runtimeCapabilityToolsText, caps.tools),
line(Environment.runtimeCapabilityAudioText, caps.audio),
line(Environment.runtimeCapabilitySpeechToTextText, caps.speechToText),
line(Environment.runtimeCapabilityTextToSpeechText, caps.textToSpeech),
line(Environment.runtimeCapabilityDocumentsText, caps.documents),
line(Environment.runtimeCapabilityOutputImagesText, caps.outputImages),
];
}
export async function formatRuntimeModelInfo(
provider: AiProvider = Environment.DEFAULT_AI_PROVIDER,
model: string | undefined = getRuntimeModel(provider),
caps?: AiModelCapabilities,
): Promise<string> {
return Environment.getRuntimeModelInfoText(
provider.toString().toLowerCase(),
model,
await getFormattedCapabilities(provider, model, caps)
);
}
type NamedModel = {
id?: string;
name?: string;
model?: string;
};
type ModelListResponse = {
models?: NamedModel[];
data?: NamedModel[];
};
export async function listProviderModels(provider: AiProvider): Promise<string[]> {
const target = resolveAiRuntimeTarget(provider, "chat", getRuntimeModel(provider));
switch (provider) {
case AiProvider.OLLAMA: {
const ollama = createOllamaClient(target);
const result = await ollama.list() as ModelListResponse;
return (result.models ?? []).map(m => m.model || m.name).filter((name): name is string => !!name);
}
case AiProvider.GEMINI: {
const models: string[] = [];
if (getGeminiApiMode(target) === "openai") {
const geminiAi = createGeminiOpenAiClient(target);
const iterable = await geminiAi.models.list() as AsyncIterable<NamedModel>;
for await (const model of iterable) models.push(model.name || model.id || String(model));
return models;
}
const geminiAi = createGoogleGenAiClient(target);
const iterable = await geminiAi.models.list() as AsyncIterable<NamedModel>;
for await (const model of iterable) {
const name = model.name || model.id || String(model);
models.push(String(name).replace(/^models\//, ""));
}
return models;
}
case AiProvider.MISTRAL: {
const mistralAi = createMistralClient(target);
const result = await mistralAi.models.list() as ModelListResponse | NamedModel[];
const items = Array.isArray(result) ? result : result.data ?? result.models ?? [];
return items.map(m => m.id || m.name || String(m)).filter((name): name is string => !!name);
}
case AiProvider.OPENAI: {
const openAi = createOpenAiClient(target);
const result = await openAi.models.list() as ModelListResponse;
return (result.data ?? []).map(m => m.id).filter((id): id is string => !!id);
}
}
}
+194
View File
@@ -0,0 +1,194 @@
import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {appLogger} from "../logging/logger";
const logger = appLogger.child("ai-provider-queue");
export type AiRequestQueueTarget = {
provider: AiProvider;
model: string;
baseUrl?: string;
};
type QueueEntry = {
target: AiRequestQueueTarget;
queueKey: string;
run: () => Promise<unknown>;
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
onPositionChange: (requestsBefore: number) => Promise<void> | void;
signal?: AbortSignal;
abortHandler?: () => void;
started: boolean;
};
type EnqueueOptions<T> = {
signal?: AbortSignal;
onPositionChange: (requestsBefore: number) => Promise<void> | void;
run: () => Promise<T>;
};
class AiProviderRequestQueue {
private readonly waiting = new Map<string, QueueEntry[]>();
private readonly active = new Map<string, number>();
enqueue<T>(target: AiRequestQueueTarget, options: EnqueueOptions<T>): Promise<T> {
if (options.signal?.aborted) {
logger.debug("enqueue.rejected.aborted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl});
return Promise.reject(new Error("Aborted"));
}
return new Promise<T>((resolve, reject) => {
const queueKey = this.queueKey(target);
const entry: QueueEntry = {
target,
queueKey,
run: options.run,
resolve: value => resolve(value as T),
reject,
onPositionChange: options.onPositionChange,
signal: options.signal,
started: false,
};
entry.abortHandler = () => {
if (entry.started) return;
const removed = this.removeWaitingEntry(entry);
if (!removed) return;
logger.debug("entry.cancelled", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queueKey});
reject(new Error("Aborted"));
this.schedule(target);
};
options.signal?.addEventListener("abort", entry.abortHandler, {once: true});
this.getOrCreateQueue(queueKey).push(entry);
logger.debug("enqueue.accepted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queued: this.getOrCreateQueue(queueKey).length, active: this.activeCount(queueKey)});
this.schedule(target);
});
}
private getQueue(queueKey: string): QueueEntry[] | undefined {
return this.waiting.get(queueKey);
}
private getOrCreateQueue(queueKey: string): QueueEntry[] {
let queue = this.waiting.get(queueKey);
if (!queue) {
queue = [];
this.waiting.set(queueKey, queue);
}
return queue;
}
private activeCount(queueKey: string): number {
return this.active.get(queueKey) ?? 0;
}
private setActiveCount(queueKey: string, count: number): void {
if (count <= 0) {
this.active.delete(queueKey);
return;
}
this.active.set(queueKey, count);
}
private maxActive(target: AiRequestQueueTarget): number {
return Math.max(1, Environment.getAiProviderMaxConcurrentRequests(target.provider));
}
private normalizeBaseUrl(baseUrl: string | undefined): string {
return (baseUrl ?? "").trim().replace(/\/+$/, "");
}
private queueKey(target: AiRequestQueueTarget): string {
return JSON.stringify([
target.provider,
this.normalizeBaseUrl(target.baseUrl),
target.model.trim(),
]);
}
private removeWaitingEntry(entry: QueueEntry): boolean {
const queue = this.getQueue(entry.queueKey);
if (!queue) return false;
const index = queue.indexOf(entry);
if (index < 0) return false;
queue.splice(index, 1);
if (entry.abortHandler) {
entry.signal?.removeEventListener("abort", entry.abortHandler);
}
this.deleteQueueIfIdle(entry.queueKey, queue);
return true;
}
private schedule(target: AiRequestQueueTarget): void {
const queueKey = this.queueKey(target);
const queue = this.getOrCreateQueue(queueKey);
while (queue.length && this.activeCount(queueKey) < this.maxActive(target)) {
const entry = queue.shift();
if (!entry) continue;
if (entry.abortHandler) {
entry.signal?.removeEventListener("abort", entry.abortHandler);
}
if (entry.signal?.aborted) {
logger.debug("entry.skipped.aborted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queueKey});
entry.reject(new Error("Aborted"));
continue;
}
entry.started = true;
this.setActiveCount(queueKey, this.activeCount(queueKey) + 1);
logger.debug("entry.started", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queued: queue.length, active: this.activeCount(queueKey)});
void this.runEntry(entry);
}
this.updateWaitingMessages(target);
if (!queue.length && this.activeCount(queueKey) <= 0) {
this.waiting.delete(queueKey);
}
}
private async runEntry(entry: QueueEntry): Promise<void> {
try {
entry.resolve(await entry.run());
logger.debug("entry.done", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl});
} catch (e) {
logger.error("entry.failed", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl, error: e});
entry.reject(e);
} finally {
this.setActiveCount(entry.queueKey, this.activeCount(entry.queueKey) - 1);
this.schedule(entry.target);
}
}
private updateWaitingMessages(target: AiRequestQueueTarget): void {
const queueKey = this.queueKey(target);
const active = this.activeCount(queueKey);
const queue = [...(this.getQueue(queueKey) ?? [])];
Promise.allSettled(queue.map((entry, index) => {
return entry.onPositionChange(active + index);
})).then(results => {
for (const result of results) {
if (result.status === "rejected") {
logger.error("position_update.failed", {provider: target.provider, model: target.model, reason: result.reason});
}
}
}).catch(error => logger.error("position_updates.failed", {provider: target.provider, model: target.model, error}));
}
private deleteQueueIfIdle(queueKey: string, queue: QueueEntry[]): void {
if (!queue.length && this.activeCount(queueKey) <= 0) {
this.waiting.delete(queueKey);
}
}
}
export const aiProviderRequestQueue = new AiProviderRequestQueue();
+24
View File
@@ -0,0 +1,24 @@
import {AiProvider} from "../model/ai-provider";
export const AI_REGENERATE_CALLBACK = "/regenerate_ai";
export type AiRegenerateCallbackData = {
provider: AiProvider;
think: boolean;
};
export function buildAiRegenerateCallbackData(provider: AiProvider, think = false): string {
return `${AI_REGENERATE_CALLBACK} ${provider} ${think ? "1" : "0"}`;
}
export function parseAiRegenerateCallbackData(data: string): AiRegenerateCallbackData | null {
if (!data.startsWith(AI_REGENERATE_CALLBACK)) return null;
const [, provider, think] = data.split(/\s+/);
if (!Object.values(AiProvider).includes(provider as AiProvider)) return null;
return {
provider: provider as AiProvider,
think: think === "1" || think === "true",
};
}
+8
View File
@@ -0,0 +1,8 @@
const OLLAMA_SPEECH_TO_TEXT_MODELS = new Set([
"gemma4:e2b",
"gemma4:e4b",
]);
export function isOllamaSpeechToTextModel(model: string | undefined | null): boolean {
return !!model && OLLAMA_SPEECH_TO_TEXT_MODELS.has(model.trim().toLowerCase());
}
+258
View File
@@ -0,0 +1,258 @@
import fs, {openAsBlob} from "node:fs";
import {AiProvider} from "../model/ai-provider";
import {
getAvailableAiProviderChoices,
getProviderChoiceLabel,
normalizeAiProviderChoice,
resolveEffectiveAiProviderForUser,
} from "../common/user-ai-settings";
import {AiDownloadedFile} from "./telegram-attachments";
import {isOllamaSpeechToTextModel} from "./speech-to-text-models";
import {
createGoogleGenAiClient,
createMistralClient,
createOllamaClient,
createOpenAiClient,
resolveAiRuntimeTarget
} from "./ai-runtime-target";
import {Environment} from "../common/environment";
export type TranscribedSpeech = {
provider: AiProvider;
model: string;
text: string;
fileName: string;
};
export type SpeechToTextRequest = {
provider: AiProvider;
audio: AiDownloadedFile;
signal?: AbortSignal;
};
export type SpeechToTextProviderResolution = {
provider: AiProvider;
fallback: boolean;
};
export type SpeechToTextResolveOptions = {
allowFallback?: boolean;
};
function providerName(provider: AiProvider): string {
return getProviderChoiceLabel(provider);
}
export function isTranscribableAudioDownload(download: AiDownloadedFile): boolean {
if (download.kind === "audio") return true;
return download.kind === "video-note" && (download.mimeType?.startsWith("audio/") || download.path.toLowerCase().endsWith(".wav"));
}
export function isSpeechToTextConfigured(provider: AiProvider): boolean {
switch (provider) {
case AiProvider.OPENAI:
const openAiTarget = resolveAiRuntimeTarget(provider, "speechToText");
return !!openAiTarget.apiKey && !!openAiTarget.model;
case AiProvider.GEMINI:
const geminiTarget = resolveAiRuntimeTarget(provider, "speechToText");
return !!geminiTarget.apiKey && !!geminiTarget.model;
case AiProvider.MISTRAL:
const mistralTarget = resolveAiRuntimeTarget(provider, "speechToText");
return !!mistralTarget.apiKey && !!mistralTarget.model;
case AiProvider.OLLAMA:
const ollamaTarget = resolveAiRuntimeTarget(provider, "speechToText");
return !!ollamaTarget.baseUrl && isOllamaSpeechToTextModel(ollamaTarget.model);
}
}
export async function resolveSpeechToTextProviderForUser(
userId: number,
preferredProvider?: AiProvider,
options: SpeechToTextResolveOptions = {},
): Promise<SpeechToTextProviderResolution> {
const allowFallback = options.allowFallback ?? true;
const availableChoices = getAvailableAiProviderChoices(userId);
const allowedProviders = availableChoices
.map(choice => normalizeAiProviderChoice(choice))
.filter((choice): choice is AiProvider => !!choice && choice !== "DEFAULT");
if (preferredProvider) {
if (!allowedProviders.includes(preferredProvider)) {
throw new Error(Environment.getProviderNotAvailableForAccessText(providerName(preferredProvider)));
}
if (isSpeechToTextConfigured(preferredProvider)) {
return {provider: preferredProvider, fallback: false};
}
if (!allowFallback) {
throw new Error(Environment.getProviderSpeechToTextUnsupportedText(providerName(preferredProvider)));
}
}
const effectiveProvider = await resolveEffectiveAiProviderForUser(userId);
if (isSpeechToTextConfigured(effectiveProvider)) {
return {
provider: effectiveProvider,
fallback: preferredProvider !== undefined && preferredProvider !== effectiveProvider
};
}
const fallbackProvider = allowedProviders.find(isSpeechToTextConfigured);
if (!fallbackProvider) {
throw new Error(Environment.noSpeechToTextProviderForAccessText);
}
return {provider: fallbackProvider, fallback: true};
}
export async function transcribeSpeech(request: SpeechToTextRequest): Promise<TranscribedSpeech> {
if (request.signal?.aborted) throw new Error("Aborted");
switch (request.provider) {
case AiProvider.OPENAI:
return transcribeOpenAiSpeech(request.audio, request.signal);
case AiProvider.GEMINI:
return transcribeGeminiSpeech(request.audio, request.signal);
case AiProvider.MISTRAL:
return transcribeMistralSpeech(request.audio, request.signal);
case AiProvider.OLLAMA:
return transcribeOllamaSpeech(request.audio, request.signal);
}
}
export async function transcribeSpeechDownloads(provider: AiProvider, downloads: AiDownloadedFile[], signal?: AbortSignal): Promise<string> {
const audios = downloads.filter(isTranscribableAudioDownload);
const transcriptions: string[] = [];
for (const [index, audio] of audios.entries()) {
if (signal?.aborted) throw new Error("Aborted");
const result = await transcribeSpeech({provider, audio, signal});
const text = result.text.trim();
if (!text) continue;
transcriptions.push(audios.length > 1
? `[${index + 1}. ${audio.fileName}]\n${text}`
: text);
}
return transcriptions.join("\n\n").trim();
}
async function transcribeOpenAiSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise<TranscribedSpeech> {
const target = resolveAiRuntimeTarget(AiProvider.OPENAI, "speechToText");
const openAi = createOpenAiClient(target);
const file = fs.createReadStream(audio.path);
try {
const result = await openAi.audio.transcriptions.create({
file,
model: target.model,
}, {signal});
return {
provider: AiProvider.OPENAI,
model: target.model,
text: result.text || "",
fileName: audio.fileName,
};
} finally {
file.destroy();
}
}
async function transcribeMistralSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise<TranscribedSpeech> {
const target = resolveAiRuntimeTarget(AiProvider.MISTRAL, "speechToText");
const mistralAi = createMistralClient(target);
const result = await mistralAi.audio.transcriptions.complete({
model: target.model,
file: await openAsBlob(audio.path),
}, {signal});
return {
provider: AiProvider.MISTRAL,
model: target.model,
text: result.text || "",
fileName: audio.fileName,
};
}
async function transcribeGeminiSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise<TranscribedSpeech> {
const target = resolveAiRuntimeTarget(AiProvider.GEMINI, "speechToText");
const geminiAi = createGoogleGenAiClient(target);
const response = await geminiAi.models.generateContent({
model: target.model,
contents: [{
role: "user",
parts: [
{text: "Transcribe the attached audio verbatim. Reply only with the transcription text. Do not answer the speaker."},
{
inlineData: {
data: audio.buffer.toString("base64"),
mimeType: audio.mimeType || "audio/wav",
}
}
]
}],
config: {
temperature: 0,
abortSignal: signal,
},
}) as unknown as GeminiSpeechResponse;
return {
provider: AiProvider.GEMINI,
model: target.model,
text: collectGeminiText(response),
fileName: audio.fileName,
};
}
async function transcribeOllamaSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise<TranscribedSpeech> {
if (signal?.aborted) throw new Error("Aborted");
const target = resolveAiRuntimeTarget(AiProvider.OLLAMA, "speechToText");
const model = target.model;
if (!isOllamaSpeechToTextModel(model)) {
throw new Error(Environment.ollamaSpeechToTextModelRequiredText);
}
const ollama = createOllamaClient(target);
const response = await ollama.chat({
model,
stream: false,
think: false,
messages: [{
role: "user",
content: "Transcribe the attached audio verbatim. Reply only with the transcription text. Do not answer the speaker.",
images: [audio.buffer.toString("base64")],
}],
options: {
temperature: 0,
},
});
return {
provider: AiProvider.OLLAMA,
model,
text: response?.message?.content || "",
fileName: audio.fileName,
};
}
type GeminiSpeechResponse = {
text?: string;
candidates?: Array<{content?: {parts?: Array<{text?: string}>}}> ;
};
function collectGeminiText(response: GeminiSpeechResponse): string {
if (typeof response.text === "string") return response.text;
const candidateText = (response.candidates ?? [])
.flatMap(candidate => candidate.content?.parts ?? [])
.map(part => part.text ?? "")
.join("");
if (candidateText.trim()) return candidateText;
return "";
}
+253
View File
@@ -0,0 +1,253 @@
import {Message} from "typescript-telegram-bot-api";
import {bot} from "../index";
import {downloadTelegramFile, logError} from "../util/utils";
import fs from "node:fs";
import path from "node:path";
import {Environment} from "../common/environment";
import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment";
import {performFFmpeg} from "../util/ffmpeg";
import ffmpeg from "fluent-ffmpeg";
import {AsyncSemaphore, KeyedAsyncLock} from "../util/async-lock";
import {appLogger} from "../logging/logger";
export type AiDownloadedFile = {
kind: StoredAttachmentKind;
fileId: string;
fileName: string;
mimeType?: string;
buffer: Buffer;
path: string;
};
const cachePathLocks = new KeyedAsyncLock();
const ffmpegSemaphore = new AsyncSemaphore(2);
const logger = appLogger.child("attachments");
function safeFileName(value: string): string {
return value.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "_").slice(0, 180);
}
function extensionFromMimeType(mimeType?: string): string {
switch ((mimeType || "").toLowerCase()) {
case "audio/ogg":
case "audio/opus":
return ".ogg";
case "audio/mpeg":
case "audio/mp3":
return ".mp3";
case "audio/mp4":
case "audio/x-m4a":
return ".m4a";
case "audio/wav":
case "audio/wave":
case "audio/x-wav":
return ".wav";
case "audio/webm":
return ".webm";
case "image/jpeg":
return ".jpg";
case "image/png":
return ".png";
case "image/webp":
return ".webp";
case "application/pdf":
return ".pdf";
case "text/plain":
return ".txt";
case "application/zip":
case "application/x-zip":
case "application/x-zip-compressed":
return ".zip";
case "application/x-tar":
case "application/tar":
return ".tar";
case "application/gzip":
case "application/x-gzip":
case "application/gzip-compressed":
return ".gz";
case "video/mp4":
return ".mp4";
default:
return "";
}
}
function fileNameWithExtension(fileName: string, mimeType?: string, telegramFilePath?: string): string {
if (path.extname(fileName)) return fileName;
const telegramExt = telegramFilePath ? path.extname(telegramFilePath) : "";
const ext = telegramExt || extensionFromMimeType(mimeType);
return ext ? `${fileName}${ext}` : fileName;
}
function cacheDirFor(kind: StoredAttachmentKind): string {
const dirName = kind === "image" ? "photo" : kind;
return path.join(Environment.DATA_PATH, "cache", dirName);
}
function cachePathFor(kind: StoredAttachmentKind, fileUniqueId: string | undefined, fileId: string, fileName: string): string {
const base = safeFileName(fileUniqueId || fileId);
const ext = path.extname(fileName);
return path.join(cacheDirFor(kind), `${base}${ext || ""}`);
}
async function downloadToCache(kind: StoredAttachmentKind, fileId: string, fileName: string, mimeType?: string, fileUniqueId?: string): Promise<StoredAttachment | null> {
const startedAt = Date.now();
logger.debug("download.start", {kind, fileId, fileName, mimeType});
const file = await bot.getFile({file_id: fileId});
const finalFileName = fileNameWithExtension(fileName, mimeType, file.file_path);
const location = cachePathFor(kind, fileUniqueId, fileId, finalFileName);
await cachePathLocks.runExclusive(location, async () => {
if (fs.existsSync(location)) {
logger.trace("download.cache_hit", {kind, location});
return;
}
const buffer = await downloadTelegramFile(file.file_path);
if (!buffer) {
logger.warn("download.empty", {kind, fileId, telegramFilePath: file.file_path});
return;
}
const tempLocation = `${location}.${process.pid}.${Date.now()}.tmp`;
fs.mkdirSync(path.dirname(location), {recursive: true});
fs.writeFileSync(tempLocation, buffer);
fs.renameSync(tempLocation, location);
logger.debug("download.saved", {kind, location, bytes: buffer.length, duration: logger.duration(startedAt)});
});
return {kind, fileId, fileUniqueId, fileName: finalFileName, mimeType, cachePath: location};
}
async function convertAudioToWav(input: string, output: string, noVideo = false): Promise<void> {
const startedAt = Date.now();
logger.debug("audio.convert.start", {input, output, noVideo});
await cachePathLocks.runExclusive(output, async () => {
if (fs.existsSync(output)) {
logger.trace("audio.convert.cache_hit", {output});
return;
}
await ffmpegSemaphore.runExclusive(async () => {
if (fs.existsSync(output)) {
logger.trace("audio.convert.cache_hit", {output});
return;
}
const tempOutput = `${output}.${process.pid}.${Date.now()}.tmp.wav`;
try {
await performFFmpeg(() => {
const command = ffmpeg(input);
if (noVideo) command.noVideo();
return command
.toFormat("wav")
.save(tempOutput)
.on("progress", (progress) => {
logger.trace("audio.convert.progress", {input, output, progress});
});
});
fs.renameSync(tempOutput, output);
logger.debug("audio.convert.done", {input, output, duration: logger.duration(startedAt)});
} catch (e) {
if (fs.existsSync(tempOutput)) {
fs.rmSync(tempOutput, {force: true});
}
logger.error("audio.convert.failed", {input, output, error: e});
throw e;
}
});
});
}
export async function cacheMessageAttachments(msg: Message): Promise<StoredAttachment[]> {
const startedAt = Date.now();
const result: StoredAttachment[] = [];
logger.debug("message.cache.start", {chatId: msg.chat?.id, messageId: msg.message_id});
try {
if (msg.photo?.length) {
const size = msg.photo[msg.photo.length - 1]!;
const file = await downloadToCache("image", size.file_id, `${size.file_unique_id || size.file_id}.jpg`, "image/jpeg", size.file_unique_id);
if (file) result.push(file);
}
if (msg.document) {
const doc = msg.document;
const kind: StoredAttachmentKind = doc.mime_type?.startsWith("image/")
? "image"
: doc.mime_type?.startsWith("audio/")
? "audio"
: "document";
const file = await downloadToCache(kind, doc.file_id, doc.file_name || `${doc.file_unique_id || doc.file_id}`, doc.mime_type, doc.file_unique_id);
if (file) result.push(file);
}
if (msg.voice) {
const file = await downloadToCache("audio", msg.voice.file_id, `${msg.voice.file_unique_id || msg.voice.file_id}.ogg`, msg.voice.mime_type || "audio/ogg", msg.voice.file_unique_id);
if (file) {
const output = cachePathFor("audio", msg.voice.file_unique_id, msg.voice.file_id, `${msg.voice.file_unique_id || msg.voice.file_id}.wav`);
try {
await convertAudioToWav(file.cachePath, output);
file.cachePath = output;
file.fileName = file?.fileName?.replace(".ogg", ".wav");
file.mimeType = "audio/wav";
} catch (e) {
logError(e);
}
}
if (file) result.push(file);
}
if (msg.audio) {
const file = await downloadToCache("audio", msg.audio.file_id, msg.audio.file_name || `${msg.audio.file_unique_id || msg.audio.file_id}.mp3`, msg.audio.mime_type, msg.audio.file_unique_id);
if (file) result.push(file);
}
if (msg.video_note) {
const file = await downloadToCache("video-note", msg.video_note.file_id, `${msg.video_note.file_unique_id || msg.video_note.file_id}.mp4`, "video/mp4", msg.video_note.file_unique_id);
if (file) {
const output = cachePathFor("audio", msg.video_note.file_unique_id, msg.video_note.file_id, `${msg.video_note.file_unique_id || msg.video_note.file_id}.wav`);
try {
await convertAudioToWav(file.cachePath, output, true);
file.cachePath = output;
file.fileName = file?.fileName?.replace(".mp4", ".wav");
file.mimeType = "audio/wav";
} catch (e) {
logError(e);
}
}
if (file) result.push(file);
}
} catch (e) {
logError(e);
}
logger.debug("message.cache.done", {chatId: msg.chat?.id, messageId: msg.message_id, attachments: result.length, duration: logger.duration(startedAt)});
return result;
}
export function attachmentsToDownloadedFiles(attachments: StoredAttachment[]): AiDownloadedFile[] {
logger.trace("downloaded_files.build", {attachments: attachments.length});
return attachments
.filter(attachment => fs.existsSync(attachment.cachePath))
.map(attachment => ({
kind: attachment.kind,
fileId: attachment.fileId,
fileName: attachment.fileName,
mimeType: attachment.mimeType,
buffer: fs.readFileSync(attachment.cachePath),
path: attachment.cachePath,
}));
}
export function cleanupDownloads(files: AiDownloadedFile[]): void {
logger.trace("downloaded_files.cleanup", {files: files.length});
// Files stay on disk in the message cache; drop in-memory buffers eagerly.
for (const file of files) {
file.buffer = Buffer.alloc(0);
}
files.length = 0;
}
+542
View File
@@ -0,0 +1,542 @@
import {FileOptions, InlineKeyboardMarkup, Message} from "typescript-telegram-bot-api";
import {bot} from "../index";
import {buildCancelledGenerationText, logError, replyToMessage} from "../util/utils";
import {Environment} from "../common/environment";
import {MessageStore} from "../common/message-store";
import {createQueuedFunction} from "../util/async-lock";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import fs from "node:fs";
import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment";
import {StoredMessage} from "../model/stored-message";
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
import {AiProvider} from "../model/ai-provider";
const TELEGRAM_LIMIT = 4096;
const TELEGRAM_CAPTION_LIMIT = 1024;
const TELEGRAM_FILE_LIMIT_BYTES = 50 * 1024 * 1024;
const TELEGRAM_PHOTO_LIMIT_BYTES = 10 * 1024 * 1024;
const EDIT_INTERVAL_MS = 4500;
export type TelegramArtifactFile = {
kind: "image" | "file";
path: string;
fileName: string;
mimeType?: string;
sizeBytes: number;
};
export class TelegramStreamMessage {
private waitMessage: Message | null = null;
private timer: NodeJS.Timeout | null = null;
private lastSent = "";
private text = "";
private status = "";
private mediaMode = false;
private cancelled = false;
private cancelledProvider = "";
private readonly startedAt = Date.now();
private readonly enqueueEdit = createQueuedFunction();
constructor(
private readonly sourceMessage: Message,
private readonly cancelRequestId: string,
private readonly stream: boolean,
private readonly regenerateCallbackData?: string,
private readonly targetMessage?: Message,
private readonly cancelProvider?: AiProvider,
private readonly isGuest?: boolean,
) {
}
keyboard(): InlineKeyboardMarkup {
return {
inline_keyboard: [[{
text: Environment.cancelText,
callback_data: this.cancelProvider
? `/cancel_ai ${this.cancelRequestId} ${this.cancelProvider}`
: `/cancel_ai ${this.cancelRequestId}`,
}]],
};
}
emptyKeyboard(): InlineKeyboardMarkup {
return {inline_keyboard: []};
}
regenerateKeyboard(): InlineKeyboardMarkup | null {
if (!this.regenerateCallbackData) return null;
return {
inline_keyboard: [[{
text: Environment.regenerateText,
callback_data: this.regenerateCallbackData,
}]],
};
}
private isMessageNotModified(error: unknown): boolean {
const textToLookUp = "message is not modified";
if (error && error instanceof Error) {
return String(error.message).includes(textToLookUp);
}
if (error && error instanceof String) {
return error.includes(textToLookUp);
}
return false;
}
private async updateKeyboard(replyMarkup: InlineKeyboardMarkup): Promise<void> {
if (!this.waitMessage) return;
try {
await enqueueTelegramApiCall(
() => bot.editMessageReplyMarkup({
chat_id: this.waitMessage!.chat.id,
message_id: this.waitMessage!.message_id,
reply_markup: replyMarkup,
}),
{
method: "editMessageReplyMarkup",
chatId: this.waitMessage.chat.id,
chatType: this.waitMessage.chat.type,
}
);
} catch (e) {
if (!this.isMessageNotModified(e)) logError(e);
}
}
private async removeKeyboard(): Promise<void> {
await this.updateKeyboard(this.emptyKeyboard());
}
private startFlushTimer(): void {
if (this.timer) clearInterval(this.timer);
this.timer = setInterval(() => this.flush().catch(logError), EDIT_INTERVAL_MS);
}
private visibleText(): string {
const parts = [this.text, this.status].filter(v => v && v.trim().length);
let value = parts.join("\n\n").trim() || Environment.waitThinkText;
if (value.length > TELEGRAM_LIMIT) {
value = value.substring(0, TELEGRAM_LIMIT - 1);
}
return value;
}
private visibleCaption(): string {
let value = this.visibleText();
if (value.length > TELEGRAM_CAPTION_LIMIT) {
value = value.substring(0, TELEGRAM_CAPTION_LIMIT - 1);
}
return value;
}
async start(initialStatus: string): Promise<Message> {
this.status = initialStatus;
const rawText = this.visibleText();
const formatted = prepareTelegramMarkdownV2(rawText, {mode: "draft"});
if (this.targetMessage) {
this.waitMessage = this.targetMessage;
try {
await MessageStore.put(this.targetMessage).catch(logError);
const result = await enqueueTelegramApiCall(
() => bot.editMessageText({
chat_id: this.targetMessage!.chat.id,
message_id: this.targetMessage!.message_id,
text: formatted,
parse_mode: "MarkdownV2",
reply_markup: this.keyboard(),
}),
{
method: "editMessageText",
chatId: this.targetMessage.chat.id,
chatType: this.targetMessage.chat.type,
}
);
if (result && result !== true) this.waitMessage = result;
this.mediaMode = false;
this.lastSent = rawText;
await this.store();
this.startFlushTimer();
return this.waitMessage;
} catch (e) {
if (this.isMessageNotModified(e)) {
this.lastSent = rawText;
await this.updateKeyboard(this.keyboard());
await this.store();
this.startFlushTimer();
return this.waitMessage;
}
logError(e);
this.waitMessage = null;
this.mediaMode = false;
}
}
this.waitMessage = await replyToMessage({
message: this.sourceMessage,
text: formatted,
reply_markup: this.keyboard(),
parse_mode: "MarkdownV2"
});
this.lastSent = rawText;
this.startFlushTimer();
return this.waitMessage;
}
setStatus(status: string): void {
if (this.cancelled) return;
this.status = status;
}
getStatus(): string {
return this.status;
}
clearStatus(): void {
if (this.cancelled) return;
this.status = "";
}
append(delta: string): void {
if (this.cancelled) return;
if (!delta) return;
this.text += delta;
}
replaceText(text: string): void {
if (this.cancelled) return;
this.text = text;
}
getText(): string {
return this.text;
}
async flush(replyMarkup: InlineKeyboardMarkup | null = this.keyboard(), end?: boolean): Promise<void> {
return this.enqueueEdit(() => this.flushUnsafe(replyMarkup, end));
}
private async flushUnsafe(replyMarkup: InlineKeyboardMarkup | null = this.keyboard(), end?: boolean): Promise<void> {
if (!this.waitMessage && this.stream) return;
const next = this.mediaMode ? this.visibleCaption() : this.visibleText();
const shouldRemoveKeyboard = replyMarkup === null;
if (next === this.lastSent && shouldRemoveKeyboard) {
await this.removeKeyboard();
return;
}
const formatted = prepareTelegramMarkdownV2(next, {mode: end ? "final" : "draft"});
if (next === this.lastSent && replyMarkup !== null) {
if (end) await this.updateKeyboard(replyMarkup);
return;
}
try {
if (!this.stream && end && !this.waitMessage) {
if (this.isGuest) {
// await enqueueTelegramApiCall(() => bot.answerGuestQuery({
// guest_query_id: this.sourceMessage.guest_query_id ?? "",
// result: {}
// }),
// {});
} else {
await replyToMessage({
message: this.sourceMessage,
text: formatted,
parse_mode: "MarkdownV2",
});
}
} else {
if (this.waitMessage) {
const result = this.mediaMode
? await enqueueTelegramApiCall(
() => bot.editMessageCaption({
chat_id: this.waitMessage!.chat.id,
message_id: this.waitMessage!.message_id,
caption: formatted,
parse_mode: "MarkdownV2",
reply_markup: replyMarkup ?? this.emptyKeyboard(),
}),
{
method: "editMessageCaption",
chatId: this.waitMessage.chat.id,
chatType: this.waitMessage.chat.type,
}
)
: await enqueueTelegramApiCall(
() => bot.editMessageText({
chat_id: this.waitMessage!.chat.id,
message_id: this.waitMessage!.message_id,
text: formatted,
parse_mode: "MarkdownV2",
reply_markup: replyMarkup ?? this.emptyKeyboard(),
}),
{
method: "editMessageText",
chatId: this.waitMessage.chat.id,
chatType: this.waitMessage.chat.type,
}
);
if (result && result !== true) this.waitMessage = result;
}
}
if (shouldRemoveKeyboard) await this.removeKeyboard();
this.lastSent = next;
} catch (e: unknown) {
if (shouldRemoveKeyboard && this.isMessageNotModified(e)) {
await this.removeKeyboard();
this.lastSent = next;
return;
}
if (!this.isMessageNotModified(e)) logError(e);
}
}
async cancel(provider: string): Promise<void> {
if (this.timer) clearInterval(this.timer);
this.timer = null;
this.cancelled = true;
this.cancelledProvider = provider;
this.status = "";
this.text = buildCancelledGenerationText(this.text, this.cancelledProvider, this.mediaMode ? TELEGRAM_CAPTION_LIMIT : TELEGRAM_LIMIT);
await this.flush(this.regenerateKeyboard(), true);
await this.store();
}
async showImage(image: Buffer): Promise<void> {
return this.enqueueEdit(() => this.showImageUnsafe(image));
}
async sendArtifact(file: TelegramArtifactFile): Promise<Message | null> {
return this.enqueueEdit(() => this.sendArtifactUnsafe(file));
}
private async showImageUnsafe(image: Buffer): Promise<void> {
if (this.cancelled) return;
const next = this.visibleCaption();
if (!this.waitMessage) {
if (this.stream) return;
this.waitMessage = await enqueueTelegramApiCall(
() => bot.sendPhoto({
chat_id: this.sourceMessage.chat.id,
photo: image,
caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
parse_mode: "MarkdownV2",
reply_parameters: {message_id: this.sourceMessage.message_id},
}),
{
method: "sendPhoto",
chatId: this.sourceMessage.chat.id,
chatType: this.sourceMessage.chat.type,
}
);
this.mediaMode = true;
this.lastSent = next;
return;
}
try {
const result = await enqueueTelegramApiCall(
() => bot.editMessageMedia({
chat_id: this.waitMessage!.chat.id,
message_id: this.waitMessage!.message_id,
media: {
type: "photo",
media: image,
caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
parse_mode: "MarkdownV2",
},
reply_markup: this.keyboard(),
}),
{
method: "editMessageMedia",
chatId: this.waitMessage.chat.id,
chatType: this.waitMessage.chat.type,
}
);
if (result && result !== true) this.waitMessage = result;
this.mediaMode = true;
this.lastSent = next;
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
if (!message.includes("message is not modified")) logError(e);
}
}
private async sendArtifactUnsafe(file: TelegramArtifactFile): Promise<Message | null> {
if (this.cancelled) return null;
if (file.sizeBytes > TELEGRAM_FILE_LIMIT_BYTES) {
throw new Error(Environment.getTelegramFileTooLargeText(
file.fileName,
TELEGRAM_FILE_LIMIT_BYTES / 1024 / 1024,
));
}
const caption = file.fileName.slice(0, TELEGRAM_CAPTION_LIMIT);
const isPhoto = this.isPhotoArtifact(file);
await enqueueTelegramApiCall(
() => bot.sendChatAction({
chat_id: this.sourceMessage.chat.id,
action: isPhoto ? "upload_photo" : "upload_document",
}),
{
method: "sendChatAction",
chatId: this.sourceMessage.chat.id,
chatType: this.sourceMessage.chat.type,
}
).catch(logError);
let sent: Message;
if (isPhoto) {
try {
sent = await enqueueTelegramApiCall(
async () => {
const upload = this.createArtifactUpload(file);
try {
return await bot.sendPhoto({
chat_id: this.sourceMessage.chat.id,
photo: upload,
caption,
reply_parameters: {message_id: this.sourceMessage.message_id},
});
} finally {
this.destroyUpload(upload);
}
},
{
method: "sendPhoto",
chatId: this.sourceMessage.chat.id,
chatType: this.sourceMessage.chat.type,
}
);
} catch (e) {
logError(e);
sent = await this.sendArtifactAsDocument(file, caption);
}
} else {
sent = await this.sendArtifactAsDocument(file, caption);
}
await this.storeArtifactMessage(sent, file);
return sent;
}
private isPhotoArtifact(file: TelegramArtifactFile): boolean {
return file.kind === "image"
&& file.sizeBytes <= TELEGRAM_PHOTO_LIMIT_BYTES
&& ["image/jpeg", "image/png", "image/webp"].includes((file.mimeType || "").toLowerCase());
}
private createArtifactUpload(file: TelegramArtifactFile): FileOptions {
return new FileOptions(fs.createReadStream(file.path), {
filename: file.fileName,
contentType: file.mimeType || "application/octet-stream",
});
}
private destroyUpload(upload: FileOptions): void {
if ("destroy" in upload.file && typeof upload.file.destroy === "function") {
upload.file.destroy();
}
}
private async sendArtifactAsDocument(file: TelegramArtifactFile, caption: string): Promise<Message> {
return enqueueTelegramApiCall(
async () => {
const upload = this.createArtifactUpload(file);
try {
return await bot.sendDocument({
chat_id: this.sourceMessage.chat.id,
document: upload,
caption,
reply_parameters: {message_id: this.sourceMessage.message_id},
});
} finally {
this.destroyUpload(upload);
}
},
{
method: "sendDocument",
chatId: this.sourceMessage.chat.id,
chatType: this.sourceMessage.chat.type,
}
);
}
private async storeArtifactMessage(sent: Message, file: TelegramArtifactFile): Promise<void> {
const photo = sent.photo?.[sent.photo.length - 1];
const attachmentKind: StoredAttachmentKind = file.kind === "image" ? "image" : "document";
const attachment: StoredAttachment = {
kind: attachmentKind,
fileId: sent.document?.file_id ?? photo?.file_id ?? file.path,
fileUniqueId: sent.document?.file_unique_id ?? photo?.file_unique_id,
fileName: file.fileName,
mimeType: file.mimeType,
cachePath: file.path,
};
const stored: StoredMessage = {
chatId: sent.chat.id,
id: sent.message_id,
replyToMessageId: sent.reply_to_message?.message_id ?? this.sourceMessage.message_id,
fromId: sent.from?.id ?? 0,
text: sent.caption ?? file.fileName,
date: sent.date ?? Math.floor(Date.now() / 1000),
attachments: [attachment],
};
await MessageStore.put(stored);
}
async finish(removeKeyboard = true): Promise<void> {
if (this.timer) clearInterval(this.timer);
this.timer = null;
if (this.cancelled) {
await this.flush(removeKeyboard ? this.regenerateKeyboard() : this.keyboard(), true);
await this.store();
return;
}
if (Environment.SEND_TIME_TOOK) {
const diff = Date.now() - this.startedAt;
if (this.text.length + 32 < TELEGRAM_LIMIT) this.text += `\n\n⏱️ ${diff}ms`;
}
this.clearStatus();
await this.flush(removeKeyboard ? this.regenerateKeyboard() : this.keyboard(), true);
await this.store();
}
async fail(error: unknown): Promise<void> {
if (this.timer) clearInterval(this.timer);
this.timer = null;
this.status = "";
this.text = `${Environment.errorText}\n${error instanceof Error ? error.message : String(error)}`;
await this.flush(this.regenerateKeyboard(), true);
}
private async store(): Promise<void> {
if (!this.waitMessage) return;
try {
await MessageStore.put({...this.waitMessage, text: this.visibleText()} as Message);
} catch (e) {
logError(e);
}
}
}
+435
View File
@@ -0,0 +1,435 @@
import fs from "node:fs";
import path from "node:path";
import {randomUUID} from "node:crypto";
import {FileOptions, Message} from "typescript-telegram-bot-api";
import {AiProvider} from "../model/ai-provider";
import {Environment} from "../common/environment";
import {bot} from "../index";
import {
getAvailableAiProviderChoices,
getProviderChoiceLabel,
normalizeAiProviderChoice,
resolveEffectiveAiProviderForUser,
} from "../common/user-ai-settings";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {MessageStore} from "../common/message-store";
import {StoredAttachment} from "../model/stored-attachment";
import {StoredMessage} from "../model/stored-message";
import {logError} from "../util/utils";
import {SpeechRequest} from "@mistralai/mistralai/models/components";
import {createGoogleGenAiClient, createMistralClient, createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target";
const MAX_TTS_TEXT_CHARS = 4096;
const TELEGRAM_FILE_LIMIT_BYTES = 50 * 1024 * 1024;
export type TextToSpeechFormat = "mp3" | "wav" | "flac" | "opus" | "aac" | "pcm";
export type SynthesizedSpeech = {
provider: AiProvider;
model: string;
voice?: string;
format: TextToSpeechFormat;
mimeType: string;
fileName: string;
path: string;
sizeBytes: number;
};
export type TextToSpeechRequest = {
provider: AiProvider;
text: string;
voice?: string;
};
export type TextToSpeechProviderResolution = {
provider: AiProvider;
fallback: boolean;
};
type SpeechFileParams = Omit<SynthesizedSpeech, "fileName" | "path" | "sizeBytes"> & {
buffer: Buffer;
};
function ttsCacheDir(): string {
return path.join(Environment.DATA_PATH, "cache", "audio");
}
function providerName(provider: AiProvider): string {
return getProviderChoiceLabel(provider);
}
function assertText(text: string): string {
const normalized = text.trim();
if (!normalized) {
throw new Error(Environment.noTextToSynthesizeText);
}
if (normalized.length > MAX_TTS_TEXT_CHARS) {
throw new Error(Environment.getTextToSpeechTooLongText(normalized.length, MAX_TTS_TEXT_CHARS));
}
return normalized;
}
export function isTextToSpeechConfigured(provider: AiProvider): boolean {
switch (provider) {
case AiProvider.OPENAI:
const openAiTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
return !!openAiTarget.apiKey && !!openAiTarget.model;
case AiProvider.GEMINI:
const geminiTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
return !!geminiTarget.apiKey && !!geminiTarget.model;
case AiProvider.MISTRAL:
const mistralTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
return !!mistralTarget.apiKey && !!mistralTarget.model;
case AiProvider.OLLAMA:
return false;
}
}
export async function resolveTextToSpeechProviderForUser(
userId: number,
explicitProvider?: AiProvider,
): Promise<TextToSpeechProviderResolution> {
const availableChoices = getAvailableAiProviderChoices(userId);
const allowedProviders = availableChoices
.map(choice => normalizeAiProviderChoice(choice))
.filter((choice): choice is AiProvider => !!choice && choice !== "DEFAULT");
if (explicitProvider) {
if (!allowedProviders.includes(explicitProvider)) {
throw new Error(Environment.getProviderNotAvailableForAccessText(providerName(explicitProvider)));
}
if (!isTextToSpeechConfigured(explicitProvider)) {
throw new Error(Environment.getProviderTextToSpeechUnsupportedText(providerName(explicitProvider)));
}
return {provider: explicitProvider, fallback: false};
}
const effectiveProvider = await resolveEffectiveAiProviderForUser(userId);
if (isTextToSpeechConfigured(effectiveProvider)) {
return {provider: effectiveProvider, fallback: false};
}
const fallbackProvider = allowedProviders.find(isTextToSpeechConfigured);
if (!fallbackProvider) {
throw new Error(Environment.noTextToSpeechProviderForAccessText);
}
return {provider: fallbackProvider, fallback: true};
}
export async function synthesizeSpeech(request: TextToSpeechRequest): Promise<SynthesizedSpeech> {
const text = assertText(request.text);
switch (request.provider) {
case AiProvider.OPENAI:
return synthesizeOpenAiSpeech(text, request.voice);
case AiProvider.GEMINI:
return synthesizeGeminiSpeech(text, request.voice);
case AiProvider.MISTRAL:
return synthesizeMistralSpeech(text, request.voice);
case AiProvider.OLLAMA:
throw new Error(Environment.ollamaTextToSpeechUnsupportedText);
}
}
async function synthesizeOpenAiSpeech(text: string, voice?: string): Promise<SynthesizedSpeech> {
const target = resolveAiRuntimeTarget(AiProvider.OPENAI, "textToSpeech");
const openAi = createOpenAiClient(target);
const response = await openAi.audio.speech.create({
model: target.model,
voice: voice || Environment.OPENAI_TTS_VOICE,
input: text,
response_format: "mp3",
instructions: Environment.OPENAI_TTS_INSTRUCTIONS,
});
const buffer = Buffer.from(await response.arrayBuffer());
return writeSpeechFile({
provider: AiProvider.OPENAI,
model: target.model,
voice: voice || Environment.OPENAI_TTS_VOICE,
buffer,
format: "mp3",
mimeType: "audio/mpeg",
});
}
async function synthesizeMistralSpeech(text: string, voice?: string): Promise<SynthesizedSpeech> {
const target = resolveAiRuntimeTarget(AiProvider.MISTRAL, "textToSpeech");
const mistralAi = createMistralClient(target);
const request: SpeechRequest = {
input: text,
responseFormat: "mp3"
// stream: false,
};
if (target.model) request.model = target.model;
if (voice || Environment.MISTRAL_TTS_VOICE_ID) request.voiceId = voice || Environment.MISTRAL_TTS_VOICE_ID;
const response = await mistralAi.audio.speech.complete(request) as unknown as {audioData?: string; audio_data?: string};
const audioData = response?.audioData ?? response?.audio_data;
if (typeof audioData !== "string" || !audioData.trim()) {
throw new Error(Environment.mistralTtsNoAudioDataText);
}
const buffer = Buffer.from(audioData, "base64");
return writeSpeechFile({
provider: AiProvider.MISTRAL,
model: target.model || "mistral speech",
voice: voice || Environment.MISTRAL_TTS_VOICE_ID,
buffer,
format: "mp3",
mimeType: "audio/mpeg",
});
}
async function synthesizeGeminiSpeech(text: string, voice?: string): Promise<SynthesizedSpeech> {
const target = resolveAiRuntimeTarget(AiProvider.GEMINI, "textToSpeech");
const geminiAi = createGoogleGenAiClient(target);
const response = await geminiAi.models.generateContent({
model: target.model,
contents: text,
config: {
responseModalities: ["AUDIO"],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: {
voiceName: voice || Environment.GEMINI_TTS_VOICE,
},
},
},
},
});
const audioPart = findGeminiAudioPart(response);
if (!audioPart) {
throw new Error(Environment.geminiTextToSpeechUnsupportedText);
}
const decoded = decodeGeminiAudio(audioPart.data, audioPart.mimeType);
return writeSpeechFile({
provider: AiProvider.GEMINI,
model: target.model,
voice: voice || Environment.GEMINI_TTS_VOICE,
buffer: decoded.buffer,
format: decoded.format,
mimeType: decoded.mimeType,
});
}
function findGeminiAudioPart(value: unknown): { data: string; mimeType?: string } | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const inlineData = record.inlineData ?? record.inline_data;
if (inlineData && typeof inlineData === "object") {
const inlineRecord = inlineData as Record<string, unknown>;
const data = inlineRecord.data;
const mimeType = inlineRecord.mimeType ?? inlineRecord.mime_type;
if (typeof data === "string" && (!mimeType || String(mimeType).startsWith("audio/"))) {
return {data, mimeType: typeof mimeType === "string" ? mimeType : undefined};
}
}
for (const child of Object.values(record)) {
if (Array.isArray(child)) {
for (const item of child) {
const found = findGeminiAudioPart(item);
if (found) return found;
}
} else if (child && typeof child === "object") {
const found = findGeminiAudioPart(child);
if (found) return found;
}
}
return null;
}
function decodeGeminiAudio(data: string, mimeType = "audio/wav"): {
buffer: Buffer;
format: TextToSpeechFormat;
mimeType: string;
} {
const normalizedMime = mimeType.toLowerCase();
const raw = Buffer.from(data, "base64");
if (normalizedMime.includes("mpeg") || normalizedMime.includes("mp3")) {
return {buffer: raw, format: "mp3", mimeType: "audio/mpeg"};
}
if (normalizedMime.includes("wav") || raw.subarray(0, 4).toString("ascii") === "RIFF") {
return {buffer: raw, format: "wav", mimeType: "audio/wav"};
}
if (normalizedMime.includes("flac")) {
return {buffer: raw, format: "flac", mimeType: "audio/flac"};
}
if (normalizedMime.includes("opus")) {
return {buffer: raw, format: "opus", mimeType: "audio/opus"};
}
if (normalizedMime.includes("aac")) {
return {buffer: raw, format: "aac", mimeType: "audio/aac"};
}
const sampleRate = Number(/rate=(\d+)/i.exec(mimeType)?.[1]) || 24_000;
return {
buffer: wrapPcm16InWav(raw, sampleRate, 1),
format: "wav",
mimeType: "audio/wav",
};
}
function wrapPcm16InWav(pcm: Buffer, sampleRate: number, channels: number): Buffer {
const bitsPerSample = 16;
const byteRate = sampleRate * channels * bitsPerSample / 8;
const blockAlign = channels * bitsPerSample / 8;
const header = Buffer.alloc(44);
header.write("RIFF", 0);
header.writeUInt32LE(36 + pcm.length, 4);
header.write("WAVE", 8);
header.write("fmt ", 12);
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(channels, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(blockAlign, 32);
header.writeUInt16LE(bitsPerSample, 34);
header.write("data", 36);
header.writeUInt32LE(pcm.length, 40);
return Buffer.concat([header, pcm]);
}
function writeSpeechFile(params: SpeechFileParams): SynthesizedSpeech {
fs.mkdirSync(ttsCacheDir(), {recursive: true});
const fileName = `${params.provider.toLowerCase()}-tts-${Date.now()}-${randomUUID()}.${params.format}`;
const filePath = path.join(ttsCacheDir(), fileName);
fs.writeFileSync(filePath, params.buffer);
return {
provider: params.provider,
model: params.model,
voice: params.voice,
format: params.format,
mimeType: params.mimeType,
fileName,
path: filePath,
sizeBytes: params.buffer.length,
};
}
function createSpeechUpload(speech: SynthesizedSpeech): FileOptions {
return new FileOptions(fs.createReadStream(speech.path), {
filename: speech.fileName,
contentType: speech.mimeType,
});
}
function destroyUpload(upload: FileOptions): void {
if ("destroy" in upload.file && typeof upload.file.destroy === "function") {
upload.file.destroy();
}
}
export async function sendSynthesizedSpeech(sourceMessage: Message, speech: SynthesizedSpeech): Promise<Message> {
if (speech.sizeBytes > TELEGRAM_FILE_LIMIT_BYTES) {
throw new Error(Environment.speechFileTooLargeText);
}
const caption = Environment.getTextToSpeechCaption(providerName(speech.provider), speech.model, speech.voice);
await enqueueTelegramApiCall(
() => bot.sendChatAction({
chat_id: sourceMessage.chat.id,
action: speech.format === "mp3" || speech.format === "opus" ? "upload_voice" : "upload_document",
}),
{method: "sendChatAction", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type}
).catch(logError);
let sent: Message;
if (speech.format === "mp3" || speech.format === "opus") {
try {
sent = await enqueueTelegramApiCall(
async () => {
const upload = createSpeechUpload(speech);
try {
return await bot.sendVoice({
chat_id: sourceMessage.chat.id,
voice: upload,
caption,
reply_parameters: {message_id: sourceMessage.message_id},
});
} finally {
destroyUpload(upload);
}
},
{method: "sendVoice", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type}
);
} catch (e) {
logError(e);
sent = await sendSpeechDocument(sourceMessage, speech, caption);
}
} else {
sent = await sendSpeechDocument(sourceMessage, speech, caption);
}
await storeSpeechMessage(sent, sourceMessage, speech);
return sent;
}
async function sendSpeechDocument(sourceMessage: Message, speech: SynthesizedSpeech, caption: string): Promise<Message> {
return enqueueTelegramApiCall(
async () => {
const upload = createSpeechUpload(speech);
try {
return await bot.sendDocument({
chat_id: sourceMessage.chat.id,
document: upload,
caption,
reply_parameters: {message_id: sourceMessage.message_id},
});
} finally {
destroyUpload(upload);
}
},
{method: "sendDocument", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type}
);
}
async function storeSpeechMessage(sent: Message, sourceMessage: Message, speech: SynthesizedSpeech): Promise<void> {
const file = sent.voice ?? sent.audio ?? sent.document;
const attachment: StoredAttachment = {
kind: "audio",
fileId: file?.file_id ?? speech.path,
fileUniqueId: file?.file_unique_id,
fileName: speech.fileName,
mimeType: speech.mimeType,
cachePath: speech.path,
};
const stored: StoredMessage = {
chatId: sent.chat.id,
id: sent.message_id,
replyToMessageId: sent.reply_to_message?.message_id ?? sourceMessage.message_id,
fromId: sent.from?.id ?? 0,
text: sent.caption ?? speech.fileName,
date: sent.date ?? Math.floor(Date.now() / 1000),
attachments: [attachment],
};
await MessageStore.put(stored);
}
+72
View File
@@ -0,0 +1,72 @@
import {AiTool} from "./tool-types";
import {AiProvider} from "../model/ai-provider";
import {getTools} from "./tools/registry";
export type AiProviderName = "ollama" | "openai" | "gemini" | "mistral";
export function getOllamaTools(): AiTool[] {
return getTools();
}
export function getOpenAITools(): AiTool[] {
return getTools().map(tool => ({
type: "function",
function: tool.function,
}));
}
export type OpenAiResponseTool = {
type: "function";
name: string;
description?: string;
parameters?: unknown;
strict: false;
};
export function getOpenAIResponsesTools(): OpenAiResponseTool[] {
return getTools().map(tool => ({
type: "function",
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
strict: false,
}));
}
export function getMistralTools(): AiTool[] {
return getTools().map(tool => ({
type: "function",
function: tool.function,
}));
}
export type GeminiTool = {
functionDeclarations: Array<{
name: string;
description?: string;
parametersJsonSchema?: unknown;
}>;
}
export function getGeminiTools(): GeminiTool[] {
const functionDeclarations = getTools().map(tool => ({
name: tool.function.name,
description: tool.function.description,
parametersJsonSchema: tool.function.parameters,
}));
return functionDeclarations.length ? [{functionDeclarations}] : [];
}
export function getProviderTools(provider: AiProvider): AiTool[] {
switch (provider) {
case AiProvider.OLLAMA:
return getOllamaTools();
case AiProvider.GEMINI:
return getTools();
case AiProvider.MISTRAL:
return getMistralTools();
case AiProvider.OPENAI:
return getOpenAITools();
}
}
+29
View File
@@ -0,0 +1,29 @@
export type AiToolParameters = {
type: "object";
properties?: Record<string, unknown>;
required?: string[];
[key: string]: unknown;
};
export type AiTool = {
type: "function";
function: {
name: string;
description?: string;
type?: string;
parameters?: AiToolParameters;
};
};
export type AiToolCall = {
function: {
name: string;
arguments: {
[key: string]: unknown;
};
};
};
+402
View File
@@ -0,0 +1,402 @@
import axios from "axios";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("brave-search");
import {Environment} from "../../common/environment";
import {logError} from "../../util/utils";
import {AiTool} from "../tool-types";
import {asBoolean, asNonEmptyString} from "./utils";
type BraveSearchProfile = {
name?: string;
long_name?: string;
url?: string;
img?: string;
};
type BraveSearchMetaUrl = {
scheme?: string;
netloc?: string;
hostname?: string;
favicon?: string;
path?: string;
};
type BraveSearchThumbnail = {
src?: string;
original?: string;
};
type BraveSearchResult = {
type?: string;
title?: string;
url?: string;
description?: string;
age?: string;
page_age?: string;
language?: string;
family_friendly?: boolean;
is_source_local?: boolean;
is_source_both?: boolean;
profile?: BraveSearchProfile;
meta_url?: BraveSearchMetaUrl;
thumbnail?: BraveSearchThumbnail;
extra_snippets?: string[];
};
type BraveSearchApiResponse = {
type?: string;
query?: {
original?: string;
show_strict_warning?: boolean;
is_navigational?: boolean;
is_news_breaking?: boolean;
spellcheck_off?: boolean;
country?: string;
bad_results?: boolean;
should_fallback?: boolean;
postal_code?: string;
city?: string;
header_country?: string;
more_results_available?: boolean;
state?: string;
altered?: string;
};
web?: {
type?: string;
results?: BraveSearchResult[];
};
news?: {
type?: string;
results?: BraveSearchResult[];
};
videos?: {
type?: string;
results?: BraveSearchResult[];
};
discussions?: {
type?: string;
results?: BraveSearchResult[];
};
faq?: unknown;
infobox?: unknown;
locations?: unknown;
mixed?: unknown;
summarizer?: unknown;
};
export const braveSearchTool = {
type: "function",
function: {
name: "web_search",
description:
"Search the web using Brave Search API. Use this for current information, facts, documentation, news, products, recent events, source lookup, and general web search. Returns ranked web/news/video results with titles, URLs and snippets.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description:
"Search query. Must be non-empty. Maximum 400 characters and 50 words.",
},
count: {
type: "number",
description:
"Number of web results to return. Min 1, max 20. Default is 5.",
},
offset: {
type: "number",
description:
"Zero-based page offset. Min 0, max 9. Default is 0.",
},
country: {
type: "string",
description:
"Optional 2-letter country code for result localization, for example US, RU, DE. Default is US.",
},
searchLang: {
type: "string",
description:
"Optional search language code, for example en, ru, de. Default is en.",
},
uiLang: {
type: "string",
description:
"Optional UI language, usually language-country format, for example en-US, ru-RU, de-DE.",
},
safesearch: {
type: "string",
enum: ["off", "moderate", "strict"],
description:
"Adult content filter. Default is moderate.",
},
freshness: {
type: "string",
description:
"Optional freshness filter: pd for last 24h, pw for last 7 days, pm for last 31 days, py for last 365 days, or YYYY-MM-DDtoYYYY-MM-DD.",
},
resultFilter: {
type: "string",
description:
"Comma-separated result types. Examples: web, news, videos, discussions, faq, infobox, locations, query, summarizer. Default is web.",
},
extraSnippets: {
type: "boolean",
description:
"Whether to request extra snippets. Default is false.",
},
spellcheck: {
type: "boolean",
description:
"Whether Brave may spellcheck and alter the query. Default is true.",
},
},
required: ["query"],
},
},
} satisfies AiTool;
export const braveSearchToolPrompt = [
"Brave Search tool rules:",
"- You have access to `web_search`.",
"- Use `web_search` when the user asks for current information, recent events, fresh prices, documentation lookup, source lookup, product info, news, public facts, or anything that may have changed.",
"- Use `web_search` for normal web search results.",
"- Do not use `shell_execute` for web search.",
"",
"How to query:",
"- Keep search queries short and focused.",
"- Prefer the user's original language unless another language is clearly better for the topic.",
"- Use `searchLang` based on the expected language of results: `ru` for Russian, `en` for English, `de` for German.",
"- Use `country` for localization when relevant, for example `RU`, `US`, `DE`.",
"- Use `count` between 3 and 10 by default.",
"- Use `resultFilter: \"web\"` for normal search.",
"- Use `resultFilter: \"news,web\"` for recent news/events.",
"- Use `resultFilter: \"videos\"` only when the user asks for videos.",
"- Use `resultFilter: \"discussions,web\"` when forum/community opinions are useful.",
"",
"Freshness:",
"- Use `freshness: \"pd\"` for last 24 hours.",
"- Use `freshness: \"pw\"` for last 7 days.",
"- Use `freshness: \"pm\"` for last 31 days.",
"- Use `freshness: \"py\"` for last 365 days.",
"- Use a custom range like `2025-01-01to2025-12-31` only when the user asks for a specific date range.",
"",
"Answering:",
"- Treat snippets as hints, not as full source documents.",
"- Do not invent details that are not present in the search results.",
"- When giving factual claims based on search results, mention the source title or URL.",
"- If results are weak, ambiguous or empty, say that the search result was insufficient.",
"",
].join("\n");
function asIntegerInRange(
value: unknown,
fallback: number,
min: number,
max: number,
): number {
const parsed = typeof value === "number"
? value
: typeof value === "string"
? Number(value)
: NaN;
if (!Number.isFinite(parsed)) return fallback;
const int = Math.trunc(parsed);
return Math.min(max, Math.max(min, int));
}
function asEnum<T extends string>(
value: unknown,
allowed: readonly T[],
fallback: T,
): T {
if (typeof value !== "string") return fallback;
const normalized = value.trim();
return allowed.includes(normalized as T)
? normalized as T
: fallback;
}
function cleanSearchText(value: unknown): string | null {
if (typeof value !== "string") return null;
return value
.replace(/<[^>]*>/g, "")
.replace(/&quot;/g, "\"")
.replace(/&#39;/g, "'")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/\s+/g, " ")
.trim() || null;
}
function normalizeBraveResultFilter(value: unknown): string {
const allowed = new Set([
"discussions",
"faq",
"infobox",
"news",
"query",
"summarizer",
"videos",
"web",
"locations",
]);
const raw = asNonEmptyString(value);
if (!raw) return "web";
const parts = raw
.split(",")
.map(part => part.trim().toLowerCase())
.filter(part => allowed.has(part));
return parts.length ? [...new Set(parts)].join(",") : "web";
}
export async function webSearch(args?: Record<string, unknown>) {
const startedAt = Date.now();
logger.info("start", {args});
try {
const query = asNonEmptyString(args?.query);
if (!query) {
throw new Error("query is required");
}
if (query.length > 400) {
throw new Error("query is too long. Max allowed length is 400 characters.");
}
const wordCount = query.split(/\s+/).filter(Boolean).length;
if (wordCount > 50) {
throw new Error("query has too many words. Max allowed word count is 50.");
}
const count = asIntegerInRange(args?.count, 5, 1, 20);
const offset = asIntegerInRange(args?.offset, 0, 0, 9);
const country = asNonEmptyString(args?.country)?.toUpperCase() ?? "US";
const searchLang = asNonEmptyString(args?.searchLang)?.toLowerCase() ?? "en";
const uiLang = asNonEmptyString(args?.uiLang) ?? undefined;
const safesearch = asEnum(
args?.safesearch,
["off", "moderate", "strict"] as const,
"moderate",
);
const freshness = asNonEmptyString(args?.freshness);
const resultFilter = normalizeBraveResultFilter(args?.resultFilter);
const extraSnippets = asBoolean(args?.extraSnippets, false);
const spellcheck = asBoolean(args?.spellcheck, true);
const response = await axios.get<BraveSearchApiResponse>(
"https://api.search.brave.com/res/v1/web/search",
{
timeout: 10_000,
params: {
q: query,
count,
offset,
country,
search_lang: searchLang,
safesearch,
result_filter: resultFilter,
text_decorations: false,
spellcheck,
extra_snippets: extraSnippets,
...(uiLang ? {ui_lang: uiLang} : {}),
...(freshness ? {freshness} : {}),
},
headers: {
"Accept": "application/json",
"Accept-Encoding": "gzip",
"X-Subscription-Token": Environment.BRAVE_SEARCH_API_KEY,
"User-Agent": "TelegramBot/1.0",
},
},
);
const data = response.data;
return {
ok: true,
query,
alteredQuery: data.query?.altered ?? null,
moreResultsAvailable: data.query?.more_results_available ?? null,
resultFilter,
count,
offset,
country,
searchLang,
safesearch,
freshness: freshness ?? null,
web: data.web?.results?.map(mapBraveResult) ?? [],
news: data.news?.results?.map(mapBraveResult) ?? [],
videos: data.videos?.results?.map(mapBraveResult) ?? [],
discussions: data.discussions?.results?.map(mapBraveResult) ?? [],
hasInfobox: Boolean(data.infobox),
hasFaq: Boolean(data.faq),
hasLocations: Boolean(data.locations),
hasSummarizer: Boolean(data.summarizer),
note: "Use returned URLs as sources. Do not invent facts that are not present in the snippets/results.",
};
} catch (e: unknown) {
logError(e);
const axiosLike = e as {response?: {status?: unknown; data?: unknown}};
const status = axiosLike.response?.status;
const data = axiosLike.response?.data;
return {
ok: false,
status: typeof status === "number" ? status : null,
error: e instanceof Error ? e.message : String(e),
response: data ?? null,
};
} finally {
logger.debug("done", {duration: logger.duration(startedAt)});
}
}
function mapBraveResult(result: BraveSearchResult) {
return {
title: cleanSearchText(result.title),
url: asNonEmptyString(result.url) ?? null,
description: cleanSearchText(result.description),
age: asNonEmptyString(result.age) ?? asNonEmptyString(result.page_age) ?? null,
language: asNonEmptyString(result.language) ?? null,
source: asNonEmptyString(result.profile?.name)
?? asNonEmptyString(result.profile?.long_name)
?? asNonEmptyString(result.meta_url?.hostname)
?? null,
hostname: asNonEmptyString(result.meta_url?.hostname) ?? null,
thumbnail: asNonEmptyString(result.thumbnail?.src)
?? asNonEmptyString(result.thumbnail?.original)
?? null,
extraSnippets: Array.isArray(result.extra_snippets)
? result.extra_snippets
.map(cleanSearchText)
.filter((value): value is string => Boolean(value))
: [],
};
}
+90
View File
@@ -0,0 +1,90 @@
import {AiTool} from "../tool-types";
import path from "node:path";
import {readFile, writeFile} from "node:fs/promises";
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import fs from "node:fs";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("create-note");
export type CreateNoteResult =
| { success: true; filePath: string }
| { success: false; error: string };
export const createNoteTool = {
type: "function",
function: {
name: "create_note",
description: "Create a new Markdown note with a valid file name, optional title, and Markdown-formatted content.",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description: "The valid file name for the note. It must be suitable for use as a file name and must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters. Use a clear, concise name based on the note topic. Include the .md extension if the user provides it or if Markdown files are expected."
},
title: {
type: "string",
description: "The title of the note. Use a concise, human-readable title based on the user's request or the note content."
},
content: {
type: "string",
description: "The full content of the note formatted as valid Markdown. Preserve existing Markdown formatting when provided. If the source content has little or no formatting, add appropriate Markdown structure such as headings, paragraphs, lists, links, code blocks, tables, or emphasis where useful, without changing the meaning."
}
},
required: ["fileName", "content"],
}
}
} satisfies AiTool;
export async function createNote(
args?: Record<string, unknown>
): Promise<CreateNoteResult> {
const startedAt = Date.now();
logger.debug("start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
return {success: false, error: "No file name provided"};
}
const title = asNonEmptyString(args?.title) ?? fileName;
const content = asNonEmptyString(args?.content) ?? "";
if (!content.trim().length) {
return {success: false, error: "No content provided"};
}
const newFilePath = path.join(notesDir, fileName.endsWith(".md") ? fileName : fileName + ".md");
const linkMarkdown = `* [${title}](${path.relative(path.dirname(notesRootFile), newFilePath)})`;
try {
if (fs.existsSync(newFilePath)) {
return {success: false, error: "File already exists"};
}
await writeFile(newFilePath, content, "utf-8");
let rootContent: string;
try {
rootContent = await readFile(notesRootFile, "utf-8");
} catch (e) {
rootContent = "";
}
const notesHeaderIndex = rootContent.indexOf(NOTES_HEADER);
if (notesHeaderIndex >= 0) {
rootContent += "\n" + linkMarkdown;
} else {
rootContent = NOTES_HEADER + "\n" + linkMarkdown;
}
await writeFile(notesRootFile, rootContent, "utf-8");
logger.debug("done", {fileName, filePath: newFilePath, duration: logger.duration(startedAt)});
return {success: true, filePath: newFilePath};
} catch (error) {
logger.error("failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to process files: ${errorMessage}`};
}
}
+92
View File
@@ -0,0 +1,92 @@
import {AiTool} from "../tool-types";
import {asNonEmptyString} from "./utils";
export const getCurrentDateTimeTool = {
type: "function",
function: {
name: "get_datetime",
description:
"Get the real current date and time. Use this tool before answering any request that depends on today, now, current time, current date, weekday, timestamp, timezone conversion, or relative dates like yesterday, tomorrow, next week, or 3 days ago.",
parameters: {
type: "object",
properties: {
timeZone: {
type: "string",
description:
"Optional IANA timezone, for example Europe/Moscow, Europe/Berlin, UTC. If omitted, system timezone is used.",
},
locale: {
type: "string",
description:
"Optional locale, for example ru-RU or en-US. If omitted, system locale/default locale is used.",
},
},
required: [],
},
},
} satisfies AiTool;
export const dateTimeToolPrompt = [
"Datetime tool rules:",
"- Use `get_datetime` whenever the answer depends on the real current date/time.",
"- Never guess the current date/time. Call the tool first.",
"",
"Arguments:",
"- `timeZone`: optional IANA timezone, e.g. `Europe/Moscow`, `Europe/Berlin`, `UTC`.",
"- `locale`: optional locale, e.g. `ru-RU`, `en-US`.",
"",
"After the tool returns:",
"- Base the answer on the returned value.",
"- Do not expose raw tool JSON unless asked.",
].join("\n");
function getSystemTimeZone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
export function getCurrentDateTime(args?: Record<string, unknown>) {
const now = new Date();
const systemTimeZone = getSystemTimeZone();
const requestedTimeZone = asNonEmptyString(args?.timeZone);
const requestedLocale = asNonEmptyString(args?.locale);
const timeZone = requestedTimeZone ?? systemTimeZone;
const locale = requestedLocale ?? undefined;
try {
const formatted = new Intl.DateTimeFormat(locale, {
timeZone,
dateStyle: "full",
timeStyle: "long",
}).format(now);
return {
iso: now.toISOString(),
unixMs: now.getTime(),
timeZone,
systemTimeZone,
locale: locale ?? "system-default",
formatted,
};
} catch (error) {
const formatted = new Intl.DateTimeFormat(undefined, {
timeZone: systemTimeZone,
dateStyle: "full",
timeStyle: "long",
}).format(now);
return {
iso: now.toISOString(),
unixMs: now.getTime(),
timeZone: systemTimeZone,
systemTimeZone,
locale: "system-default",
formatted,
warning: "Invalid locale or timezone was provided. Fallback to system locale and system timezone was used.",
requestedTimeZone: requestedTimeZone ?? null,
requestedLocale: requestedLocale ?? null,
error: error instanceof Error ? error.message : String(error),
};
}
}
+852
View File
@@ -0,0 +1,852 @@
import fs from "node:fs";
import path from "node:path";
import {Environment} from "../../common/environment";
import {AiTool} from "../tool-types";
import {MAX_COPY_ENTRIES, MAX_COPY_TOTAL_BYTES, MAX_DIRECTORY_ENTRIES, MAX_FILE_READ_BYTES, MAX_FILE_WRITE_BYTES} from "./limits";
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils";
export const readFileTool = {
type: "function",
function: {
name: "read_file",
description:
"Read a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative file path inside the root directory, for example notes/task.txt.",
},
maxBytes: {
type: "number",
description: `Optional max bytes to read. Maximum allowed value is ${MAX_FILE_READ_BYTES}.`,
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const listDirectoryTool = {
type: "function",
function: {
name: "list_directory",
description:
"List files and directories inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative directory path inside the root directory. Use . for root.",
},
},
required: [],
},
},
} satisfies AiTool;
export const createFileTool = {
type: "function",
function: {
name: "create_file",
description:
"Create a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative file path inside the root directory.",
},
content: {
type: "string",
description: "File content.",
},
overwrite: {
type: "boolean",
description: "Whether to overwrite the file if it already exists. Default is false.",
},
createParents: {
type: "boolean",
description: "Whether to create parent directories automatically. Default is true.",
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const createDirectoryTool = {
type: "function",
function: {
name: "create_directory",
description:
"Create a directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative directory path inside the root directory.",
},
recursive: {
type: "boolean",
description: "Whether to create parent directories automatically. Default is true.",
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const copyPathTool = {
type: "function",
function: {
name: "copy_path",
description:
"Copy a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Directory copy requires recursive=true. Symlinks are forbidden.",
parameters: {
type: "object",
properties: {
sourcePath: {
type: "string",
description: "Relative source file or directory path inside the root directory.",
},
targetPath: {
type: "string",
description: "Relative target file or directory path inside the root directory.",
},
recursive: {
type: "boolean",
description: "Required for copying directories. Default is false.",
},
overwrite: {
type: "boolean",
description: "Whether to overwrite existing files. Directory merge is allowed, but existing directories are not deleted. Default is false.",
},
createParents: {
type: "boolean",
description: "Whether to create target parent directories automatically. Default is true.",
},
},
required: ["sourcePath", "targetPath"],
},
},
} satisfies AiTool;
export const updateFileTool = {
type: "function",
function: {
name: "update_file",
description:
"Update a UTF-8 text file inside the hardcoded root directory. Supports replace, append and prepend. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative file path inside the root directory.",
},
content: {
type: "string",
description: "Content to write.",
},
mode: {
type: "string",
enum: ["replace", "append", "prepend"],
description: "Update mode. Default is replace.",
},
createIfMissing: {
type: "boolean",
description: "Whether to create the file if it does not exist. Default is false.",
},
},
required: ["path", "content"],
},
},
} satisfies AiTool;
export const renamePathTool = {
type: "function",
function: {
name: "rename_path",
description:
"Rename or move a file/directory inside the hardcoded root directory. This is the main directory modification tool. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
sourcePath: {
type: "string",
description: "Relative source path inside the root directory.",
},
targetPath: {
type: "string",
description: "Relative target path inside the root directory.",
},
overwrite: {
type: "boolean",
description: "Whether to overwrite an existing target file. Directory overwrite is not supported. Default is false.",
},
createParents: {
type: "boolean",
description: "Whether to create target parent directories automatically. Default is false.",
},
},
required: ["sourcePath", "targetPath"],
},
},
} satisfies AiTool;
export const deletePathTool = {
type: "function",
function: {
name: "delete_path",
description:
"Delete a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Recursive deletion requires recursive=true.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative file or directory path inside the root directory.",
},
recursive: {
type: "boolean",
description: "Whether to delete non-empty directories recursively. Default is false.",
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const fileToolsToolPrompt = [
"Filesystem tool rules:",
"- You have access to filesystem tools working only inside the hardcoded root directory.",
"- All filesystem paths must be relative to the root directory.",
"- You may go into child directories.",
"- You must never go up to parent directories.",
"- Do not use ../ paths.",
"- Do not use absolute paths.",
"- Do not try to access symlinks.",
"- Use read_file for reading files.",
"- Use list_directory for reading directories.",
"- Use create_file for creating files.",
"- Use create_directory for creating directories.",
"- Use update_file for replacing, appending or prepending file content.",
"- Use rename_path for renaming or moving files/directories inside the root.",
"- Use delete_path for deleting files/directories inside the root.",
""
].join("\n");
const requireFileToolsRootDir = () => <string>Environment.FILE_TOOLS_ROOT_DIR;
async function ensureFileToolsRootExists(): Promise<void> {
await fs.promises.mkdir(requireFileToolsRootDir(), {recursive: true});
const stat = await fs.promises.stat(requireFileToolsRootDir());
if (!stat.isDirectory()) {
throw new Error(`File tools root is not a directory: ${requireFileToolsRootDir()}`);
}
}
function resolveSafeToolPath(inputPath: unknown, fallback = "."): {
absolutePath: string;
relativePath: string;
} {
const rawPath = asNonEmptyString(inputPath) ?? fallback;
if (rawPath.includes("\0")) {
throw new Error("Path must not contain null bytes.");
}
if (
path.isAbsolute(rawPath) ||
path.win32.isAbsolute(rawPath) ||
path.posix.isAbsolute(rawPath)
) {
throw new Error("Absolute paths are not allowed. Use only relative paths inside the root directory.");
}
const normalizedInputPath = rawPath.replace(/[\\/]+/g, path.sep);
const absolutePath = path.resolve(requireFileToolsRootDir(), normalizedInputPath);
const relativePath = path.relative(requireFileToolsRootDir(), absolutePath);
if (
relativePath.startsWith("..") ||
path.isAbsolute(relativePath)
) {
throw new Error("Path escapes the root directory. Going up is not allowed.");
}
return {
absolutePath,
relativePath: relativePath || ".",
};
}
function assertTargetIsNotInsideSource(sourceAbsolutePath: string, targetAbsolutePath: string): void {
const relative = path.relative(sourceAbsolutePath, targetAbsolutePath);
if (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
) {
throw new Error("Cannot copy a directory into itself.");
}
}
async function assertNoSymlinkInPath(
absolutePath: string,
options?: {
allowMissingTail?: boolean;
}
): Promise<void> {
await ensureFileToolsRootExists();
const relativePath = path.relative(requireFileToolsRootDir(), absolutePath);
if (!relativePath || relativePath === ".") {
return;
}
const parts = relativePath.split(path.sep).filter(Boolean);
let currentPath = requireFileToolsRootDir();
for (const part of parts) {
currentPath = path.join(currentPath, part);
try {
const stat = await fs.promises.lstat(currentPath);
if (stat.isSymbolicLink()) {
throw new Error("Symlinks are not allowed in file tool paths.");
}
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === "ENOENT" && options?.allowMissingTail) {
return;
}
throw e;
}
}
}
async function pathExists(absolutePath: string): Promise<boolean> {
try {
await fs.promises.lstat(absolutePath);
return true;
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === "ENOENT") return false;
throw e;
}
}
function assertNotRoot(relativePath: string): void {
if (relativePath === ".") {
throw new Error("Operation on the root directory itself is not allowed.");
}
}
function getEntryType(stat: fs.Stats): "file" | "directory" | "symlink" | "other" {
if (stat.isSymbolicLink()) return "symlink";
if (stat.isFile()) return "file";
if (stat.isDirectory()) return "directory";
return "other";
}
export async function readFile(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
await assertNoSymlinkInPath(absolutePath);
const stat = await fs.promises.lstat(absolutePath);
if (!stat.isFile()) {
throw new Error(`Path is not a file: ${relativePath}`);
}
const maxBytes = asPositiveInt(args?.maxBytes, MAX_FILE_READ_BYTES, MAX_FILE_READ_BYTES);
if (stat.size > maxBytes) {
throw new Error(`File is too large: ${stat.size} bytes. Max allowed: ${maxBytes} bytes.`);
}
const buffer = await fs.promises.readFile(absolutePath);
if (buffer.includes(0)) {
throw new Error("Binary files are not supported.");
}
return {
ok: true,
path: relativePath,
sizeBytes: stat.size,
content: buffer.toString("utf8"),
};
}
export async function listDirectory(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path, ".");
await assertNoSymlinkInPath(absolutePath);
const stat = await fs.promises.lstat(absolutePath);
if (!stat.isDirectory()) {
throw new Error(`Path is not a directory: ${relativePath}`);
}
const dirEntries = await fs.promises.readdir(absolutePath, {
withFileTypes: true,
});
const limitedEntries = dirEntries.slice(0, MAX_DIRECTORY_ENTRIES);
const entries = await Promise.all(limitedEntries.map(async entry => {
const entryAbsolutePath = path.join(absolutePath, entry.name);
const entryRelativePath = relativePath === "."
? entry.name
: path.join(relativePath, entry.name);
const entryStat = await fs.promises.lstat(entryAbsolutePath);
return {
name: entry.name,
path: entryRelativePath,
type: getEntryType(entryStat),
sizeBytes: entryStat.isFile() ? entryStat.size : null,
modifiedAt: entryStat.mtime.toISOString(),
};
}));
return {
ok: true,
path: relativePath,
entries,
totalEntries: dirEntries.length,
returnedEntries: entries.length,
truncated: dirEntries.length > entries.length,
};
}
export async function createFile(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
assertNotRoot(relativePath);
const content = asString(args?.content, "");
const overwrite = asBoolean(args?.overwrite, false);
const createParents = asBoolean(args?.createParents, true);
const contentSizeBytes = Buffer.byteLength(content, "utf8");
if (contentSizeBytes > MAX_FILE_WRITE_BYTES) {
throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
}
const parentPath = path.dirname(absolutePath);
if (createParents) {
await assertNoSymlinkInPath(parentPath, {allowMissingTail: true});
await fs.promises.mkdir(parentPath, {recursive: true});
} else {
await assertNoSymlinkInPath(parentPath);
}
if (await pathExists(absolutePath)) {
const stat = await fs.promises.lstat(absolutePath);
if (stat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (stat.isDirectory()) {
throw new Error(`Path is a directory, not a file: ${relativePath}`);
}
if (!overwrite) {
throw new Error(`File already exists: ${relativePath}`);
}
}
await fs.promises.writeFile(absolutePath, content, {
encoding: "utf8",
flag: overwrite ? "w" : "wx",
});
return {
ok: true,
path: relativePath,
sizeBytes: contentSizeBytes,
overwritten: overwrite,
};
}
type CopyPathStats = {
entries: number;
totalBytes: number;
};
async function copyPathRecursive(params: {
sourceAbsolutePath: string;
targetAbsolutePath: string;
overwrite: boolean;
stats: CopyPathStats;
}): Promise<void> {
const {
sourceAbsolutePath,
targetAbsolutePath,
overwrite,
stats,
} = params;
if (stats.entries >= MAX_COPY_ENTRIES) {
throw new Error(`Too many entries to copy. Max allowed: ${MAX_COPY_ENTRIES}.`);
}
stats.entries++;
const sourceStat = await fs.promises.lstat(sourceAbsolutePath);
if (sourceStat.isSymbolicLink()) {
throw new Error("Symlinks are not allowed in copied paths.");
}
if (sourceStat.isFile()) {
stats.totalBytes += sourceStat.size;
if (stats.totalBytes > MAX_COPY_TOTAL_BYTES) {
throw new Error(`Copied data is too large. Max allowed: ${MAX_COPY_TOTAL_BYTES} bytes.`);
}
if (await pathExists(targetAbsolutePath)) {
const targetStat = await fs.promises.lstat(targetAbsolutePath);
if (targetStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (targetStat.isDirectory()) {
throw new Error("Cannot overwrite a directory with a file.");
}
if (!overwrite) {
throw new Error(`Target file already exists: ${path.relative(requireFileToolsRootDir(), targetAbsolutePath)}`);
}
}
await fs.promises.copyFile(
sourceAbsolutePath,
targetAbsolutePath,
overwrite ? 0 : fs.constants.COPYFILE_EXCL,
);
return;
}
if (sourceStat.isDirectory()) {
if (await pathExists(targetAbsolutePath)) {
const targetStat = await fs.promises.lstat(targetAbsolutePath);
if (targetStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (!targetStat.isDirectory()) {
throw new Error("Cannot overwrite a file with a directory.");
}
} else {
await fs.promises.mkdir(targetAbsolutePath);
}
const entries = await fs.promises.readdir(sourceAbsolutePath);
for (const entry of entries) {
const childSourcePath = path.join(sourceAbsolutePath, entry);
const childTargetPath = path.join(targetAbsolutePath, entry);
await copyPathRecursive({
sourceAbsolutePath: childSourcePath,
targetAbsolutePath: childTargetPath,
overwrite,
stats,
});
}
return;
}
throw new Error("Only files and directories can be copied.");
}
export async function copyPath(args?: Record<string, unknown>) {
const source = resolveSafeToolPath(args?.sourcePath);
const target = resolveSafeToolPath(args?.targetPath);
assertNotRoot(source.relativePath);
assertNotRoot(target.relativePath);
await assertNoSymlinkInPath(source.absolutePath);
const sourceStat = await fs.promises.lstat(source.absolutePath);
if (sourceStat.isSymbolicLink()) {
throw new Error("Symlink sources are not allowed.");
}
const recursive = asBoolean(args?.recursive, false);
const overwrite = asBoolean(args?.overwrite, false);
const createParents = asBoolean(args?.createParents, true);
if (sourceStat.isDirectory() && !recursive) {
throw new Error("Source is a directory. Set recursive=true to copy directories.");
}
if (sourceStat.isDirectory()) {
assertTargetIsNotInsideSource(source.absolutePath, target.absolutePath);
}
const targetParentPath = path.dirname(target.absolutePath);
if (createParents) {
await assertNoSymlinkInPath(targetParentPath, {
allowMissingTail: true,
});
await fs.promises.mkdir(targetParentPath, {
recursive: true,
});
await assertNoSymlinkInPath(targetParentPath);
} else {
await assertNoSymlinkInPath(targetParentPath);
}
const stats: CopyPathStats = {
entries: 0,
totalBytes: 0,
};
await copyPathRecursive({
sourceAbsolutePath: source.absolutePath,
targetAbsolutePath: target.absolutePath,
overwrite,
stats,
});
return {
ok: true,
from: source.relativePath,
to: target.relativePath,
recursive,
overwrite,
entriesCopied: stats.entries,
bytesCopied: stats.totalBytes,
};
}
export async function createDirectory(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
const recursive = asBoolean(args?.recursive, true);
await assertNoSymlinkInPath(absolutePath, {
allowMissingTail: true,
});
await fs.promises.mkdir(absolutePath, {
recursive,
});
return {
ok: true,
path: relativePath,
recursive,
};
}
export async function updateFile(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
assertNotRoot(relativePath);
const content = asString(args?.content, "");
const mode = (asNonEmptyString(args?.mode) ?? "replace").toLowerCase();
const createIfMissing = asBoolean(args?.createIfMissing, false);
if (!["replace", "append", "prepend"].includes(mode)) {
throw new Error(`Unsupported update mode: ${mode}`);
}
const contentSizeBytes = Buffer.byteLength(content, "utf8");
if (contentSizeBytes > MAX_FILE_WRITE_BYTES) {
throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
}
const parentPath = path.dirname(absolutePath);
await assertNoSymlinkInPath(parentPath);
const exists = await pathExists(absolutePath);
if (!exists && !createIfMissing) {
throw new Error(`File does not exist: ${relativePath}`);
}
if (exists) {
await assertNoSymlinkInPath(absolutePath);
const stat = await fs.promises.lstat(absolutePath);
if (!stat.isFile()) {
throw new Error(`Path is not a file: ${relativePath}`);
}
}
if (mode === "replace") {
await fs.promises.writeFile(absolutePath, content, {
encoding: "utf8",
flag: "w",
});
} else if (mode === "append") {
await fs.promises.appendFile(absolutePath, content, {
encoding: "utf8",
});
} else {
const oldContent = exists
? await fs.promises.readFile(absolutePath, "utf8")
: "";
const resultSizeBytes = Buffer.byteLength(content + oldContent, "utf8");
if (resultSizeBytes > MAX_FILE_WRITE_BYTES) {
throw new Error(`Result file content is too large: ${resultSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
}
await fs.promises.writeFile(absolutePath, content + oldContent, {
encoding: "utf8",
flag: "w",
});
}
const newStat = await fs.promises.stat(absolutePath);
return {
ok: true,
path: relativePath,
mode,
sizeBytes: newStat.size,
created: !exists,
};
}
export async function renamePath(args?: Record<string, unknown>) {
const source = resolveSafeToolPath(args?.sourcePath);
const target = resolveSafeToolPath(args?.targetPath);
assertNotRoot(source.relativePath);
assertNotRoot(target.relativePath);
await assertNoSymlinkInPath(source.absolutePath);
const sourceStat = await fs.promises.lstat(source.absolutePath);
if (sourceStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
const relativeTargetInsideSource = path.relative(source.absolutePath, target.absolutePath);
if (
relativeTargetInsideSource === "" ||
(!relativeTargetInsideSource.startsWith("..") && !path.isAbsolute(relativeTargetInsideSource))
) {
throw new Error("Cannot move a directory into itself.");
}
const overwrite = asBoolean(args?.overwrite, false);
const createParents = asBoolean(args?.createParents, false);
const targetParentPath = path.dirname(target.absolutePath);
if (createParents) {
await assertNoSymlinkInPath(targetParentPath, {allowMissingTail: true});
await fs.promises.mkdir(targetParentPath, {recursive: true});
} else {
await assertNoSymlinkInPath(targetParentPath);
}
if (await pathExists(target.absolutePath)) {
const targetStat = await fs.promises.lstat(target.absolutePath);
if (targetStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (!overwrite) {
throw new Error(`Target already exists: ${target.relativePath}`);
}
if (sourceStat.isDirectory() || targetStat.isDirectory()) {
throw new Error("Overwrite for directories is not supported.");
}
await fs.promises.rm(target.absolutePath, {
force: false,
});
}
await fs.promises.rename(source.absolutePath, target.absolutePath);
return {
ok: true,
from: source.relativePath,
to: target.relativePath,
overwrite,
};
}
export async function deletePath(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
assertNotRoot(relativePath);
await assertNoSymlinkInPath(absolutePath);
const stat = await fs.promises.lstat(absolutePath);
if (stat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
const recursive = asBoolean(args?.recursive, false);
if (stat.isDirectory()) {
if (recursive) {
await fs.promises.rm(absolutePath, {
recursive: true,
force: false,
});
} else {
await fs.promises.rmdir(absolutePath);
}
} else {
await fs.promises.rm(absolutePath, {
force: false,
});
}
return {
ok: true,
path: relativePath,
recursive,
deleted: true,
};
}
+5
View File
@@ -0,0 +1,5 @@
export const MAX_FILE_READ_BYTES = 128 * 1024 * 1024;
export const MAX_FILE_WRITE_BYTES = 128 * 1024 * 1024;
export const MAX_DIRECTORY_ENTRIES = 200;
export const MAX_COPY_TOTAL_BYTES = 10 * 1024 * 1024;
export const MAX_COPY_ENTRIES = 500;
+318
View File
@@ -0,0 +1,318 @@
import {AiTool} from "../tool-types";
import path from "node:path";
import {readdir, readFile, unlink, writeFile} from "node:fs/promises";
import {notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("notes");
export type NoteListItem = {
fileName: string;
filePath: string;
relativePath: string;
title: string;
};
export type ListNotesResult =
| { success: true; notes: NoteListItem[] }
| { success: false; error: string };
export type GetNoteContentResult =
| {
success: true;
fileName: string;
filePath: string;
relativePath: string;
title: string;
content: string;
} | { success: false; error: string };
export const listNotesTool = {
type: "function",
function: {
name: "list_notes",
description: "Display all available Markdown notes from the notes directory.",
parameters: {
type: "object",
properties: {},
required: [],
},
},
} satisfies AiTool;
export const getNoteContentTool = {
type: "function",
function: {
name: "get_note_content",
description: "Get the full Markdown content of a specific note by its file name.",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description:
"The file name of the note to read. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
},
},
required: ["fileName"],
},
},
} satisfies AiTool;
export async function listNotes(): Promise<ListNotesResult> {
const startedAt = Date.now();
logger.debug("list.start");
try {
const entries = await readdir(notesDir, {withFileTypes: true});
const markdownFiles = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((fileName) => fileName.endsWith(".md"));
const notes: NoteListItem[] = await Promise.all(
markdownFiles.map(async (fileName) => {
const filePath = path.join(notesDir, fileName);
const relativePath = path.relative(path.dirname(notesRootFile), filePath);
let content = "";
try {
content = await readFile(filePath, "utf-8");
} catch {
// Ignore content read errors for individual files.
}
return {
fileName,
filePath,
relativePath,
title: extractNoteTitle(fileName, content),
};
}),
);
notes.sort((a, b) => a.title.localeCompare(b.title));
logger.debug("list.done", {notes: notes.length, duration: logger.duration(startedAt)});
return {success: true, notes};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to list notes: ${errorMessage}`};
}
}
export async function getNoteContent(
args?: Record<string, unknown>,
): Promise<GetNoteContentResult> {
const startedAt = Date.now();
logger.debug("get_content.start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
return {success: false, error: "No file name provided"};
}
const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"};
}
try {
const content = await readFile(noteFilePath, "utf-8");
const normalizedFileName = path.basename(noteFilePath);
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
logger.debug("get_content.done", {fileName: normalizedFileName, relativePath, chars: content.length, duration: logger.duration(startedAt)});
return {
success: true,
fileName: normalizedFileName,
filePath: noteFilePath,
relativePath,
title: extractNoteTitle(normalizedFileName, content),
content,
};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to read note: ${errorMessage}`};
}
}
function extractNoteTitle(fileName: string, content: string): string {
const headingMatch = content.match(/^#\s+(.+)$/m);
const heading = headingMatch?.[1]?.trim();
if (heading) {
return heading;
}
return path.basename(fileName, ".md");
}
export function buildSafeNoteFilePath(fileName: string): string | null {
const normalizedFileName = fileName.endsWith(".md") ? fileName : `${fileName}.md`;
if (!normalizedFileName.trim().length) {
return null;
}
const unsafeFileNamePattern = /[/\\:*?"<>|\x00-\x1F]/;
if (unsafeFileNamePattern.test(normalizedFileName)) {
return null;
}
const resolvedNotesDir = path.resolve(notesDir);
const resolvedFilePath = path.resolve(notesDir, normalizedFileName);
if (!resolvedFilePath.startsWith(resolvedNotesDir + path.sep)) {
return null;
}
return resolvedFilePath;
}
export type UpdateNoteContentResult =
| { success: true; filePath: string }
| { success: false; error: string };
export type DeleteNoteResult =
| { success: true; filePath: string }
| { success: false; error: string };
export const updateNoteContentTool = {
type: "function",
function: {
name: "update_note_content",
description: "Update the full Markdown content of an existing note by its file name.",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description:
"The file name of the note to update. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
},
content: {
type: "string",
description:
"The new full content of the note formatted as valid Markdown. This replaces the previous content completely.",
},
},
required: ["fileName", "content"],
},
},
} satisfies AiTool;
export const deleteNoteTool = {
type: "function",
function: {
name: "delete_note",
description: "Delete an existing Markdown note by its file name and remove its link from the notes root file if present.",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description:
"The file name of the note to delete. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
},
},
required: ["fileName"],
},
},
} satisfies AiTool;
export async function updateNoteContent(
args?: Record<string, unknown>,
): Promise<UpdateNoteContentResult> {
const startedAt = Date.now();
logger.debug("update_content.start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
return {success: false, error: "No file name provided"};
}
const content = asNonEmptyString(args?.content) ?? "";
if (!content.trim().length) {
return {success: false, error: "No content provided"};
}
const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"};
}
try {
await readFile(noteFilePath, "utf-8");
await writeFile(noteFilePath, content, "utf-8");
logger.debug("update_content.done", {fileName, filePath: noteFilePath, chars: content.length, duration: logger.duration(startedAt)});
return {success: true, filePath: noteFilePath};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to update note: ${errorMessage}`};
}
}
export async function deleteNote(
args?: Record<string, unknown>,
): Promise<DeleteNoteResult> {
const startedAt = Date.now();
logger.debug("delete.start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
return {success: false, error: "No file name provided"};
}
const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"};
}
try {
await unlink(noteFilePath);
await removeNoteLinkFromRoot(noteFilePath);
logger.debug("delete.done", {fileName, filePath: noteFilePath, duration: logger.duration(startedAt)});
return {success: true, filePath: noteFilePath};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to delete note: ${errorMessage}`};
}
}
async function removeNoteLinkFromRoot(noteFilePath: string): Promise<void> {
let rootContent: string;
try {
rootContent = await readFile(notesRootFile, "utf-8");
} catch {
return;
}
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
const normalizedRelativePath = relativePath.replaceAll("\\", "\\\\");
const escapedRelativePath = escapeRegExp(normalizedRelativePath);
const linkLinePattern = new RegExp(
`^\\s*[-*]\\s+\\[[^\\]]+]\\(${escapedRelativePath}\\)\\s*$\\n?`,
"gm",
);
const updatedRootContent = rootContent.replace(linkLinePattern, "");
if (updatedRootContent !== rootContent) {
await writeFile(notesRootFile, updatedRootContent.trimEnd() + "\n", "utf-8");
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+75
View File
@@ -0,0 +1,75 @@
import {AiTool} from "../tool-types";
import axios from "axios";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("market-rates");
export const getMarketRatesTool = {
type: "function",
function: {
name: "get_market_rates",
description:
"Retrieve the latest exchange rates for supported currency, crypto, and precious metal pairs, including 24-hour change data when available. Supported pairs: USD/RUB, USD/EUR, USD/KZT, USD/UAH, USD/BYN, USD/GBP, USD/CNY, TON/USD, BTC/USD, ETH/USD, SOL/USD, and XAU/USD. Use this tool when the user asks for current rates, currency conversion, crypto prices, gold price, or recent 24-hour movement. This tool takes no parameters.",
parameters: {
type: "object",
properties: {},
required: [],
},
},
} satisfies AiTool;
export const marketRatesToolPrompt = [
"Currency rates tool rules:",
"- Use `get_market_rates` whenever the answer depends on current exchange rates, crypto prices, or gold price.",
"- Use `get_market_rates` when the user asks whether a supported asset went up or down recently.",
"- Use `get_market_rates` when the user asks for the 24-hour change, percentage change, or movement direction for a supported pair.",
"- Never guess current rates, prices, or 24-hour changes. Call the tool first.",
"- Do not use this tool for unsupported pairs unless the user asks about one of the supported pairs listed below.",
"- Do not use this tool for historical rates beyond the provided 24-hour comparison.",
"",
"Supported pairs:",
"- `usd_to_rub`: USD to RUB.",
"- `usd_to_eur`: USD to EUR.",
"- `usd_to_kzt`: USD to KZT.",
"- `usd_to_uah`: USD to UAH.",
"- `usd_to_byn`: USD to BYN.",
"- `usd_to_gbp`: USD to GBP.",
"- `usd_to_cny`: USD to CNY.",
"- `ton_to_usd`: TON to USD.",
"- `btc_to_usd`: BTC to USD.",
"- `eth_to_usd`: ETH to USD.",
"- `sol_to_usd`: SOL to USD.",
"- `xau_to_usd`: gold/XAU to USD.",
"",
"Arguments:",
"- This tool takes no arguments.",
"",
"Returned data:",
"- Each supported pair contains `rate` with the latest available rate.",
"- Each supported pair may contain `change.absolute` with the absolute 24-hour change.",
"- Each supported pair may contain `change.percent` with the percentage 24-hour change.",
"- Each supported pair may contain `change.direction` with the movement direction, e.g. `up`, `down`, or `flat`.",
"- `has_24h_comparison`: whether 24-hour comparison data is available.",
"",
"After the tool returns:",
"- Base the answer only on the returned values.",
"- If `has_24h_comparison` is false, provide only the current rates and say that 24-hour comparison is unavailable.",
"- Do not expose raw tool JSON unless asked.",
"- Format the answer in a user-friendly way.",
"- For fiat pairs, show the rate with the target currency, for example: `USD/RUB is 75.22 RUB, down 0.16% over 24 hours.`",
"- For crypto and gold pairs, show the USD price, for example: `BTC/USD is $81,451.66, up 0.22% over 24 hours.`",
"- When the user asks for all rates, group fiat currencies separately from crypto and gold.",
].join("\n");
export async function getMarketRates(): Promise<unknown | undefined> {
const startedAt = Date.now();
try {
logger.info("start");
const response = await axios.get("https://apid.r00t.top/api/v2/currency/rates");
logger.debug("done", {duration: logger.duration(startedAt), status: response.status});
return response.data;
} catch (e: unknown) {
logger.error("failed", {duration: logger.duration(startedAt), error: e});
return undefined;
}
}
+813
View File
@@ -0,0 +1,813 @@
import {spawn} from "node:child_process";
import {copyFile, lstat, mkdir, readdir, writeFile} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {AiTool} from "../tool-types";
import {Environment} from "../../common/environment";
import {randomUUID} from "node:crypto";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("python-interpreter");
export const PYTHON_INTERPRETER_TOOL_NAME = "python_interpreter";
export type PythonInterpreterArgs = {
/**
* Full Python 3 script.
* The model should use print(...) to expose useful output.
*/
code: string;
/**
* Optional stdin passed to the Python process.
*/
stdin?: string;
/**
* Optional timeout override.
*/
timeoutMs?: number;
};
export type PythonInterpreterOptions = {
pythonBinary?: string;
syntaxTimeoutMs?: number;
executionTimeoutMs?: number;
maxCodeChars?: number;
maxOutputChars?: number;
maxArtifactBytes?: number;
maxArtifactCount?: number;
inputFiles?: PythonInterpreterInputFile[];
};
type ProcessRunResult = {
exitCode: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
timedOut: boolean;
outputTruncated: boolean;
durationMs: number;
};
export type PythonInterpreterInputFile = {
kind?: string;
path: string;
fileName: string;
mimeType?: string;
};
export type PythonInterpreterRuntimeInputFile = PythonInterpreterInputFile & {
index: number;
path: string;
sourcePath: string;
relativePath: string;
sizeBytes: number;
};
export type PythonInterpreterArtifact = {
kind: "image" | "file";
path: string;
relativePath: string;
fileName: string;
mimeType?: string;
sizeBytes: number;
};
export type PythonInterpreterSkippedArtifact = {
path: string;
relativePath: string;
fileName: string;
sizeBytes?: number;
reason: string;
maxSizeBytes?: number;
};
export type PythonToolResult =
| {
ok: true;
phase: "execution";
stdout: string;
stderr: string;
exitCode: number | null;
durationMs: number;
outputTruncated: boolean;
inputDir?: string;
outputDir?: string;
inputFiles?: PythonInterpreterRuntimeInputFile[];
artifacts?: PythonInterpreterArtifact[];
skippedArtifacts?: PythonInterpreterSkippedArtifact[];
}
| {
ok: false;
phase: "syntax" | "execution" | "internal";
error: string;
stdout?: string;
stderr?: string;
exitCode?: number | null;
signal?: NodeJS.Signals | null;
timedOut?: boolean;
durationMs?: number;
outputTruncated?: boolean;
};
const DEFAULT_PYTHON_BINARY = process.platform === "win32" ? "python" : "python3";
const DEFAULT_SYNTAX_TIMEOUT_MS = 3_000;
const DEFAULT_EXECUTION_TIMEOUT_MS = 8_000;
const DEFAULT_MAX_CODE_CHARS = 100_000;
const DEFAULT_MAX_OUTPUT_CHARS = 20_000;
export const PYTHON_INTERPRETER_MAX_ARTIFACT_BYTES = 50 * 1024 * 1024;
const DEFAULT_MAX_ARTIFACT_COUNT = 20;
const PYTHON_INPUTS_DIR_NAME = "inputs";
const PYTHON_OUTPUTS_DIR_NAME = "outputs";
const PYTHON_ATTACHMENTS_FILE_NAME = "attachments.json";
const PYTHON_USER_CODE_FILE_NAME = "user_code.py";
const PYTHON_RUNNER_FILE_NAME = "main.py";
const PYTHON_CODE_TEMPLATE = [
"from pathlib import Path",
"import json",
"",
"# These globals are predefined by the python_interpreter runtime:",
"# INPUT_DIR = Path('inputs')",
"# OUTPUT_DIR = Path('outputs')",
"# ATTACHMENTS_FILE = Path('attachments.json')",
"",
"attachments = load_attachments()",
"# Read attached files from INPUT_DIR, for example:",
"# text = (INPUT_DIR/attachments[0]['fileName']).read_text(encoding='utf-8')",
"",
"# Save every user-visible generated file into outputs.",
"# Example:",
"# (OUTPUT_DIR/'result.txt').write_text('done', encoding='utf-8')",
"",
"print('done')",
].join("\n");
export const pythonInterpreterToolPrompt = [
"Python interpreter rules:",
"- You have access to the `python_interpreter` tool for Python 3 code.",
"- Each Python run starts in a temporary workspace.",
"- Incoming user files are always in `inputs/`.",
"- Outgoing user-visible files must always be saved into `outputs/`.",
"- Attachment metadata is always in `attachments.json`.",
"- The runtime predefines these globals in executed code: `INPUT_DIR`, `OUTPUT_DIR`, `ATTACHMENTS_FILE`, `WORK_DIR`, `input_path(name)`, `output_path(name)`, and `load_attachments()`.",
"- Use `input_path(filename)` for reading incoming files.",
"- Use `output_path(filename)` for files that should be returned to the user.",
"- Do not invent other directories for user attachments or generated artifacts.",
"- Prefer this template:",
"```python",
PYTHON_CODE_TEMPLATE,
"```",
"",
].join("\n");
export const pythonInterpreterTool = {
type: "function",
function: {
name: PYTHON_INTERPRETER_TOOL_NAME,
description:
"Validate and execute short Python 3 code. Use for calculations, data transformations, parsing, chart rendering, and image/file processing. The code must print useful text results. The runtime always creates hardcoded directories `inputs/` and `outputs/` in the current working directory. User attachments are copied into `inputs/` and described in `attachments.json`. The executed code has predefined globals: INPUT_DIR, OUTPUT_DIR, ATTACHMENTS_FILE, WORK_DIR, input_path(name), output_path(name), and load_attachments(). Put every user-visible output image or file into `outputs/`; every regular file there up to 50 MB will be returned by the tool and sent to the user.",
parameters: {
type: "object",
required: ["code"],
properties: {
code: {
type: "string",
description:
`Complete Python 3 script to execute. Use print(...) for the final answer. Do not use markdown fences. Read incoming files only from INPUT_DIR / "file" or input_path("file"). Save charts/images/files intended for the user only into OUTPUT_DIR / "file" or output_path("file"). You can inspect attachments via load_attachments(). Template:\n${PYTHON_CODE_TEMPLATE}`,
},
stdin: {
type: "string",
description: "Optional stdin passed to the Python script.",
},
timeoutMs: {
type: "integer",
description: "Optional execution timeout in milliseconds. Default is 8000.",
},
},
},
},
} satisfies AiTool;
export async function runPythonInterpreter(
rawArgs: unknown,
options: PythonInterpreterOptions = {},
): Promise<PythonToolResult> {
let args: PythonInterpreterArgs;
try {
args = parsePythonInterpreterArgs(rawArgs, options);
} catch (error) {
return {
ok: false,
phase: "internal",
error: errorToString(error),
};
}
const syntaxStartedAt = Date.now();
const syntax = await validatePythonSyntax(args.code, options);
logger.debug("syntax.done", {duration: logger.duration(syntaxStartedAt), ok: syntax.ok});
if (!syntax.ok) {
return syntax;
}
const executionStartedAt = Date.now();
const result = await executePythonCode(args, options);
logger.debug("execution.done", {duration: logger.duration(executionStartedAt), ok: result.ok, phase: result.phase});
return result;
}
export async function validatePythonSyntax(
code: string,
options: PythonInterpreterOptions = {},
): Promise<PythonToolResult> {
const pythonBinary = options.pythonBinary ?? DEFAULT_PYTHON_BINARY;
const timeoutMs = options.syntaxTimeoutMs ?? DEFAULT_SYNTAX_TIMEOUT_MS;
const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
const syntaxCheckScript = `
import ast
import sys
source = sys.stdin.read()
try:
ast.parse(source, filename="<llm_python>")
except SyntaxError as e:
print(f"SyntaxError: {e.msg} at line {e.lineno}, column {e.offset}", file=sys.stderr)
if e.text:
print(e.text.rstrip(), file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"{type(e).__name__}: {e}", file=sys.stderr)
sys.exit(1)
`.trim();
const result = await runProcess({
command: pythonBinary,
args: ["-I", "-B", "-S", "-c", syntaxCheckScript],
input: code,
timeoutMs,
maxOutputChars,
env: buildSafeEnv(),
});
if (result.timedOut) {
return {
ok: false,
phase: "syntax",
error: `Python syntax check timed out after ${timeoutMs} ms.`,
stderr: result.stderr,
durationMs: result.durationMs,
timedOut: true,
outputTruncated: result.outputTruncated,
};
}
if (result.exitCode !== 0) {
return {
ok: false,
phase: "syntax",
error: result.stderr.trim() || "Python syntax check failed.",
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
signal: result.signal,
durationMs: result.durationMs,
outputTruncated: result.outputTruncated,
};
}
return {
ok: true,
phase: "execution",
stdout: "",
stderr: "",
exitCode: 0,
durationMs: result.durationMs,
outputTruncated: result.outputTruncated,
};
}
async function executePythonCode(
args: PythonInterpreterArgs,
options: PythonInterpreterOptions = {},
): Promise<PythonToolResult> {
const startedAt = Date.now();
logger.info("execute.start", {args, options});
const pythonBinary =
options.pythonBinary ?? process.env.PYTHON_INTERPRETER_BINARY ?? "C:\\Users\\meloda\\Desktop\\AI_BOT\\.venv\\Scripts\\python.exe";
const timeoutMs = args.timeoutMs ?? options.executionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS;
const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
// const tempDir = path.join(Environment.DATA_PATH, "cache", "python", "python-temp-" + randomUUID());
const tempDir = path.join(Environment.FILE_TOOLS_ROOT_DIR ?? ".", "ollama-python-temp-" + randomUUID());
const inputDir = path.join(tempDir, PYTHON_INPUTS_DIR_NAME);
const outputDir = path.join(tempDir, PYTHON_OUTPUTS_DIR_NAME);
const attachmentsPath = path.join(tempDir, PYTHON_ATTACHMENTS_FILE_NAME);
await mkdir(tempDir, {recursive: true});
await mkdir(inputDir, {recursive: true});
await mkdir(outputDir, {recursive: true});
const userScriptPath = path.join(tempDir, PYTHON_USER_CODE_FILE_NAME);
const runnerPath = path.join(tempDir, PYTHON_RUNNER_FILE_NAME);
try {
const inputFiles = await prepareInputFiles(options.inputFiles ?? [], inputDir);
await writeFile(attachmentsPath, JSON.stringify(inputFiles, null, 2), {
encoding: "utf8",
mode: 0o600,
});
await writeFile(userScriptPath, args.code, {
encoding: "utf8",
mode: 0o600,
});
await writeFile(runnerPath, buildPythonRunnerScript(), {
encoding: "utf8",
mode: 0o600,
});
logger.debug("script.written", {tempDir, userScriptPath, runnerPath, duration: logger.duration(startedAt)});
const result = await runProcess({
command: pythonBinary,
args: ["-I", "-B", runnerPath],
input: args.stdin ?? "",
cwd: tempDir,
timeoutMs,
maxOutputChars,
env: {
...buildSafeEnv(tempDir),
PYTHON_INPUT_DIR: inputDir,
PYTHON_OUTPUT_DIR: outputDir,
PYTHON_ATTACHMENTS_FILE: attachmentsPath,
},
});
logger.debug("process.done", {duration: logger.duration(startedAt), exitCode: result.exitCode, timedOut: result.timedOut, outputTruncated: result.outputTruncated});
if (result.timedOut) {
logger.warn("process.timeout", {duration: logger.duration(startedAt)});
return {
ok: false,
phase: "execution",
error: `Python execution timed out after ${timeoutMs} ms.`,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
signal: result.signal,
timedOut: true,
durationMs: result.durationMs,
outputTruncated: result.outputTruncated,
};
}
if (result.outputTruncated) {
logger.warn("process.output_truncated", {duration: logger.duration(startedAt), stdoutChars: result.stdout.length, stderrChars: result.stderr.length});
return {
ok: false,
phase: "execution",
error: `Python output exceeded limit of ${maxOutputChars} characters.`,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
signal: result.signal,
timedOut: false,
durationMs: result.durationMs,
outputTruncated: true,
};
}
if (result.exitCode !== 0) {
logger.warn("process.non_zero_exit", {duration: logger.duration(startedAt), result});
return {
ok: false,
phase: "execution",
error: result.stderr.trim() || `Python exited with code ${result.exitCode}.`,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
signal: result.signal,
timedOut: false,
durationMs: result.durationMs,
outputTruncated: result.outputTruncated,
};
}
logger.debug("process.ok", {duration: logger.duration(startedAt)});
const {
artifacts,
skippedArtifacts
} = await collectOutputArtifacts(outputDir, options);
return {
ok: true,
phase: "execution",
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
durationMs: result.durationMs,
outputTruncated: result.outputTruncated,
inputDir,
outputDir,
inputFiles,
artifacts,
skippedArtifacts,
};
} catch (error) {
logger.error("execute.failed", {duration: logger.duration(startedAt), error});
return {
ok: false,
phase: "internal",
error: errorToString(error),
};
} finally {
// await rm(tempDir, {
// recursive: true,
// force: true,
// });
}
}
function buildPythonRunnerScript(): string {
return `
import json
import runpy
from pathlib import Path
WORK_DIR = Path(__file__).resolve().parent
INPUT_DIR = WORK_DIR / ${JSON.stringify(PYTHON_INPUTS_DIR_NAME)}
OUTPUT_DIR = WORK_DIR / ${JSON.stringify(PYTHON_OUTPUTS_DIR_NAME)}
ATTACHMENTS_FILE = WORK_DIR / ${JSON.stringify(PYTHON_ATTACHMENTS_FILE_NAME)}
USER_CODE_FILE = WORK_DIR / ${JSON.stringify(PYTHON_USER_CODE_FILE_NAME)}
INPUT_DIR.mkdir(parents=True, exist_ok=True)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
def input_path(name=""):
return INPUT_DIR / name
def output_path(name=""):
return OUTPUT_DIR / name
def load_attachments():
if not ATTACHMENTS_FILE.exists():
return []
return json.loads(ATTACHMENTS_FILE.read_text(encoding="utf-8"))
runpy.run_path(
str(USER_CODE_FILE),
run_name="__main__",
init_globals={
"Path": Path,
"WORK_DIR": WORK_DIR,
"INPUT_DIR": INPUT_DIR,
"OUTPUT_DIR": OUTPUT_DIR,
"ATTACHMENTS_FILE": ATTACHMENTS_FILE,
"input_path": input_path,
"output_path": output_path,
"load_attachments": load_attachments,
},
)
`.trimStart();
}
async function prepareInputFiles(
inputFiles: PythonInterpreterInputFile[],
inputDir: string,
): Promise<PythonInterpreterRuntimeInputFile[]> {
const prepared: PythonInterpreterRuntimeInputFile[] = [];
for (const [index, file] of inputFiles.entries()) {
const sourcePath = path.resolve(file.path);
const info = await lstat(sourcePath).catch(() => null);
if (!info?.isFile()) continue;
const fileName = uniqueInputFileName(index, file.fileName || path.basename(sourcePath));
const runtimePath = path.join(inputDir, fileName);
await copyFile(sourcePath, runtimePath);
prepared.push({
...file,
index,
path: runtimePath,
sourcePath,
relativePath: path.join(PYTHON_INPUTS_DIR_NAME, fileName).replace(/\\/g, "/"),
sizeBytes: info.size,
fileName,
});
}
return prepared;
}
async function collectOutputArtifacts(
outputDir: string,
options: PythonInterpreterOptions,
): Promise<{
artifacts: PythonInterpreterArtifact[];
skippedArtifacts: PythonInterpreterSkippedArtifact[];
}> {
const maxBytes = options.maxArtifactBytes ?? PYTHON_INTERPRETER_MAX_ARTIFACT_BYTES;
const maxCount = options.maxArtifactCount ?? DEFAULT_MAX_ARTIFACT_COUNT;
const artifacts: PythonInterpreterArtifact[] = [];
const skippedArtifacts: PythonInterpreterSkippedArtifact[] = [];
const walk = async (dir: string): Promise<void> => {
const entries = await readdir(dir, {withFileTypes: true}).catch(() => []);
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const info = await lstat(fullPath).catch(() => null);
if (!info) continue;
const relativePath = path.relative(outputDir, fullPath).replace(/\\/g, "/");
if (info.isSymbolicLink()) {
skippedArtifacts.push({
path: fullPath,
relativePath,
fileName: safeFileName(entry.name),
reason: "Symbolic links are not returned.",
});
continue;
}
if (info.isDirectory()) {
await walk(fullPath);
continue;
}
if (!info.isFile()) continue;
const fileName = safeFileName(entry.name);
if (info.size > maxBytes) {
skippedArtifacts.push({
path: fullPath,
relativePath,
fileName,
sizeBytes: info.size,
reason: `File exceeds the ${maxBytes} byte limit.`,
maxSizeBytes: maxBytes,
});
continue;
}
if (artifacts.length >= maxCount) {
skippedArtifacts.push({
path: fullPath,
relativePath,
fileName,
sizeBytes: info.size,
reason: `Artifact count exceeds the ${maxCount} file limit.`,
});
continue;
}
const mimeType = mimeTypeFromPath(fullPath);
if (mimeType) {
artifacts.push({
kind: mimeType?.startsWith("image/") ? "image" : "file",
path: fullPath,
relativePath,
fileName,
mimeType,
sizeBytes: info.size,
});
} else {
skippedArtifacts.push({
path: fullPath,
relativePath,
fileName,
sizeBytes: info.size,
reason: "Unsupported mimeType for extension " + path.extname(fullPath)
});
}
}
};
await walk(outputDir);
return {artifacts, skippedArtifacts};
}
function safeFileName(value: string): string {
const sanitized = path.basename(value)
.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "_")
.trim()
.slice(0, 180);
return sanitized || "file";
}
function uniqueInputFileName(index: number, value: string): string {
const safe = safeFileName(value);
const ext = path.extname(safe);
const base = path.basename(safe, ext).slice(0, 140) || "input";
return `${index + 1}_${base}${ext}`;
}
function mimeTypeFromPath(filePath: string): string | undefined {
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";
case ".bmp":
return "image/bmp";
case ".svg":
return "image/svg+xml";
case ".pdf":
return "application/pdf";
case ".txt":
return "text/plain";
case ".csv":
return "text/csv";
case ".json":
return "application/json";
case ".zip":
return "application/zip";
case ".mp3":
return "audio/mpeg";
case ".wav":
return "audio/wav";
case ".mp4":
return "video/mp4";
default:
return undefined;
}
}
function parsePythonInterpreterArgs(
rawArgs: unknown,
options: PythonInterpreterOptions,
): PythonInterpreterArgs {
let args = rawArgs;
if (typeof rawArgs === "string") {
try {
args = JSON.parse(rawArgs);
} catch {
args = {code: rawArgs};
}
}
if (!args || typeof args !== "object") {
throw new Error("Tool arguments must be an object.");
}
const record = args as Record<string, unknown>;
const code = record.code;
if (typeof code !== "string" || !code.trim()) {
throw new Error("Tool argument `code` must be a non-empty string.");
}
const maxCodeChars = options.maxCodeChars ?? DEFAULT_MAX_CODE_CHARS;
if (code.length > maxCodeChars) {
throw new Error(`Python code is too large: ${code.length} chars, max ${maxCodeChars}.`);
}
const stdin = record.stdin;
if (stdin !== undefined && typeof stdin !== "string") {
throw new Error("Tool argument `stdin` must be a string when provided.");
}
const timeoutMs = record.timeoutMs;
if (
timeoutMs !== undefined &&
(!Number.isInteger(timeoutMs) || Number(timeoutMs) < 100 || Number(timeoutMs) > 60_000)
) {
throw new Error("Tool argument `timeoutMs` must be an integer from 100 to 60000.");
}
return {
code,
stdin: typeof stdin === "string" ? stdin : undefined,
timeoutMs: timeoutMs === undefined ? undefined : Number(timeoutMs),
};
}
async function runProcess(params: {
command: string;
args: string[];
input?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs: number;
maxOutputChars: number;
}): Promise<ProcessRunResult> {
const startedAt = Date.now();
return new Promise<ProcessRunResult>((resolve) => {
let stdout = "";
let stderr = "";
let timedOut = false;
let outputTruncated = false;
let settled = false;
const child = spawn(params.command, params.args, {
cwd: params.cwd,
env: params.env,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
const finish = (result: Omit<ProcessRunResult, "durationMs">) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({
...result,
durationMs: Date.now() - startedAt,
});
};
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, params.timeoutMs);
const appendOutput = (target: "stdout" | "stderr", chunk: Buffer) => {
const text = chunk.toString("utf8");
if (target === "stdout") {
stdout += text;
} else {
stderr += text;
}
const total = stdout.length + stderr.length;
if (total > params.maxOutputChars) {
outputTruncated = true;
stdout = stdout.slice(0, params.maxOutputChars);
stderr = stderr.slice(0, params.maxOutputChars);
child.kill("SIGKILL");
}
};
child.stdout.on("data", (chunk: Buffer) => appendOutput("stdout", chunk));
child.stderr.on("data", (chunk: Buffer) => appendOutput("stderr", chunk));
child.on("error", (error) => {
finish({
exitCode: null,
signal: null,
stdout,
stderr: stderr + `\n${errorToString(error)}`,
timedOut,
outputTruncated,
});
});
child.on("close", (exitCode, signal) => {
finish({
exitCode,
signal,
stdout,
stderr,
timedOut,
outputTruncated,
});
});
child.stdin.end(params.input ?? "");
});
}
function buildSafeEnv(tempDir?: string): NodeJS.ProcessEnv {
return {
PATH: process.env.PATH ?? "",
PATHEXT: process.env.PATHEXT ?? "",
SystemRoot: process.env.SystemRoot ?? "",
HOME: tempDir ?? os.tmpdir(),
USERPROFILE: tempDir ?? os.tmpdir(),
TEMP: tempDir ?? os.tmpdir(),
TMP: tempDir ?? os.tmpdir(),
LANG: "C.UTF-8",
LC_ALL: "C.UTF-8",
};
}
function errorToString(error: unknown): string {
if (error instanceof Error) {
return error.stack || error.message;
}
return String(error);
}
+153
View File
@@ -0,0 +1,153 @@
import {Environment} from "../../common/environment";
import {AiTool} from "../tool-types";
import {braveSearchTool, webSearch} from "./brave-search";
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime";
import {shellExecute, shellExecuteTool} from "./shell";
import {ToolHandler} from "./types";
import {getWeather, getWeatherTool} from "./weather";
import {getMarketRates, getMarketRatesTool} from "./market-rates";
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator";
import {
copyPath,
copyPathTool,
createDirectory,
createDirectoryTool,
createFile,
createFileTool,
deletePath,
deletePathTool,
listDirectory,
listDirectoryTool,
readFile,
readFileTool,
renamePath,
renamePathTool,
updateFile,
updateFileTool
} from "./file-system";
import {createNote, createNoteTool} from "./create-note";
import {
deleteNote,
deleteNoteTool,
getNoteContent,
getNoteContentTool,
listNotes,
listNotesTool,
updateNoteContent,
updateNoteContentTool
} from "./list-notes";
import {getNoteFile, getNoteFileTool} from "./send-note-file";
import {searchNotes, searchNotesTool} from "./search-notes";
export const getTools = () => {
const tools: AiTool[] = [
getCurrentDateTimeTool,
getMarketRatesTool,
createNoteTool,
listNotesTool,
getNoteContentTool,
updateNoteContentTool,
deleteNoteTool,
getNoteFileTool,
searchNotesTool
];
if (Environment.ENABLE_PYTHON_INTERPRETER) {
tools.push(pythonInterpreterTool);
}
if (Environment.ENABLE_UNSAFE_EVAL) {
tools.push(shellExecuteTool);
}
if (Environment.BRAVE_SEARCH_API_KEY) {
tools.push(braveSearchTool);
}
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
tools.push(getWeatherTool);
}
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
tools.push(
readFileTool,
listDirectoryTool,
createFileTool,
createDirectoryTool,
updateFileTool,
renamePathTool,
copyPathTool,
deletePathTool,
);
}
return tools;
// return [
// createNoteTool,
// listNotesTool,
// getNoteContentTool,
// updateNoteContentTool,
// deleteNoteTool,
// getNoteFileTool,
// searchNotesTool
// ];
};
export const getToolHandlers = () => {
let handlers: Record<string, ToolHandler> = {
get_datetime: getCurrentDateTime,
get_market_rates: getMarketRates,
create_note: createNote,
list_notes: listNotes,
get_note_content: getNoteContent,
update_note_content: updateNoteContent,
delete_note: deleteNote,
get_note_file: getNoteFile,
search_notes: searchNotes
};
if (Environment.ENABLE_PYTHON_INTERPRETER) {
handlers = {
python_interpreter: runPythonInterpreter,
...handlers
};
}
if (Environment.ENABLE_UNSAFE_EVAL) {
handlers = {
shell_execute: shellExecute,
...handlers,
};
}
if (Environment.BRAVE_SEARCH_API_KEY) {
handlers = {
web_search: webSearch,
...handlers,
};
}
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
handlers = {
get_weather: getWeather,
...handlers,
};
}
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
handlers = {
read_file: readFile,
list_directory: listDirectory,
create_file: createFile,
create_directory: createDirectory,
update_file: updateFile,
rename_path: renamePath,
copy_path: copyPath,
delete_path: deletePath,
...handlers,
};
}
return handlers;
};
+58
View File
@@ -0,0 +1,58 @@
import {getToolHandlers} from "./registry";
import {normalizeToolArguments} from "./utils";
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("runtime");
export type ToolRuntimeContext = {
pythonInputFiles?: PythonInterpreterInputFile[];
};
function stringifyToolResult(result: unknown): string {
if (typeof result === "string") return result;
return JSON.stringify(result, null, 2);
}
export async function executeToolCall(
name: string,
args?: unknown,
context: ToolRuntimeContext = {},
): Promise<string> {
const startedAt = Date.now();
const handler = getToolHandlers()[name];
logger.info("execute.start", {name, args});
if (!handler) {
return stringifyToolResult({
error: `Unknown tool: ${name}`,
});
}
try {
if (name === PYTHON_INTERPRETER_TOOL_NAME) {
const result = await runPythonInterpreter(normalizeToolArguments(args), {
executionTimeoutMs: 8_000,
syntaxTimeoutMs: 3_000,
maxCodeChars: 100_000,
maxOutputChars: 20_000,
inputFiles: context.pythonInputFiles,
});
const s = stringifyToolResult(result);
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
return s;
}
const result = await handler(normalizeToolArguments(args));
const s = stringifyToolResult(result);
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
return s;
} catch (error) {
logger.error("execute.failed", {name, duration: logger.duration(startedAt), error});
return stringifyToolResult({
error: error instanceof Error ? error.message : String(error),
});
}
}
+394
View File
@@ -0,0 +1,394 @@
import {AiTool} from "../tool-types";
import path from "node:path";
import {readdir, readFile} from "node:fs/promises";
import {notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("search-notes");
export type SearchNoteMatchedField = "file_name" | "title" | "content";
export type SearchNoteItem = {
fileName: string;
filePath: string;
relativePath: string;
title: string;
score: number;
matchedFields: SearchNoteMatchedField[];
snippet?: string;
};
export type SearchNotesResult =
| { success: true; results: SearchNoteItem[] }
| { success: false; error: string };
export const searchNotesTool = {
type: "function",
function: {
name: "search_notes",
description:
"Search Markdown notes by file name, note title, and full note content. Supports fuzzy matching. Use this when the user refers to a note by title, topic, partial title, approximate name, keyword, or something written inside the note. Returns success=true and results[], where each result contains fileName, title, score, matchedFields, relativePath, and optional snippet. Later note tools should use results[0].fileName unless multiple results are ambiguous.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description:
"Search query for finding notes by file name, title, topic, keywords, or content. Can be partial, approximate, or contain typos. Use a short clean phrase, not the full user sentence.",
},
limit: {
type: "integer",
description:
"Maximum number of search results to return. Defaults to 3. Maximum is 10.",
minimum: 1,
maximum: 10,
default: 3,
},
},
required: ["query"],
},
},
} satisfies AiTool;
export async function searchNotes(
args?: Record<string, unknown>,
): Promise<SearchNotesResult> {
const startedAt = Date.now();
logger.debug("start", {args});
const query = asNonEmptyString(args?.query) ?? "";
if (!query.trim().length) {
return {success: false, error: "No query provided"};
}
const limit = parseSearchLimit(args?.limit);
try {
const entries = await readdir(notesDir, {withFileTypes: true});
const markdownFiles = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((fileName) => fileName.endsWith(".md"));
const notes = await Promise.all(
markdownFiles.map(async (fileName) => {
const filePath = path.join(notesDir, fileName);
const relativePath = path.relative(path.dirname(notesRootFile), filePath);
let content = "";
try {
content = await readFile(filePath, "utf-8");
} catch {
// Ignore content read errors for individual files.
}
const title = extractNoteTitle(fileName, content);
const fileNameWithoutExtension = path.basename(fileName, ".md");
const fileNameScore = calculateFuzzyScore(query, fileNameWithoutExtension);
const titleScore = calculateFuzzyScore(query, title);
const contentScore = calculateContentScore(query, content);
const matchedFields: SearchNoteMatchedField[] = [];
if (fileNameScore > 0) {
matchedFields.push("file_name");
}
if (titleScore > 0) {
matchedFields.push("title");
}
if (contentScore > 0) {
matchedFields.push("content");
}
const score = Math.max(
fileNameScore,
titleScore,
contentScore,
);
return {
fileName,
filePath,
relativePath,
title,
score,
matchedFields,
snippet:
contentScore > 0
? buildContentSnippet(query, content)
: undefined,
};
}),
);
const results = notes
.filter((note) => note.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
logger.debug("done", {query, limit, results: results.length, duration: logger.duration(startedAt)});
return {success: true, results};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to search notes: ${errorMessage}`};
}
}
function parseSearchLimit(value: unknown): number {
const parsed =
typeof value === "number"
? value
: typeof value === "string"
? Number.parseInt(value, 10)
: 3;
if (!Number.isFinite(parsed)) {
return 3;
}
return Math.max(1, Math.min(10, Math.floor(parsed)));
}
function extractNoteTitle(fileName: string, content: string): string {
const headingMatch = content.match(/^#\s+(.+)$/m);
const heading = headingMatch?.[1]?.trim();
if (heading) {
return heading;
}
return path.basename(fileName, ".md");
}
function calculateFuzzyScore(query: string, value: string): number {
const normalizedQuery = normalizeSearchText(query);
const normalizedValue = normalizeSearchText(value);
if (!normalizedQuery.length || !normalizedValue.length) {
return 0;
}
if (normalizedValue === normalizedQuery) {
return 100;
}
if (normalizedValue.startsWith(normalizedQuery)) {
return 90;
}
if (normalizedValue.includes(normalizedQuery)) {
return 85;
}
const queryWords = normalizedQuery.split(" ").filter(Boolean);
const valueWords = normalizedValue.split(" ").filter(Boolean);
const wordMatchScore = calculateWordMatchScore(queryWords, valueWords);
const subsequenceScore = isSubsequence(normalizedQuery, normalizedValue) ? 55 : 0;
const distanceScore = calculateLevenshteinScore(normalizedQuery, normalizedValue);
return Math.max(wordMatchScore, subsequenceScore, distanceScore);
}
function calculateContentScore(query: string, content: string): number {
const normalizedQuery = normalizeSearchText(query);
const normalizedContent = normalizeSearchText(content);
if (!normalizedQuery.length || !normalizedContent.length) {
return 0;
}
if (normalizedContent.includes(normalizedQuery)) {
return 70;
}
const queryWords = normalizedQuery.split(" ").filter(Boolean);
const contentWords = new Set(normalizedContent.split(" ").filter(Boolean));
if (!queryWords.length) {
return 0;
}
let matchedWords = 0;
for (const queryWord of queryWords) {
if (contentWords.has(queryWord)) {
matchedWords++;
continue;
}
const hasPartialMatch = [...contentWords].some((contentWord) => {
if (contentWord.includes(queryWord) || queryWord.includes(contentWord)) {
return true;
}
if (queryWord.length < 4 || contentWord.length < 4) {
return false;
}
const distance = levenshteinDistance(queryWord, contentWord);
const maxLength = Math.max(queryWord.length, contentWord.length);
const similarity = 1 - distance / maxLength;
return similarity >= 0.75;
});
if (hasPartialMatch) {
matchedWords += 0.75;
}
}
const matchRatio = matchedWords / queryWords.length;
if (matchRatio <= 0) {
return 0;
}
return Math.round(matchRatio * 60);
}
function normalizeSearchText(value: string): string {
return value
.toLowerCase()
.trim()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ё/g, "е")
.replace(/[^a-zа-я0-9\s-]/gi, " ")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ");
}
function calculateWordMatchScore(queryWords: string[], valueWords: string[]): number {
if (!queryWords.length || !valueWords.length) {
return 0;
}
let matchedWords = 0;
for (const queryWord of queryWords) {
const bestWordScore = Math.max(
...valueWords.map((valueWord) => {
if (valueWord === queryWord) {
return 1;
}
if (valueWord.startsWith(queryWord) || valueWord.includes(queryWord)) {
return 0.85;
}
const distance = levenshteinDistance(queryWord, valueWord);
const maxLength = Math.max(queryWord.length, valueWord.length);
const similarity = 1 - distance / maxLength;
return similarity >= 0.7 ? similarity : 0;
}),
);
if (bestWordScore > 0) {
matchedWords += bestWordScore;
}
}
const ratio = matchedWords / queryWords.length;
return Math.round(ratio * 75);
}
function calculateLevenshteinScore(query: string, value: string): number {
const distance = levenshteinDistance(query, value);
const maxLength = Math.max(query.length, value.length);
if (maxLength === 0) {
return 0;
}
const similarity = 1 - distance / maxLength;
if (similarity < 0.45) {
return 0;
}
return Math.round(similarity * 65);
}
function isSubsequence(query: string, value: string): boolean {
let queryIndex = 0;
for (const valueChar of value) {
if (valueChar === query[queryIndex]) {
queryIndex++;
}
if (queryIndex === query.length) {
return true;
}
}
return false;
}
function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = Array.from({length: a.length + 1}, () =>
Array.from({length: b.length + 1}, () => 0),
);
for (let i = 0; i <= a.length; i++) {
matrix[i][0] = i;
}
for (let j = 0; j <= b.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost,
);
}
}
return matrix[a.length][b.length];
}
function buildContentSnippet(query: string, content: string): string | undefined {
const normalizedQuery = query.trim().toLowerCase();
const normalizedContent = content.toLowerCase();
let matchIndex = normalizedContent.indexOf(normalizedQuery);
if (matchIndex < 0) {
const queryWords = normalizeSearchText(query)
.split(" ")
.filter((word) => word.length >= 3);
for (const word of queryWords) {
matchIndex = normalizedContent.indexOf(word);
if (matchIndex >= 0) {
break;
}
}
}
if (matchIndex < 0) {
return undefined;
}
const snippetRadius = 120;
const start = Math.max(0, matchIndex - snippetRadius);
const end = Math.min(content.length, matchIndex + normalizedQuery.length + snippetRadius);
const prefix = start > 0 ? "..." : "";
const suffix = end < content.length ? "..." : "";
return `${prefix}${content.slice(start, end).replace(/\s+/g, " ").trim()}${suffix}`;
}
+113
View File
@@ -0,0 +1,113 @@
import {AiTool} from "../tool-types";
import path from "node:path";
import {readFile, stat} from "node:fs/promises";
import {notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import {buildSafeNoteFilePath} from "./list-notes";
import z from "zod";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("get-note-file");
export type NoteFileAttachment = {
type: "local_file";
fileName: string;
// filePath: string;
relativePath: string;
mimeType: "text/markdown";
sizeBytes: number;
};
export type GetNoteFileResult =
| {
success: true;
attachment: NoteFileAttachment;
} | { success: false; error: string };
export const NoteFileAttachmentSchema = z.object({
type: z.literal("local_file"),
fileName: z.string(),
// filePath: z.string(),
relativePath: z.string(),
mimeType: z.literal("text/markdown"),
sizeBytes: z.number(),
});
export const GetNoteFileResultSchema = z.discriminatedUnion("success", [
z.object({
success: z.literal(true),
attachment: NoteFileAttachmentSchema,
}),
z.object({
success: z.literal(false),
error: z.string(),
}),
]);
export const getNoteFileTool = {
type: "function",
function: {
name: "get_note_file",
description:
"Prepare a Markdown note file to be sent to the user as a .md attachment. Returns a local file descriptor that the host application should use to upload or send the file.",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description:
"The file name of the note to send. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
},
},
required: ["fileName"],
},
},
} satisfies AiTool;
export async function getNoteFile(
args?: Record<string, unknown>,
): Promise<GetNoteFileResult> {
logger.debug("start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
return {success: false, error: "No file name provided"};
}
const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"};
}
try {
// Проверяем, что файл существует и действительно читается.
await readFile(noteFilePath, "utf-8");
const fileStat = await stat(noteFilePath);
if (!fileStat.isFile()) {
return {success: false, error: "Note path is not a file"};
}
const normalizedFileName = path.basename(noteFilePath);
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
const result: GetNoteFileResult = {
success: true,
attachment: {
type: "local_file",
fileName: normalizedFileName,
// filePath: noteFilePath,
relativePath,
mimeType: "text/markdown",
sizeBytes: fileStat.size,
},
};
logger.debug("done", {fileName: result.attachment.fileName, relativePath: result.attachment.relativePath, sizeBytes: result.attachment.sizeBytes});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to prepare note file: ${errorMessage}`};
}
}
+109
View File
@@ -0,0 +1,109 @@
import {AiTool} from "../tool-types";
import {runCommand} from "../../util/utils";
import {asNonEmptyString} from "./utils";
export const shellExecuteTool = {
type: "function",
function: {
name: "shell_execute",
description: "Execute command in a shell",
parameters: {
type: "object",
properties: {
cmd: {
type: "string",
description: "Actual command to execute in a shell"
}
},
required: ["cmd"]
}
}
} satisfies AiTool;
export const shellExecuteToolPrompt = [
"Shell tool rules:",
"- You have access to the `shell_execute` tool.",
"- `shell_execute` executes a shell command on the server.",
"- This tool is powerful and potentially dangerous.",
"- Use this tool only when command execution is actually necessary.",
"- Prefer specialized tools when available, for example filesystem tools for reading, creating, updating, copying, moving or deleting files.",
"",
"Platform awareness:",
"- The server shell may be Linux/macOS shell, Windows CMD, or Windows PowerShell.",
"- Do not assume Bash/Linux commands are available.",
"- Do not assume Windows commands are available.",
"- If the current OS/shell is unknown, first run a safe environment inspection command.",
"- Safe OS inspection examples:",
" - Node.js: `node -p \"process.platform\"`",
" - Node.js: `node -p \"process.cwd()\"`",
" - Windows CMD: `ver`",
" - PowerShell: `$PSVersionTable.PSVersion`",
" - POSIX shell: `uname -a`",
"",
"Preferred safe commands:",
"- Prefer read-only commands.",
"- Prefer short, explicit and predictable commands.",
"- Cross-platform when Node.js is available:",
" - `node -p \"process.cwd()\"`",
" - `node -p \"process.platform\"`",
" - `node -e \"console.log(require('fs').readdirSync('.'))\"`",
"- POSIX examples:",
" - `pwd`, `ls`, `find`, `cat`, `head`, `tail`, `grep`, `sed -n`, `wc`, `stat`, `file`, `du`, `df`, `ps`.",
"- Windows CMD examples:",
" - `cd`, `dir`, `type`, `where`, `findstr`.",
"- PowerShell examples:",
" - `Get-Location`, `Get-ChildItem`, `Get-Content`, `Select-String`, `Measure-Object`, `Get-Item`, `Get-Process`.",
"",
"Filesystem restrictions:",
"- Work only inside the allowed project/root directory.",
"- Use relative paths when possible.",
"- Do not use absolute paths unless the user explicitly asks and it is safe.",
"- Do not use `..` to go to parent directories.",
"- Do not access files outside the allowed root directory.",
"- Do not follow or use symlinks to escape the allowed root directory.",
"",
"Forbidden actions unless the user explicitly asks and the action is clearly safe:",
"- Do not delete files or directories.",
"- Do not overwrite files.",
"- Do not move files.",
"- Do not change permissions.",
"- Do not change ownership.",
"- Do not install packages.",
"- Do not update the system.",
"- Do not start, stop or restart services.",
"- Do not run background processes.",
"- Do not run long-running commands.",
"- Do not run infinite loops.",
"- Do not use fork bombs.",
"- Do not use privilege escalation.",
"",
"Forbidden command examples:",
"- POSIX: `sudo`, `su`, `rm`, `rmdir`, `chmod`, `chown`, `dd`, `mkfs`, `mount`, `umount`, `kill`, `reboot`, `shutdown`.",
"- Windows CMD: `del`, `erase`, `rmdir`, `rd`, `format`, `shutdown`, `taskkill`.",
"- PowerShell: `Remove-Item`, `Move-Item`, `Set-ItemProperty`, `Stop-Process`, `Restart-Computer`, `Stop-Computer`.",
"",
"Network restrictions:",
"- Do not make network requests unless the user explicitly asks.",
"- Do not use `curl`, `wget`, `Invoke-WebRequest`, `Invoke-RestMethod`, `ssh`, `scp`, `rsync`, `nc`, `nmap` unless explicitly requested and safe.",
"",
"Secrets and privacy:",
"- Never read secrets, tokens, API keys, passwords, private keys, certificates, `.env` files, SSH keys, browser data or credential stores unless the user explicitly asks and it is necessary.",
"- If command output contains secrets, do not repeat them back to the user.",
"",
"Command construction:",
"- Do not execute untrusted user text directly as shell code.",
"- Quote paths and arguments safely.",
"- Avoid command chaining with `;`, `&&`, `||`, pipes, backticks or command substitution unless necessary.",
"- Avoid glob patterns that may affect too many files.",
"- If unsure whether a command is safe, do not run it.",
"",
].join("\n");
export async function shellExecute(args?: Record<string, unknown>): Promise<string | undefined | null> {
const cmd = asNonEmptyString(args?.cmd);
if (!cmd) return undefined;
const {stdout, stderr} = await runCommand(cmd);
return stdout ?? stderr;
}
+3
View File
@@ -0,0 +1,3 @@
import {appLogger} from "../../logging/logger";
export const toolsLogger = appLogger.child("ai-tools");
+1
View File
@@ -0,0 +1 @@
export type ToolHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
+130
View File
@@ -0,0 +1,130 @@
import {Ollama} from "ollama";
import {z} from "zod";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("utils");
export function asNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
export function normalizeToolArguments(args: unknown): Record<string, unknown> {
if (!args) return {};
if (typeof args === "string") {
try {
const parsed = JSON.parse(args);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
return {
raw: args,
};
}
return {};
}
if (typeof args === "object" && !Array.isArray(args)) {
return args as Record<string, unknown>;
}
return {};
}
export function asBoolean(value: unknown, defaultValue = false): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (normalized === "true") return true;
if (normalized === "false") return false;
}
return defaultValue;
}
export function asString(value: unknown, defaultValue = ""): string {
return typeof value === "string" ? value : defaultValue;
}
export function asPositiveInt(value: unknown, defaultValue: number, maxValue: number): number {
const n = typeof value === "number"
? value
: typeof value === "string"
? Number(value)
: NaN;
if (!Number.isFinite(n) || n <= 0) return defaultValue;
return Math.min(Math.floor(n), maxValue);
}
export async function unloadAllOllamaModels(ollama: Ollama, exceptFor?: string[]) {
try {
const runningModels = await ollama.ps();
const modelsToUnload = runningModels.models
.filter(m => !exceptFor?.includes(m.model));
const unloadPromises = modelsToUnload
.map(model =>
ollama.generate({
model: model.name,
keep_alive: 0,
stream: false,
prompt: ""
})
);
await Promise.all(unloadPromises);
logger.info("ollama.unload_all.done", {count: modelsToUnload.length, exceptFor});
} catch (error) {
logger.error("ollama.unload_all.failed", {exceptFor, error});
}
}
export async function loadOllamaModel(model: string, ollama: Ollama, contextLength: number): Promise<boolean> {
try {
logger.info("ollama.load.start", {model, contextLength});
await ollama.generate({
model: model,
stream: false,
prompt: "",
options: {
num_ctx: contextLength
}
});
logger.info("ollama.load.done", {model, contextLength});
return true;
} catch (e: unknown) {
logger.error("ollama.load.failed", {model, contextLength, error: e});
return false;
}
}
export type ToolPlanStep = {
t: string;
h: string;
from: string;
};
export type RouterPlan = {
s: ToolPlanStep[];
m: string;
};
export const ToolPlanStepSchema = z.object({
t: z.string(),
h: z.string(),
from: z.string(),
});
export const RouterPlanSchema = z.object({
s: z.array(ToolPlanStepSchema),
m: z.string()
});
+151
View File
@@ -0,0 +1,151 @@
import axios from "axios";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("weather");
import {Environment} from "../../common/environment";
import {logError} from "../../util/utils";
import {AiTool} from "../tool-types";
import {asNonEmptyString} from "./utils";
export const getWeatherTool = {
type: "function",
function: {
name: "get_weather",
description: "Get the current temperature for a city.",
parameters: {
type: "object",
properties: {
city: {
type: "string",
description: "The name of the city."
},
lang: {
type: "string",
description: "language code for the response/content. Must be a valid ISO 639-1 two-letter language code, for example: \"en\", \"ru\", \"de\", \"fr\".Determine the value automatically from the language the user is using to communicate with the LLM. If the user explicitly requests a specific language, use that requested language instead. Do not use language names, locales, or regional variants such as \"English\", \"ru-RU\", or \"en_US\"; return only the ISO 639-1 code."
}
},
required: ["city", "lang"],
}
}
} satisfies AiTool;
export const weatherToolPrompt = [
"Weather tool rules:",
"- Use `get_weather` for current weather, current temperature, conditions, hot/cold/rainy/snowy questions, and weather follow-ups.",
"- Weather is live/current data. Never answer it from memory.",
"- A weather tool result is valid only for the exact city used in that tool call.",
"- If the user changes the city, call `get_weather` again.",
"- Follow-up questions like `what about Moscow?`, `and for Krasnodar?`, `what about there?`, `what about Berlin?` inherit the previous weather intent and require a new tool call for the new city.",
"",
"Arguments:",
"- `city`: the city from the latest user request or resolved from the follow-up context.",
"- `lang`: ISO 639-1 two-letter language code only: `ru`, `en`, `de`, etc.",
"",
"Do not guess, compare, or reuse weather from another city.",
"If the city is missing or unclear, ask the user to specify it.",
].join("\n");
export async function getWeather(args?: Record<string, unknown>): Promise<Record<string, unknown> | null> {
const startedAt = Date.now();
logger.info("start", {args});
try {
const city = asNonEmptyString(args?.city);
const lang = asNonEmptyString(args?.lang);
if (!city) {
return null;
}
const apiKey = Environment.OPEN_WEATHER_MAP_API_KEY;
const geocodeResponse = (await axios.get("https://api.openweathermap.org/geo/1.0/direct", {
params: {
q: city,
limit: 1,
appid: apiKey,
},
})).data[0];
logger.debug("geocode.done", {city, country: geocodeResponse?.country, hasResult: !!geocodeResponse, geocodeResponse});
if (!geocodeResponse) {
return {
ok: false,
tool: "get_weather",
error: `City not found: ${city}`,
city,
lang,
};
}
const lat = geocodeResponse.lat;
const lon = geocodeResponse.lon;
const response = (await axios.get("https://api.openweathermap.org/data/2.5/weather", {
params: {
lat,
lon,
units: "metric",
appid: apiKey,
...(lang ? {lang} : {}),
},
})).data;
logger.debug("weather_api.done", {city, country: geocodeResponse.country, lang, units: "metric", hasResponse: !!response});
const main = response.main;
const sys = response.sys;
const wind = response.wind;
const weather = response.weather[0];
let date = new Date(sys.sunrise * 1000);
const sunrise = [
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
]
.map((v) => String(v).padStart(2, "0"))
.join(":");
date = new Date(sys.sunset * 1000);
const sunset = [
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
]
.map((v) => String(v).padStart(2, "0"))
.join(":");
return {
ok: true,
tool: "get_weather",
scope: {
city,
lang,
validOnlyForExactCity: true,
liveData: true,
note: "If the user asks about another city, call get_weather again.",
},
weather: {
main: weather.main,
description: weather.description,
temperature: main.temp,
temperatureMax: main.temp_max,
temperatureMin: main.temp_min,
feelsLike: main.feels_like,
humidity: main.humidity,
pressure: main.pressure,
seaLevel: main.sea_level ?? null,
groundLevel: main.grnd_level ?? null,
sunriseUtc: sunrise,
sunsetUtc: sunset,
windDegree: wind.deg,
windSpeed: wind.speed,
},
};
} catch (e: unknown) {
logger.error("failed", {duration: logger.duration(startedAt), error: e});
logError(e);
return null;
} finally {
logger.debug("done", {duration: logger.duration(startedAt)});
}
}
+176
View File
@@ -0,0 +1,176 @@
// Gemini provider runner extracted from unified-ai-runner.ts.
import {getGeminiTools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime";
import {GeminiMessage} from "./gemini-chat-message";
import {createGoogleGenAiClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {
AsyncIterableStream,
executeToolBatch,
GeminiFunctionCallLike,
GeminiGenerationRequest,
GeminiResponseLike,
MAX_TOOL_ROUNDS,
roundStatus,
RuntimeConfigSnapshot,
safeJsonParseObject,
ToolCallData,
ToolExecutionMemory
} from "./unified-ai-runner.shared";
function collectGeminiResponseText(response: GeminiResponseLike & { text?: string }): string {
if (typeof response.text === "string") return response.text;
return (response.candidates ?? [])
.flatMap(candidate => candidate.content?.parts ?? [])
.map(part => part.text ?? "")
.join("");
}
function collectGeminiFunctionCalls(response: GeminiResponseLike): ToolCallData[] {
const calls = response.functionCalls
?? (response.candidates ?? []).flatMap(candidate => {
return (candidate.content?.parts ?? [])
.map(part => part.functionCall)
.filter((call): call is GeminiFunctionCallLike => !!call);
});
return (calls ?? []).map((call, index) => ({
id: call.id ?? `gemini_${index}_${call.name ?? "call"}`,
name: call.name ?? "",
argumentsText: JSON.stringify(call.args ?? {}),
})).filter((call: ToolCallData) => call.name);
}
function mergeGeminiFunctionCalls(existing: ToolCallData[], next: ToolCallData[]): ToolCallData[] {
const merged = [...existing];
for (const call of next) {
const index = merged.findIndex(item => item.id === call.id);
if (index === -1) {
merged.push(call);
} else {
merged[index] = call;
}
}
return merged;
}
function appendGeminiToolRound(messages: GeminiMessage[], calls: ToolCallData[], results: string[]): void {
messages.push({
role: "model",
parts: calls.map(call => ({
functionCall: {
id: call.id,
name: call.name,
args: safeJsonParseObject(call.argumentsText),
},
})),
});
messages.push({
role: "user",
parts: calls.map((call, index) => ({
functionResponse: {
id: call.id,
name: call.name,
response: {result: results[index] ?? ""},
},
})),
});
}
export async function runGemini(
messages: GeminiMessage[],
streamMessage: TelegramStreamMessage,
signal: AbortSignal,
stream: boolean,
firstRoundStatus: string,
config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext,
): Promise<void> {
const runnerStartedAt = Date.now();
const geminiAi = createGoogleGenAiClient(config.geminiChatTarget);
aiLog("info", "gemini.run.start", {
stream,
target: aiLogProviderTarget(config.geminiChatTarget),
inputMessages: messages.length,
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
});
// TODO: 13.05.2026, Danil Nikolaev: find a better way?
const imageCount = messages.reduce((sum, m) => {
return sum + (m.parts.filter(p => "inlineData" in p && "mimeType" in p.inlineData && p.inlineData.mimeType.startsWith("image")).length)
}, 0);
const target = imageCount ? config.geminiImageTarget : config.geminiChatTarget;
const model = target.model;
const toolMemory: ToolExecutionMemory = new Map();
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const roundStartedAt = Date.now();
aiLog("debug", "gemini.round.start", {round, messages: messages.length, stream});
if (signal.aborted) throw new Error("Aborted");
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
await streamMessage.flush();
const request: GeminiGenerationRequest = {
model: model,
contents: messages,
config: {
tools: getGeminiTools(),
temperature: messages.length <= 2 ? 0 : 0.6,
abortSignal: signal,
},
};
if (!stream) {
const response = await geminiAi.models.generateContent(request) as unknown as GeminiResponseLike & {
text?: string
};
const text = collectGeminiResponseText(response);
streamMessage.append(text);
const calls = collectGeminiFunctionCalls(response);
aiLog(calls.length ? "info" : "success", calls.length ? "gemini.tool_calls" : "gemini.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: text.length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
appendGeminiToolRound(messages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory));
continue;
}
const response = await geminiAi.models.generateContentStream(request) as unknown as AsyncIterableStream<GeminiResponseLike & {
text?: string
}>;
aiLog("debug", "gemini.stream.open", {round});
let calls: ToolCallData[] = [];
const roundTextStart = streamMessage.getText().length;
for await (const chunk of response) {
if (signal.aborted) throw new Error("Aborted");
streamMessage.append(collectGeminiResponseText(chunk));
calls = mergeGeminiFunctionCalls(calls, collectGeminiFunctionCalls(chunk));
}
aiLog(calls.length ? "info" : "success", calls.length ? "gemini.tool_calls" : "gemini.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: streamMessage.getText().slice(roundTextStart).length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
appendGeminiToolRound(messages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory));
}
}
export class GeminiProviderRunner {
static run = runGemini;
}
+137
View File
@@ -0,0 +1,137 @@
// Mistral provider runner extracted from unified-ai-runner.ts.
import {Environment} from "../common/environment";
import {getMistralTools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime";
import {MistralChatMessage} from "./mistral-chat-message";
import {createMistralClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {MAX_TOOL_ROUNDS, MistralDeltaLike, MistralDocumentReference, RuntimeConfigSnapshot, StreamingToolCallAccumulator, ToolCallData, ToolExecutionMemory, contentFromMistralDelta, executeToolBatch, mistralToolCalls, normalizeMistralToolCalls, roundStatus} from "./unified-ai-runner.shared";
export async function runMistral(
messages: MistralChatMessage[],
documents: MistralDocumentReference[],
streamMessage: TelegramStreamMessage,
signal: AbortSignal,
stream: boolean,
firstRoundStatus: string,
config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext,
): Promise<void> {
const runnerStartedAt = Date.now();
const mistralAi = createMistralClient(config.mistralChatTarget);
aiLog("info", "mistral.run.start", {
stream,
target: aiLogProviderTarget(config.mistralChatTarget),
inputMessages: messages.length,
documents: documents.length,
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
});
const toolMemory: ToolExecutionMemory = new Map();
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const roundStartedAt = Date.now();
aiLog("debug", "mistral.round.start", {round, messages: messages.length, stream});
if (signal.aborted) throw new Error("Aborted");
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
await streamMessage.flush();
if (!stream) {
const request = {
model: config.mistralChatTarget.model,
messages,
tools: getMistralTools(),
documents: documents
} as unknown as Parameters<typeof mistralAi.chat.complete>[0];
const response = await mistralAi.chat.complete(request, {signal});
const msg = response.choices?.[0]?.message;
const text = typeof msg?.content === "string" ? msg.content : JSON.stringify(msg?.content ?? "");
streamMessage.append(text);
const calls = normalizeMistralToolCalls(mistralToolCalls(msg));
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: text.length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
messages.push({
role: "assistant",
content: text,
toolCalls: calls.map(call => ({
id: call.id,
function: {name: call.name, arguments: call.argumentsText},
})),
});
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
name: call.name,
toolCallId: call.id,
content: toolResults[index] ?? "",
});
}
continue;
}
const request = {
model: config.mistralChatTarget.model,
messages,
tools: getMistralTools(),
documents: documents
} as unknown as Parameters<typeof mistralAi.chat.stream>[0];
const streamResponse = await mistralAi.chat.stream(request, {signal});
aiLog("debug", "mistral.stream.open", {round});
let calls: ToolCallData[] = [];
const roundTextStart = streamMessage.getText().length;
const toolCallAccumulator = new StreamingToolCallAccumulator("mistral_stream", round);
for await (const event of streamResponse) {
if (signal.aborted) throw new Error("Aborted");
const choice = event.data?.choices?.[0];
const delta = choice?.delta;
const mistralDelta = delta as MistralDeltaLike;
streamMessage.append(contentFromMistralDelta(mistralDelta));
const rawDeltaCalls = mistralToolCalls(mistralDelta);
if (rawDeltaCalls.length) {
calls = toolCallAccumulator.add(rawDeltaCalls);
streamMessage.setStatus(Environment.getUseToolText(calls));
await streamMessage.flush();
}
}
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: streamMessage.getText().slice(roundTextStart).length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
const roundText = streamMessage.getText().slice(roundTextStart);
messages.push({
role: "assistant",
content: roundText,
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
});
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
name: call.name,
toolCallId: call.id,
content: toolResults[index] ?? "",
});
}
}
}
export class MistralProviderRunner {
static run = runMistral;
}
+404
View File
@@ -0,0 +1,404 @@
// Ollama provider runner extracted from unified-ai-runner.ts.
import {Message} from "typescript-telegram-bot-api";
import * as fs from "node:fs";
import path from "node:path";
import {AiProvider} from "../model/ai-provider";
import {Environment} from "../common/environment";
import {bot, notesDir} from "../index";
import {clamp, logError} from "../util/utils";
import {getOllamaTools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {getModelCapabilities} from "./provider-model-runtime";
import {ChatMessage} from "./chat-messages-types";
import {ChatRequest, Tool} from "ollama";
import {ToolRuntimeContext} from "./tools/runtime";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {getCurrentDateTimeTool} from "./tools/datetime";
import {getMarketRatesTool} from "./tools/market-rates";
import {getWeatherTool} from "./tools/weather";
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
import {createOllamaClient} from "./ai-runtime-target";
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-file";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {DEFAULT_OLLAMA_CONTEXT_SIZE, MAX_OLLAMA_CONTEXT_SIZE, MAX_TOOL_ROUNDS, MIN_OLLAMA_CONTEXT_SIZE, RuntimeConfigSnapshot, Think, ToolCallData, ToolExecutionMemory, allToolSchemaNames, appendOllamaToolResults, dedupeToolCalls, executeToolBatch, normalizeOllamaToolCalls, roundStatus, safeJsonParseObject, isRecord, isOllamaModelActive, OllamaToolCallLike} from "./unified-ai-runner.shared";
import {latestUserTextFromOllamaMessages, looksLikeToolRankerJson, OllamaToolRanker} from "./unified-ai-runner.tool-ranker";
export async function runOllama(
msg: Message,
messages: ChatMessage[],
streamMessage: TelegramStreamMessage,
signal: AbortSignal,
stream: boolean,
think: Think,
firstRoundStatus: string,
config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext,
contextSize?: number,
): Promise<void> {
const fromId = msg.from?.id;
const runnerStartedAt = Date.now();
const audioCount = messages.reduce((sum, m) => sum + (m.audioParts?.length || m.audios?.length || 0), 0);
const videoNoteCount = messages.reduce((sum, m) => sum + (m.videoNotes?.length ?? 0), 0);
const imageCount = messages.reduce((sum, m) => sum + (m.imageParts?.length || m.images?.length || 0), 0);
const target = (audioCount || videoNoteCount) ? config.ollamaAudioTarget :
imageCount ? config.ollamaVisionTarget :
think ? config.ollamaThinkingTarget : config.ollamaChatTarget;
const model = target.model;
aiLog("info", "ollama.run.start", {
stream,
think,
target: aiLogProviderTarget(target),
requestedContextSize: contextSize,
message: aiLogMessageIdentity(msg),
counts: {messages: messages.length, images: imageCount, audio: audioCount, videoNotes: videoNoteCount},
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
});
const ollama = createOllamaClient(target);
const modelInfo = await ollama.show({model});
const modelInfoMap = isRecord(modelInfo.model_info) ? modelInfo.model_info : {};
const contextKey = Object.keys(modelInfoMap).find(k => k.endsWith(".context_length"));
// @ts-ignore
const rawMaxContextLength = contextKey ? modelInfoMap[contextKey] : undefined;
const parsedMaxContextLength =
typeof rawMaxContextLength === "number"
? rawMaxContextLength
: typeof rawMaxContextLength === "string"
? Number(rawMaxContextLength)
: DEFAULT_OLLAMA_CONTEXT_SIZE;
const maxContextLength = Number.isFinite(parsedMaxContextLength)
? parsedMaxContextLength
: DEFAULT_OLLAMA_CONTEXT_SIZE;
const context = clamp(
contextSize === -1 ? MAX_OLLAMA_CONTEXT_SIZE : contextSize ?? DEFAULT_OLLAMA_CONTEXT_SIZE,
MIN_OLLAMA_CONTEXT_SIZE,
maxContextLength ?? DEFAULT_OLLAMA_CONTEXT_SIZE
);
aiLog("debug", "ollama.context.resolved", {model, contextKey, maxContextLength, context});
const modelsToLoad = [model];
try {
const activeModels = (await ollama.ps()).models.map(m => m.model);
const oldSet = new Set(activeModels);
const newSet = new Set(modelsToLoad);
const added = modelsToLoad.filter(m => !oldSet.has(m));
const removed = activeModels.filter(m => !newSet.has(m));
const diff = [...added, ...removed];
aiLog("debug", "ollama.models.active", {activeModels, requiredModels: modelsToLoad, added, removed});
if (diff.length) {
aiLog("info", "ollama.models.unload_extra", {keep: modelsToLoad, diff});
await unloadAllOllamaModels(ollama, modelsToLoad);
}
} catch (e) {
logError(e);
}
if (!(await isOllamaModelActive(ollama, target))) {
const loadStartedAt = Date.now();
aiLog("info", "ollama.model.load.start", {model, context});
const currentStatus = streamMessage.getStatus();
streamMessage.setStatus(Environment.getLoadingModelText(model));
await streamMessage.flush();
if (await loadOllamaModel(model, ollama, context)) {
aiLog("success", "ollama.model.load.done", {model, duration: aiLogDuration(loadStartedAt)});
streamMessage.setStatus(currentStatus ?? Environment.waitThinkText);
await streamMessage.flush();
}
} else {
aiLog("debug", "ollama.model.already_loaded", {model});
}
let interval: ReturnType<typeof setInterval> | null = null;
if (!stream) {
let typingInFlight = false;
const applyTyping = async () => {
if (typingInFlight) return;
typingInFlight = true;
try {
await enqueueTelegramApiCall(
() => bot.sendChatAction({chat_id: msg.chat.id, action: "typing"}),
{method: "sendChatAction", chatId: msg.chat.id, chatType: msg.chat.type}
).catch(logError);
} finally {
typingInFlight = false;
}
};
await applyTyping();
interval = setInterval(() => {
applyTyping().catch(logError);
}, 5000);
}
const toolMemory: ToolExecutionMemory = new Map();
try {
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const roundStartedAt = Date.now();
aiLog("debug", "ollama.round.start", {
round,
context,
messages: messages.length,
stream,
think: audioCount ? false : think,
});
const request: ChatRequest = {
model: model,
messages: messages,
think: audioCount ? false : think,
options: {
temperature: 0.6,
num_ctx: context,
}
};
let activeToolNames: string[] = [];
if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
const availableOllamaTools: Tool[] = fromId !== Environment.CREATOR_ID
? [getCurrentDateTimeTool, getMarketRatesTool, getWeatherTool]
: getOllamaTools() as Tool[];
aiLog("debug", "ollama.tools.available", {
round,
tools: allToolSchemaNames(availableOllamaTools),
rankerEnabled: !!config.ollamaToolRankerTarget,
});
const rankerSelection = await new OllamaToolRanker(config).selectTools({
userQuery: latestUserTextFromOllamaMessages(messages),
availableTools: availableOllamaTools,
round,
signal,
});
activeToolNames = rankerSelection.selectedNames;
if (rankerSelection.tools.length > 0) {
request.tools = rankerSelection.tools;
} else {
delete request.tools;
}
aiLog("debug", "ollama.tools.selected", {
round,
tools: activeToolNames,
count: activeToolNames.length,
usedRanker: rankerSelection.usedRanker,
missing: rankerSelection.missing,
});
}
if (!stream) {
const response = await ollama.chat({
...request,
stream: false
});
const message = response.message;
const rawContent = message?.content ?? "";
const nativeCalls = dedupeToolCalls(
normalizeOllamaToolCalls(
message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
round,
),
);
const responseText = rawContent;
if (looksLikeToolRankerJson(responseText)) {
aiLog("error", "ollama.response.looks_like_tool_ranker_json", {
round,
preview: responseText.slice(0, 800),
target: aiLogProviderTarget(target),
});
throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up.");
}
streamMessage.append(responseText);
aiLog("debug", "ollama.response.received", {
round,
duration: aiLogDuration(roundStartedAt),
textChars: responseText.length,
nativeToolCallCount: nativeCalls.length,
});
if (!nativeCalls.length) {
aiLog("success", "ollama.run.done", {round, duration: aiLogDuration(runnerStartedAt)});
break;
}
const calls = nativeCalls;
aiLog("info", "ollama.tool_calls", {
round,
calls: calls.map(aiLogToolCall),
});
messages.push({
role: "assistant",
content: responseText,
tool_calls: calls.map(c => ({
function: {
name: c.name,
arguments: safeJsonParseObject(c.argumentsText),
},
})),
});
appendOllamaToolResults(
messages,
calls,
await executeToolBatch(calls, streamMessage, toolContext, toolMemory),
);
continue;
}
const response = await ollama.chat({
...request,
stream: true
});
aiLog("debug", "ollama.stream.open", {round});
const calls: ToolCallData[] = [];
const roundTextStart = streamMessage.getText().length;
const abortOllamaResponse = () => response.abort?.();
signal.addEventListener("abort", abortOllamaResponse, {once: true});
if (signal.aborted) abortOllamaResponse();
try {
for await (const chunk of response) {
const localToolCalls: ToolCallData[] = [];
localToolCalls.push(...normalizeOllamaToolCalls(
chunk.message.tool_calls as readonly OllamaToolCallLike[] | undefined,
round,
));
const newStatus = roundStatus(round, firstRoundStatus, chunk.message.content, localToolCalls, !!chunk.message.thinking);
const previousStatus = streamMessage.getStatus();
if (newStatus && newStatus !== Environment.waitThinkText) {
streamMessage.setStatus(newStatus);
} else {
streamMessage.clearStatus();
}
if (streamMessage.getStatus() !== previousStatus && previousStatus && newStatus !== Environment.waitThinkText) {
await streamMessage.flush();
}
if (signal.aborted) {
response.abort?.();
throw new Error("Aborted");
}
if (!(chunk.message?.thinking && streamMessage.getStatus() !== Environment.reasoningText)) {
streamMessage.append(chunk.message?.content ?? "");
}
calls.push(...normalizeOllamaToolCalls(
chunk.message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
round,
));
if (chunk.done) {
aiLog("debug", "ollama.stream.done", {
round,
duration: aiLogDuration(roundStartedAt),
textChars: streamMessage.getText().slice(roundTextStart).length,
toolCallCount: calls.length,
});
await streamMessage.flush(streamMessage.regenerateKeyboard(), true);
}
}
} finally {
signal.removeEventListener("abort", abortOllamaResponse);
}
const streamedRoundText = streamMessage.getText().slice(roundTextStart);
if (!calls.length && looksLikeToolRankerJson(streamedRoundText)) {
streamMessage.replaceText(streamMessage.getText().slice(0, roundTextStart));
aiLog("error", "ollama.response.looks_like_tool_ranker_json", {
round,
preview: streamedRoundText.slice(0, 800),
target: aiLogProviderTarget(target),
});
throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up.");
}
if (!calls.length) {
aiLog("success", "ollama.run.done", {
round,
duration: aiLogDuration(runnerStartedAt),
});
break;
}
calls.splice(0, calls.length, ...dedupeToolCalls(calls));
aiLog("info", "ollama.tool_calls", {
round,
calls: calls.map(aiLogToolCall),
});
const roundText = streamMessage.getText().slice(roundTextStart);
messages.push({
role: "assistant",
content: roundText,
tool_calls: calls.map(c => ({
function: {
name: c.name,
arguments: safeJsonParseObject(c.argumentsText),
},
})),
});
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
for (const toolResult of toolResults) {
try {
const raw = JSON.parse(toolResult);
const res = GetNoteFileResultSchema.safeParse(raw);
if (res.success && res.data.success) {
successGetNoteFileResult = res.data;
}
} catch {
// Not every tool result is JSON.
}
}
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
}).catch(logError);
}
appendOllamaToolResults(messages, calls, toolResults);
}
} finally {
if (interval) clearInterval(interval);
}
}
export class OllamaProviderRunner {
static run = runOllama;
}
+563
View File
@@ -0,0 +1,563 @@
// OpenAI and OpenAI-compatible provider runners extracted from unified-ai-runner.ts.
import {Message} from "typescript-telegram-bot-api";
import {Environment} from "../common/environment";
import {getOpenAITools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime";
import {OpenAIChatMessage} from "./openai-chat-message";
import type {
ResponseCreateParamsNonStreaming,
ResponseCreateParamsStreaming,
ResponseInputItem,
ResponseStreamEvent
} from "openai/resources/responses/responses";
import type {
ChatCompletionCreateParamsNonStreaming,
ChatCompletionCreateParamsStreaming
} from "openai/resources/chat/completions";
import {createGeminiOpenAiClient, createOpenAiClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {
AsyncIterableStream,
collectOpenAiResponseFunctionCalls,
collectOpenAiResponseImages,
collectOpenAiResponseText,
executeToolBatch,
getOpenAIResponsesToolsWithImage,
isRecord,
MAX_TOOL_ROUNDS,
OPENAI_IMAGE_PARTIALS,
OpenAiChatCompletionResponseLike,
OpenAiChatCompletionStreamChunkLike,
OpenAiChatToolCallLike,
OpenAiCompatibleChatMessage,
OpenAiCompatibleContentPart,
openAiResponseItemCallId,
OpenAiResponseLike,
OpenAiResponseOutputItem,
roundStatus,
RuntimeConfigSnapshot,
safeJsonParseObject,
showOpenAiGeneratedImage,
StreamingToolCallAccumulator,
ToolCallData,
ToolExecutionMemory
} from "./unified-ai-runner.shared";
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-file";
import {bot, notesDir} from "../index";
import fs from "node:fs";
import path from "node:path";
import {logError} from "../util/utils";
export async function runOpenAi(
msg: Message,
messages: OpenAIChatMessage[],
streamMessage: TelegramStreamMessage,
signal: AbortSignal,
stream: boolean,
firstRoundStatus: string,
sourceMessage: Message,
config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext,
): Promise<void> {
// TODO: 13.05.2026: remove
firstRoundStatus;
const runnerStartedAt = Date.now();
let responseInput: unknown[] = [...messages];
const openAi = createOpenAiClient(config.openAiChatTarget);
aiLog("info", "openai.run.start", {
stream,
target: aiLogProviderTarget(config.openAiChatTarget),
imageTarget: aiLogProviderTarget(config.openAiImageTarget),
inputMessages: messages.length,
sourceMessage: aiLogMessageIdentity(sourceMessage),
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
});
const toolMemory: ToolExecutionMemory = new Map();
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const roundStartedAt = Date.now();
aiLog("debug", "openai.round.start", {round, inputItems: responseInput.length, stream});
if (!stream) {
const request: ResponseCreateParamsNonStreaming = {
model: config.openAiChatTarget.model,
input: responseInput as ResponseInputItem[],
tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsNonStreaming["tools"],
instructions: config.systemPrompt,
};
const response = await openAi.responses.create(request, {signal}) as unknown as OpenAiResponseLike;
const responseText = collectOpenAiResponseText(response);
streamMessage.append(responseText);
aiLog("debug", "openai.response.received", {
round,
duration: aiLogDuration(roundStartedAt),
textChars: responseText.length,
outputItems: response?.output?.length ?? 0,
});
const images = collectOpenAiResponseImages(response);
if (images.length) {
await showOpenAiGeneratedImage(
streamMessage,
sourceMessage,
images[images.length - 1],
`final_${round}`,
Environment.getImageGenDoneText(config.openAiImageTarget.model),
true,
);
}
const calls = collectOpenAiResponseFunctionCalls(response);
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
calls: calls.map(call => ({
id: call.callId,
name: call.name,
arguments: safeJsonParseObject(call.argumentsText)
})),
});
if (!calls.length) return;
const toolCalls = calls.map(call => ({
id: call.callId,
name: call.name,
argumentsText: call.argumentsText,
}));
const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory);
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
for (const toolResult of toolResults) {
try {
const raw = JSON.parse(toolResult);
const res = GetNoteFileResultSchema.safeParse(raw);
if (res.success && res.data.success) {
successGetNoteFileResult = res.data;
}
} catch {
// Not every tool result is JSON.
}
}
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
}).catch(logError);
}
const toolOutputs = calls.map((call, index) => ({
type: "function_call_output" as const,
call_id: call.callId,
output: toolResults[index] ?? "",
}));
responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs];
continue;
}
let completedResponse: OpenAiResponseLike | null = null;
const request: ResponseCreateParamsStreaming = {
model: config.openAiChatTarget.model,
input: responseInput as ResponseInputItem[],
stream: true,
tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsStreaming["tools"],
};
const response = await openAi.responses.create(request, {signal}) as unknown as AsyncIterableStream<ResponseStreamEvent>;
aiLog("debug", "openai.stream.open", {round});
let localToolCalls: ToolCallData[] = [];
for await (const event of response) {
if (signal.aborted) throw new Error("Aborted");
switch (event.type) {
case "response.output_text.delta":
streamMessage.append(event.delta ?? "");
break;
case "response.image_generation_call.in_progress":
streamMessage.setStatus(Environment.startingImageGenText);
await streamMessage.flush();
break;
case "response.image_generation_call.generating":
streamMessage.setStatus(Environment.imageGenText);
await streamMessage.flush();
break;
case "response.image_generation_call.partial_image": {
const iteration = (event.partial_image_index ?? 0) + 1;
await showOpenAiGeneratedImage(
streamMessage,
sourceMessage,
event.partial_image_b64,
`partial_${round}_${iteration}`,
Environment.getPartialImageGenText(iteration, OPENAI_IMAGE_PARTIALS),
false,
);
break;
}
case "response.image_generation_call.completed":
streamMessage.setStatus(Environment.finalizingImageGenText);
await streamMessage.flush();
break;
case "response.output_item.added":
if (event.item.type === "function_call" && event.item.name) {
const item = event.item as OpenAiResponseOutputItem & { id?: string };
localToolCalls.push({
id: openAiResponseItemCallId(item),
name: item.name ?? "",
argumentsText: item.arguments ?? "{}",
});
aiLog("info", "openai.stream.tool_call.added", {
round,
toolCalls: localToolCalls.map(aiLogToolCall)
});
streamMessage.setStatus(Environment.getUseToolText(localToolCalls));
await streamMessage.flush();
}
break;
case "response.output_item.done":
if (event.item.type === "function_call" && event.item.name) {
const item = event.item as OpenAiResponseOutputItem & { id?: string };
const itemId = openAiResponseItemCallId(item);
const index = localToolCalls.findIndex(c => c.id === itemId);
if (index !== -1) {
localToolCalls.splice(index, 1);
if (localToolCalls.length === 0) {
streamMessage.clearStatus();
} else {
streamMessage.setStatus(Environment.getUseToolText(localToolCalls));
}
await streamMessage.flush();
}
}
break;
case "response.function_call_arguments.delta":
break;
case "response.function_call_arguments.done":
break;
case "response.completed":
completedResponse = event.response as unknown as OpenAiResponseLike;
break;
case "response.failed":
throw new Error(event.response?.error?.message ?? "OpenAI response failed");
case "error":
throw new Error(event.message ?? event?.message ?? "OpenAI stream error");
}
}
if (!completedResponse) throw new Error("OpenAI did not return the final response.completed event.");
aiLog("debug", "openai.stream.completed", {
round,
duration: aiLogDuration(roundStartedAt),
outputItems: completedResponse?.output?.length ?? 0,
});
const images = collectOpenAiResponseImages(completedResponse);
if (images.length) {
await showOpenAiGeneratedImage(
streamMessage,
sourceMessage,
images[images.length - 1],
`final_${round}`,
Environment.getImageGenDoneText(config.openAiImageTarget.model),
true,
);
}
const calls = collectOpenAiResponseFunctionCalls(completedResponse);
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
calls: calls.map(call => ({
id: call.callId,
name: call.name,
arguments: safeJsonParseObject(call.argumentsText)
})),
});
if (!calls.length) return;
const toolCalls = calls.map(call => ({
id: call.callId,
name: call.name,
argumentsText: call.argumentsText,
}));
const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory);
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
for (const toolResult of toolResults) {
try {
const raw = JSON.parse(toolResult);
const res = GetNoteFileResultSchema.safeParse(raw);
if (res.success && res.data.success) {
successGetNoteFileResult = res.data;
}
} catch {
// Not every tool result is JSON.
}
}
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
}).catch(logError);
}
const toolOutputs = calls.map((call, index) => ({
type: "function_call_output",
call_id: call.callId,
output: toolResults[index] ?? "",
}));
responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs];
}
}
function openAiResponseContentToText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content.map(part => isRecord(part) ? part.text ?? part.content ?? part.refusal ?? "" : "").join("");
}
function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAiCompatibleChatMessage[] {
return messages.map((message): OpenAiCompatibleChatMessage => {
if (message.role === "system" || message.role === "assistant") {
return {
role: message.role,
content: openAiResponseContentToText(message.content),
};
}
const content = Array.isArray(message.content)
? message.content.map((part): OpenAiCompatibleContentPart => {
if (isRecord(part) && part.type === "input_image") {
return {
type: "image_url",
image_url: {url: String(part.image_url ?? "")},
};
}
return {
type: "text",
text: isRecord(part) && typeof part.text === "string" ? part.text : "",
};
})
: message.content;
return {role: "user", content};
});
}
function normalizeOpenAiChatToolCalls(toolCalls: OpenAiChatToolCallLike[] = []): ToolCallData[] {
return toolCalls.map((call, i) => ({
id: call.id || `openai_chat_${Date.now()}_${i}`,
name: call.function?.name || call.name || "",
argumentsText: typeof call.function?.arguments === "string"
? call.function.arguments
: JSON.stringify(call.function?.arguments ?? call.arguments ?? {}),
})).filter(call => call.name);
}
async function appendOpenAiChatToolResults(
messages: OpenAiCompatibleChatMessage[],
calls: ToolCallData[],
results: string[],
): Promise<void> {
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
tool_call_id: call.id,
content: results[index] ?? "",
});
}
}
export async function runOpenAiCompatibleChat(
msg: Message,
messages: OpenAIChatMessage[],
streamMessage: TelegramStreamMessage,
signal: AbortSignal,
stream: boolean,
firstRoundStatus: string,
config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext,
): Promise<void> {
const runnerStartedAt = Date.now();
const geminiOpenAi = createGeminiOpenAiClient(config.geminiChatTarget);
const chatMessages = openAiResponseMessagesToChatCompletions(messages);
const toolMemory: ToolExecutionMemory = new Map();
aiLog("info", "openai_compatible.run.start", {
stream,
target: aiLogProviderTarget(config.geminiChatTarget),
inputMessages: messages.length,
chatMessages: chatMessages.length,
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
});
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const roundStartedAt = Date.now();
aiLog("debug", "openai_compatible.round.start", {round, messages: chatMessages.length, stream});
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
await streamMessage.flush();
if (!stream) {
const request: ChatCompletionCreateParamsNonStreaming = {
model: config.geminiChatTarget.model,
messages: chatMessages,
tools: getOpenAITools(),
temperature: 0.6,
};
const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as OpenAiChatCompletionResponseLike;
const message = response.choices?.[0]?.message;
streamMessage.append(message?.content ?? "");
const calls = normalizeOpenAiChatToolCalls(message?.tool_calls ?? []);
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: message?.content?.length ?? 0,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
chatMessages.push({
role: "assistant",
content: message?.content ?? "",
tool_calls: calls.map(call => ({
id: call.id,
type: "function" as const,
function: {
name: call.name,
arguments: call.argumentsText,
},
})),
});
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
for (const toolResult of toolResults) {
try {
const raw = JSON.parse(toolResult);
const res = GetNoteFileResultSchema.safeParse(raw);
if (res.success && res.data.success) {
successGetNoteFileResult = res.data;
}
} catch {
// Not every tool result is JSON.
}
}
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
}).catch(logError);
}
await appendOpenAiChatToolResults(chatMessages, calls, toolResults);
continue;
}
const request: ChatCompletionCreateParamsStreaming = {
model: config.geminiChatTarget.model,
messages: chatMessages,
tools: getOpenAITools(),
temperature: 0.6,
stream: true,
};
const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>;
aiLog("debug", "openai_compatible.stream.open", {round});
// const streamToolCalls: OpenAiChatToolCallLike[] = [];
const roundTextStart = streamMessage.getText().length;
const toolCallAccumulator = new StreamingToolCallAccumulator("openai_chat_stream", round);
let calls: ToolCallData[] = [];
for await (const chunk of response) {
if (signal.aborted) throw new Error("Aborted");
const delta = chunk.choices?.[0]?.delta;
streamMessage.append(delta?.content ?? "");
if (delta?.tool_calls?.length) {
calls = toolCallAccumulator.add(delta.tool_calls);
streamMessage.setStatus(Environment.getUseToolText(calls));
await streamMessage.flush();
}
}
// const calls = collectOpenAiChatStreamToolCalls(streamToolCalls);
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: streamMessage.getText().slice(roundTextStart).length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
const roundText = streamMessage.getText().slice(roundTextStart);
chatMessages.push({
role: "assistant",
content: roundText,
tool_calls: calls.map(call => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.argumentsText,
},
})),
});
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
for (const toolResult of toolResults) {
try {
const raw = JSON.parse(toolResult);
const res = GetNoteFileResultSchema.safeParse(raw);
if (res.success && res.data.success) {
successGetNoteFileResult = res.data;
}
} catch {
// Not every tool result is JSON.
}
}
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
}).catch(logError);
}
await appendOpenAiChatToolResults(chatMessages, calls, toolResults);
}
}
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
import {Tool} from "ollama";
import {AiRuntimeTarget, createOllamaClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger";
import {allToolSchemaNames, isRecord, JsonObject, RuntimeConfigSnapshot, safeJsonParseObject, toolSchemaNames} from "./unified-ai-runner.shared";
type RankedToolStep = {
t: string | string[];
h?: string;
from?: string;
};
type RankedToolPlan = {
s?: RankedToolStep[];
m?: string;
};
export type ToolRankerSelection = {
tools: Tool[];
selectedNames: string[];
missing: string;
raw: string;
usedRanker: boolean;
};
export class OllamaToolRanker {
constructor(private readonly config: RuntimeConfigSnapshot) {}
async selectTools(args: {
userQuery: string;
availableTools: Tool[];
round: number;
signal: AbortSignal;
}): Promise<ToolRankerSelection> {
const {availableTools, round, signal, userQuery} = args;
const target = this.config.ollamaToolRankerTarget;
if (!availableTools.length) {
return {tools: [], selectedNames: [], missing: "", raw: "", usedRanker: false};
}
// Ranker disabled/unconfigured: keep old behavior and expose all allowed tools.
if (!target?.model) {
return {
tools: availableTools,
selectedNames: allToolSchemaNames(availableTools),
missing: "",
raw: "",
usedRanker: false,
};
}
const startedAt = Date.now();
const availableNames = new Set(allToolSchemaNames(availableTools));
const prompt = this.config.rankerToolPrompt?.trim() || DEFAULT_TOOL_RANKER_PROMPT;
const toolsForPrompt = availableTools.map(tool => ({
names: toolSchemaNames(tool),
schema: tool,
}));
aiLog("debug", "ollama.tool_ranker.start", {
round,
target: aiLogProviderTarget(target),
queryChars: userQuery.length,
availableTools: [...availableNames],
});
try {
const ollama = createOllamaClient(target as AiRuntimeTarget);
const response = await ollama.chat({
model: target.model,
messages: [
{role: "system", content: prompt},
{
role: "user",
content: JSON.stringify({
q: userQuery,
tools: toolsForPrompt,
}),
},
],
stream: false,
options: {
temperature: 0,
num_ctx: 8192,
},
});
if (signal.aborted) throw new Error("Aborted");
const raw = response.message?.content?.trim() ?? "";
const plan = parseToolRankerPlan(raw);
const selectedNames = normalizeToolRankerNames(plan, availableNames);
const selectedNameSet = new Set(selectedNames);
const tools = availableTools.filter(tool => toolSchemaNames(tool).some(name => selectedNameSet.has(name)));
const missing = typeof plan?.m === "string" ? plan.m.trim() : "";
aiLog("debug", "ollama.tool_ranker.done", {
round,
duration: aiLogDuration(startedAt),
selectedNames,
selectedCount: tools.length,
missing,
rawPreview: raw.slice(0, 800),
});
// Important: if the plan is valid and empty, use NO tools. Do not fall back to all tools.
return {tools, selectedNames, missing, raw, usedRanker: true};
} catch (error) {
if (String(error).includes("Aborted")) throw error;
aiLog("warn", "ollama.tool_ranker.failed.fallback_all_allowed", {
round,
target: aiLogProviderTarget(target),
duration: aiLogDuration(startedAt),
error,
});
// Ranker transport/model failure is different from "ranker returned empty plan".
// In that case, preserve availability rather than silently disabling tools.
return {
tools: availableTools,
selectedNames: allToolSchemaNames(availableTools),
missing: "",
raw: "",
usedRanker: false,
};
}
}
}
export function latestUserTextFromOllamaMessages(messages: readonly { role?: string; content?: unknown }[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message?.role !== "user") continue;
if (typeof message.content === "string") return message.content;
if (Array.isArray(message.content)) {
return message.content
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
.filter(Boolean)
.join("\n");
}
}
return "";
}
export function looksLikeToolRankerJson(text: string): boolean {
const parsed = safeJsonParseObject(extractJsonObjectText(text) ?? text);
return Array.isArray(parsed.s) && typeof parsed.m === "string";
}
function parseToolRankerPlan(raw: string): RankedToolPlan | undefined {
const jsonText = extractJsonObjectText(raw);
if (!jsonText) return undefined;
const parsed = safeJsonParseObject(jsonText) as JsonObject;
if (!Array.isArray(parsed.s)) return undefined;
return parsed as RankedToolPlan;
}
function normalizeToolRankerNames(plan: RankedToolPlan | undefined, availableNames: Set<string>): string[] {
if (!plan?.s?.length) return [];
const result: string[] = [];
for (const step of plan.s) {
const rawNames = Array.isArray(step.t) ? step.t : [step.t];
for (const rawName of rawNames) {
if (typeof rawName !== "string") continue;
const name = rawName.trim();
if (availableNames.has(name) && !result.includes(name)) {
result.push(name);
}
}
}
return result;
}
function extractJsonObjectText(raw: string): string | undefined {
const text = raw.trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
const start = text.indexOf("{");
if (start === -1) return undefined;
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === "{") depth++;
if (ch === "}") depth--;
if (depth === 0) {
return text.slice(start, i + 1);
}
}
return undefined;
}
const DEFAULT_TOOL_RANKER_PROMPT = `You are a tool router. Return strict compact JSON only.
Schema: {"s":[{"t":"tool_name","h":"short input hint","from":"previous_tool.output_or_empty"}],"m":""}
Use tools only when they are needed. If no tool is needed, return {"s":[],"m":""}.
Never answer the user. Never explain. Never use markdown.`;
+333
View File
@@ -0,0 +1,333 @@
// Facade extracted from unified-ai-runner.ts.
import {AiProvider} from "../model/ai-provider";
import {Environment} from "../common/environment";
import {ifTrue, logError, replyToMessage} from "../util/utils";
import {createAiCancelRequest, finishAiRequest, setAiCancelMessageId} from "./cancel-registry";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {AiDownloadedFile, attachmentsToDownloadedFiles, cleanupDownloads} from "./telegram-attachments";
import {ChatMessage} from "./chat-messages-types";
import {aiProviderRequestQueue} from "./provider-request-queue";
import {prepareOllamaDocumentRag} from "./ollama-rag";
import {AI_VOICE_MODE_TRANSCRIPT, DEFAULT_AI_RESPONSE_LANGUAGE, resolveAiContextSizeForUser, resolveAiResponseLanguageForUser, resolveAiVoiceModeForUser} from "../common/user-ai-settings";
import {isTranscribableAudioDownload} from "./speech-to-text";
import {OpenAIChatMessage} from "./openai-chat-message";
import {MistralChatMessage} from "./mistral-chat-message";
import {OllamaChatMessage} from "./ollama-chat-message";
import {GeminiMessage} from "./gemini-chat-message";
import {buildAiRegenerateCallbackData} from "./regenerate-callback";
import {createOllamaClient, getGeminiApiMode} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget} from "../logging/ai-logger";
import {runOpenAi, runOpenAiCompatibleChat} from "./unified-ai-runner.openai";
import {runOllama} from "./unified-ai-runner.ollama";
import {runMistral} from "./unified-ai-runner.mistral";
import {runGemini} from "./unified-ai-runner.gemini";
import {AI_REQUEST_TIMEOUT_MS, TELEGRAM_LIMIT, RuntimeConfigSnapshot, UnifiedRunOptions, appendTranscriptToChatMessages, collectCachedMessageAttachments, collectRequestedAttachmentKinds, collectTextMessages, deleteMistralLibrary, hasAudioAttachmentKind, initialStatus, isAbortError, prepareMistralDocuments, providerName, rejectUnsupportedAttachments, resolveAiRequestQueueTarget, snapshotModel, snapshotRuntimeConfig, stripAudioFromRunnerMessages, toolRuntimeContextFromDownloads, transcribeAudioIfNeeded} from "./unified-ai-runner.shared";
export type {ToolCallData} from "./unified-ai-runner.shared";
export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared";
async function executeUnifiedAiRequest(
options: UnifiedRunOptions,
config: RuntimeConfigSnapshot,
downloads: AiDownloadedFile[],
controller: AbortController,
streamMessage: TelegramStreamMessage,
): Promise<{ mistralLibraryId?: string }> {
const requestStartedAt = Date.now();
aiLog("info", "request.execute.start", {
provider: providerName(options.provider),
stream: options.stream ?? true,
think: options.think,
responseLanguage: options.responseLanguage,
contextSize: options.contextSize,
voiceMode: options.voiceMode,
message: aiLogMessageIdentity(options.msg),
downloads: downloads.map(d => ({
kind: d.kind,
fileName: d.fileName,
mimeType: d.mimeType,
sizeBytes: d.buffer.length
})),
});
const {
chatMessages,
imageCount
} = await collectTextMessages(
options.msg,
options.text,
options.provider,
downloads,
config,
options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE,
);
const firstRoundStatus = initialStatus(downloads, imageCount);
const toolContext = toolRuntimeContextFromDownloads(downloads);
aiLog("debug", "request.messages.collected", {
provider: providerName(options.provider),
chatMessages: chatMessages.length,
imageCount,
firstRoundStatus,
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
});
streamMessage.setStatus(firstRoundStatus);
await streamMessage.flush();
const hasDocument = downloads.some(d => d.kind === "document");
if (hasDocument && options.provider !== AiProvider.MISTRAL && options.provider !== AiProvider.OLLAMA) {
aiLog("warn", "request.documents.unsupported_provider", {provider: providerName(options.provider)});
throw new Error(Environment.documentsUnifiedRunnerUnsupportedText);
}
let mistralLibraryId: string | undefined;
const transcript = await transcribeAudioIfNeeded(options.provider, options.msg.from?.id, downloads, streamMessage, controller.signal).catch(e => {
if (downloads.some(isTranscribableAudioDownload)) throw e;
return "";
});
if (transcript.trim()) {
if (options.voiceMode === AI_VOICE_MODE_TRANSCRIPT) {
// TODO: 12.05.2026: extract to string
streamMessage.replaceText(`[Расшифровка]\n${transcript.trim()}`);
await streamMessage.finish();
return {mistralLibraryId};
}
appendTranscriptToChatMessages(chatMessages, options.provider, transcript);
stripAudioFromRunnerMessages(chatMessages);
aiLog("debug", "request.transcript.appended", {
provider: providerName(options.provider),
transcriptChars: transcript.length,
chatMessages: chatMessages.length,
});
}
try {
const preparedMistral = options.provider === AiProvider.MISTRAL
? await prepareMistralDocuments(downloads, chatMessages as MistralChatMessage[], streamMessage, config.mistralChatTarget, controller.signal)
: {documents: []};
const documents = preparedMistral.documents;
mistralLibraryId = preparedMistral.libraryId;
if (options.provider === AiProvider.OLLAMA) {
await prepareOllamaDocumentRag({
downloads,
messages: chatMessages as OllamaChatMessage[],
userQuery: options.text,
message: streamMessage,
config: {
embeddingModel: config.ollamaDocumentsTarget.model,
embeddingClient: createOllamaClient(config.ollamaDocumentsTarget),
chunkSize: config.ollamaRagChunkSize,
chunkOverlap: config.ollamaRagChunkOverlap,
topK: config.ollamaRagTopK,
maxContextChars: config.ollamaRagMaxContextChars,
minScore: config.ollamaRagMinScore,
maxArchiveFiles: config.ollamaRagMaxArchiveFiles,
maxArchiveBytes: config.ollamaRagMaxArchiveBytes,
maxArchiveDepth: config.ollamaRagMaxArchiveDepth,
},
});
}
aiLog("info", "request.provider.dispatch", {provider: providerName(options.provider)});
switch (options.provider) {
case AiProvider.OPENAI:
await runOpenAi(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, options.msg, config, toolContext);
break;
case AiProvider.OLLAMA:
const currentModel = config.ollamaChatTarget.model;
if (currentModel?.includes("gpt-oss")) {
if (options.think) {
options.think = "high";
}
}
await runOllama(options.msg, chatMessages as ChatMessage[], streamMessage, controller.signal, ifTrue(options.stream), options.think ?? false, firstRoundStatus, config, toolContext, options.contextSize);
break;
case AiProvider.MISTRAL:
await runMistral(chatMessages as MistralChatMessage[], documents, streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
break;
case AiProvider.GEMINI:
if (getGeminiApiMode(config.geminiChatTarget) === "openai") {
await runOpenAiCompatibleChat(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
} else {
await runGemini(chatMessages as GeminiMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
}
break;
}
if (streamMessage.getText().length > TELEGRAM_LIMIT) {
streamMessage.replaceText(streamMessage.getText().slice(0, TELEGRAM_LIMIT - 3) + "...");
}
await streamMessage.finish();
// await sendVoiceResponseIfNeeded(options, downloads, streamMessage.getText());
aiLog("success", "request.execute.done", {
provider: providerName(options.provider),
duration: aiLogDuration(requestStartedAt),
responseChars: streamMessage.getText().length,
mistralLibraryId,
});
return {mistralLibraryId};
} catch (e) {
aiLog("error", "request.execute.failed", {
provider: providerName(options.provider),
duration: aiLogDuration(requestStartedAt),
error: e,
});
if (mistralLibraryId) {
await deleteMistralLibrary(mistralLibraryId, config.mistralChatTarget);
}
throw e;
}
}
export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
const startedAt = Date.now();
const config = snapshotRuntimeConfig();
options.responseLanguage ??= await resolveAiResponseLanguageForUser(options.msg.from?.id);
options.contextSize ??= await resolveAiContextSizeForUser(options.msg.from?.id);
options.voiceMode ??= await resolveAiVoiceModeForUser(options.msg.from?.id);
const requestedAttachmentKinds = await collectRequestedAttachmentKinds(options.msg);
aiLog("info", "run.start", {
provider: providerName(options.provider),
model: snapshotModel(options.provider, config),
message: aiLogMessageIdentity(options.msg),
targetMessage: aiLogMessageIdentity(options.targetMessage),
isGuestMsg: options.isGuestMsg,
stream: options.stream,
think: options.think,
responseLanguage: options.responseLanguage,
contextSize: options.contextSize,
voiceMode: options.voiceMode,
requestedAttachmentKinds: [...requestedAttachmentKinds],
textChars: options.text.length,
});
if (await rejectUnsupportedAttachments(options.provider, snapshotModel(options.provider, config), options.msg, config, requestedAttachmentKinds)) {
aiLog("warn", "run.rejected.unsupported_attachment", {
provider: providerName(options.provider),
requestedAttachmentKinds: [...requestedAttachmentKinds],
});
return;
}
const cached = await collectCachedMessageAttachments(options.msg);
aiLog("debug", "run.attachments.cache", {
attachments: cached.attachments.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})),
missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})),
});
if (cached.missing.length) {
await replyToMessage({
message: options.msg,
text: Environment.getAttachmentMissingFromCacheText(cached.missing[0].fileName),
}).catch(logError);
aiLog("warn", "run.rejected.missing_attachment_cache", {
missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})),
});
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS);
const cancel = createAiCancelRequest({
chatId: options.msg.chat.id,
fromId: options.msg.from?.id ?? 0,
provider: providerName(options.provider),
controller
});
const streamMessage = new TelegramStreamMessage(
options.msg,
cancel.id,
ifTrue(options.stream),
options.voiceMode === AI_VOICE_MODE_TRANSCRIPT && hasAudioAttachmentKind(requestedAttachmentKinds)
? undefined
: buildAiRegenerateCallbackData(options.provider, !!options.think),
options.targetMessage,
options.provider,
options.isGuestMsg
);
cancel.onCancel = () => streamMessage.cancel(cancel.provider);
let mistralLibraryId: string | undefined;
const queueTarget = resolveAiRequestQueueTarget(options, config, requestedAttachmentKinds);
aiLog("debug", "run.queue.target", {target: aiLogProviderTarget(queueTarget), cancelId: cancel.id});
try {
const queueMessage = await streamMessage.start(Environment.getAiQueueText(options.provider, 0));
setAiCancelMessageId(cancel.id, queueMessage.message_id);
aiLog("info", "run.queue.enter", {
cancelId: cancel.id,
queueMessageId: queueMessage.message_id,
target: aiLogProviderTarget(queueTarget),
});
await aiProviderRequestQueue.enqueue(queueTarget, {
signal: controller.signal,
onPositionChange: async requestsBefore => {
aiLog("debug", "run.queue.position", {cancelId: cancel.id, requestsBefore});
streamMessage.setStatus(Environment.getAiQueueText(options.provider, requestsBefore));
await streamMessage.flush();
},
run: async () => {
const queueWaitFinishedAt = Date.now();
aiLog("info", "run.queue.dequeued", {cancelId: cancel.id});
const downloads = attachmentsToDownloadedFiles(cached.attachments);
aiLog("debug", "run.downloads.ready", {
count: downloads.length,
downloads: downloads.map(d => ({
kind: d.kind,
fileName: d.fileName,
mimeType: d.mimeType,
path: d.path,
sizeBytes: d.buffer.length
})),
});
try {
const result = await executeUnifiedAiRequest(options, config, downloads, controller, streamMessage);
mistralLibraryId = result.mistralLibraryId;
aiLog("success", "run.queue.task.done", {
cancelId: cancel.id,
duration: aiLogDuration(queueWaitFinishedAt),
mistralLibraryId,
});
} finally {
cleanupDownloads(downloads);
aiLog("debug", "run.downloads.cleaned", {cancelId: cancel.id, count: downloads.length});
}
},
});
} catch (e) {
if (controller.signal.aborted || isAbortError(e)) {
aiLog("warn", "run.aborted", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e});
streamMessage.replaceText(streamMessage.getText());
await streamMessage.finish();
} else {
aiLog("error", "run.failed", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e});
await streamMessage.fail(e);
logError(e);
}
} finally {
clearTimeout(timeout);
finishAiRequest(cancel.id);
if (mistralLibraryId) {
aiLog("debug", "run.mistral_library.cleanup", {mistralLibraryId});
await deleteMistralLibrary(mistralLibraryId, config.mistralChatTarget);
}
aiLog("success", "run.finished", {
cancelId: cancel.id,
provider: providerName(options.provider),
duration: aiLogDuration(startedAt),
aborted: controller.signal.aborted,
});
}
}
export class UnifiedAiRunner {
static run = runUnifiedAi;
}
+7 -3
View File
@@ -3,17 +3,18 @@ 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 {
abstract text: string;
abstract data: string;
requirements?: Requirements = null;
requirements?: Requirements | null = null;
abstract execute(query: CallbackQuery): Promise<void>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
afterExecute(query: CallbackQuery): Promise<void> {
afterExecute(_query: CallbackQuery): Promise<void> {
return Promise.resolve();
}
@@ -23,7 +24,10 @@ export abstract class CallbackCommand {
}
async answerCallbackQuery(query: CallbackQuery): Promise<void> {
bot.answerCallbackQuery(this.getOptions(query)).catch(logError);
enqueueTelegramApiCall(
() => bot.answerCallbackQuery(this.getOptions(query)),
{method: "answerCallbackQuery", skipPerChatLimit: true}
).catch(logError);
}
asButton(): InlineKeyboardButton {
+4 -4
View File
@@ -9,7 +9,7 @@ export abstract class Command {
command?: string | string[];
argsMode: ArgsMode = "none";
requirements?: Requirements = null;
requirements?: Requirements | null = null;
title?: string;
description?: string;
@@ -24,7 +24,7 @@ export abstract class Command {
abstract execute(
msg: Message,
match?: RegExpExecArray
match?: RegExpExecArray | null
): Promise<void>;
}
@@ -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");
}
+1 -1
View File
@@ -4,7 +4,7 @@ export class Requirements {
requirements: Requirement[] = [];
private constructor(requirements?: Requirement[]) {
this.requirements = requirements;
this.requirements = requirements || [];
}
static Build(...requirements: Requirement[]): Requirements {
+142
View File
@@ -0,0 +1,142 @@
import {CallbackCommand} from "../base/callback-command";
import {CallbackQuery, InlineKeyboardMarkup, Message} from "typescript-telegram-bot-api";
import {abortAiRequest, getAiCancelRequest} from "../ai/cancel-registry";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {MessageStore} from "../common/message-store";
import {bot} from "../index";
import {buildCancelledGenerationText, logError} from "../util/utils";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
import {buildAiRegenerateCallbackData} from "../ai/regenerate-callback";
import {isAiProviderConfigured, resolveEffectiveAiProviderForUser} from "../common/user-ai-settings";
const TELEGRAM_TEXT_LIMIT = 4096;
const TELEGRAM_CAPTION_LIMIT = 1024;
export class AiCancel extends CallbackCommand {
data = "/cancel_ai";
text = Environment.aiCancelCallbackText;
requirements = Requirements.Build(Requirement.SAME_USER);
async execute(query: CallbackQuery): Promise<void> {
if (!query.message || !query.data) return;
const parsed = this.parseCallbackData(query.data);
if (!parsed) return;
const request = getAiCancelRequest(parsed.requestId);
if (!request) {
await this.markMessageAsCancelled(query, parsed.provider);
return;
}
if (request.fromId !== query.from.id && query.from.id !== Environment.CREATOR_ID) return;
const cancelled = await abortAiRequest(parsed.requestId);
if (!cancelled) return;
}
private parseCallbackData(data: string): { requestId: string; provider?: AiProvider } | null {
const [, requestId, provider] = data.split(/\s+/);
if (!requestId) return null;
return {
requestId,
provider: Object.values(AiProvider).includes(provider as AiProvider) ? provider as AiProvider : undefined,
};
}
private async markMessageAsCancelled(query: CallbackQuery, providerFromCallback?: AiProvider): Promise<void> {
const callbackMessage = query.message;
if (!callbackMessage || callbackMessage.date === 0) return;
const message = callbackMessage as Message;
const stored = await MessageStore.get(message.chat.id, message.message_id).catch(e => {
logError(e);
return null;
});
const sourceFromId = await this.resolveSourceFromId(message, stored).catch(e => {
logError(e);
return undefined;
});
const regenerateProvider = providerFromCallback && isAiProviderConfigured(providerFromCallback)
? providerFromCallback
: await resolveEffectiveAiProviderForUser(sourceFromId ?? query.from.id);
const providerName = (providerFromCallback ?? regenerateProvider).toLowerCase();
const isCaption = this.isCaptionMessage(message);
const limit = isCaption ? TELEGRAM_CAPTION_LIMIT : TELEGRAM_TEXT_LIMIT;
const baseText = stored?.text ?? message.text ?? message.caption ?? "";
const cancelledText = buildCancelledGenerationText(baseText, providerName, limit);
const replyMarkup = this.regenerateKeyboard(regenerateProvider);
const formatted = prepareTelegramMarkdownV2(cancelledText, {mode: "final"});
try {
const result = isCaption
? await enqueueTelegramApiCall(
() => bot.editMessageCaption({
chat_id: message.chat.id,
message_id: message.message_id,
caption: formatted,
parse_mode: "MarkdownV2",
reply_markup: replyMarkup,
}),
{method: "editMessageCaption", chatId: message.chat.id, chatType: message.chat.type}
)
: await enqueueTelegramApiCall(
() => bot.editMessageText({
chat_id: message.chat.id,
message_id: message.message_id,
text: formatted,
parse_mode: "MarkdownV2",
reply_markup: replyMarkup,
}),
{method: "editMessageText", chatId: message.chat.id, chatType: message.chat.type}
);
if (result) {
await MessageStore.put({...(result as object), text: cancelledText} as Message);
} else {
await MessageStore.put({
chatId: message.chat.id,
id: message.message_id,
replyToMessageId: stored?.replyToMessageId ?? this.replyToMessageId(message),
fromId: message.from?.id ?? stored?.fromId ?? 0,
text: cancelledText,
date: message.date ?? stored?.date ?? Math.floor(Date.now() / 1000),
photoMaxSizeFilePath: stored?.photoMaxSizeFilePath,
attachments: stored?.attachments,
});
}
} catch (e) {
logError(e);
}
}
private regenerateKeyboard(provider: AiProvider): InlineKeyboardMarkup {
return {
inline_keyboard: [[{
text: Environment.regenerateText,
callback_data: buildAiRegenerateCallbackData(provider),
}]],
};
}
private async resolveSourceFromId(message: Message, stored: Awaited<ReturnType<typeof MessageStore.get>>): Promise<number | undefined> {
const reply = "reply_to_message" in message ? message.reply_to_message : undefined;
if (reply?.from?.id) return reply.from.id;
const source = await MessageStore.get(message.chat.id, stored?.replyToMessageId);
return source?.fromId;
}
private replyToMessageId(message: Message): number | undefined {
return "reply_to_message" in message ? message.reply_to_message?.message_id : undefined;
}
private isCaptionMessage(message: Message): boolean {
return message.caption !== undefined;
}
}
+85
View File
@@ -0,0 +1,85 @@
import {CallbackQuery, Message} from "typescript-telegram-bot-api";
import {CallbackCommand} from "../base/callback-command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {MessageStore} from "../common/message-store";
import {StoredMessage} from "../model/stored-message";
import {cutPrefixes, logError} from "../util/utils";
import {runUnifiedAi} from "../ai/unified-ai-runner";
import {AI_REGENERATE_CALLBACK, parseAiRegenerateCallbackData} from "../ai/regenerate-callback";
import {resolveEffectiveAiProviderForUser} from "../common/user-ai-settings";
import {Environment} from "../common/environment";
export class AiRegenerate extends CallbackCommand {
data = AI_REGENERATE_CALLBACK;
text = Environment.aiRegenerateCallbackText;
requirements = Requirements.Build(Requirement.SAME_USER);
async execute(query: CallbackQuery): Promise<void> {
if (!query.message || !query.data) return;
const parsed = parseAiRegenerateCallbackData(query.data);
if (!parsed) return;
const source = await this.resolveSourceMessage(query);
if (!source) return;
const sourceFromId = source.stored?.fromId ?? source.message.from?.id;
if (!sourceFromId || (sourceFromId !== query.from.id && query.from.id !== Environment.CREATOR_ID)) return;
const provider =
// isAiProviderConfigured(parsed.provider)
// ? parsed.provider
// :
await resolveEffectiveAiProviderForUser(source.message.from?.id ?? query.from.id);
const text = cutPrefixes(source.stored ?? source.message) ?? "";
runUnifiedAi({
provider,
msg: source.message,
text,
stream: true,
think: parsed.think,
targetMessage: query.message,
}).catch(logError);
}
private async resolveSourceMessage(query: CallbackQuery): Promise<{
message: Message;
stored: StoredMessage | null;
} | null> {
const responseMessage = query.message;
if (!responseMessage) return null;
const directSource = "reply_to_message" in responseMessage ? responseMessage.reply_to_message : undefined;
if (directSource) {
const stored = await MessageStore.put(directSource).catch(e => {
logError(e);
return null;
});
return {message: directSource, stored};
}
const storedResponse = await MessageStore.get(responseMessage.chat.id, responseMessage.message_id);
const storedSource = await MessageStore.get(responseMessage.chat.id, storedResponse?.replyToMessageId);
if (!storedSource) return null;
return {
message: this.storedToMessage(storedSource, responseMessage, query),
stored: storedSource,
};
}
private storedToMessage(stored: StoredMessage, responseMessage: Message, query: CallbackQuery): Message {
return {
message_id: stored.id,
chat: responseMessage.chat,
date: stored.date,
from: query.from.id === stored.fromId
? query.from
: {id: stored.fromId, is_bot: false, first_name: ""},
text: stored.text ?? undefined,
} as Message;
}
}
+4 -3
View File
@@ -1,9 +1,10 @@
import {CallbackCommand} from "../base/callback-command";
import {Environment} from "../common/environment";
export class Cancel extends CallbackCommand {
text = "❌ Отменить";
data = null;
text = Environment.cancelText;
data = "";
constructor(text?: string, data?: string) {
super();
@@ -13,7 +14,7 @@ export class Cancel extends CallbackCommand {
}
static withData(data?: string): Cancel {
return new Cancel(null, data);
return new Cancel(undefined, data);
}
async execute(): Promise<void> {
@@ -1,36 +0,0 @@
import {CallbackCommand} from "../base/callback-command";
import {CallbackQuery} from "typescript-telegram-bot-api";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {commands} from "../index";
import {YouTubeDownload} from "../commands/youtube-download";
const downloadText = " 📥 Скачать";
const getFromCacheText = "📥 Загрузить из кэша";
export class DownloadYtVideo extends CallbackCommand {
data = "/ytdl";
text = " 📥 Скачать";
requirements = Requirements.Build(Requirement.SAME_USER);
constructor(text?: string, data?: string) {
super();
this.text = text || this.text;
this.data = data || this.data;
}
static withData(inCache?: boolean, data?: string): DownloadYtVideo {
return new DownloadYtVideo(inCache ? getFromCacheText : downloadText, data);
}
async execute(query: CallbackQuery): Promise<void> {
const videoId = query.data.split(" ")[1];
if (!videoId) return;
const yt = commands.find(c => c instanceof YouTubeDownload);
if (!yt) return;
await yt.downloadYouTubeVideo(query.message, {videoId: videoId});
}
}
-72
View File
@@ -1,72 +0,0 @@
import {CallbackCommand} from "../base/callback-command";
import {CallbackQuery} from "typescript-telegram-bot-api";
import {abortOllamaRequest, bot, getOllamaRequest} from "../index";
import {logError} from "../util/utils";
import {MessageStore} from "../common/message-store";
import {StoredMessage} from "../model/stored-message";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Environment} from "../common/environment";
export class OllamaCancel extends CallbackCommand {
data = "/cancel_ollama";
text = "Cancel Ollama generation";
requirements = Requirements.Build(Requirement.SAME_USER);
async execute(query: CallbackQuery): Promise<void> {
const chatId = query.message.chat.id;
const fromId = query.from.id;
const messageId = query.message.message_id;
const uuid = query.data.split(" ")[1];
if (!uuid) return;
const request = getOllamaRequest(uuid);
if (request) {
if (request.fromId !== fromId && fromId !== Environment.CREATOR_ID) return;
const aborted = abortOllamaRequest(uuid);
console.log(`aborted request ${uuid}:`, aborted);
} else {
console.log(`no request with uuid "${uuid}" found`);
}
let msg: StoredMessage | null = null;
try {
msg = await MessageStore.get(chatId, messageId);
} catch (e) {
logError(e);
}
console.log(`Message for ${chatId}-${messageId}:`, msg);
let content: string | null = null;
if (msg?.text?.trim()?.length > 0) {
content = msg?.text.trim();
if (content.length + Environment.ollamaCancelledText.length > 4096) {
content = content.substring(0, 4096 - Environment.ollamaCancelledText.length - 2) + "\n";
}
}
const newText = `${content ? content : ""}${Environment.ollamaCancelledText}`;
try {
await bot.editMessageText({
chat_id: chatId,
message_id: messageId,
text: newText,
parse_mode: "Markdown",
reply_markup: {inline_keyboard: []},
});
if (msg) {
await MessageStore.put(msg);
}
} catch (e) {
logError(e);
}
}
}
-21
View File
@@ -1,21 +0,0 @@
import {CallbackCommand} from "../base/callback-command";
export class TryAgain extends CallbackCommand {
data = "";
text = "🔁 Повторить";
constructor(text?: string, data?: string) {
super();
this.text = text ?? this.text;
this.data = data ?? this.data;
}
static withData(data?: string): TryAgain {
return new TryAgain(null, data);
}
async execute(): Promise<void> {
return Promise.resolve();
}
}
+99
View File
@@ -0,0 +1,99 @@
import {CallbackQuery} from "typescript-telegram-bot-api";
import {CallbackCommand} from "../base/callback-command";
import {UserStore} from "../common/user-store";
import {
ensureValidUserAiSettings,
normalizeAiContextSizeChoice,
normalizeAiProviderChoice,
normalizeAiResponseLanguage,
normalizeAiVoiceMode,
normalizeInterfaceLanguage,
resolveInterfaceLocaleForUser,
setUserAiContextSizeChoice,
setUserAiProviderChoice,
setUserAiResponseLanguage,
setUserAiVoiceMode,
setUserInterfaceLanguage,
} from "../common/user-ai-settings";
import {
buildUserSettingsKeyboard,
formatUserSettingsText,
parseUserSettingsCallbackData,
USER_SETTINGS_CALLBACK_PREFIX,
UserSettingsScreen,
} from "../common/user-settings-view";
import {editMessageText, ignoreIfNotChanged, logError} from "../util/utils";
import {Environment} from "../common/environment";
import {Localization} from "../common/localization";
export class UserSettingsCallback extends CallbackCommand {
data = USER_SETTINGS_CALLBACK_PREFIX;
text = Environment.userSettingsCallbackText;
async execute(query: CallbackQuery): Promise<void> {
if (!query.message || !query.data) return;
const message = query.message;
const parsed = parseUserSettingsCallbackData(query.data);
if (!parsed || parsed.userId !== query.from.id) return;
await UserStore.put(query.from);
let screen: UserSettingsScreen = parsed.screen;
let settings = await ensureValidUserAiSettings(query.from.id);
if (parsed.screen === "provider" && parsed.providerChoice) {
const choice = normalizeAiProviderChoice(parsed.providerChoice);
if (choice) {
const result = await setUserAiProviderChoice(query.from.id, choice);
settings = result.settings;
}
screen = "provider";
}
if (parsed.screen === "interfaceLanguage" && parsed.interfaceLanguage) {
const language = normalizeInterfaceLanguage(parsed.interfaceLanguage);
if (language) {
const result = await setUserInterfaceLanguage(query.from.id, language);
settings = result.settings;
}
screen = "interfaceLanguage";
}
if (parsed.screen === "responseLanguage" && parsed.responseLanguage) {
const language = normalizeAiResponseLanguage(parsed.responseLanguage);
if (language) {
const result = await setUserAiResponseLanguage(query.from.id, language);
settings = result.settings;
}
screen = "responseLanguage";
}
if (parsed.screen === "contextSize" && parsed.contextSizeChoice) {
const choice = normalizeAiContextSizeChoice(parsed.contextSizeChoice);
if (choice || choice === -1) {
const result = await setUserAiContextSizeChoice(query.from.id, choice);
settings = result.settings;
}
screen = "contextSize";
}
if (parsed.screen === "voiceMode" && parsed.voiceMode) {
const mode = normalizeAiVoiceMode(parsed.voiceMode);
if (mode) {
const result = await setUserAiVoiceMode(query.from.id, mode);
settings = result.settings;
}
screen = "voiceMode";
}
const locale = await resolveInterfaceLocaleForUser(query.from.id, query.from.language_code);
await Localization.runWithLocale(locale, () => editMessageText({
chat_id: message.chat.id,
message_id: message.message_id,
text: formatUserSettingsText(settings, screen),
reply_markup: buildUserSettingsKeyboard(settings, screen),
})).catch(ignoreIfNotChanged).catch(logError);
}
}
-15
View File
@@ -1,15 +0,0 @@
import {CallbackCommand} from "../base/callback-command";
import {CallbackQuery} from "typescript-telegram-bot-api";
import {processYouTubeLink} from "../util/utils";
export class YtInfo extends CallbackCommand {
data = "/ytinfo";
text: string;
async execute(query: CallbackQuery): Promise<void> {
const videoId = query.data.split(" ")[1];
if (!videoId) return;
await processYouTubeLink(query.message, null, videoId);
}
}
+7 -7
View File
@@ -8,8 +8,8 @@ import {botUser} from "../index";
export class AdminsAdd extends Command {
command = "addAdmin";
title = "/addAdmin";
description = "Add user to admins";
title = Environment.commandTitles.adminsAdd;
description = Environment.commandDescriptions.adminsAdd;
requirements = Requirements.Build(
Requirement.BOT_CREATOR,
@@ -18,25 +18,25 @@ export class AdminsAdd extends Command {
);
async execute(msg: Message): Promise<void> {
if (!msg.reply_to_message) return;
if (!msg.reply_to_message || !msg.reply_to_message.from) return;
const id = msg.reply_to_message.from.id;
const text = fullName(msg.reply_to_message.from);
if (id === botUser.id) {
await oldSendMessage(msg, "Бот не может сам себя сделать админом").catch(logError);
await oldSendMessage(msg, Environment.botCannotMakeItselfAdminText).catch(logError);
return;
}
if (id === Environment.CREATOR_ID) {
await oldSendMessage(msg, "Создатель бота и так является админом").catch(logError);
await oldSendMessage(msg, Environment.botCreatorAlreadyAdminText).catch(logError);
return;
}
if (await Environment.addAdmin(id)) {
await oldSendMessage(msg, text + " теперь админ!").catch(logError);
await oldSendMessage(msg, Environment.getUserIsNowAdminText(text)).catch(logError);
} else {
await oldSendMessage(msg, text + " и так уже админ 🤔").catch(logError);
await oldSendMessage(msg, Environment.getUserAlreadyAdminText(text)).catch(logError);
}
}
}
+4 -4
View File
@@ -3,7 +3,7 @@ import {Message} from "typescript-telegram-bot-api";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Environment} from "../common/environment";
import {fullName, logError, replyToMessage, sendErrorPlaceholder} from "../util/utils";
import {escapePlainMarkdownV2, fullName, logError, replyToMessage, sendErrorPlaceholder} from "../util/utils";
import {StoredUser} from "../model/stored-user";
import {UserStore} from "../common/user-store";
@@ -29,14 +29,14 @@ export class AdminsList extends Command {
}
}
let text = "*Администраторы*:\n\n";
let text = Environment.administratorsHeaderText;
users.forEach(user => {
text += "\\* ";
if (user) {
text += `[${fullName(user)}](tg://user?id=${user.id})`;
text += `[${escapePlainMarkdownV2(fullName(user))}](tg://user?id=${user.id})`;
} else {
text += "Нет информации о пользователе";
text += Environment.noUserInfoText;
}
text += "\n";
+7 -7
View File
@@ -8,8 +8,8 @@ import {botUser} from "../index";
export class AdminsRemove extends Command {
command = "removeAdmin";
title = "/removeAdmin";
description = "Remove user from admins";
title = Environment.commandTitles.adminsRemove;
description = Environment.commandDescriptions.adminsRemove;
requirements = Requirements.Build(
Requirement.BOT_CREATOR,
@@ -18,25 +18,25 @@ export class AdminsRemove extends Command {
);
async execute(msg: Message): Promise<void> {
if (!msg.reply_to_message) return;
if (!msg.reply_to_message || !msg.reply_to_message.from) return;
const id = msg.reply_to_message.from.id;
const text = fullName(msg.reply_to_message.from);
if (id === botUser.id) {
await oldSendMessage(msg, "Бот не может сам себя убрать из админов").catch(logError);
await oldSendMessage(msg, Environment.botCannotRemoveItselfFromAdminsText).catch(logError);
return;
}
if (id === Environment.CREATOR_ID) {
await oldSendMessage(msg, "Создатель бота не может перестать быть админом").catch(logError);
await oldSendMessage(msg, Environment.botCreatorCannotStopBeingAdminText).catch(logError);
return;
}
if (await Environment.removeAdmin(id)) {
await oldSendMessage(msg, text + " больше не админ!").catch(logError);
await oldSendMessage(msg, Environment.getUserNoLongerAdminText(text)).catch(logError);
} else {
await oldSendMessage(msg, text + " и так не был админом 🤔").catch(logError);
await oldSendMessage(msg, Environment.getUserWasNotAdminText(text)).catch(logError);
}
}
}
+35 -12
View File
@@ -3,36 +3,59 @@ import {Message} from "typescript-telegram-bot-api";
import {errorPlaceholder, logError, oldSendMessage} from "../util/utils";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Environment} from "../common/environment";
export class Ae extends Command {
argsMode = "required" as const;
title = "/ae";
description = "evaluation";
command = ["ae"];
title = Environment.commandTitles.ae;
description = Environment.commandDescriptions.ae;
requirements = Requirements.Build(Requirement.BOT_CREATOR);
async execute(msg: Message, params?: RegExpExecArray) {
const match = params?.[3];
const match = params?.[3] || "";
try {
let e = eval(match);
e = ((typeof e == "string") ? e : JSON.stringify(e));
await oldSendMessage(msg, e).catch(async () => await errorPlaceholder(msg));
} catch (e) {
const text = e.message.toString();
let result = this.executeEvaluation(match);
await oldSendMessage(msg, result).catch(async () => await errorPlaceholder(msg));
} catch (e: unknown) {
const error = e instanceof Error ? e : new Error(String(e));
const text = error.message.toString();
if (text.includes("is not defined")) {
await oldSendMessage(msg, "variable is not defined").catch(logError);
await oldSendMessage(msg, Environment.variableNotDefinedText).catch(logError);
return;
}
logError(`${text}
* Stacktrace: ${e.stack}`);
* Stacktrace: ${error.stack}`);
await oldSendMessage(msg, text).catch(logError);
}
}
executeEvaluation(evaluation: string): string {
try {
let e = eval(evaluation);
e = ((typeof e == "string") ? e : JSON.stringify(e));
return e;
} catch (e: unknown) {
const error = e instanceof Error ? e : new Error(String(e));
const text = error.message.toString();
if (text.includes("is not defined")) {
return Environment.evaluationVariableNotDefinedText;
}
logError(`${text}
* Stacktrace: ${error.stack}`);
return text;
}
}
}
+13 -9
View File
@@ -5,10 +5,11 @@ import {Message} from "typescript-telegram-bot-api";
import {bot, botUser} from "../index";
import {fullName, logError, oldSendMessage, oldReplyToMessage} from "../util/utils";
import {Environment} from "../common/environment";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
export class Ban extends Command {
title = "/ban [reply]";
description = "ban user from chat";
title = Environment.commandTitles.ban;
description = Environment.commandDescriptions.ban;
requirements = Requirements.Build(
Requirement.BOT_ADMIN,
@@ -19,32 +20,35 @@ export class Ban extends Command {
);
async execute(msg: Message) {
if (!msg.reply_to_message) return;
if (!msg.reply_to_message || !msg.from || ! msg.reply_to_message.from) return;
const user = msg.reply_to_message.from;
const userId = user.id;
if (userId === botUser.id) {
await oldReplyToMessage(msg, "Используй /leave").catch(logError);
await oldReplyToMessage(msg, Environment.useLeaveCommandText).catch(logError);
return;
}
if (userId === Environment.CREATOR_ID) {
await oldReplyToMessage(msg, "Бот не будет банить своего создателя.").catch(logError);
await oldReplyToMessage(msg, Environment.botWillNotBanCreatorText).catch(logError);
return;
}
if (msg.from.id !== Environment.CREATOR_ID && Environment.ADMIN_IDS.has(userId)) {
await oldReplyToMessage(msg, "Бот не будет банить своих администраторов.").catch(logError);
await oldReplyToMessage(msg, Environment.botWillNotBanAdminsText).catch(logError);
return;
}
bot.banChatMember({chat_id: msg.chat.id, user_id: userId})
enqueueTelegramApiCall(
() => bot.banChatMember({chat_id: msg.chat.id, user_id: userId}),
{method: "banChatMember", chatId: msg.chat.id, chatType: msg.chat.type}
)
.then(async () => {
await oldSendMessage(msg, `${fullName(user)} забанен 🚫`).catch(logError);
await oldSendMessage(msg, Environment.getUserBannedText(fullName(user))).catch(logError);
})
.catch(async () => {
await oldSendMessage(msg, `Не смог забанить ${fullName(user)} ☹️`).catch(logError);
await oldSendMessage(msg, Environment.getUserBanFailedText(fullName(user))).catch(logError);
});
}
}
+18 -5
View File
@@ -1,18 +1,23 @@
import {Command} from "../base/command";
import {Message} from "typescript-telegram-bot-api";
import {logError, oldReplyToMessage, randomValue} from "../util/utils";
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
import {Environment} from "../common/environment";
import {appLogger} from "../logging/logger";
const logger = appLogger.child("command:choice");
export class Choice extends Command {
command = "choice";
argsMode = "required" as const;
title = "/choice a, b, ..., c";
description = "Выбор случайного значения";
title = Environment.commandTitles.choice;
description = Environment.commandDescriptions.choice;
async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
console.log("match", match);
logger.debug("execute", {chatId: msg.chat?.id, messageId: msg.message_id, match});
const payload = match[3];
const payload = match?.[3] || "";
const re =
/\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^,]+?))\s*(?:,|$)/g;
@@ -33,7 +38,15 @@ export class Choice extends Command {
}
const random = randomValue(out);
if (!random) {
await oldReplyToMessage(msg, Environment.noChoicesText).catch(logError);
return;
}
await oldReplyToMessage(msg, `Выбрал *${random}*`, "Markdown").catch(logError);
await oldReplyToMessage(
msg,
Environment.getChoiceText(prepareTelegramMarkdownV2(random, {mode: "final"})),
"MarkdownV2"
).catch(logError);
}
}
+6 -4
View File
@@ -1,13 +1,15 @@
import {Command} from "../base/command";
import {Message} from "typescript-telegram-bot-api";
import {getRangedRandomInt, logError, oldReplyToMessage} from "../util/utils";
import {Environment} from "../common/environment";
export class Coin extends Command {
title = "/coin";
description = "Heads or tails";
title = Environment.commandTitles.coin;
description = Environment.commandDescriptions.coin;
async execute(msg: Message): Promise<void> {
const random = getRangedRandomInt(0, 2);
const headsOrTails = random === 1 ? "Выпал *Орёл* 🪙" : "Выпала *Решка* 🪙";
await oldReplyToMessage(msg, headsOrTails, "Markdown").catch(logError); }
const headsOrTails = Environment.getCoinResultText(random === 1 ? Environment.coinHeadsText : Environment.coinTailsText) + " 🪙";
await oldReplyToMessage(msg, headsOrTails, "Markdown").catch(logError);
}
}
+3 -2
View File
@@ -3,10 +3,11 @@ import {Message} from "typescript-telegram-bot-api";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {logError, replyToMessage} from "../util/utils";
import {Environment} from "../common/environment";
export class Debug extends Command {
title = "/debug";
description = "Returns msg (or reply) as json";
title = Environment.commandTitles.debug;
description = Environment.commandDescriptions.debug;
requirements = Requirements.Build(Requirement.BOT_ADMIN);
+19 -14
View File
@@ -2,26 +2,31 @@ import {Command} from "../base/command";
import {Message} from "typescript-telegram-bot-api";
import {logError, randomValue} from "../util/utils";
import {bot} from "../index";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {Environment} from "../common/environment";
type DiceEmoji = "🎲" | "🎯" | "🏀" | "⚽" | "🎳" | "🎰";
const emojis = ["🎲", "🎯", "🏀", "⚽", "🎳", "🎰"];
const emojis: readonly DiceEmoji[] = ["🎲", "🎯", "🏀", "⚽", "🎳", "🎰"];
export class Dice extends Command {
title = "/dice";
description = "Sends random or specific dice";
title = Environment.commandTitles.dice;
description = Environment.commandDescriptions.dice;
async execute(msg: Message): Promise<void> {
const split = msg.text.split("/dice ");
const secondPart = split[1]?.trim();
const emojiIndex = emojis.indexOf(secondPart);
const emojiToDice: DiceEmoji = (emojiIndex >= 0 ? emojis[emojiIndex] : randomValue(emojis)) as DiceEmoji;
const split = msg.text?.split("/dice ");
const secondPart = split?.[1]?.trim() || "";
const requestedEmoji = secondPart as DiceEmoji;
const emojiToDice = emojis.includes(requestedEmoji) ? requestedEmoji : randomValue(emojis) ?? "🎲";
await bot.sendDice({
chat_id: msg.chat.id,
emoji: emojiToDice,
reply_parameters: {
message_id: msg.message_id
}
}).catch(logError);
await enqueueTelegramApiCall(
() => bot.sendDice({
chat_id: msg.chat.id,
emoji: emojiToDice,
reply_parameters: {
message_id: msg.message_id
}
}),
{method: "sendDice", chatId: msg.chat.id, chatType: msg.chat.type}
).catch(logError);
}
}
+21 -13
View File
@@ -2,13 +2,15 @@ import {Command} from "../base/command";
import {Message} from "typescript-telegram-bot-api";
import {downloadTelegramFile, extractImageFileId, logError, oldReplyToMessage, waveDistortSharp} from "../util/utils";
import {bot} from "../index";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {Environment} from "../common/environment";
export class Distort extends Command {
command = "distort";
argsMode = "optional" as const;
title = "/distort [amp] [wavelength]";
description = "Distortion of picture";
title = Environment.commandTitles.distort;
description = Environment.commandDescriptions.distort;
async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
const chatId = msg.chat.id;
@@ -17,7 +19,7 @@ export class Distort extends Command {
if (!reply) {
await oldReplyToMessage(
msg,
"Ответь командой /distort на сообщение с картинкой (фото, документ или стикер).\n" + "Пример: /distort 16 80"
Environment.distortReplyInstructionText
);
return;
}
@@ -26,7 +28,7 @@ export class Distort extends Command {
if (!fileId) {
await oldReplyToMessage(
msg,
"В реплае не вижу картинку. Пришли фото или файл-изображение."
Environment.distortMissingImageText
);
return;
}
@@ -37,7 +39,10 @@ export class Distort extends Command {
const wavelength = b ? Number(b) : 72;
try {
await bot.sendChatAction({chat_id: chatId, action: "upload_photo"});
await enqueueTelegramApiCall(
() => bot.sendChatAction({chat_id: chatId, action: "upload_photo"}),
{method: "sendChatAction", chatId, chatType: msg.chat.type}
);
const file = await bot.getFile({file_id: fileId});
if (!file.file_path) {
@@ -47,16 +52,19 @@ export class Distort extends Command {
const inputBuf = await downloadTelegramFile(file.file_path);
const outBuf = await waveDistortSharp(inputBuf, amp, wavelength);
const outBuf = await waveDistortSharp(<Buffer>inputBuf, amp, wavelength);
await bot.sendPhoto({
chat_id: chatId,
photo: outBuf,
caption: `Искажение готово ✅ (amp=${amp}, wavelength=${wavelength})`,
});
} catch (e) {
await enqueueTelegramApiCall(
() => bot.sendPhoto({
chat_id: chatId,
photo: outBuf,
caption: Environment.getDistortionReadyCaption(amp, wavelength),
}),
{method: "sendPhoto", chatId, chatType: msg.chat.type}
);
} catch (e: unknown) {
await oldReplyToMessage(
msg, `Не получилось исказить изображение: ${e?.message ?? String(e)}`
msg, Environment.getDistortFailedText(e)
).catch(logError);
}
}
+10 -8
View File
@@ -6,6 +6,7 @@ import {Environment} from "../common/environment";
import fs from "node:fs";
import {logError, replyToMessage, sendErrorPlaceholder} from "../util/utils";
import {bot} from "../index";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
export class ExportDb extends Command {
@@ -23,16 +24,17 @@ export class ExportDb extends Command {
}
try {
const buffer = fs.readFileSync(fullPath);
await bot.sendDocument({
chat_id: Environment.CREATOR_ID,
document: new FileOptions(buffer, {filename: "database.db", contentType: "application/sql"}),
caption: "Бэкап базы данных",
});
await enqueueTelegramApiCall(
() => bot.sendDocument({
chat_id: Environment.CREATOR_ID,
document: new FileOptions(fs.createReadStream(fullPath), {filename: "database.db", contentType: "application/sql"}),
caption: Environment.databaseBackupCaption,
}),
{method: "sendDocument", chatId: Environment.CREATOR_ID, chatType: "private"}
);
if (msg.chat.id !== Environment.CREATOR_ID) {
await replyToMessage({message: msg, text: "Успешно отправлено в ЛС создателю!"});
await replyToMessage({message: msg, text: Environment.databaseBackupSentText});
}
} catch (e) {
logError(e);
+8 -175
View File
@@ -1,188 +1,21 @@
import {Message} from "typescript-telegram-bot-api";
import {Environment} from "../common/environment";
import {bot, googleAi} from "../index";
import {MessageStore} from "../common/message-store";
import {ChatCommand} from "../base/chat-command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {ApiError} from "@google/genai";
import {
collectReplyChainText,
escapeMarkdownV2Text,
logError,
oldReplyToMessage, replyToMessage,
startIntervalEditor
} from "../util/utils";
import {ChatCommand} from "../base/chat-command";
import {AiProvider} from "../model/ai-provider";
import {runUnifiedAi} from "../ai/unified-ai-runner";
import {Environment} from "../common/environment";
export class GeminiChat extends ChatCommand {
command = "gemini";
command = ["gemini", "gemini-chat"];
argsMode = "required" as const;
requirements = Requirements.Build(Requirement.BOT_CREATOR);
title = "/gemini";
description = "Chat with AI (Gemini)";
title = Environment.commandTitles.geminiChat;
description = Environment.commandDescriptions.geminiChat;
async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
console.log("match", match);
return this.executeGemini(msg, match?.[3]);
}
async executeGemini(msg: Message, text: string): Promise<void> {
if (!text || text.trim().length === 0) 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 => {
return {
role: part.bot ? "assistant" : "user",
content: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `MESSAGE FROM USER "${part.name}":\n` : "") + part.content
};
});
chatMessages.reverse();
if (Environment.SYSTEM_PROMPT) {
chatMessages.unshift({role: "system", content: Environment.SYSTEM_PROMPT});
}
let chatContent = "";
for (const part of chatMessages) {
chatContent += `${part.role.toUpperCase()}:\n${part.content}\n\n`;
}
chatContent = chatContent.trim();
const input = [];
input.push(
{
type: "text",
text: chatContent
}
);
// TODO: 12/02/2026, Danil Nikolaev: support for multiple images
if (messageParts.some(p => p.images?.length)) {
const firstImages = messageParts.find(p => p.images?.length)?.images ?? [];
firstImages.forEach(image => {
input.push({
type: "image",
data: image,
mime_type: "image/png"
});
});
}
let waitMessage: Message;
const startTime = Date.now();
try {
waitMessage = await bot.sendMessage({
chat_id: chatId,
text: Environment.waitThinkText,
reply_parameters: {
chat_id: chatId,
message_id: msg.message_id
}
});
const stream = await googleAi.interactions.create({
model: Environment.GEMINI_MODEL,
input: input,
stream: true
});
let currentText = "";
let shouldBreak = false;
const editor = startIntervalEditor({
intervalMs: 4500,
getText: () => currentText,
editFn: async (text) => {
await bot.editMessageText(
{
chat_id: chatId,
message_id: waitMessage.message_id,
text: escapeMarkdownV2Text(text),
parse_mode: "MarkdownV2"
}
).catch(logError);
console.log("editMessageText", text);
waitMessage.reply_to_message = msg;
waitMessage.text = text;
await MessageStore.put(waitMessage);
},
onStop: async () => {
}
});
await editor.tick();
try {
for await (const event of stream) {
switch (event.event_type) {
case "content.delta":
switch (event.delta?.type) {
case "text": {
const text = event.delta.text;
currentText += text;
if (currentText.length > 4096) {
currentText = currentText.slice(0, 4093) + "...";
shouldBreak = true;
}
console.log("messageText", currentText);
console.log("length", currentText.length);
if (shouldBreak) {
console.log("break", true);
break;
}
break;
}
case "image": {
const image = event.delta.data;
console.log("image", image);
}
}
}
}
} finally {
await editor.tick();
await editor.stop();
if (!shouldBreak) {
console.log("ended", true);
}
const diff = Math.abs(Date.now() - startTime) / 1000.0;
console.log("time", diff);
waitMessage.reply_to_message = msg;
waitMessage.text = currentText;
await MessageStore.put(waitMessage);
if (Environment.SEND_TIME_TOOK) {
await replyToMessage({message: waitMessage, text: `⏱️ ${diff}s`});
}
}
} catch (error) {
logError(error);
if (error instanceof ApiError) {
if (error.status === 429) {
await oldReplyToMessage(waitMessage, "На сегодня всё, лимиты закончились.").catch(logError);
return;
}
}
await oldReplyToMessage(waitMessage, `Произошла ошибка!\n${error.toString()}`).catch(logError);
}
await runUnifiedAi({provider: AiProvider.GEMINI, msg: msg, text: match?.[3] ?? "", stream: true});
}
}
-60
View File
@@ -1,60 +0,0 @@
import {Command} from "../base/command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {googleAi} from "../index";
import {logError, replyToMessage} from "../util/utils";
import {Environment} from "../common/environment";
export class GeminiGenerateImage extends Command {
command = "geminiGenImage";
argsMode = "required" as const;
title = "/geminiGenImage";
description = "Generate image with Gemini";
requirements = Requirements.Build(Requirement.BOT_CREATOR);
async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
console.log("match", match);
const prompt = match?.[3];
return this.executeGenImage(msg, prompt);
}
async executeGenImage(msg: Message, text: string): Promise<void> {
if (!text || text.trim().length === 0) return;
let waitMessage: Message;
try {
waitMessage = await replyToMessage({
message: msg,
text: Environment.genImageText,
});
const interaction = await googleAi.interactions.create({
model: Environment.GEMINI_IMAGE_MODEL,
response_modalities: ["image"],
input: text,
});
interaction.outputs?.forEach((output, index) => {
if (output.type === "image") {
// const image = output.data;
console.log(`Image output ${index + 1}:`, output);
} else {
console.log(`Output ${index + 1}: ${output}`);
}
});
} catch (e) {
logError(e);
await replyToMessage({
message: waitMessage,
text: `Произошла ошибка!\n${e.toString()}`,
link_preview_options: {is_disabled: true}
}).catch(logError);
}
}
}
+9 -28
View File
@@ -1,32 +1,13 @@
import {Command} from "../base/command";
import {Message} from "typescript-telegram-bot-api";
import {logError, replyToMessage} from "../util/utils";
import {Environment} from "../common/environment";
import {googleAi} from "../index";
import {AiModelCapabilities} from "../model/ai-model-capabilities";
import {AiProvider} from "../model/ai-provider";
import {ProviderGetModelCommand} from "./provider-model-command";
export class GeminiGetModel extends Command {
title = "/geminiGetModel";
description = "Get current Gemini model";
async execute(msg: Message): Promise<void> {
await replyToMessage({message: msg, text: `Текущая модель: "${Environment.GEMINI_MODEL}"`}).catch(logError);
}
async getModelCapabilities(): Promise<AiModelCapabilities | null> {
try {
const info = await googleAi.models.get({model: Environment.GEMINI_MODEL});
console.log(info);
return {
vision: {supported: true},
ocr: null,
thinking: {supported: info.thinking},
tools: null
};
} catch (e) {
logError(e);
return null;
}
export class GeminiGetModel extends ProviderGetModelCommand {
constructor() {
super({
provider: AiProvider.GEMINI,
title: Environment.commandTitles.geminiGetModel,
description: Environment.commandDescriptions.geminiGetModel,
});
}
}
+10 -33
View File
@@ -1,36 +1,13 @@
import {Command} from "../base/command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {googleAi} from "../index";
import {logError, replyToMessage} from "../util/utils";
import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {ProviderListModelsCommand} from "./provider-model-command";
export class GeminiListModels extends Command {
title = "/geminiListModels";
description = "List all Gemini models";
requirements = Requirements.Build(Requirement.BOT_CREATOR);
async execute(msg: Message): Promise<void> {
try {
const listResponse = await googleAi.models.list();
console.log(listResponse);
const modelsString = listResponse.page
.sort((a, b) => a.name.localeCompare(b.name))
.map(e => `${e.name}`)
.join("\n");
const text = "Доступные модели:\n\n" + "<blockquote expandable>" + modelsString + "</blockquote>";
await replyToMessage({
message: msg,
text: text,
parse_mode: "HTML"
});
} catch (e) {
logError(e);
await replyToMessage({message: msg, text: "Не получилось загрузить список моделей"}).catch(logError);
}
export class GeminiListModels extends ProviderListModelsCommand {
constructor() {
super({
provider: AiProvider.GEMINI,
title: Environment.commandTitles.geminiListModels,
description: Environment.commandDescriptions.geminiListModels,
});
}
}
+9 -21
View File
@@ -1,25 +1,13 @@
import {Command} from "../base/command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {Environment} from "../common/environment";
import {logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {ProviderSetModelCommand} from "./provider-model-command";
export class GeminiSetModel extends Command {
argsMode = "required" as const;
title = "/geminiSetModel";
description = "Set Gemini model";
requirements = Requirements.Build(Requirement.BOT_CREATOR);
async execute(msg: Message, match?: RegExpExecArray | null): Promise<void> {
const newModel = match?.[3];
Environment.setGeminiModel(newModel || Environment.GEMINI_MODEL);
const text = newModel ? `Выбрана модель "${newModel}"`
: `Модель не задана. Будет использоваться стандартная модель "${Environment.GEMINI_MODEL}".`;
await replyToMessage({message: msg, text: text}).catch(logError);
export class GeminiSetModel extends ProviderSetModelCommand {
constructor() {
super({
provider: AiProvider.GEMINI,
title: Environment.commandTitles.geminiSetModel,
description: Environment.commandDescriptions.geminiSetModel,
});
}
}
+7 -5
View File
@@ -3,15 +3,17 @@ import {chatCommandToString, delay, logError, sendMessage} from "../util/utils";
import {Command} from "../base/command";
import {commands} from "../index";
import {TelegramError} from "typescript-telegram-bot-api/dist/errors";
import {Environment} from "../common/environment";
export class Help extends Command {
command = ["h", "help"];
title = "/help";
description = "Show list of commands";
title = Environment.commandTitles.help;
description = Environment.commandDescriptions.help;
async execute(msg: Message) {
let text = "Commands:\n\n";
if (!msg.from) return;
let text = Environment.commandsHeaderText;
commands.forEach(c => {
text += `${chatCommandToString(c)}\n`;
@@ -20,7 +22,7 @@ export class Help extends Command {
await sendMessage({chat_id: msg.from.id, text: text})
.then(async () => {
if (msg.chat.type !== "private") {
await sendMessage({message: msg, text: "Отправил команды в ЛС 😎"}).catch(logError);
await sendMessage({message: msg, text: Environment.sentCommandsInDmText}).catch(logError);
}
})
.catch(async (e) => {
@@ -28,7 +30,7 @@ export class Help extends Command {
if (e.response?.error_code === 403) {
await sendMessage({
message: msg,
text: "Не смог отправить команды в ЛС ☹️\nТогда отправлю сюда"
text: Environment.couldNotSendCommandsInDmText
}).catch(logError);
await delay(1000);
+8 -8
View File
@@ -1,17 +1,17 @@
import {Command} from "../base/command";
import {Message} from "typescript-telegram-bot-api";
import {logError, oldReplyToMessage} from "../util/utils";
import {Environment} from "../common/environment";
export class Id extends Command {
title = "/id";
description = "ID of chat, user and reply (if replied to any message)";
title = Environment.commandTitles.id;
description = Environment.commandDescriptions.id;
async execute(msg: Message): Promise<void> {
let text = `chat id: \n\`\`\`${msg.chat.id}\`\`\` \nfrom id: \n\`\`\`${msg.from.id}\`\`\``;
if (msg.reply_to_message) {
text += ` \nreply id: \n\`\`\`${msg.reply_to_message.from.id}\`\`\``;
}
await oldReplyToMessage(msg, text, "MarkdownV2").catch(logError);
await oldReplyToMessage(
msg,
Environment.getIdText(msg.chat.id, msg.from?.id, msg.reply_to_message?.from?.id),
"MarkdownV2",
).catch(logError);
}
}
+7 -7
View File
@@ -7,8 +7,8 @@ import {botUser} from "../index";
import {Environment} from "../common/environment";
export class Ignore extends Command {
title = "/ignore";
description = "Bot will ignore user";
title = Environment.commandTitles.ignore;
description = Environment.commandDescriptions.ignore;
requirements = Requirements.Build(
Requirement.BOT_ADMIN,
@@ -19,25 +19,25 @@ export class Ignore extends Command {
);
async execute(msg: Message) {
if (!msg.reply_to_message) return;
if (!msg.reply_to_message || !msg.reply_to_message.from) return;
const id = msg.reply_to_message.from.id;
const text = fullName(msg.reply_to_message.from);
if (id === botUser.id) {
await oldSendMessage(msg, "Бот не может сам себя игнорировать").catch(logError);
await oldSendMessage(msg, Environment.botWillNotIgnoreItselfText).catch(logError);
return;
}
if (id === Environment.CREATOR_ID) {
await oldSendMessage(msg, "Бот не будет игнорировать своего создателя").catch(logError);
await oldSendMessage(msg, Environment.botWillNotIgnoreCreatorText).catch(logError);
return;
}
if (await Environment.addMute(id)) {
await oldSendMessage(msg, text + " в муте! 🔇").catch(logError);
await oldSendMessage(msg, Environment.getUserIgnoredText(text)).catch(logError);
} else {
await oldSendMessage(msg, text + " уже в муте 🤔").catch(logError);
await oldSendMessage(msg, Environment.getUserAlreadyIgnoredText(text)).catch(logError);
}
}
}
+54 -50
View File
@@ -2,66 +2,70 @@ import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {callbackCommands, commands} from "../index";
import {Environment} from "../common/environment";
import {boolToEmoji, getCurrentModel, getCurrentModelCapabilities, logError, replyToMessage} from "../util/utils";
import {AiModelCapabilities} from "../model/ai-model-capabilities";
import {logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {Command} from "../base/command";
import {getProviderTools} from "../ai/tool-mappers";
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
import {resolveEffectiveAiProviderForUser} from "../common/user-ai-settings";
import {getFormattedCapabilities} from "../ai/provider-model-runtime";
export class Info extends Command {
command = ["info", "v"];
title = "/info";
description = "Info about bot";
title = Environment.commandTitles.info;
description = Environment.commandDescriptions.info;
async execute(msg: Message): Promise<void> {
const aiProvider = Environment.DEFAULT_AI_PROVIDER;
const aiModel = getCurrentModel();
let aiModelCapabilities: AiModelCapabilities = {};
if (!msg.from) return;
try {
aiModelCapabilities = await getCurrentModelCapabilities();
} catch (e) {
logError(e);
await replyToMessage({message: msg, text: `Произошла ошибка: ${e}`}).catch(logError);
return;
}
const getToolsInfo = async () => {
const tools = getProviderTools(provider);
return Environment.getInfoToolsBlockText(tools.map(t => t.function.name));
};
const getCommandsInfo = async () => {
const cmds = commands.filter(c => !(c instanceof ChatCommand));
const chatCmds = commands.filter(c => c instanceof ChatCommand);
const callbackCmds = callbackCommands;
const publicCmdsLength = cmds.filter(c => c.requirements?.isPublic()).length;
const privateCmdsLength = cmds.length - publicCmdsLength;
const chatCmdsLength = chatCmds.length;
const callbackCmdsLength = callbackCmds.length;
return Environment.getInfoCommandsBlockText({
publicCommands: publicCmdsLength,
privateCommands: privateCmdsLength,
chatCommands: chatCmdsLength,
callbackCommands: callbackCmdsLength,
});
};
const provider = await resolveEffectiveAiProviderForUser(msg.from.id);
// const aiProvidersLength = Object.keys(AiProvider).filter(key => isNaN(Number(key))).length;
const aiProviders = Object.keys(AiProvider).map(p => p.toLowerCase());
const finalText = [
`\`\`\`${Environment.runtimeProviderLabelText}`,
`${Environment.infoSupportedProvidersLabelText}: ${aiProviders.join(", ")}`,
`${Environment.runtimeProviderCurrentLabelText}: ${provider.toLowerCase()}`,
"```",
"",
`\`\`\`${Environment.runtimeCapabilitiesLabelText}`,
(await getFormattedCapabilities(provider)).join("\n"),
"```",
"",
await getToolsInfo(),
await getCommandsInfo()
].join("\n");
const aiInfo = "```" +
"AI\n" +
`supported providers: ${Object.keys(AiProvider).filter(key => isNaN(Number(key))).length}\n\n` +
`provider: ${aiProvider.toLowerCase()}\n` +
`model: ${aiModel}\n\n` +
`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)}` +
"```";
const cmds = commands.filter(c => !(c instanceof ChatCommand));
const chatCmds = commands.filter(c => c instanceof ChatCommand);
const callbackCmds = callbackCommands;
const publicCmdsLength = cmds.filter(c => c.requirements?.isPublic()).length;
const privateCmdsLength = cmds.length - publicCmdsLength;
const chatCmdsLength = chatCmds.length;
const callbackCmdsLength = callbackCmds.length;
const text =
aiInfo + "\n\n" +
"```" +
"Commands\n" +
`Public: ${publicCmdsLength}\n` +
`Private: ${privateCmdsLength}\n` +
`Chat: ${chatCmdsLength}\n` +
`Callback: ${callbackCmdsLength}\n` +
"```"
;
await replyToMessage({message: msg, text: text, parse_mode: "Markdown"}).catch(logError);
await replyToMessage({
message: msg,
text: prepareTelegramMarkdownV2(finalText, {mode: "final"}),
parse_mode: "MarkdownV2"
}).catch(logError);
}
}
+8 -3
View File
@@ -3,10 +3,12 @@ import {Message} from "typescript-telegram-bot-api";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {bot} from "../index";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {Environment} from "../common/environment";
export class Leave extends Command {
title = "/leave";
description = "Bot will leave current chat";
title = Environment.commandTitles.leave;
description = Environment.commandDescriptions.leave;
requirements = Requirements.Build(
Requirement.BOT_ADMIN,
@@ -14,6 +16,9 @@ export class Leave extends Command {
);
async execute(msg: Message): Promise<void> {
await bot.leaveChat({chat_id: msg.chat.id});
await enqueueTelegramApiCall(
() => bot.leaveChat({chat_id: msg.chat.id}),
{method: "leaveChat", chatId: msg.chat.id, chatType: msg.chat.type}
);
}
}
+8 -167
View File
@@ -1,180 +1,21 @@
import {Message} from "typescript-telegram-bot-api";
import {ChatCommand} from "../base/chat-command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {
collectReplyChainText,
escapeMarkdownV2Text,
logError,
oldReplyToMessage,
replyToMessage,
startIntervalEditor
} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {runUnifiedAi} from "../ai/unified-ai-runner";
import {Environment} from "../common/environment";
import {bot, commands, mistralAi} from "../index";
import {MessageStore} from "../common/message-store";
import {ChatCommand} from "../base/chat-command";
import {MistralGetModel} from "./mistral-get-model";
export class MistralChat extends ChatCommand {
command = "mistral";
command = ["mistral", "mistral-chat"];
argsMode = "required" as const;
requirements = Requirements.Build(Requirement.BOT_CREATOR);
title = "/mistral";
description = "Chat with AI (Mistral)";
title = Environment.commandTitles.mistralChat;
description = Environment.commandDescriptions.mistralChat;
async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
console.log("match", match);
return this.executeMistral(msg, match?.[3]);
}
async executeMistral(msg: Message, text: string): Promise<void> {
if (!text || text.trim().length === 0) 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 => {
const content = [];
content.push({
type: "text",
text: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `MESSAGE FROM USER "${part.name}":\n` : "") + part.content,
});
for (const image of part.images) {
content.push({
type: "image_url",
imageUrl: "data:image/jpeg;base64," + image
});
}
return {
role: part.bot ? "assistant" : "user",
content: content,
};
});
chatMessages.reverse();
if (Environment.SYSTEM_PROMPT) {
chatMessages.unshift({role: "system", content: [{type: "text", text: Environment.SYSTEM_PROMPT}]});
}
let waitMessage: Message;
const startTime = Date.now();
try {
const imagesCount = chatMessages.reduce((total, curr) => {
return total + (curr.content.filter(c => c.type === "image_url")?.length ?? 0);
}, 0);
if (imagesCount) {
try {
const modelInfo = await commands.find(c => c instanceof MistralGetModel).getModelCapabilities();
if (modelInfo) {
if (!modelInfo.vision?.supported) {
await replyToMessage({
message: msg,
text: "Моя текущая модель не умеет анализировать изображения 🥹"
});
return;
}
}
} catch (e) {
logError(e);
}
}
waitMessage = await bot.sendMessage({
chat_id: chatId,
text: imagesCount ?
imagesCount > 1 ? Environment.analyzingPicturesText : Environment.analyzingPictureText
: Environment.waitThinkText,
reply_parameters: {
chat_id: chatId,
message_id: msg.message_id
}
});
const stream = await mistralAi.chat.stream({
model: Environment.MISTRAL_MODEL,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
messages: chatMessages as any
});
let currentText = "";
let shouldBreak = false;
const editor = startIntervalEditor({
intervalMs: 4500,
getText: () => currentText,
editFn: async (text) => {
await bot.editMessageText(
{
chat_id: chatId,
message_id: waitMessage.message_id,
text: escapeMarkdownV2Text(text),
parse_mode: "MarkdownV2"
}
).catch(logError);
console.log("editMessageText", text);
waitMessage.reply_to_message = msg;
waitMessage.text = text;
await MessageStore.put(waitMessage);
},
onStop: async () => {
}
});
await editor.tick();
try {
for await (const chunk of stream) {
console.log("chunk", chunk);
const text = chunk.data.choices[0].delta.content;
currentText += text;
if (currentText.length > 4096) {
currentText = currentText.slice(0, 4093) + "...";
shouldBreak = true;
}
console.log("messageText", currentText);
console.log("length", currentText.length);
if (shouldBreak) {
console.log("break", true);
break;
}
}
} finally {
await editor.tick();
await editor.stop();
if (!shouldBreak) {
console.log("ended", true);
}
const diff = Math.abs(Date.now() - startTime) / 1000.0;
console.log("time", diff);
waitMessage.reply_to_message = msg;
waitMessage.text = currentText;
await MessageStore.put(waitMessage);
if (Environment.SEND_TIME_TOOK) {
await replyToMessage({message: waitMessage, text: `⏱️ ${diff}s`});
}
}
} catch (error) {
logError(error);
await oldReplyToMessage(waitMessage, `Произошла ошибка!\n${error.toString()}`).catch(logError);
}
await runUnifiedAi({provider: AiProvider.MISTRAL, msg: msg, text: match?.[3] ?? "", stream: true});
}
}
+9 -32
View File
@@ -1,36 +1,13 @@
import {Command} from "../base/command";
import {Message} from "typescript-telegram-bot-api";
import {logError, replyToMessage} from "../util/utils";
import {Environment} from "../common/environment";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {mistralAi} from "../index";
import {AiModelCapabilities} from "../model/ai-model-capabilities";
import {AiProvider} from "../model/ai-provider";
import {ProviderGetModelCommand} from "./provider-model-command";
export class MistralGetModel extends Command {
title = "/mistralGetModel";
description = "Get current Mistral model";
requirements = Requirements.Build(Requirement.BOT_CREATOR);
async execute(msg: Message): Promise<void> {
await replyToMessage({message: msg, text: `Текущая модель: "${Environment.MISTRAL_MODEL}"`}).catch(logError);
}
async getModelCapabilities(): Promise<AiModelCapabilities | null> {
try {
const info = await mistralAi.models.retrieve({modelId: Environment.MISTRAL_MODEL});
console.log(info);
return {
vision: {supported: info.capabilities.vision},
ocr: {supported: info.capabilities.ocr},
thinking: null,
tools: {supported: info.capabilities.functionCalling}
};
} catch (e) {
logError(e);
return null;
}
export class MistralGetModel extends ProviderGetModelCommand {
constructor() {
super({
provider: AiProvider.MISTRAL,
title: Environment.commandTitles.mistralGetModel,
description: Environment.commandDescriptions.mistralGetModel,
});
}
}
+10 -33
View File
@@ -1,36 +1,13 @@
import {Command} from "../base/command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {mistralAi} from "../index";
import {logError, oldReplyToMessage, replyToMessage} from "../util/utils";
import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {ProviderListModelsCommand} from "./provider-model-command";
export class MistralListModels extends Command {
title = "/mistralListModels";
description = "List all Mistral models";
requirements = Requirements.Build(Requirement.BOT_CREATOR);
async execute(msg: Message): Promise<void> {
try {
const listResponse = await mistralAi.models.list();
console.log(listResponse);
const modelsString = listResponse.data
.sort((a, b) => a.name.localeCompare(b.name))
.map(e => `${e.id}`)
.join("\n");
const text = "Доступные модели:\n\n" + "<blockquote expandable>" + modelsString + "</blockquote>";
await replyToMessage({
message: msg,
text: text,
parse_mode: "HTML"
});
} catch (e) {
logError(e);
await oldReplyToMessage(msg, "Не получилось загрузить список моделей").catch(logError);
}
export class MistralListModels extends ProviderListModelsCommand {
constructor() {
super({
provider: AiProvider.MISTRAL,
title: Environment.commandTitles.mistralListModels,
description: Environment.commandDescriptions.mistralListModels,
});
}
}
+9 -21
View File
@@ -1,25 +1,13 @@
import {Command} from "../base/command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {Environment} from "../common/environment";
import {logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {ProviderSetModelCommand} from "./provider-model-command";
export class MistralSetModel extends Command {
argsMode = "required" as const;
title = "/mistralSetModel";
description = "Set Mistral model";
requirements = Requirements.Build(Requirement.BOT_CREATOR);
async execute(msg: Message, match?: RegExpExecArray | null): Promise<void> {
const newModel = match?.[3];
Environment.setMistralModel(newModel || Environment.MISTRAL_MODEL);
const text = newModel ? `Выбрана модель "${newModel}"`
: `Модель не задана. Будет использоваться стандартная модель "${Environment.MISTRAL_MODEL}".`;
await replyToMessage({message: msg, text: text}).catch(logError);
export class MistralSetModel extends ProviderSetModelCommand {
constructor() {
super({
provider: AiProvider.MISTRAL,
title: Environment.commandTitles.mistralSetModel,
description: Environment.commandDescriptions.mistralSetModel,
});
}
}
+16 -239
View File
@@ -1,250 +1,27 @@
import {Message} from "typescript-telegram-bot-api";
import {abortOllamaRequest, bot, commands, getOllamaRequest, ollama, ollamaRequests} from "../index";
import {
collectReplyChainText,
escapeMarkdownV2Text,
logError,
oldReplyToMessage,
replyToMessage,
startIntervalEditor
} from "../util/utils";
import {Environment} from "../common/environment";
import {MessageStore} from "../common/message-store";
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 {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {AiProvider} from "../model/ai-provider";
import {runUnifiedAi} from "../ai/unified-ai-runner";
import {Environment} from "../common/environment";
export class OllamaChat extends ChatCommand {
command = ["ollamaThink", "ollama"];
command = ["ollama", "ollama-chat", "think"];
argsMode = "required" as const;
title = "/ollama";
description = "Chat with AI (Ollama)";
requirements = Requirements.Build(Requirement.BOT_CREATOR);
async execute(msg: Message, match?: RegExpExecArray | null): Promise<void> {
console.log("match", match);
return this.executeOllama(msg, match?.[3], match?.[1]?.toLowerCase()?.startsWith("ollamathink"));
}
title = Environment.commandTitles.ollamaChat;
description = Environment.commandDescriptions.ollamaChat;
async executeOllama(msg: Message, text: string, think: boolean = false): Promise<void> {
if (!text || text.trim().length === 0) 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 => {
return {
role: part.bot ? "assistant" : "user",
content: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `MESSAGE FROM USER "${part.name}":\n` : "") + part.content,
images: part.images
};
async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
await runUnifiedAi({
provider: AiProvider.OLLAMA,
msg: msg,
text: match?.[3] ?? "",
stream: true,
think: match?.[1]?.toLowerCase()?.startsWith("think")
});
chatMessages.reverse();
if (Environment.SYSTEM_PROMPT) {
chatMessages.unshift({role: "system", content: Environment.SYSTEM_PROMPT, images: []});
}
let waitMessage: Message;
const startTime = Date.now();
try {
const imagesCount = chatMessages.reduce((total, curr) => {
return total + (curr.images?.length ?? 0);
}, 0);
if (!think && imagesCount) {
try {
const modelInfo = await commands.find(c => c instanceof OllamaGetModel).loadImageModelInfo();
if (modelInfo) {
if (!modelInfo.vision?.supported) {
await replyToMessage({
message: msg,
text: "Моя текущая модель не умеет анализировать изображения 🥹"
});
return;
}
}
} catch (e) {
logError(e);
}
}
if (think) {
try {
const modelInfo = await commands.find(c => c instanceof OllamaGetModel).loadThinkModelInfo();
if (modelInfo) {
if (!modelInfo.thinking?.supported) {
await replyToMessage({
message: msg,
text: "Моя текущая модель не умеет размышлять 🥹"
});
return;
}
}
} catch (e) {
logError(e);
}
}
const uuid = crypto.randomUUID();
const cancelMarkup = {inline_keyboard: [[Cancel.withData(new OllamaCancel().data + " " + uuid).asButton()]]};
waitMessage = await replyToMessage({
message: msg,
text: (!think && imagesCount) ?
imagesCount > 1 ? Environment.analyzingPicturesText : Environment.analyzingPictureText
: Environment.waitThinkText
});
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,
});
const newRequest = {
uuid: uuid,
stream: stream,
done: false,
fromId: msg.from.id,
chatId: msg.chat.id,
};
console.log("Pushing new request", newRequest);
ollamaRequests.push(newRequest);
await bot.editMessageReplyMarkup(
{
chat_id: chatId,
message_id: waitMessage.message_id,
reply_markup: cancelMarkup
}
).catch(logError);
let currentText = "";
let shouldBreak = false;
const editor = startIntervalEditor({
uuid: uuid,
intervalMs: 4500,
getText: () => currentText,
editFn: async (text) => {
if (getOllamaRequest(uuid)?.done) return;
try {
await bot.editMessageText({
chat_id: chatId,
message_id: waitMessage.message_id,
text: escapeMarkdownV2Text(text),
parse_mode: "MarkdownV2",
reply_markup: cancelMarkup
}).catch(logError);
console.log("editMessageText", text);
waitMessage.reply_to_message = msg;
waitMessage.text = text;
await MessageStore.put(waitMessage);
} catch (e) {
logError(e);
}
}
});
await editor.tick();
try {
let isThinking = false;
for await (const chunk of stream) {
const content = chunk.message.content;
if (content === "<think>" || chunk.message.thinking) {
if (!isThinking) {
await bot.editMessageText({
chat_id: chatId,
message_id: waitMessage.message_id,
text: "🤔 Размышляю...",
parse_mode: "Markdown",
reply_markup: cancelMarkup
}).catch(logError);
}
isThinking = true;
}
if (!isThinking) {
currentText += content;
}
if (isThinking && !chunk.message.thinking) {
currentText += content;
}
if (content === "</think>" || !chunk.message.thinking) {
isThinking = false;
}
if (currentText.length > 4096) {
currentText = currentText.slice(0, 4093) + "...";
shouldBreak = true;
}
if (getOllamaRequest(uuid).done) {
shouldBreak = true;
}
if (shouldBreak || chunk.done) {
console.log("messageText", currentText);
console.log("length", currentText.length);
if (shouldBreak) {
console.log("break", true);
} else {
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 {
await bot.editMessageReplyMarkup({
chat_id: chatId,
message_id: waitMessage.message_id,
reply_markup: {inline_keyboard: []}
}).catch(logError);
}
} catch (error) {
if (error.message.toLowerCase().includes("aborted")) return;
await bot.editMessageReplyMarkup({
chat_id: chatId,
message_id: waitMessage.message_id,
reply_markup: {inline_keyboard: []}
}).catch(logError);
logError(error);
await oldReplyToMessage(waitMessage, `Произошла ошибка!\n${error.toString()}`).catch(logError);
}
}
}
+9 -108
View File
@@ -1,112 +1,13 @@
import {Command} from "../base/command";
import {Message} from "typescript-telegram-bot-api";
import {boolToEmoji, logError, replyToMessage} from "../util/utils";
import {Environment} from "../common/environment";
import {ollama} from "../index";
import {AiModelCapabilities} from "../model/ai-model-capabilities";
import {AiProvider} from "../model/ai-provider";
import {ProviderGetModelCommand} from "./provider-model-command";
export class OllamaGetModel extends Command {
title = "/ollamaGetModel";
description = "Ollama model info";
async execute(msg: Message): Promise<void> {
try {
const model = Environment.OLLAMA_MODEL;
const imageModel = Environment.OLLAMA_IMAGE_MODEL;
const thinkModel = Environment.OLLAMA_THINK_MODEL;
const promises: (Promise<AiModelCapabilities | null> | null)[] = [this.getModelCapabilities()];
if (imageModel && imageModel !== model) {
promises.push(this.loadImageModelInfo());
} else {
promises.push(null);
}
if (thinkModel && thinkModel !== model) {
promises.push(this.loadThinkModelInfo());
} else {
promises.push(null);
}
const infos = await Promise.all(promises);
let modelInfo = infos[0];
const modelText = "```Text\n" + this.getModelText(model, modelInfo) + "```";
modelInfo = infos[1];
const imageModelText = modelInfo ?
"```Image\n" + this.getModelText(imageModel, modelInfo) + "```" : null;
modelInfo = infos[2];
const thinkModelText = modelInfo ?
"```Think\n" + this.getModelText(thinkModel, modelInfo) + "```" : null;
const modelInfos = [modelText];
if (imageModelText) {
modelInfos.push(imageModelText);
}
if (thinkModelText) {
modelInfos.push(thinkModelText);
}
await replyToMessage({
message: msg,
text: modelInfos.join("\n\n"),
parse_mode: "Markdown"
}).catch(logError);
} catch (e) {
logError(e);
await replyToMessage({message: msg, text: e.toString()}).catch(logError);
}
}
private getModelText(model: string, info: AiModelCapabilities): string {
return `model: ${model}\n\n` +
`vision: ${boolToEmoji(info.vision?.supported)}\n` +
`thinking: ${boolToEmoji(info.thinking?.supported)}\n` +
`tools: ${boolToEmoji(info.tools?.supported)}`;
}
async getModelCapabilities(model: string = Environment.OLLAMA_MODEL): Promise<AiModelCapabilities | null> {
try {
const info = await ollama.show({model: model});
console.log(info);
return {
vision: {
supported: info.capabilities.includes("vision"),
external: model !== Environment.OLLAMA_MODEL,
model: model
},
ocr: {
supported: info.capabilities.includes("ocr"),
external: model !== Environment.OLLAMA_MODEL,
model: model
},
thinking: {
supported: info.capabilities.includes("thinking"),
external: model !== Environment.OLLAMA_MODEL,
model: model
},
tools: {
supported: info.capabilities.includes("tools"),
external: model !== Environment.OLLAMA_MODEL,
model: model
},
};
} catch (e) {
logError(e);
return null;
}
}
async loadImageModelInfo(): Promise<AiModelCapabilities | null> {
return this.getModelCapabilities(Environment.OLLAMA_IMAGE_MODEL);
}
async loadThinkModelInfo(): Promise<AiModelCapabilities | null> {
return this.getModelCapabilities(Environment.OLLAMA_THINK_MODEL);
export class OllamaGetModel extends ProviderGetModelCommand {
constructor() {
super({
provider: AiProvider.OLLAMA,
title: Environment.commandTitles.ollamaGetModel,
description: Environment.commandDescriptions.ollamaGetModel,
});
}
}
+10 -33
View File
@@ -1,36 +1,13 @@
import {Command} from "../base/command";
import {Message} from "typescript-telegram-bot-api";
import {ollama} from "../index";
import {logError, oldReplyToMessage, replyToMessage} from "../util/utils";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {ProviderListModelsCommand} from "./provider-model-command";
export class OllamaListModels extends Command {
title = "/ollamaListModels";
description = "List all Ollama models";
requirements = Requirements.Build(Requirement.BOT_CREATOR);
async execute(msg: Message): Promise<void> {
try {
const listResponse = await ollama.list();
console.log(listResponse);
const modelsString = listResponse.models
.sort((a, b) => a.name.localeCompare(b.name))
.map(e => `${e.model}`)
.join("\n");
const text = "Доступные модели:\n\n" + "<blockquote expandable>" + modelsString + "</blockquote>";
await replyToMessage({
message: msg,
text: text,
parse_mode: "HTML"
});
} catch (e) {
logError(e);
await oldReplyToMessage(msg, "Не получилось загрузить список моделей").catch(logError);
}
export class OllamaListModels extends ProviderListModelsCommand {
constructor() {
super({
provider: AiProvider.OLLAMA,
title: Environment.commandTitles.ollamaListModels,
description: Environment.commandDescriptions.ollamaListModels,
});
}
}
-189
View File
@@ -1,189 +0,0 @@
import {Command} from "../base/command";
import {Message} from "typescript-telegram-bot-api";
import {abortOllamaRequest, bot, getOllamaRequest, ollama, ollamaRequests} from "../index";
import {escapeMarkdownV2Text, logError, oldReplyToMessage, startIntervalEditor} from "../util/utils";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Environment} from "../common/environment";
import {Cancel} from "../callback_commands/cancel";
import {OllamaCancel} from "../callback_commands/ollama-cancel";
import {MessageStore} from "../common/message-store";
export class OllamaPrompt extends Command {
command = "ollamaPrompt";
argsMode = "required" as const;
title = "/ollamaPrompt";
description = "Custom prompt for AI (Ollama)";
requirements = Requirements.Build(Requirement.BOT_ADMIN);
async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
console.log("match", match);
return this.executeOllama(msg, match?.[3]);
}
async executeOllama(msg: Message, text: string): Promise<void> {
if (!text || text.trim().length === 0) return;
const chatId = msg.chat.id;
let waitMessage: Message;
const startTime = Date.now();
try {
const uuid = crypto.randomUUID();
const cancelMarkup = {inline_keyboard: [[Cancel.withData(new OllamaCancel().data + " " + uuid).asButton()]]};
waitMessage = await bot.sendMessage({
chat_id: chatId,
text: Environment.waitThinkText,
reply_parameters: {
chat_id: chatId,
message_id: msg.message_id
}
});
const stream = await ollama.generate({
model: Environment.OLLAMA_MODEL,
stream: true,
think: false,
prompt: text
});
const newRequest = {
uuid: uuid,
stream: stream,
done: false,
fromId: msg.from.id,
chatId: msg.chat.id,
};
console.log("Pushing new request", newRequest);
ollamaRequests.push(newRequest);
await bot.editMessageReplyMarkup(
{
chat_id: chatId,
message_id: waitMessage.message_id,
reply_markup: cancelMarkup
}
).catch(logError);
let currentText = "";
let shouldBreak = false;
const editor = startIntervalEditor({
uuid: uuid,
intervalMs: 4500,
getText: () => currentText,
editFn: async (text) => {
if (getOllamaRequest(uuid)?.done) return;
try {
await bot.editMessageText({
chat_id: chatId,
message_id: waitMessage.message_id,
text: escapeMarkdownV2Text(text),
parse_mode: "Markdown",
reply_markup: cancelMarkup
}).catch(logError);
console.log("editMessageText", text);
waitMessage.reply_to_message = msg;
waitMessage.text = text;
await MessageStore.put(waitMessage);
} catch (e) {
logError(e);
}
}
});
await editor.tick();
try {
let isThinking = false;
for await (const chunk of stream) {
const content = chunk.response;
if (content === "<think>" || chunk.thinking) {
if (!isThinking) {
await bot.editMessageText({
chat_id: chatId,
message_id: waitMessage.message_id,
text: "🤔 Размышляю...",
parse_mode: "Markdown",
}).catch(logError);
}
isThinking = true;
}
if (!isThinking) {
currentText += content;
}
if (isThinking && !chunk.thinking) {
currentText += content;
}
if (content === "</think>" || !chunk.thinking) {
isThinking = false;
}
if (currentText.length > 4096) {
currentText = currentText.slice(0, 4093) + "...";
shouldBreak = true;
}
if (getOllamaRequest(uuid).done) {
shouldBreak = true;
}
if (shouldBreak || chunk.done) {
console.log("messageText", currentText);
console.log("length", currentText.length);
if (shouldBreak) {
console.log("break", true);
} else {
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);
await oldReplyToMessage(waitMessage, `⏱️ ${diff}s`);
break;
}
}
} finally {
await bot.editMessageReplyMarkup({
chat_id: chatId,
message_id: waitMessage.message_id,
reply_markup: {inline_keyboard: []}
}).catch(logError);
}
} catch (error) {
if (error.message.toLowerCase().includes("aborted")) return;
await bot.editMessageReplyMarkup({
chat_id: chatId,
message_id: waitMessage.message_id,
reply_markup: {inline_keyboard: []}
}).catch(logError);
logError(error);
await oldReplyToMessage(waitMessage, `Произошла ошибка!\n${error.toString()}`).catch(logError);
}
}
}
+18 -27
View File
@@ -2,48 +2,39 @@ import {Command} from "../base/command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {bot, ollama} from "../index";
import {WebSearchResponse} from "../model/web-search-response";
import {oldEditMessageText, logError} from "../util/utils";
import {escapeHtml, logError, replyToMessage} from "../util/utils";
import {Environment} from "../common/environment";
import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target";
import {AiProvider} from "../model/ai-provider";
export class OllamaSearch extends Command {
command = ["s", "search"];
argsMode = "required" as const;
title = "/search";
description = "Web search via Ollama";
title = Environment.commandTitles.ollamaSearch;
description = Environment.commandDescriptions.ollamaSearch;
override requirements = Requirements.Build(Requirement.BOT_ADMIN);
async execute(msg: Message, match?: RegExpExecArray | null): Promise<void> {
console.log("match", match);
const chatId = msg.chat.id;
const query = match?.[3] || "";
if (!query || !query.length) return;
try {
const wait = await bot.sendMessage({
chat_id: chatId,
text: Environment.waitThinkText,
reply_parameters: {
chat_id: chatId,
message_id: msg.message_id
},
parse_mode: "Markdown"
const target = resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat");
const result = await createOllamaClient(target).webSearch({query, maxResults: 10});
const body = (result.results ?? [])
.map((item, index) => `${index + 1}. ${item.content}`)
.join("\n\n");
await replyToMessage({
message: msg,
text: Environment.searchResultsHeaderText + "<blockquote expandable>" + escapeHtml(body) + "</blockquote>",
parse_mode: "HTML",
});
const results = await ollama.webSearch({query: match?.[3]});
console.log("results", results);
let message = "Результаты:\n\n";
results.results.forEach((result, index) => {
const r = result as WebSearchResponse;
message += `${index + 1}. ${r.url}\n`;
});
await oldEditMessageText(chatId, wait.message_id, message);
} catch (error) {
logError(error);
await replyToMessage({message: msg, text: Environment.errorText}).catch(logError);
}
return Promise.resolve();
}
}
+9 -30
View File
@@ -1,34 +1,13 @@
import {Message} from "typescript-telegram-bot-api";
import {Command} from "../base/command";
import {Environment} from "../common/environment";
import {logError, replyToMessage} from "../util/utils";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {ollama} from "../index";
import {AiProvider} from "../model/ai-provider";
import {ProviderSetModelCommand} from "./provider-model-command";
export class OllamaSetModel extends Command {
argsMode = "required" as const;
title = "/ollamaSetModel";
description = "Set Ollama model";
requirements = Requirements.Build(Requirement.BOT_CREATOR);
async execute(msg: Message, match?: RegExpExecArray | null): Promise<void> {
const newModel = match?.[3];
try {
await ollama.show({model: newModel});
Environment.setOllamaModel(newModel || Environment.OLLAMA_MODEL);
const text = newModel ? `Выбрана модель "${newModel}"`
: `Модель не задана. Будет использоваться стандартная модель "${Environment.OLLAMA_MODEL}".`;
await replyToMessage({message: msg, text: text}).catch(logError);
} catch (e) {
logError(e);
await replyToMessage({message: msg, text: e.toString()}).catch(logError);
}
export class OllamaSetModel extends ProviderSetModelCommand {
constructor() {
super({
provider: AiProvider.OLLAMA,
title: Environment.commandTitles.ollamaSetModel,
description: Environment.commandDescriptions.ollamaSetModel,
});
}
}
+7 -153
View File
@@ -1,17 +1,10 @@
import {Message} from "typescript-telegram-bot-api";
import {MessageStore} from "../common/message-store";
import {
collectReplyChainText,
escapeMarkdownV2Text,
logError,
replyToMessage,
startIntervalEditor
} from "../util/utils";
import {Environment} from "../common/environment";
import {bot, openAi} from "../index";
import {ChatCommand} from "../base/chat-command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {ChatCommand} from "../base/chat-command";
import {AiProvider} from "../model/ai-provider";
import {runUnifiedAi} from "../ai/unified-ai-runner";
import {Environment} from "../common/environment";
export class OpenAIChat extends ChatCommand {
command = ["openai", "chatgpt"];
@@ -19,149 +12,10 @@ export class OpenAIChat extends ChatCommand {
requirements = Requirements.Build(Requirement.BOT_CREATOR);
title = "/openAI";
description = "Chat with AI (OpenAI)";
title = Environment.commandTitles.openAiChat;
description = Environment.commandDescriptions.openAiChat;
async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
console.log("OpenAI Chat: ", match);
return this.executeOpenAI(msg, match?.[3]);
}
async executeOpenAI(msg: Message, text: string): Promise<void> {
if (!text || text.trim().length === 0) 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 => {
const content = [];
content.push({
type: part.bot ? "output_text" : "input_text",
text: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `MESSAGE FROM USER "${part.name}":\n` : "") + part.content,
});
// TODO: 03/02/2026, Danil Nikolaev: upload file then add here
// for (const image of part.images) {
// content.push({
// type: "image_url",
// imageUrl: "data:image/jpeg;base64," + image
// });
// }
return {
role: part.bot ? "assistant" : "user",
content: content,
type: "message",
};
});
chatMessages.reverse();
if (Environment.SYSTEM_PROMPT) {
chatMessages.unshift({
role: "system",
content: [{type: "input_text", text: Environment.SYSTEM_PROMPT}],
type: "message"
});
}
let waitMessage: Message;
const startTime = Date.now();
try {
waitMessage = await bot.sendMessage({
chat_id: chatId,
text: Environment.waitThinkText,
reply_parameters: {
chat_id: chatId,
message_id: msg.message_id
}
});
const stream = await openAi.responses.create({
model: Environment.OPENAI_MODEL,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
input: chatMessages as any,
stream: true
});
let currentText = "";
let shouldBreak = false;
const editor = startIntervalEditor({
intervalMs: 4500,
getText: () => currentText,
editFn: async (text) => {
await bot.editMessageText(
{
chat_id: chatId,
message_id: waitMessage.message_id,
text: escapeMarkdownV2Text(text),
parse_mode: "MarkdownV2"
}
).catch(logError);
console.log("editMessageText", text);
waitMessage.reply_to_message = msg;
waitMessage.text = text;
await MessageStore.put(waitMessage);
},
onStop: async () => {
}
});
await editor.tick();
try {
for await (const chunk of stream) {
console.log("chunk", chunk);
if (chunk.type === "response.output_text.delta") {
const text = chunk.delta;
currentText += text;
if (currentText.length > 4096) {
currentText = currentText.slice(0, 4093) + "...";
shouldBreak = true;
}
console.log("messageText", currentText);
console.log("length", currentText.length);
if (shouldBreak) {
console.log("break", true);
break;
}
}
}
} finally {
await editor.tick();
await editor.stop();
if (!shouldBreak) {
console.log("ended", true);
}
const diff = Math.abs(Date.now() - startTime) / 1000.0;
console.log("time", diff);
waitMessage.reply_to_message = msg;
waitMessage.text = currentText;
await MessageStore.put(waitMessage);
if (Environment.SEND_TIME_TOOK) {
await replyToMessage({message: waitMessage, text: `⏱️ ${diff}s`});
}
}
} catch (error) {
logError(error);
await replyToMessage({
message: waitMessage,
text: `Произошла ошибка!\n${error.toString()}`
}).catch(logError);
}
await runUnifiedAi({provider: AiProvider.OPENAI, msg: msg, text: match?.[3] ?? "", stream: true});
}
}

Some files were not shown because too many files have changed in this diff Show More