mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
!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.
188 lines
6.9 KiB
Python
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(),
|
|
)
|