signal-bot/signal_bot/config_env.py
Krzysztof kuhy Rudnicki 08599e6056 feat: two commands that answer with somebody else's work
!sztuka posts a work of art, skewed 60% Polish; !wiersz posts one short
public-domain Polish poem, whole. Both follow the rule the rest of the bot
already keeps: the repository commits identifiers, and the words arrive over
the wire when somebody asks.

!wiersz was written the other way first, and the gate caught it. A pack of
short public-domain poems was harvested, name-filtered and committed, and
check_personal_data.sh refused it: three lines carried a real group member's
given name, and two still did after a filter that had discarded 31% of the
corpus. That is structural, not a tuning problem -- Polish verse capitalises
every line, so a line-initial capital carries no signal, and that is exactly
where a name sits. The pack now holds Wolne Lektury slugs; the poem, its
title and its author are fetched at post time and appear nowhere in git.
It is also the better version: with no name filter there are no false
positives, so the pool is larger and the attribution is a full name again.

The gate then refused the art category list too, over a painter whose given
name is somebody's. That is the popes.json case exactly, so it takes the
same answer: a file-scoped allowlist entry, still refused everywhere else.

The Commons harvest is now three modules by what they are, not by where the
line count fell: commons_api is the conversation with Commons, commons_harvest
turns categories into a pool of licensed page ids, commons_cache makes an
interrupted run resumable. Along the way it gained licences in the same
request that lists a category (the second pass was 1,500 requests at 2.3s
each), maxlag instead of a guessed sleep, 5xx retries because one 504 used to
destroy a whole run, and Wikidata for works by creators of Polish
citizenship: the Polish pool grew 15,221 -> 20,092.

Batching categories through CirrusSearch looked like a further 6x and was
measured out again: 2,404 files where the authoritative enumeration returns
2,429, a different ~1% missing on every run.

The offline run is 19s where it was four minutes, almost all of it the
deafness gate waiting out three thirty-second liveness probes to prove
something provable in three; the tick is configuration now, and e2e enforces
a 40s budget on itself so that never silently comes back.
2026-08-31 21:32:07 +02:00

188 lines
6.9 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_LIVENESS_TICK_SECONDS,
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,
DEFAULT_POEM_TEXT_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,
poem_text_url=env.get("POEM_TEXT_URL", "").strip() or DEFAULT_POEM_TEXT_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),
liveness_tick_seconds=_positive_int(
env, "LIVENESS_TICK_SECONDS", DEFAULT_LIVENESS_TICK_SECONDS
),
photo_pack_dirs=_photo_pack_dirs(),
)