Files
app/backend/fluksio/api/routes/alerts.py
T
stroblme 5d0ea480bf Alerts: a Telegram channel, one chat or group each, connected by a one-time link
The channel sends through the Bot API with httpx only: HTML with escaped text,
cut to fit a message, silent for a warning, a short 429 waited out once, and
errors that say what to do about a blocked bot, a lost chat or a bad token and
never quote the request URL. The bot token is a stored secret: saving a
channel refuses a literal one, the card's "Store token" writes the secret and
a reference, and no endpoint returns it.

A chat id is never typed. Connect starts a ten minute code, and the chat that
sends `/start <code>` back through the bot is the one connected, so it is a
chat somebody opened a link for. The engine fetches updates only while a code
is waiting (no standing poll, no webhook, no inbound address) and ignores
everything else without a reply. Connect needs a saved form, and the form
takes the connected chat in without touching what was typed since.

The token is part of every Bot API URL, so the httpx logger gets the same
filter the request lines have, which now also masks an httpx.URL argument.
2026-10-28 13:06:58 +01:00

250 lines
9.0 KiB
Python

"""Alerting configuration: which failures go where."""
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from fluksio.api.deps import (
FlowControllerDep,
get_current_active_superuser,
get_current_user,
)
from fluksio.core.config import settings
from fluksio.flow import telegram, webpush
from fluksio.flow.alerts import Alert, AlertsConfig
from fluksio.flow.secrets import SECRET_REF, SecretNotFound, resolve_params
from fluksio.models import Message
# Reading or changing where alerts go is a superuser's: a channel setting may
# hold a `{"$secret": ...}` reference that `send` resolves, and the url or
# server beside it is the sender's to choose, so an ordinary account could
# point a stored credential at a host of its own and press Test. The topic of
# an ntfy channel and a webhook's url are credentials too, so reading is gated
# as well. Subscribing a browser stays open to every account: it only says
# where *this* device can be reached.
router = APIRouter(
prefix="/alerts", tags=["alerts"], dependencies=[Depends(get_current_user)]
)
SUPERUSER = [Depends(get_current_active_superuser)]
def _path() -> Path:
return settings.ALERTS_FILE
def read_config() -> AlertsConfig:
"""The stored configuration, or an empty one. Blocking."""
path = _path()
if not path.exists():
return AlertsConfig()
try:
return AlertsConfig.model_validate_json(path.read_text())
except Exception:
# A hand-edited file that no longer parses must not stop the engine.
return AlertsConfig()
def write_config(config: AlertsConfig) -> None:
"""Blocking."""
path = _path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(config.model_dump_json(indent=2))
@router.get("/config", response_model=AlertsConfig, dependencies=SUPERUSER)
async def read_alerts_config() -> Any:
"""What the engine alerts on, and where it sends it."""
return await run_in_threadpool(read_config)
@router.put("/config", response_model=AlertsConfig, dependencies=SUPERUSER)
async def save_alerts_config(body: AlertsConfig, controller: FlowControllerDep) -> Any:
"""Replace the configuration. Takes effect immediately."""
known = {channel.name for channel in body.channels}
for rule in body.rules:
missing = [name for name in rule.channels if name not in known]
if missing:
raise HTTPException(
status_code=422,
detail=f"No channel named {', '.join(repr(m) for m in missing)}",
)
for channel in body.channels:
token = channel.config.get("token")
# A bot token is the bot: it belongs in the secrets store, and this
# file is read by anyone who can read the data directory.
if (
channel.kind == "telegram"
and token
and not (isinstance(token, dict) and set(token) == {SECRET_REF})
):
raise HTTPException(
status_code=422,
detail=f"The bot token of '{channel.name}' has to be a stored secret",
)
await run_in_threadpool(write_config, body)
if controller.alerts is not None:
controller.alerts.config = body
return body
class WebPushKey(BaseModel):
key: str
class Unsubscribe(BaseModel):
endpoint: str
@router.get("/webpush/key", response_model=WebPushKey)
async def read_webpush_key() -> Any:
"""The key a browser subscribes against. Made on the first ask."""
return WebPushKey(key=await run_in_threadpool(webpush.public_key))
@router.post("/webpush/subscriptions", response_model=Message)
async def add_webpush_subscription(body: webpush.Subscription) -> Any:
"""Remember this browser, so a `webpush` channel can reach it."""
if not body.keys.get("p256dh") or not body.keys.get("auth"):
raise HTTPException(status_code=422, detail="The subscription has no keys")
await run_in_threadpool(webpush.add_subscription, body)
return Message(message="This browser will be notified")
@router.post("/webpush/unsubscribe", response_model=Message)
async def remove_webpush_subscription(body: Unsubscribe) -> Any:
"""Forget this browser."""
await run_in_threadpool(webpush.remove_subscription, body.endpoint)
return Message(message="This browser will no longer be notified")
class TelegramLink(BaseModel):
#: Opens the bot in a private chat; pressing Start connects that chat.
url: str
#: Asks which group to add the bot to; the group is connected instead.
group_url: str
expires_in: int
class TelegramLinkStatus(BaseModel):
#: A code is out and the engine is listening for it.
waiting: bool
expires_in: int = 0
#: Why it stopped listening, when it did not run out.
error: str = ""
def _store_chat(channel_name: str, chat_id: str, label: str) -> AlertsConfig | None:
"""Put a chat that proved itself into the stored channel. Blocking."""
config = read_config()
channel = next((c for c in config.channels if c.name == channel_name), None)
if channel is None:
# Renamed or removed while the code was out.
return None
channel.config = {**channel.config, "chat_id": chat_id, "label": label}
write_config(config)
return config
@router.post(
"/telegram/{channel_name}/link",
response_model=TelegramLink,
dependencies=SUPERUSER,
)
async def start_telegram_link(channel_name: str, controller: FlowControllerDep) -> Any:
"""A one-time link that connects whichever chat opens it first.
Typing a chat id would prove nothing about whose it is. Here the chat
sends the code back through the bot, so it is one somebody opened with a
link the operator handed out. Good for ten minutes, and for one chat.
"""
config = await run_in_threadpool(read_config)
channel = next(
(c for c in config.channels if c.name == channel_name and c.kind == "telegram"),
None,
)
if channel is None:
raise HTTPException(
status_code=404, detail=f"No telegram channel '{channel_name}'"
)
try:
token = resolve_params(channel.config.get("token"))
except (SecretNotFound, RuntimeError) as exc:
raise HTTPException(status_code=422, detail=str(exc))
if not token:
raise HTTPException(
status_code=422,
detail="Store the bot token and save the channel first",
)
try:
bot = await telegram.call(str(token), "getMe")
except telegram.TelegramError as exc:
raise HTTPException(status_code=502, detail=str(exc))
async def linked(name: str, chat_id: str, label: str) -> None:
stored = await run_in_threadpool(_store_chat, name, chat_id, label)
if stored is not None and controller.alerts is not None:
controller.alerts.config = stored
code = telegram.linker.start(channel_name, str(token), linked)
base = f"https://t.me/{bot['username']}"
return TelegramLink(
url=f"{base}?start={code}",
group_url=f"{base}?startgroup={code}",
expires_in=int(telegram.LINK_TTL_S),
)
@router.get(
"/telegram/{channel_name}/link",
response_model=TelegramLinkStatus,
dependencies=SUPERUSER,
)
async def read_telegram_link(channel_name: str) -> Any:
"""Whether a code for this channel is still waiting to be used."""
waiting, left, error = telegram.linker.status(channel_name)
return TelegramLinkStatus(waiting=waiting, expires_in=left, error=error)
@router.delete(
"/telegram/{channel_name}/link",
response_model=Message,
dependencies=SUPERUSER,
)
async def cancel_telegram_link(channel_name: str) -> Any:
"""Stop waiting for a chat to open the link."""
telegram.linker.cancel(channel_name)
return Message(message="Stopped waiting for a chat")
@router.post("/test/{channel_name}", response_model=Message, dependencies=SUPERUSER)
async def test_channel(channel_name: str, controller: FlowControllerDep) -> Any:
"""Send one alert, to prove the channel works before relying on it."""
if controller.alerts is None:
raise HTTPException(status_code=503, detail="Alerting is not running")
config = controller.alerts.config
channel = next((c for c in config.channels if c.name == channel_name), None)
if channel is None:
raise HTTPException(status_code=404, detail=f"No channel '{channel_name}'")
try:
await controller.alerts.send(
channel,
Alert(
title="Fluksio test alert",
body="If you are reading this, the channel works.",
severity="warning",
),
raise_on_error=True,
)
except Exception as exc:
# Whatever the sender said, verbatim: it is the only clue the operator
# has about why the channel does not work.
raise HTTPException(status_code=502, detail=str(exc) or type(exc).__name__)
return Message(message=f"Sent a test alert through '{channel_name}'")