From fe9f4b866fc259ac983c2a3bfde4aa71fead6d24 Mon Sep 17 00:00:00 2001 From: DesvoSoft Date: Sun, 18 Oct 2026 13:30:16 -0600 Subject: [PATCH 1/2] feat(discord): webhook messages follow the app language, with an override in Settings --- app/api/areas.py | 8 +- app/core/core.py | 1 + app/core/i18n.py | 37 +++++- app/services/discord_webhook.py | 112 +++++++++++------- app/services/settings_manager.py | 2 + app/ui/app_settings.py | 22 +++- assets/locales/es/LC_MESSAGES/zbb.mo | Bin 34111 -> 36671 bytes assets/locales/es/LC_MESSAGES/zbb.po | 124 +++++++++++++++++++- assets/locales/zbb.pot | 116 ++++++++++++++++++- docs/ROADMAP.md | 1 + docs/changelog.md | 4 +- tests/test_api_areas.py | 2 +- tests/test_app_settings.py | 16 ++- tests/test_discord_language.py | 164 +++++++++++++++++++++++++++ 14 files changed, 555 insertions(+), 54 deletions(-) create mode 100644 tests/test_discord_language.py diff --git a/app/api/areas.py b/app/api/areas.py index 6c1ec13..56fe0ad 100644 --- a/app/api/areas.py +++ b/app/api/areas.py @@ -202,6 +202,8 @@ def reload_notifications(self) -> None: _require(self._manager, "SettingsApi.reload_notifications").reload_discord_webhook() @staticmethod - def send_test_notification(url: str, username: str = "", avatar_url: str = "") -> tuple: - """(ok, error). Blocking network call.""" - return DiscordWebhookService.send_test(url, username=username, avatar_url=avatar_url) + def send_test_notification(url: str, username: str = "", avatar_url: str = "", + language: str = "auto") -> tuple: + """(ok, error). Blocking network call. `language` is a + `discord_language` value ("auto", "en", "es").""" + return DiscordWebhookService.send_test(url, username=username, avatar_url=avatar_url, language=language) diff --git a/app/core/core.py b/app/core/core.py index 9328bb3..49a240c 100644 --- a/app/core/core.py +++ b/app/core/core.py @@ -131,6 +131,7 @@ def _init_discord_webhook(self) -> None: avatar_url=settings.get("webhook_avatar_url", ""), crash_mention_role=settings.get("webhook_crash_mention_role", ""), templates=settings.get("webhook_templates", {}), + language=settings.get("discord_language", "auto"), ) logger.info("Discord webhook active (%d events)", len(enabled)) diff --git a/app/core/i18n.py b/app/core/i18n.py index 0375af6..e136cdd 100644 --- a/app/core/i18n.py +++ b/app/core/i18n.py @@ -118,6 +118,34 @@ def get_language() -> str: return _language +_catalogs: dict = {} + + +def catalog(code) -> gettext.NullTranslations: + """The catalog for `code`, independent of the language the app runs in + (`_()` and friends always use the app's). For text rendered for another + audience than the UI, e.g. a Discord channel. Unsupported or unreadable + -> English. Cached, and safe to call from any thread.""" + lang = normalize(code) or DEFAULT_LANGUAGE + cached = _catalogs.get(lang) + if cached is not None: + return cached + translations = gettext.NullTranslations() + if lang != DEFAULT_LANGUAGE: + try: + translations = gettext.translation(DOMAIN, localedir=str(locale_dir()), languages=[lang]) + except (OSError, ValueError, struct.error) as e: + logger.warning("No usable %s catalog, using English: %s", lang, e) + _catalogs[lang] = translations + return translations + + +def resolve_language(preference) -> str: + """A language preference ("auto", "en", "es", anything else) -> a code. + "auto" and unknown values follow the app's language.""" + return normalize(preference) or _language + + def _(message: str) -> str: return _translations.gettext(message) @@ -196,10 +224,13 @@ def choice_key(choices, label, default=None): N_("Jul"), N_("Aug"), N_("Sep"), N_("Oct"), N_("Nov"), N_("Dec")) -def format_number(value: float, decimals: int = 0) -> str: +def format_number(value: float, decimals: int = 0, language=None) -> str: """2.5 -> '2.5' in English, '2,5' in Spanish. No digit grouping: the - numbers shown are sizes and compact counts, never long figures.""" - return f"{value:.{decimals}f}".replace(".", pgettext("decimal separator", ".")) + numbers shown are sizes and compact counts, never long figures. + `language` renders for that language instead of the app's.""" + separator = (catalog(language).pgettext("decimal separator", ".") if language + else pgettext("decimal separator", ".")) + return f"{value:.{decimals}f}".replace(".", separator) def format_percent(value: float, decimals: int = 0) -> str: diff --git a/app/services/discord_webhook.py b/app/services/discord_webhook.py index fe78764..a43055d 100644 --- a/app/services/discord_webhook.py +++ b/app/services/discord_webhook.py @@ -7,7 +7,7 @@ import requests -from app.core.i18n import english +from app.core.i18n import N_, catalog, english, format_number, resolve_language from app.core.server_events import EventBus, ServerEvent logger = logging.getLogger(__name__) @@ -25,16 +25,16 @@ _PLAYERS_FLUSH = "_players_flush" _EVENT_LABELS = { - ServerEvent.CRASHED: ("Server Crashed", 0xFF0000), - ServerEvent.READY: ("Server Ready", 0x57F287), - ServerEvent.STOPPED: ("Server Stopped", 0x99AAB5), - ServerEvent.RESTARTED: ("Server Restarted", 0xF1C40F), - ServerEvent.ZOMBIE_DETECTED: ("Server Unresponsive", 0xE74C3C), - ServerEvent.LAG_SPIKE: ("Lag Detected", 0xE67E22), - ServerEvent.TUNNEL_STATUS: ("Tunnel Online", 0x9B59B6), - ServerEvent.PLAYER_LIST: ("Players", 0x3498DB), - ServerEvent.BACKUP_COMPLETED: ("Backup Completed", 0x3498DB), - ServerEvent.BACKUP_FAILED: ("Backup Failed", 0xE67E22), + ServerEvent.CRASHED: (N_("Server Crashed"), 0xFF0000), + ServerEvent.READY: (N_("Server Ready"), 0x57F287), + ServerEvent.STOPPED: (N_("Server Stopped"), 0x99AAB5), + ServerEvent.RESTARTED: (N_("Server Restarted"), 0xF1C40F), + ServerEvent.ZOMBIE_DETECTED: (N_("Server Unresponsive"), 0xE74C3C), + ServerEvent.LAG_SPIKE: (N_("Lag Detected"), 0xE67E22), + ServerEvent.TUNNEL_STATUS: (N_("Tunnel Online"), 0x9B59B6), + ServerEvent.PLAYER_LIST: (N_("Players"), 0x3498DB), + ServerEvent.BACKUP_COMPLETED: (N_("Backup Completed"), 0x3498DB), + ServerEvent.BACKUP_FAILED: (N_("Backup Failed"), 0xE67E22), } # Settings key (webhook_events dict) -> ServerEvent @@ -86,15 +86,16 @@ _EVENT_SETTING_KEYS = {v: k for k, v in SETTING_EVENT_KEYS.items()} -def _fmt_duration(seconds: float) -> str: +def _fmt_duration(seconds: float, language: str = "en") -> str: + _ = catalog(language).gettext seconds = int(seconds) h, rem = divmod(seconds, 3600) m, s = divmod(rem, 60) if h: - return f"{h}h {m}m" + return _("{h}h {m}m").format(h=h, m=m) if m: - return f"{m}m {s}s" - return f"{s}s" + return _("{m}m {s}s").format(m=m, s=s) + return _("{s}s").format(s=s) class DiscordWebhookService: @@ -103,8 +104,12 @@ def __init__(self, webhook_url: str, events: EventBus, enabled_events: Optional[set] = None, username: str = "", avatar_url: str = "", crash_mention_role: str = "", - templates: Optional[dict] = None): + templates: Optional[dict] = None, + language: str = "auto"): self._url = webhook_url + # "auto" follows the app's language, "en"/"es" pin the channel's. + # Only the built-in wording is translated, never custom templates. + self._language = language self._events = events # Getter, not a snapshot: the active server can change after the # service is created (it lives for the whole app session). @@ -246,22 +251,23 @@ def _run(self) -> None: def _post(self, event_type: str, data) -> None: server_name = self._get_server() + lang = resolve_language(self._language) if event_type == _PLAYERS_FLUSH: label, color = _EVENT_LABELS[ServerEvent.PLAYER_LIST] template = self._templates.get("player_joins") if template: description = self._render_template(template, ServerEvent.PLAYER_LIST, data, server_name) else: - description = self._format_players_flush(data) + description = self._format_players_flush(data, lang) else: - label, color = _EVENT_LABELS.get(event_type, ("Event", 0x99AAB5)) + label, color = _EVENT_LABELS.get(event_type, (N_("Event"), 0x99AAB5)) template = self._templates.get(_EVENT_SETTING_KEYS.get(event_type, "")) if template: description = self._render_template(template, event_type, data, server_name) else: - description = self._format_description(event_type, data) + description = self._format_description(event_type, data, lang) embed = { - "title": label, + "title": catalog(lang).gettext(label), "description": description, "color": color, "timestamp": datetime.now(timezone.utc).isoformat(), @@ -299,46 +305,70 @@ def _post_payload(self, payload: dict) -> None: logger.warning("Discord webhook returned %s", resp.status_code) return - def _format_description(self, event_type: str, data) -> str: + def _format_description(self, event_type: str, data, language: str = "en") -> str: + """The built-in wording, rendered in `language` whatever language the + UI runs in (the webhook's channel is shared, so it is chosen apart).""" + t = catalog(language) + _ = t.gettext if event_type == ServerEvent.CRASHED and isinstance(data, dict): reason = data.get("reason", "unknown") - attempt = data.get("retry", 0) - return f"Reason: `{reason}` — Retry attempt {attempt}" + attempt = format_number(data.get("retry", 0), language=language) + return _("Reason: `{reason}` — Retry attempt {attempt}").format(reason=reason, attempt=attempt) if event_type == ServerEvent.STOPPED and isinstance(data, dict): uptime = data.get("uptime") if uptime: - return f"Clean shutdown after {_fmt_duration(uptime)}." - return "Server stopped." + return _("Clean shutdown after {duration}.").format(duration=_fmt_duration(uptime, language)) + return _("Server stopped.") if event_type == ServerEvent.RESTARTED and isinstance(data, dict): - attempt = data.get("retry", 0) + attempt = format_number(data.get("retry", 0), language=language) context = data.get("context", "crash") - return f"Automatic restart (attempt {attempt}, cause: {context})." + return _("Automatic restart (attempt {attempt}, cause: {context}).").format( + attempt=attempt, context=context) if event_type == ServerEvent.ZOMBIE_DETECTED and isinstance(data, dict): - silence = int(data.get("silence_seconds", 0)) - return f"No console output for {silence}s. Watchdog is restarting the server." + silence = format_number(int(data.get("silence_seconds", 0)), language=language) + return _("No console output for {seconds}s. Watchdog is restarting the server.").format(seconds=silence) if event_type == ServerEvent.LAG_SPIKE and isinstance(data, dict): - count = data.get("count", 0) + count = int(data.get("count", 0)) window_min = int(data.get("window_seconds", 300) // 60) - return f"{count} TPS warnings in the last {window_min} min. Server may be lagging." + return t.ngettext( + "{count} TPS warning in the last {minutes} min. Server may be lagging.", + "{count} TPS warnings in the last {minutes} min. Server may be lagging.", + count, + ).format(count=format_number(count, language=language), + minutes=format_number(window_min, language=language)) + return self._format_infra_description(event_type, data, language) + + @staticmethod + def _format_infra_description(event_type: str, data, language: str) -> str: + """Tunnel and backup events (split from `_format_description` for size).""" + _ = catalog(language).gettext if event_type == ServerEvent.TUNNEL_STATUS and isinstance(data, dict): addr = data.get("dns") or data.get("ip") or "" - return f"Server address: `{addr}`" + return _("Server address: `{address}`").format(address=addr) if event_type == ServerEvent.BACKUP_COMPLETED and isinstance(data, dict): path = data.get("path", "") - return f"Backup saved: `{path}`" if path else "Backup completed successfully." + if path: + return _("Backup saved: `{path}`").format(path=path) + return _("Backup completed successfully.") if event_type == ServerEvent.BACKUP_FAILED and isinstance(data, dict): - return f"Error: {english(data.get('error', 'unknown'))}" + # The error was translated for the UI's language; only its English form is kept. + return _("Error: {error}").format(error=english(data.get("error", "unknown"))) return "" - def _format_players_flush(self, data: dict) -> str: + def _format_players_flush(self, data: dict, language: str = "en") -> str: + t = catalog(language) + _ = t.gettext joins = data.get("joined") or [] leaves = data.get("left") or [] parts = [] if joins: - parts.append("Joined: " + ", ".join(f"**{p}**" for p in joins)) + parts.append(t.ngettext("Joined: {players}", "Joined: {players}", len(joins)).format( + players=", ".join(f"**{p}**" for p in joins))) if leaves: - parts.append("Left: " + ", ".join(f"**{p}**" for p in leaves)) - parts.append(f"Online now: **{data.get('online', 0)}**") + parts.append(t.ngettext("Left: {players}", "Left: {players}", len(leaves)).format( + players=", ".join(f"**{p}**" for p in leaves))) + parts.append(_("Online now: **{count}**").format( + count=format_number(data.get("online", 0), language=language))) return "\n".join(parts) def _template_vars(self, event_type: str, data, server_name: str) -> dict: @@ -393,12 +423,14 @@ def stop(self) -> None: self._subscriptions.clear() @staticmethod - def send_test(webhook_url: str, username: str = "", avatar_url: str = "") -> tuple[bool, str]: + def send_test(webhook_url: str, username: str = "", avatar_url: str = "", + language: str = "auto") -> tuple[bool, str]: """Post a test embed synchronously. Returns (ok, error_detail).""" payload = { "embeds": [{ "title": "ZeroBlockBridge", - "description": "Webhook test successful. Notifications will appear here.", + "description": catalog(resolve_language(language)).gettext( + "Webhook test successful. Notifications will appear here."), "color": 0x57F287, }] } diff --git a/app/services/settings_manager.py b/app/services/settings_manager.py index ff0f799..0ddc3a3 100644 --- a/app/services/settings_manager.py +++ b/app/services/settings_manager.py @@ -39,6 +39,8 @@ def _get_defaults(self) -> dict: "webhook_avatar_url": "", "webhook_crash_mention_role": "", "webhook_templates": {}, + # Language of the built-in Discord messages: "auto" (the app's) | "en" | "es" + "discord_language": "auto", # Window X: "ask" | "tray" | "exit" (see app.ui.tray) "close_action": "ask", } diff --git a/app/ui/app_settings.py b/app/ui/app_settings.py index 1a05343..5bf1144 100644 --- a/app/ui/app_settings.py +++ b/app/ui/app_settings.py @@ -51,6 +51,8 @@ class AppSettingsDialog(ZBBToplevel): ("Storage", N_("Storage")), ("About", N_("About"))) # Theme keys are stored as-is and passed to CTk's set_appearance_mode. _THEMES = (("Dark", N_("Dark")), ("Light", N_("Light")), ("System", N_("System"))) + # Stored in "discord_language"; language names are shown in their own language. + _DISCORD_LANGUAGES = (("auto", N_("Same as the app")), ("en", "English"), ("es", "Español")) def __init__(self, master, api, **kwargs): super().__init__(master, **kwargs) @@ -271,6 +273,18 @@ def _build_notifications_tab(self, tab): if current: self.entry_webhook.insert(0, current) + language_card = self._card( + scroll, _("Message language"), + _("Language of the default messages posted to Discord. Custom templates are posted as written."), + ) + self._discord_language_selector = ctk.CTkSegmentedButton( + language_card, values=i18n.choice_labels(self._DISCORD_LANGUAGES), height=32, + font=AppConfig.FONT_CAPTION, + ) + self._discord_language_selector.set( + i18n.choice_label(self._DISCORD_LANGUAGES, self._settings.get("discord_language", "auto"))) + self._discord_language_selector.grid(row=2, column=0, sticky="w", padx=15, pady=(4, 14)) + events_card = self._card(scroll, "Events", "Which events get posted to the webhook.") prefs = self._settings.get("webhook_events", {}) or {} self._event_vars = {} @@ -424,6 +438,9 @@ def _clear_template(self) -> None: self._webhook_templates.pop(key, None) self.entry_template.delete(0, "end") + def _selected_discord_language(self) -> str: + return i18n.choice_key(AppSettingsDialog._DISCORD_LANGUAGES, self._discord_language_selector.get(), "auto") + def _current_url(self) -> str: return self.entry_webhook.get().strip() @@ -460,6 +477,7 @@ def _save_notifications(self): self._settings.set("webhook_avatar_url", self.entry_webhook_avatar.get().strip()) self._settings.set("webhook_crash_mention_role", role) self._settings.set("webhook_templates", dict(self._webhook_templates)) + self._settings.set("discord_language", self._selected_discord_language()) self._settings.reload_notifications() if url: Toast.show(self, "Discord notifications enabled.", toast_type="success") @@ -476,9 +494,11 @@ def _send_test(self): self.btn_test.configure(state="disabled", text="Sending...") username = self.entry_webhook_name.get().strip() avatar_url = self.entry_webhook_avatar.get().strip() + language = self._selected_discord_language() def _worker(): - ok, error = self._settings.send_test_notification(url, username=username, avatar_url=avatar_url) + ok, error = self._settings.send_test_notification( + url, username=username, avatar_url=avatar_url, language=language) def _apply(): if not self.winfo_exists(): return diff --git a/assets/locales/es/LC_MESSAGES/zbb.mo b/assets/locales/es/LC_MESSAGES/zbb.mo index da1afb0bbaaa11145bc42e2eaa986ec5f05bd76d..bf924553f266a24f85df1d3fafc8a7c09d584eac 100644 GIT binary patch delta 7972 zcmb8zeRx#Woxt&v7eW#O32zA`1TG*+2oNM82tgn~UVuad!dpQ?E;BdD;LM$I?o2`m z(-A~LV1dU$Z4s=1?ShZs?oL@B)Gl^YD_y0F&x)nC(kiZ2U8|3)R!f>I-}s-M()|9G>Ns-)_}!FUP_@n5kI zzKCLA15@zFI1iu1cKisdv4t?p`zLWIm*96H3Up;!xay^r;S`tmq7m#tYaNKfzS|631W?`$SMw0m_8uU{pTX zLtz6xg)KN}lv3%q8I$o|lr7kW((zu$hfpT^C{DqrQ2KilB~r1+lB69aneNA1@f6C+KEQFiX*5G|O7tcHG znaoadB?o2P$}v%Uz-k&~?^aCO=AnsH*}j#cC|aO*k1(A%E3ne#r4lV)?SyIXDeVPzErZ z_H9@}{fC%@uVOM@Lh0`fY{dSAPsga2f}6sxu>s2p?JV7aOR1kn7O6%NHzu#

*8r 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 I((ax1Kc^Y(G5`Po delta 5571 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 diff --git a/assets/locales/es/LC_MESSAGES/zbb.po b/assets/locales/es/LC_MESSAGES/zbb.po index ade049b..1c8f7d4 100644 --- a/assets/locales/es/LC_MESSAGES/zbb.po +++ b/assets/locales/es/LC_MESSAGES/zbb.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: ZeroBlockBridge VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-15 14:28-0600\n" +"POT-Creation-Date: 2026-10-18 13:27-0600\n" "PO-Revision-Date: 2026-10-15 13:10-0600\n" "Last-Translator: DesvoSoft\n" "Language: es\n" @@ -17,7 +17,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.17.0\n" +"Generated-By: Babel 2.18.0\n" #, python-brace-format msgid "'{name}' isn't a valid player name (3-16 letters, numbers or _)." @@ -93,6 +93,19 @@ msgstr "ago" msgid "Auto-backup failed unexpectedly: {error}" msgstr "La copia de seguridad automática falló de forma inesperada: {error}" +#, python-brace-format +msgid "Automatic restart (attempt {attempt}, cause: {context})." +msgstr "Reinicio automático (intento {attempt}, causa: {context})." + +msgid "Backup Completed" +msgstr "Copia de seguridad completada" + +msgid "Backup Failed" +msgstr "Falló la copia de seguridad" + +msgid "Backup completed successfully." +msgstr "La copia de seguridad se completó correctamente." + #, python-brace-format msgid "Backup created with warnings. Skipped {count} locked file." msgid_plural "Backup created with warnings. Skipped {count} locked files." @@ -103,6 +116,10 @@ msgstr[1] "" "Copia de seguridad creada con advertencias. Se omitieron {count} archivos" " bloqueados." +#, python-brace-format +msgid "Backup saved: `{path}`" +msgstr "Copia de seguridad guardada: `{path}`" + msgid "Banned" msgstr "Baneados" @@ -199,6 +216,10 @@ msgstr "Elegir carpeta..." msgid "Choose how to set it up on this PC." msgstr "Elija cómo configurarlo en este equipo." +#, python-brace-format +msgid "Clean shutdown after {duration}." +msgstr "Apagado normal tras {duration}." + msgid "Close" msgstr "Cerrar" @@ -287,6 +308,13 @@ msgstr "Descargas" msgid "Error" msgstr "Error" +#, python-brace-format +msgid "Error: {error}" +msgstr "Error: {error}" + +msgid "Event" +msgstr "Evento" + msgid "Exit" msgstr "Salir" @@ -519,6 +547,12 @@ msgstr "" msgid "Java {major} ({kind}) — {source}" msgstr "Java {major} ({kind}) — {source}" +#, python-brace-format +msgid "Joined: {players}" +msgid_plural "Joined: {players}" +msgstr[0] "Se unió: {players}" +msgstr[1] "Se unieron: {players}" + msgid "Jul" msgstr "jul" @@ -535,13 +569,29 @@ msgstr "Seguir usando este archivo. No se le volverá a preguntar." msgid "Kicked {name}." msgstr "Se expulsó a {name}." +msgid "Lag Detected" +msgstr "Lag detectado" + msgid "Language" msgstr "Idioma" +msgid "" +"Language of the default messages posted to Discord. Custom templates are " +"posted as written." +msgstr "" +"Idioma de los mensajes predeterminados que se publican en Discord. Las " +"plantillas personalizadas se publican tal como las escribió." + #, python-brace-format msgid "Last seen {when}" msgstr "Visto por última vez {when}" +#, python-brace-format +msgid "Left: {players}" +msgid_plural "Left: {players}" +msgstr[0] "Salió: {players}" +msgstr[1] "Salieron: {players}" + msgid "Light" msgstr "Claro" @@ -585,6 +635,9 @@ msgstr "may" msgid "Medium" msgstr "Medio" +msgid "Message language" +msgstr "Idioma de los mensajes" + msgid "Minecraft EULA" msgstr "EULA de Minecraft" @@ -671,6 +724,12 @@ msgstr "" "level.dat). Seleccione la carpeta del propio mundo, p. ej. " ".minecraft/saves/." +#, python-brace-format +msgid "No console output for {seconds}s. Watchdog is restarting the server." +msgstr "" +"Sin salida de consola durante {seconds} s. El vigilante está reiniciando " +"el servidor." + msgid "No folder selected yet" msgstr "Aún no hay carpeta elegida" @@ -744,6 +803,10 @@ msgstr "En línea" msgid "Online now" msgstr "En línea ahora" +#, python-brace-format +msgid "Online now: **{count}**" +msgstr "En línea ahora: **{count}**" + msgid "Open ZeroBlockBridge" msgstr "Abrir ZeroBlockBridge" @@ -804,6 +867,10 @@ msgstr "Leer el EULA de Minecraft (aka.ms/MinecraftEULA)" msgid "Reading modpack manifest…" msgstr "Leyendo el manifiesto del modpack…" +#, python-brace-format +msgid "Reason: `{reason}` — Retry attempt {attempt}" +msgstr "Motivo: `{reason}` — Intento de reintento {attempt}" + msgid "Relevance" msgstr "Relevancia" @@ -851,6 +918,9 @@ msgstr "" "aplicación:\n" "{path}" +msgid "Same as the app" +msgstr "El mismo de la aplicación" + #, python-brace-format msgid "Saved with Minecraft {version}, the same version as this server." msgstr "Guardado con Minecraft {version}, la misma versión que este servidor." @@ -884,6 +954,25 @@ msgstr "El servidor '{name}' ya existe." msgid "Server '{name}' does not exist" msgstr "El servidor '{name}' no existe" +msgid "Server Crashed" +msgstr "Servidor caído" + +msgid "Server Ready" +msgstr "Servidor listo" + +msgid "Server Restarted" +msgstr "Servidor reiniciado" + +msgid "Server Stopped" +msgstr "Servidor detenido" + +msgid "Server Unresponsive" +msgstr "Servidor sin respuesta" + +#, python-brace-format +msgid "Server address: `{address}`" +msgstr "Dirección del servidor: `{address}`" + #, python-brace-format msgid "Server crashed: {cause} (attempt {attempt})." msgstr "El servidor falló: {cause} (intento {attempt})." @@ -903,6 +992,9 @@ msgstr "No se encontró el jar del servidor: {jar}" msgid "Server ready!" msgstr "¡Servidor listo!" +msgid "Server stopped." +msgstr "El servidor se detuvo." + #, python-brace-format msgid "Servers and backups: {path}" msgstr "Servidores y copias de seguridad: {path}" @@ -1083,6 +1175,9 @@ msgstr "" msgid "Tunnel Log" msgstr "Registro del túnel" +msgid "Tunnel Online" +msgstr "Túnel en línea" + msgid "Tunnel creation returned success but no ID." msgstr "La creación del túnel se completó, pero no devolvió un ID." @@ -1154,6 +1249,9 @@ msgstr "Actualizando..." msgid "Use letters, numbers and basic punctuation in the reason (max 100)." msgstr "Use letras, números y puntuación básica en el motivo (máx. 100)." +msgid "Webhook test successful. Notifications will appear here." +msgstr "La prueba del webhook fue exitosa. Las notificaciones aparecerán aquí." + msgid "Welcome to ZeroBlockBridge" msgstr "Bienvenido a ZeroBlockBridge" @@ -1285,6 +1383,16 @@ msgstr "" "{code}: Se alcanzó el límite de agentes. Elimine los agentes antiguos en " "playit.gg/dashboard" +#, python-brace-format +msgid "{count} TPS warning in the last {minutes} min. Server may be lagging." +msgid_plural "{count} TPS warnings in the last {minutes} min. Server may be lagging." +msgstr[0] "" +"{count} advertencia de TPS en los últimos {minutes} min. Es posible que " +"el servidor tenga lag." +msgstr[1] "" +"{count} advertencias de TPS en los últimos {minutes} min. Es posible que " +"el servidor tenga lag." + #, python-brace-format msgid "{count} client-only mod skipped." msgid_plural "{count} client-only mods skipped." @@ -1361,6 +1469,10 @@ msgstr "hace {hours} h" msgid "{hours}h {minutes}m" msgstr "{hours} h {minutes} min" +#, python-brace-format +msgid "{h}h {m}m" +msgstr "{h} h {m} min" + #, python-brace-format msgid "{item}: folder not found" msgstr "{item}: carpeta no encontrada" @@ -1377,6 +1489,10 @@ msgstr "{minutes} min" msgid "{minutes}m ago" msgstr "hace {minutes} min" +#, python-brace-format +msgid "{m}m {s}s" +msgstr "{m} min {s} s" + #, python-brace-format msgid "{name} is no longer an operator." msgstr "{name} ya no es operador." @@ -1393,6 +1509,10 @@ msgstr "" msgid "{seconds}s" msgstr "{seconds} s" +#, python-brace-format +msgid "{s}s" +msgstr "{s} s" + #, python-brace-format msgid "{tab} ({count})" msgstr "{tab} ({count})" diff --git a/assets/locales/zbb.pot b/assets/locales/zbb.pot index 50b0e5d..dd35862 100644 --- a/assets/locales/zbb.pot +++ b/assets/locales/zbb.pot @@ -9,14 +9,14 @@ msgid "" msgstr "" "Project-Id-Version: ZeroBlockBridge VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-15 14:28-0600\n" +"POT-Creation-Date: 2026-10-18 13:27-0600\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.17.0\n" +"Generated-By: Babel 2.18.0\n" #, python-brace-format msgid "'{name}' isn't a valid player name (3-16 letters, numbers or _)." @@ -90,12 +90,29 @@ msgstr "" msgid "Auto-backup failed unexpectedly: {error}" msgstr "" +#, python-brace-format +msgid "Automatic restart (attempt {attempt}, cause: {context})." +msgstr "" + +msgid "Backup Completed" +msgstr "" + +msgid "Backup Failed" +msgstr "" + +msgid "Backup completed successfully." +msgstr "" + #, python-brace-format msgid "Backup created with warnings. Skipped {count} locked file." msgid_plural "Backup created with warnings. Skipped {count} locked files." msgstr[0] "" msgstr[1] "" +#, python-brace-format +msgid "Backup saved: `{path}`" +msgstr "" + msgid "Banned" msgstr "" @@ -179,6 +196,10 @@ msgstr "" msgid "Choose how to set it up on this PC." msgstr "" +#, python-brace-format +msgid "Clean shutdown after {duration}." +msgstr "" + msgid "Close" msgstr "" @@ -263,6 +284,13 @@ msgstr "" msgid "Error" msgstr "" +#, python-brace-format +msgid "Error: {error}" +msgstr "" + +msgid "Event" +msgstr "" + msgid "Exit" msgstr "" @@ -471,6 +499,12 @@ msgstr "" msgid "Java {major} ({kind}) — {source}" msgstr "" +#, python-brace-format +msgid "Joined: {players}" +msgid_plural "Joined: {players}" +msgstr[0] "" +msgstr[1] "" + msgid "Jul" msgstr "" @@ -487,13 +521,27 @@ msgstr "" msgid "Kicked {name}." msgstr "" +msgid "Lag Detected" +msgstr "" + msgid "Language" msgstr "" +msgid "" +"Language of the default messages posted to Discord. Custom templates are " +"posted as written." +msgstr "" + #, python-brace-format msgid "Last seen {when}" msgstr "" +#, python-brace-format +msgid "Left: {players}" +msgid_plural "Left: {players}" +msgstr[0] "" +msgstr[1] "" + msgid "Light" msgstr "" @@ -533,6 +581,9 @@ msgstr "" msgid "Medium" msgstr "" +msgid "Message language" +msgstr "" + msgid "Minecraft EULA" msgstr "" @@ -610,6 +661,10 @@ msgid "" "world's own folder, e.g. .minecraft/saves/." msgstr "" +#, python-brace-format +msgid "No console output for {seconds}s. Watchdog is restarting the server." +msgstr "" + msgid "No folder selected yet" msgstr "" @@ -675,6 +730,10 @@ msgstr "" msgid "Online now" msgstr "" +#, python-brace-format +msgid "Online now: **{count}**" +msgstr "" + msgid "Open ZeroBlockBridge" msgstr "" @@ -729,6 +788,10 @@ msgstr "" msgid "Reading modpack manifest…" msgstr "" +#, python-brace-format +msgid "Reason: `{reason}` — Retry attempt {attempt}" +msgstr "" + msgid "Relevance" msgstr "" @@ -768,6 +831,9 @@ msgid "" "{path}" msgstr "" +msgid "Same as the app" +msgstr "" + #, python-brace-format msgid "Saved with Minecraft {version}, the same version as this server." msgstr "" @@ -796,6 +862,25 @@ msgstr "" msgid "Server '{name}' does not exist" msgstr "" +msgid "Server Crashed" +msgstr "" + +msgid "Server Ready" +msgstr "" + +msgid "Server Restarted" +msgstr "" + +msgid "Server Stopped" +msgstr "" + +msgid "Server Unresponsive" +msgstr "" + +#, python-brace-format +msgid "Server address: `{address}`" +msgstr "" + #, python-brace-format msgid "Server crashed: {cause} (attempt {attempt})." msgstr "" @@ -815,6 +900,9 @@ msgstr "" msgid "Server ready!" msgstr "" +msgid "Server stopped." +msgstr "" + #, python-brace-format msgid "Servers and backups: {path}" msgstr "" @@ -960,6 +1048,9 @@ msgstr "" msgid "Tunnel Log" msgstr "" +msgid "Tunnel Online" +msgstr "" + msgid "Tunnel creation returned success but no ID." msgstr "" @@ -1031,6 +1122,9 @@ msgstr "" msgid "Use letters, numbers and basic punctuation in the reason (max 100)." msgstr "" +msgid "Webhook test successful. Notifications will appear here." +msgstr "" + msgid "Welcome to ZeroBlockBridge" msgstr "" @@ -1145,6 +1239,12 @@ msgstr "" msgid "{code}: Agent limit reached. Delete old agents at playit.gg/dashboard" msgstr "" +#, python-brace-format +msgid "{count} TPS warning in the last {minutes} min. Server may be lagging." +msgid_plural "{count} TPS warnings in the last {minutes} min. Server may be lagging." +msgstr[0] "" +msgstr[1] "" + #, python-brace-format msgid "{count} client-only mod skipped." msgid_plural "{count} client-only mods skipped." @@ -1215,6 +1315,10 @@ msgstr "" msgid "{hours}h {minutes}m" msgstr "" +#, python-brace-format +msgid "{h}h {m}m" +msgstr "" + #, python-brace-format msgid "{item}: folder not found" msgstr "" @@ -1231,6 +1335,10 @@ msgstr "" msgid "{minutes}m ago" msgstr "" +#, python-brace-format +msgid "{m}m {s}s" +msgstr "" + #, python-brace-format msgid "{name} is no longer an operator." msgstr "" @@ -1245,6 +1353,10 @@ msgstr "" msgid "{seconds}s" msgstr "" +#, python-brace-format +msgid "{s}s" +msgstr "" + #, python-brace-format msgid "{tab} ({count})" msgstr "" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c93a3cb..35bebfc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,6 +34,7 @@ CustomTkinter's rendering problems are structural (every widget is a Tk canvas; | # | Item | Notes | |---|------|-------| | 8a | i18n foundation and language at install | **Part 1 in review** (`feature/i18n-foundation`): i18n module, catalogs + tooling, OS language detection, picker on the first setup page, persistence (marker / portable settings / Settings → General), Spanish for the pre-app dialogs, EULA, close dialog, tray and single-instance message, locale-aware formatting. **Part 2 in review** (`feature/i18n-core-messages`): label/key separation (console and Settings tabs, theme, player filters, Modrinth sort, schedule mode, property impact, tunnel status, crash reasons, blocked-command reasons, Java dropdown, explicit console line kinds) and Spanish for the core/services messages (notifications, exceptions, progress). Scope: Dependency-free i18n module, gettext `.po` catalogs in `assets/locales`, Babel extraction, plural and locale-aware formatting helpers. Separate display labels from internal keys (console/settings tabs, theme, player filters, sort options, BlockedReason, crash reason codes, tunnel-log tagging). Language picker on the first setup page (default: Windows UI language), stored in `install.json` (portable: the data folder's settings) and in Settings → General. Spanish for setup/update/uninstall dialogs, core messages and notifications. | +| 8a-2 | Discord messages in Spanish | **In review** (`feature/discord-language`): setting `discord_language` (auto = app language / en / es), default embeds, test message, durations and plurals rendered with an explicit catalog (`i18n.catalog`), independent of the UI language; custom templates never translated; Settings → Notifications → Message language. | | 8b | UI-agnostic API seam over the core | **Part A done** (PR #25, `feature/api-seam`): `app/api` facade (servers, console, tunnel, players, backups, mods, Java, settings, storage), event subscriptions through a UI-supplied dispatcher, task handles with progress and cancellation; mod file deletes/installs/modpacks/icons moved from the Modrinth browser into `app/services/mod_content.py` / `mod_icons.py` behind a mods/plugins-only delete guard; "open folder" in one place; main window and Modrinth browser switched to the API. **Part B in review** (`feature/api-seam-b`): server-properties catalog in `app/api/property_catalog.py`; players panel, Settings dialog, wizard and properties editor on the API (new `players`, `config`, `creation` areas; backups, Java, settings and storage extended); `AppApi.legacy_manager` removed; every screen held to API-only imports by a test. Scope: Commands, queries and events with tests; move logic out of screens (Modrinth browser file deletes/downloads, folder opening). | | 8c | Qt spike (2 days) | Server list, live console, tray, one wizard dialog, dark/light, Spanish from the same catalog. Passes if: dialog opens < 100 ms with no flash, exe ≤ 60 MB, startup ≤ current + 1 s, correct at 150% scaling with Spanish text. Otherwise repeat with pywebview. | | 8d | Screen-by-screen migration behind `--ui=qt` | Main window/console/tray → server list/header → wizard → properties → settings and setup dialogs → players → Modrinth browser. One PR per group, Spanish included. Remove CustomTkinter at parity. | diff --git a/docs/changelog.md b/docs/changelog.md index 1520504..64d23d2 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,9 +12,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - **Spanish** — the setup, update and uninstall dialogs have a language picker (English / Español, defaulting to the Windows display language) and are fully translated, as are the Minecraft EULA prompt, the close-to-tray dialog, the tray menu and its notification, and the "already running" message. The choice is remembered for the install (or the portable folder) and can be changed in Settings → General → Language (takes effect after a restart). The rest of the app follows as each screen is rebuilt. - Uptime, "last seen", RAM, file sizes, backup dates and Modrinth download counts use the chosen language's number and date format (e.g. `2,3 GB`, `hace 5 min`, `02 may 2026`); backup dates no longer depend on the system locale. - Translation tooling: gettext catalogs in `assets/locales`, `tools/i18n.py` (Babel) to extract, update and compile them. -- In Spanish, the app's notifications and messages are translated too: server start and Java problems, crashes (now explained in words: "out of memory", "it stopped while starting"... instead of a code like `boot_crash`), tunnel status and Playit notifications, blocked console commands, player actions (op, whitelist, kick, ban), backups, world and modpack import checks, server creation progress and errors, Modrinth and Java download errors. Counts use proper plurals ("1 mod", "2 mods"). The console and tunnel log stay in English, as do Discord messages. +- In Spanish, the app's notifications and messages are translated too: server start and Java problems, crashes (now explained in words: "out of memory", "it stopped while starting"... instead of a code like `boot_crash`), tunnel status and Playit notifications, blocked console commands, player actions (op, whitelist, kick, ban), backups, world and modpack import checks, server creation progress and errors, Modrinth and Java download errors. Counts use proper plurals ("1 mod", "2 mods"). The console and tunnel log stay in English. - Settings tabs and theme, console tabs, player filters, Modrinth sort order, the restart schedule mode and the property impact hints show in the chosen language. +- **Discord messages in Spanish.** The webhook's default messages (titles, descriptions, the test message, durations and plurals) follow the app's language, or can be set apart in Settings → Notifications → Message language (Same as the app / English / Español), since the channel is shared. Custom message templates are always posted exactly as written. + ### Changed - Internal: a UI-independent API (`app/api`) now sits between the screens and the core, so the upcoming Qt interface and today's one share the same tested code. The main window, Mods, Players, Settings, the creation wizard and server properties use it; nothing changes on screen. - Deleting or uninstalling a mod/plugin only ever removes a file directly inside that server's `mods/` or `plugins/` folder: a damaged `installed_mods.json` entry can no longer point a delete elsewhere. Deleting a server refuses a name that isn't a plain folder name. diff --git a/tests/test_api_areas.py b/tests/test_api_areas.py index 6998d9b..6c25961 100644 --- a/tests/test_api_areas.py +++ b/tests/test_api_areas.py @@ -130,7 +130,7 @@ def test_notifications(self): manager.reload_discord_webhook.assert_called_once() with patch("app.api.areas.DiscordWebhookService.send_test", return_value=(True, "")) as send: assert SettingsApi.send_test_notification("https://x", "ZBB", "") == (True, "") - send.assert_called_once_with("https://x", username="ZBB", avatar_url="") + send.assert_called_once_with("https://x", username="ZBB", avatar_url="", language="auto") def test_default_store_is_the_settings_singleton(self): with patch("app.api.areas.SettingsManager") as sm: diff --git a/tests/test_app_settings.py b/tests/test_app_settings.py index 1690668..451469e 100644 --- a/tests/test_app_settings.py +++ b/tests/test_app_settings.py @@ -18,7 +18,8 @@ def _entry(value=""): return MagicMock(get=MagicMock(return_value=value)) -def _dialog(url=VALID_URL, role="", name="ZBB", avatar="", templates=None, template_text="", event_label=None): +def _dialog(url=VALID_URL, role="", name="ZBB", avatar="", templates=None, template_text="", event_label=None, + language_label="Same as the app"): d = SimpleNamespace( entry_webhook=_entry(url), entry_webhook_role=_entry(role), @@ -28,12 +29,14 @@ def _dialog(url=VALID_URL, role="", name="ZBB", avatar="", templates=None, templ opt_template_event=_entry(event_label or "Server crashed"), _event_vars={"crashed": MagicMock(get=MagicMock(return_value=True)), "ready": MagicMock(get=MagicMock(return_value=False))}, + _discord_language_selector=_entry(language_label), _webhook_templates=dict(templates or {}), _settings=MagicMock(), api=MagicMock(), ) d._current_url = lambda: AppSettingsDialog._current_url(d) d._validate = lambda u: AppSettingsDialog._validate(d, u) + d._selected_discord_language = lambda: AppSettingsDialog._selected_discord_language(d) d._current_template_key = lambda: AppSettingsDialog._current_template_key(d) return d @@ -57,6 +60,17 @@ def test_valid_settings_are_persisted_and_webhook_reloaded(self, ui): d._settings.reload_notifications.assert_called_once() assert ui.toast.call_args.kwargs["toast_type"] == "success" + @pytest.mark.parametrize("label,stored", [("Same as the app", "auto"), ("English", "en"), ("Español", "es")]) + def test_message_language_is_saved_by_key_not_label(self, ui, label, stored): + d = _dialog(language_label=label) + AppSettingsDialog._save_notifications(d) + d._settings.set.assert_any_call("discord_language", stored) + + def test_unknown_language_label_falls_back_to_auto(self, ui): + d = _dialog(language_label="Klingon") + AppSettingsDialog._save_notifications(d) + d._settings.set.assert_any_call("discord_language", "auto") + def test_empty_url_disables_notifications(self, ui): d = _dialog(url="") AppSettingsDialog._save_notifications(d) diff --git a/tests/test_discord_language.py b/tests/test_discord_language.py new file mode 100644 index 0000000..c4821b2 --- /dev/null +++ b/tests/test_discord_language.py @@ -0,0 +1,164 @@ +"""Language of the Discord webhook messages: independent of the UI language.""" +from unittest.mock import MagicMock, patch + +import pytest + +from app.core import i18n +from app.core.server_events import EventBus, ServerEvent +from app.services.discord_webhook import DiscordWebhookService, _fmt_duration + + +@pytest.fixture +def app_language(): + """Set the app (UI) language inside a test; English again afterwards.""" + yield i18n.set_language + i18n.set_language("en") + + +def _render(event, data, language="auto", templates=None, server="Srv"): + """The embed the service would post for one event.""" + sent = [] + + def fake_post(url, json=None, timeout=None): + sent.append(json) + return MagicMock(status_code=204) + + with patch("app.services.discord_webhook.requests.post", side_effect=fake_post): + svc = DiscordWebhookService("http://example.com/hook", EventBus(), lambda: server, + templates=templates, language=language) + svc.stop() + svc._post(event, data) + return sent[0]["embeds"][0] + + +class TestMessageLanguage: + def test_english_defaults(self): + e = _render(ServerEvent.CRASHED, {"reason": "oom", "retry": 2}, language="en") + assert e["title"] == "Server Crashed" + assert e["description"] == "Reason: `oom` — Retry attempt 2" + + def test_spanish_override_ignores_english_ui(self, app_language): + app_language("en") + e = _render(ServerEvent.CRASHED, {"reason": "oom", "retry": 2}, language="es") + assert e["title"] == "Servidor caído" + assert e["description"] == "Motivo: `oom` — Intento de reintento 2" + + def test_english_override_ignores_spanish_ui(self, app_language): + app_language("es") + assert _render(ServerEvent.READY, {}, language="en")["title"] == "Server Ready" + + def test_auto_follows_app_language(self, app_language): + app_language("es") + assert _render(ServerEvent.READY, {}, language="auto")["title"] == "Servidor listo" + app_language("en") + assert _render(ServerEvent.READY, {}, language="auto")["title"] == "Server Ready" + + def test_unknown_value_behaves_like_auto(self, app_language): + app_language("es") + assert _render(ServerEvent.READY, {}, language="fr")["title"] == "Servidor listo" + + def test_rendering_does_not_change_the_app_language(self, app_language): + app_language("en") + _render(ServerEvent.READY, {}, language="es") + assert i18n.get_language() == "en" + assert i18n._("Server Ready") == "Server Ready" + + @pytest.mark.parametrize("event,data", [ + (ServerEvent.STOPPED, {"uptime": 3700}), + (ServerEvent.STOPPED, {}), + (ServerEvent.RESTARTED, {"retry": 1, "context": "crash"}), + (ServerEvent.ZOMBIE_DETECTED, {"silence_seconds": 90}), + (ServerEvent.LAG_SPIKE, {"count": 4, "window_seconds": 300}), + (ServerEvent.TUNNEL_STATUS, {"dns": "a.b.c"}), + (ServerEvent.BACKUP_COMPLETED, {"path": "x.zip"}), + (ServerEvent.BACKUP_COMPLETED, {}), + (ServerEvent.BACKUP_FAILED, {"error": "disk full"}), + (ServerEvent.CRASHED, {"reason": "oom", "retry": 1}), + (ServerEvent.READY, {}), + ]) + def test_every_default_is_translated(self, event, data): + en = _render(event, data, language="en") + es = _render(event, data, language="es") + assert es["title"] != en["title"] + if en["description"] and event != ServerEvent.BACKUP_FAILED: # "Error: ..." reads the same + assert es["description"] != en["description"] + + def test_players_flush_is_translated_and_plural(self): + flush = {"joined": ["Ana"], "left": ["Luis", "Eva"], "online": 3} + es = _render("_players_flush", flush, language="es") + assert es["title"] == "Jugadores" + assert es["description"] == "Se unió: **Ana**\nSalieron: **Luis**, **Eva**\nEn línea ahora: **3**" + en = _render("_players_flush", flush, language="en") + assert en["description"] == "Joined: **Ana**\nLeft: **Luis**, **Eva**\nOnline now: **3**" + + def test_lag_spike_plural(self): + def lag(count, language): + return _render(ServerEvent.LAG_SPIKE, {"count": count, "window_seconds": 300}, + language=language)["description"] + assert lag(1, "es").startswith("1 advertencia de TPS") + assert lag(5, "es").startswith("5 advertencias de TPS") + assert lag(1, "en").startswith("1 TPS warning in") + assert lag(5, "en").startswith("5 TPS warnings in") + + def test_durations_are_locale_aware(self): + assert _fmt_duration(3700, "en") == "1h 1m" + assert _fmt_duration(3700, "es") == "1 h 1 min" + assert _fmt_duration(125, "es") == "2 min 5 s" + assert _fmt_duration(7, "es") == "7 s" + assert _fmt_duration(7) == "7s" + e = _render(ServerEvent.STOPPED, {"uptime": 3700}, language="es") + assert e["description"] == "Apagado normal tras 1 h 1 min." + + +class TestCustomTemplatesUntouched: + TEMPLATES = {"crashed": "{server} crashed: {reason} (try {retry})", "stopped": "up {uptime}"} + + def test_template_is_posted_as_written_in_any_language(self, app_language): + app_language("es") + for lang in ("auto", "en", "es"): + e = _render(ServerEvent.CRASHED, {"reason": "oom", "retry": 2}, + language=lang, templates=self.TEMPLATES) + assert e["description"] == "Srv crashed: oom (try 2)" + + def test_placeholder_values_stay_english(self): + e = _render(ServerEvent.STOPPED, {"uptime": 3700}, language="es", templates=self.TEMPLATES) + assert e["description"] == "up 1h 1m" + + +class TestTestMessage: + @staticmethod + def _send(language): + with patch("app.services.discord_webhook.requests.post") as post: + post.return_value = MagicMock(status_code=204) + assert DiscordWebhookService.send_test("http://x", language=language) == (True, "") + return post.call_args.kwargs["json"]["embeds"][0]["description"] + + def test_english_and_spanish(self): + assert self._send("en") == "Webhook test successful. Notifications will appear here." + assert self._send("es").startswith("La prueba del webhook fue exitosa") + + def test_auto_follows_app_language(self, app_language): + app_language("es") + assert self._send("auto").startswith("La prueba") + assert self._send("en").startswith("Webhook test") + + +def test_default_setting_is_auto(): + from app.services.settings_manager import SettingsManager + assert SettingsManager()._get_defaults()["discord_language"] == "auto" + + +class TestCatalogForAnotherLanguage: + def test_catalog_is_independent_of_the_app_language(self, app_language): + app_language("en") + assert i18n.catalog("es").gettext("Players") == "Jugadores" + assert i18n.catalog("en").gettext("Players") == "Players" + assert i18n.catalog("xx").gettext("Players") == "Players" + assert i18n.format_number(2.5, 1, language="es") == "2,5" + assert i18n.format_number(2.5, 1) == "2.5" + + def test_resolve_language(self, app_language): + app_language("es") + assert i18n.resolve_language("auto") == "es" + assert i18n.resolve_language("en") == "en" + assert i18n.resolve_language(None) == "es" From f0106edbc5782cc879c69310d34b7cfa5a059fec Mon Sep 17 00:00:00 2001 From: DesvoSoft Date: Sun, 18 Oct 2026 13:32:44 -0600 Subject: [PATCH 2/2] fix(discord): tolerate a missing retry count in crash messages --- app/services/discord_webhook.py | 4 ++-- tests/test_discord_language.py | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/services/discord_webhook.py b/app/services/discord_webhook.py index a43055d..78c5805 100644 --- a/app/services/discord_webhook.py +++ b/app/services/discord_webhook.py @@ -312,7 +312,7 @@ def _format_description(self, event_type: str, data, language: str = "en") -> st _ = t.gettext if event_type == ServerEvent.CRASHED and isinstance(data, dict): reason = data.get("reason", "unknown") - attempt = format_number(data.get("retry", 0), language=language) + attempt = format_number(data.get("retry") or 0, language=language) return _("Reason: `{reason}` — Retry attempt {attempt}").format(reason=reason, attempt=attempt) if event_type == ServerEvent.STOPPED and isinstance(data, dict): uptime = data.get("uptime") @@ -320,7 +320,7 @@ def _format_description(self, event_type: str, data, language: str = "en") -> st return _("Clean shutdown after {duration}.").format(duration=_fmt_duration(uptime, language)) return _("Server stopped.") if event_type == ServerEvent.RESTARTED and isinstance(data, dict): - attempt = format_number(data.get("retry", 0), language=language) + attempt = format_number(data.get("retry") or 0, language=language) context = data.get("context", "crash") return _("Automatic restart (attempt {attempt}, cause: {context}).").format( attempt=attempt, context=context) diff --git a/tests/test_discord_language.py b/tests/test_discord_language.py index c4821b2..3c64c93 100644 --- a/tests/test_discord_language.py +++ b/tests/test_discord_language.py @@ -162,3 +162,8 @@ def test_resolve_language(self, app_language): assert i18n.resolve_language("auto") == "es" assert i18n.resolve_language("en") == "en" assert i18n.resolve_language(None) == "es" + + +def test_missing_retry_count_does_not_break_the_message(): + e = _render(ServerEvent.CRASHED, {"reason": "x", "retry": None}, language="es") + assert e["description"].endswith("reintento 0")