mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20: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>
158 lines
5.8 KiB
Python
158 lines
5.8 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""!pomoc, and the two gates that keep it from going stale."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from signal_bot.analytics_command import register_analytics_command
|
|
from signal_bot.animals import register_animal_commands
|
|
from signal_bot.art_command import register_art_command
|
|
from signal_bot.config import DEFAULT_COMMONS_API_URL, DEFAULT_POEM_TEXT_URL
|
|
from signal_bot.context import CommandContext
|
|
from signal_bot.help_command import (
|
|
COMMAND_NAME,
|
|
GROUPS,
|
|
HEADING,
|
|
SPELLINGS,
|
|
register_help_command,
|
|
render_help,
|
|
)
|
|
from signal_bot.http_client import ImageFetcher
|
|
from signal_bot.models import parse_envelope
|
|
from signal_bot.mute_command import register_mute_commands
|
|
from signal_bot.poem_command import register_poem_command
|
|
from signal_bot.popes_command import register_pope_command
|
|
from signal_bot.portraits import PortraitCache
|
|
from signal_bot.registry import CommandRegistry
|
|
from signal_bot.stats.commands import register_stats_commands
|
|
from signal_bot.suggest_command import register_suggest_command
|
|
from signal_bot.traps_command import register_trap_command
|
|
from tests.conftest import WATCHED_GROUP, envelope
|
|
|
|
if TYPE_CHECKING:
|
|
from signal_bot.config import Config
|
|
from signal_bot.store import Store
|
|
from signal_bot.transport_fake import FakeTransport
|
|
|
|
|
|
def _offline_fetcher() -> ImageFetcher:
|
|
"""A fetcher wired to a transport that refuses to be called.
|
|
|
|
!pomoc never fetches anything, and the other commands are registered here
|
|
only so that the help text has something to list.
|
|
"""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
raise AssertionError(request.url)
|
|
|
|
return ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
|
|
|
|
|
def full_registry() -> CommandRegistry:
|
|
"""Every command the live bot registers, wired the way build_bot wires it."""
|
|
registry = CommandRegistry()
|
|
fetcher = _offline_fetcher()
|
|
register_animal_commands(registry, fetcher)
|
|
register_pope_command(registry, PortraitCache(fetcher, DEFAULT_COMMONS_API_URL))
|
|
register_trap_command(registry, fetcher, DEFAULT_COMMONS_API_URL, traps=(1,))
|
|
register_art_command(registry, fetcher, DEFAULT_COMMONS_API_URL, pools=((1,), (2,)))
|
|
register_poem_command(registry, fetcher, DEFAULT_POEM_TEXT_URL, slugs=("impas",))
|
|
register_stats_commands(registry)
|
|
register_analytics_command(registry)
|
|
register_suggest_command(registry)
|
|
register_mute_commands(registry)
|
|
register_help_command(registry)
|
|
return registry
|
|
|
|
|
|
async def _noop(ctx: CommandContext) -> None:
|
|
"""A handler that does nothing."""
|
|
|
|
|
|
def test_every_registered_command_carries_a_description() -> None:
|
|
"""The whole point of making description a required argument."""
|
|
for command in full_registry().commands:
|
|
assert command.description.strip(), command.name
|
|
|
|
|
|
def test_help_lists_every_command_exactly_once() -> None:
|
|
"""A command missing from !pomoc, or listed twice, fails here.
|
|
|
|
Asserted on the whole line rather than the bare trigger, so prose that
|
|
happens to mention "!stats" can neither satisfy nor break it.
|
|
"""
|
|
registry = full_registry()
|
|
text = render_help(registry.commands, WATCHED_GROUP)
|
|
for command in registry.commands:
|
|
assert text.count(f"\n{command.primary} — {command.description}") == 1, command.name
|
|
|
|
|
|
def test_help_prints_the_polish_spelling_not_the_folded_one() -> None:
|
|
"""`triggers` holds skeletons; only `primary` still carries the diacritics."""
|
|
text = render_help(full_registry().commands, WATCHED_GROUP)
|
|
assert "!papież" in text
|
|
assert "!papiez" not in text
|
|
assert "!tydzień" in text
|
|
|
|
|
|
def test_headings_appear_in_order() -> None:
|
|
"""Obrazki, Papież, Statystyki, then the ungrouped block, then the note."""
|
|
text = render_help(full_registry().commands, WATCHED_GROUP)
|
|
positions = [text.index(f"\n{title}\n") for title, _ in GROUPS]
|
|
assert positions == sorted(positions)
|
|
assert text.startswith(HEADING)
|
|
assert text.endswith(SPELLINGS)
|
|
|
|
|
|
def test_a_command_outside_the_groups_is_still_listed() -> None:
|
|
"""The safety net: forgetting to add a command to GROUPS cannot hide it."""
|
|
registry = full_registry()
|
|
registry.register("ping", ["!ping"], _noop, "odpowiada pong")
|
|
assert "\n!ping — odpowiada pong" in render_help(registry.commands, WATCHED_GROUP)
|
|
|
|
|
|
def test_a_group_naming_an_unknown_command_fails_loudly() -> None:
|
|
"""A typo in GROUPS raises rather than silently dropping a line."""
|
|
registry = CommandRegistry()
|
|
register_help_command(registry)
|
|
with pytest.raises(KeyError):
|
|
render_help(registry.commands, WATCHED_GROUP)
|
|
|
|
|
|
async def test_the_handler_replies_with_the_rendered_list(
|
|
transport: FakeTransport, store: Store, config: Config
|
|
) -> None:
|
|
"""!pomoc answers in the group that asked, quoting the asker."""
|
|
registry = full_registry()
|
|
command = registry.resolve("!pomoc")
|
|
assert command is not None
|
|
assert command.name == COMMAND_NAME
|
|
message = parse_envelope(envelope(text="!pomoc"))
|
|
assert message is not None
|
|
await command.handler(
|
|
CommandContext(
|
|
message=message,
|
|
group_id=WATCHED_GROUP,
|
|
transport=transport,
|
|
store=store,
|
|
config=config,
|
|
command_name=COMMAND_NAME,
|
|
now=100.0,
|
|
day="2026-08-30",
|
|
)
|
|
)
|
|
assert transport.sent[0].recipient == WATCHED_GROUP
|
|
assert transport.sent[0].text == render_help(registry.commands, WATCHED_GROUP)
|
|
|
|
|
|
@pytest.mark.parametrize("trigger", ["!pomoc", "!pomocy", "!help", "!komendy", "!commands"])
|
|
def test_every_alias_resolves(trigger: str) -> None:
|
|
"""All five spellings reach the same command."""
|
|
command = full_registry().resolve(trigger)
|
|
assert command is not None
|
|
assert command.name == COMMAND_NAME
|