Files
notify-bridge/packages/server/src/notify_bridge_server/api/telegram_bots.py
T
alexei.dolgolyov ef942b77cc 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.
2026-04-25 14:41:28 +03:00

469 lines
17 KiB
Python

"""Telegram bot management API routes."""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.notifications.telegram.client import TelegramClient
from ..auth.dependencies import get_current_user
from ..commands.handler import register_commands_with_telegram, sync_chat_command_binding
from ..commands.webhook import register_webhook, unregister_webhook
from ..database.engine import get_session
from ..database.models import AppSetting, NotificationTarget, TargetReceiver, TelegramBot, TelegramChat, User
from ..services.notifier import _get_test_message, resolve_telegram_chat_locale
from ..services.telegram_poller import schedule_bot_polling, unschedule_bot_polling
from .app_settings import get_setting
from .helpers import get_owned_entity
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/telegram-bots", tags=["telegram-bots"])
class BotCreate(BaseModel):
name: str
token: str
class BotUpdate(BaseModel):
name: str | None = None
icon: str | None = None
update_mode: str | None = None
@router.get("")
async def list_bots(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all registered Telegram bots."""
result = await session.exec(
select(TelegramBot).where(TelegramBot.user_id == user.id)
)
return [_bot_response(b) for b in result.all()]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_bot(
body: BotCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Register a new Telegram bot (validates token via getMe)."""
bot_info = await _get_me(body.token)
if not bot_info:
raise HTTPException(status_code=400, detail="Invalid bot token")
bot = TelegramBot(
user_id=user.id,
name=body.name,
token=body.token,
bot_username=bot_info.get("username", ""),
bot_id=bot_info.get("id", 0),
)
session.add(bot)
await session.commit()
await session.refresh(bot)
return _bot_response(bot)
@router.put("/{bot_id}")
async def update_bot(
bot_id: int,
body: BotUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a bot's display name, icon, commands config, and update mode."""
bot = await _get_user_bot(session, bot_id, user.id)
if body.name is not None:
bot.name = body.name
if body.icon is not None:
bot.icon = body.icon
# Handle mode switching
if body.update_mode is not None and body.update_mode != bot.update_mode:
if body.update_mode not in ("none", "polling", "webhook"):
raise HTTPException(
status_code=400,
detail=f"Invalid update_mode: {body.update_mode!r}. Must be 'none', 'polling', or 'webhook'.",
)
if body.update_mode == "webhook":
# Validate and register webhook BEFORE stopping polling
base_url = await get_setting(session, "external_url")
if not base_url:
raise HTTPException(
status_code=400,
detail="Cannot switch to webhook: external domain URL not configured. Set it in Settings.",
)
webhook_url = f"{base_url.rstrip('/')}/api/telegram/webhook/{bot.webhook_path_id}"
secret = await get_setting(session, "telegram_webhook_secret")
result = await register_webhook(bot.token, webhook_url, secret or None)
if not result.get("success"):
raise HTTPException(
status_code=400,
detail=f"Webhook registration failed: {result.get('error', 'unknown')}",
)
# Webhook registered successfully — now stop polling
unschedule_bot_polling(bot.id)
elif body.update_mode == "polling":
# Switching to polling: unregister webhook, start polling
await unregister_webhook(bot.token)
schedule_bot_polling(bot.id)
elif body.update_mode == "none":
# Disable listener: stop polling and clear any webhook so Telegram
# stops delivering updates. This makes the bot send-only, which is
# safe when another instance owns the listener.
unschedule_bot_polling(bot.id)
await unregister_webhook(bot.token)
bot.update_mode = body.update_mode
session.add(bot)
await session.commit()
await session.refresh(bot)
return _bot_response(bot)
@router.delete("/{bot_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_bot(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a registered bot and its chats."""
from .delete_protection import check_telegram_bot, raise_if_used
bot = await _get_user_bot(session, bot_id, user.id)
raise_if_used(await check_telegram_bot(session, bot.id), bot.name)
# Delete associated chats
result = await session.exec(select(TelegramChat).where(TelegramChat.bot_id == bot_id))
for chat in result.all():
await session.delete(chat)
await session.delete(bot)
await session.commit()
@router.get("/{bot_id}/token")
async def get_bot_token(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get the full bot token (used by frontend to construct target config)."""
bot = await _get_user_bot(session, bot_id, user.id)
return {"token": bot.token}
# --- Chat management ---
@router.get("/{bot_id}/chats")
async def list_bot_chats(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List persisted chats for a bot."""
await _get_user_bot(session, bot_id, user.id) # Auth check
result = await session.exec(
select(TelegramChat).where(TelegramChat.bot_id == bot_id)
)
return [_chat_response(c) for c in result.all()]
@router.post("/{bot_id}/chats/discover")
async def discover_chats(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Discover new chats via Telegram getUpdates and persist them.
Merges newly discovered chats with existing ones (no duplicates).
Returns the full updated chat list.
"""
bot = await _get_user_bot(session, bot_id, user.id)
discovered = await _fetch_chats_from_telegram(bot.token)
# Load existing chats to avoid duplicates
result = await session.exec(
select(TelegramChat).where(TelegramChat.bot_id == bot_id)
)
existing = {c.chat_id: c for c in result.all()}
new_count = 0
for chat_data in discovered:
cid = str(chat_data["id"])
if cid in existing:
# Update title/username if changed
existing_chat = existing[cid]
existing_chat.title = chat_data.get("title", existing_chat.title)
existing_chat.username = chat_data.get("username", existing_chat.username)
session.add(existing_chat)
else:
new_chat = TelegramChat(
bot_id=bot_id,
chat_id=cid,
title=chat_data.get("title", ""),
chat_type=chat_data.get("type", "private"),
username=chat_data.get("username", ""),
)
session.add(new_chat)
new_count += 1
await session.commit()
# Return full list
result = await session.exec(
select(TelegramChat).where(TelegramChat.bot_id == bot_id)
)
return [_chat_response(c) for c in result.all()]
@router.post("/{bot_id}/sync-commands")
async def sync_commands(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Register enabled commands with Telegram BotFather API."""
bot = await _get_user_bot(session, bot_id, user.id)
success = await register_commands_with_telegram(bot)
return {"success": success}
@router.post("/{bot_id}/webhook/register")
async def register_bot_webhook(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Register Telegram webhook for this bot."""
bot = await _get_user_bot(session, bot_id, user.id)
base_url = await get_setting(session, "external_url")
if not base_url:
return {"success": False, "error": "External domain URL not configured. Set it in Telegram Settings."}
webhook_url = f"{base_url.rstrip('/')}/api/telegram/webhook/{bot.webhook_path_id}"
secret = await get_setting(session, "telegram_webhook_secret")
result = await register_webhook(bot.token, webhook_url, secret or None)
if not result.get("success"):
return result
# Verify with getWebhookInfo
info = await _get_webhook_info(bot.token)
if info and info.get("url") == webhook_url:
result["verified"] = True
result["webhook_url"] = webhook_url
else:
result["verified"] = False
result["warning"] = "Webhook set but verification failed"
return result
@router.post("/{bot_id}/webhook/unregister")
async def unregister_bot_webhook(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Unregister Telegram webhook for this bot."""
bot = await _get_user_bot(session, bot_id, user.id)
result = await unregister_webhook(bot.token)
return result
@router.get("/{bot_id}/webhook/status")
async def get_webhook_status(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get current webhook status from Telegram."""
bot = await _get_user_bot(session, bot_id, user.id)
info = await _get_webhook_info(bot.token)
return {
"url": info.get("url", "") if info else "",
"has_custom_certificate": info.get("has_custom_certificate", False) if info else False,
"pending_update_count": info.get("pending_update_count", 0) if info else 0,
"last_error_message": info.get("last_error_message", "") if info else "",
"last_error_date": info.get("last_error_date", 0) if info else 0,
}
@router.post("/{bot_id}/chats/{chat_id}/test")
async def test_chat(
bot_id: int,
chat_id: str,
locale: str = Query("en"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test message to a chat via the bot.
Locale resolution is delegated to ``resolve_telegram_chat_locale`` so this
endpoint, the per-receiver fan-out, and the target receiver test all
apply the same priority order (override → language_code → fallback).
"""
bot = await _get_user_bot(session, bot_id, user.id)
effective_locale = await resolve_telegram_chat_locale(
session, bot_id=bot_id, chat_id=chat_id, fallback=locale,
)
from ..services.http_session import get_http_session
message = _get_test_message(effective_locale, "telegram")
http = await get_http_session()
client = TelegramClient(http, bot.token)
return await client.send_message(chat_id, message)
class ChatUpdate(BaseModel):
language_override: str | None = None
title: str | None = None
commands_enabled: bool | None = None
@router.put("/{bot_id}/chats/{chat_db_id}")
async def update_chat(
bot_id: int,
chat_db_id: int,
body: ChatUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a chat's language_code or title."""
await _get_user_bot(session, bot_id, user.id)
chat = await session.get(TelegramChat, chat_db_id)
if not chat or chat.bot_id != bot_id:
raise HTTPException(status_code=404, detail="Chat not found")
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():
setattr(chat, key, value)
session.add(chat)
await session.commit()
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)
@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_chat(
bot_id: int,
chat_db_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a persisted chat entry."""
await _get_user_bot(session, bot_id, user.id) # Auth check
chat = await session.get(TelegramChat, chat_db_id)
if not chat or chat.bot_id != bot_id:
raise HTTPException(status_code=404, detail="Chat not found")
await session.delete(chat)
await session.commit()
# --- Helpers ---
async def _get_webhook_info(token: str) -> dict | None:
"""Call Telegram getWebhookInfo via TelegramClient."""
from ..services.http_session import get_http_session
http = await get_http_session()
client = TelegramClient(http, token)
result = await client.get_webhook_info()
return result.get("result") if result.get("success") else None
async def _get_me(token: str) -> dict | None:
"""Call Telegram getMe via TelegramClient."""
from ..services.http_session import get_http_session
http = await get_http_session()
client = TelegramClient(http, token)
result = await client.get_me()
return result.get("result") if result.get("success") else None
async def _fetch_chats_from_telegram(token: str) -> list[dict]:
"""Fetch chats from Telegram getUpdates via TelegramClient."""
from ..services.http_session import get_http_session
http = await get_http_session()
client = TelegramClient(http, token)
result = await client.get_updates(limit=100)
if not result.get("success"):
return []
seen: dict[int, dict] = {}
for update in result.get("result", []):
msg = update.get("message", {})
chat = msg.get("chat", {})
chat_id = chat.get("id")
if chat_id and chat_id not in seen:
seen[chat_id] = {
"id": chat_id,
"title": chat.get("title") or (chat.get("first_name", "") + (" " + chat.get("last_name", "")).strip()),
"type": chat.get("type", "private"),
"username": chat.get("username", ""),
}
return list(seen.values())
def _chat_response(c: TelegramChat) -> dict:
return {
"id": c.id,
"chat_id": c.chat_id,
"title": c.title,
"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(),
}
def _bot_response(b: TelegramBot) -> dict:
return {
"id": b.id,
"name": b.name,
"icon": b.icon,
"bot_username": b.bot_username,
"bot_id": b.bot_id,
"webhook_path_id": b.webhook_path_id,
"update_mode": b.update_mode or "none",
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
"created_at": b.created_at.isoformat(),
}
async def _get_user_bot(session: AsyncSession, bot_id: int, user_id: int) -> TelegramBot:
return await get_owned_entity(
session, TelegramBot, bot_id, user_id, not_found_msg="Bot not found",
)
# Re-export for backward compatibility
from ..services.telegram import save_chat_from_webhook # noqa: F401