mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:17:02 +01:00
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>
106 lines
4.2 KiB
Python
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)
|