Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions app/api/areas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
1 change: 1 addition & 0 deletions app/core/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
37 changes: 34 additions & 3 deletions app/core/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
112 changes: 72 additions & 40 deletions app/services/discord_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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).
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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") 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")
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") or 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:
Expand Down Expand Up @@ -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,
}]
}
Expand Down
2 changes: 2 additions & 0 deletions app/services/settings_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
22 changes: 21 additions & 1 deletion app/ui/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down
Binary file modified assets/locales/es/LC_MESSAGES/zbb.mo
Binary file not shown.
Loading
Loading