diff --git a/DESIGN.md b/DESIGN.md index 8de3bfa..b020840 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -118,7 +118,7 @@ Notes: - `/generate` calls `PlaylistJobService.generate_for_user(..., force=True, notify=False)` - `/sync` only refreshes liked tracks cache - each command uses a short-lived DB session from `session_factory` -- bot UI supports `ru` and `en` (localized text/buttons) +- bot UI supports `ru`, `en`, `uk`, and `nl` (localized text/buttons) ### 3. Service layer diff --git a/README.md b/README.md index f3301c2..979fbb3 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Recommended: - `/setsize 30` - playlist size (5..100) - `/setratio 0.8` - target new-track ratio (0.5..1.0) - `/sync` - force sync liked tracks -- `/lang ru|en` - switch bot language +- `/lang ru|en|uk|nl` - switch bot language ## Recommendation Algorithm diff --git a/app/bot/telegram_bot.py b/app/bot/telegram_bot.py index bc85cd5..d987c8d 100644 --- a/app/bot/telegram_bot.py +++ b/app/bot/telegram_bot.py @@ -30,6 +30,8 @@ I18N: dict[str, dict[str, str]] = { "button_ratio_100": "🆕 Новые 100%", "button_lang_ru": "🇺🇦 Русский", "button_lang_en": "🇺🇸 English", + "button_lang_uk": "🇺🇦 Українська", + "button_lang_nl": "🇳🇱 Nederlands", "button_back": "⬅️ Назад", "start_text": ( "Я бот для Spotify daily vibe playlist.\n" @@ -41,7 +43,7 @@ I18N: dict[str, dict[str, str]] = { "/setsize N - размер плейлиста\n" "/setratio X - доля новых треков (0.5..1.0)\n" "/sync - обновить лайкнутые треки из Spotify\n" - "/lang [ru|en] - сменить язык" + "/lang [ru|en|uk|nl] - сменить язык" ), "help_text": ( "Я бот для Spotify daily vibe playlist.\n" @@ -53,7 +55,7 @@ I18N: dict[str, dict[str, str]] = { "/setsize N - размер плейлиста\n" "/setratio X - доля новых треков (0.5..1.0)\n" "/sync - обновить лайкнутые треки из Spotify\n" - "/lang [ru|en] - сменить язык" + "/lang [ru|en|uk|nl] - сменить язык" ), "connect_open_link": "Открой ссылку и авторизуй Spotify:\n{url}", "spotify_connected_notice": "Spotify подключен: {display_name}\nТеперь можно /generate", @@ -71,7 +73,7 @@ I18N: dict[str, dict[str, str]] = { "sync_done": "Лайкнутые треки обновлены: {count}", "lang_choose": "Выбери язык интерфейса:", "lang_set": "Язык интерфейса: {lang_name}", - "lang_invalid": "Поддерживаются только `ru` и `en`.", + "lang_invalid": "Поддерживаются только `ru`, `en`, `uk` и `nl`.", "unknown_action": "Не понял команду. Используйте кнопки ниже или /help.", "main_menu_hint": "Главное меню", "settings_menu_hint": "Настройки", @@ -105,6 +107,8 @@ I18N: dict[str, dict[str, str]] = { "button_ratio_100": "🆕 New 100%", "button_lang_ru": "🇺🇦 Русский", "button_lang_en": "🇺🇸 English", + "button_lang_uk": "🇺🇦 Українська", + "button_lang_nl": "🇳🇱 Nederlands", "button_back": "⬅️ Back", "start_text": ( "I am a Spotify daily vibe playlist bot.\n" @@ -116,7 +120,7 @@ I18N: dict[str, dict[str, str]] = { "/setsize N - playlist size\n" "/setratio X - min new ratio (0.5..1.0)\n" "/sync - sync liked tracks from Spotify\n" - "/lang [ru|en] - change language" + "/lang [ru|en|uk|nl] - change language" ), "help_text": ( "I am a Spotify daily vibe playlist bot.\n" @@ -128,7 +132,7 @@ I18N: dict[str, dict[str, str]] = { "/setsize N - playlist size\n" "/setratio X - min new ratio (0.5..1.0)\n" "/sync - sync liked tracks from Spotify\n" - "/lang [ru|en] - change language" + "/lang [ru|en|uk|nl] - change language" ), "connect_open_link": "Open the link and authorize Spotify:\n{url}", "spotify_connected_notice": "Spotify connected: {display_name}\nNow you can use /generate", @@ -146,7 +150,7 @@ I18N: dict[str, dict[str, str]] = { "sync_done": "Liked tracks synced: {count}", "lang_choose": "Choose interface language:", "lang_set": "Interface language: {lang_name}", - "lang_invalid": "Only `ru` and `en` are supported.", + "lang_invalid": "Only `ru`, `en`, `uk`, and `nl` are supported.", "unknown_action": "I did not understand that. Use the buttons below or /help.", "main_menu_hint": "Main menu", "settings_menu_hint": "Settings", @@ -163,9 +167,164 @@ I18N: dict[str, dict[str, str]] = { "status_last_run": "Last run: {status}, tracks={tracks}", "status_last_url": "{url}", }, + "uk": { + "button_connect": "🔗 Підключити Spotify", + "button_generate": "✨ Згенерувати", + "button_status": "📊 Статус", + "button_latest": "🎵 Останній плейлист", + "button_sync": "🔄 Оновити лайки", + "button_help": "❓ Допомога", + "button_settings": "⚙️ Налаштування", + "button_language": "🌐 Мова / Language", + "button_size_20": "📏 Розмір 20", + "button_size_30": "📏 Розмір 30", + "button_size_50": "📏 Розмір 50", + "button_ratio_60": "🆕 Нові 60%", + "button_ratio_80": "🆕 Нові 80%", + "button_ratio_100": "🆕 Нові 100%", + "button_lang_ru": "🇺🇦 Русский", + "button_lang_en": "🇺🇸 English", + "button_lang_uk": "🇺🇦 Українська", + "button_lang_nl": "🇳🇱 Nederlands", + "button_back": "⬅️ Назад", + "start_text": ( + "Я бот для Spotify daily vibe playlist.\n" + "Створюю плейлисти з вашого recent listening + liked tracks і намагаюся давати більше нових треків та менше повторів.\n\n" + "/connect - підключити Spotify\n" + "/status - статус акаунта та останнього плейлиста\n" + "/generate - згенерувати плейлист зараз\n" + "/latest - показати посилання на останній плейлист\n" + "/setsize N - розмір плейлиста\n" + "/setratio X - частка нових треків (0.5..1.0)\n" + "/sync - оновити лайкнуті треки зі Spotify\n" + "/lang [ru|en|uk|nl] - змінити мову" + ), + "help_text": ( + "Я бот для Spotify daily vibe playlist.\n" + "Створюю плейлисти з вашого recent listening + liked tracks і намагаюся давати більше нових треків та менше повторів.\n\n" + "/connect - підключити Spotify\n" + "/status - статус акаунта та останнього плейлиста\n" + "/generate - згенерувати плейлист зараз\n" + "/latest - показати посилання на останній плейлист\n" + "/setsize N - розмір плейлиста\n" + "/setratio X - частка нових треків (0.5..1.0)\n" + "/sync - оновити лайкнуті треки зі Spotify\n" + "/lang [ru|en|uk|nl] - змінити мову" + ), + "connect_open_link": "Відкрийте посилання та авторизуйте Spotify:\n{url}", + "spotify_connected_notice": "Spotify підключено: {display_name}\nТепер можна /generate", + "user_not_found_start": "Користувача не знайдено. Напишіть /start", + "no_playlist_yet": "Ще немає згенерованого плейлиста.", + "generate_wait": "Генерую плейлист, це може зайняти 20-60 секунд...", + "error_prefix": "Помилка: {message}", + "size_usage": "Використання: /setsize 30", + "size_invalid": "Розмір має бути числом від 5 до 100.", + "size_set": "Розмір плейлиста встановлено: {value}", + "ratio_usage": "Використання: /setratio 0.8", + "ratio_invalid": "Значення має бути від 0.5 до 1.0", + "ratio_set": "Мінімальна частка нових треків: {value:.2f}", + "sync_need_connect": "Спочатку /connect", + "sync_done": "Лайкнуті треки оновлено: {count}", + "lang_choose": "Оберіть мову інтерфейсу:", + "lang_set": "Мова інтерфейсу: {lang_name}", + "lang_invalid": "Підтримуються лише `ru`, `en`, `uk` та `nl`.", + "unknown_action": "Не зрозумів команду. Використовуйте кнопки нижче або /help.", + "main_menu_hint": "Головне меню", + "settings_menu_hint": "Налаштування", + "status_yes": "так", + "status_no": "ні", + "status_text": ( + "Connected: {connected}\n" + "Spotify user: {spotify_user}\n" + "Liked tracks cached: {saved_count}\n" + "Playlist size: {playlist_size}\n" + "Min new ratio: {min_new_ratio:.2f}\n" + "Last generated: {last_generated}" + ), + "status_last_run": "Last run: {status}, tracks={tracks}", + "status_last_url": "{url}", + }, + "nl": { + "button_connect": "🔗 Spotify koppelen", + "button_generate": "✨ Genereren", + "button_status": "📊 Status", + "button_latest": "🎵 Laatste playlist", + "button_sync": "🔄 Likes syncen", + "button_help": "❓ Help", + "button_settings": "⚙️ Instellingen", + "button_language": "🌐 Taal / Language", + "button_size_20": "📏 Grootte 20", + "button_size_30": "📏 Grootte 30", + "button_size_50": "📏 Grootte 50", + "button_ratio_60": "🆕 Nieuw 60%", + "button_ratio_80": "🆕 Nieuw 80%", + "button_ratio_100": "🆕 Nieuw 100%", + "button_lang_ru": "🇺🇦 Русский", + "button_lang_en": "🇺🇸 English", + "button_lang_uk": "🇺🇦 Українська", + "button_lang_nl": "🇳🇱 Nederlands", + "button_back": "⬅️ Terug", + "start_text": ( + "Ik ben een Spotify daily vibe playlist-bot.\n" + "Ik maak playlists op basis van je recent listening + liked tracks en probeer ze fris te houden met minder herhalingen.\n\n" + "/connect - Spotify koppelen\n" + "/status - accountstatus en laatste playlist\n" + "/generate - nu een playlist genereren\n" + "/latest - link naar de laatste playlist tonen\n" + "/setsize N - playlistgrootte\n" + "/setratio X - minimale nieuwe ratio (0.5..1.0)\n" + "/sync - liked tracks uit Spotify synchroniseren\n" + "/lang [ru|en|uk|nl] - taal wijzigen" + ), + "help_text": ( + "Ik ben een Spotify daily vibe playlist-bot.\n" + "Ik maak playlists op basis van je recent listening + liked tracks en probeer ze fris te houden met minder herhalingen.\n\n" + "/connect - Spotify koppelen\n" + "/status - accountstatus en laatste playlist\n" + "/generate - nu een playlist genereren\n" + "/latest - link naar de laatste playlist tonen\n" + "/setsize N - playlistgrootte\n" + "/setratio X - minimale nieuwe ratio (0.5..1.0)\n" + "/sync - liked tracks uit Spotify synchroniseren\n" + "/lang [ru|en|uk|nl] - taal wijzigen" + ), + "connect_open_link": "Open de link en autoriseer Spotify:\n{url}", + "spotify_connected_notice": "Spotify gekoppeld: {display_name}\nJe kunt nu /generate gebruiken", + "user_not_found_start": "Gebruiker niet gevonden. Stuur /start", + "no_playlist_yet": "Nog geen gegenereerde playlist.", + "generate_wait": "Playlist wordt gegenereerd, dit kan 20-60 seconden duren...", + "error_prefix": "Fout: {message}", + "size_usage": "Gebruik: /setsize 30", + "size_invalid": "Grootte moet een getal zijn van 5 t/m 100.", + "size_set": "Playlistgrootte ingesteld op: {value}", + "ratio_usage": "Gebruik: /setratio 0.8", + "ratio_invalid": "Waarde moet tussen 0.5 en 1.0 zijn", + "ratio_set": "Minimale nieuwe ratio: {value:.2f}", + "sync_need_connect": "Gebruik eerst /connect", + "sync_done": "Liked tracks gesynchroniseerd: {count}", + "lang_choose": "Kies de interfacetaal:", + "lang_set": "Interfacetaal: {lang_name}", + "lang_invalid": "Alleen `ru`, `en`, `uk` en `nl` worden ondersteund.", + "unknown_action": "Ik begreep dat niet. Gebruik de knoppen hieronder of /help.", + "main_menu_hint": "Hoofdmenu", + "settings_menu_hint": "Instellingen", + "status_yes": "ja", + "status_no": "nee", + "status_text": ( + "Connected: {connected}\n" + "Spotify user: {spotify_user}\n" + "Liked tracks cached: {saved_count}\n" + "Playlist size: {playlist_size}\n" + "Min new ratio: {min_new_ratio:.2f}\n" + "Last generated: {last_generated}" + ), + "status_last_run": "Last run: {status}, tracks={tracks}", + "status_last_url": "{url}", + }, } -LANG_NAMES = {"ru": "Русский", "en": "English"} +LANG_NAMES = {"ru": "Русский", "en": "English", "uk": "Українська", "nl": "Nederlands"} +SUPPORTED_LANGS = frozenset(LANG_NAMES.keys()) class TelegramBotRunner: @@ -216,7 +375,7 @@ class TelegramBotRunner: await self.application.bot.send_message(chat_id=int(chat_id), text=text, disable_web_page_preview=False) async def send_spotify_connected_notice(self, chat_id: str, display_name: str) -> None: - lang = self._chat_lang.get(str(chat_id), "ru") + lang = self._chat_lang.get(str(chat_id), "en") await self.send_message(chat_id, self._t(lang, "spotify_connected_notice", display_name=display_name)) async def _ensure_user(self, update: Update): @@ -241,17 +400,24 @@ class TelegramBotRunner: chat = update.effective_chat if chat: cached = self._chat_lang.get(str(chat.id)) - if cached in {"ru", "en"}: + if cached in SUPPORTED_LANGS: return cached user = update.effective_user tg_lang = (getattr(user, "language_code", None) or "").lower() - lang = "ru" if tg_lang.startswith("ru") else "en" + if tg_lang.startswith("uk") or tg_lang.startswith("ua"): + lang = "uk" + elif tg_lang.startswith("ru"): + lang = "ru" + elif tg_lang.startswith("nl"): + lang = "nl" + else: + lang = "en" if chat: self._chat_lang[str(chat.id)] = lang return lang def _set_chat_lang(self, chat_id: str, lang: Lang) -> None: - if lang in {"ru", "en"}: + if lang in SUPPORTED_LANGS: self._chat_lang[str(chat_id)] = lang async def _reply( @@ -299,12 +465,13 @@ class TelegramBotRunner: def _language_menu(self, lang: Lang) -> ReplyKeyboardMarkup: kb = [ [self._t(lang, "button_lang_ru"), self._t(lang, "button_lang_en")], + [self._t(lang, "button_lang_uk"), self._t(lang, "button_lang_nl")], [self._t(lang, "button_back")], ] return ReplyKeyboardMarkup(kb, resize_keyboard=True, is_persistent=True) def _button_labels(self, key: str) -> set[str]: - return {I18N["ru"][key], I18N["en"][key]} + return {I18N[code][key] for code in SUPPORTED_LANGS if key in I18N.get(code, {})} async def on_text_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: text = (update.effective_message.text if update.effective_message else "") or "" @@ -331,6 +498,12 @@ class TelegramBotRunner: if text in self._button_labels("button_lang_en"): await self._set_language_from_ui(update, "en", reply_markup=self._settings_menu("en")) return + if text in self._button_labels("button_lang_uk"): + await self._set_language_from_ui(update, "uk", reply_markup=self._settings_menu("uk")) + return + if text in self._button_labels("button_lang_nl"): + await self._set_language_from_ui(update, "nl", reply_markup=self._settings_menu("nl")) + return if text in self._button_labels("button_back"): await self._reply(update, self._t(lang, "main_menu_hint"), lang=lang) return @@ -398,6 +571,12 @@ class TelegramBotRunner: if requested.startswith("en"): await self._set_language_from_ui(update, "en") return + if requested.startswith("uk") or requested.startswith("ua"): + await self._set_language_from_ui(update, "uk") + return + if requested.startswith("nl"): + await self._set_language_from_ui(update, "nl") + return await self._reply(update, self._t(lang, "lang_invalid"), lang=lang) return await self._reply(