mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 12:07:53 +01:00
Some checks are pending
CI / check (push) Waiting to run
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
182 lines
6.6 KiB
Python
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(),
|
|
)
|