mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
Bot.listen awaited every frame to completion, so one Commons fetch or one attachment upload held up every text reply behind it (DOCS-latency.md). Each frame now runs in its own task, never more than LIMIT at once; the reader waits for a slot before spawning, so a burst applies back-pressure at the websocket instead of fanning out upstream. One person's commands stay serialised (user_lock.py): two quick !kot would otherwise both pass the cooldown gate before either recorded a use. Every served command reports its timings split into the bot's own time and everybody else's (served.py); /_selftest carries that block and tool/latency.py holds own_ms to 100 ms at p95, with the command's cooldown switched off for the run and put back afterwards. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
178 lines
7.0 KiB
Python
178 lines
7.0 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""The ASGI entrypoint, and the one endpoint that says whether the bot works.
|
|
|
|
Wiring lives in bot.py and the startup sequence in startup.py. What is left
|
|
here is the part uvicorn imports: an app that begins serving immediately, so
|
|
that "the bot is still connecting" is a state you can ask about rather than a
|
|
process that is not there.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, Response, status
|
|
from pydantic import BaseModel
|
|
|
|
from signal_bot.app_commands import router as commands_router
|
|
from signal_bot.config_env import load_config
|
|
from signal_bot.selftest import SelfTestError
|
|
from signal_bot.selftest import run as run_selftest
|
|
from signal_bot.startup_retry import Starter
|
|
from signal_bot.store import Store
|
|
from signal_bot.versioning import POPE, __version__, latin_name, pope_line
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncIterator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
HTTP_TIMEOUT_SECONDS = 30.0
|
|
# iNaturalist asks automated clients to identify themselves, and Wikimedia's
|
|
# policy outright refuses the default "python-httpx/x.y" this used to send.
|
|
USER_AGENT = f"signal-bot/{__version__} (+https://github.com/kuhyx/signal-bot)"
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
"""Serve first, connect to Signal afterwards.
|
|
|
|
Two earlier versions of this got it wrong in opposite directions. The
|
|
first awaited asyncio.gather() on two infinite tasks, so uvicorn never
|
|
finished starting and never served at all. The second connected to
|
|
signal-cli inline, so a container that was not ready yet failed startup
|
|
outright and burned the unit's restart limit in 25 seconds.
|
|
|
|
Config errors still fail here and exit non-zero: load_config() raising is
|
|
an answer, not an outage, and no amount of waiting fixes a missing
|
|
PHONE_NUMBER. Everything that needs the network is handed to Starter.
|
|
"""
|
|
config = load_config()
|
|
client = httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS, headers={"User-Agent": USER_AGENT})
|
|
# Opened once, here, rather than inside the retried connection attempt:
|
|
# a Store per attempt would leak one SQLite handle per failed try, for
|
|
# as long as the container stayed down.
|
|
store = Store(config.database_path)
|
|
starter = Starter(config, client, store)
|
|
app.state.starter = starter
|
|
app.state.client = client
|
|
connecting = asyncio.create_task(starter.run())
|
|
logger.info("serving on %s:%d while connecting to Signal", config.bind_host, config.bind_port)
|
|
try:
|
|
yield
|
|
finally:
|
|
connecting.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await connecting
|
|
if starter.runtime is not None:
|
|
await starter.runtime.stop()
|
|
await client.aclose()
|
|
store.close()
|
|
|
|
|
|
class SelfTestRequest(BaseModel):
|
|
"""One command to type at the bot, where, and what it carries.
|
|
|
|
``attachments`` are ids the container's own attachment store already
|
|
holds. That is how !add_photo gets verified without a second human: the
|
|
handler fetches them back over the real API, so the ingest path exercised
|
|
is the live one rather than a hand-fed buffer.
|
|
"""
|
|
|
|
group: str
|
|
text: str
|
|
attachments: list[str] = []
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
|
# The per-command switchboard, in its own module so this one stays about
|
|
# serving and connecting.
|
|
app.include_router(commands_router)
|
|
|
|
|
|
@app.get("/healthz")
|
|
async def healthz(response: Response) -> dict[str, str]:
|
|
"""Liveness probe, and the only place the bot admits it is not working.
|
|
|
|
Answering 200 while nothing can reach Signal is what let a three-hour
|
|
outage look like a quiet evening, so this must go red before anybody
|
|
thinks to look at the logs. It has three answers, not two: a bot that has
|
|
not connected yet is "connecting", which is what a booting machine looks
|
|
like, and is not the same thing as one that has gone deaf.
|
|
"""
|
|
starter: Starter = app.state.starter
|
|
build = {
|
|
"version": __version__,
|
|
"pope": latin_name(POPE),
|
|
# The same sentence the group is shown, so "which release is this?"
|
|
# has one answer whether it is asked here or in Signal.
|
|
"papacy": pope_line(POPE),
|
|
}
|
|
runtime = starter.runtime
|
|
if runtime is None:
|
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
|
return {"status": "connecting", "reason": starter.reason, **build}
|
|
liveness = runtime.bot.liveness
|
|
if liveness.healthy:
|
|
return {"status": "ok", **build}
|
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
|
return {"status": "deaf", "reason": liveness.reason, **build}
|
|
|
|
|
|
@app.post("/_selftest")
|
|
async def selftest(request: SelfTestRequest, response: Response) -> dict[str, Any]:
|
|
"""Type a command at the bot on its own behalf, for verification.
|
|
|
|
Bound to loopback with the rest of the app, and refuses any group not
|
|
named in SELFTEST_GROUPS. See signal_bot/selftest.py for why this exists.
|
|
When the text was a command, ``served`` carries what it cost, split into
|
|
the bot's own time and the time spent waiting on Commons and signal-cli
|
|
(``served.py``); ``tool/latency.py`` reads it to hold the bot to a budget.
|
|
"""
|
|
starter: Starter = app.state.starter
|
|
runtime = starter.runtime
|
|
if runtime is None:
|
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
|
return {"status": "connecting", "reason": starter.reason}
|
|
try:
|
|
group_id, served = await run_selftest(
|
|
runtime.bot,
|
|
app.state.client,
|
|
request.group,
|
|
request.text,
|
|
attachments=request.attachments,
|
|
)
|
|
except SelfTestError as refusal:
|
|
response.status_code = status.HTTP_403_FORBIDDEN
|
|
return {"status": "refused", "reason": str(refusal)}
|
|
answer: dict[str, Any] = {"status": "delivered", "group": group_id, "text": request.text}
|
|
if served is not None:
|
|
answer["served"] = served.as_dict()
|
|
return answer
|
|
|
|
|
|
@app.post("/_reload")
|
|
async def reload_commands(response: Response) -> dict[str, str | int]:
|
|
"""Rebuild the command table from the data on disk, without a restart.
|
|
|
|
The answer is what the bot is serving *now*: after a rejected reload
|
|
that is still the previous table, and ``reason`` says what was wrong
|
|
with the new one. 409 rather than 500, because the data was bad and the
|
|
bot is fine. Loopback only, like everything else on this port.
|
|
"""
|
|
starter: Starter = app.state.starter
|
|
runtime = starter.runtime
|
|
if runtime is None:
|
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
|
return {"status": "connecting", "reason": starter.reason}
|
|
bot = runtime.bot
|
|
if bot.reload_commands():
|
|
return {"status": "reloaded", "commands": bot.command_count}
|
|
response.status_code = status.HTTP_409_CONFLICT
|
|
holder = bot.dispatcher.holder
|
|
return {"status": "kept", "commands": bot.command_count, "reason": holder.last_error}
|