signal-bot/signal_bot/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

70 lines
2.7 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Per-group silence: the state, and the one question the dispatcher asks it.
A mute is a deadline in ``group_meta``, keyed by the canonical group id, so
it survives a restart and a reload alike and never leaks into another
group. While it stands the bot says nothing in that group: no replies, no
"did you mean", no nightly report. Messages are still counted -- silence is
about talking, not about listening -- and exactly one command still runs,
the one that ends it.
Anyone may ask for silence, for at most ten minutes, because the worst
anyone can do with that is buy ten quiet minutes. Ending it early is the
operator's call: see mute_command.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from signal_bot.registry import Command
from signal_bot.store import Store
# The group_meta key. Epoch seconds, as text, like every other value there.
MUTED_UNTIL_KEY: Final = "muted_until"
MAX_MINUTES: Final = 10
DEFAULT_MINUTES: Final = MAX_MINUTES
# The one command that runs through a mute. By name rather than by a flag on
# Command, so that the registry -- which has nothing to do with silence --
# does not grow a field only one command sets.
UNMUTE_NAME: Final = "unmute"
def muted_until(store: Store, group_id: str) -> float | None:
"""When the current mute ends, or None when there has never been one."""
raw = store.meta(group_id, MUTED_UNTIL_KEY)
return None if raw is None else float(raw)
def is_muted(store: Store, group_id: str, now: float) -> bool:
"""Whether the group is silent at this instant.
An expired deadline is left in place rather than deleted: reading it
costs one comparison, and deleting it would make this a write.
"""
until = muted_until(store, group_id)
return until is not None and now < until
def mute(store: Store, group_id: str, until: float) -> None:
"""Silence the group until the given instant, replacing any earlier deadline."""
store.set_meta(group_id, MUTED_UNTIL_KEY, repr(until))
def unmute(store: Store, group_id: str) -> None:
"""End the silence now. Idempotent: a group that is not muted stays not muted."""
store.set_meta(group_id, MUTED_UNTIL_KEY, "0")
def allows(store: Store, group_id: str, now: float, command: Command | None) -> bool:
"""Whether the dispatcher may say anything in reply to this message.
True whenever the group is not muted; while it is, true only for the
command that ends the mute. ``None`` is a word nobody registered, whose
"did you mean" reply is exactly the kind of noise a mute is for.
"""
if not is_muted(store, group_id, now):
return True
return command is not None and command.name == UNMUTE_NAME