mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 18:23:49 +01:00
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
174 lines
6.3 KiB
Python
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(),
|
|
)
|