Files
simplex-chat/apps/simplex-support-bot-light/src/support_bot_light/config.py
T
sh 68916aa90a feat: add simplex-support-bot-light (#7378)
* feat: add simplex-support-bot-light

* feat: package simplex-support-bot-light for docker

* docs: document simplex-support-bot-light

* fix: make a missing config actionable and bounded

* docs: note the attached compose Ctrl+C behaviour

* refactor: use the library for startup and custom data

* refactor: drop the display-name workaround

* style: shorten the startup comment

* build: build the core and the library into the image

* docs: correct where the image type rule comes from

* fix: log what the core said a command got wrong

* build: run the container as the operator's uid

* docs: keep the container uid in .env

* docs: tighten the README
2026-08-17 09:12:47 +01:00

208 lines
7.9 KiB
Python

"""Load and validate `config.toml`."""
from __future__ import annotations
import base64
import stat
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Any, get_args
from simplex_chat.types import T
DEFAULT_MEMBER_ROLE: T.GroupMemberRole = "owner"
MEMBER_ROLES: tuple[str, ...] = get_args(T.GroupMemberRole)
# maxProfileImageSize in src/Simplex/Chat/Library/Commands.hs. Measured against
# the whole data URI, not the raw file.
MAX_PROFILE_IMAGE_SIZE = 12500
# Raw bytes that still fit once base64 and the "data:image/png;base64," prefix
# are added. Checked before the file is read.
MAX_IMAGE_BYTES = (MAX_PROFILE_IMAGE_SIZE - 22) // 4 * 3
# The welcome is sent as a chat message, so it is held below the core's wire
# limit (maxEncodedMsgLength) with room to spare rather than at it.
MAX_WELCOME_BYTES = 12000
# On unless switched off, so a deployment is monitorable without being
# configured for it. Loopback, because the endpoint has no authentication.
DEFAULT_HEALTH_HOST = "127.0.0.1"
DEFAULT_HEALTH_PORT = 8080
MAX_PORT = 65535
# Tag in the data:image/<tag>;base64, prefix. The core accepts any "data:" string;
# the clients strip only the png and jpg prefixes, so jpeg renders as nothing.
IMAGE_EXTENSION_TAGS = {".png": "png", ".jpg": "jpg", ".jpeg": "jpg"}
class ConfigError(ValueError):
"""`config.toml` is missing, malformed, or has an invalid value."""
@dataclass(frozen=True, slots=True)
class Health:
"""Where the monitoring endpoint listens."""
host: str
port: int
# True when the config names the port. A port the operator chose has to
# work; the default must never be what keeps the bot from starting.
configured: bool = False
@dataclass(frozen=True, slots=True)
class Config:
"""Validated settings loaded from `config.toml`."""
display_name: str
db_prefix: str
welcome: str
group_name: str
member_role: T.GroupMemberRole
image: str | None = None
health: Health | None = None
def load_config(path: Path) -> Config:
"""Read and validate `config.toml` at `path`, raising `ConfigError` on any problem."""
try:
raw = tomllib.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as e:
# Names, not a command: under Docker this directory is mounted
# read-only, so the copy is made on the host.
template = path.with_name(path.name + ".example")
hint = f" — copy {template.name} to {path.name} and edit it" if template.exists() else ""
raise ConfigError(f"config file not found: {path}{hint}") from e
except tomllib.TOMLDecodeError as e:
raise ConfigError(f"invalid TOML in {path}: {e}") from e
except UnicodeDecodeError as e:
raise ConfigError(f"config file is not UTF-8: {path}") from e
except OSError as e:
raise ConfigError(f"config file could not be read ({path}): {e}") from e
bot = _section(raw, "bot")
roster = _section(raw, "roster")
role = roster.get("member_role", DEFAULT_MEMBER_ROLE)
if role not in MEMBER_ROLES:
raise ConfigError(
f"roster.member_role must be one of {', '.join(MEMBER_ROLES)}, got {role!r}"
)
return Config(
display_name=_text(bot, "bot", "display_name"),
db_prefix=_text(bot, "bot", "db_prefix"),
welcome=_bounded_text(bot, "bot", "welcome", MAX_WELCOME_BYTES),
group_name=_text(roster, "roster", "group_name"),
member_role=role,
image=_image(bot, path.parent),
health=_health(raw),
)
def _health(raw: dict[str, Any]) -> Health | None:
"""Where the endpoint listens, or None when `health.enabled` switches it off."""
health = raw.get("health", {})
if not isinstance(health, dict):
raise ConfigError("[health] must be a section")
enabled = health.get("enabled", True)
if not isinstance(enabled, bool):
raise ConfigError(f"health.enabled must be true or false, got {enabled!r}")
if not enabled:
return None
port = health.get("port", DEFAULT_HEALTH_PORT)
# bool is an int, and TOML has booleans.
if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= MAX_PORT:
raise ConfigError(f"health.port must be an integer between 1 and {MAX_PORT}, got {port!r}")
host = health.get("host", DEFAULT_HEALTH_HOST)
if not isinstance(host, str) or not host.strip():
raise ConfigError("health.host must be a non-empty string")
# Either key means the operator chose where it listens, and a bind failure
# there is a misconfiguration rather than a coincidence.
return Health(host=host, port=port, configured=bool({"host", "port"} & health.keys()))
def _section(raw: dict[str, Any], name: str) -> dict[str, Any]:
section = raw.get(name)
if not isinstance(section, dict):
raise ConfigError(f"missing [{name}] section")
return section
def _text(section: dict[str, Any], section_name: str, key: str) -> str:
value = section.get(key)
if not isinstance(value, str) or not value.strip():
raise ConfigError(f"{section_name}.{key} must be a non-empty string")
return value
def _bounded_text(section: dict[str, Any], section_name: str, key: str, max_bytes: int) -> str:
value = _text(section, section_name, key)
if len(value.encode()) > max_bytes:
raise ConfigError(
f"{section_name}.{key} is too long: {len(value.encode())} bytes exceeds "
f"the {max_bytes} the core will send; shorten it"
)
return value
def _image(bot: dict[str, Any], config_dir: Path) -> str | None:
"""Encode `bot.image` (a file path) as a profile-image data URI, or `None`
if the key is absent. Relative paths resolve against `config_dir` — the
directory containing `config.toml` — not the process's working directory.
"""
if "image" not in bot:
return None
value = _text(bot, "bot", "image")
image_path = Path(value)
if not image_path.is_absolute():
image_path = config_dir / image_path
extension = image_path.suffix.lower()
tag = IMAGE_EXTENSION_TAGS.get(extension)
if tag is None:
supported = ", ".join(sorted(IMAGE_EXTENSION_TAGS))
raise ConfigError(
f"bot.image has unsupported extension {extension!r} ({image_path}); "
f"supported extensions: {supported}"
)
# Inspect before reading: a FIFO would block startup indefinitely and a
# character device such as /dev/zero would exhaust memory.
try:
info = image_path.stat()
except FileNotFoundError as e:
raise ConfigError(f"bot.image file not found: {image_path}") from e
except OSError as e:
raise ConfigError(f"bot.image could not be read ({image_path}): {e}") from e
if not stat.S_ISREG(info.st_mode):
raise ConfigError(f"bot.image is not a regular file: {image_path}")
if info.st_size > MAX_IMAGE_BYTES:
raise ConfigError(
f"bot.image is too large: {info.st_size} bytes exceeds the {MAX_IMAGE_BYTES} "
f"a {MAX_PROFILE_IMAGE_SIZE}-character data URI can hold; shrink the image "
"(a 128x128 avatar) and try again"
)
try:
data = image_path.read_bytes()
except OSError as e:
raise ConfigError(f"bot.image could not be read ({image_path}): {e}") from e
# The core rejects an empty image file rather than broadcasting a profile
# with an undecodable data URI.
if not data:
raise ConfigError(f"bot.image file is empty: {image_path}")
encoded = base64.b64encode(data).decode("ascii")
data_uri = f"data:image/{tag};base64,{encoded}"
if len(data_uri) > MAX_PROFILE_IMAGE_SIZE:
raise ConfigError(
f"bot.image is too large: encoded size {len(data_uri)} exceeds the "
f"{MAX_PROFILE_IMAGE_SIZE}-character limit the core enforces on profile "
"images; shrink the image (e.g. to a 96x96 or 128x128 avatar) and try again"
)
return data_uri