From 09c972b466c238e7fe5747b94fbe71ca1ef75575 Mon Sep 17 00:00:00 2001 From: enzo Date: Sat, 28 Mar 2026 17:51:04 +0100 Subject: [PATCH 1/3] chore: update copy and formatting for about and services strings --- src/handlers.py | 14 +++++++------- src/messages.py | 21 +++++++++++++++++++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/handlers.py b/src/handlers.py index acd2275..c472320 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -7,7 +7,7 @@ from src.beauty import handle_beauty from src.buttons import get_main_menu - +import src.messages as message async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message and update.effective_user: @@ -20,26 +20,26 @@ async def about(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message: user = update.effective_user + info = message.getAboutString() if not user: - await update.message.reply_text("Ciao, utente informazioni sul progetto.") + await update.message.reply_text(f"Ciao, utente.\n\n{info}",parse_mode='HTML',disable_web_page_preview=True) return - await update.message.reply_text( - f"Ciao, {user.first_name} informazioni sul progetto." - ) + await update.message.reply_text(f"Ciao, {user.first_name}\n\n{info}", parse_mode='HTML',disable_web_page_preview=True) async def service(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message: user = update.effective_user + services = message.getServiceString() if not user: - await update.message.reply_text("Ciao, utente stato dei servizi.") + await update.message.reply_text(f"Ciao, utente.\n\n{services}",parse_mode='HTML') return - await update.message.reply_text(f"Ciao, {user.first_name} stato dei servizi.") + await update.message.reply_text(f"Ciao, {user.first_name}.\n\n{services}",parse_mode='HTML') async def download(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: diff --git a/src/messages.py b/src/messages.py index 1657eac..bf1daf6 100644 --- a/src/messages.py +++ b/src/messages.py @@ -1,6 +1,23 @@ def getAboutString() -> str: - return "Siamo una squadra fortissimi!" + about = ( + "โšก๏ธ Video Downloader Bot\n" + "Scarica i tuoi video preferiti da YouTube e altri servizi in pochi secondi.\n\n" + "Creato e mantenuto dal team:\n" + "๐Ÿ‘จโ€๐Ÿ’ป Gianmarco [@sean8819]\n" + "๐Ÿ‘จโ€๐Ÿ’ป Marce [@Marss08]\n" + "๐Ÿ‘จโ€๐Ÿ’ป Enzo [@enzobarba]\n\n" + "โค๏ธ Grazie per aver scelto il nostro bot!" + ) + return about + + def getServiceString() -> str: - return "Download video ... ok!" + services = ( + "๐Ÿ›  I miei servizi\n\n" + "๐Ÿ“ฅ Download Media: Invia un link per scaricare facilmente video o tracce audio da YouTube e altri servizi supportati.\n\n" + "๐ŸŒ Multilingua (i18n): Supporto nativo per lingua italiana e inglese.\n\n" + "๐ŸŒ… Ispirazione Quotidiana: Ricevi la foto della bellezza del giorno per un tocco di meraviglia quotidiana.\n\n" + ) + return services \ No newline at end of file From 2ce0dda563a96ff7d74b934246926eaf03436e9d Mon Sep 17 00:00:00 2001 From: enzo Date: Sat, 28 Mar 2026 22:32:20 +0100 Subject: [PATCH 2/3] feat: add i18n with /lang handler --- lang/en.json | 23 ++++++++++++++ lang/it.json | 22 ++++++++++++++ main.py | 7 ++++- src/buttons.py | 47 +++++++++++++++++++--------- src/handlers.py | 81 ++++++++++++++++++++++++++++++++++++++----------- src/i18n.py | 55 +++++++++++++++++++++++++++++++++ src/messages.py | 28 +++++------------ 7 files changed, 210 insertions(+), 53 deletions(-) create mode 100644 lang/en.json create mode 100644 lang/it.json create mode 100644 src/i18n.py diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..97569be --- /dev/null +++ b/lang/en.json @@ -0,0 +1,23 @@ +{ + "operation_canceled": "Operation canceled.", + "cancel_button": "โŒ Cancel", + "confirm_mp3_download": "You are about to download an mp3", + "confirm_mp4_download": "You are about to download an mp4", + "file_too_large": "โŒ File too large for Telegram (max 50MB).", + "loading": "โณ Download in progress...", + "error_sending_file": "Error sending file!", + "error_removing_file": "Error removing file!", + "download_failed": "Download failed.", + "hello": "Hello,", + "compare": "your trusty buddy is here to help you download your media!", + "user": "user.", + "download_params_error": "You need to format your message like this: /download ", + "download_content": "download your content!", + "beauty_params_error": "Please use /beauty without any additional arguments.", + "about_string": "โšก๏ธ Video Downloader Bot\nDownload your favorite videos from YouTube and other services in seconds.\n\nCreated and maintained by the team:\nGianmarco [@sean8819]\n๐Ÿ‘จโ€๐Ÿ’ป Marce [@Marss08]\n๐Ÿ‘จโ€๐Ÿ’ป Enzo [@enzobarba]\n\nโค๏ธ Thank you for choosing our bot!", + "services_string": "๐Ÿ›  My services\n\n๐Ÿ“ฅ Media Download: Send a link to easily download videos or audio tracks from YouTube and other supported services.\n\n๐ŸŒ Multilingual (i18n): Native support for Italian and English.\n\n๐ŸŒ… Daily Inspiration: Receive the beauty photo of the day for a daily touch of wonder.\n\n", + "lang_format_error": "โš ๏ธ Invalid format. Use: /lang it or /lang en", + "language_not_supported":"โš ๏ธ Language not supported\nUse 'it' for Italian or 'en' for English.", + "lang_updated":"โœ… Language successfully updated" + +} \ No newline at end of file diff --git a/lang/it.json b/lang/it.json new file mode 100644 index 0000000..85c2b3b --- /dev/null +++ b/lang/it.json @@ -0,0 +1,22 @@ +{ + "operation_canceled": "Operazione annullata.", + "cancel_button": "โŒ Annulla", + "confirm_mp3_download": "Scaricherai un mp3", + "confirm_mp4_download": "Scaricherai un mp4", + "file_too_large":"โŒ File troppo grande per Telegram (max 50MB).", + "loading":"โณ Download in corso...", + "error_sending_file":"Errore invio file!", + "error_removing_file":"Errore eliminazione file!", + "download_failed":"Download fallito.", + "hello":"Ciao,", + "compare":"il tuo compare di fiducia ti aiuta a scaricare i tuoi media!", + "user":"utente.", + "download_params_error":"Devi fornire un messaggio cosรฌ formato /download ", + "download_content":"scarica il tuo contenuto!", + "beauty_params_error":"Usa solo /beauty senza altri argomenti.", + "about_string":"โšก๏ธ Video Downloader Bot\nScarica i tuoi video preferiti da YouTube e altri servizi in pochi secondi.\n\nCreato e mantenuto dal team:\nGianmarco [@sean8819]\n๐Ÿ‘จโ€๐Ÿ’ป Marce [@Marss08]\n๐Ÿ‘จโ€๐Ÿ’ป Enzo [@enzobarba]\n\nโค๏ธ Grazie per aver scelto il nostro bot!", + "services_string":"๐Ÿ›  I miei servizi\n\n๐Ÿ“ฅ Download Media: Invia un link per scaricare facilmente video o tracce audio da YouTube e altri servizi supportati.\n\n๐ŸŒ Multilingua (i18n): Supporto nativo per lingua italiana e inglese.\n\n๐ŸŒ… Ispirazione Quotidiana: Ricevi la foto della bellezza del giorno per un tocco di meraviglia quotidiana.\n\n", + "lang_format_error": "โš ๏ธ Comando non valido. Usa: /lang it o /lang en", + "language_not_supported":"โš ๏ธ Lingua non supportata.\nUsa 'it' per Italiano o 'en' per Inglese.", + "lang_updated":"โœ… Lingua cambiata con successo" +} \ No newline at end of file diff --git a/main.py b/main.py index 5b16f8f..be3ba29 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,9 @@ import os from dotenv import load_dotenv + +load_dotenv(override=True) + from telegram import BotCommand from telegram.ext import ( ApplicationBuilder, @@ -11,7 +14,7 @@ ) from src.buttons import handle_buttons, handle_resolution -from src.handlers import about, beauty, download, service, start +from src.handlers import about, beauty, download, lang, service, start from src.logo import print_logo @@ -28,6 +31,7 @@ async def post_init(application): BotCommand("service", "Servizi disponibili"), BotCommand("beauty", "Bellezza del giorno"), BotCommand("about", "Info sul progetto"), + BotCommand("lang", "Cambia lingua del bot"), ] ) @@ -53,6 +57,7 @@ def main(): app.add_handler(CommandHandler("about", about)) app.add_handler(CommandHandler("service", service)) app.add_handler(CommandHandler("beauty", beauty)) + app.add_handler(CommandHandler("lang", lang)) app.add_handler( CallbackQueryHandler(handle_buttons, pattern="^(audio|video|annulla)$") ) diff --git a/src/buttons.py b/src/buttons.py index 91a029c..b70fc92 100644 --- a/src/buttons.py +++ b/src/buttons.py @@ -1,14 +1,15 @@ import asyncio import os -from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update, User from telegram.error import TelegramError from telegram.ext import CallbackContext, ContextTypes from src.downloader import get_media, get_media_size +from src.i18n import get_string -def get_main_menu() -> InlineKeyboardMarkup: +def get_main_menu(user: User) -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ [ @@ -16,13 +17,15 @@ def get_main_menu() -> InlineKeyboardMarkup: InlineKeyboardButton("๐Ÿ“น Video", callback_data="video"), ], [ - InlineKeyboardButton("โŒ Annulla", callback_data="annulla"), + InlineKeyboardButton( + get_string(user, "cancel_button"), callback_data="annulla" + ), ], ] ) -def get_resolution_video() -> InlineKeyboardMarkup: +def get_resolution_video(user: User) -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ [ @@ -31,7 +34,9 @@ def get_resolution_video() -> InlineKeyboardMarkup: InlineKeyboardButton("720p", callback_data="720"), ], [ - InlineKeyboardButton("โŒ Annulla", callback_data="annulla"), + InlineKeyboardButton( + get_string(user, "cancel_button"), callback_data="annulla" + ), ], ] ) @@ -47,7 +52,9 @@ async def handle_resolution(update: Update, context: ContextTypes.DEFAULT_TYPE) if query.data == "annulla": if context.user_data is not None: context.user_data.clear() - await query.edit_message_text("Operazione annullata.") + await query.edit_message_text( + get_string(update.effective_user, "operation_canceled") + ) return elif query.data == "360": @@ -74,20 +81,25 @@ async def handle_buttons(update: Update, context: ContextTypes.DEFAULT_TYPE) -> if query.data == "annulla": context.user_data.clear() - await query.edit_message_text("Operazione annullata.") + await query.edit_message_text( + get_string(update.effective_user, "operation_canceled") + ) return if query.data == "audio": context.user_data["download_audio"] = True context.user_data["download_video"] = False await handle_download(update, context) - await query.edit_message_text("Scaricherai un mp3") + await query.edit_message_text( + get_string(update.effective_user, "confirm_mp3_download") + ) elif query.data == "video": context.user_data["download_video"] = True context.user_data["download_audio"] = False await query.edit_message_text( - "Scaricherai un mp4", reply_markup=get_resolution_video() + get_string(update.effective_user, "confirm_mp4_download"), + reply_markup=get_resolution_video(update.effective_user), ) @@ -119,11 +131,13 @@ async def handle_download(update: Update, context: CallbackContext) -> None: if size > telegram_max_upload_file: await update.callback_query.edit_message_text( - "โŒ File troppo grande per Telegram (max 50MB)." + get_string(update.effective_user, "file_too_large") ) return - await update.callback_query.edit_message_text("โณ Download in corso...") + await update.callback_query.edit_message_text( + get_string(update.effective_user, "loading") + ) file_path = await asyncio.to_thread( get_media, context.user_data["url"], resolution, media_type @@ -156,7 +170,7 @@ async def handle_download(update: Update, context: CallbackContext) -> None: print(e) await context.bot.send_message( chat_id=chat_id, - text="Errore invio file!", + text=get_string(update.effective_user, "error_sending_file"), ) finally: if os.path.exists(file_path): @@ -165,7 +179,12 @@ async def handle_download(update: Update, context: CallbackContext) -> None: except OSError: await context.bot.send_message( chat_id=chat_id, - text="Errore eliminazione file!", + text=get_string( + update.effective_user, "error_removing_file" + ), ) else: - await context.bot.send_message(chat_id=chat_id, text="Download fallito.") + await context.bot.send_message( + chat_id=chat_id, + text=get_string(update.effective_user, "download_failed"), + ) diff --git a/src/handlers.py b/src/handlers.py index c472320..e8a48f3 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -2,17 +2,21 @@ import validators -from telegram import Update +from telegram import Update, User from telegram.ext import ContextTypes +import src.messages as message from src.beauty import handle_beauty from src.buttons import get_main_menu -import src.messages as message +from src.i18n import get_string, set_user_language + async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if update.message and update.effective_user: + user = update.effective_user + if update.message and user: + set_user_language(user.id, "en") await update.message.reply_text( - f"Ciao, {update.effective_user.first_name} il tuo compare di fiducia ti aiuta a scaricare i tuoi media!" + f"{get_string(user,'hello')} {user.first_name} {get_string(user,'compare')}" ) @@ -20,38 +24,51 @@ async def about(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message: user = update.effective_user - info = message.getAboutString() + info = message.getAboutString(user) if not user: - await update.message.reply_text(f"Ciao, utente.\n\n{info}",parse_mode='HTML',disable_web_page_preview=True) + await update.message.reply_text( + f"{get_string(user,'hello')} {get_string(user,'user')}\n\n{info}", + parse_mode="HTML", + disable_web_page_preview=True, + ) return - await update.message.reply_text(f"Ciao, {user.first_name}\n\n{info}", parse_mode='HTML',disable_web_page_preview=True) + await update.message.reply_text( + f"{get_string(user,'hello')} {user.first_name}\n\n{info}", + parse_mode="HTML", + disable_web_page_preview=True, + ) async def service(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message: user = update.effective_user - services = message.getServiceString() + services = message.getServiceString(user) if not user: - await update.message.reply_text(f"Ciao, utente.\n\n{services}",parse_mode='HTML') + await update.message.reply_text( + f"{get_string(user,'hello')} {get_string(user,'user')}\n\n{services}", + parse_mode="HTML", + ) return - await update.message.reply_text(f"Ciao, {user.first_name}.\n\n{services}",parse_mode='HTML') + await update.message.reply_text( + f"{get_string(user,'hello')} {user.first_name}.\n\n{services}", + parse_mode="HTML", + ) async def download(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message or not update.message.text: return + user = update.effective_user parts = update.message.text.split(" ", 1) if len(parts) != 2: - await update.message.reply_text( - "Devi fornire un messaggio cosรฌ formato /download " - ) + await update.message.reply_text({get_string(user, "download_params_error")}) return url_video = parts[1] @@ -64,14 +81,12 @@ async def download(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: context.user_data["url"] = url_video - user = update.effective_user - if not user: return await update.message.reply_text( - f"{user.first_name}, scarica il tuo contenuto!", - reply_markup=get_main_menu(), + f"{user.first_name}, {get_string(user,'download_content')}", + reply_markup=get_main_menu(update.effective_user), ) @@ -79,11 +94,41 @@ async def beauty(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message or not update.message.text: return + user = update.effective_user + raw_text = update.message.text.strip() command = raw_text.split()[0] if raw_text != command: - await update.message.reply_text("Usa solo /beauty senza altri argomenti.") + await update.message.reply_text({get_string(user, "beauty_params_error")}) return await handle_beauty(update) + + +async def lang(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not update.message or not update.message.text: + return + + user = update.effective_user + if not user: + return + + parts = update.message.text.split(" ", 1) + + if len(parts) != 2: + error_message = get_string(user, "lang_format_error") + await update.message.reply_text(error_message) + return + + requested_lang = parts[1].strip().lower() + + if requested_lang not in ["it", "en"]: + await update.message.reply_text(get_string(user, "language_not_supported")) + return + + set_user_language(user.id, requested_lang) + + confirmation_message = get_string(user, "lang_updated") + + await update.message.reply_text(confirmation_message) diff --git a/src/i18n.py b/src/i18n.py new file mode 100644 index 0000000..017f644 --- /dev/null +++ b/src/i18n.py @@ -0,0 +1,55 @@ +import hashlib +import json +import os + +USERS_DB_FILE = "users.json" +SALT = os.getenv("HASH_SALT", "fallback_salt") + +supported_languages = ["it", "en"] +translations = {} + +for lan in supported_languages: + with open(f"lang/{lan}.json", "r", encoding="utf-8") as file: + translations[lan] = json.load(file) + + +def _telegram_id_to_sha256(user_id: int) -> str: + string_to_hash = f"{user_id}{SALT}" + return hashlib.sha256(string_to_hash.encode("utf-8")).hexdigest() + + +def _load_users_db() -> dict: + if not os.path.exists(USERS_DB_FILE): + return {} + try: + with open(USERS_DB_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError: + return {} + + +def _save_users_db(data: dict): + with open(USERS_DB_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4) + + +def set_user_language(user_id: int, lang: str = "en"): + + user_hash = _telegram_id_to_sha256(user_id) + users = _load_users_db() + + users[user_hash] = lang + + _save_users_db(users) + + +def get_string(telegram_user, key: str) -> str: + + user_hash = _telegram_id_to_sha256(telegram_user.id) + users = _load_users_db() + + lang = users.get(user_hash, "en") + + dictionary = translations.get(lang, translations["en"]) + + return dictionary.get(key, key) diff --git a/src/messages.py b/src/messages.py index bf1daf6..40400ce 100644 --- a/src/messages.py +++ b/src/messages.py @@ -1,23 +1,11 @@ -def getAboutString() -> str: - about = ( - "โšก๏ธ Video Downloader Bot\n" - "Scarica i tuoi video preferiti da YouTube e altri servizi in pochi secondi.\n\n" - "Creato e mantenuto dal team:\n" - "๐Ÿ‘จโ€๐Ÿ’ป Gianmarco [@sean8819]\n" - "๐Ÿ‘จโ€๐Ÿ’ป Marce [@Marss08]\n" - "๐Ÿ‘จโ€๐Ÿ’ป Enzo [@enzobarba]\n\n" - "โค๏ธ Grazie per aver scelto il nostro bot!" - ) - return about +from telegram import User - +from src.i18n import get_string -def getServiceString() -> str: - services = ( - "๐Ÿ›  I miei servizi\n\n" - "๐Ÿ“ฅ Download Media: Invia un link per scaricare facilmente video o tracce audio da YouTube e altri servizi supportati.\n\n" - "๐ŸŒ Multilingua (i18n): Supporto nativo per lingua italiana e inglese.\n\n" - "๐ŸŒ… Ispirazione Quotidiana: Ricevi la foto della bellezza del giorno per un tocco di meraviglia quotidiana.\n\n" - ) - return services \ No newline at end of file +def getAboutString(user: User) -> str: + return get_string(user, "about_string") + + +def getServiceString(user: User) -> str: + return get_string(user, "services_string") From 430a8b215b9d01dc6e722c7f7e0f72738dd97386 Mon Sep 17 00:00:00 2001 From: enzo Date: Sun, 29 Mar 2026 11:42:25 +0200 Subject: [PATCH 3/3] feat: add i18n test --- .env.example | 3 +- .gitignore | 3 +- lang/en.json | 5 +- lang/it.json | 4 +- main.py | 5 +- pyproject.toml | 2 +- src/buttons.py | 5 +- src/handlers.py | 30 +++++------ tests/test_buttons.py | 40 ++++++++------- tests/test_downloader.py | 2 +- tests/test_handlers.py | 41 ++++++++++++--- tests/test_i18n.py | 106 +++++++++++++++++++++++++++++++++++++++ tests/test_messages.py | 27 ++++++++++ 13 files changed, 217 insertions(+), 56 deletions(-) create mode 100644 tests/test_i18n.py create mode 100644 tests/test_messages.py diff --git a/.env.example b/.env.example index 09c905f..b915148 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ BOT_TOKEN="INSERT_YOUR_BOT_TOKEN_HERE_MY_FRIEND" -PIXABAY_TOKEN="INSERT_YOUR_PIXABAY_TOKEN_MY_FRIEND" \ No newline at end of file +PIXABAY_TOKEN="INSERT_YOUR_PIXABAY_TOKEN_MY_FRIEND" +HASH_SALT="INSERT_YOUR_SALT_HERE_MY_FRIEND" diff --git a/.gitignore b/.gitignore index 66e30eb..0e8a0d2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .venv/ .env .coverage -tmp/ \ No newline at end of file +tmp/ +users.json diff --git a/lang/en.json b/lang/en.json index 97569be..41beb97 100644 --- a/lang/en.json +++ b/lang/en.json @@ -8,16 +8,15 @@ "error_sending_file": "Error sending file!", "error_removing_file": "Error removing file!", "download_failed": "Download failed.", - "hello": "Hello,", + "hello": "Hello", "compare": "your trusty buddy is here to help you download your media!", "user": "user.", "download_params_error": "You need to format your message like this: /download ", "download_content": "download your content!", "beauty_params_error": "Please use /beauty without any additional arguments.", - "about_string": "โšก๏ธ Video Downloader Bot\nDownload your favorite videos from YouTube and other services in seconds.\n\nCreated and maintained by the team:\nGianmarco [@sean8819]\n๐Ÿ‘จโ€๐Ÿ’ป Marce [@Marss08]\n๐Ÿ‘จโ€๐Ÿ’ป Enzo [@enzobarba]\n\nโค๏ธ Thank you for choosing our bot!", + "about_string": "โšก๏ธ Video Downloader Bot\nDownload your favorite videos from YouTube and other services in seconds.\n\nCreated and maintained by the team:\n๐Ÿ‘จโ€๐Ÿ’ป Gianmarco [@sean8819]\n๐Ÿ‘จโ€๐Ÿ’ป Marce [@Marss08]\n๐Ÿ‘จโ€๐Ÿ’ป Enzo [@enzobarba]\n\nโค๏ธ Thank you for choosing our bot!", "services_string": "๐Ÿ›  My services\n\n๐Ÿ“ฅ Media Download: Send a link to easily download videos or audio tracks from YouTube and other supported services.\n\n๐ŸŒ Multilingual (i18n): Native support for Italian and English.\n\n๐ŸŒ… Daily Inspiration: Receive the beauty photo of the day for a daily touch of wonder.\n\n", "lang_format_error": "โš ๏ธ Invalid format. Use: /lang it or /lang en", "language_not_supported":"โš ๏ธ Language not supported\nUse 'it' for Italian or 'en' for English.", "lang_updated":"โœ… Language successfully updated" - } \ No newline at end of file diff --git a/lang/it.json b/lang/it.json index 85c2b3b..9f407cc 100644 --- a/lang/it.json +++ b/lang/it.json @@ -8,13 +8,13 @@ "error_sending_file":"Errore invio file!", "error_removing_file":"Errore eliminazione file!", "download_failed":"Download fallito.", - "hello":"Ciao,", + "hello":"Ciao", "compare":"il tuo compare di fiducia ti aiuta a scaricare i tuoi media!", "user":"utente.", "download_params_error":"Devi fornire un messaggio cosรฌ formato /download ", "download_content":"scarica il tuo contenuto!", "beauty_params_error":"Usa solo /beauty senza altri argomenti.", - "about_string":"โšก๏ธ Video Downloader Bot\nScarica i tuoi video preferiti da YouTube e altri servizi in pochi secondi.\n\nCreato e mantenuto dal team:\nGianmarco [@sean8819]\n๐Ÿ‘จโ€๐Ÿ’ป Marce [@Marss08]\n๐Ÿ‘จโ€๐Ÿ’ป Enzo [@enzobarba]\n\nโค๏ธ Grazie per aver scelto il nostro bot!", + "about_string":"โšก๏ธ Video Downloader Bot\nScarica i tuoi video preferiti da YouTube e altri servizi in pochi secondi.\n\nCreato e mantenuto dal team:\n๐Ÿ‘จโ€๐Ÿ’ป Gianmarco [@sean8819]\n๐Ÿ‘จโ€๐Ÿ’ป Marce [@Marss08]\n๐Ÿ‘จโ€๐Ÿ’ป Enzo [@enzobarba]\n\nโค๏ธ Grazie per aver scelto il nostro bot!", "services_string":"๐Ÿ›  I miei servizi\n\n๐Ÿ“ฅ Download Media: Invia un link per scaricare facilmente video o tracce audio da YouTube e altri servizi supportati.\n\n๐ŸŒ Multilingua (i18n): Supporto nativo per lingua italiana e inglese.\n\n๐ŸŒ… Ispirazione Quotidiana: Ricevi la foto della bellezza del giorno per un tocco di meraviglia quotidiana.\n\n", "lang_format_error": "โš ๏ธ Comando non valido. Usa: /lang it o /lang en", "language_not_supported":"โš ๏ธ Lingua non supportata.\nUsa 'it' per Italiano o 'en' per Inglese.", diff --git a/main.py b/main.py index be3ba29..bd77bb8 100644 --- a/main.py +++ b/main.py @@ -2,9 +2,6 @@ import os from dotenv import load_dotenv - -load_dotenv(override=True) - from telegram import BotCommand from telegram.ext import ( ApplicationBuilder, @@ -38,7 +35,7 @@ async def post_init(application): def main(): - load_dotenv() + load_dotenv(override=True) logging.basicConfig(level=logging.ERROR) diff --git a/pyproject.toml b/pyproject.toml index 17e4e37..8d3cd8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ line_length = 120 ignore_missing_imports = true [tool.coverage.run] -omit = ["src/logo.py", "src/messages.py", "__init__.py"] +omit = ["src/logo.py", "__init__.py"] [tool.pytest.ini_options] asyncio_mode = "auto" \ No newline at end of file diff --git a/src/buttons.py b/src/buttons.py index b70fc92..2b31082 100644 --- a/src/buttons.py +++ b/src/buttons.py @@ -97,9 +97,12 @@ async def handle_buttons(update: Update, context: ContextTypes.DEFAULT_TYPE) -> elif query.data == "video": context.user_data["download_video"] = True context.user_data["download_audio"] = False + user = update.effective_user + if user is None: + return await query.edit_message_text( get_string(update.effective_user, "confirm_mp4_download"), - reply_markup=get_resolution_video(update.effective_user), + reply_markup=get_resolution_video(user), ) diff --git a/src/handlers.py b/src/handlers.py index e8a48f3..94b9def 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -1,8 +1,7 @@ # pylint: disable=unused-argument - import validators -from telegram import Update, User +from telegram import Update from telegram.ext import ContextTypes import src.messages as message @@ -22,20 +21,19 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def about(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message: - user = update.effective_user - info = message.getAboutString(user) if not user: await update.message.reply_text( - f"{get_string(user,'hello')} {get_string(user,'user')}\n\n{info}", + "Hello user.\n\nProject info.", parse_mode="HTML", disable_web_page_preview=True, ) return + info = message.getAboutString(user) await update.message.reply_text( - f"{get_string(user,'hello')} {user.first_name}\n\n{info}", + f"{get_string(user, 'hello')} {user.first_name}\n\n{info}", parse_mode="HTML", disable_web_page_preview=True, ) @@ -45,15 +43,15 @@ async def service(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message: user = update.effective_user - services = message.getServiceString(user) if not user: await update.message.reply_text( - f"{get_string(user,'hello')} {get_string(user,'user')}\n\n{services}", + "Hello user.\n\nServices info.", parse_mode="HTML", ) return + services = message.getServiceString(user) await update.message.reply_text( f"{get_string(user,'hello')} {user.first_name}.\n\n{services}", parse_mode="HTML", @@ -65,28 +63,26 @@ async def download(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: return user = update.effective_user + if not user: + return + parts = update.message.text.split(" ", 1) if len(parts) != 2: - await update.message.reply_text({get_string(user, "download_params_error")}) + await update.message.reply_text(get_string(user, "download_params_error")) return url_video = parts[1] - # Verifico se รจ un url ben formato. if validators.url(url_video): - if context.user_data is None: return context.user_data["url"] = url_video - if not user: - return - await update.message.reply_text( - f"{user.first_name}, {get_string(user,'download_content')}", - reply_markup=get_main_menu(update.effective_user), + f"{user.first_name}, {get_string(user, 'download_content')}", + reply_markup=get_main_menu(user), ) @@ -100,7 +96,7 @@ async def beauty(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: command = raw_text.split()[0] if raw_text != command: - await update.message.reply_text({get_string(user, "beauty_params_error")}) + await update.message.reply_text(get_string(user, "beauty_params_error")) return await handle_beauty(update) diff --git a/tests/test_buttons.py b/tests/test_buttons.py index 95f3e90..8ee16f2 100644 --- a/tests/test_buttons.py +++ b/tests/test_buttons.py @@ -1,4 +1,4 @@ -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, mock_open, patch import pytest from telegram import InlineKeyboardMarkup @@ -14,24 +14,27 @@ def test_get_main_menu(): - menu = get_main_menu() + mock_user = MagicMock() + mock_user.id = 12345 + menu = get_main_menu(mock_user) assert isinstance(menu, InlineKeyboardMarkup) assert len(menu.inline_keyboard) == 2 assert menu.inline_keyboard[0][0].text == "๐ŸŽถ Audio" assert menu.inline_keyboard[0][1].text == "๐Ÿ“น Video" - assert menu.inline_keyboard[1][0].text == "โŒ Annulla" + assert menu.inline_keyboard[1][0].text == "โŒ Cancel" def test_get_resolution_video(): - menu = get_resolution_video() + mock_user = MagicMock() + menu = get_resolution_video(mock_user) assert isinstance(menu, InlineKeyboardMarkup) assert len(menu.inline_keyboard) == 2 assert menu.inline_keyboard[0][0].text == "360p" assert menu.inline_keyboard[0][1].text == "480p" assert menu.inline_keyboard[0][2].text == "720p" - assert menu.inline_keyboard[1][0].text == "โŒ Annulla" + assert menu.inline_keyboard[1][0].text == "โŒ Cancel" @pytest.mark.asyncio @@ -48,7 +51,7 @@ async def test_handle_resolution_annulla(): assert context.user_data == {} update.callback_query.edit_message_text.assert_called_once_with( - "Operazione annullata." + "Operation canceled." ) @@ -132,7 +135,7 @@ async def test_handle_buttons_annulla(): assert context.user_data == {} update.callback_query.edit_message_text.assert_called_once_with( - "Operazione annullata." + "Operation canceled." ) @@ -170,7 +173,8 @@ async def test_handle_buttons_video(): assert context.user_data["download_video"] assert not context.user_data["download_audio"] update.callback_query.edit_message_text.assert_called_once_with( - "Scaricherai un mp4", reply_markup=get_resolution_video() + "You are about to download an mp4", + reply_markup=get_resolution_video(update.effective_user), ) @@ -228,7 +232,7 @@ async def test_handle_download_file_too_large(): with patch("src.buttons.get_media_size", return_value=100 * 1024 * 1024): await handle_download(update, context) update.callback_query.edit_message_text.assert_called_once_with( - "โŒ File troppo grande per Telegram (max 50MB)." + "โŒ File too large for Telegram (max 50MB)." ) @@ -248,7 +252,7 @@ async def test_handle_download_audio_success(): with patch("src.buttons.get_media_size", return_value=10 * 1024 * 1024), patch( "src.buttons.get_media", return_value="/tmp/audio.mp3" ), patch("os.path.exists", return_value=True), patch("os.remove"), patch( - "builtins.open", MagicMock() + "builtins.open", mock_open(read_data="{}") ): await handle_download(update, context) context.bot.send_audio.assert_called_once() @@ -271,7 +275,7 @@ async def test_handle_download_video_success(): with patch("src.buttons.get_media_size", return_value=10 * 1024 * 1024), patch( "src.buttons.get_media", return_value="/tmp/video.mp4" ), patch("os.path.exists", return_value=True), patch("os.remove"), patch( - "builtins.open", MagicMock() + "builtins.open", mock_open(read_data="{}") ): await handle_download(update, context) context.bot.send_video.assert_called_once() @@ -292,10 +296,12 @@ async def test_handle_download_failed(): with patch("src.buttons.get_media_size", return_value=10 * 1024 * 1024), patch( "src.buttons.get_media", return_value="error" - ), patch("os.path.exists", return_value=False): + ), patch("os.path.exists", return_value=False), patch( + "builtins.open", mock_open(read_data="{}") + ): await handle_download(update, context) context.bot.send_message.assert_called_once_with( - chat_id=update.callback_query.message.chat.id, text="Download fallito." + chat_id=update.callback_query.message.chat.id, text="Download failed." ) @@ -317,11 +323,11 @@ async def test_handle_download_telegram_error(): with patch("src.buttons.get_media_size", return_value=10 * 1024 * 1024), patch( "src.buttons.get_media", return_value="/tmp/audio.mp3" ), patch("os.path.exists", return_value=True), patch("os.remove"), patch( - "builtins.open", MagicMock() + "builtins.open", mock_open(read_data="{}") ): await handle_download(update, context) context.bot.send_message.assert_called_once_with( - chat_id=update.callback_query.message.chat.id, text="Errore invio file!" + chat_id=update.callback_query.message.chat.id, text="Error sending file!" ) @@ -344,10 +350,10 @@ async def test_handle_download_os_error_on_remove(): ), patch("os.path.exists", return_value=True), patch( "os.remove", side_effect=OSError("errore") ), patch( - "builtins.open", MagicMock() + "builtins.open", mock_open(read_data="{}") ): await handle_download(update, context) context.bot.send_message.assert_called_once_with( chat_id=update.callback_query.message.chat.id, - text="Errore eliminazione file!", + text="Error removing file!", ) diff --git a/tests/test_downloader.py b/tests/test_downloader.py index a4fe29f..068e23e 100644 --- a/tests/test_downloader.py +++ b/tests/test_downloader.py @@ -55,7 +55,7 @@ def test_outtmpl_contains_download_dir(): def test_outtmpl_contains_template_titolo(): opts = build_ydl_opts(720, "mp4", "05adfd95 ...") assert ( - os.path.join(DOWNLOAD_DIR, f"{"05adfd95 ..."}.%(title)s.%(ext)s") + os.path.join(DOWNLOAD_DIR, f'{"05adfd95 ..."}.%(title)s.%(ext)s') in opts["outtmpl"] ) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index f852b42..e20253d 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -17,7 +17,7 @@ async def test_start(): await start(update, context) update.message.reply_text.assert_called_once_with( - "Ciao, Erika il tuo compare di fiducia ti aiuta a scaricare i tuoi media!" + "Hello Erika your trusty buddy is here to help you download your media!" ) @@ -27,12 +27,26 @@ async def test_about(): context = MagicMock() update.message.reply_text = AsyncMock() - update.effective_user.first_name = "Erika" + + mock_user = MagicMock() + mock_user.first_name = "Erika" + update.effective_user = mock_user await about(update, context) + expected_text = ( + "Hello Erika\n\n" + "โšก๏ธ Video Downloader Bot\n" + "Download your favorite videos from YouTube and other services in seconds.\n\n" + "Created and maintained by the team:\n" + "๐Ÿ‘จโ€๐Ÿ’ป Gianmarco [@sean8819]\n" + "๐Ÿ‘จโ€๐Ÿ’ป Marce [@Marss08]\n" + "๐Ÿ‘จโ€๐Ÿ’ป Enzo [@enzobarba]\n\n" + "โค๏ธ Thank you for choosing our bot!" + ) + update.message.reply_text.assert_called_once_with( - "Ciao, Erika informazioni sul progetto." + expected_text, parse_mode="HTML", disable_web_page_preview=True ) @@ -46,7 +60,15 @@ async def test_service(): await service(update, context) - update.message.reply_text.assert_called_once_with("Ciao, Erika stato dei servizi.") + expected_text = ( + "Hello Erika.\n\n" + "๐Ÿ›  My services\n\n" + "๐Ÿ“ฅ Media Download: Send a link to easily download videos or audio tracks from YouTube and other supported services.\n\n" + "๐ŸŒ Multilingual (i18n): Native support for Italian and English.\n\n" + "๐ŸŒ… Daily Inspiration: Receive the beauty photo of the day for a daily touch of wonder.\n\n" + ) + + update.message.reply_text.assert_called_once_with(expected_text, parse_mode="HTML") @pytest.mark.asyncio @@ -69,7 +91,7 @@ async def test_download_no_url(): await download(update, context) update.message.reply_text.assert_called_once_with( - "Devi fornire un messaggio cosรฌ formato /download " + "You need to format your message like this: /download " ) @@ -100,7 +122,8 @@ async def test_download_valid_url(): assert context.user_data["url"] == "https://www.youtube.com/watch?v=xxx" update.message.reply_text.assert_called_once_with( - "Erika, scarica il tuo contenuto!", reply_markup=get_main_menu() + "Erika, download your content!", + reply_markup=get_main_menu(update.effective_user), ) @@ -115,7 +138,7 @@ async def test_about_no_user(): await about(update, context) update.message.reply_text.assert_called_once_with( - "Ciao, utente informazioni sul progetto." + "Hello user.\n\nProject info.", parse_mode="HTML", disable_web_page_preview=True ) @@ -129,7 +152,9 @@ async def test_service_no_user(): await service(update, context) - update.message.reply_text.assert_called_once_with("Ciao, utente stato dei servizi.") + update.message.reply_text.assert_called_once_with( + "Hello user.\n\nServices info.", parse_mode="HTML" + ) @pytest.mark.asyncio diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 0000000..29edcc6 --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,106 @@ +# pylint: disable=unused-argument + +from unittest.mock import MagicMock, mock_open, patch + +from src.i18n import ( + _load_users_db, + _save_users_db, + _telegram_id_to_sha256, + get_string, + set_user_language, +) + + +def test_telegram_id_to_sha256_is_deterministic(): + hash1 = _telegram_id_to_sha256(123456) + hash2 = _telegram_id_to_sha256(123456) + assert hash1 == hash2 + + +def test_telegram_id_to_sha256_different_users(): + hash1 = _telegram_id_to_sha256(123456) + hash2 = _telegram_id_to_sha256(789012) + assert hash1 != hash2 + + +@patch("os.path.exists", return_value=False) +def test_load_users_db_file_not_exists(mock_exists): + result = _load_users_db() + assert result == {} + + +@patch("os.path.exists", return_value=True) +@patch("builtins.open", new_callable=mock_open, read_data='{"user_hash_1": "it"}') +def test_load_users_db_valid_json(mock_file, mock_exists): + # Se il file esiste ed รจ un JSON valido, deve restituire il dizionario + result = _load_users_db() + assert result == {"user_hash_1": "it"} + + +@patch("os.path.exists", return_value=True) +@patch("builtins.open", new_callable=mock_open, read_data="invalid_json") +def test_load_users_db_invalid_json(mock_file, mock_exists): + # Se il JSON รจ corrotto, per evitare crash deve restituire un dizionario vuoto + result = _load_users_db() + assert result == {} + + +@patch("builtins.open", new_callable=mock_open) +@patch("src.i18n.json.dump") +def test_save_users_db(mock_json_dump, mock_file): + # Verifica che la funzione provi a scrivere i dati nel file formattandoli correttamente + dummy_data = {"user_hash_1": "en"} + _save_users_db(dummy_data) + + mock_json_dump.assert_called_once_with(dummy_data, mock_file(), indent=4) + + +@patch("src.i18n._load_users_db", return_value={}) +@patch("src.i18n._save_users_db") +def test_set_user_language_new_user(mock_save, mock_load): + user_id = 12345 + set_user_language(user_id, "it") + + expected_hash = _telegram_id_to_sha256(user_id) + mock_save.assert_called_once_with({expected_hash: "it"}) + + +@patch("src.i18n._load_users_db") +@patch.dict( + "src.i18n.translations", {"en": {"hello": "Hello"}, "it": {"hello": "Ciao"}} +) +def test_get_string_fallback_english(mock_load): + # Se l'utente non รจ nel DB, deve usare l'inglese di default + mock_load.return_value = {} + + mock_user = MagicMock() + mock_user.id = 12345 + + result = get_string(mock_user, "hello") + assert result == "Hello" + + +@patch("src.i18n._load_users_db") +@patch.dict( + "src.i18n.translations", {"en": {"hello": "Hello"}, "it": {"hello": "Ciao"}} +) +def test_get_string_with_saved_language(mock_load): + mock_user = MagicMock() + mock_user.id = 12345 + user_hash = _telegram_id_to_sha256(mock_user.id) + + mock_load.return_value = {user_hash: "it"} + + result = get_string(mock_user, "hello") + assert result == "Ciao" + + +@patch("src.i18n._load_users_db", return_value={}) +@patch.dict("src.i18n.translations", {"en": {}}) +def test_get_string_missing_key(mock_load): + # Se chiediamo una stringa che non esiste nel file JSON, deve restituire la chiave stessa come fallback + mock_user = MagicMock() + mock_user.id = 12345 + + result = get_string(mock_user, "chiave_inesistente") + assert result == "chiave_inesistente" diff --git a/tests/test_messages.py b/tests/test_messages.py new file mode 100644 index 0000000..e705bce --- /dev/null +++ b/tests/test_messages.py @@ -0,0 +1,27 @@ +from unittest.mock import MagicMock, patch + +from telegram import User + +from src.messages import getAboutString, getServiceString + + +@patch("src.messages.get_string") +def test_getAboutString(mock_get_string): + mock_user = MagicMock(spec=User) + mock_get_string.return_value = "Testo About" + + result = getAboutString(mock_user) + + mock_get_string.assert_called_once_with(mock_user, "about_string") + assert result == "Testo About" + + +@patch("src.messages.get_string") +def test_getServiceString(mock_get_string): + mock_user = MagicMock(spec=User) + mock_get_string.return_value = "Testo Servizi" + + result = getServiceString(mock_user) + + mock_get_string.assert_called_once_with(mock_user, "services_string") + assert result == "Testo Servizi"