ef942b77cc
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.
469 lines
17 KiB
Python
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
|