feat: Receiver OOP hierarchy with per-receiver locale resolution

- Introduce Receiver base class + typed subclasses (TelegramReceiver,
  WebhookReceiver, EmailReceiver, etc.) in core/notifications/receiver.py
- Dispatcher uses typed Receiver objects instead of raw dicts, with
  per-receiver locale-aware template rendering
- load_link_data resolves locale from TelegramChat.language_override at
  load time: TargetReceiver.locale || chat.language_override || chat.language_code
- Add language_override field to TelegramChat (separate from auto-detected
  language_code), with per-chat commands toggle and command dispatch using
  override language
- Add locale field to TargetReceiver for explicit per-receiver overrides
This commit is contained in:
2026-03-23 21:20:31 +03:00
parent b3b6c31c4d
commit 1cfa72888c
16 changed files with 334 additions and 94 deletions
@@ -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()
@@ -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(),
}
@@ -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
@@ -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(),
}
@@ -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)
@@ -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(
@@ -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)
@@ -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:
@@ -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)