# 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)