Files
notify-bridge/packages/server/src/notify_bridge_server/api/target_receivers.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

196 lines
6.3 KiB
Python

"""Target receiver management API routes (nested under targets)."""
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, TargetReceiver, User
from ..services.notifier import (
_get_test_message,
resolve_telegram_chat_locale,
send_to_receiver,
)
from .helpers import get_owned_entity
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/targets/{target_id}/receivers", tags=["target-receivers"])
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
def _receiver_key(target_type: str, config: dict[str, Any]) -> str:
"""Derive a unique key for deduplication from receiver config."""
key_fields = {
"telegram": "chat_id",
"webhook": "url",
"email": "email",
"discord": "webhook_url",
"slack": "webhook_url",
"ntfy": "topic",
"matrix": "room_id",
}
field = key_fields.get(target_type, "")
return str(config.get(field, "")) if field else ""
@router.get("")
async def list_receivers(
target_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
target = await _get_user_target(session, target_id, user.id)
result = await session.exec(
select(TargetReceiver).where(TargetReceiver.target_id == target.id)
)
return [_response(r) for r in result.all()]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_receiver(
target_id: int,
body: ReceiverCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
target = await _get_user_target(session, target_id, user.id)
key = _receiver_key(target.type, body.config)
if not key:
raise HTTPException(status_code=400, detail="Receiver config must include a delivery endpoint (chat_id, url, or email)")
# Check for duplicate
existing = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.receiver_key == key,
)
)
if existing.first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Receiver already exists for this target")
receiver = TargetReceiver(
target_id=target.id,
name=body.name,
config=body.config,
receiver_key=key,
locale=body.locale,
enabled=body.enabled,
)
session.add(receiver)
await session.commit()
await session.refresh(receiver)
return _response(receiver)
@router.put("/{receiver_id}")
async def update_receiver(
target_id: int,
receiver_id: int,
body: ReceiverUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
await _get_user_target(session, target_id, user.id)
receiver = await session.get(TargetReceiver, receiver_id)
if not receiver or receiver.target_id != target_id:
raise HTTPException(status_code=404, detail="Receiver not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(receiver, field, value)
# Update receiver_key if config changed
if body.config is not None:
target = await session.get(NotificationTarget, target_id)
receiver.receiver_key = _receiver_key(target.type, receiver.config)
session.add(receiver)
await session.commit()
await session.refresh(receiver)
return _response(receiver)
@router.post("/{receiver_id}/test")
async def test_receiver(
target_id: int,
receiver_id: int,
locale: str = Query("en"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""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)
receiver = await session.get(TargetReceiver, receiver_id)
if not receiver or receiver.target_id != target_id:
raise HTTPException(status_code=404, detail="Receiver not found")
if target.type == "telegram":
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)
return await send_to_receiver(target, dict(receiver.config), message)
@router.delete("/{receiver_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_receiver(
target_id: int,
receiver_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
await _get_user_target(session, target_id, user.id)
receiver = await session.get(TargetReceiver, receiver_id)
if not receiver or receiver.target_id != target_id:
raise HTTPException(status_code=404, detail="Receiver not found")
await session.delete(receiver)
await session.commit()
def _response(r: TargetReceiver) -> dict:
return {
"id": r.id,
"target_id": r.target_id,
"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(),
}
async def _get_user_target(session: AsyncSession, target_id: int, user_id: int) -> NotificationTarget:
return await get_owned_entity(
session, NotificationTarget, target_id, user_id,
not_found_msg="Target not found",
)