signal-bot/tests/test_mute_command.py
Krzysztof kuhy Rudnicki 41df308393
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
feat: !cisza silences the bot in one group for up to ten minutes
Anybody may ask; ten quiet minutes is the whole downside, so there is no
trust gate on the way in and no cooldown. The confirmation is the last
thing said: below it the dispatcher drops every reply in that group --
commands, the near-miss suggestion, and the nightly report, which is
held back (never marked) rather than lost. Messages are still counted.

!odcisz is the one command that runs through a mute, and only for
TRUSTED_INVITERS; a stranger is refused without a reply, because a
refusal is a reply. Unset, anybody may -- the same reading InviteTrust
gives an unset variable, now shared as trust.is_trusted.

There is deliberately no !unmute alias: it is one edit from !mute, and
tests/test_nearest.py forbids two commands within reach of each other.

The state is a deadline in group_meta, so it survives a restart and a
reload and is cleared with the group. tests/test_help_command.py hit
the 250-line cap and its album half moved to tests/test_help_album.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 10:31:08 +02:00

160 lines
5.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""!cisza and !odcisz: who may ask for quiet, for how long, and who may end it."""
from __future__ import annotations
from dataclasses import replace
from typing import TYPE_CHECKING
import pytest
from signal_bot.context import CommandContext
from signal_bot.models import parse_envelope
from signal_bot.mute import MAX_MINUTES, is_muted, mute, muted_until
from signal_bot.mute_command import (
MUTE_NAME,
NOT_MUTED,
UNMUTED,
USAGE,
minutes_from,
register_mute_commands,
)
from signal_bot.registry import CommandRegistry
from tests.conftest import STRANGER_NUMBER, TRUSTED_NUMBER, WATCHED_GROUP, envelope
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
NOW = 1_000.0
ALIASES = {MUTE_NAME: ["!cisza"], "unmute": ["!odcisz"]}
def _registry() -> CommandRegistry:
registry = CommandRegistry()
register_mute_commands(registry, aliases=ALIASES)
return registry
async def _run(
store: Store, transport: FakeTransport, config: Config, text: str, *, number: str = ""
) -> str | None:
"""Type one line at the bot and return what it answered, or None for silence."""
message = parse_envelope(envelope(text=text, timestamp=77))
assert message is not None
if number:
message = replace(message, source_number=number)
resolved = _registry().resolve_with_args(text, WATCHED_GROUP)
assert resolved is not None
before = len(transport.sent)
ctx = CommandContext(
message=message,
group_id=WATCHED_GROUP,
transport=transport,
store=store,
config=config,
command_name=resolved.command.name,
now=NOW,
day="2026-09-04",
args=resolved.args,
)
await resolved.command.handler(ctx)
return transport.sent[-1].text if len(transport.sent) > before else None
@pytest.mark.parametrize(
("args", "minutes"),
[
((), MAX_MINUTES),
(("5",), 5),
(("1",), 1),
(("10",), MAX_MINUTES),
(("11",), MAX_MINUTES),
(("9999",), MAX_MINUTES),
(("0",), None),
(("-3",), None),
(("chwile",), None),
(("5", "minut"), 5),
],
)
def test_minutes_asked_for_are_clamped_to_the_cap(
args: tuple[str, ...], minutes: int | None
) -> None:
assert minutes_from(args) == minutes
async def test_the_bare_word_buys_the_full_ten_minutes(
store: Store, transport: FakeTransport, config: Config
) -> None:
answer = await _run(store, transport, config, "!cisza")
assert answer == "BEEP BOOP CISZA PRZEZ 10 MINUT."
assert muted_until(store, WATCHED_GROUP) == NOW + 10 * 60
@pytest.mark.parametrize(
("text", "answer", "seconds"),
[
("!cisza 1", "BEEP BOOP CISZA PRZEZ 1 MINUTĘ.", 60),
("!cisza 3", "BEEP BOOP CISZA PRZEZ 3 MINUTY.", 180),
("!cisza 7", "BEEP BOOP CISZA PRZEZ 7 MINUT.", 420),
("!cisza 60", "BEEP BOOP CISZA PRZEZ 10 MINUT.", 600),
],
)
async def test_the_confirmation_names_the_minutes_actually_granted(
store: Store, transport: FakeTransport, config: Config, text: str, answer: str, seconds: int
) -> None:
"""The one message before the silence must say how long it is, in Polish."""
assert await _run(store, transport, config, text) == answer
assert muted_until(store, WATCHED_GROUP) == NOW + seconds
async def test_a_number_that_is_not_a_number_gets_the_usage_line(
store: Store, transport: FakeTransport, config: Config
) -> None:
assert await _run(store, transport, config, "!cisza chwile") == USAGE
assert not is_muted(store, WATCHED_GROUP, NOW)
async def test_unmuting_a_group_that_is_not_muted_says_so(
store: Store, transport: FakeTransport, config: Config
) -> None:
assert await _run(store, transport, config, "!odcisz") == NOT_MUTED
async def test_anybody_may_unmute_when_nobody_is_trusted(
store: Store, transport: FakeTransport, config: Config
) -> None:
"""An unset TRUSTED_INVITERS is the gate being off, as it is for invitations."""
mute(store, WATCHED_GROUP, NOW + 600)
assert await _run(store, transport, config, "!odcisz", number=STRANGER_NUMBER) == UNMUTED
assert not is_muted(store, WATCHED_GROUP, NOW)
async def test_a_trusted_operator_ends_the_silence_early(
store: Store, transport: FakeTransport, config: Config
) -> None:
trusting = replace(config, trusted_inviters=(TRUSTED_NUMBER,))
mute(store, WATCHED_GROUP, NOW + 600)
assert await _run(store, transport, trusting, "!odcisz", number=TRUSTED_NUMBER) == UNMUTED
assert not is_muted(store, WATCHED_GROUP, NOW)
async def test_a_stranger_asking_to_unmute_is_refused_in_silence(
store: Store, transport: FakeTransport, config: Config
) -> None:
"""No reply on purpose: the group is muted, and a refusal is a reply."""
trusting = replace(config, trusted_inviters=(TRUSTED_NUMBER,))
mute(store, WATCHED_GROUP, NOW + 600)
assert await _run(store, transport, trusting, "!odcisz", number=STRANGER_NUMBER) is None
assert is_muted(store, WATCHED_GROUP, NOW)
def test_the_real_aliases_register_without_a_collision() -> None:
"""The pair is registered from aliases.json, alongside nothing else."""
registry = CommandRegistry()
register_mute_commands(registry)
assert {c.name for c in registry.commands} == {MUTE_NAME, "unmute"}
assert registry.resolve("!cisza") is not None
assert registry.resolve("!odcisz") is not None