From 28f67aefc27f53d2ec456bad6002b57f199031b0 Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Sun, 10 May 2026 22:51:52 +0300 Subject: [PATCH] config: add env schema and localization foundation --- .env.example | 66 ++- locales/en.json | 223 +++++++++ locales/ru.json | 249 ++++++++++ locales/ua.json | 212 +++++++++ src/common/environment.ts | 940 ++++++++++++++++++++++++++++++++++++- src/common/localization.ts | 251 ++++++++++ 6 files changed, 1912 insertions(+), 29 deletions(-) create mode 100644 locales/en.json create mode 100644 locales/ru.json create mode 100644 locales/ua.json create mode 100644 src/common/localization.ts diff --git a/.env.example b/.env.example index 2032fd8..5c7afee 100644 --- a/.env.example +++ b/.env.example @@ -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= \ No newline at end of file +# 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: +# __MODEL= +# __BASE_URL= +# __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 diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..8ee942d --- /dev/null +++ b/locales/en.json @@ -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
{content}
", + "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" + } +} diff --git a/locales/ru.json b/locales/ru.json new file mode 100644 index 0000000..0b0f560 --- /dev/null +++ b/locales/ru.json @@ -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": "провайдер", + "runtimeModelLabelText": "модель", + "runtimeCapabilitiesLabelText": "возможности", + "runtimeExternalLabelText": "внешний", + "infoAiBlockLabelText": "AI", + "infoSupportedProvidersLabelText": "поддерживаемые провайдеры", + "infoToolsBlockLabelText": "инструменты", + "infoCountLabelText": "количество", + "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
{content}
", + "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": "случайная дата" + } +} diff --git a/locales/ua.json b/locales/ua.json new file mode 100644 index 0000000..dc325b0 --- /dev/null +++ b/locales/ua.json @@ -0,0 +1,212 @@ +{ + "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": "інструменти", + "infoCountLabelText": "кількість", + "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
{content}
", + "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": "випадкова дата" + } +} diff --git a/src/common/environment.ts b/src/common/environment.ts index 7c8d206..60f301b 100644 --- a/src/common/environment.ts +++ b/src/common/environment.ts @@ -8,6 +8,9 @@ import {Answers} from "../model/answers"; import {ifTrue} from "../util/utils"; import {AiProvider} from "../model/ai-provider"; import {ImageHandleFallbackPolicy, ImageHandlePolicy, RateLimitFallbackPolicy} from "./policies"; +import type {ToolCallData} from "../ai/unified-ai-runner"; +import {PYTHON_INTERPRETER_TOOL_NAME} from "../ai/tools/python-interpretator"; +import {Localization, type LocalizationParams} from "./localization"; type EnvRecord = Record; type StringEnumLike = Record; @@ -72,6 +75,7 @@ function numberWithDefaultSchema(defaultValue: number) { const number = Number(normalized); return Number.isFinite(number) ? number : defaultValue; }, z.number()) + .default(defaultValue) .catch(defaultValue); } @@ -135,6 +139,7 @@ const RuntimeEnvSchema = z.object({ ENABLE_UNSAFE_EVAL: booleanWithDefaultSchema(false), MAX_PHOTO_SIZE: positiveIntWithDefaultSchema(1280), PROCESS_LINKS: booleanWithDefaultSchema(false), + LOCALES_DIR: stringWithDefaultSchema("locales"), RATE_LIMIT_FALLBACK_POLICY: enumWithDefaultSchema( RateLimitFallbackPolicy, @@ -155,34 +160,70 @@ const RuntimeEnvSchema = z.object({ OPEN_WEATHER_MAP_API_KEY: optionalStringSchema, FILE_TOOLS_ROOT_DIR: optionalStringSchema, + ENABLE_FS_TOOLS: optionalBooleanSchema, DEFAULT_AI_PROVIDER: enumWithDefaultSchema( AiProvider, AiProvider.OLLAMA, ), + SYSTEM_PROMPT: optionalStringSchema, USE_NAMES_IN_PROMPT: booleanWithDefaultSchema(false), USE_SYSTEM_PROMPT: booleanWithDefaultSchema(true), SEND_TIME_TOOK: optionalBooleanSchema, + ENABLE_PYTHON_INTERPRETER: optionalBooleanSchema, + OLLAMA_API_KEY: optionalStringSchema, OLLAMA_ADDRESS: optionalStringSchema, - OLLAMA_MODEL: stringWithDefaultSchema("gemma3:4b"), + OLLAMA_CHAT_MODEL: stringWithDefaultSchema("gemma4:e4b"), OLLAMA_IMAGE_MODEL: optionalStringSchema, OLLAMA_THINK_MODEL: optionalStringSchema, + OLLAMA_AUDIO_MODEL: optionalStringSchema, + OLLAMA_EMBEDDING_MODEL: stringWithDefaultSchema("nomic-embed-text:latest"), + OLLAMA_RAG_CHUNK_SIZE: positiveIntWithDefaultSchema(1400), + OLLAMA_RAG_CHUNK_OVERLAP: positiveIntWithDefaultSchema(220), + OLLAMA_RAG_TOP_K: positiveIntWithDefaultSchema(8), + OLLAMA_RAG_MAX_CONTEXT_CHARS: positiveIntWithDefaultSchema(14000), + OLLAMA_RAG_MIN_SCORE: numberWithDefaultSchema(0.12), + OLLAMA_RAG_MAX_ARCHIVE_FILES: positiveIntWithDefaultSchema(200), + OLLAMA_RAG_MAX_ARCHIVE_BYTES: positiveIntWithDefaultSchema(50 * 1024 * 1024), + OLLAMA_RAG_MAX_ARCHIVE_DEPTH: positiveIntWithDefaultSchema(2), + OLLAMA_MAX_CONCURRENT_REQUESTS: positiveIntWithDefaultSchema(1), GEMINI_API_KEY: optionalStringSchema, + GEMINI_API_MODE: enumWithDefaultSchema( + { + GOOGLE: "google", + OPENAI: "openai", + AUTO: "auto", + }, + "google", + ), GEMINI_MODEL: stringWithDefaultSchema("gemini-2.5-flash-lite"), GEMINI_IMAGE_MODEL: stringWithDefaultSchema("gemini-2.5-flash-image"), + GEMINI_TRANSCRIPTION_MODEL: stringWithDefaultSchema("gemini-2.5-flash"), + GEMINI_TTS_MODEL: stringWithDefaultSchema("gemini-2.5-flash-preview-tts"), + GEMINI_TTS_VOICE: stringWithDefaultSchema("Kore"), + GEMINI_MAX_CONCURRENT_REQUESTS: positiveIntWithDefaultSchema(3), MISTRAL_API_KEY: optionalStringSchema, MISTRAL_MODEL: stringWithDefaultSchema("mistral-tiny-latest"), + MISTRAL_TRANSCRIPTION_MODEL: stringWithDefaultSchema("voxtral-mini-latest"), + MISTRAL_TTS_MODEL: stringWithDefaultSchema("voxtral-mini-tts-latest"), + MISTRAL_TTS_VOICE_ID: stringWithDefaultSchema("cb891218-482c-4392-9878-91e8d999d57a"), + MISTRAL_MAX_CONCURRENT_REQUESTS: positiveIntWithDefaultSchema(3), OPENAI_BASE_URL: optionalStringSchema, OPENAI_API_KEY: optionalStringSchema, OPENAI_MODEL: stringWithDefaultSchema("gpt-4.1-nano"), OPENAI_IMAGE_MODEL: stringWithDefaultSchema("gpt-image-1-mini"), + OPENAI_TRANSCRIPTION_MODEL: stringWithDefaultSchema("gpt-4o-mini-transcribe"), + OPENAI_TTS_MODEL: stringWithDefaultSchema("gpt-4o-mini-tts"), + OPENAI_TTS_VOICE: stringWithDefaultSchema("alloy"), + OPENAI_TTS_INSTRUCTIONS: optionalStringSchema, + OPENAI_MAX_CONCURRENT_REQUESTS: positiveIntWithDefaultSchema(3), }); type StartupEnv = z.infer; @@ -193,6 +234,7 @@ export class Environment { private static lastEnvMtimeMs: number | undefined; private static lastSystemPromptMtimeMs: number | undefined; + private static envSystemPrompt: string | undefined; static BOT_TOKEN: string = ""; static TEST_ENVIRONMENT: boolean = false; @@ -212,9 +254,10 @@ export class Environment { static ANSWERS: Answers; - static MAX_PHOTO_SIZE: number = 1280; + static MAX_PHOTO_SIZE: number = 0; static PROCESS_LINKS: boolean = false; + static LOCALES_DIR: string = "locales"; static RATE_LIMIT_FALLBACK_POLICY: RateLimitFallbackPolicy = RateLimitFallbackPolicy.NOTIFY_USER; static IMAGE_HANDLE_POLICY: ImageHandlePolicy = ImageHandlePolicy.HANDLE_IF_CAPABLE; @@ -224,6 +267,7 @@ export class Environment { static OPEN_WEATHER_MAP_API_KEY?: string; static FILE_TOOLS_ROOT_DIR?: string; + static ENABLE_FS_TOOLS: boolean = false; // AI Stuff static DEFAULT_AI_PROVIDER: AiProvider = AiProvider.OLLAMA; @@ -233,32 +277,808 @@ export class Environment { static USE_SYSTEM_PROMPT: boolean = true; static SEND_TIME_TOOK: boolean = false; + static ENABLE_PYTHON_INTERPRETER: boolean = false; + static OLLAMA_API_KEY?: string; static OLLAMA_ADDRESS?: string; - static OLLAMA_MODEL: string = "gemma3:4b"; - static OLLAMA_IMAGE_MODEL: string = Environment.OLLAMA_MODEL; - static OLLAMA_THINK_MODEL: string = Environment.OLLAMA_MODEL; + static OLLAMA_CHAT_MODEL: string = ""; + static OLLAMA_IMAGE_MODEL: string = Environment.OLLAMA_CHAT_MODEL; + static OLLAMA_THINK_MODEL: string = Environment.OLLAMA_CHAT_MODEL; + static OLLAMA_AUDIO_MODEL: string = Environment.OLLAMA_CHAT_MODEL; + static OLLAMA_EMBEDDING_MODEL: string = ""; + static OLLAMA_RAG_CHUNK_SIZE: number = 0; + static OLLAMA_RAG_CHUNK_OVERLAP: number = 0; + static OLLAMA_RAG_TOP_K: number = 0; + static OLLAMA_RAG_MAX_CONTEXT_CHARS: number = 0; + static OLLAMA_RAG_MIN_SCORE: number = 0.0; + static OLLAMA_RAG_MAX_ARCHIVE_FILES: number = 0; + static OLLAMA_RAG_MAX_ARCHIVE_BYTES: number = 0; + static OLLAMA_RAG_MAX_ARCHIVE_DEPTH: number = 0; + static OLLAMA_MAX_CONCURRENT_REQUESTS: number = 0; static GEMINI_API_KEY?: string; - static GEMINI_MODEL: string = "gemini-2.5-flash-lite"; - static GEMINI_IMAGE_MODEL: string = "gemini-2.5-flash-image"; + static GEMINI_API_MODE: "google" | "openai" | "auto" = "google"; + static GEMINI_MODEL: string = ""; + static GEMINI_IMAGE_MODEL: string = ""; + static GEMINI_TRANSCRIPTION_MODEL: string = ""; + static GEMINI_TTS_MODEL: string = ""; + static GEMINI_TTS_VOICE: string = ""; + static GEMINI_MAX_CONCURRENT_REQUESTS: number = 0; static MISTRAL_API_KEY?: string; - static MISTRAL_MODEL: string = "mistral-tiny-latest"; + static MISTRAL_MODEL: string = ""; + static MISTRAL_TRANSCRIPTION_MODEL: string = ""; + static MISTRAL_TTS_MODEL: string = ""; + static MISTRAL_TTS_VOICE_ID: string = ""; + static MISTRAL_MAX_CONCURRENT_REQUESTS: number = 0; static OPENAI_BASE_URL?: string; static OPENAI_API_KEY?: string; - static OPENAI_MODEL: string = "gpt-4.1-nano"; - static OPENAI_IMAGE_MODEL: string = "gpt-image-1-mini"; + static OPENAI_MODEL: string = ""; + static OPENAI_IMAGE_MODEL: string = ""; + static OPENAI_TRANSCRIPTION_MODEL: string = ""; + static OPENAI_TTS_MODEL: string = ""; + static OPENAI_TTS_VOICE: string = ""; + static OPENAI_TTS_INSTRUCTIONS?: string; + static OPENAI_MAX_CONCURRENT_REQUESTS: number = 0; - static errorText = "⚠️ Произошла ошибка."; - static waitText = "⏳ Секунду..."; - static waitThinkText = "⏳ Дайте-ка подумать..."; - static analyzingPictureText = "🔍 Внимательно изучаю изображение..."; - static analyzingPicturesText = "🔍 Внимательно изучаю изображения..."; - static transcribingAudioText = "🦻 Внимательно слушаю аудио..."; - static genImageText = "👨‍🎨 Генерирую изображение..."; - static ollamaCancelledText = "```Ollama\n❌ Отменено```"; + private static text(key: string, fallback: string, params: LocalizationParams = {}): string { + return Localization.text(key, params, fallback); + } + + private static textArray(key: string, fallback: string[], params: LocalizationParams = {}): string[] { + return Localization.textArray(key, params, fallback); + } + + static get errorText() { return this.text("errorText", "⚠️ An error occurred."); } + static get waitThinkText() { return this.text("waitThinkText", "⏳ Let me think..."); } + static get analyzingPictureText() { return this.text("analyzingPictureText", "🔍 Analyzing the image..."); } + static get analyzingPicturesText() { return this.text("analyzingPicturesText", "🔍 Analyzing the images..."); } + static get reasoningText() { return this.text("reasoningText", "🤔 Reasoning..."); } + static get transcribingAudioText() { return this.text("transcribingAudioText", "🦻 Transcribing audio..."); } + static get genImageText() { return this.text("genImageText", "👨‍🎨 Generating an image..."); } + + static get cancelText() { return this.text("cancelText", "❌ Cancel"); } + static get regenerateText() { return this.text("regenerateText", "🔄 Regenerate"); } + static get aiCancelCallbackText() { return this.text("aiCancelCallbackText", "Cancel AI generation"); } + static get aiRegenerateCallbackText() { return this.text("aiRegenerateCallbackText", "Regenerate AI response"); } + static get userSettingsCallbackText() { return this.text("userSettingsCallbackText", "User settings"); } + static get noAccessText() { return this.text("noAccessText", "No access"); } + static get notBotCreatorText() { return this.text("notBotCreatorText", "You are not the bot creator."); } + static get notBotAdministratorText() { return this.text("notBotAdministratorText", "You are not a bot administrator."); } + static get notAChatText() { return this.text("notAChatText", "This is not a chat."); } + static get notChatAdministratorText() { return this.text("notChatAdministratorText", "You are not a chat administrator."); } + static get botNotChatAdministratorText() { return this.text("botNotChatAdministratorText", "The bot is not a chat administrator."); } + static get replyRequiredText() { return this.text("replyRequiredText", "A reply to a message is required."); } + static get onlyOriginalAuthorText() { return this.text("onlyOriginalAuthorText", "Only the author of the original message can perform this action."); } + static get dockerContainerLabelText() { return this.text("dockerContainerLabelText", "Docker container"); } + static get processLabelText() { return this.text("processLabelText", "Process"); } + static get systemLabelText() { return this.text("systemLabelText", "System"); } + static get systemInfoOsLabelText() { return this.text("systemInfoOsLabelText", "OS"); } + static get systemInfoRuntimeLabelText() { return this.text("systemInfoRuntimeLabelText", "RUNTIME"); } + static get systemInfoDockerLabelText() { return this.text("systemInfoDockerLabelText", "DOCKER"); } + static get systemInfoCpuLabelText() { return this.text("systemInfoCpuLabelText", "CPU"); } + static get systemInfoRamLabelText() { return this.text("systemInfoRamLabelText", "RAM"); } + static get systemInfoCpuCoresText() { return this.text("systemInfoCpuCoresText", "cores"); } + static get systemInfoCpuThreadsText() { return this.text("systemInfoCpuThreadsText", "threads"); } + static get idChatLabelText() { return this.text("idChatLabelText", "chat id"); } + static get idFromLabelText() { return this.text("idFromLabelText", "from id"); } + static get idReplyLabelText() { return this.text("idReplyLabelText", "reply id"); } + static get runtimeProviderLabelText() { return this.text("runtimeProviderLabelText", "provider"); } + static get runtimeModelLabelText() { return this.text("runtimeModelLabelText", "model"); } + static get runtimeCapabilitiesLabelText() { return this.text("runtimeCapabilitiesLabelText", "capabilities"); } + static get runtimeExternalLabelText() { return this.text("runtimeExternalLabelText", "external"); } + static get runtimeCapabilityVisionText() { return this.text("runtimeCapabilityVisionText", "vision / image input"); } + static get runtimeCapabilityOcrText() { return this.text("runtimeCapabilityOcrText", "ocr"); } + static get runtimeCapabilityThinkingText() { return this.text("runtimeCapabilityThinkingText", "thinking / reasoning"); } + static get runtimeCapabilityExtendedThinkingText() { return this.text("runtimeCapabilityExtendedThinkingText", "leveled thinking / reasoning"); } + static get runtimeCapabilityToolsText() { return this.text("runtimeCapabilityToolsText", "tools / function calling"); } + static get runtimeCapabilityAudioText() { return this.text("runtimeCapabilityAudioText", "audio input"); } + static get runtimeCapabilitySpeechToTextText() { return this.text("runtimeCapabilitySpeechToTextText", "speech-to-text"); } + static get runtimeCapabilityTextToSpeechText() { return this.text("runtimeCapabilityTextToSpeechText", "text-to-speech"); } + static get runtimeCapabilityDocumentsText() { return this.text("runtimeCapabilityDocumentsText", "documents / rag"); } + static get runtimeCapabilityOutputImagesText() { return this.text("runtimeCapabilityOutputImagesText", "image gen / image output"); } + static get infoAiBlockLabelText() { return this.text("infoAiBlockLabelText", "AI"); } + static get infoSupportedProvidersLabelText() { return this.text("infoSupportedProvidersLabelText", "supported providers"); } + static get infoToolsBlockLabelText() { return this.text("infoToolsBlockLabelText", "tools"); } + static get infoCountLabelText() { return this.text("infoCountLabelText", "count"); } + static get infoCommandsBlockLabelText() { return this.text("infoCommandsBlockLabelText", "commands"); } + static get infoPublicLabelText() { return this.text("infoPublicLabelText", "public"); } + static get infoPrivateLabelText() { return this.text("infoPrivateLabelText", "private"); } + static get infoChatLabelText() { return this.text("infoChatLabelText", "chat"); } + static get infoCallbackLabelText() { return this.text("infoCallbackLabelText", "callback"); } + static get commandsHeaderText() { return this.text("commandsHeaderText", "Commands:\n\n"); } + static get sentCommandsInDmText() { return this.text("sentCommandsInDmText", "Sent commands in DM 😎"); } + static get couldNotSendCommandsInDmText() { return this.text("couldNotSendCommandsInDmText", "Could not send commands in DM ☹️\nSending them here instead"); } + static get administratorsHeaderText() { return this.text("administratorsHeaderText", "*Administrators*:\n\n"); } + static get noUserInfoText() { return this.text("noUserInfoText", "No user information"); } + static get useLeaveCommandText() { return this.text("useLeaveCommandText", "Use /leave"); } + static get databaseBackupCaption() { return this.text("databaseBackupCaption", "Database backup"); } + static get databaseBackupSentText() { return this.text("databaseBackupSentText", "Successfully sent to the creator in DM!"); } + static get noChoicesText() { return this.text("noChoicesText", "Nothing to choose from"); } + static get qrCodeMissingTextText() { return this.text("qrCodeMissingTextText", "No text found for QR code generation."); } + static get quoteMissingTextText() { return this.text("quoteMissingTextText", "Could not find text in the message 😢"); } + static get quoteBuildFailedText() { return this.text("quoteBuildFailedText", "Could not build the quote 😢"); } + static get speechToTextInstructionText() { return this.text("speechToTextInstructionText", "Send audio/voice/video-note or reply with /stt to a message containing audio."); } + static get speechToTextEmptyResultText() { return this.text("speechToTextEmptyResultText", "Speech-to-text did not return transcription text."); } + static get textToSpeechInstructionText() { return this.text("textToSpeechInstructionText", "Send text after the command or reply with /tts to a message containing text."); } + static get titleMissingText() { return this.text("titleMissingText", "Could not find a title..."); } + static get betterFallbackText() { return this.text("betterFallbackText", "Better"); } + static get pongText() { return this.text("pongText", "pong"); } + static get variableNotDefinedText() { return this.text("variableNotDefinedText", "variable is not defined"); } + static get evaluationVariableNotDefinedText() { return this.text("evaluationVariableNotDefinedText", "Variable not defined"); } + static get defaultTestAnswerText() { return this.text("defaultTestAnswerText", "a"); } + static get prefixFallbackText() { return this.text("prefixFallbackText", "?"); } + static get searchResultsHeaderText() { return this.text("searchResultsHeaderText", "Results:\n\n"); } + static get modelListHeaderText() { return this.text("modelListHeaderText", "Available models:\n\n"); } + static get modelListLoadFailedText() { return this.text("modelListLoadFailedText", "Could not load the model list"); } + static get noCurrentModelText() { return this.text("noCurrentModelText", "Model is not set. Use one of the listed values."); } + static get unsupportedAttachmentText() { return this.text("unsupportedAttachmentText", "This attachment type is not supported."); } + static get attachmentMissingFromCacheText() { return this.text("attachmentMissingFromCacheText", "Attachment file is missing from cache."); } + static get couldNotIdentifyUserForSpeechToTextText() { return this.text("couldNotIdentifyUserForSpeechToTextText", "Could not identify the user for speech-to-text."); } + static get missingTranscriptionFileText() { return this.text("missingTranscriptionFileText", "Unable to prepare the audio file for transcription."); } + static get transcriptionFailedText() { return this.text("transcriptionFailedText", "Could not transcribe the audio."); } + static get imageGenUnsupportedFilesText() { return this.text("imageGenUnsupportedFilesText", "Image generation does not support files in this mode."); } + static get unsupportedDocumentProviderText() { return this.text("unsupportedDocumentProviderText", "This provider does not support attached documents."); } + static get mistralPdfOnlyText() { return this.text("mistralPdfOnlyText", "Mistral currently supports only PDF documents."); } + static get mistralDocumentUploadFailedText() { return this.text("mistralDocumentUploadFailedText", "Could not upload the document to Mistral."); } + static get documentContentLabelText() { return this.text("documentContentLabelText", "Document content"); } + static get mistralLibraryIdMissingText() { return this.text("mistralLibraryIdMissingText", "Mistral did not return a temporary document library id."); } + static get documentsUnifiedRunnerUnsupportedText() { return this.text("documentsUnifiedRunnerUnsupportedText", "Documents in the unified runner are currently handled only by Ollama RAG and Mistral."); } + static get zipCentralDirectoryNotFoundText() { return this.text("zipCentralDirectoryNotFoundText", "ZIP archive is corrupted: central directory was not found."); } + static get zipInvalidCentralDirectoryText() { return this.text("zipInvalidCentralDirectoryText", "ZIP archive is corrupted: invalid central directory."); } + static get tarFileTooLargeText() { return this.text("tarFileTooLargeText", "TAR contains a file that is too large."); } + static get tarInvalidEntrySizeText() { return this.text("tarInvalidEntrySizeText", "TAR archive is corrupted: invalid entry size."); } + static get tarEntryExceedsBoundsText() { return this.text("tarEntryExceedsBoundsText", "TAR archive is corrupted: entry exceeds file bounds."); } + static get docxDocumentXmlMissingText() { return this.text("docxDocumentXmlMissingText", "DOCX does not contain word/document.xml."); } + static get localRagEmbeddingModelRequiredText() { return this.text("localRagEmbeddingModelRequiredText", "Local RAG requires OLLAMA_EMBEDDING_MODEL, for example nomic-embed-text."); } + static get localRagChunksBuildFailedText() { return this.text("localRagChunksBuildFailedText", "Could not build chunks for local RAG."); } + static get localRagNoSuitableFragmentsText() { return this.text("localRagNoSuitableFragmentsText", "Local RAG did not find suitable document fragments."); } + static get unsupportedAiProviderText() { return this.text("unsupportedAiProviderText", "Unsupported AI provider."); } + static get noSupportedTranscriptionProviderText() { return this.text("noSupportedTranscriptionProviderText", "No supported speech-to-text provider is configured."); } + static get noSupportedTextToSpeechProviderText() { return this.text("noSupportedTextToSpeechProviderText", "No supported text-to-speech provider is configured."); } + static get noSpeechToTextProviderForAccessText() { return this.text("noSpeechToTextProviderForAccessText", "No speech-to-text providers are configured for your access level."); } + static get noTextToSpeechProviderForAccessText() { return this.text("noTextToSpeechProviderForAccessText", "No text-to-speech providers are configured for your access level."); } + static get geminiSpeechToTextUnsupportedText() { return this.text("geminiSpeechToTextUnsupportedText", "Gemini does not support speech-to-text right now."); } + static get geminiTextToSpeechUnsupportedText() { return this.text("geminiTextToSpeechUnsupportedText", "Gemini does not support text-to-speech right now."); } + static get ollamaTextToSpeechUnsupportedText() { return this.text("ollamaTextToSpeechUnsupportedText", "Ollama does not support text-to-speech right now."); } + static get ollamaSpeechToTextModelRequiredText() { return this.text("ollamaSpeechToTextModelRequiredText", "Ollama speech-to-text requires OLLAMA_AUDIO_MODEL=gemma4:e2b or OLLAMA_AUDIO_MODEL=gemma4:e4b."); } + static get noTextToSynthesizeText() { return this.text("noTextToSynthesizeText", "No text to synthesize."); } + static get mistralTtsNoAudioDataText() { return this.text("mistralTtsNoAudioDataText", "Mistral TTS did not return audioData."); } + static get speechFileTooLargeText() { return this.text("speechFileTooLargeText", "The speech file is larger than 50 MB and cannot be sent."); } + static get userSettingsTitle() { return this.text("userSettingsTitle", "User Settings"); } + static get userSettingsAiProviderSelectionTitle() { return this.text("userSettingsAiProviderSelectionTitle", "AI Provider Selection"); } + static get userSettingsInterfaceLanguageSelectionTitle() { return this.text("userSettingsInterfaceLanguageSelectionTitle", "Interface Language Selection"); } + static get userSettingsResponseLanguageSelectionTitle() { return this.text("userSettingsResponseLanguageSelectionTitle", "Response Language Selection"); } + static get userSettingsContextSizeSelectionTitle() { return this.text("userSettingsContextSizeSelectionTitle", "Context Size Selection"); } + static get userSettingsVoiceModeSelectionTitle() { return this.text("userSettingsVoiceModeSelectionTitle", "Voice Message Mode Selection"); } + static get userSettingsTierLabel() { return this.text("userSettingsTierLabel", "Tier"); } + static get userSettingsAiProviderLabel() { return this.text("userSettingsAiProviderLabel", "AI provider"); } + static get userSettingsInterfaceLanguageLabel() { return this.text("userSettingsInterfaceLanguageLabel", "Interface language"); } + static get userSettingsResponseLanguageLabel() { return this.text("userSettingsResponseLanguageLabel", "LLM response language"); } + static get userSettingsContextSizeLabel() { return this.text("userSettingsContextSizeLabel", "Context size"); } + static get userSettingsVoiceModeLabel() { return this.text("userSettingsVoiceModeLabel", "Voice messages"); } + static get userSettingsBackButtonText() { return this.text("userSettingsBackButtonText", "Back"); } + static get userSettingsAiProviderButtonPrefix() { return this.text("userSettingsAiProviderButtonPrefix", "AI provider"); } + static get userSettingsInterfaceLanguageButtonPrefix() { return this.text("userSettingsInterfaceLanguageButtonPrefix", "Interface language"); } + static get userSettingsResponseLanguageButtonPrefix() { return this.text("userSettingsResponseLanguageButtonPrefix", "Response language"); } + static get userSettingsContextSizeButtonPrefix() { return this.text("userSettingsContextSizeButtonPrefix", "Context size"); } + static get userSettingsVoiceModeButtonPrefix() { return this.text("userSettingsVoiceModeButtonPrefix", "Voice messages"); } + static get userSettingsCreatorTierText() { return this.text("userSettingsCreatorTierText", "Creator"); } + static get userSettingsAdminTierText() { return this.text("userSettingsAdminTierText", "Admin"); } + static get userSettingsUserTierText() { return this.text("userSettingsUserTierText", "User"); } + static get userSettingsSelectedPrefix() { return this.text("userSettingsSelectedPrefix", "✓ "); } + static get userSettingsContextSizeDefaultText() { return this.text("userSettingsContextSizeDefaultText", "Default"); } + static get userSettingsVoiceModeExecuteText() { return this.text("userSettingsVoiceModeExecuteText", "Run through AI"); } + static get userSettingsVoiceModeTranscriptText() { return this.text("userSettingsVoiceModeTranscriptText", "Show transcript only"); } + + static commandTitles = { + ae: "/ae", + adminsAdd: "/addAdmin", + adminsRemove: "/removeAdmin", + ban: "/ban [reply]", + choice: "/choice a, b, ..., c", + coin: "/coin", + debug: "/debug", + dice: "/dice", + distort: "/distort [amp] [wavelength]", + geminiChat: "/gemini", + geminiGetModel: "/GeminiGetModel", + geminiListModels: "/GeminiListModels", + geminiSetModel: "/GeminiSetModel", + help: "/help", + id: "/id", + ignore: "/ignore", + info: "/info", + leave: "/leave", + mistralChat: "/mistral", + mistralGetModel: "/MistralGetModel", + mistralListModels: "/MistralListModels", + mistralSetModel: "/MistralSetModel", + ollamaChat: "/ollama", + ollamaGetModel: "/OllamaGetModel", + ollamaListModels: "/OllamaListModels", + ollamaSearch: "/search", + ollamaSetModel: "/OllamaSetModel", + openAiChat: "/openAI", + openAiGetModel: "/OpenAIGetModel", + openAiListModels: "/OpenAIListModels", + openAiSetModel: "/OpenAISetModel", + ping: "/ping", + qr: "/qr", + quote: "/quote", + randomInt: "/randomInt", + randomString: "/randomString", + settings: "/settings", + shutdown: "/shutdown", + speechToText: "/stt", + start: "/start", + systemInfo: "/systemInfo", + textToSpeech: "/tts", + title: "/title", + test: "test", + transliteration: "/tr [text or reply]", + unban: "/unban [reply]", + unignore: "/unignore", + uptime: "/uptime", + whatBetter: "/what better [a] or [b]", + when: "/when [value]", + } as const; + + static get commandDescriptions() { + return { + ae: this.text("commandDescriptions.ae", "evaluation"), + adminsAdd: this.text("commandDescriptions.adminsAdd", "Add user to admins"), + adminsRemove: this.text("commandDescriptions.adminsRemove", "Remove user from admins"), + ban: this.text("commandDescriptions.ban", "ban user from chat"), + choice: this.text("commandDescriptions.choice", "Choose a random value"), + coin: this.text("commandDescriptions.coin", "Heads or tails"), + debug: this.text("commandDescriptions.debug", "Returns msg (or reply) as json"), + dice: this.text("commandDescriptions.dice", "Sends random or specific dice"), + distort: this.text("commandDescriptions.distort", "Distortion of picture"), + geminiChat: this.text("commandDescriptions.geminiChat", "Chat with AI (Gemini)"), + geminiGetModel: this.text("commandDescriptions.geminiGetModel", "Get current Gemini model"), + geminiListModels: this.text("commandDescriptions.geminiListModels", "List all Gemini models"), + geminiSetModel: this.text("commandDescriptions.geminiSetModel", "Set Gemini model"), + help: this.text("commandDescriptions.help", "Show list of commands"), + id: this.text("commandDescriptions.id", "ID of chat, user and reply (if replied to any message)"), + ignore: this.text("commandDescriptions.ignore", "Bot will ignore user"), + info: this.text("commandDescriptions.info", "Info about bot"), + leave: this.text("commandDescriptions.leave", "Bot will leave current chat"), + mistralChat: this.text("commandDescriptions.mistralChat", "Chat with AI (Mistral)"), + mistralGetModel: this.text("commandDescriptions.mistralGetModel", "Get current Mistral model"), + mistralListModels: this.text("commandDescriptions.mistralListModels", "List all Mistral models"), + mistralSetModel: this.text("commandDescriptions.mistralSetModel", "Set Mistral model"), + ollamaChat: this.text("commandDescriptions.ollamaChat", "Chat with AI (Ollama)"), + ollamaGetModel: this.text("commandDescriptions.ollamaGetModel", "Get current Ollama model"), + ollamaListModels: this.text("commandDescriptions.ollamaListModels", "List all Ollama models"), + ollamaSearch: this.text("commandDescriptions.ollamaSearch", "Web search via Ollama"), + ollamaSetModel: this.text("commandDescriptions.ollamaSetModel", "Set Ollama model"), + openAiChat: this.text("commandDescriptions.openAiChat", "Chat with AI (OpenAI)"), + openAiGetModel: this.text("commandDescriptions.openAiGetModel", "Get current OpenAI model"), + openAiListModels: this.text("commandDescriptions.openAiListModels", "List all OpenAI models"), + openAiSetModel: this.text("commandDescriptions.openAiSetModel", "Set OpenAI model"), + ping: this.text("commandDescriptions.ping", "Ping between received and sent message"), + qr: this.text("commandDescriptions.qr", "Generates QR-code from text you sent or replied to."), + quote: this.text("commandDescriptions.quote", "Make quote from text (or quote)"), + randomInt: this.text("commandDescriptions.randomInt", "Ranged random integer from parameters"), + randomString: this.text("commandDescriptions.randomString", "literally random string (up to 4096 symbols)"), + settings: this.text("commandDescriptions.settings", "User settings"), + shutdown: this.text("commandDescriptions.shutdown", "Self-destruction sequence for bot (shutdown)"), + speechToText: this.text("commandDescriptions.speechToText", "Transcribe speech to text"), + start: this.text("commandDescriptions.start", "Start the bot"), + systemInfo: this.text("commandDescriptions.systemInfo", "System information"), + textToSpeech: this.text("commandDescriptions.textToSpeech", "Generate speech from text"), + title: this.text("commandDescriptions.title", "Change group title"), + test: this.text("commandDescriptions.test", "System functionality check"), + transliteration: this.text("commandDescriptions.transliteration", "Transliteration EN <--> RU"), + unban: this.text("commandDescriptions.unban", "unban user from chat"), + unignore: this.text("commandDescriptions.unignore", "Bot will start responding to the user"), + uptime: this.text("commandDescriptions.uptime", "Bot's uptime"), + whatBetter: this.text("commandDescriptions.whatBetter", "either a or b randomly (50% chance)"), + when: this.text("commandDescriptions.when", "random date"), + } as const; + } + + static getUserSettingsTitle(screen: string): string { + if (screen === "provider") return this.userSettingsAiProviderSelectionTitle; + if (screen === "interfaceLanguage") return this.userSettingsInterfaceLanguageSelectionTitle; + if (screen === "responseLanguage" || screen === "language") return this.userSettingsResponseLanguageSelectionTitle; + if (screen === "contextSize") return this.userSettingsContextSizeSelectionTitle; + if (screen === "voiceMode") return this.userSettingsVoiceModeSelectionTitle; + return this.userSettingsTitle; + } + + static getUserSettingsFieldText(label: string, value: string): string { + return `${label}: ${value}`; + } + + static getUserSettingsSelectedText(text: string): string { + return `${this.userSettingsSelectedPrefix}${text}`; + } + + static getUserSettingsContextSizeText(size: number): string { + return this.text("getUserSettingsContextSizeText", "{size} tokens", {size}); + } + + static getCancelledText(provider: string): string { + return this.text("getCancelledText", "{provider}\n❌ Generation cancelled.", {provider}); + } + + static get startingImageGenText() { return this.text("startingImageGenText", "🌈 Starting image generation..."); } + static get imageGenText() { return this.text("imageGenText", "🌈 Generating image..."); } + static get finalizingImageGenText() { return this.text("finalizingImageGenText", "🌈 Finalizing image generation..."); } + + static getPartialImageGenText(iteration: number, total: number): string { + return this.text("getPartialImageGenText", "🌈 Generating image ({iteration}/{total})...", {iteration, total}); + } + + static getImageGenDoneText(model?: string): string { + return model + ? this.text("getImageGenDoneText.withModel", "👨‍🎨 Image generated. Model: `{model}`.", {model}) + : this.text("getImageGenDoneText.default", "👨‍🎨 Image generated."); + } + + static getErrorText(error?: unknown): string { + if (!error) return this.errorText; + + const reason = error instanceof Error ? error.message : String(error); + return this.text("getErrorText.withReason", "{errorText} Reason:\n{reason}", { + errorText: this.errorText, + reason, + }); + } + + static getUptimeText(processUptime: string, osUptime: string): string { + return `${Environment.IS_DOCKER ? this.dockerContainerLabelText : this.processLabelText}:\n${processUptime}\n\n${this.systemLabelText}:\n${osUptime}`; + } + + static getExpandableBlockquoteText(content: string): string { + return `
${content}
`; + } + + static getSystemSpecsText(params: { + os: string; + runtime: string; + docker: boolean; + cpu: string; + ramGb: string; + }): string { + return [ + `${this.systemInfoOsLabelText}: ${params.os}`, + `${this.systemInfoRuntimeLabelText}: ${params.runtime}`, + `${this.systemInfoDockerLabelText}: ${params.docker}`, + `${this.systemInfoCpuLabelText}: ${params.cpu}`, + `${this.systemInfoRamLabelText}: ${params.ramGb} GB`, + ].join("\n"); + } + + static getIdText(chatId: number | string, fromId: number | string | undefined, replyId?: number | string): string { + let text = `${this.idChatLabelText}: \n\`\`\`${chatId}\`\`\` \n${this.idFromLabelText}: \n\`\`\`${fromId}\`\`\``; + if (replyId !== undefined) { + text += ` \n${this.idReplyLabelText}: \n\`\`\`${replyId}\`\`\``; + } + return text; + } + + static getRandomIntRangeText(min: number, max: number, value: number): string { + return this.text("getRandomIntRangeText", "[{min}; {max}]: {value}", {min, max, value}); + } + + static getRuntimeCapabilityLineText(params: { + state: string; + title: string; + model?: string; + endpointBaseUrl?: string; + external?: boolean; + }): string { + const modelPart = params.model ? ` (${params.model})` : ""; + const endpointPart = params.endpointBaseUrl ? ` @ ${params.endpointBaseUrl}` : ""; + const externalPart = params.external ? ` ${this.runtimeExternalLabelText}` : ""; + return this.text("getRuntimeCapabilityLineText", "{state} {title}{modelPart}{endpointPart}{externalPart}", { + state: params.state, + title: params.title, + modelPart, + endpointPart, + externalPart, + }); + } + + static getRuntimeModelInfoText(provider: string, model: string | undefined, capabilities: string[]): string { + return [ + `${this.runtimeProviderLabelText}: ${provider}`, + `${this.runtimeModelLabelText}: ${model}`, + "", + `${this.runtimeCapabilitiesLabelText}:`, + ...capabilities, + ].join("\n"); + } + + static getInfoAiBlockText(supportedProvidersLength: number, modelInfo: string): string { + return [ + `\`\`\`${this.infoAiBlockLabelText}`, + `${this.infoSupportedProvidersLabelText}: ${supportedProvidersLength}`, + "", + modelInfo, + "```", + ].join("\n"); + } + + static getInfoToolsBlockText(toolNames: string[]): string { + return [ + `\`\`\`${this.infoToolsBlockLabelText}`, + `${this.infoCountLabelText}: ${toolNames.length}`, + toolNames.map(name => `- ${name}`).join("\n"), + "```", + ].join("\n"); + } + + static getInfoCommandsBlockText(params: { + publicCommands: number; + privateCommands: number; + chatCommands: number; + callbackCommands: number; + }): string { + return [ + `\`\`\`${this.infoCommandsBlockLabelText}`, + `${this.infoPublicLabelText}: ${params.publicCommands}`, + `${this.infoPrivateLabelText}: ${params.privateCommands}`, + `${this.infoChatLabelText}: ${params.chatCommands}`, + `${this.infoCallbackLabelText}: ${params.callbackCommands}`, + "```", + ].join("\n"); + } + + static getUseToolText(toolCalls: ToolCallData[] | string[]): string { + const isString = (toolCall: ToolCallData | string) => { + return typeof toolCall === "string"; + }; + + return toolCalls.map(toolCall => { + const name = isString(toolCall) ? toolCall : toolCall.name; + return name === PYTHON_INTERPRETER_TOOL_NAME + ? this.text("getUseToolText.python", "👨‍💻 Running `Python`") + : this.text("getUseToolText.default", "🔧 Using tool `{name}`", {name}); + }).join("\n"); + } + + static getAnalyzingDocumentText(documentNames?: string[]): string { + if (!documentNames) return this.text("getAnalyzingDocumentText.default", "🔍 Analyzing the document..."); + if (documentNames.length === 1) { + return this.text("getAnalyzingDocumentText.single", "🔍 Analyzing document: `{name}`", {name: documentNames[0]}); + } + + return this.text("getAnalyzingDocumentText.many", "🔍 Analyzing documents: {names}", { + names: documentNames.map(n => `\`${n}\``).join(", "), + }); + } + + static getPreparingRAGText(documentNames?: string[]): string { + if (!documentNames) return this.text("getPreparingRAGText.default", "🔍 Preparing RAG for the document..."); + if (documentNames.length === 1) { + return this.text("getPreparingRAGText.single", "🔍 Preparing RAG for document: `{name}`", {name: documentNames[0]}); + } + + return this.text("getPreparingRAGText.many", "🔍 Preparing RAG for documents: {names}", { + names: documentNames.map(n => `\`${n}\``).join(", "), + }); + } + + static getBuildingRAGIndexText(modelName?: string): string { + return modelName + ? this.text("getBuildingRAGIndexText.withModel", "🧠 Building RAG index: `{modelName}`.", {modelName}) + : this.text("getBuildingRAGIndexText.default", "🧠 Building RAG index..."); + } + + static getAiQueueText(provider: AiProvider, requestsBefore: number): string { + const count = Math.max(0, requestsBefore); + const beforeText = count === 0 ? this.text("queueNoneText", "none") : count.toString(); + return [ + this.text("getAiQueueText.queued", "⏳ Request to {provider} is queued.", {provider: provider.toString().toLowerCase()}), + this.text("getAiQueueText.ahead", "Requests ahead: {count}.", {count: beforeText}), + ].join("\n"); + } + + static getTelegramFileTooLargeText(fileName: string, maxSizeMb: number): string { + return this.text("getTelegramFileTooLargeText", "File {fileName} is larger than {maxSizeMb} MB and cannot be sent.", {fileName, maxSizeMb}); + } + + static getUserIsNowAdminText(name: string): string { + return this.text("getUserIsNowAdminText", "{name} is now an admin!", {name}); + } + + static getUserAlreadyAdminText(name: string): string { + return this.text("getUserAlreadyAdminText", "{name} is already an admin 🤔", {name}); + } + + static getUserNoLongerAdminText(name: string): string { + return this.text("getUserNoLongerAdminText", "{name} is no longer an admin!", {name}); + } + + static getUserWasNotAdminText(name: string): string { + return this.text("getUserWasNotAdminText", "{name} was not an admin 🤔", {name}); + } + + static get botCannotMakeItselfAdminText() { return this.text("botCannotMakeItselfAdminText", "The bot cannot make itself an admin"); } + static get botCreatorAlreadyAdminText() { return this.text("botCreatorAlreadyAdminText", "The bot creator is already an admin"); } + static get botCannotRemoveItselfFromAdminsText() { return this.text("botCannotRemoveItselfFromAdminsText", "The bot cannot remove itself from admins"); } + static get botCreatorCannotStopBeingAdminText() { return this.text("botCreatorCannotStopBeingAdminText", "The bot creator cannot stop being an admin"); } + static get botWillNotBanCreatorText() { return this.text("botWillNotBanCreatorText", "The bot will not ban its creator."); } + static get botWillNotBanAdminsText() { return this.text("botWillNotBanAdminsText", "The bot will not ban its administrators."); } + static get botIsNotBannedByItselfText() { return this.text("botIsNotBannedByItselfText", "The bot is not banned by itself anyway."); } + static get botCreatorNeverBannedText() { return this.text("botCreatorNeverBannedText", "The bot creator is not banned and never will be."); } + static get botAdminsNotBannedText() { return this.text("botAdminsNotBannedText", "Bot administrators are not banned anyway."); } + static get botWillNotIgnoreItselfText() { return this.text("botWillNotIgnoreItselfText", "The bot will not ignore itself."); } + static get botWillNotIgnoreCreatorText() { return this.text("botWillNotIgnoreCreatorText", "The bot will not ignore its creator."); } + static get botWillNotIgnoreAdminsText() { return this.text("botWillNotIgnoreAdminsText", "The bot will not ignore its administrators."); } + static get botIsNotIgnoredByItselfText() { return this.text("botIsNotIgnoredByItselfText", "The bot is not ignored by itself anyway."); } + static get botCreatorNotIgnoredText() { return this.text("botCreatorNotIgnoredText", "The bot creator is not ignored and never will be."); } + static get botAdminsNotIgnoredText() { return this.text("botAdminsNotIgnoredText", "Bot administrators are not ignored anyway."); } + static get botAlreadyAlwaysListensToItselfText() { return this.text("botAlreadyAlwaysListensToItselfText", "The bot already always listens to itself"); } + static get botAlwaysListensToCreatorText() { return this.text("botAlwaysListensToCreatorText", "The bot always listens to its creator"); } + + static getUserBannedText(name: string): string { + return this.text("getUserBannedText", "{name} banned 🚫", {name}); + } + + static getUserBanFailedText(name: string): string { + return this.text("getUserBanFailedText", "Could not ban {name} ☹️", {name}); + } + + static getUserUnbannedText(name: string): string { + return this.text("getUserUnbannedText", "{name} unbanned ⛓️‍💥", {name}); + } + + static getUserUnbanFailedText(name: string): string { + return this.text("getUserUnbanFailedText", "Could not unban {name} ☹️", {name}); + } + + static getUserIgnoredText(name: string): string { + return this.text("getUserIgnoredText", "{name} is muted! 🔇", {name}); + } + + static getUserAlreadyIgnoredText(name: string): string { + return this.text("getUserAlreadyIgnoredText", "{name} is already muted 🤔", {name}); + } + + static getUserIgnoreFailedText(name: string): string { + return this.text("getUserIgnoreFailedText", "Could not mute {name} ☹️", {name}); + } + + static getUserUnignoredText(name: string): string { + return this.text("getUserUnignoredText", "{name} is no longer muted! 🔈", {name}); + } + + static getUserWasNotIgnoredText(name: string): string { + return this.text("getUserWasNotIgnoredText", "{name} was not muted 🤔", {name}); + } + + static getUserUnignoreFailedText(name: string): string { + return this.text("getUserUnignoreFailedText", "Could not unmute {name} ☹️", {name}); + } + + static getChoiceText(choice: string): string { + return this.text("getChoiceText", "Chose *{choice}*", {choice}); + } + + static getCoinResultText(result: string): string { + return this.text("getCoinResultText", "It landed on *{result}*", {result}); + } + + static get coinHeadsText() { return this.text("coinHeadsText", "Heads"); } + static get coinTailsText() { return this.text("coinTailsText", "Tails"); } + static get distortReplyInstructionText() { return this.text("distortReplyInstructionText", "Reply with /distort to a message containing an image (photo, document, or sticker).\nExample: /distort 16 80"); } + static get distortMissingImageText() { return this.text("distortMissingImageText", "I do not see an image in the reply. Send a photo or image file."); } + + static getDistortionReadyCaption(amp: number, wavelength: number): string { + return this.text("getDistortionReadyCaption", "Distortion ready ✅ (amp={amp}, wavelength={wavelength})", {amp, wavelength}); + } + + static getDistortFailedText(error: unknown): string { + return this.text("getDistortFailedText", "Could not distort image: {reason}", { + reason: error instanceof Error ? error.message : String(error), + }); + } + + static getLoadedModelsText(modelNames: string[]): string { + return this.text("getLoadedModelsText", "Loaded models: {models}", {models: modelNames.join(", ")}); + } + + static getSelectedModelText(model: string): string { + return this.text("getSelectedModelText", "Selected model: `{model}`", {model}); + } + + static getSelectedModelWithInfoText(model: string, info: string): string { + return this.text("getSelectedModelWithInfoText", "Selected model \"{model}\"\n\n{info}", {model, info}); + } + + static getModelIsNotSetCurrentText(model: string): string { + return this.text("getModelIsNotSetCurrentText", "Model is not set. Current model: \"{model}\"", {model}); + } + + static getCurrentModelText(model: string): string { + return this.text("getCurrentModelText", "Current model: `{model}`", {model}); + } + + static getLoadingModelText(model: string): string { + return this.text("getLoadingModelText", "Loading model `{model}`...", {model}); + } + + static getCurrentModelUnsupportedInputText(model: string, providerName: string, inputKind: string): string { + return this.text("getCurrentModelUnsupportedInputText", "⚠️ Current model `{model}` ({providerName}) does not support {inputKind}.", { + model, + providerName, + inputKind, + }); + } + + static getDocumentIsEmptyText(fileName: string): string { + return this.text("getDocumentIsEmptyText", "Document {fileName} is empty or contains no readable text.", {fileName}); + } + + static getDocumentContentText(fileName: string, content: string): string { + return this.text("getDocumentContentText", "{label} for \"{fileName}\":\n\n{content}", { + label: this.documentContentLabelText, + fileName, + content, + }); + } + + static getMistralUploadedDocumentIdMissingText(fileName: string): string { + return this.text("getMistralUploadedDocumentIdMissingText", "Mistral did not return an uploaded document id for {fileName}.", {fileName}); + } + + static getMistralDocumentProcessingFailedText(fileName: string, status: string): string { + return this.text("getMistralDocumentProcessingFailedText", "Mistral could not process document {fileName}: {status}", {fileName, status}); + } + + static getMistralDocumentProcessingTimedOutText(fileName: string): string { + return this.text("getMistralDocumentProcessingTimedOutText", "Mistral did not process document {fileName} within the allotted time.", {fileName}); + } + + static getAttachmentMissingFromCacheText(fileName: string): string { + return this.text("getAttachmentMissingFromCacheText", "⚠️ Attachment file is missing from the cache: {fileName}", {fileName}); + } + + static getZipInvalidLocalHeaderText(entryName: string): string { + return this.text("getZipInvalidLocalHeaderText", "ZIP archive is corrupted: invalid local header for {entryName}.", {entryName}); + } + + static getZipUnsupportedCompressionMethodText(method: number, entryName: string): string { + return this.text("getZipUnsupportedCompressionMethodText", "ZIP archive uses unsupported compression method {method} for {entryName}.", {method, entryName}); + } + + static getGzipUncompressedLimitText(maxBytes: number): string { + return this.text("getGzipUncompressedLimitText", "GZIP archive exceeds the uncompressed data limit ({maxBytes} bytes).", {maxBytes}); + } + + static getNestedArchiveDepthLimitText(maxDepth: number): string { + return this.text("getNestedArchiveDepthLimitText", "nested archive depth limit reached ({maxDepth})", {maxDepth}); + } + + static getUnsupportedArchiveFormatText(fileName: string): string { + return this.text("getUnsupportedArchiveFormatText", "Archive format \"{fileName}\" is not supported by local RAG.", {fileName}); + } + + static getDocumentEmptyOrNoExtractableText(fileName: string): string { + return this.text("getDocumentEmptyOrNoExtractableText", "Document \"{fileName}\" is empty or contains no extractable text.", {fileName}); + } + + static getUnsupportedLocalRagDocumentFormatText(fileName: string): string { + return this.text("getUnsupportedLocalRagDocumentFormatText", "Document format \"{fileName}\" is not supported by local RAG. Supported formats: text files, code, CSV, JSON, Markdown, YAML, XML, DOCX, text PDFs, and ZIP/TAR/GZIP archives containing those files.", {fileName}); + } + + static getOllamaEmbeddingInvalidResponseText(model: string): string { + return this.text("getOllamaEmbeddingInvalidResponseText", "Ollama embedding model \"{model}\" returned an invalid response.", {model}); + } + + static getProviderNotAvailableForAccessText(providerName: string): string { + return this.text("getProviderNotAvailableForAccessText", "Provider {providerName} is not available for your access level.", {providerName}); + } + + static getProviderSpeechToTextUnsupportedText(providerName: string): string { + return this.text("getProviderSpeechToTextUnsupportedText", "Provider {providerName} does not support speech-to-text or is not configured for it.", {providerName}); + } + + static getProviderTextToSpeechUnsupportedText(providerName: string): string { + return this.text("getProviderTextToSpeechUnsupportedText", "Provider {providerName} does not support text-to-speech or is not configured for it.", {providerName}); + } + + static getTextToSpeechTooLongText(actualLength: number, maxLength: number): string { + return this.text("getTextToSpeechTooLongText", "Text for speech synthesis is too long: {actualLength} characters, maximum {maxLength}.", { + actualLength, + maxLength, + }); + } + + static getTextToSpeechCaption(providerName: string, model: string, voice?: string): string { + return [ + `TTS: ${providerName}`, + `model: ${model}`, + voice ? `voice: ${voice}` : null, + ].filter(Boolean).join("\n"); + } + + static getQrCodeTextTooLongText(actualLength: number, maxLength: number): string { + return this.text("getQrCodeTextTooLongText", "Text is too long for QR ({actualLength} characters). It will be trimmed to {maxLength} characters.", { + actualLength, + maxLength, + }); + } + + static getQrCodeReadyText(content: string): string { + return this.text("getQrCodeReadyText", "QR code ready ✅\nContent:\n
{content}
", {content}); + } + + static getQrCodeFailedText(error: unknown): string { + return this.text("getQrCodeFailedText", "Could not generate QR: {reason}", { + reason: error instanceof Error ? error.message : String(error), + }); + } + static get shutdownFallbackText() { return this.text("shutdownFallbackText", "..."); } + static get shutdownSequenceTexts() { + return this.textArray("shutdownSequenceTexts", [ + "well then, everyone", + "it was nice talking to you", + "but it is time for me to rest", + "all the best", + ]); + } + static get shutdownDoneText() { return this.text("shutdownDoneText", "*R.I.P*"); } + + static getWhenPrefixText(): string { + return this.text("getWhenPrefixText", "in "); + } + + static get whenNowText() { return this.text("whenNowText", "right now"); } + static get whenNeverText() { return this.text("whenNeverText", "never"); } + static get whenYearUnitText() { return this.text("whenYearUnitText", "year"); } + static get whenDayUnitText() { return this.text("whenDayUnitText", "day"); } + static get whenWeekUnitText() { return this.text("whenWeekUnitText", "week"); } + static get whenMonthUnitText() { return this.text("whenMonthUnitText", "month"); } + static get whenHourUnitText() { return this.text("whenHourUnitText", "hour"); } + static get whenMinuteUnitText() { return this.text("whenMinuteUnitText", "minute"); } + static get whenSecondUnitText() { return this.text("whenSecondUnitText", "second"); } + + static getWhenDurationText(value: number, unit: string): string { + const pluralUnit = value === 1 ? unit : this.text("getWhenPluralUnitText", "{unit}s", {unit}); + return this.text("getWhenDurationText", "{prefix}{value} {unit}", { + prefix: this.getWhenPrefixText(), + value, + unit: pluralUnit, + }); + } + + static getPingReportText( + telegramPingMs: string, + apiPingMs: string, + messageDate: string, + messageTime: string, + localDate: string, + localTime: string, + ): string { + return this.text("getPingReportText", "```ping\nTG: {telegramPingMs}ms\nAPI {apiPingMs}ms\n\n🗓️ Message date: {messageDate}\n🕒 Message time: {messageTime}\n\n🗓️ Local date : {localDate}\n🕒 Local time: {localTime}```", { + telegramPingMs, + apiPingMs, + messageDate, + messageTime, + localDate, + localTime, + }); + } + + static getAiProviderMaxConcurrentRequests(provider: AiProvider): number { + switch (provider) { + case AiProvider.OLLAMA: + return Environment.OLLAMA_MAX_CONCURRENT_REQUESTS; + case AiProvider.GEMINI: + return Environment.GEMINI_MAX_CONCURRENT_REQUESTS; + case AiProvider.MISTRAL: + return Environment.MISTRAL_MAX_CONCURRENT_REQUESTS; + case AiProvider.OPENAI: + return Environment.OPENAI_MAX_CONCURRENT_REQUESTS; + } + } private static processEnvAsRecord(): EnvRecord { return Object.fromEntries( @@ -308,8 +1128,12 @@ export class Environment { }; } + static getOptionalConfigValue(name: string): string | undefined { + return normalizeString(Environment.readConfigSource()[name]); + } + private static getSystemPromptPath(): string { - return path.join(Environment.DATA_PATH, "system_prompt.txt"); + return path.join(Environment.DATA_PATH, "SYSTEM_PROMPT.md"); } private static readSystemPrompt(): string | undefined { @@ -323,6 +1147,10 @@ export class Environment { return prompt.length > 0 ? prompt : undefined; } + private static refreshSystemPrompt(): void { + Environment.SYSTEM_PROMPT = Environment.readSystemPrompt() ?? Environment.envSystemPrompt; + } + private static applyStartupEnv(env: StartupEnv): void { Environment.BOT_TOKEN = env.BOT_TOKEN; Environment.TEST_ENVIRONMENT = env.TEST_ENVIRONMENT; @@ -346,6 +1174,8 @@ export class Environment { Environment.ENABLE_UNSAFE_EVAL = env.ENABLE_UNSAFE_EVAL; Environment.MAX_PHOTO_SIZE = env.MAX_PHOTO_SIZE; Environment.PROCESS_LINKS = env.PROCESS_LINKS; + Environment.LOCALES_DIR = env.LOCALES_DIR; + Localization.configure(env.LOCALES_DIR); Environment.RATE_LIMIT_FALLBACK_POLICY = env.RATE_LIMIT_FALLBACK_POLICY; Environment.IMAGE_HANDLE_POLICY = env.IMAGE_HANDLE_POLICY; @@ -358,29 +1188,60 @@ export class Environment { ? path.resolve(env.FILE_TOOLS_ROOT_DIR) : undefined; + Environment.ENABLE_FS_TOOLS = env.ENABLE_FS_TOOLS ?? false; + Environment.DEFAULT_AI_PROVIDER = env.DEFAULT_AI_PROVIDER; + Environment.envSystemPrompt = env.SYSTEM_PROMPT; + Environment.SYSTEM_PROMPT = env.SYSTEM_PROMPT; Environment.USE_NAMES_IN_PROMPT = env.USE_NAMES_IN_PROMPT; Environment.USE_SYSTEM_PROMPT = env.USE_SYSTEM_PROMPT; Environment.SEND_TIME_TOOK = env.SEND_TIME_TOOK ?? false; + Environment.ENABLE_PYTHON_INTERPRETER = env.ENABLE_PYTHON_INTERPRETER ?? false; + Environment.OLLAMA_API_KEY = env.OLLAMA_API_KEY; Environment.OLLAMA_ADDRESS = env.OLLAMA_ADDRESS; - Environment.OLLAMA_MODEL = env.OLLAMA_MODEL; - Environment.OLLAMA_IMAGE_MODEL = env.OLLAMA_IMAGE_MODEL ?? env.OLLAMA_MODEL; - Environment.OLLAMA_THINK_MODEL = env.OLLAMA_THINK_MODEL ?? env.OLLAMA_MODEL; + Environment.OLLAMA_CHAT_MODEL = env.OLLAMA_CHAT_MODEL; + Environment.OLLAMA_IMAGE_MODEL = env.OLLAMA_IMAGE_MODEL ?? env.OLLAMA_CHAT_MODEL; + Environment.OLLAMA_THINK_MODEL = env.OLLAMA_THINK_MODEL ?? env.OLLAMA_CHAT_MODEL; + Environment.OLLAMA_AUDIO_MODEL = env.OLLAMA_AUDIO_MODEL ?? env.OLLAMA_CHAT_MODEL; + Environment.OLLAMA_EMBEDDING_MODEL = env.OLLAMA_EMBEDDING_MODEL; + Environment.OLLAMA_RAG_CHUNK_SIZE = env.OLLAMA_RAG_CHUNK_SIZE; + Environment.OLLAMA_RAG_CHUNK_OVERLAP = Math.min(env.OLLAMA_RAG_CHUNK_OVERLAP, Math.max(1, env.OLLAMA_RAG_CHUNK_SIZE - 1)); + Environment.OLLAMA_RAG_TOP_K = env.OLLAMA_RAG_TOP_K; + Environment.OLLAMA_RAG_MAX_CONTEXT_CHARS = env.OLLAMA_RAG_MAX_CONTEXT_CHARS; + Environment.OLLAMA_RAG_MIN_SCORE = env.OLLAMA_RAG_MIN_SCORE; + Environment.OLLAMA_RAG_MAX_ARCHIVE_FILES = env.OLLAMA_RAG_MAX_ARCHIVE_FILES; + Environment.OLLAMA_RAG_MAX_ARCHIVE_BYTES = env.OLLAMA_RAG_MAX_ARCHIVE_BYTES; + Environment.OLLAMA_RAG_MAX_ARCHIVE_DEPTH = env.OLLAMA_RAG_MAX_ARCHIVE_DEPTH; + Environment.OLLAMA_MAX_CONCURRENT_REQUESTS = env.OLLAMA_MAX_CONCURRENT_REQUESTS; Environment.GEMINI_API_KEY = env.GEMINI_API_KEY; + Environment.GEMINI_API_MODE = env.GEMINI_API_MODE as typeof Environment.GEMINI_API_MODE; Environment.GEMINI_MODEL = env.GEMINI_MODEL; Environment.GEMINI_IMAGE_MODEL = env.GEMINI_IMAGE_MODEL; + Environment.GEMINI_TRANSCRIPTION_MODEL = env.GEMINI_TRANSCRIPTION_MODEL; + Environment.GEMINI_TTS_MODEL = env.GEMINI_TTS_MODEL; + Environment.GEMINI_TTS_VOICE = env.GEMINI_TTS_VOICE; + Environment.GEMINI_MAX_CONCURRENT_REQUESTS = env.GEMINI_MAX_CONCURRENT_REQUESTS; Environment.MISTRAL_API_KEY = env.MISTRAL_API_KEY; Environment.MISTRAL_MODEL = env.MISTRAL_MODEL; + Environment.MISTRAL_TRANSCRIPTION_MODEL = env.MISTRAL_TRANSCRIPTION_MODEL; + Environment.MISTRAL_TTS_MODEL = env.MISTRAL_TTS_MODEL; + Environment.MISTRAL_TTS_VOICE_ID = env.MISTRAL_TTS_VOICE_ID; + Environment.MISTRAL_MAX_CONCURRENT_REQUESTS = env.MISTRAL_MAX_CONCURRENT_REQUESTS; Environment.OPENAI_BASE_URL = env.OPENAI_BASE_URL; Environment.OPENAI_API_KEY = env.OPENAI_API_KEY; Environment.OPENAI_MODEL = env.OPENAI_MODEL; Environment.OPENAI_IMAGE_MODEL = env.OPENAI_IMAGE_MODEL; + Environment.OPENAI_TRANSCRIPTION_MODEL = env.OPENAI_TRANSCRIPTION_MODEL; + Environment.OPENAI_TTS_MODEL = env.OPENAI_TTS_MODEL; + Environment.OPENAI_TTS_VOICE = env.OPENAI_TTS_VOICE; + Environment.OPENAI_TTS_INSTRUCTIONS = env.OPENAI_TTS_INSTRUCTIONS; + Environment.OPENAI_MAX_CONCURRENT_REQUESTS = env.OPENAI_MAX_CONCURRENT_REQUESTS; } static load(): void { @@ -392,7 +1253,7 @@ export class Environment { Environment.applyStartupEnv(startupEnv); Environment.applyRuntimeEnv(runtimeEnv); - Environment.SYSTEM_PROMPT = Environment.readSystemPrompt(); + Environment.refreshSystemPrompt(); Environment.lastEnvMtimeMs = Environment.getFileMtimeMs(Environment.ENV_FILE_PATH); Environment.lastSystemPromptMtimeMs = Environment.getFileMtimeMs(Environment.getSystemPromptPath()); @@ -406,6 +1267,8 @@ export class Environment { const envChanged = envMtimeMs !== Environment.lastEnvMtimeMs; const systemPromptChanged = systemPromptMtimeMs !== Environment.lastSystemPromptMtimeMs; + Localization.reloadIfChanged(); + if (!envChanged && !systemPromptChanged) { return; } @@ -415,11 +1278,12 @@ export class Environment { const runtimeEnv = RuntimeEnvSchema.parse(rawEnv); Environment.applyRuntimeEnv(runtimeEnv); + Environment.refreshSystemPrompt(); Environment.lastEnvMtimeMs = envMtimeMs; } if (systemPromptChanged) { - Environment.SYSTEM_PROMPT = Environment.readSystemPrompt(); + Environment.refreshSystemPrompt(); Environment.lastSystemPromptMtimeMs = systemPromptMtimeMs; } } catch (e) { @@ -522,7 +1386,7 @@ export class Environment { } static setOllamaModel(ollamaModel: string): void { - this.OLLAMA_MODEL = ollamaModel; + this.OLLAMA_CHAT_MODEL = ollamaModel; } static setOllamaThinkModel(ollamaThinkModel: string): void { @@ -545,6 +1409,14 @@ export class Environment { this.GEMINI_IMAGE_MODEL = newImageModel; } + static setGeminiTranscriptionModel(newModel: string): void { + this.GEMINI_TRANSCRIPTION_MODEL = newModel; + } + + static setGeminiTtsModel(newModel: string): void { + this.GEMINI_TTS_MODEL = newModel; + } + static setMistralApiKey(newMistralApiKey: string | undefined): void { this.MISTRAL_API_KEY = newMistralApiKey; } @@ -553,6 +1425,14 @@ export class Environment { this.MISTRAL_MODEL = newModel; } + static setMistralTranscriptionModel(newModel: string): void { + this.MISTRAL_TRANSCRIPTION_MODEL = newModel; + } + + static setMistralTtsModel(newModel: string): void { + this.MISTRAL_TTS_MODEL = newModel; + } + static setOpenAIBaseUrl(newAIBaseUrl: string | undefined): void { this.OPENAI_BASE_URL = newAIBaseUrl; } @@ -568,4 +1448,12 @@ export class Environment { static setOpenAIImageModel(newImageModel: string): void { this.OPENAI_IMAGE_MODEL = newImageModel; } -} \ No newline at end of file + + static setOpenAITranscriptionModel(newModel: string): void { + this.OPENAI_TRANSCRIPTION_MODEL = newModel; + } + + static setOpenAITtsModel(newModel: string): void { + this.OPENAI_TTS_MODEL = newModel; + } +} diff --git a/src/common/localization.ts b/src/common/localization.ts new file mode 100644 index 0000000..8da30ed --- /dev/null +++ b/src/common/localization.ts @@ -0,0 +1,251 @@ +import {AsyncLocalStorage} from "node:async_hooks"; +import fs from "node:fs"; +import path from "node:path"; + +export const DEFAULT_LOCALE = "en"; +export const DEFAULT_LANGUAGE_CHOICE = "default"; + +export type LanguageChoice = string; +export type LocalizationParam = string | number | boolean | null | undefined; +export type LocalizationParams = Record; +type LocalizationBundle = Record; + +const KNOWN_LANGUAGE_ORDER = ["en", "ru", "ua"]; + +function normalizeLanguageCode(value: string | undefined | null): string | undefined { + const normalized = value?.trim().toLowerCase().replace("_", "-"); + if (!normalized) return undefined; + + const code = normalized.split("-")[0]; + return code === "uk" ? "ua" : code; +} + +function readMtimeMs(filePath: string): number | undefined { + try { + return fs.statSync(filePath).mtimeMs; + } catch (e: any) { + if (e?.code === "ENOENT") return undefined; + throw e; + } +} + +function valueByPath(bundle: LocalizationBundle, key: string): unknown { + if (Object.prototype.hasOwnProperty.call(bundle, key)) { + return bundle[key]; + } + + return key.split(".").reduce((value, part) => { + if (!value || typeof value !== "object") return undefined; + return (value as Record)[part]; + }, bundle); +} + +function interpolate(value: string, params: LocalizationParams): string { + return value.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => { + const param = params[key]; + return param === undefined || param === null ? match : String(param); + }); +} + +export class Localization { + private static localesDir = path.resolve("locales"); + private static bundles = new Map(); + private static fileMtimeMs = new Map(); + private static fileSignature = ""; + private static readonly storage = new AsyncLocalStorage(); + + static configure(localesDir: string): void { + Localization.localesDir = path.resolve(localesDir); + Localization.reload(true); + } + + static reloadIfChanged(): void { + Localization.reload(false); + } + + static runWithLocale(locale: string, callback: () => T): T { + const resolved = Localization.normalizeLocale(locale) ?? DEFAULT_LOCALE; + return Localization.storage.run(resolved, callback); + } + + static currentLocale(): string { + return Localization.storage.getStore() ?? DEFAULT_LOCALE; + } + + static resolveLocale(choice: LanguageChoice | undefined | null, telegramLanguageCode?: string): string { + Localization.reloadIfChanged(); + + const normalizedChoice = Localization.normalizeLocale(choice); + if (normalizedChoice && normalizedChoice !== DEFAULT_LANGUAGE_CHOICE && Localization.bundles.has(normalizedChoice)) { + return normalizedChoice; + } + + const telegramLocale = Localization.normalizeLocale(telegramLanguageCode); + if (telegramLocale && Localization.bundles.has(telegramLocale)) { + return telegramLocale; + } + + return Localization.bundles.has(DEFAULT_LOCALE) + ? DEFAULT_LOCALE + : Localization.availableLocaleCodes()[0] ?? DEFAULT_LOCALE; + } + + static normalizeLocale(value: LanguageChoice | undefined | null): string | undefined { + return normalizeLanguageCode(value); + } + + static isKnownLanguageChoice(value: string | undefined | null): boolean { + if (!value) return false; + if (value === DEFAULT_LANGUAGE_CHOICE) return true; + + const normalized = Localization.normalizeLocale(value); + if (!normalized) return false; + + Localization.reloadIfChanged(); + return Localization.bundles.has(normalized); + } + + static availableLocaleCodes(): string[] { + Localization.reloadIfChanged(); + + return [...Localization.bundles.keys()].sort((a, b) => { + const aIndex = KNOWN_LANGUAGE_ORDER.indexOf(a); + const bIndex = KNOWN_LANGUAGE_ORDER.indexOf(b); + + if (aIndex !== -1 || bIndex !== -1) { + return (aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) + - (bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex); + } + + return a.localeCompare(b); + }); + } + + static languageChoices(): string[] { + return [DEFAULT_LANGUAGE_CHOICE, ...Localization.availableLocaleCodes()]; + } + + static languageLabel(choice: LanguageChoice): string { + if (choice === DEFAULT_LANGUAGE_CHOICE) { + return Localization.text("language.default", {}, "Default"); + } + + const locale = Localization.normalizeLocale(choice) ?? choice; + return Localization.text(`language.${locale}`, {}, locale.toUpperCase()); + } + + static languageInstructionName(choice: LanguageChoice): string { + if (choice === DEFAULT_LANGUAGE_CHOICE) return ""; + + const locale = Localization.normalizeLocale(choice) ?? choice; + const bundle = Localization.bundles.get(locale); + const value = bundle ? valueByPath(bundle, "language.instructionName") : undefined; + return typeof value === "string" && value.trim().length > 0 ? value : locale; + } + + static text( + key: string, + params: LocalizationParams = {}, + fallback = key, + locale = Localization.currentLocale(), + ): string { + Localization.reloadIfChanged(); + + const value = Localization.lookup(locale, key); + return interpolate(typeof value === "string" ? value : fallback, params); + } + + static textArray( + key: string, + params: LocalizationParams = {}, + fallback: string[] = [], + locale = Localization.currentLocale(), + ): string[] { + Localization.reloadIfChanged(); + + const value = Localization.lookup(locale, key); + const values = Array.isArray(value) && value.every(item => typeof item === "string") + ? value + : fallback; + + return values.map(item => interpolate(item, params)); + } + + private static lookup(locale: string, key: string): unknown { + const normalized = Localization.normalizeLocale(locale) ?? DEFAULT_LOCALE; + const bundleValue = Localization.lookupInBundle(normalized, key); + if (bundleValue !== undefined) return bundleValue; + + if (normalized !== DEFAULT_LOCALE) { + const fallbackValue = Localization.lookupInBundle(DEFAULT_LOCALE, key); + if (fallbackValue !== undefined) return fallbackValue; + } + + return undefined; + } + + private static lookupInBundle(locale: string, key: string): unknown { + const bundle = Localization.bundles.get(locale); + return bundle ? valueByPath(bundle, key) : undefined; + } + + private static listLocaleFiles(): Map { + const files = new Map(); + + if (!fs.existsSync(Localization.localesDir)) { + return files; + } + + for (const entry of fs.readdirSync(Localization.localesDir, {withFileTypes: true})) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + + const locale = Localization.normalizeLocale(path.basename(entry.name, ".json")); + if (locale) { + files.set(locale, path.join(Localization.localesDir, entry.name)); + } + } + + return files; + } + + private static reload(force: boolean): void { + try { + const files = Localization.listLocaleFiles(); + const signature = [...files.entries()] + .map(([locale, filePath]) => `${locale}:${filePath}`) + .sort() + .join("|"); + + const mtimes = new Map(); + let changed = force || signature !== Localization.fileSignature; + + for (const [locale, filePath] of files) { + const mtimeMs = readMtimeMs(filePath); + mtimes.set(locale, mtimeMs); + + if (mtimeMs !== Localization.fileMtimeMs.get(locale)) { + changed = true; + } + } + + if (!changed) return; + + const bundles = new Map(); + for (const [locale, filePath] of files) { + try { + bundles.set(locale, JSON.parse(fs.readFileSync(filePath, "utf8")) as LocalizationBundle); + } catch (e) { + console.error(`Failed to load localization file ${filePath}`, e); + const previous = Localization.bundles.get(locale); + if (previous) bundles.set(locale, previous); + } + } + + Localization.bundles = bundles; + Localization.fileMtimeMs = mtimes; + Localization.fileSignature = signature; + } catch (e) { + console.error("Failed to reload localization files", e); + } + } +}