Files
shandGitHub 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

287 lines
9.6 KiB
Python

import base64
import pytest
from support_bot_light.config import Config, ConfigError, Health, load_config
VALID = """
[bot]
display_name = "Support"
db_prefix = "./support_bot_light"
welcome = "Hi! Someone will join shortly."
[roster]
group_name = "Invite roster"
member_role = "admin"
"""
# Minimal 1x1 PNG. The loader never parses it, only encodes the bytes.
PNG_BYTES = (
b"\x89PNG\r\n\x1a\n"
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x03\x01\x01\x00\xc9\xfe\x92\xef"
b"\x00\x00\x00\x00IEND\xaeB`\x82"
)
def write(tmp_path, text):
p = tmp_path / "config.toml"
p.write_text(text, encoding="utf-8")
return p
def test_loads_all_fields(tmp_path):
cfg = load_config(write(tmp_path, VALID))
assert cfg == Config(
display_name="Support",
db_prefix="./support_bot_light",
welcome="Hi! Someone will join shortly.",
group_name="Invite roster",
member_role="admin",
health=Health(host="127.0.0.1", port=8080),
)
def test_member_role_defaults_to_owner(tmp_path):
text = VALID.replace('member_role = "admin"\n', "")
assert load_config(write(tmp_path, text)).member_role == "owner"
def test_rejects_unknown_member_role(tmp_path):
text = VALID.replace('"admin"', '"chief"')
with pytest.raises(ConfigError, match="member_role"):
load_config(write(tmp_path, text))
def test_rejects_missing_key(tmp_path):
text = VALID.replace('welcome = "Hi! Someone will join shortly."\n', "")
with pytest.raises(ConfigError, match="bot.welcome"):
load_config(write(tmp_path, text))
def test_rejects_missing_bot_section(tmp_path):
with pytest.raises(ConfigError, match=r"missing \[bot\] section"):
load_config(write(tmp_path, '[roster]\ngroup_name = "R"\n'))
def test_rejects_missing_roster_section(tmp_path):
with pytest.raises(ConfigError, match=r"missing \[roster\] section"):
load_config(write(tmp_path, "[bot]\n"))
def test_rejects_empty_string(tmp_path):
text = VALID.replace('"Invite roster"', '" "')
with pytest.raises(ConfigError, match="roster.group_name"):
load_config(write(tmp_path, text))
def test_missing_file(tmp_path):
with pytest.raises(ConfigError, match="not found"):
load_config(tmp_path / "nope.toml")
def test_invalid_toml(tmp_path):
with pytest.raises(ConfigError, match="invalid TOML"):
load_config(write(tmp_path, "[bot"))
def test_image_defaults_to_none(tmp_path):
assert load_config(write(tmp_path, VALID)).image is None
def test_png_image_encodes_with_prefix_and_roundtrips(tmp_path):
(tmp_path / "avatar.png").write_bytes(PNG_BYTES)
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
)
image = load_config(write(tmp_path, text)).image
assert image is not None
prefix = "data:image/png;base64,"
assert image.startswith(prefix)
assert base64.b64decode(image[len(prefix) :]) == PNG_BYTES
@pytest.mark.parametrize("ext", ["jpg", "jpeg"])
def test_jpg_and_jpeg_extensions_encode_as_jpg(tmp_path, ext):
(tmp_path / f"avatar.{ext}").write_bytes(b"not really a jpeg, just bytes")
text = VALID.replace(
'db_prefix = "./support_bot_light"',
f'db_prefix = "./support_bot_light"\nimage = "avatar.{ext}"',
)
image = load_config(write(tmp_path, text)).image
assert image is not None
assert image.startswith("data:image/jpg;base64,")
def test_uppercase_extension_accepted(tmp_path):
(tmp_path / "avatar.PNG").write_bytes(PNG_BYTES)
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.PNG"',
)
image = load_config(write(tmp_path, text)).image
assert image is not None
assert image.startswith("data:image/png;base64,")
def test_rejects_unsupported_extension(tmp_path):
(tmp_path / "avatar.gif").write_bytes(b"gif bytes")
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.gif"',
)
with pytest.raises(ConfigError, match=r"\.gif"):
load_config(write(tmp_path, text))
def test_rejects_missing_image_file(tmp_path):
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "missing.png"',
)
resolved = tmp_path / "missing.png"
with pytest.raises(ConfigError, match=r"not found.*missing\.png|missing\.png.*not found"):
load_config(write(tmp_path, text))
assert not resolved.exists()
def test_rejects_oversized_image(tmp_path):
# 12500 caps the whole data URI, prefix included.
(tmp_path / "avatar.png").write_bytes(b"\x00" * 20000)
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
)
with pytest.raises(ConfigError, match="12500"):
load_config(write(tmp_path, text))
def test_rejects_empty_image_string(tmp_path):
text = VALID.replace(
'db_prefix = "./support_bot_light"', 'db_prefix = "./support_bot_light"\nimage = " "'
)
with pytest.raises(ConfigError, match="bot.image"):
load_config(write(tmp_path, text))
def test_relative_image_path_resolves_against_config_dir(tmp_path, monkeypatch):
other_dir = tmp_path / "elsewhere"
other_dir.mkdir()
monkeypatch.chdir(other_dir)
(tmp_path / "avatar.png").write_bytes(PNG_BYTES)
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
)
image = load_config(write(tmp_path, text)).image
assert image is not None
assert base64.b64decode(image[len("data:image/png;base64,") :]) == PNG_BYTES
def test_absolute_image_path_works(tmp_path):
image_path = tmp_path / "avatar.png"
image_path.write_bytes(PNG_BYTES)
text = VALID.replace(
'db_prefix = "./support_bot_light"',
f'db_prefix = "./support_bot_light"\nimage = "{image_path}"',
)
image = load_config(write(tmp_path, text)).image
assert image is not None
assert base64.b64decode(image[len("data:image/png;base64,") :]) == PNG_BYTES
def test_rejects_empty_image_file(tmp_path):
(tmp_path / "avatar.png").write_bytes(b"")
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
)
with pytest.raises(ConfigError, match="empty"):
load_config(write(tmp_path, text))
def test_rejects_a_non_regular_image_file(tmp_path):
import os
os.mkfifo(tmp_path / "avatar.png")
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
)
with pytest.raises(ConfigError, match="not a regular file"):
load_config(write(tmp_path, text))
def test_rejects_an_oversized_image_before_reading_it(tmp_path):
(tmp_path / "avatar.png").write_bytes(b"A" * 20000)
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
)
with pytest.raises(ConfigError, match="bytes exceeds"):
load_config(write(tmp_path, text))
def test_rejects_an_over_long_welcome(tmp_path):
text = VALID.replace(
'welcome = "Hi! Someone will join shortly."', 'welcome = "' + "x" * 20000 + '"'
)
with pytest.raises(ConfigError, match="too long"):
load_config(write(tmp_path, text))
def test_health_is_on_without_configuration(tmp_path):
assert load_config(write(tmp_path, VALID)).health == Health(host="127.0.0.1", port=8080)
def test_health_can_be_switched_off(tmp_path):
assert load_config(write(tmp_path, VALID + "\n[health]\nenabled = false\n")).health is None
def test_health_port_can_be_set(tmp_path):
config = load_config(write(tmp_path, VALID + "\n[health]\nport = 9999\n"))
assert config.health == Health(host="127.0.0.1", port=9999, configured=True)
def test_the_default_port_is_not_treated_as_chosen(tmp_path):
# A port nobody asked for must not be able to stop the bot from starting.
assert load_config(write(tmp_path, VALID)).health == Health("127.0.0.1", 8080)
assert load_config(write(tmp_path, VALID)).health.configured is False
def test_health_host_can_be_set(tmp_path):
config = load_config(write(tmp_path, VALID + '\n[health]\nhost = "0.0.0.0"\nport = 9000\n'))
assert config.health == Health(host="0.0.0.0", port=9000, configured=True)
@pytest.mark.parametrize(
"section",
[
'[health]\nenabled = "yes"\n',
"[health]\nport = 0\n",
"[health]\nport = 65536\n",
"[health]\nport = true\n", # TOML booleans are ints in Python
'[health]\nport = "8080"\n',
'[health]\nport = 8080\nhost = " "\n',
],
)
def test_invalid_health_settings_are_rejected(tmp_path, section):
with pytest.raises(ConfigError):
load_config(write(tmp_path, VALID + "\n" + section))
def test_a_missing_config_points_at_the_template(tmp_path):
# Under Docker this is a restart loop until the operator acts, so the error
# has to say what the action is.
(tmp_path / "config.toml.example").write_text(VALID, encoding="utf-8")
with pytest.raises(ConfigError, match="copy config.toml.example to config.toml"):
load_config(tmp_path / "config.toml")
def test_a_missing_config_without_a_template_says_only_that(tmp_path):
with pytest.raises(ConfigError, match="not found") as raised:
load_config(tmp_path / "config.toml")
assert "copy" not in str(raised.value)