config: add env schema and localization foundation

This commit is contained in:
2026-05-10 22:51:52 +03:00
parent 986d4aca46
commit 28f67aefc2
6 changed files with 1912 additions and 29 deletions
+63 -3
View File
@@ -31,25 +31,85 @@ ONLY_FOR_CREATOR_MODE=false
# Use user names in AI prompts # Use user names in AI prompts
USE_NAMES_IN_PROMPT=true 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 # Maximum photo size in pixels
MAX_PHOTO_SIZE=1280 MAX_PHOTO_SIZE=1280
# Directory with localization JSON files
LOCALES_DIR=locales
# ============================================ # ============================================
# AI MODELS CONFIGURATION (Optional) # AI MODELS CONFIGURATION (Optional)
# ============================================ # ============================================
# Google Gemini # Google Gemini
GEMINI_API_KEY= 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_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 AI
MISTRAL_API_KEY= MISTRAL_API_KEY=
MISTRAL_MODEL=mistral-small-latest 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 (Local AI Model)
OLLAMA_ADDRESS= OLLAMA_ADDRESS=
OLLAMA_MODEL= OLLAMA_CHAT_MODEL=
OLLAMA_IMAGE_MODEL=
OLLAMA_THINK_MODEL=
OLLAMA_AUDIO_MODEL=gemma4:e2b
OLLAMA_API_KEY= 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 # OpenAI
SYSTEM_PROMPT= OPENAI_API_KEY=
OPENAI_BASE_URL=
OPENAI_MODEL=gpt-4.1-nano
OPENAI_IMAGE_MODEL=gpt-image-1-mini
OPENAI_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe
OPENAI_TTS_MODEL=gpt-4o-mini-tts
OPENAI_TTS_VOICE=alloy
OPENAI_TTS_INSTRUCTIONS=
OPENAI_MAX_CONCURRENT_REQUESTS=3
# Per-capability AI endpoint overrides
# Pattern:
# <PROVIDER>_<CAPABILITY>_MODEL=
# <PROVIDER>_<CAPABILITY>_BASE_URL=
# <PROVIDER>_<CAPABILITY>_API_KEY=
#
# Providers: OLLAMA, GEMINI, MISTRAL, OPENAI
# Capabilities: CHAT, VISION, OCR, THINKING, EXTENDED_THINKING, TOOLS, AUDIO,
# DOCUMENTS, OUTPUT_IMAGES, SPEECH_TO_TEXT, TEXT_TO_SPEECH
# Endpoint aliases are also supported: *_ENDPOINT and *_ADDRESS.
# Provider-wide fallback endpoints: OPENAI_BASE_URL, MISTRAL_BASE_URL,
# GEMINI_BASE_URL, OLLAMA_ADDRESS or OLLAMA_BASE_URL.
# Capability aliases are also supported: IMAGE, THINK, RAG, EMBEDDING,
# TRANSCRIPTION, STT, TTS.
#
# Examples:
# OPENAI_SPEECH_TO_TEXT_MODEL=gpt-4o-mini-transcribe
# OPENAI_SPEECH_TO_TEXT_BASE_URL=https://api.openai.com/v1
# OPENAI_SPEECH_TO_TEXT_API_KEY=
# MISTRAL_TTS_BASE_URL=
# OLLAMA_DOCUMENTS_ADDRESS=http://localhost:11434
+223
View File
@@ -0,0 +1,223 @@
{
"language": {
"default": "Default",
"en": "English",
"ru": "Russian",
"ua": "Ukrainian",
"instructionName": "English"
},
"providerChoice.default": "Default",
"errorText": "⚠️ An error occurred.",
"waitThinkText": "⏳ Let me think...",
"analyzingPictureText": "🔍 Analyzing the image...",
"analyzingPicturesText": "🔍 Analyzing the images...",
"reasoningText": "🤔 Reasoning...",
"transcribingAudioText": "🦻 Transcribing audio...",
"genImageText": "👨‍🎨 Generating an image...",
"cancelText": "❌ Cancel",
"regenerateText": "🔄 Regenerate",
"aiCancelCallbackText": "Cancel AI generation",
"aiRegenerateCallbackText": "Regenerate AI response",
"userSettingsCallbackText": "User settings",
"noAccessText": "No access",
"notBotCreatorText": "You are not the bot creator.",
"notBotAdministratorText": "You are not a bot administrator.",
"notAChatText": "This is not a chat.",
"notChatAdministratorText": "You are not a chat administrator.",
"botNotChatAdministratorText": "The bot is not a chat administrator.",
"replyRequiredText": "A reply to a message is required.",
"onlyOriginalAuthorText": "Only the author of the original message can perform this action.",
"commandsHeaderText": "Commands:\n\n",
"sentCommandsInDmText": "Sent commands in DM 😎",
"couldNotSendCommandsInDmText": "Could not send commands in DM ☹️\nSending them here instead",
"administratorsHeaderText": "*Administrators*:\n\n",
"noUserInfoText": "No user information",
"useLeaveCommandText": "Use /leave",
"databaseBackupCaption": "Database backup",
"databaseBackupSentText": "Successfully sent to the creator in DM!",
"noChoicesText": "Nothing to choose from",
"qrCodeMissingTextText": "No text found for QR code generation.",
"quoteMissingTextText": "Could not find text in the message 😢",
"quoteBuildFailedText": "Could not build the quote 😢",
"speechToTextInstructionText": "Send audio/voice/video-note or reply with /stt to a message containing audio.",
"speechToTextEmptyResultText": "Speech-to-text did not return transcription text.",
"textToSpeechInstructionText": "Send text after the command or reply with /tts to a message containing text.",
"titleMissingText": "Could not find a title...",
"betterFallbackText": "Better",
"pongText": "pong",
"modelListHeaderText": "Available models:\n\n",
"modelListLoadFailedText": "Could not load the model list",
"noCurrentModelText": "Model is not set. Use one of the listed values.",
"unsupportedAttachmentText": "This attachment type is not supported.",
"attachmentMissingFromCacheText": "Attachment file is missing from cache.",
"noSupportedTranscriptionProviderText": "No supported speech-to-text provider is configured.",
"noSupportedTextToSpeechProviderText": "No supported text-to-speech provider is configured.",
"noSpeechToTextProviderForAccessText": "No speech-to-text providers are configured for your access level.",
"noTextToSpeechProviderForAccessText": "No text-to-speech providers are configured for your access level.",
"noTextToSynthesizeText": "No text to synthesize.",
"speechFileTooLargeText": "The speech file is larger than 50 MB and cannot be sent.",
"userSettingsTitle": "User Settings",
"userSettingsAiProviderSelectionTitle": "AI Provider Selection",
"userSettingsInterfaceLanguageSelectionTitle": "Interface Language Selection",
"userSettingsResponseLanguageSelectionTitle": "Response Language Selection",
"userSettingsContextSizeSelectionTitle": "Context Size Selection",
"userSettingsVoiceModeSelectionTitle": "Voice Message Mode Selection",
"userSettingsTierLabel": "Tier",
"userSettingsAiProviderLabel": "AI provider",
"userSettingsInterfaceLanguageLabel": "Interface language",
"userSettingsResponseLanguageLabel": "LLM response language",
"userSettingsContextSizeLabel": "Context size",
"userSettingsVoiceModeLabel": "Voice messages",
"userSettingsBackButtonText": "Back",
"userSettingsAiProviderButtonPrefix": "AI provider",
"userSettingsInterfaceLanguageButtonPrefix": "Interface language",
"userSettingsResponseLanguageButtonPrefix": "Response language",
"userSettingsContextSizeButtonPrefix": "Context",
"userSettingsVoiceModeButtonPrefix": "Voice",
"userSettingsCreatorTierText": "Creator",
"userSettingsAdminTierText": "Admin",
"userSettingsUserTierText": "User",
"userSettingsSelectedPrefix": "✓ ",
"userSettingsContextSizeDefaultText": "Default",
"userSettingsVoiceModeExecuteText": "Run through AI",
"userSettingsVoiceModeTranscriptText": "Show transcript only",
"startingImageGenText": "🌈 Starting image generation...",
"imageGenText": "🌈 Generating image...",
"finalizingImageGenText": "🌈 Finalizing image generation...",
"botCannotMakeItselfAdminText": "The bot cannot make itself an admin",
"botCreatorAlreadyAdminText": "The bot creator is already an admin",
"botCannotRemoveItselfFromAdminsText": "The bot cannot remove itself from admins",
"botCreatorCannotStopBeingAdminText": "The bot creator cannot stop being an admin",
"botWillNotBanCreatorText": "The bot will not ban its creator.",
"botWillNotBanAdminsText": "The bot will not ban its administrators.",
"botIsNotBannedByItselfText": "The bot is not banned by itself anyway.",
"botCreatorNeverBannedText": "The bot creator is not banned and never will be.",
"botAdminsNotBannedText": "Bot administrators are not banned anyway.",
"botWillNotIgnoreItselfText": "The bot will not ignore itself.",
"botWillNotIgnoreCreatorText": "The bot will not ignore its creator.",
"botWillNotIgnoreAdminsText": "The bot will not ignore its administrators.",
"botIsNotIgnoredByItselfText": "The bot is not ignored by itself anyway.",
"botCreatorNotIgnoredText": "The bot creator is not ignored and never will be.",
"botAdminsNotIgnoredText": "Bot administrators are not ignored anyway.",
"botAlreadyAlwaysListensToItselfText": "The bot already always listens to itself",
"botAlwaysListensToCreatorText": "The bot always listens to its creator",
"coinHeadsText": "Heads",
"coinTailsText": "Tails",
"distortReplyInstructionText": "Reply with /distort to a message containing an image (photo, document, or sticker).\nExample: /distort 16 80",
"distortMissingImageText": "I do not see an image in the reply. Send a photo or image file.",
"shutdownFallbackText": "...",
"shutdownSequenceTexts": [
"well then, everyone",
"it was nice talking to you",
"but it is time for me to rest",
"all the best"
],
"shutdownDoneText": "*R.I.P*",
"whenNowText": "right now",
"whenNeverText": "never",
"whenYearUnitText": "year",
"whenDayUnitText": "day",
"whenWeekUnitText": "week",
"whenMonthUnitText": "month",
"whenHourUnitText": "hour",
"whenMinuteUnitText": "minute",
"whenSecondUnitText": "second",
"getCancelledText": "{provider}\n❌ Generation cancelled.",
"getPartialImageGenText": "🌈 Generating image ({iteration}/{total})...",
"getImageGenDoneText.withModel": "👨‍🎨 Image generated. Model: `{model}`.",
"getImageGenDoneText.default": "👨‍🎨 Image generated.",
"getErrorText.withReason": "{errorText} Reason:\n{reason}",
"getUseToolText.python": "👨‍💻 Running `Python`",
"getUseToolText.default": "🔧 Using tool `{name}`",
"getAnalyzingDocumentText.default": "🔍 Analyzing the document...",
"getAnalyzingDocumentText.single": "🔍 Analyzing document: `{name}`",
"getAnalyzingDocumentText.many": "🔍 Analyzing documents: {names}",
"getPreparingRAGText.default": "🔍 Preparing RAG for the document...",
"getPreparingRAGText.single": "🔍 Preparing RAG for document: `{name}`",
"getPreparingRAGText.many": "🔍 Preparing RAG for documents: {names}",
"getBuildingRAGIndexText.default": "🧠 Building RAG index...",
"getBuildingRAGIndexText.withModel": "🧠 Building RAG index: `{modelName}`.",
"queueNoneText": "none",
"getAiQueueText.queued": "⏳ Request to {provider} is queued.",
"getAiQueueText.ahead": "Requests ahead: {count}.",
"getTelegramFileTooLargeText": "File {fileName} is larger than {maxSizeMb} MB and cannot be sent.",
"getUserIsNowAdminText": "{name} is now an admin!",
"getUserAlreadyAdminText": "{name} is already an admin 🤔",
"getUserNoLongerAdminText": "{name} is no longer an admin!",
"getUserWasNotAdminText": "{name} was not an admin 🤔",
"getUserBannedText": "{name} banned 🚫",
"getUserBanFailedText": "Could not ban {name} ☹️",
"getUserUnbannedText": "{name} unbanned ⛓️‍💥",
"getUserUnbanFailedText": "Could not unban {name} ☹️",
"getUserIgnoredText": "{name} is muted! 🔇",
"getUserAlreadyIgnoredText": "{name} is already muted 🤔",
"getUserIgnoreFailedText": "Could not mute {name} ☹️",
"getUserUnignoredText": "{name} is no longer muted! 🔈",
"getUserWasNotIgnoredText": "{name} was not muted 🤔",
"getUserUnignoreFailedText": "Could not unmute {name} ☹️",
"getChoiceText": "Chose *{choice}*",
"getCoinResultText": "It landed on *{result}*",
"getLoadedModelsText": "Loaded models: {models}",
"getSelectedModelText": "Selected model: `{model}`",
"getCurrentModelText": "Current model: `{model}`",
"getLoadingModelText": "Loading model `{model}`...",
"getUserSettingsContextSizeText": "{size} tokens",
"getQrCodeTextTooLongText": "Text is too long for QR ({actualLength} characters). It will be trimmed to {maxLength} characters.",
"getQrCodeReadyText": "QR code ready ✅\nContent:\n<blockquote expandable>{content}</blockquote>",
"getQrCodeFailedText": "Could not generate QR: {reason}",
"getWhenPrefixText": "in ",
"getWhenPluralUnitText": "{unit}s",
"getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": {
"ae": "evaluation",
"adminsAdd": "Add user to admins",
"adminsRemove": "Remove user from admins",
"ban": "ban user from chat",
"choice": "Choose a random value",
"coin": "Heads or tails",
"debug": "Returns msg (or reply) as json",
"dice": "Sends random or specific dice",
"distort": "Distortion of picture",
"geminiChat": "Chat with AI (Gemini)",
"geminiGetModel": "Get current Gemini model",
"geminiListModels": "List all Gemini models",
"geminiSetModel": "Set Gemini model",
"help": "Show list of commands",
"id": "ID of chat, user and reply (if replied to any message)",
"ignore": "Bot will ignore user",
"info": "Info about bot",
"leave": "Bot will leave current chat",
"mistralChat": "Chat with AI (Mistral)",
"mistralGetModel": "Get current Mistral model",
"mistralListModels": "List all Mistral models",
"mistralSetModel": "Set Mistral model",
"ollamaChat": "Chat with AI (Ollama)",
"ollamaGetModel": "Get current Ollama model",
"ollamaListModels": "List all Ollama models",
"ollamaSearch": "Web search via Ollama",
"ollamaSetModel": "Set Ollama model",
"openAiChat": "Chat with AI (OpenAI)",
"openAiGetModel": "Get current OpenAI model",
"openAiListModels": "List all OpenAI models",
"openAiSetModel": "Set OpenAI model",
"ping": "Ping between received and sent message",
"qr": "Generates QR-code from text you sent or replied to.",
"quote": "Make quote from text (or quote)",
"randomInt": "Ranged random integer from parameters",
"randomString": "literally random string (up to 4096 symbols)",
"settings": "User settings",
"shutdown": "Self-destruction sequence for bot (shutdown)",
"speechToText": "Transcribe speech to text",
"start": "Start the bot",
"systemInfo": "System information",
"textToSpeech": "Generate speech from text",
"title": "Change group title",
"test": "System functionality check",
"transliteration": "Transliteration EN <--> RU",
"unban": "unban user from chat",
"unignore": "Bot will start responding to the user",
"uptime": "Bot's uptime",
"whatBetter": "either a or b randomly (50% chance)",
"when": "random date"
}
}
+249
View File
@@ -0,0 +1,249 @@
{
"language": {
"default": "По умолчанию",
"en": "Английский",
"ru": "Русский",
"ua": "Украинский",
"instructionName": "Russian"
},
"providerChoice.default": "По умолчанию",
"errorText": "⚠️ Произошла ошибка.",
"waitThinkText": "⏳ Думаю...",
"analyzingPictureText": "🔍 Анализирую изображение...",
"analyzingPicturesText": "🔍 Анализирую изображения...",
"reasoningText": "🤔 Рассуждаю...",
"transcribingAudioText": "🦻 Распознаю аудио...",
"genImageText": "👨‍🎨 Генерирую изображение...",
"cancelText": "❌ Отмена",
"regenerateText": "🔄 Сгенерировать заново",
"aiCancelCallbackText": "Отменить генерацию ИИ",
"aiRegenerateCallbackText": "Сгенерировать ответ ИИ заново",
"userSettingsCallbackText": "Настройки пользователя",
"noAccessText": "Нет доступа",
"notBotCreatorText": "Вы не создатель бота.",
"notBotAdministratorText": "Вы не администратор бота.",
"notAChatText": "Это не чат.",
"notChatAdministratorText": "Вы не администратор чата.",
"botNotChatAdministratorText": "Бот не является администратором чата.",
"replyRequiredText": "Нужно ответить на сообщение.",
"onlyOriginalAuthorText": "Это действие доступно только автору исходного сообщения.",
"dockerContainerLabelText": "Docker-контейнер",
"processLabelText": "Процесс",
"systemLabelText": "Система",
"systemInfoOsLabelText": "ОС",
"systemInfoRuntimeLabelText": "RUNTIME",
"systemInfoDockerLabelText": "DOCKER",
"systemInfoCpuLabelText": "CPU",
"systemInfoRamLabelText": "RAM",
"systemInfoCpuCoresText": "ядер",
"systemInfoCpuThreadsText": "потоков",
"idChatLabelText": "id чата",
"idFromLabelText": "id пользователя",
"idReplyLabelText": "id ответа",
"runtimeProviderLabelText": "провайдер",
"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<blockquote expandable>{content}</blockquote>",
"getQrCodeFailedText": "Не удалось сгенерировать QR: {reason}",
"getWhenPrefixText": "через ",
"getWhenPluralUnitText": "{unit}",
"getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": {
"ae": "вычисление",
"adminsAdd": "Добавить пользователя в администраторы",
"adminsRemove": "Удалить пользователя из администраторов",
"ban": "забанить пользователя в чате",
"choice": "Выбрать случайное значение",
"coin": "Орёл или решка",
"debug": "Вернуть msg или reply в JSON",
"dice": "Отправить случайный или конкретный дайс",
"distort": "Искажение изображения",
"geminiChat": "Чат с AI (Gemini)",
"geminiGetModel": "Показать текущую модель Gemini",
"geminiListModels": "Показать все модели Gemini",
"geminiSetModel": "Установить модель Gemini",
"help": "Показать список команд",
"id": "ID чата, пользователя и ответа",
"ignore": "Бот будет игнорировать пользователя",
"info": "Информация о боте",
"leave": "Бот покинет текущий чат",
"mistralChat": "Чат с AI (Mistral)",
"mistralGetModel": "Показать текущую модель Mistral",
"mistralListModels": "Показать все модели Mistral",
"mistralSetModel": "Установить модель Mistral",
"ollamaChat": "Чат с AI (Ollama)",
"ollamaGetModel": "Показать текущую модель Ollama",
"ollamaListModels": "Показать все модели Ollama",
"ollamaSearch": "Веб-поиск через Ollama",
"ollamaSetModel": "Установить модель Ollama",
"openAiChat": "Чат с AI (OpenAI)",
"openAiGetModel": "Показать текущую модель OpenAI",
"openAiListModels": "Показать все модели OpenAI",
"openAiSetModel": "Установить модель OpenAI",
"ping": "Задержка между получением и отправкой сообщения",
"qr": "Сгенерировать QR-код из текста",
"quote": "Сделать цитату из текста",
"randomInt": "Случайное число из диапазона",
"randomString": "Случайная строка до 4096 символов",
"settings": "Настройки пользователя",
"shutdown": "Выключить бота",
"speechToText": "Распознать речь в текст",
"start": "Запустить бота",
"systemInfo": "Информация о системе",
"textToSpeech": "Сгенерировать речь из текста",
"title": "Изменить название группы",
"test": "Проверка системной функциональности",
"transliteration": "Транслитерация EN <--> RU",
"unban": "разбанить пользователя в чате",
"unignore": "Бот снова будет отвечать пользователю",
"uptime": "Время работы бота",
"whatBetter": "случайно выбрать a или b",
"when": "случайная дата"
}
}
+212
View File
@@ -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<blockquote expandable>{content}</blockquote>",
"getQrCodeFailedText": "Не вдалося згенерувати QR: {reason}",
"getWhenPrefixText": "через ",
"getWhenPluralUnitText": "{unit}",
"getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": {
"help": "Показати список команд",
"settings": "Налаштування користувача",
"start": "Запустити бота",
"ping": "Затримка між отриманням і надсиланням повідомлення",
"info": "Інформація про бота",
"systemInfo": "Інформація про систему",
"speechToText": "Розпізнати мовлення в текст",
"textToSpeech": "Згенерувати мовлення з тексту",
"qr": "Згенерувати QR-код з тексту",
"quote": "Створити цитату з тексту",
"choice": "Вибрати випадкове значення",
"coin": "Орел або решка",
"when": "випадкова дата"
}
}
+914 -26
View File
File diff suppressed because it is too large Load Diff
+251
View File
@@ -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<string, LocalizationParam>;
type LocalizationBundle = Record<string, unknown>;
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<unknown>((value, part) => {
if (!value || typeof value !== "object") return undefined;
return (value as Record<string, unknown>)[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<string, LocalizationBundle>();
private static fileMtimeMs = new Map<string, number | undefined>();
private static fileSignature = "";
private static readonly storage = new AsyncLocalStorage<string>();
static configure(localesDir: string): void {
Localization.localesDir = path.resolve(localesDir);
Localization.reload(true);
}
static reloadIfChanged(): void {
Localization.reload(false);
}
static runWithLocale<T>(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<string, string> {
const files = new Map<string, string>();
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<string, number | undefined>();
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<string, LocalizationBundle>();
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);
}
}
}