feat(telegram): per-chat command localization + unified locale resolver
Two related Telegram changes:
1. Per-chat command localization. setMyCommands now accepts a scope
(BotCommandScopeChat) and deleteMyCommands clears scoped bindings.
Command registration runs three tiers: default → per-language
(Telegram client language) → per-chat (UI override). Saving a
chat's language_override or commands_enabled toggle pushes the
binding to Telegram inline rather than waiting on the 30s
debounced bot-wide sync.
2. Unified Telegram locale resolution. Three test paths (bot test_chat,
target receiver test, target-level fan-out) used to disagree on
locale priority — the target receiver test in particular only
consulted receiver.locale and ignored the chat's language_override.
Introduced pick_telegram_locale (pure) and
resolve_telegram_chat_locale (async DB lookup) in services/notifier
so all three paths share one priority order:
receiver.locale → chat.language_override → chat.language_code → fallback
Fan-out keeps batch-loading TelegramChat rows for efficiency, just
runs them through the same priority function now.
This commit is contained in:
@@ -829,12 +829,41 @@ class TelegramClient:
|
|||||||
|
|
||||||
async def set_my_commands(
|
async def set_my_commands(
|
||||||
self, commands: list[dict[str, str]], language_code: str | None = None,
|
self, commands: list[dict[str, str]], language_code: str | None = None,
|
||||||
|
scope: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Register bot commands with BotFather API."""
|
"""Register bot commands with BotFather API.
|
||||||
|
|
||||||
|
``scope`` is a Telegram BotCommandScope object (e.g.
|
||||||
|
``{"type": "chat", "chat_id": 123}``). When provided, the
|
||||||
|
registration applies only to that scope. ``language_code`` and
|
||||||
|
``scope`` may be combined to localize per-scope.
|
||||||
|
"""
|
||||||
url = f"{TELEGRAM_API_BASE_URL}{self._token}/setMyCommands"
|
url = f"{TELEGRAM_API_BASE_URL}{self._token}/setMyCommands"
|
||||||
payload: dict[str, Any] = {"commands": commands}
|
payload: dict[str, Any] = {"commands": commands}
|
||||||
if language_code:
|
if language_code:
|
||||||
payload["language_code"] = language_code
|
payload["language_code"] = language_code
|
||||||
|
if scope:
|
||||||
|
payload["scope"] = scope
|
||||||
|
try:
|
||||||
|
async with self._session.post(url, json=payload) as resp:
|
||||||
|
data = await resp.json()
|
||||||
|
if data.get("ok"):
|
||||||
|
return {"success": True}
|
||||||
|
return {"success": False, "error": data.get("description", "Unknown error")}
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
return {"success": False, "error": str(err)}
|
||||||
|
|
||||||
|
async def delete_my_commands(
|
||||||
|
self, language_code: str | None = None,
|
||||||
|
scope: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Clear bot commands for the given scope/language via BotFather API."""
|
||||||
|
url = f"{TELEGRAM_API_BASE_URL}{self._token}/deleteMyCommands"
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
if language_code:
|
||||||
|
payload["language_code"] = language_code
|
||||||
|
if scope:
|
||||||
|
payload["scope"] = scope
|
||||||
try:
|
try:
|
||||||
async with self._session.post(url, json=payload) as resp:
|
async with self._session.post(url, json=payload) as resp:
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||||||
from ..auth.dependencies import get_current_user
|
from ..auth.dependencies import get_current_user
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import NotificationTarget, TargetReceiver, User
|
from ..database.models import NotificationTarget, TargetReceiver, User
|
||||||
from ..services.notifier import send_to_receiver
|
from ..services.notifier import (
|
||||||
|
_get_test_message,
|
||||||
|
resolve_telegram_chat_locale,
|
||||||
|
send_to_receiver,
|
||||||
|
)
|
||||||
from .helpers import get_owned_entity
|
from .helpers import get_owned_entity
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -130,14 +134,28 @@ async def test_receiver(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Send a test notification to a single receiver."""
|
"""Send a test notification to a single receiver.
|
||||||
|
|
||||||
|
For Telegram targets, locale resolution goes through the shared
|
||||||
|
``resolve_telegram_chat_locale`` helper so the per-chat ``language_override``
|
||||||
|
set in the bot manager is respected here too — previously this endpoint
|
||||||
|
only consulted ``receiver.locale`` and ignored chat-side overrides.
|
||||||
|
"""
|
||||||
target = await _get_user_target(session, target_id, user.id)
|
target = await _get_user_target(session, target_id, user.id)
|
||||||
receiver = await session.get(TargetReceiver, receiver_id)
|
receiver = await session.get(TargetReceiver, receiver_id)
|
||||||
if not receiver or receiver.target_id != target_id:
|
if not receiver or receiver.target_id != target_id:
|
||||||
raise HTTPException(status_code=404, detail="Receiver not found")
|
raise HTTPException(status_code=404, detail="Receiver not found")
|
||||||
|
|
||||||
from ..services.notifier import _get_test_message
|
if target.type == "telegram":
|
||||||
effective_locale = getattr(receiver, 'locale', '') or locale
|
effective_locale = await resolve_telegram_chat_locale(
|
||||||
|
session,
|
||||||
|
bot_id=target.config.get("bot_id"),
|
||||||
|
chat_id=receiver.config.get("chat_id"),
|
||||||
|
receiver=receiver,
|
||||||
|
fallback=locale,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
effective_locale = (getattr(receiver, "locale", "") or locale)[:2].lower()
|
||||||
message = _get_test_message(effective_locale, target.type)
|
message = _get_test_message(effective_locale, target.type)
|
||||||
return await send_to_receiver(target, dict(receiver.config), message)
|
return await send_to_receiver(target, dict(receiver.config), message)
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||||||
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||||
|
|
||||||
from ..auth.dependencies import get_current_user
|
from ..auth.dependencies import get_current_user
|
||||||
from ..commands.handler import register_commands_with_telegram
|
from ..commands.handler import register_commands_with_telegram, sync_chat_command_binding
|
||||||
from ..commands.webhook import register_webhook, unregister_webhook
|
from ..commands.webhook import register_webhook, unregister_webhook
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import AppSetting, NotificationTarget, TargetReceiver, TelegramBot, TelegramChat, User
|
from ..database.models import AppSetting, NotificationTarget, TargetReceiver, TelegramBot, TelegramChat, User
|
||||||
from ..services.notifier import _get_test_message
|
from ..services.notifier import _get_test_message, resolve_telegram_chat_locale
|
||||||
from ..services.telegram_poller import schedule_bot_polling, unschedule_bot_polling
|
from ..services.telegram_poller import schedule_bot_polling, unschedule_bot_polling
|
||||||
from .app_settings import get_setting
|
from .app_settings import get_setting
|
||||||
from .helpers import get_owned_entity
|
from .helpers import get_owned_entity
|
||||||
@@ -300,26 +300,14 @@ async def test_chat(
|
|||||||
):
|
):
|
||||||
"""Send a test message to a chat via the bot.
|
"""Send a test message to a chat via the bot.
|
||||||
|
|
||||||
Locale resolution: prefer the chat row's ``language_override`` (explicit
|
Locale resolution is delegated to ``resolve_telegram_chat_locale`` so this
|
||||||
user choice in the UI), fall back to Telegram's ``language_code`` sent
|
endpoint, the per-receiver fan-out, and the target receiver test all
|
||||||
with the chat, and only use the ``?locale=`` query param if neither is
|
apply the same priority order (override → language_code → fallback).
|
||||||
set. Otherwise users who set RU on a chat would still see an EN test.
|
|
||||||
"""
|
"""
|
||||||
bot = await _get_user_bot(session, bot_id, user.id)
|
bot = await _get_user_bot(session, bot_id, user.id)
|
||||||
chat_row = (await session.exec(
|
effective_locale = await resolve_telegram_chat_locale(
|
||||||
select(TelegramChat).where(
|
session, bot_id=bot_id, chat_id=chat_id, fallback=locale,
|
||||||
TelegramChat.bot_id == bot_id,
|
)
|
||||||
TelegramChat.chat_id == chat_id,
|
|
||||||
)
|
|
||||||
)).first()
|
|
||||||
effective_locale = locale
|
|
||||||
if chat_row:
|
|
||||||
chat_locale = (
|
|
||||||
getattr(chat_row, 'language_override', '') or
|
|
||||||
getattr(chat_row, 'language_code', '') or ''
|
|
||||||
)
|
|
||||||
if chat_locale:
|
|
||||||
effective_locale = chat_locale[:2].lower()
|
|
||||||
from ..services.http_session import get_http_session
|
from ..services.http_session import get_http_session
|
||||||
message = _get_test_message(effective_locale, "telegram")
|
message = _get_test_message(effective_locale, "telegram")
|
||||||
http = await get_http_session()
|
http = await get_http_session()
|
||||||
@@ -347,11 +335,37 @@ async def update_chat(
|
|||||||
if not chat or chat.bot_id != bot_id:
|
if not chat or chat.bot_id != bot_id:
|
||||||
raise HTTPException(status_code=404, detail="Chat not found")
|
raise HTTPException(status_code=404, detail="Chat not found")
|
||||||
updates = body.model_dump(exclude_unset=True)
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
# Track whether anything changed that affects the chat-scoped command
|
||||||
|
# binding registered with Telegram (so the per-chat language_override
|
||||||
|
# actually takes effect on the bot's command list, not just the reply
|
||||||
|
# locale). We push it inline rather than via the debounced auto-sync
|
||||||
|
# so the user sees the change reflected on Telegram immediately —
|
||||||
|
# Telegram clients still cache the menu until the next "/" or chat
|
||||||
|
# re-open, but the source of truth is correct from the moment save
|
||||||
|
# returns.
|
||||||
|
sync_relevant_keys = {"language_override", "commands_enabled"}
|
||||||
|
needs_sync = any(
|
||||||
|
key in updates and getattr(chat, key) != value
|
||||||
|
for key, value in updates.items()
|
||||||
|
if key in sync_relevant_keys
|
||||||
|
)
|
||||||
for key, value in updates.items():
|
for key, value in updates.items():
|
||||||
setattr(chat, key, value)
|
setattr(chat, key, value)
|
||||||
session.add(chat)
|
session.add(chat)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(chat)
|
await session.refresh(chat)
|
||||||
|
if needs_sync:
|
||||||
|
bot = await session.get(TelegramBot, bot_id)
|
||||||
|
if bot is not None:
|
||||||
|
try:
|
||||||
|
await sync_chat_command_binding(bot, chat)
|
||||||
|
except Exception:
|
||||||
|
# Telegram-side failure shouldn't block the save — the
|
||||||
|
# debounced bot-wide sync will retry on the next change.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Inline command sync failed for bot=%d chat=%s",
|
||||||
|
bot_id, chat.chat_id, exc_info=True,
|
||||||
|
)
|
||||||
return _chat_response(chat)
|
return _chat_response(chat)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from ..database.models import (
|
|||||||
NotificationTracker,
|
NotificationTracker,
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
TelegramBot,
|
TelegramBot,
|
||||||
|
TelegramChat,
|
||||||
)
|
)
|
||||||
from .base import CommandResponse
|
from .base import CommandResponse
|
||||||
from .parser import parse_command
|
from .parser import parse_command
|
||||||
@@ -483,8 +484,87 @@ async def send_media_group(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_locale(raw: str | None) -> str:
|
||||||
|
"""Mirror the locale normalization used by the message handler."""
|
||||||
|
locale = (raw or "")[:2].lower()
|
||||||
|
if locale not in ("en", "ru"):
|
||||||
|
locale = "en"
|
||||||
|
return locale
|
||||||
|
|
||||||
|
|
||||||
|
def _build_command_list(
|
||||||
|
enabled: list[str], templates: dict[str, dict[str, str]], locale: str,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
commands: list[dict[str, str]] = []
|
||||||
|
for cmd in enabled:
|
||||||
|
desc = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
|
||||||
|
commands.append({"command": cmd, "description": desc})
|
||||||
|
return commands
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_chat_command_binding(bot: TelegramBot, chat: TelegramChat) -> bool:
|
||||||
|
"""Push Telegram's per-chat command binding for a single chat.
|
||||||
|
|
||||||
|
Used for immediate refresh when the user toggles a chat's
|
||||||
|
``language_override`` or ``commands_enabled`` flag — avoids the
|
||||||
|
30 s debounce of the bot-wide sync. Only touches the chat-scoped
|
||||||
|
binding (one Telegram API call); global per-language registrations
|
||||||
|
stay untouched. The bot-wide sync (``register_commands_with_telegram``)
|
||||||
|
remains the source of truth for everything else.
|
||||||
|
|
||||||
|
Returns ``True`` when Telegram acknowledged the change.
|
||||||
|
"""
|
||||||
|
from ..services.http_session import get_http_session
|
||||||
|
http = await get_http_session()
|
||||||
|
client = TelegramClient(http, bot.token)
|
||||||
|
|
||||||
|
scope = {"type": "chat", "chat_id": chat.chat_id}
|
||||||
|
|
||||||
|
# Chat is opted out of commands → ensure no chat-scoped override
|
||||||
|
# lingers. Telegram returns ok=true even if there was nothing to
|
||||||
|
# delete, so this is safe to call unconditionally.
|
||||||
|
if not chat.commands_enabled or not chat.language_override:
|
||||||
|
result = await client.delete_my_commands(scope=scope)
|
||||||
|
if not result.get("success"):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"delete_my_commands(immediate) failed bot=%d chat=%s: %s",
|
||||||
|
bot.id, chat.chat_id, result.get("error"),
|
||||||
|
)
|
||||||
|
return bool(result.get("success"))
|
||||||
|
|
||||||
|
# Override active → resolve the command list for this bot in the
|
||||||
|
# override locale and push it scoped to this chat.
|
||||||
|
ctx_tuples, templates_by_config_id = await _resolve_command_context(bot)
|
||||||
|
enabled, _ = _merge_enabled_commands(ctx_tuples)
|
||||||
|
templates = _merge_all_templates(templates_by_config_id)
|
||||||
|
override_locale = _normalize_locale(chat.language_override)
|
||||||
|
commands = _build_command_list(enabled, templates, override_locale)
|
||||||
|
|
||||||
|
result = await client.set_my_commands(commands, scope=scope)
|
||||||
|
if not result.get("success"):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"set_my_commands(immediate) failed bot=%d chat=%s locale=%s: %s",
|
||||||
|
bot.id, chat.chat_id, override_locale, result.get("error"),
|
||||||
|
)
|
||||||
|
return bool(result.get("success"))
|
||||||
|
|
||||||
|
|
||||||
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
|
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
|
||||||
"""Register enabled commands with Telegram BotFather API via TelegramClient."""
|
"""Register enabled commands with Telegram BotFather API via TelegramClient.
|
||||||
|
|
||||||
|
Registration happens at three levels:
|
||||||
|
|
||||||
|
1. Default (no scope, no language) — fallback for any user.
|
||||||
|
2. Per-language (no scope, ``language_code=en|ru``) — Telegram picks
|
||||||
|
based on the *user's* Telegram client language.
|
||||||
|
3. Per-chat (``scope=BotCommandScopeChat``) — when a chat has
|
||||||
|
``language_override`` set, register chat-scoped commands so the
|
||||||
|
override takes effect regardless of each user's Telegram client
|
||||||
|
language. This is the only level Telegram honors for "this chat
|
||||||
|
should use RU even though the user's Telegram is in EN" — the
|
||||||
|
per-language registration alone is keyed on the client locale,
|
||||||
|
not on any per-chat preference we store.
|
||||||
|
"""
|
||||||
ctx_tuples, templates_by_config_id = await _resolve_command_context(bot)
|
ctx_tuples, templates_by_config_id = await _resolve_command_context(bot)
|
||||||
enabled, _ = _merge_enabled_commands(ctx_tuples)
|
enabled, _ = _merge_enabled_commands(ctx_tuples)
|
||||||
templates = _merge_all_templates(templates_by_config_id)
|
templates = _merge_all_templates(templates_by_config_id)
|
||||||
@@ -494,12 +574,9 @@ async def register_commands_with_telegram(bot: TelegramBot) -> bool:
|
|||||||
client = TelegramClient(http, bot.token)
|
client = TelegramClient(http, bot.token)
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
# Register per-locale commands
|
# Register per-locale commands (keyed on user's Telegram client language)
|
||||||
for locale in ("en", "ru"):
|
for locale in ("en", "ru"):
|
||||||
commands = []
|
commands = _build_command_list(enabled, templates, locale)
|
||||||
for cmd in enabled:
|
|
||||||
desc = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
|
|
||||||
commands.append({"command": cmd, "description": desc})
|
|
||||||
result = await client.set_my_commands(commands, language_code=locale)
|
result = await client.set_my_commands(commands, language_code=locale)
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
success = True
|
success = True
|
||||||
@@ -507,13 +584,56 @@ async def register_commands_with_telegram(bot: TelegramBot) -> bool:
|
|||||||
_LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("error"))
|
_LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("error"))
|
||||||
|
|
||||||
# Register default (no language_code) with EN descriptions
|
# Register default (no language_code) with EN descriptions
|
||||||
en_commands = []
|
en_commands = _build_command_list(enabled, templates, "en")
|
||||||
for cmd in enabled:
|
|
||||||
desc = _resolve_template(templates, f"desc_{cmd}", "en") or cmd
|
|
||||||
en_commands.append({"command": cmd, "description": desc})
|
|
||||||
result = await client.set_my_commands(en_commands)
|
result = await client.set_my_commands(en_commands)
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
_LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username)
|
_LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username)
|
||||||
success = True
|
success = True
|
||||||
|
|
||||||
|
# Per-chat overrides: apply chat-scoped commands so language_override
|
||||||
|
# wins over the user's Telegram client language. For chats with
|
||||||
|
# commands enabled but no override, clear any prior chat-scoped
|
||||||
|
# binding so they fall back to the per-language registration above.
|
||||||
|
engine = get_engine()
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
chat_result = await session.exec(
|
||||||
|
select(TelegramChat).where(
|
||||||
|
TelegramChat.bot_id == bot.id,
|
||||||
|
TelegramChat.commands_enabled == True, # noqa: E712 — SQLModel needs == for column comparison
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chats = list(chat_result.all())
|
||||||
|
|
||||||
|
override_count = 0
|
||||||
|
for chat in chats:
|
||||||
|
scope = {"type": "chat", "chat_id": chat.chat_id}
|
||||||
|
if chat.language_override:
|
||||||
|
override_locale = _normalize_locale(chat.language_override)
|
||||||
|
commands = _build_command_list(enabled, templates, override_locale)
|
||||||
|
result = await client.set_my_commands(commands, scope=scope)
|
||||||
|
if result.get("success"):
|
||||||
|
override_count += 1
|
||||||
|
else:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Failed to register chat-scoped commands for bot=%d chat=%s locale=%s: %s",
|
||||||
|
bot.id, chat.chat_id, override_locale, result.get("error"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Clear any stale chat-scoped binding from a previous override
|
||||||
|
# so this chat falls back to the per-language registration.
|
||||||
|
# Telegram returns ok=true even when nothing was set; safe to
|
||||||
|
# call unconditionally.
|
||||||
|
result = await client.delete_my_commands(scope=scope)
|
||||||
|
if not result.get("success"):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"delete_my_commands for bot=%d chat=%s returned: %s",
|
||||||
|
bot.id, chat.chat_id, result.get("error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if override_count:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Applied %d per-chat command override(s) for bot @%s",
|
||||||
|
override_count, bot.bot_username,
|
||||||
|
)
|
||||||
|
|
||||||
return success
|
return success
|
||||||
|
|||||||
@@ -43,6 +43,62 @@ def _get_test_message(locale: str, target_type: str) -> str:
|
|||||||
return msgs.get(target_type, msgs.get("webhook", "Test"))
|
return msgs.get(target_type, msgs.get("webhook", "Test"))
|
||||||
|
|
||||||
|
|
||||||
|
def pick_telegram_locale(
|
||||||
|
*,
|
||||||
|
receiver_locale: str = "",
|
||||||
|
chat_override: str = "",
|
||||||
|
chat_language_code: str = "",
|
||||||
|
fallback: str = "en",
|
||||||
|
) -> str:
|
||||||
|
"""Pick the effective 2-letter locale for a Telegram chat.
|
||||||
|
|
||||||
|
Priority (highest first):
|
||||||
|
1. ``receiver_locale`` — explicit per-receiver override on a target.
|
||||||
|
2. ``chat_override`` — explicit ``TelegramChat.language_override``
|
||||||
|
set in the bot/chat manager UI.
|
||||||
|
3. ``chat_language_code`` — Telegram-provided ``language_code``.
|
||||||
|
4. ``fallback`` — caller-supplied default (e.g. query param).
|
||||||
|
|
||||||
|
All inputs are coerced to lowercase 2-letter codes.
|
||||||
|
"""
|
||||||
|
for candidate in (receiver_locale, chat_override, chat_language_code, fallback):
|
||||||
|
if candidate:
|
||||||
|
return candidate[:2].lower()
|
||||||
|
return "en"
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_telegram_chat_locale(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
bot_id: int | None,
|
||||||
|
chat_id: str | int | None,
|
||||||
|
receiver: TargetReceiver | None = None,
|
||||||
|
fallback: str = "en",
|
||||||
|
) -> str:
|
||||||
|
"""Look up a Telegram chat and resolve its effective locale.
|
||||||
|
|
||||||
|
Single source of truth for "what language should I send to this chat in?".
|
||||||
|
Used by every Telegram test/preview path (bot test_chat, target test
|
||||||
|
receiver, per-receiver fan-out) so they stay in lockstep.
|
||||||
|
"""
|
||||||
|
from ..database.models import TelegramChat
|
||||||
|
|
||||||
|
chat_row = None
|
||||||
|
if bot_id and chat_id:
|
||||||
|
chat_row = (await session.exec(
|
||||||
|
select(TelegramChat).where(
|
||||||
|
TelegramChat.bot_id == bot_id,
|
||||||
|
TelegramChat.chat_id == str(chat_id),
|
||||||
|
)
|
||||||
|
)).first()
|
||||||
|
return pick_telegram_locale(
|
||||||
|
receiver_locale=getattr(receiver, "locale", "") if receiver else "",
|
||||||
|
chat_override=getattr(chat_row, "language_override", "") if chat_row else "",
|
||||||
|
chat_language_code=getattr(chat_row, "language_code", "") if chat_row else "",
|
||||||
|
fallback=fallback,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _load_receivers(target_id: int) -> list[dict]:
|
async def _load_receivers(target_id: int) -> list[dict]:
|
||||||
"""Load enabled receivers for a target from DB."""
|
"""Load enabled receivers for a target from DB."""
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
@@ -343,9 +399,12 @@ async def _send_telegram_test_per_receiver(
|
|||||||
if not recv_rows:
|
if not recv_rows:
|
||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
|
|
||||||
# Resolve per-receiver locale
|
# Batch-load TelegramChat rows so per-receiver locale picks don't
|
||||||
|
# round-trip the DB N times. Priority resolution then runs through the
|
||||||
|
# shared pick_telegram_locale() helper so single-shot test endpoints
|
||||||
|
# and this fan-out agree on the same rules.
|
||||||
chat_ids = [str(r.config.get("chat_id", "")) for r in recv_rows if r.config.get("chat_id")]
|
chat_ids = [str(r.config.get("chat_id", "")) for r in recv_rows if r.config.get("chat_id")]
|
||||||
chat_locale_map: dict[str, str] = {}
|
chat_row_map: dict[str, TelegramChat] = {}
|
||||||
if bot_id and chat_ids:
|
if bot_id and chat_ids:
|
||||||
chat_rows = (await session.exec(
|
chat_rows = (await session.exec(
|
||||||
select(TelegramChat).where(
|
select(TelegramChat).where(
|
||||||
@@ -353,13 +412,7 @@ async def _send_telegram_test_per_receiver(
|
|||||||
TelegramChat.chat_id.in_(chat_ids),
|
TelegramChat.chat_id.in_(chat_ids),
|
||||||
)
|
)
|
||||||
)).all()
|
)).all()
|
||||||
for chat in chat_rows:
|
chat_row_map = {chat.chat_id: chat for chat in chat_rows}
|
||||||
override = (
|
|
||||||
getattr(chat, "language_override", "") or
|
|
||||||
getattr(chat, "language_code", "") or ""
|
|
||||||
)
|
|
||||||
if override:
|
|
||||||
chat_locale_map[chat.chat_id] = override[:2].lower()
|
|
||||||
|
|
||||||
http = await get_http_session()
|
http = await get_http_session()
|
||||||
client = TelegramClient(http, bot_token)
|
client = TelegramClient(http, bot_token)
|
||||||
@@ -374,9 +427,14 @@ async def _send_telegram_test_per_receiver(
|
|||||||
chat_id = str(r.config.get("chat_id", ""))
|
chat_id = str(r.config.get("chat_id", ""))
|
||||||
if not chat_id:
|
if not chat_id:
|
||||||
return None
|
return None
|
||||||
explicit = getattr(r, "locale", "") or ""
|
chat_row = chat_row_map.get(chat_id)
|
||||||
locale = explicit or chat_locale_map.get(chat_id) or default_locale
|
locale = pick_telegram_locale(
|
||||||
message = _get_test_message(locale[:2].lower(), "telegram")
|
receiver_locale=getattr(r, "locale", "") or "",
|
||||||
|
chat_override=getattr(chat_row, "language_override", "") if chat_row else "",
|
||||||
|
chat_language_code=getattr(chat_row, "language_code", "") if chat_row else "",
|
||||||
|
fallback=default_locale,
|
||||||
|
)
|
||||||
|
message = _get_test_message(locale, "telegram")
|
||||||
async with sem:
|
async with sem:
|
||||||
return await client.send_message(
|
return await client.send_message(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user