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.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)