signal-bot/signal_bot/config_env.py
Krzysztof kuhy Rudnicki 48957a105d
Some checks are pending
CI / check (push) Waiting to run
feat: serve several photo albums at once, each scoped to its own groups
You asked for !add_photo in testbot without the real album being reachable
there. One album cannot do that: its people.json "groups" list *is* the
privacy guarantee, so making the commands exist in a second group necessarily
makes the real photos postable there. So the bot now loads several albums.

PHOTO_PACK_DIR takes a comma-separated list, the spelling GROUP_IDS and
TRUSTED_INVITERS already use. Each album carries its own group scope, its own
people, its own tags and its own custom labels; they share nothing but the
process they run in.

That makes trigger collisions between albums legal for the first time, and the
registry had to learn it. Two albums are two different sets of people and both
may reasonably call somebody "!alfa" -- no message can ever mean both, because
a command a group is not scoped to is not a command there at all. So a trigger
may be claimed more than once, and DuplicateTriggerError now fires only where
the group sets *overlap*. Overlapping albums are refused at startup rather than
resolved by registration order: a coin toss about which private album receives
a photo is the outcome this whole design exists to prevent.

Two consequences worth naming:

- registry.resolve() takes an optional group. Without one it still answers
  "does anything claim this word", which is what pack_add's mint check wants;
  with one it answers "what runs here", which is what the dispatcher wants.
  resolve_with_args no longer resolves-then-checks, because with two claimants
  the first one found may be the wrong group's.
- CommandContext moved to signal_bot/context.py to keep registry.py under the
  250-line cap. A filing change: that file is "which word runs what", this one
  is "what the thing that runs may do".

DOCS-add-photo.md is likewise a split, not new prose -- DOCS-photo-pack.md had
grown past the cap and the ingest path is its own subject now.

Verified live: the unit serves both albums, the real group resolves its 13
album commands and testbot its 7, and !losowe / !alfa / !dodaj all fire in
testbot through the live dispatcher. 997 tests, 100% branch coverage, 28 e2e
scenarios.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqqE22jp94B1ypwxppdr1o
2026-08-31 11:58:50 +02:00

182 lines
6.6 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_dirs
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_dirs() -> tuple[Path, ...]:
"""Every album directory, each 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.
Each 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.
"""
return tuple(
directory
for directory, explicit in default_dirs()
if _servable(directory, explicit=explicit)
)
def _servable(directory: Path, *, explicit: bool) -> bool:
"""Whether one album can be served, raising when a named one cannot."""
if not directory.is_dir():
if explicit:
msg = f"PHOTO_PACK_DIR={directory} is not a directory"
raise ConfigError(msg)
return False
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 False
load_pack(directory)
return True
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_dirs=_photo_pack_dirs(),
)