mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 16:43:49 +01:00
First live reply, as its own registered account. `!cat` and `!dog` from the
phone return images -- verified in the production shape (the bot *invited to
an already-existing group*) and under a systemd user unit.
Four failures, each of which left the bot looking perfectly healthy:
- Invited is not joined. Signal only adds an account outright if the adder
holds its profile key; with no profile the add becomes a pending invite,
and signal-cli cannot accept one addressed to a phone number ("Cannot find
service ID for self"). Group listed, websocket up, /healthz OK, zero
messages. register_bot.sh now sets the profile *before* the add, and
startup refuses on `member: false`.
- MODE=json-rpc-native is a GraalVM build with no AWT: text sends work,
every attachment 400s with "Can't load library: awt". Both mode guards now
demand exactly json-rpc, and start_container replaces a wrong-mode
container instead of reusing it.
- The API spells one group two ways -- /v1/groups returns `group.<base64>`,
envelopes carry what that decodes to -- so the scoping gate rejected the
bot's own group. e2e.sh now models both spellings; reverting the fix fails
4 scenarios, which is why the offline suite missed it before.
- BIND_PORT in .env was dead: __main__ read os.getenv before anything loaded
a .env. The bind address comes from Config now.
A taken port used to be uvicorn logging the collision and exiting 0, which
every supervisor reads as success. portcheck.py refuses to start, exits
non-zero, and names the holder by reading /proc directly -- no subprocess, no
PATH assumptions, testable against a fake proc tree. Default port is 8137:
not 8000, and below the ephemeral range so the kernel cannot take it.
The bot now runs as signal-bot.service (Restart=always, linger checked rather
than assumed, StartLimitBurst so a config error fails visibly instead of
looping). Verified by SIGKILL and by re-running a command under the unit.
100% branch coverage, 230 tests, all gates green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
174 lines
5.8 KiB
Python
174 lines
5.8 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Configuration loading, validation and URL derivation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from signal_bot.config import Config, ConfigError, group_id_aliases, load_config
|
|
|
|
BASE_ENV = {
|
|
"PHONE_NUMBER": "+15550000000",
|
|
"GROUP_ID": "watched==",
|
|
"GROUP_ID_SEND": "reply==",
|
|
}
|
|
|
|
|
|
def test_loads_minimal_environment() -> None:
|
|
config = load_config(dict(BASE_ENV))
|
|
assert config.phone_number == "+15550000000"
|
|
assert config.api_base_url == "http://localhost:9922"
|
|
assert config.report_hour == 21
|
|
assert config.report_minute == 37
|
|
|
|
|
|
@pytest.mark.parametrize("missing", ["PHONE_NUMBER", "GROUP_ID", "GROUP_ID_SEND"])
|
|
def test_refuses_to_start_without_required_value(missing: str) -> None:
|
|
env = dict(BASE_ENV)
|
|
del env[missing]
|
|
with pytest.raises(ConfigError, match=missing):
|
|
load_config(env)
|
|
|
|
|
|
@pytest.mark.parametrize("missing", ["PHONE_NUMBER", "GROUP_ID", "GROUP_ID_SEND"])
|
|
def test_refuses_to_start_on_blank_value(missing: str) -> None:
|
|
"""A linked device sees every chat, so an unscoped bot must not start."""
|
|
env = dict(BASE_ENV) | {missing: " "}
|
|
with pytest.raises(ConfigError, match=missing):
|
|
load_config(env)
|
|
|
|
|
|
def test_overrides_are_honoured() -> None:
|
|
env = dict(BASE_ENV) | {
|
|
"API_BASE_URL": "https://signal.example:8443",
|
|
"DATABASE_PATH": "/var/lib/bot/bot.sqlite3",
|
|
"REPORT_HOUR": "6",
|
|
"REPORT_MINUTE": "5",
|
|
"COOLDOWN_SECONDS": "0",
|
|
}
|
|
config = load_config(env)
|
|
assert config.api_base_url == "https://signal.example:8443"
|
|
assert config.database_path == "/var/lib/bot/bot.sqlite3"
|
|
assert config.report_hour == 6
|
|
assert config.cooldown_seconds == 0
|
|
|
|
|
|
def test_blank_optionals_fall_back_to_defaults() -> None:
|
|
env = dict(BASE_ENV) | {"API_BASE_URL": " ", "DATABASE_PATH": "", "REPORT_HOUR": ""}
|
|
config = load_config(env)
|
|
assert config.api_base_url == "http://localhost:9922"
|
|
assert config.database_path == "signal_bot.sqlite3"
|
|
assert config.report_hour == 21
|
|
|
|
|
|
def test_rejects_non_integer_option() -> None:
|
|
with pytest.raises(ConfigError, match="must be an integer"):
|
|
load_config(dict(BASE_ENV) | {"REPORT_HOUR": "quarter past"})
|
|
|
|
|
|
def test_rejects_negative_option() -> None:
|
|
with pytest.raises(ConfigError, match="must not be negative"):
|
|
load_config(dict(BASE_ENV) | {"COOLDOWN_SECONDS": "-1"})
|
|
|
|
|
|
@pytest.mark.parametrize(("hour", "minute"), [("24", "0"), ("0", "60")])
|
|
def test_rejects_out_of_range_clock(hour: str, minute: str) -> None:
|
|
env = dict(BASE_ENV) | {"REPORT_HOUR": hour, "REPORT_MINUTE": minute}
|
|
with pytest.raises(ConfigError, match="out of range"):
|
|
load_config(env)
|
|
|
|
|
|
def test_reads_os_environ_when_no_mapping_given(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
for key, value in BASE_ENV.items():
|
|
monkeypatch.setenv(key, value)
|
|
config = load_config(use_dotenv=False)
|
|
assert config.group_id == "watched=="
|
|
|
|
|
|
def test_derived_urls() -> None:
|
|
config = load_config(dict(BASE_ENV))
|
|
assert config.receive_url == (
|
|
"ws://localhost:9922/v1/receive/+15550000000?send_read_receipts=false"
|
|
)
|
|
assert config.send_url == "http://localhost:9922/v2/send"
|
|
assert config.attachments_url == "http://localhost:9922/v1/attachments"
|
|
assert config.about_url == "http://localhost:9922/v1/about"
|
|
|
|
|
|
def test_https_base_url_yields_wss() -> None:
|
|
config = Config(
|
|
phone_number="+1",
|
|
group_id="g",
|
|
group_id_send="r",
|
|
api_base_url="https://signal.example",
|
|
)
|
|
assert config.receive_url.startswith("wss://signal.example/")
|
|
|
|
|
|
def test_group_id_aliases_adds_the_internal_form() -> None:
|
|
"""The API spells one group two ways; the bot must answer to both."""
|
|
# Real value observed live: the base64 decodes to the internal_id the
|
|
# same endpoint reports, and to what envelopes actually carry.
|
|
configured = "group.Ni9nRXdSa3FzLzI1V1F4MkpPMkVlUlc4RUIwSXJBdXVPKzhEMGh1K09wST0="
|
|
assert group_id_aliases(configured) == {
|
|
configured,
|
|
"6/gEwRkqs/25WQx2JO2EeRW8EB0IrAuuO+8D0hu+OpI=",
|
|
}
|
|
|
|
|
|
def test_group_id_aliases_leaves_a_bare_internal_id_alone() -> None:
|
|
assert group_id_aliases("6/gEwRkqs=") == {"6/gEwRkqs="}
|
|
|
|
|
|
def test_group_id_aliases_tolerates_undecodable_ids() -> None:
|
|
"""A hand-edited GROUP_ID must not crash startup before the real check."""
|
|
assert group_id_aliases("group.not!valid!base64") == {"group.not!valid!base64"}
|
|
|
|
|
|
def test_watched_group_ids_exposes_both_spellings() -> None:
|
|
config = load_config(
|
|
{
|
|
"PHONE_NUMBER": "+15550000000",
|
|
"GROUP_ID": "group.Ni9nRXdSa3FzLzI1V1F4MkpPMkVlUlc4RUIwSXJBdXVPKzhEMGh1K09wST0=",
|
|
"GROUP_ID_SEND": "reply==",
|
|
}
|
|
)
|
|
assert "6/gEwRkqs/25WQx2JO2EeRW8EB0IrAuuO+8D0hu+OpI=" in config.watched_group_ids
|
|
|
|
|
|
def test_groups_url_is_scoped_to_the_account() -> None:
|
|
config = load_config(
|
|
{
|
|
"PHONE_NUMBER": "+15550000000",
|
|
"GROUP_ID": "g==",
|
|
"GROUP_ID_SEND": "g==",
|
|
"API_BASE_URL": "http://localhost:9922",
|
|
}
|
|
)
|
|
assert config.groups_url == "http://localhost:9922/v1/groups/+15550000000"
|
|
|
|
|
|
def test_an_unknown_log_level_is_rejected() -> None:
|
|
with pytest.raises(ConfigError, match="LOG_LEVEL"):
|
|
load_config(
|
|
{
|
|
"PHONE_NUMBER": "+15550000000",
|
|
"GROUP_ID": "g==",
|
|
"GROUP_ID_SEND": "g==",
|
|
"LOG_LEVEL": "chatty",
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("port", ["0", "65536"])
|
|
def test_a_port_outside_the_valid_range_is_rejected(port: str) -> None:
|
|
with pytest.raises(ConfigError, match="BIND_PORT"):
|
|
load_config(
|
|
{
|
|
"PHONE_NUMBER": "+15550000000",
|
|
"GROUP_ID": "g==",
|
|
"GROUP_ID_SEND": "g==",
|
|
"BIND_PORT": port,
|
|
}
|
|
)
|