signal-bot/signal_bot/config_env.py
Krzysztof kuhy Rudnicki 35102be863 release: 0.2.0 «Urbanus I» — a private photo album, and a tagger for it
Per-person photo commands, where the mechanism is public and the album is
not. Nothing identifying is in this repository: who the people are, what
fires their photos and which file shows whom all live in one directory
outside it, and the tests, the docs and the offline e2e run use three
invented people instead.

Several names in one message intersect -- "!alfa beta" is a photo with both
-- and where an entry offers a choice the bot picks among the readings that
actually have a photo, so a question the album could answer is not refused.
A combination nobody photographed gets one line rather than silence, which
from the group's side is indistinguishable from a broken bot.

Scoping is enforced in the dispatcher, not in the handler: a command the
group is not allowed resolves to nothing, so it is not answered, not counted
as a command, and never named in a log line outside its own groups. A
handler that quietly returned would leak all three. Commands are named by
pseudonym (pack:p1), so the journal and command_uses say somebody asked for
a photo and nothing about whose.

Video works: the attachment goes out as data:video/mp4;filename=...;base64,
which is the container's own spelling, read off its /swagger/doc.json.
Told it is a photo, Signal renders a clip as an unplayable file.

tool/tagger.py serves one page on loopback to fill the album in -- names are
typed into the page, never as arguments, and it prints how many photos each
command would have when it stops, because a command with zero is dead on
arrival.

Also renames the real people who were still sitting in the stats test
fixtures. They remain in the history; the point is that nothing new is
added to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
2026-08-30 22:35:41 +02:00

174 lines
6.3 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Reading a :class:`~signal_bot.config.Config` out of the environment.
Split out of ``config.py`` so both stay under the 250-line cap. "What the
settings are" and "how they are parsed and validated" are two things, and only
the first is imported by the rest of the bot.
The dependency runs one way -- this module imports ``config``, never the
reverse -- so ``load_config`` is imported from here rather than re-exported
from ``config``. A re-export would need an import at the bottom of ``config``
and would break depending on which module was imported first.
"""
from __future__ import annotations
import logging
import os
from typing import TYPE_CHECKING
from dotenv import load_dotenv
from signal_bot.config import (
DEFAULT_API_BASE_URL,
DEFAULT_BIND_HOST,
DEFAULT_BIND_PORT,
DEFAULT_COOLDOWN_SECONDS,
DEFAULT_DATABASE_PATH,
DEFAULT_LOG_LEVEL,
DEFAULT_REPORT_HOUR,
DEFAULT_REPORT_MINUTE,
DEFAULT_SILENCE_SECONDS,
MAX_HOUR,
MAX_MINUTE,
MAX_PORT,
Config,
)
from signal_bot.endpoints import (
DEFAULT_CAT_API_URL,
DEFAULT_COMMONS_API_URL,
DEFAULT_DOG_API_URL,
DEFAULT_GECKO_API_URL,
)
from signal_bot.groups import ConfigError, parse_group_ids
from signal_bot.pack import PEOPLE_FILE, default_dir
from signal_bot.pack_load import load as load_pack
from signal_bot.trust import parse_trusted_inviters
if TYPE_CHECKING:
from collections.abc import Mapping
from pathlib import Path
__all__ = ["load_config"]
def _require(env: Mapping[str, str], key: str) -> str:
"""Return a non-empty environment value or raise ConfigError."""
value = env.get(key, "").strip()
if not value:
msg = f"{key} is required and must not be empty."
raise ConfigError(msg)
return value
def _positive_int(env: Mapping[str, str], key: str, default: int) -> int:
"""Return an int environment value, falling back to default when unset."""
raw = env.get(key, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
msg = f"{key} must be an integer, got {raw!r}"
raise ConfigError(msg) from None
if value < 0:
msg = f"{key} must not be negative, got {value}"
raise ConfigError(msg)
return value
def _report_time(env: Mapping[str, str]) -> tuple[int, int]:
"""Return the validated (hour, minute) the daily report fires at."""
hour = _positive_int(env, "REPORT_HOUR", DEFAULT_REPORT_HOUR)
minute = _positive_int(env, "REPORT_MINUTE", DEFAULT_REPORT_MINUTE)
if hour > MAX_HOUR or minute > MAX_MINUTE:
msg = f"REPORT_HOUR/REPORT_MINUTE out of range: {hour:02d}:{minute:02d}"
raise ConfigError(msg)
return hour, minute
def _log_level(env: Mapping[str, str]) -> str:
"""Return the validated logging level name."""
log_level = env.get("LOG_LEVEL", "").strip().upper() or DEFAULT_LOG_LEVEL
if log_level not in logging.getLevelNamesMapping():
msg = f"LOG_LEVEL must be a logging level name, got {log_level!r}"
raise ConfigError(msg)
return log_level
def _bind_port(env: Mapping[str, str]) -> int:
"""Return the validated port the readiness probe binds to."""
bind_port = _positive_int(env, "BIND_PORT", DEFAULT_BIND_PORT)
if not 1 <= bind_port <= MAX_PORT:
msg = f"BIND_PORT must be between 1 and {MAX_PORT}, got {bind_port}"
raise ConfigError(msg)
return bind_port
def _photo_pack_dir() -> Path | None:
"""The album directory, loaded once here to prove it can be served.
Two different absences, on purpose. A directory the operator *named* and
that is not there is a typo, and startup says so. A directory nobody
named and that is not there is simply an installation with no album, and
the commands do not exist -- which is also what every test gets.
The pack is parsed and thrown away rather than trusted: a manifest that
cannot be honoured should fail here, where load_config raising is an
answer, and not later inside a retry loop that would hide it.
"""
directory, explicit = default_dir()
if not directory.is_dir():
if explicit:
msg = f"PHOTO_PACK_DIR={directory} is not a directory"
raise ConfigError(msg)
return None
if not (directory / PEOPLE_FILE).is_file():
# A directory the tagger has created but not filled in yet. Only an
# explicitly named pack may fail here: the implicit path is a place a
# pack *would* live, and failing on it would put the unit into a
# restart loop the moment somebody made the directory.
if explicit:
msg = f"PHOTO_PACK_DIR={directory} has no {PEOPLE_FILE}"
raise ConfigError(msg)
return None
load_pack(directory)
return directory
def load_config(env: dict[str, str] | None = None, *, use_dotenv: bool = True) -> Config:
"""Build a Config from the environment, failing loudly on anything missing.
Args:
env: Mapping to read instead of os.environ. Used by the tests.
use_dotenv: Load a local .env file first. Disabled in tests.
Returns:
A validated, immutable Config.
"""
if env is None:
if use_dotenv:
load_dotenv()
env = dict(os.environ)
hour, minute = _report_time(env)
return Config(
phone_number=_require(env, "PHONE_NUMBER"),
group_ids=parse_group_ids(env),
api_base_url=env.get("API_BASE_URL", "").strip() or DEFAULT_API_BASE_URL,
database_path=env.get("DATABASE_PATH", "").strip() or DEFAULT_DATABASE_PATH,
report_hour=hour,
report_minute=minute,
cooldown_seconds=_positive_int(env, "COOLDOWN_SECONDS", DEFAULT_COOLDOWN_SECONDS),
cat_api_url=env.get("CAT_API_URL", "").strip() or DEFAULT_CAT_API_URL,
dog_api_url=env.get("DOG_API_URL", "").strip() or DEFAULT_DOG_API_URL,
gecko_api_url=env.get("GECKO_API_URL", "").strip() or DEFAULT_GECKO_API_URL,
commons_api_url=env.get("COMMONS_API_URL", "").strip() or DEFAULT_COMMONS_API_URL,
bind_host=env.get("BIND_HOST", "").strip() or DEFAULT_BIND_HOST,
bind_port=_bind_port(env),
log_level=_log_level(env),
trusted_inviters=parse_trusted_inviters(env),
silence_seconds=_positive_int(env, "SILENCE_SECONDS", DEFAULT_SILENCE_SECONDS),
photo_pack_dir=_photo_pack_dir(),
)