signal-bot/tests/test_dispatcher_mute.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

132 lines
4.7 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The dispatcher while a group is muted: listening, counting, not talking."""
from __future__ import annotations
import datetime as dt
from typing import TYPE_CHECKING
from signal_bot.dispatcher import Dispatcher
from signal_bot.mute import mute
from signal_bot.mute_command import register_mute_commands
from signal_bot.registry import CommandRegistry
from signal_bot.registry_holder import RegistryHolder
from signal_bot.stats.queries_day import day_rows
from tests.conftest import SECOND_GROUP, WATCHED_GROUP, envelope
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.context import CommandContext
from signal_bot.discovery import GroupDirectory
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
NOW = dt.datetime(2026, 9, 4, 12, 0, tzinfo=dt.UTC)
ALIASES = {"mute": ["!cisza"], "unmute": ["!odcisz"]}
def _dispatcher(
config: Config,
directory: GroupDirectory,
store: Store,
transport: FakeTransport,
runs: list[str],
clock: list[dt.datetime],
) -> Dispatcher:
"""!cat plus the mute pair, in front of the real dispatcher."""
async def handler(ctx: CommandContext) -> None:
runs.append(ctx.command_name)
registry = CommandRegistry()
registry.register("cat", ["!cat"], handler, "kot")
register_mute_commands(registry, aliases=ALIASES)
return Dispatcher(
config=config,
directory=directory,
holder=RegistryHolder(lambda: registry),
store=store,
transport=transport,
clock=lambda: clock[0],
)
async def test_a_muted_group_is_counted_but_not_answered(
config: Config, directory: GroupDirectory, store: Store, transport: FakeTransport
) -> None:
"""A command, an unknown word, chatter: three messages recorded, no reply."""
runs: list[str] = []
dispatcher = _dispatcher(config, directory, store, transport, runs, [NOW])
mute(store, WATCHED_GROUP, NOW.timestamp() + 60)
await dispatcher.handle(envelope(text="!cat", timestamp=1))
await dispatcher.handle(envelope(text="!cta", timestamp=2))
await dispatcher.handle(envelope(text="hello", timestamp=3))
assert runs == []
assert transport.sent == []
day = store.events.first_day(WATCHED_GROUP)
assert day is not None
(row,) = day_rows(store.events, WATCHED_GROUP, day)
assert (row.messages, row.commands) == (3, 1)
# The near miss is not filed either: nothing about the mute is remembered
# as a suggestion to act on later.
assert store.suggestions.unknown() == []
async def test_the_mute_is_confirmed_once_and_then_the_bot_is_silent(
config: Config, directory: GroupDirectory, store: Store, transport: FakeTransport
) -> None:
runs: list[str] = []
dispatcher = _dispatcher(config, directory, store, transport, runs, [NOW])
await dispatcher.handle(envelope(text="!cisza 1"))
await dispatcher.handle(envelope(text="!cisza 1"))
await dispatcher.handle(envelope(text="!cat"))
assert [m.text for m in transport.sent] == ["BEEP BOOP CISZA PRZEZ 1 MINUTĘ."]
assert runs == []
async def test_unmute_is_the_one_command_that_runs_through_a_mute(
config: Config, directory: GroupDirectory, store: Store, transport: FakeTransport
) -> None:
runs: list[str] = []
dispatcher = _dispatcher(config, directory, store, transport, runs, [NOW])
mute(store, WATCHED_GROUP, NOW.timestamp() + 600)
await dispatcher.handle(envelope(text="!odcisz"))
await dispatcher.handle(envelope(text="!cat"))
assert transport.sent[0].text == "BEEP BOOP WRACAM."
assert runs == ["cat"]
async def test_the_silence_ends_on_its_own_at_the_deadline(
config: Config, directory: GroupDirectory, store: Store, transport: FakeTransport
) -> None:
runs: list[str] = []
clock = [NOW]
dispatcher = _dispatcher(config, directory, store, transport, runs, clock)
await dispatcher.handle(envelope(text="!cisza 1"))
clock[0] = NOW + dt.timedelta(seconds=59)
await dispatcher.handle(envelope(text="!cat"))
assert runs == []
clock[0] = NOW + dt.timedelta(seconds=60)
await dispatcher.handle(envelope(text="!cat"))
assert runs == ["cat"]
async def test_a_mute_in_one_group_leaves_the_other_talking(
config: Config, directory: GroupDirectory, store: Store, transport: FakeTransport
) -> None:
runs: list[str] = []
dispatcher = _dispatcher(config, directory, store, transport, runs, [NOW])
await dispatcher.handle(envelope(text="!cisza", group_id=WATCHED_GROUP))
await dispatcher.handle(envelope(text="!cat", group_id=WATCHED_GROUP))
await dispatcher.handle(envelope(text="!cat", group_id=SECOND_GROUP))
assert runs == ["cat"]