mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:17:02 +01:00
The tally was one integer per person, deleted after each post. Nothing dated survived it, so weeks, months, records, streaks and weekday averages were all unanswerable. This replaces it with an append-only per-message log holding metadata only -- never a message body -- and rebuilds the nightly report on top. What changes for the group: a photo posted without a caption now counts (it was worth zero), stickers count, the bot's own messages no longer do, people who said nothing appear with a 0, and the report covers the previous full calendar day in Europe/Warsaw instead of "since the last post" -- which used to merge two days whenever a group went quiet. Notable decisions, each with the trap it avoids: - Events and people are PERMANENT_TABLES: neither forget_group nor a future SCHEMA_VERSION bump may drop them. Both paths used to cost a day's counter and would now cost the entire history, and forget_group fires on a listing that merely omits members. - is_own comes from which envelope branch carried the payload, not from comparing identities. The account is spelled as a uuid on some frames and a +E.164 number on others, so any comparison misses one of them. - The roster is stashed in refresh(), not _adopt(), which returns early once the watched set stops changing -- i.e. always, in the steady state. - The roster matches on uuid OR number because /v1/groups spells kuhy as +48123456789 while his messages carry an ACI uuid. Joining on uuid alone would have listed him twice every night, once with 0. - Members whose display name we have never seen are counted, not named: their only spellings are a uuid or a phone number. - (group, uuid, sent_at_ms) is the primary key, so a websocket redelivery after a reconnect cannot inflate the counts. - Days come from a stored Warsaw date string, never a timestamp range: 2026-03-29 is 23 hours long and 2026-10-25 is 25. - Polish number agreement is a real rule with a 12-14 exception, so "44 znaki" and "45 znaków" are both spelled correctly. config.py and startup.py were two lines under the 250-line cap and both had to grow, so config_env and startup_retry come out of them first, as pure moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
243 lines
8.6 KiB
Python
243 lines
8.6 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,
|
|
canonical_group_id,
|
|
group_id_aliases,
|
|
)
|
|
from signal_bot.config_env import load_config
|
|
|
|
# The real spellings of one group, as the API reports them.
|
|
PREFIXED = "group.WTI5dVptbG5MV2R5YjNWd0xXbGtMVE15TFdKNWRHVnpJU0VoSVNFaElRPT0="
|
|
INTERNAL = "Y29uZmlnLWdyb3VwLWlkLTMyLWJ5dGVzISEhISEhIQ=="
|
|
|
|
BASE_ENV = {
|
|
"PHONE_NUMBER": "+15550000000",
|
|
"GROUP_IDS": "group.watched==",
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_refuses_to_start_without_a_phone_number() -> None:
|
|
env = dict(BASE_ENV)
|
|
del env["PHONE_NUMBER"]
|
|
with pytest.raises(ConfigError, match="PHONE_NUMBER"):
|
|
load_config(env)
|
|
|
|
|
|
def test_refuses_to_start_on_a_blank_phone_number() -> None:
|
|
with pytest.raises(ConfigError, match="PHONE_NUMBER"):
|
|
load_config(dict(BASE_ENV) | {"PHONE_NUMBER": " "})
|
|
|
|
|
|
@pytest.mark.parametrize("env", [{}, {"GROUP_IDS": " "}])
|
|
def test_no_allowlist_is_the_normal_case(env: dict[str, str]) -> None:
|
|
"""Empty means "every group I am in": adding the bot is the whole setup."""
|
|
config = load_config({"PHONE_NUMBER": "+15550000000"} | env)
|
|
assert config.group_ids == ()
|
|
assert not config.is_pinned
|
|
assert config.allows("group.anything==")
|
|
|
|
|
|
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_ids == ("group.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_ids=("group.g==",),
|
|
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.WTI5dVptbG5MV2R5YjNWd0xXbGtMVE15TFdKNWRHVnpJU0VoSVNFaElRPT0="
|
|
assert group_id_aliases(configured) == {
|
|
configured,
|
|
"Y29uZmlnLWdyb3VwLWlkLTMyLWJ5dGVzISEhISEhIQ==",
|
|
}
|
|
|
|
|
|
def test_group_id_aliases_offers_the_prefixed_form_of_an_internal_id() -> None:
|
|
"""An internal id must still be addressable: /v2/send only takes group.*."""
|
|
aliases = group_id_aliases(INTERNAL)
|
|
assert aliases == {INTERNAL, PREFIXED}
|
|
|
|
|
|
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_the_allowlist_accepts_both_spellings() -> None:
|
|
"""An envelope's internal id must satisfy an allowlist written the other way."""
|
|
config = load_config({"PHONE_NUMBER": "+15550000000", "GROUP_IDS": PREFIXED})
|
|
assert config.is_pinned
|
|
assert config.allows(PREFIXED)
|
|
assert config.allows(INTERNAL)
|
|
assert not config.allows("group.someone-elses==")
|
|
|
|
|
|
def test_groups_url_is_scoped_to_the_account() -> None:
|
|
config = load_config(
|
|
{
|
|
"PHONE_NUMBER": "+15550000000",
|
|
"GROUP_IDS": "group.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_IDS": "group.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_IDS": "group.g==",
|
|
"BIND_PORT": port,
|
|
}
|
|
)
|
|
|
|
|
|
def test_several_groups_are_parsed_in_order() -> None:
|
|
"""The point of the change: one process, a test group and a real one."""
|
|
config = load_config(dict(BASE_ENV) | {"GROUP_IDS": f"{PREFIXED}, group.other=="})
|
|
assert config.group_ids == (PREFIXED, "group.other==")
|
|
assert config.allows(INTERNAL)
|
|
assert config.allows("group.other==")
|
|
|
|
|
|
def test_group_id_is_still_accepted_as_the_one_group_spelling() -> None:
|
|
"""An .env written before the list form must keep working untouched."""
|
|
env = {"PHONE_NUMBER": "+15550000000", "GROUP_ID": PREFIXED}
|
|
assert load_config(env).group_ids == (PREFIXED,)
|
|
|
|
|
|
def test_setting_both_spellings_is_refused() -> None:
|
|
env = dict(BASE_ENV) | {"GROUP_ID": PREFIXED}
|
|
with pytest.raises(ConfigError, match="not both"):
|
|
load_config(env)
|
|
|
|
|
|
def test_the_removed_send_target_fails_startup() -> None:
|
|
"""Silently ignoring it would post the daily report into the void."""
|
|
with pytest.raises(ConfigError, match="GROUP_ID_SEND has been removed"):
|
|
load_config(dict(BASE_ENV) | {"GROUP_ID_SEND": PREFIXED})
|
|
|
|
|
|
def test_blank_entries_between_separators_are_skipped() -> None:
|
|
config = load_config(dict(BASE_ENV) | {"GROUP_IDS": f" {PREFIXED} , , "})
|
|
assert config.group_ids == (PREFIXED,)
|
|
|
|
|
|
def test_a_list_of_only_separators_is_refused() -> None:
|
|
with pytest.raises(ConfigError, match="only separators"):
|
|
load_config(dict(BASE_ENV) | {"GROUP_IDS": " , , "})
|
|
|
|
|
|
def test_one_group_listed_in_both_spellings_is_refused() -> None:
|
|
"""Two scopes for one group would split its tally and its cooldowns."""
|
|
with pytest.raises(ConfigError, match="listed twice"):
|
|
load_config(dict(BASE_ENV) | {"GROUP_IDS": f"{PREFIXED},{INTERNAL}"})
|
|
|
|
|
|
def test_an_internal_id_is_stored_in_its_addressable_form() -> None:
|
|
"""Replies are sent to the canonical id, and /v2/send rejects the other."""
|
|
config = load_config(dict(BASE_ENV) | {"GROUP_IDS": INTERNAL})
|
|
assert config.group_ids == (PREFIXED,)
|
|
assert config.allows(PREFIXED)
|
|
|
|
|
|
def test_canonical_group_id_is_idempotent() -> None:
|
|
assert canonical_group_id(PREFIXED) == PREFIXED
|
|
assert canonical_group_id(canonical_group_id(INTERNAL)) == PREFIXED
|
|
|
|
|
|
def test_a_pinned_bot_refuses_a_stranger_group() -> None:
|
|
config = load_config(dict(BASE_ENV) | {"GROUP_IDS": PREFIXED})
|
|
assert not config.allows("group.stranger==")
|