mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
One row per live command on a new Commands tab of the management page,
each with an on/off switch, an optional "off until" moment and a cooldown
that overrides the bot's default for that command alone. Every switch is
live: the bot keeps them in its own SQLite (command_settings, a permanent
table) and reads the row on every attempt, so nothing goes through
/_reload and nothing restarts.
A command that is off answers "BEEP BOOP !kot WYŁĄCZONE." (with "DO HH:MM"
for a timed switch) rather than going quiet, so nobody reports it broken;
the refusal is filed as refused_off, which the analytics count with the
other refusals, and it neither starts the cooldown nor counts as a use.
The bot grows GET /_commands and PUT /_commands/{name}; the management app
proxies them under /api/commands and passes the bot's 404/503 through
unwrapped. !cisza and !odcisz carry no cooldown and the page says so
instead of accepting a number the bot would ignore (test_wiring_ungated
keeps that set honest against the real table).
Proven end to end: scripts/e2e.sh now brackets a PUT with switch_off.yaml
and switch_on.yaml, the same actor refused and then served, with no reload
between them. Also bumps vitest and @vitest/coverage-v8 to 5.0.0 and
@types/react-dom to 19.2.7, which the freshness gate asked for.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
172 lines
6.7 KiB
Python
172 lines
6.7 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
|
|
|
|
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, str]:
|
|
"""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.
|
|
"""
|
|
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 = 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)}
|
|
return {"status": "delivered", "group": group_id, "text": request.text}
|
|
|
|
|
|
@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}
|