# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """Coming up, including when signal-cli is not there yet. On 2026-08-30 the machine rebooted, the bot started before the signal-api container was ready, and its very first HTTP call raised. uvicorn reported "Application startup failed", the unit burned its five restarts in 25 seconds, and it sat in `failed` for eight hours while the container had been healthy for almost all of them. Nobody noticed, because a unit nobody is watching is indistinguishable from a quiet group. So the connection to Signal is no longer part of starting up. uvicorn serves first, /healthz answers 503 with the reason, and this module keeps trying in the background until the container answers. A bot that is still connecting is then *visibly* unhealthy rather than absent. This module owns what a running bot *is*. The retry loop that gets there, and the rule about which errors are worth retrying, live in ``signal_bot.startup_retry``. """ from __future__ import annotations import asyncio import contextlib from dataclasses import dataclass, field from typing import TYPE_CHECKING from signal_bot.bot import Bot, build_bot, now_local from signal_bot.discovery import GroupDirectory from signal_bot.liveness import Liveness from signal_bot.scheduler import DailyScheduler from signal_bot.transport_rest import RestTransport from signal_bot.trust import InviteTrust if TYPE_CHECKING: import httpx from signal_bot.config import Config from signal_bot.store import Store class NotReadyError(RuntimeError): """Raised when signal-cli answers but cannot yet say what it knows.""" @dataclass class Runtime: """The running bot and the background tasks it owns.""" bot: Bot tasks: list[asyncio.Task[None]] = field(default_factory=list) def start(self) -> None: """Spawn the listener and the daily scheduler as background tasks.""" scheduler = DailyScheduler( self.bot.config.report_hour, self.bot.config.report_minute, self.bot.publish_report, now_local, asyncio.sleep, ) self.tasks = [ asyncio.create_task(self.bot.listen()), asyncio.create_task(scheduler.run()), # Third loop: without it, a group added later is only noticed # when somebody happens to speak in it. asyncio.create_task(self.bot.directory.run()), # Fourth loop: without it, a dead connection to Signal is # indistinguishable from a quiet evening, for hours. asyncio.create_task(self.bot.liveness.run()), # Fifth, and one-shot: says hello with a changelog when the # running version is newer than what a group was last told. asyncio.create_task(self.bot.announce_release()), ] async def stop(self) -> None: """Cancel the background tasks. The caller owns the client and store.""" for task in self.tasks: task.cancel() for task in self.tasks: with contextlib.suppress(asyncio.CancelledError): await task async def create_runtime(config: Config, client: httpx.AsyncClient, store: Store) -> Runtime: """Build a Runtime, verifying the container exposes a WebSocket first. Args: config: Resolved runtime configuration. client: HTTP client shared by every component. store: The already-open tally database. Owned by the caller, because this function is retried and must not leak a connection per try. Returns: A Runtime whose loops have not been started yet. Raises: MembershipError: GROUP_IDS names a group the bot is not a member of, established against a listing that was actually read. """ transport = RestTransport(config, client) await transport.verify_mode() directory = GroupDirectory(config, client, store, InviteTrust(config.trusted_inviters)) # Only a listing that really arrived can convict the allowlist. A refresh # that failed leaves the watched set empty, and judging GROUP_IDS on that # would turn "the container is not up yet" into "you are not in your own # group" -- fatal, and wrong. if not await directory.refresh(): msg = f"group listing at {config.groups_url} could not be read" raise NotReadyError(msg) await directory.verify_allowlist() # No probe here: /v1/devices has been seen to hang past 20s, and a # startup that can hang is a startup that can burn the unit's restart # limit. The gate starts healthy and earns its verdict at runtime. liveness = Liveness(config, client) return Runtime(bot=build_bot(config, client, store, transport, directory, liveness))