diff --git a/CLAUDE.md b/CLAUDE.md index 93f47f6..d1656f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,3 +17,10 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the 3. **Template variables** must be updated in 6 files simultaneously — see [template-system.md](.claude/docs/template-system.md). 4. **Entity cache** — shared entities use `$state`-based caches in `frontend/src/lib/stores/caches.svelte.ts`. Always use cache for cross-page data; invalidate after mutations — see [frontend-architecture.md](.claude/docs/frontend-architecture.md). 5. **Telegram API** — ALL Telegram Bot API calls (sendMessage, sendPhoto, sendMediaGroup, etc.) MUST go through `TelegramClient` in `packages/core/src/notify_bridge_core/notifications/telegram/client.py`. NEVER duplicate sending logic in command handlers, API routes, or services. If `TelegramClient` lacks a method you need, add it there. +6. **Service provider defaults** — when implementing a new service provider, ALWAYS create default notification and command templates and configs. This requires changes across all of these locations: + - Jinja2 notification templates for each locale in `packages/core/src/notify_bridge_core/templates/defaults/{en,ru}/` + - Jinja2 command templates for each locale in `packages/core/src/notify_bridge_core/templates/command_defaults/{en,ru}/{provider}/` + - Notification slot mapping in `packages/core/src/notify_bridge_core/templates/defaults/loader.py` (`PROVIDER_SLOT_FILE_MAP`) + - Command slot mapping in `packages/core/src/notify_bridge_core/templates/command_defaults/loader.py` (`PROVIDER_COMMAND_SLOTS`) + - Provider capabilities in `packages/core/src/notify_bridge_core/providers/capabilities.py` + - Seed functions in `packages/server/src/notify_bridge_server/database/seeds.py` (notification templates, command templates, tracking configs, command configs) diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 02693d8..506349f 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -338,6 +338,7 @@ "chatName": "Name", "chatType": "Type", "chatLang": "Lang", + "langOverride": "Override", "cmds": "Cmds", "commandsToggle": "Toggle command listening for this chat", "chatId": "Chat ID", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 6ce7591..a619ad7 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -338,6 +338,7 @@ "chatName": "Имя", "chatType": "Тип", "chatLang": "Язык", + "langOverride": "Переопр.", "cmds": "Команды", "commandsToggle": "Включить/выключить команды для этого чата", "chatId": "ID чата", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 00e6285..6d0ac99 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -52,6 +52,7 @@ export interface TelegramChat { type: string; username: string; language_code?: string; + language_override?: string; commands_enabled: boolean; discovered_at: string; } diff --git a/frontend/src/routes/bots/TelegramBotTab.svelte b/frontend/src/routes/bots/TelegramBotTab.svelte index 97da4ed..bf0ede5 100644 --- a/frontend/src/routes/bots/TelegramBotTab.svelte +++ b/frontend/src/routes/bots/TelegramBotTab.svelte @@ -129,10 +129,10 @@ try { await api(`/telegram-bots/${botId}/chats/${chat.id}`, { method: 'PUT', - body: JSON.stringify({ language_code: lang }), + body: JSON.stringify({ language_override: lang }), }); chats[botId] = (chats[botId] || []).map(c => - c.id === chat.id ? { ...c, language_code: lang } : c + c.id === chat.id ? { ...c, language_override: lang } : c ); snackSuccess(t('telegramBot.languageUpdated')); } catch (err: any) { snackError(err.message); } @@ -362,13 +362,14 @@ {:else if (chats[bot.id] || []).length === 0}

{t('telegramBot.noChats')}

{:else} - {@const gridStyle = "display:grid; grid-template-columns:1fr 80px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"} + {@const gridStyle = "display:grid; grid-template-columns:1fr 80px 40px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"}
{t('telegramBot.chatName')} {t('telegramBot.chatType')} {t('telegramBot.chatLang')} + {t('telegramBot.langOverride')} {t('telegramBot.cmds')} {t('telegramBot.chatId')} @@ -382,10 +383,11 @@ role="button" tabindex="0"> {chat.title || chat.username || 'Unknown'} {chatTypeLabel(chat.type)} + {(chat.language_code || '—').toUpperCase()}
e.stopPropagation()}> updateChatLanguage(bot.id, chat, String(val ?? ''))} /> diff --git a/packages/core/src/notify_bridge_core/notifications/dispatcher.py b/packages/core/src/notify_bridge_core/notifications/dispatcher.py index f8ab864..7a50313 100644 --- a/packages/core/src/notify_bridge_core/notifications/dispatcher.py +++ b/packages/core/src/notify_bridge_core/notifications/dispatcher.py @@ -12,6 +12,16 @@ from notify_bridge_core.models.events import ServiceEvent from notify_bridge_core.templates.context import build_template_context from notify_bridge_core.templates.renderer import render_template +from .receiver import ( + Receiver, + TelegramReceiver, + WebhookReceiver, + EmailReceiver, + DiscordReceiver, + SlackReceiver, + NtfyReceiver, + MatrixReceiver, +) from .telegram.cache import TelegramFileCache from .telegram.client import TelegramClient from .webhook.client import WebhookClient @@ -28,14 +38,13 @@ class TargetConfig: type: str # "telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix" config: dict[str, Any] # target-level config (bot_token, settings, etc.) template_slots: dict[str, dict[str, str]] | None = None # event_type -> {locale -> template} - locale: str = "en" # preferred locale for template resolution + locale: str = "en" # default locale for template resolution date_format: str = "%d.%m.%Y, %H:%M UTC" date_only_format: str = "%d.%m.%Y" provider_api_key: str | None = None # API key for downloading assets from provider provider_internal_url: str | None = None # Internal provider URL for API key scoping provider_external_url: str | None = None # External domain for API key scoping - # Broadcast receivers — if non-empty, sends to each receiver instead of config - receivers: list[dict[str, Any]] = field(default_factory=list) + receivers: list[Receiver] = field(default_factory=list) class NotificationDispatcher: @@ -69,24 +78,43 @@ class NotificationDispatcher: results.append({"success": False, "error": str(e)}) return results - async def _send_to_target( - self, event: ServiceEvent, target: TargetConfig - ) -> dict[str, Any]: - """Send event to a single target (potentially multiple receivers).""" - # Select template with locale fallback + def _resolve_template( + self, event: ServiceEvent, target: TargetConfig, locale: str, + ) -> str: + """Resolve template string for an event, with locale fallback.""" template_str = DEFAULT_TEMPLATE if target.template_slots: locale_map = target.template_slots.get(event.event_type.value) if locale_map: - template_str = locale_map.get(target.locale) or locale_map.get("en") or template_str + template_str = locale_map.get(locale) or locale_map.get("en") or template_str + return template_str - # Build context and render ONCE + def _render_message( + self, event: ServiceEvent, target: TargetConfig, locale: str, + ) -> str: + """Resolve template and render message for a given locale.""" + template_str = self._resolve_template(event, target, locale) ctx = build_template_context( event, target_type=target.type, date_format=target.date_format, date_only_format=target.date_only_format, ) - message = render_template(template_str, ctx) + return render_template(template_str, ctx) + + def _message_for_receiver( + self, receiver: Receiver, default_message: str, + event: ServiceEvent, target: TargetConfig, + ) -> str: + """Return per-receiver message, re-rendering if receiver has a different locale.""" + if receiver.locale and receiver.locale != target.locale: + return self._render_message(event, target, receiver.locale) + return default_message + + async def _send_to_target( + self, event: ServiceEvent, target: TargetConfig + ) -> dict[str, Any]: + """Send event to a single target (potentially multiple receivers).""" + default_message = self._render_message(event, target, target.locale) send_method = { "telegram": self._send_telegram, @@ -98,11 +126,11 @@ class NotificationDispatcher: "matrix": self._send_matrix, }.get(target.type) if send_method: - return await send_method(target, message, event) + return await send_method(target, default_message, event) return {"success": False, "error": f"Unknown target type: {target.type}"} async def _send_telegram( - self, target: TargetConfig, message: str, event: ServiceEvent + self, target: TargetConfig, default_message: str, event: ServiceEvent ) -> dict[str, Any]: bot_token = target.config.get("bot_token") disable_preview = target.config.get("disable_url_preview", False) @@ -120,7 +148,6 @@ class NotificationDispatcher: if not target.receivers: return {"success": False, "error": "No receivers configured"} - receivers = target.receivers # Prepare assets list once (shared across receivers) provider_urls = [] @@ -146,28 +173,27 @@ class NotificationDispatcher: asset_cache=self._asset_cache, ) - for receiver in receivers: - chat_id = receiver.get("chat_id") - if not chat_id: - results.append({"success": False, "error": "Missing chat_id in receiver"}) + for receiver in target.receivers: + if not isinstance(receiver, TelegramReceiver) or not receiver.chat_id: + results.append({"success": False, "error": "Invalid telegram receiver"}) continue - # Step 1: Send the text message + message = self._message_for_receiver(receiver, default_message, event, target) + text_result = await client.send_message( - chat_id=str(chat_id), + chat_id=receiver.chat_id, text=message, disable_web_page_preview=bool(disable_preview), ) if not text_result.get("success"): - _LOGGER.warning("Failed to send to chat %s: %s", chat_id, text_result.get("error")) + _LOGGER.warning("Failed to send to chat %s: %s", receiver.chat_id, text_result.get("error")) results.append(text_result) continue - # Step 2: Send assets as reply if assets: reply_to = text_result.get("message_id") media_result = await client.send_notification( - chat_id=str(chat_id), + chat_id=receiver.chat_id, assets=assets, reply_to_message_id=reply_to, max_group_size=max_group, @@ -177,43 +203,40 @@ class NotificationDispatcher: chat_action=chat_action or None, ) if not media_result.get("success"): - _LOGGER.warning("Text sent OK but media failed for chat %s: %s", chat_id, media_result.get("error")) + _LOGGER.warning("Text sent OK but media failed for chat %s: %s", receiver.chat_id, media_result.get("error")) results.append(text_result) return self._aggregate_results(results) async def _send_webhook( - self, target: TargetConfig, message: str, event: ServiceEvent + self, target: TargetConfig, default_message: str, event: ServiceEvent ) -> dict[str, Any]: if not target.receivers: return {"success": False, "error": "No receivers configured"} - receivers = target.receivers - - payload = { - "message": message, - "event_type": event.event_type.value, - "provider_type": event.provider_type.value, - "collection_name": event.collection_name, - "collection_id": event.collection_id, - "timestamp": event.timestamp.isoformat(), - } results: list[dict[str, Any]] = [] async with aiohttp.ClientSession() as session: - for receiver in receivers: - url = receiver.get("url") - headers = receiver.get("headers", {}) - if not url: - results.append({"success": False, "error": "Missing url in receiver"}) + for receiver in target.receivers: + if not isinstance(receiver, WebhookReceiver) or not receiver.url: + results.append({"success": False, "error": "Invalid webhook receiver"}) continue - client = WebhookClient(session, url, headers) + message = self._message_for_receiver(receiver, default_message, event, target) + payload = { + "message": message, + "event_type": event.event_type.value, + "provider_type": event.provider_type.value, + "collection_name": event.collection_name, + "collection_id": event.collection_id, + "timestamp": event.timestamp.isoformat(), + } + client = WebhookClient(session, receiver.url, receiver.headers) results.append(await client.send(payload)) return self._aggregate_results(results) async def _send_email( - self, target: TargetConfig, message: str, event: ServiceEvent + self, target: TargetConfig, default_message: str, event: ServiceEvent ) -> dict[str, Any]: from .email.client import EmailClient, SmtpConfig @@ -233,71 +256,68 @@ class NotificationDispatcher: if not target.receivers: return {"success": False, "error": "No receivers configured"} - receivers = target.receivers subject = f"[Notify Bridge] {event.event_type.value}: {event.collection_name}" results: list[dict[str, Any]] = [] - for receiver in receivers: - email = receiver.get("email") - if not email: - results.append({"success": False, "error": "Missing email in receiver"}) + for receiver in target.receivers: + if not isinstance(receiver, EmailReceiver) or not receiver.email: + results.append({"success": False, "error": "Invalid email receiver"}) continue + message = self._message_for_receiver(receiver, default_message, event, target) result = await client.send( - to_email=email, + to_email=receiver.email, subject=subject, body_text=message, - to_name=receiver.get("name", ""), + to_name=receiver.name, ) results.append(result) return self._aggregate_results(results) async def _send_discord( - self, target: TargetConfig, message: str, event: ServiceEvent + self, target: TargetConfig, default_message: str, event: ServiceEvent ) -> dict[str, Any]: from .discord.client import DiscordClient if not target.receivers: return {"success": False, "error": "No receivers configured"} - receivers = target.receivers username = target.config.get("username") results: list[dict[str, Any]] = [] async with aiohttp.ClientSession() as session: client = DiscordClient(session) - for receiver in receivers: - webhook_url = receiver.get("webhook_url") - if not webhook_url: - results.append({"success": False, "error": "Missing webhook_url"}) + for receiver in target.receivers: + if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url: + results.append({"success": False, "error": "Invalid discord receiver"}) continue - results.append(await client.send(webhook_url, message, username=username)) + message = self._message_for_receiver(receiver, default_message, event, target) + results.append(await client.send(receiver.webhook_url, message, username=username)) return self._aggregate_results(results) async def _send_slack( - self, target: TargetConfig, message: str, event: ServiceEvent + self, target: TargetConfig, default_message: str, event: ServiceEvent ) -> dict[str, Any]: from .slack.client import SlackClient if not target.receivers: return {"success": False, "error": "No receivers configured"} - receivers = target.receivers username = target.config.get("username") results: list[dict[str, Any]] = [] async with aiohttp.ClientSession() as session: client = SlackClient(session) - for receiver in receivers: - webhook_url = receiver.get("webhook_url") - if not webhook_url: - results.append({"success": False, "error": "Missing webhook_url"}) + for receiver in target.receivers: + if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url: + results.append({"success": False, "error": "Invalid slack receiver"}) continue - results.append(await client.send(webhook_url, message, username=username)) + message = self._message_for_receiver(receiver, default_message, event, target) + results.append(await client.send(receiver.webhook_url, message, username=username)) return self._aggregate_results(results) async def _send_ntfy( - self, target: TargetConfig, message: str, event: ServiceEvent + self, target: TargetConfig, default_message: str, event: ServiceEvent ) -> dict[str, Any]: from .ntfy.client import NtfyClient @@ -305,28 +325,26 @@ class NotificationDispatcher: auth_token = target.config.get("auth_token") if not target.receivers: return {"success": False, "error": "No receivers configured"} - receivers = target.receivers title = f"{event.event_type.value}: {event.collection_name}" results: list[dict[str, Any]] = [] async with aiohttp.ClientSession() as session: client = NtfyClient(session) - for receiver in receivers: - topic = receiver.get("topic") - if not topic: - results.append({"success": False, "error": "Missing topic"}) + for receiver in target.receivers: + if not isinstance(receiver, NtfyReceiver) or not receiver.topic: + results.append({"success": False, "error": "Invalid ntfy receiver"}) continue - priority = receiver.get("priority", 3) + message = self._message_for_receiver(receiver, default_message, event, target) results.append(await client.send( - server_url, topic, message, - title=title, priority=priority, auth_token=auth_token, + server_url, receiver.topic, message, + title=title, priority=receiver.priority, auth_token=auth_token, )) return self._aggregate_results(results) async def _send_matrix( - self, target: TargetConfig, message: str, event: ServiceEvent + self, target: TargetConfig, default_message: str, event: ServiceEvent ) -> dict[str, Any]: from .matrix.client import MatrixClient @@ -337,18 +355,17 @@ class NotificationDispatcher: if not target.receivers: return {"success": False, "error": "No receivers configured"} - receivers = target.receivers results: list[dict[str, Any]] = [] async with aiohttp.ClientSession() as session: client = MatrixClient(session, homeserver, access_token) - for receiver in receivers: - room_id = receiver.get("room_id") - if not room_id: - results.append({"success": False, "error": "Missing room_id"}) + for receiver in target.receivers: + if not isinstance(receiver, MatrixReceiver) or not receiver.room_id: + results.append({"success": False, "error": "Invalid matrix receiver"}) continue + message = self._message_for_receiver(receiver, default_message, event, target) results.append(await client.send_message( - room_id, message, html_message=message, + receiver.room_id, message, html_message=message, )) return self._aggregate_results(results) diff --git a/packages/core/src/notify_bridge_core/notifications/receiver.py b/packages/core/src/notify_bridge_core/notifications/receiver.py new file mode 100644 index 0000000..b07c221 --- /dev/null +++ b/packages/core/src/notify_bridge_core/notifications/receiver.py @@ -0,0 +1,120 @@ +"""Receiver hierarchy — typed delivery endpoints with locale resolution.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class Receiver: + """Base receiver with locale and raw config.""" + + locale: str = "" + config: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """Return raw dict for backward compat (includes locale).""" + return {**self.config, "locale": self.locale} + + +@dataclass +class TelegramReceiver(Receiver): + """Telegram chat receiver.""" + + chat_id: str = "" + + +@dataclass +class WebhookReceiver(Receiver): + """Webhook URL receiver.""" + + url: str = "" + headers: dict[str, str] = field(default_factory=dict) + + +@dataclass +class EmailReceiver(Receiver): + """Email receiver.""" + + email: str = "" + name: str = "" + + +@dataclass +class DiscordReceiver(Receiver): + """Discord webhook receiver.""" + + webhook_url: str = "" + + +@dataclass +class SlackReceiver(Receiver): + """Slack webhook receiver.""" + + webhook_url: str = "" + + +@dataclass +class NtfyReceiver(Receiver): + """ntfy topic receiver.""" + + topic: str = "" + priority: int = 3 + + +@dataclass +class MatrixReceiver(Receiver): + """Matrix room receiver.""" + + room_id: str = "" + + +def build_receiver(target_type: str, config: dict[str, Any], locale: str = "") -> Receiver: + """Factory: build typed Receiver from target type and config dict.""" + if target_type == "telegram": + return TelegramReceiver( + locale=locale, + config=config, + chat_id=str(config.get("chat_id", "")), + ) + if target_type == "webhook": + return WebhookReceiver( + locale=locale, + config=config, + url=config.get("url", ""), + headers=config.get("headers", {}), + ) + if target_type == "email": + return EmailReceiver( + locale=locale, + config=config, + email=config.get("email", ""), + name=config.get("name", ""), + ) + if target_type == "discord": + return DiscordReceiver( + locale=locale, + config=config, + webhook_url=config.get("webhook_url", ""), + ) + if target_type == "slack": + return SlackReceiver( + locale=locale, + config=config, + webhook_url=config.get("webhook_url", ""), + ) + if target_type == "ntfy": + return NtfyReceiver( + locale=locale, + config=config, + topic=config.get("topic", ""), + priority=config.get("priority", 3), + ) + if target_type == "matrix": + return MatrixReceiver( + locale=locale, + config=config, + room_id=config.get("room_id", ""), + ) + return Receiver(locale=locale, config=config) diff --git a/packages/server/src/notify_bridge_server/api/notification_tracker_targets.py b/packages/server/src/notify_bridge_server/api/notification_tracker_targets.py index d255dfe..7f35683 100644 --- a/packages/server/src/notify_bridge_server/api/notification_tracker_targets.py +++ b/packages/server/src/notify_bridge_server/api/notification_tracker_targets.py @@ -15,6 +15,7 @@ from ..database.models import ( NotificationTracker, NotificationTrackerTarget, ServiceProvider, + TargetReceiver, TemplateConfig, TemplateSlot, TrackingConfig, @@ -206,8 +207,39 @@ async def test_notification_tracker_target( if not target: raise HTTPException(status_code=404, detail="Target not found") + # Resolve effective locale from first receiver (explicit locale → TelegramChat lang) + effective_locale = locale + recv_result = await session.exec( + select(TargetReceiver).where( + TargetReceiver.target_id == target.id, + TargetReceiver.enabled == True, + ) + ) + first_recv = recv_result.first() + if first_recv: + recv_locale = getattr(first_recv, 'locale', '') or '' + if not recv_locale and target.type == "telegram": + # Resolve from TelegramChat language_override/language_code + from ..database.models import TelegramBot, TelegramChat + chat_id = str(first_recv.config.get("chat_id", "")) + bot_id = target.config.get("bot_id") + if chat_id and bot_id: + chat_row = (await session.exec( + select(TelegramChat).where( + TelegramChat.bot_id == bot_id, + TelegramChat.chat_id == chat_id, + ) + )).first() + if chat_row: + recv_locale = ( + getattr(chat_row, 'language_override', '') or + getattr(chat_row, 'language_code', '') or '' + ) + if recv_locale: + effective_locale = recv_locale[:2].lower() + if test_type == "basic": - r = await send_test_notification(target, locale=locale) + r = await send_test_notification(target, locale=effective_locale) return {"target": target.name, **r} # For periodic/scheduled/memory — fetch real data from provider @@ -226,7 +258,7 @@ async def test_notification_tracker_target( select(TemplateSlot).where( TemplateSlot.config_id == template_config.id, TemplateSlot.slot_name == slot_name, - TemplateSlot.locale == "en", + TemplateSlot.locale == effective_locale, ) ) slot = slot_result.first() diff --git a/packages/server/src/notify_bridge_server/api/target_receivers.py b/packages/server/src/notify_bridge_server/api/target_receivers.py index 8581a2f..fd06ef9 100644 --- a/packages/server/src/notify_bridge_server/api/target_receivers.py +++ b/packages/server/src/notify_bridge_server/api/target_receivers.py @@ -21,12 +21,14 @@ router = APIRouter(prefix="/api/targets/{target_id}/receivers", tags=["target-re class ReceiverCreate(BaseModel): name: str = "" config: dict[str, Any] = {} + locale: str = "" enabled: bool = True class ReceiverUpdate(BaseModel): name: str | None = None config: dict[str, Any] | None = None + locale: str | None = None enabled: bool | None = None @@ -85,6 +87,7 @@ async def create_receiver( name=body.name, config=body.config, receiver_key=key, + locale=body.locale, enabled=body.enabled, ) session.add(receiver) @@ -133,7 +136,8 @@ async def test_receiver( raise HTTPException(status_code=404, detail="Receiver not found") from ..services.notifier import _get_test_message - message = _get_test_message(locale, target.type) + effective_locale = getattr(receiver, 'locale', '') or locale + message = _get_test_message(effective_locale, target.type) return await send_to_receiver(target, dict(receiver.config), message) @@ -159,6 +163,7 @@ def _response(r: TargetReceiver) -> dict: "name": r.name, "config": dict(r.config), "receiver_key": r.receiver_key, + "locale": getattr(r, 'locale', '') or '', "enabled": r.enabled, "created_at": r.created_at.isoformat(), } diff --git a/packages/server/src/notify_bridge_server/api/targets.py b/packages/server/src/notify_bridge_server/api/targets.py index 24301d5..49c8bf5 100644 --- a/packages/server/src/notify_bridge_server/api/targets.py +++ b/packages/server/src/notify_bridge_server/api/targets.py @@ -107,7 +107,7 @@ async def list_targets( chat = chat_result.first() if chat: chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or "" - lang = getattr(chat, 'language_code', '') or '' + lang = getattr(chat, 'language_override', '') or getattr(chat, 'language_code', '') or '' if lang: chat_languages[f"{bot_id}_{chat_id}"] = lang @@ -278,6 +278,7 @@ def _target_response( "name": r.name, "config": dict(r.config), "receiver_key": r.receiver_key, + "locale": getattr(r, 'locale', '') or '', "enabled": r.enabled, } for r in recv_list diff --git a/packages/server/src/notify_bridge_server/api/telegram_bots.py b/packages/server/src/notify_bridge_server/api/telegram_bots.py index c3c6fba..b13dda2 100644 --- a/packages/server/src/notify_bridge_server/api/telegram_bots.py +++ b/packages/server/src/notify_bridge_server/api/telegram_bots.py @@ -15,7 +15,7 @@ from ..auth.dependencies import get_current_user from ..commands.handler import register_commands_with_telegram from ..commands.webhook import register_webhook, unregister_webhook from ..database.engine import get_session -from ..database.models import AppSetting, TelegramBot, TelegramChat, User +from ..database.models import AppSetting, NotificationTarget, TargetReceiver, TelegramBot, TelegramChat, User from ..services.notifier import _get_test_message from ..services.telegram_poller import schedule_bot_polling, unschedule_bot_polling from .app_settings import get_setting @@ -297,7 +297,7 @@ async def test_chat( class ChatUpdate(BaseModel): - language_code: str | None = None + language_override: str | None = None title: str | None = None commands_enabled: bool | None = None @@ -389,6 +389,7 @@ def _chat_response(c: TelegramChat) -> dict: "type": c.chat_type, "username": c.username, "language_code": getattr(c, 'language_code', '') or '', + "language_override": getattr(c, 'language_override', '') or '', "commands_enabled": getattr(c, 'commands_enabled', False), "discovered_at": c.discovered_at.isoformat(), } diff --git a/packages/server/src/notify_bridge_server/commands/webhook.py b/packages/server/src/notify_bridge_server/commands/webhook.py index 153f9a7..79a8c69 100644 --- a/packages/server/src/notify_bridge_server/commands/webhook.py +++ b/packages/server/src/notify_bridge_server/commands/webhook.py @@ -86,8 +86,9 @@ async def telegram_webhook( )).first() if not chat_row or not chat_row.commands_enabled: return {"ok": True, "skipped": "commands_disabled"} + effective_lang = chat_row.language_override or msg_language message_id = message.get("message_id") - cmd_response = await handle_command(bot, chat_id, text, language_code=msg_language) + cmd_response = await handle_command(bot, chat_id, text, language_code=effective_lang) if cmd_response is not None: if isinstance(cmd_response, list): await send_media_group(bot.token, chat_id, cmd_response, reply_to_message_id=message_id) diff --git a/packages/server/src/notify_bridge_server/database/migrations.py b/packages/server/src/notify_bridge_server/database/migrations.py index 6cf6dbd..b682068 100644 --- a/packages/server/src/notify_bridge_server/database/migrations.py +++ b/packages/server/src/notify_bridge_server/database/migrations.py @@ -207,6 +207,21 @@ async def migrate_schema(engine: AsyncEngine) -> None: ) logger.info("Added language_code column to telegram_chat table") + # Add language_override to telegram_chat if missing + if not await _has_column(conn, "telegram_chat", "language_override"): + await conn.execute( + text("ALTER TABLE telegram_chat ADD COLUMN language_override TEXT DEFAULT ''") + ) + logger.info("Added language_override column to telegram_chat table") + + # Add locale to target_receiver if missing + if await _has_table(conn, "target_receiver"): + if not await _has_column(conn, "target_receiver", "locale"): + await conn.execute( + text("ALTER TABLE target_receiver ADD COLUMN locale TEXT DEFAULT ''") + ) + logger.info("Added locale column to target_receiver table") + # Add commands_enabled to telegram_chat if missing (default disabled) if not await _has_column(conn, "telegram_chat", "commands_enabled"): await conn.execute( diff --git a/packages/server/src/notify_bridge_server/database/models.py b/packages/server/src/notify_bridge_server/database/models.py index cc26e79..825d6ce 100644 --- a/packages/server/src/notify_bridge_server/database/models.py +++ b/packages/server/src/notify_bridge_server/database/models.py @@ -95,7 +95,8 @@ class TelegramChat(SQLModel, table=True): title: str = Field(default="") chat_type: str = Field(default="private") username: str = Field(default="") - language_code: str = Field(default="") + language_code: str = Field(default="") # auto-detected from Telegram + language_override: str = Field(default="") # manual override set by user commands_enabled: bool = Field(default=False) discovered_at: datetime = Field(default_factory=_utcnow) @@ -279,6 +280,7 @@ class TargetReceiver(SQLModel, table=True): name: str = Field(default="") config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) receiver_key: str = Field(default="") # dedup key (e.g. chat_id, url, email) + locale: str = Field(default="") # e.g. "en", "ru"; empty = use target default enabled: bool = Field(default=True) created_at: datetime = Field(default_factory=_utcnow) diff --git a/packages/server/src/notify_bridge_server/services/dispatch_helpers.py b/packages/server/src/notify_bridge_server/services/dispatch_helpers.py index 6b9d3d5..b194a27 100644 --- a/packages/server/src/notify_bridge_server/services/dispatch_helpers.py +++ b/packages/server/src/notify_bridge_server/services/dispatch_helpers.py @@ -10,6 +10,7 @@ from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession from notify_bridge_core.models.events import ServiceEvent +from notify_bridge_core.notifications.receiver import Receiver, build_receiver from ..database.models import ( EmailBot, @@ -17,6 +18,8 @@ from ..database.models import ( NotificationTarget, NotificationTrackerTarget, TargetReceiver, + TelegramBot, + TelegramChat, TemplateConfig, TemplateSlot, TrackingConfig, @@ -115,14 +118,44 @@ async def load_link_data( if not target: continue - # Load receivers + # Load receivers as typed Receiver objects recv_result = await session.exec( select(TargetReceiver).where( TargetReceiver.target_id == target.id, TargetReceiver.enabled == True, ) ) - receivers = [dict(r.config) for r in recv_result.all()] + recv_rows = recv_result.all() + + # For Telegram targets, resolve locale from TelegramChat + chat_locale_map: dict[str, str] = {} + if target.type == "telegram": + bot_id = target.config.get("bot_id") + if bot_id: + chat_ids = [str(r.config.get("chat_id", "")) for r in recv_rows if r.config.get("chat_id")] + if chat_ids: + chat_result = await session.exec( + select(TelegramChat).where( + TelegramChat.bot_id == bot_id, + TelegramChat.chat_id.in_(chat_ids), + ) + ) + for chat in chat_result.all(): + resolved = ( + getattr(chat, 'language_override', '') or + getattr(chat, 'language_code', '') or '' + ) + if resolved: + chat_locale_map[chat.chat_id] = resolved[:2].lower() + + receivers: list[Receiver] = [] + for r in recv_rows: + explicit_locale = getattr(r, 'locale', '') or '' + locale = explicit_locale + if not locale and target.type == "telegram": + chat_id = str(r.config.get("chat_id", "")) + locale = chat_locale_map.get(chat_id, "") + receivers.append(build_receiver(target.type, dict(r.config), locale)) tracking_config = None if tt.tracking_config_id: diff --git a/packages/server/src/notify_bridge_server/services/telegram_poller.py b/packages/server/src/notify_bridge_server/services/telegram_poller.py index 2fbf1b2..5114308 100644 --- a/packages/server/src/notify_bridge_server/services/telegram_poller.py +++ b/packages/server/src/notify_bridge_server/services/telegram_poller.py @@ -207,8 +207,9 @@ async def _poll_bot(bot_id: int) -> None: )).first() if not chat_row or not chat_row.commands_enabled: continue + effective_lang = chat_row.language_override or msg_language message_id = message.get("message_id") - cmd_response = await handle_command(bot_obj, chat_id, text, language_code=msg_language) + cmd_response = await handle_command(bot_obj, chat_id, text, language_code=effective_lang) if cmd_response is not None: if isinstance(cmd_response, list): await send_media_group(bot_token, chat_id, cmd_response, reply_to_message_id=message_id)