mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
204 lines
7.4 KiB
Python
204 lines
7.4 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Runtime configuration, read once from the environment and validated eagerly."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Final
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
# Re-exported: every caller has always imported these from config, and the
|
|
# split is a filing change, not an interface change.
|
|
from signal_bot.groups import (
|
|
ConfigError,
|
|
build_alias_map,
|
|
canonical_group_id,
|
|
group_id_aliases,
|
|
parse_group_ids,
|
|
)
|
|
|
|
__all__ = [
|
|
"Config",
|
|
"ConfigError",
|
|
"canonical_group_id",
|
|
"group_id_aliases",
|
|
"load_config",
|
|
]
|
|
|
|
DEFAULT_API_BASE_URL: Final = "http://localhost:9922"
|
|
DEFAULT_DATABASE_PATH: Final = "signal_bot.sqlite3"
|
|
DEFAULT_REPORT_HOUR: Final = 21
|
|
DEFAULT_REPORT_MINUTE: Final = 37
|
|
DEFAULT_COOLDOWN_SECONDS: Final = 10
|
|
DEFAULT_CAT_API_URL: Final = "https://api.thecatapi.com/v1/images/search"
|
|
DEFAULT_DOG_API_URL: Final = "https://dog.ceo/api/breeds/image/random"
|
|
DEFAULT_BIND_HOST: Final = "127.0.0.1"
|
|
# Not 8000: that port is a magnet for unrelated dev servers and was already
|
|
# taken here by a long-running enforcer. 8137 sits below the ephemeral range
|
|
# (/proc/sys/net/ipv4/ip_local_port_range, 32768-60999 on Arch), so the kernel
|
|
# will never hand it out to an outbound connection and steal it back.
|
|
DEFAULT_BIND_PORT: Final = 8137
|
|
MAX_HOUR: Final = 23
|
|
MAX_MINUTE: Final = 59
|
|
MAX_PORT: Final = 65535
|
|
DEFAULT_LOG_LEVEL: Final = "INFO"
|
|
|
|
_WS_SCHEMES: Final = {"http": "ws", "https": "wss"}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Config:
|
|
"""Everything the bot needs to know, resolved and validated."""
|
|
|
|
phone_number: str
|
|
# The optional allowlist. Empty means every group the account is a
|
|
# full member of, discovered at runtime -- see signal_bot.discovery.
|
|
group_ids: tuple[str, ...] = ()
|
|
api_base_url: str = DEFAULT_API_BASE_URL
|
|
database_path: str = DEFAULT_DATABASE_PATH
|
|
report_hour: int = DEFAULT_REPORT_HOUR
|
|
report_minute: int = DEFAULT_REPORT_MINUTE
|
|
cooldown_seconds: int = DEFAULT_COOLDOWN_SECONDS
|
|
# Overridable so the offline environment can serve fixture images
|
|
# without the bot knowing it is not talking to the real APIs.
|
|
cat_api_url: str = DEFAULT_CAT_API_URL
|
|
dog_api_url: str = DEFAULT_DOG_API_URL
|
|
# Where the /healthz readiness probe binds. Read here rather than in
|
|
# __main__ so that .env governs it: __main__ runs before anything has
|
|
# loaded a .env file, so an os.getenv() there would silently ignore it.
|
|
bind_host: str = DEFAULT_BIND_HOST
|
|
bind_port: int = DEFAULT_BIND_PORT
|
|
# Set LOG_LEVEL=DEBUG to see why a frame was ignored. Without a knob,
|
|
# the one log line that explains a silently-dropped message is
|
|
# unreachable in production, which is how a scoping mismatch turns
|
|
# into a bot that looks healthy and answers nothing.
|
|
log_level: str = DEFAULT_LOG_LEVEL
|
|
|
|
@property
|
|
def is_pinned(self) -> bool:
|
|
"""True when GROUP_IDS restricts the bot to a fixed set of groups."""
|
|
return bool(self.group_ids)
|
|
|
|
def allows(self, group_id: str) -> bool:
|
|
"""True when a discovered group is permitted by the allowlist.
|
|
|
|
An empty allowlist permits everything: the bot answers wherever it
|
|
has been added. This is only the *permission* check -- whether the
|
|
account is actually a member is decided by the live listing, in
|
|
signal_bot.discovery.
|
|
|
|
Args:
|
|
group_id: A canonical group id from the group listing.
|
|
|
|
Returns:
|
|
True when the group may be watched.
|
|
"""
|
|
if not self.group_ids:
|
|
return True
|
|
return group_id in build_alias_map(self.group_ids)
|
|
|
|
@property
|
|
def receive_url(self) -> str:
|
|
"""WebSocket URL the bot subscribes to for incoming envelopes."""
|
|
parts = urlsplit(self.api_base_url)
|
|
scheme = _WS_SCHEMES.get(parts.scheme, parts.scheme)
|
|
path = f"/v1/receive/{self.phone_number}"
|
|
query = "send_read_receipts=false"
|
|
return urlunsplit((scheme, parts.netloc, path, query, ""))
|
|
|
|
@property
|
|
def send_url(self) -> str:
|
|
"""REST endpoint for outgoing messages and attachments."""
|
|
return f"{self.api_base_url}/v2/send"
|
|
|
|
@property
|
|
def attachments_url(self) -> str:
|
|
"""REST endpoint listing server-side stored attachments."""
|
|
return f"{self.api_base_url}/v1/attachments"
|
|
|
|
@property
|
|
def about_url(self) -> str:
|
|
"""REST endpoint reporting the container build and its mode."""
|
|
return f"{self.api_base_url}/v1/about"
|
|
|
|
@property
|
|
def groups_url(self) -> str:
|
|
"""REST endpoint listing the groups this account knows about."""
|
|
return f"{self.api_base_url}/v1/groups/{self.phone_number}"
|
|
|
|
|
|
def _require(env: dict[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: dict[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 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 = _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)
|
|
|
|
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)
|
|
|
|
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 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,
|
|
bind_host=env.get("BIND_HOST", "").strip() or DEFAULT_BIND_HOST,
|
|
bind_port=bind_port,
|
|
log_level=log_level,
|
|
)
|