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

106 lines
4.2 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""!cisza and !odcisz: ask the bot to be quiet for a while, and let it speak again.
Two commands with different owners. Anybody can mute, because a bot that
posts a photo into a conversation that has turned serious needs a way to be
told "not now" by whoever is in it, and a ten-minute cap is the whole
safeguard -- there is nothing to abuse in ten quiet minutes. Ending a mute
early belongs to the operator (TRUSTED_INVITERS), because otherwise the
mute is only as long as the next person's patience.
The mute confirms once and then the bot says nothing at all in that group
until the deadline: the dispatcher drops every reply, including "did you
mean" and the nightly report (see mute.allows and Bot._report_group). A
release announcement is the one thing not covered: it is sent at startup
and never repeated, so skipping it would lose it rather than delay it.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Final
from signal_bot.animals import load_aliases
from signal_bot.mute import DEFAULT_MINUTES, MAX_MINUTES, UNMUTE_NAME, is_muted, mute, unmute
from signal_bot.stats.text import plural
from signal_bot.trust import is_trusted
if TYPE_CHECKING:
from signal_bot.context import CommandContext
from signal_bot.registry import CommandRegistry
logger = logging.getLogger(__name__)
MUTE_NAME: Final = "mute"
MUTE_DESCRIPTION: Final = f"ucisza bota na N minut (najwyżej {MAX_MINUTES}), np. !cisza 5"
UNMUTE_DESCRIPTION: Final = "kończy ciszę przed czasem (tylko zaufani)"
MUTED: Final = "BEEP BOOP CISZA PRZEZ {minutes} {unit}."
USAGE: Final = f"BEEP BOOP !cisza <1-{MAX_MINUTES}>."
NOT_MUTED: Final = "BEEP BOOP NIE JESTEM UCISZONY."
UNMUTED: Final = "BEEP BOOP WRACAM."
def minutes_from(args: tuple[str, ...]) -> int | None:
"""How long was asked for, clamped to the cap; None when it was not a number.
No argument is the full ten minutes: somebody typing the bare word wants
quiet, and asking them to be precise about it defeats the point.
"""
if not args:
return DEFAULT_MINUTES
try:
asked = int(args[0])
except ValueError:
return None
if asked < 1:
return None
return min(asked, MAX_MINUTES)
async def handle_mute(ctx: CommandContext) -> None:
"""Silence the group for the requested time, and say so exactly once."""
minutes = minutes_from(ctx.args)
if minutes is None:
await ctx.reply(USAGE)
return
mute(ctx.store, ctx.group_id, ctx.now + minutes * 60)
logger.info("%s muted %s for %d min", ctx.message.source_name, ctx.group_id, minutes)
await ctx.reply(
MUTED.format(minutes=minutes, unit=plural(minutes, "MINUTĘ", "MINUTY", "MINUT"))
)
async def handle_unmute(ctx: CommandContext) -> None:
"""End the silence early -- for the operator; in silence for anybody else.
A refused unmute gets no reply on purpose: the group is muted, and
"you may not unmute me" is exactly the reply a mute forbids. It is
logged instead. An unset TRUSTED_INVITERS means anybody may, the same
reading InviteTrust gives an unset variable.
"""
if not is_muted(ctx.store, ctx.group_id, ctx.now):
await ctx.reply(NOT_MUTED)
return
trusted = ctx.config.trusted_inviters
message = ctx.message
if trusted and not is_trusted(trusted, message.source_number, message.source_uuid):
logger.info("%s asked to unmute %s and is not trusted", message.source_name, ctx.group_id)
return
unmute(ctx.store, ctx.group_id)
logger.info("%s unmuted %s", message.source_name, ctx.group_id)
await ctx.reply(UNMUTED)
def register_mute_commands(
registry: CommandRegistry, *, aliases: dict[str, list[str]] | None = None
) -> None:
"""Register both commands, deliberately outside the cooldown.
A cooldown would mean "wait ten seconds before you may ask for quiet",
and the unmute is gated by trust rather than by time.
"""
table = load_aliases() if aliases is None else aliases
registry.register(MUTE_NAME, table[MUTE_NAME], handle_mute, MUTE_DESCRIPTION, takes_args=True)
registry.register(UNMUTE_NAME, table[UNMUTE_NAME], handle_unmute, UNMUTE_DESCRIPTION)