feat: a word the bot does not know gets an answer, and gets kept

An unrecognised "!word" was dropped in silence, which made a typo
indistinguishable from the bot being down and threw away the best available
list of commands worth writing. Three things now happen at the one place in
dispatcher.py that used to be a bare `return`.

A near miss is corrected -- "!cta" asks whether you meant "!kot". The
distance is optimal string alignment rather than difflib's ratio: a
transposition is the typo people actually make, it costs one edit here, and
it shreds the matching blocks a ratio counts ("!cta"/"!cat" scores 0.75 but
"!cta"/"!kot" scores 0.50, so no cutoff separates them on principle).

A word with no near miss points at !zaproponuj, which files an idea from
inside the group. Either way the word and the message are recorded, and the
management page grows an Ideas tab that lists them.

Three things stay silent, each for its own reason. "!kot ladny" is a real
trigger with words after it and always has been silent. "!!" is punctuation.
And a command claimed in some *other* group is neither answered nor recorded
-- the album's triggers are real people's names, and pack_scoping.yaml says
it out loud: a refusal would itself announce that the album exists. The
existence check is therefore global, which is what pack_add already asks.

Measured rather than guessed, against the shipped aliases.json:

- a minimum candidate length, because "!ja" is one edit from ~40 of the 676
  two-letter words somebody could type; 69 of them got a suggestion before
  the rule and 18 after.
- the two-edit budget starts at six characters, not five. At five it put
  four pairs of *different* commands within reach of each other --
  !pies/!popes, !days/!cats, !dzis/!dni, !mnie/!dni -- and costs nothing to
  give up, because every real typo this was written for is one edit away.
  test_no_two_commands_are_within_reach_of_each_other fails if a future
  alias reopens the gap.

Both new tables are keyed on the envelope's own sent_at_ms, never the wall
clock: signal-cli replays undelivered frames on reconnect, and a clock-keyed
table collects a duplicate row per reconnect instead of ignoring one.

The branch is gated on should_count -- which excludes the bot's own envelope,
and that is the loop guard, since handle() checks is_own nowhere else -- and
wrapped in its own try/except, because it runs above the one that wraps a
handler and Bot.listen has no exception handling of its own.

Suggestions live in SQLite, not in a tracked file, and the export goes to the
state directory. check_personal_data learns the names it refuses from the
live database, so it catches a name somebody has typed and cannot catch a
misspelling of one -- and an unrecognised-command corpus is made of
misspellings. A group-writable file in a public tree is both a leak and a
wedge. The export therefore carries
deliberate !zaproponuj notes in full and unrecognised words as counts only;
bodies stay on the loopback-only page.

Verified on the live bot: restarted, both tables created with every existing
row count unchanged, !cta and !zaproponuj !pogoda delivered through
/_selftest, both rows read back out of the live database, and the journal
names neither the word nor the body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
This commit is contained in:
Krzysztof Rudnicki 2026-08-31 16:48:26 +02:00
parent 486c478618
commit 6ec746fad2
39 changed files with 1917 additions and 12 deletions

View File

@ -28,9 +28,36 @@ This file is the trigger reference — every spelling that resolves, which
| help | `!pomoc` `!pomocy` `!help` `!komendy` `!commands` |
| add_photo | `!dodaj` `!dodaj_zdjecie` `!dodajzdjecie` `!add_photo` `!addphoto` |
| random | `!losowe` `!losuj` `!random` `!rand` |
| suggest | `!zaproponuj` `!propozycja` `!sugestia` `!suggest` `!pomysl` |
Aliases live in `signal_bot/aliases.json`, not in source, because they are
data: adding one should never mean touching Python.
data: adding one should never mean touching Python. Two constraints on a new
one. It is subtracted from the personal-data gate's word list by
`_repo_vocabulary()`, so it must not be name-shaped; and
`tests/test_nearest.py` fails if it lands within edit distance of a
*different* command's trigger, because the near-miss suggestion below would
then be a coin toss.
## A word nothing claims
An unrecognised `!word` used to be dropped in silence, which made a typo
indistinguishable from the bot being down. Now it is answered, and kept:
- within one edit (two, from six characters up) of a real trigger, the bot
asks whether that is what you meant — `!cta` → *chodziło o !kot?* The
distance is optimal string alignment rather than `difflib`'s ratio,
because a transposition costs one edit there and shreds the matching
blocks a ratio counts.
- otherwise it points at `!zaproponuj`, which files the idea.
- either way the word and the message go into `unknown_commands`, which the
management page shows. That is the list of commands worth writing next.
Three things stay silent, and each for its own reason. `!kot ładny` is a
real trigger with words after it, and always has been silent. `!!` and `!?`
are punctuation, not a mistyped command. And a command scoped to some *other*
group — the album's, whose triggers are real people's names — is neither
answered nor recorded, because a refusal would announce that the album
exists.
`add_photo` and `random` exist **only in the photo album's own groups**, and
only on an installation that has an album at all — everywhere else they are

View File

@ -27,6 +27,7 @@ and the star went too.
| the message database | `signal_bot.sqlite3` | `*.sqlite3` and its `-wal`/`-shm` siblings are ignored |
| the account's number, groups, inviters | `.env` | `.env` is ignored; `.env.sample` carries the shape and no values |
| everyone's names | the database and the pack manifest | nothing reads them into the repo; the bot logs `pack:p1`, never a name |
| what people typed after a `!` the bot did not know | `unknown_commands` in the database | the database is ignored; the log line names neither the word nor the message, and the export the management page writes goes to the state directory, never into this tree |
All of it is `700`/`600` on disk. The repository never needs any of it: the
tests, the documents and the offline end-to-end run use invented people —

View File

@ -116,6 +116,23 @@ item *i* of the album while tagging item *i* of the filtered set. Anything
seen this session stays navigable even once it has tags, or tagging the photo
you are looking at would remove it from the list you are walking.
## Ideas
The fourth tab lists what the group has asked for: `!zaproponuj` notes with
the text people wrote, and every unrecognised `!word` with a count and the
messages it came in.
The **Export** button writes that to a file, and the file goes to the state
directory — `$STATE_DIRECTORY`, else `$XDG_DATA_HOME/signal-bot/` — never
into this repository. Two reasons, and both are about a public git tree that
has leaked real names once. A file the group can write into would wedge
`check_personal_data` the moment somebody's name landed in it, refusing every
future commit until a human edited it by hand; and the gate learns the names
it refuses from the live database, so it catches `anna` and cannot possibly
catch `!annna`, which is exactly what an unrecognised-command corpus is full
of. The export therefore carries deliberate notes in full and unrecognised
words as counts only. Copy what is worth keeping into the tree yourself.
## Routes
Everything under `/api`. The page itself is served from `web/dist` at every
@ -135,6 +152,8 @@ other path, so a reload on a sub-view works.
| POST | `/api/album/setup` | Write the roster, then prove the bot could load it. |
| POST | `/api/album/person` | Add one person mid-session. |
| GET | `/api/album/media/{index}` | One item, **by position**. |
| GET | `/api/suggestions` | Filed ideas and unrecognised-word tallies, bodies included. |
| POST | `/api/suggestions/export` | Write those to a file **outside the repository**. |
That last one is not a style choice. A filename supplied by a client is a
path, and this directory holds private photographs; the route takes an `int`,

View File

@ -191,8 +191,8 @@ container, and `TRUSTED_INVITERS` decides who may add this bot to a group:
line of description each. It is built from the registry at run time and
`CommandRegistry.register` takes that description as a required argument, so a
command cannot be added without appearing there. Every trigger that resolves,
and why the printed spelling is not one of them:
[DOCS-commands.md](DOCS-commands.md).
what the bot now answers to a `!word` nothing claims, and why the printed
spelling is not one of them: [DOCS-commands.md](DOCS-commands.md).
Aliases live in `signal_bot/aliases.json`, not in source. Each command is rate-limited per
user, per command — the original bot shared one 10-second window across everybody.

View File

@ -0,0 +1,9 @@
# Ordinary conversation is not a command attempt and never was. Kept as its
# own scenario when unknown_command.yaml stopped expecting silence, because
# this half of the old assertion is the half still worth pinning.
name: "ordinary chatter produces nothing"
inject:
- { text: "just chatting", uuid: uuid-alice, name: Alice }
- { text: "hej! super", uuid: uuid-alice, name: Alice }
- { text: "!!", uuid: uuid-alice, name: Alice }
expect: []

View File

@ -0,0 +1,8 @@
# The terminal's trick. "!gekno" is one transposition from "!gekon", which is
# why the distance is optimal string alignment rather than difflib's ratio --
# a swap costs one edit there and shreds the matching blocks difflib counts.
name: "a near miss is corrected"
inject:
- { text: "!gekno", uuid: uuid-alice, name: Alice }
expect:
- { kind: text, to: reply, contains: "!gekon" }

View File

@ -0,0 +1,7 @@
# Filing an idea from inside the group. The word must not already be a
# command anywhere, so this one is invented rather than borrowed.
name: "!zaproponuj files an idea"
inject:
- { text: "!zaproponuj !pogoda pokaz pogode na jutro", uuid: uuid-alice, name: Alice }
expect:
- { kind: text, to: reply, contains: "Zapisane" }

View File

@ -1,5 +1,8 @@
name: "chatter and unknown commands produce nothing"
# A "!" word nothing claims. Until 0.1.x this was silent, which made a typo
# indistinguishable from the bot being down. Nothing is close enough to
# "!nosuchcommand" to suggest, so the answer points at !zaproponuj instead.
name: "an unrecognised command is answered, not ignored"
inject:
- { text: "just chatting", uuid: uuid-alice, name: Alice }
- { text: "!nosuchcommand", uuid: uuid-alice, name: Alice }
expect: []
expect:
- { kind: text, to: reply, contains: "!zaproponuj" }

View File

@ -0,0 +1,9 @@
# The privacy guarantee again, this time for the near-miss path. The album's
# triggers are real people's names. In a group without the album "!alfa" is
# unclaimed *here*, so a check scoped to this group would answer it and store
# it -- announcing the album and putting a name in the database. The check is
# global instead: claimed anywhere at all means silence.
name: "an album name is not echoed in a group without the album"
inject:
- { text: "!alfa", group: second, uuid: uuid-pack-five, name: Pack5 }
expect: []

View File

@ -103,5 +103,12 @@
"!losuj",
"!random",
"!rand"
],
"suggest": [
"!zaproponuj",
"!propozycja",
"!sugestia",
"!suggest",
"!pomysl"
]
}

View File

@ -34,6 +34,7 @@ from signal_bot.reporting import compose
from signal_bot.stats import periods
from signal_bot.stats.commands import register_stats_commands
from signal_bot.stats.nightly import days_to_report, mark_reported
from signal_bot.suggest_command import register_suggest_command
from signal_bot.versioning import POPE
if TYPE_CHECKING:
@ -198,6 +199,12 @@ def build_bot(
)
register_pope_command(registry, portraits, decorate=rate_limit)
register_stats_commands(registry, decorate=rate_limit)
# Rate-limited like every other command, and unlike !add_photo, whose
# "NO CAP, NO LIMITS" is a deliberate exception rather than a default.
# The cost is real and accepted: somebody told "napisz !zaproponuj ..."
# who then files two ideas in a row is asked to wait for the second. The
# alternative is one person being able to fill a table nothing prunes.
register_suggest_command(registry, decorate=rate_limit)
# Absent unless this installation has an album, and scoped to that
# album's own groups when it does -- so on every other machine, and in
# every other group, these commands simply do not exist.

View File

@ -15,10 +15,12 @@ from signal_bot.envelope_shape import shape_of
from signal_bot.models import parse_envelope
from signal_bot.registry import CommandRefusedError, CommandRegistry
from signal_bot.stats.recorder import record_message
from signal_bot.unknown import answer_unknown
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.discovery import GroupDirectory
from signal_bot.models import IncomingMessage
from signal_bot.store import Store
from signal_bot.transport_base import Transport
@ -38,6 +40,26 @@ class Dispatcher:
transport: Transport
clock: Clock
async def _answer_unknown(self, message: IncomingMessage, group: str) -> None:
"""Answer a "!" word nothing claims, and remember it.
Guarded on its own because it runs *above* the try that wraps a
handler, and Bot.listen has no exception handling of its own: one
raise here ends the receive loop and the bot goes silently deaf
until the watchdog notices. A sqlite insert and a network send are
exactly the two things in this branch that can raise.
"""
try:
await answer_unknown(
message=message,
group_id=group,
registry=self.registry,
store=self.store,
transport=self.transport,
)
except Exception:
logger.exception("could not answer an unrecognised command in %s", group)
async def handle(self, frame: str) -> None:
"""Process one raw websocket frame end to end."""
# Keys and types only, never values -- see envelope_shape. This is
@ -82,7 +104,12 @@ class Dispatcher:
# outside the groups it belongs to.
resolved = self.registry.resolve_with_args(message.text, group)
command = resolved.command if resolved is not None else None
if should_count(message):
# Computed once and reused below. should_count is False for a
# reaction and for the bot's own envelope, and the second of those
# is the loop guard: handle() does not check is_own anywhere else,
# so without this the bot could answer itself.
counted = should_count(message)
if counted:
record_message(
self.store.events,
group,
@ -92,6 +119,11 @@ class Dispatcher:
)
if command is None:
# is_command above stays False, and this stays below the
# recording rather than in front of it: an unrecognised word is
# not a command just because the bot now says something about it.
if counted:
await self._answer_unknown(message, group)
return
logger.info("running command %s for %s", command.name, message.source_name)

View File

@ -40,6 +40,7 @@ HEADING: Final = "Komendy"
GROUPS: Final[tuple[tuple[str, tuple[str, ...]], ...]] = (
("Obrazki", ("cat", "dog", "gecko")),
("Papież", ("pope",)),
("Propozycje", ("suggest",)),
(
"Statystyki",
(

View File

@ -18,7 +18,15 @@ from typing import TYPE_CHECKING
from fastapi import FastAPI
from signal_bot.manage import assets, guard, routes_actions, routes_album, routes_env, routes_status
from signal_bot.manage import (
assets,
guard,
routes_actions,
routes_album,
routes_env,
routes_status,
routes_suggestions,
)
if TYPE_CHECKING:
from signal_bot.manage.deps import ManageDeps
@ -42,5 +50,6 @@ def build_app(deps: ManageDeps, *, container: str = DEFAULT_CONTAINER) -> FastAP
app.include_router(routes_env.router(deps))
app.include_router(routes_actions.router(deps, container))
app.include_router(routes_album.router(deps))
app.include_router(routes_suggestions.router(deps))
assets.install(app, deps.dist_dir)
return app

View File

@ -37,6 +37,24 @@ class ManageDeps:
album: Album
"""The one pack this session tags, chosen by --dir."""
database_path: Path
"""The bot's database, read read-only.
Required, with no default, and that is the point. Config.database_path
is the relative "signal_bot.sqlite3", pytest runs from the repo root,
and the operator's real database sits there -- so a default here would
quietly point the whole test suite at live private data.
"""
export_path: Path
"""Where the export is written. Never inside the repository.
A file the group can write into, inside a public git tree, is both a
leak and a wedge: the personal-data gate would refuse every future
commit once somebody's name landed in it, and it cannot recognise the
misspelled names this particular file is full of.
"""
client: httpx.AsyncClient
"""Shared, so the proxied /healthz and the group listing pool sockets."""

View File

@ -0,0 +1,162 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""What the group asked for, read back on the management page.
Read-only, and connected read-only: ``mode=ro`` rather than a plain connect,
which is the same URI ``scripts/personal_data_sources.py`` uses. Two reasons.
The bot has this file open and commits on every inbound message, so a second
writer is a lock waiting to happen; and ``Store`` applies its schema on open,
which drops the ephemeral tables whenever the versions disagree -- a
management page must never be able to do that to a running bot.
The export deliberately does **not** write into the repository. See
``render_export``.
"""
from __future__ import annotations
import contextlib
import os
import sqlite3
from pathlib import Path
from typing import TYPE_CHECKING, Any, Final
from fastapi import APIRouter
from signal_bot.suggestions import SuggestionStore
if TYPE_CHECKING:
from signal_bot.manage.deps import ManageDeps
# Named for the repository's markdown convention, so that a copy pasted
# into the tree is already compliant -- but written outside it. See
# default_export_path.
EXPORT_NAME: Final = "DOCS-suggestions.md"
def default_export_path() -> Path:
"""Where the export goes: the state directory, never the repository.
Same order as pack.default_dir, and for the same reason -- systemd
creates STATE_DIRECTORY and exposes it regardless of ProtectHome, and
reading os.environ live is what lets one autouse fixture keep the whole
test suite out of the operator's real data.
"""
state = os.environ.get("STATE_DIRECTORY", "").strip()
if state:
return Path(state) / EXPORT_NAME
home = os.environ.get("XDG_DATA_HOME", "").strip() or str(Path.home() / ".local" / "share")
return Path(home) / "signal-bot" / EXPORT_NAME
# What a body is truncated to in the export. Long enough for a sentence,
# short enough that a pasted paragraph cannot bury the file.
EXPORT_BODY_LIMIT = 200
HEADER = """# Suggestions
Written by the management page, from the bot's own database. Not generated
at build time and not checked by anything -- read it, act on what is worth
acting on, and delete it.
Bodies are included for deliberate `!zaproponuj` notes only. Unrecognised
commands are counts, because that half of the table is full of half-typed
words and a half-typed name is one the personal-data gate cannot recognise.
"""
def collect(path: Path) -> dict[str, list[dict[str, Any]]]:
"""Everything the page shows, or empty lists when there is nothing to read.
Opening and reading are inside one try because every way this can fail
is the same answer -- a bot that has never run, a database older than
these tables, a half-written file -- and none of them is worth breaking
the page over. The page is most useful exactly when something is wrong.
Closed explicitly: the bot holds this file open and commits on every
inbound message, so a reader left dangling is a lock waiting to happen.
"""
if not path.is_file():
return {"suggestions": [], "unknown": []}
try:
with contextlib.closing(sqlite3.connect(f"file:{path}?mode=ro", uri=True)) as conn:
conn.row_factory = sqlite3.Row
store = SuggestionStore(conn)
filed = store.filed()
unknown = store.unknown()
except sqlite3.Error:
return {"suggestions": [], "unknown": []}
return {
"suggestions": [
{
"command": one.command,
"body": one.body,
"sent_at_ms": one.sent_at_ms,
"group_id": one.group_id,
}
for one in filed
],
"unknown": [
{
"word": one.word,
"hits": one.hits,
"last_seen": one.last_seen,
"group_id": one.group_id,
"bodies": list(one.bodies),
}
for one in unknown
],
}
def render_export(data: dict[str, list[dict[str, Any]]]) -> str:
"""Turn the collected rows into the markdown the export writes.
Deliberate notes keep their text; unrecognised words are reduced to a
count. That asymmetry is the whole point of the file. A body typed into
a group chat is free text, and this repository is public and has leaked
real names once already -- the personal-data gate learns the names it
refuses from the live database, so it catches "anna" and cannot possibly
catch "!annna". Counts cannot leak a name; sentences can.
"""
lines = [HEADER, "## Asked for", ""]
if not data["suggestions"]:
lines.append("_Nothing yet._")
lines.extend(
f"- `{one['command']}` — {one['body'][:EXPORT_BODY_LIMIT]}" for one in data["suggestions"]
)
lines.extend(["", "## Mistyped, or tried and not there", ""])
if not data["unknown"]:
lines.append("_Nothing yet._")
lines.extend(f"- `{one['word']}` x {one['hits']}" for one in data["unknown"])
return "\n".join(lines) + "\n"
def router(deps: ManageDeps) -> APIRouter:
"""The suggestions endpoints.
Args:
deps: The running app's configuration and paths.
Returns:
A router to include in the management app.
"""
api = APIRouter(prefix="/api")
@api.get("/suggestions")
async def suggestions() -> dict[str, list[dict[str, Any]]]:
"""Every filed idea and every unrecognised word, newest first."""
return collect(deps.database_path)
@api.post("/suggestions/export")
async def export() -> dict[str, Any]:
"""Write the export and say where it went."""
data = collect(deps.database_path)
deps.export_path.parent.mkdir(parents=True, exist_ok=True)
deps.export_path.write_text(render_export(data), encoding="utf-8")
return {
"path": str(deps.export_path),
"suggestions": len(data["suggestions"]),
"unknown": len(data["unknown"]),
}
return api

115
signal_bot/nearest.py Normal file
View File

@ -0,0 +1,115 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""How far one typed word is from another, and which command a typo meant.
``difflib`` was the obvious candidate and it is the wrong one. Its ratio is
built out of matching blocks, which is exactly what a transposition destroys:
"!cta" against "!cat" scores 0.75, but "!cta" against "!kot" scores 0.50. No
single cutoff both catches the first and rejects the second on principle
rather than by luck. Optimal string alignment counts a swap as one edit,
which is what somebody who typed the letters in the wrong order actually did.
Both sides are expected to be folded already -- see ``signal_bot.folding``.
Comparing a folded trigger against an unfolded word would measure the
alphabet somebody's keyboard was in rather than how badly they mistyped.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from collections.abc import Iterable
# A short word has no room to be wrong twice, and five characters is still
# short. Measured against the shipped aliases.json: at a boundary of four,
# two edits put four pairs of *different* commands within reach of each
# other -- !pies/!popes, !days/!cats, !dzis/!dni and !mnie/!dni, every one
# of them exactly five characters long. Moving the boundary to five removes
# all four and costs nothing, because every real typo this feature was
# written for (!cta, !kto, !gekno, !papiesz, !stat, !rok2, !korna) is one
# edit away, not two. test_no_two_commands_are_within_reach_of_each_other
# fails if a future alias reopens the gap.
SHORT: Final = 5
SHORT_BUDGET: Final = 1
LONG_BUDGET: Final = 2
# A candidate this short is not a suggestion, it is a magnet. "!ja" is three
# characters, so at a budget of one edit it sits within reach of roughly
# forty of the 676 two-letter words somebody could type: !aa, !ba, !ca, !da
# and so on all "meant" !ja, and so did !jak and !nie. Refusing to offer
# anything shorter costs exactly one real command its typo correction --
# measured against the live aliases.json, !ja is the only trigger the rule
# excludes -- and buys silence for all forty.
MIN_CANDIDATE: Final = 4
def distance(one: str, other: str) -> int:
"""Optimal string alignment distance between two words.
Damerau-Levenshtein restricted to adjacent transpositions: insertion,
deletion and substitution each cost one, and swapping two neighbouring
characters costs one rather than the two a plain Levenshtein charges.
Args:
one: The first word.
other: The second word.
Returns:
The number of edits that turn one into the other.
"""
rows, columns = len(one), len(other)
grid = [[0] * (columns + 1) for _ in range(rows + 1)]
for row in range(rows + 1):
grid[row][0] = row
for column in range(columns + 1):
grid[0][column] = column
for row in range(1, rows + 1):
for column in range(1, columns + 1):
cost = 0 if one[row - 1] == other[column - 1] else 1
best = min(
grid[row - 1][column] + 1,
grid[row][column - 1] + 1,
grid[row - 1][column - 1] + cost,
)
# The transposition arm. Guarded on both indices because it
# reaches two cells back in each direction, and the first row
# and column of the grid are the base case rather than real
# comparisons.
if (
row > 1
and column > 1
and one[row - 1] == other[column - 2]
and one[row - 2] == other[column - 1]
):
best = min(best, grid[row - 2][column - 2] + 1)
grid[row][column] = best
return grid[rows][columns]
def budget(length: int) -> int:
"""How many edits a word of this length may be wrong by."""
return SHORT_BUDGET if length <= SHORT else LONG_BUDGET
def nearest(token: str, candidates: Iterable[str]) -> str | None:
"""The candidate a token most likely meant, or None if none is close.
Ties are broken by distance, then by length, then alphabetically. That
ordering is not cosmetic: without it the suggestion would depend on the
iteration order of the registry, and the same typo would get different
answers on different runs.
Args:
token: The folded word somebody typed, including its "!".
candidates: Every folded trigger it could plausibly have meant.
Anything shorter than MIN_CANDIDATE is never offered.
Returns:
The closest candidate within budget, or None.
"""
ranked = sorted(
(distance(token, one), len(one), one) for one in candidates if len(one) >= MIN_CANDIDATE
)
if not ranked or ranked[0][0] > budget(len(token)):
return None
return ranked[0][2]

View File

@ -204,6 +204,25 @@ class CommandRegistry:
"""Every registered command, in registration order."""
return list(self._commands)
def triggers_in(self, group_id: str) -> dict[str, Command]:
"""Every folded trigger live in one group, mapped to its command.
Scoped rather than global, and that is the whole point: it is what
the near-miss search reads, so a command another group owns cannot
be offered as a suggestion here. Offering one would name it, which
is precisely what resolve_with_args refuses to do.
One trigger cannot have two claimants in a single group --
DuplicateTriggerError is raised at registration for exactly the
overlapping case -- so collapsing to a dict loses nothing.
"""
return {
trigger: command
for trigger, claimants in self._by_trigger.items()
for command in claimants
if command.allowed_in(group_id)
}
def _overlap(one: frozenset[str] | None, other: frozenset[str] | None) -> bool:
"""Whether two group scopes can ever both be live for one message.

View File

@ -77,6 +77,27 @@ CREATE TABLE IF NOT EXISTS group_people (
last_seen TEXT NOT NULL,
PRIMARY KEY (group_id, uuid)
);
-- Keyed on the envelope's own sent timestamp, never on the wall clock.
-- signal-cli replays undelivered frames when the bot reconnects; a clock
-- reading is fresh on every replay, so a clock-keyed table collects a
-- duplicate row per reconnect instead of ignoring one. message_events
-- learned this already -- see its ON CONFLICT DO NOTHING.
CREATE TABLE IF NOT EXISTS unknown_commands (
group_id TEXT NOT NULL,
word TEXT NOT NULL,
body TEXT NOT NULL,
sent_at_ms INTEGER NOT NULL,
PRIMARY KEY (group_id, word, sent_at_ms)
);
CREATE INDEX IF NOT EXISTS idx_unknown_group_word
ON unknown_commands (group_id, word);
CREATE TABLE IF NOT EXISTS suggestions (
group_id TEXT NOT NULL,
command TEXT NOT NULL,
body TEXT NOT NULL,
sent_at_ms INTEGER NOT NULL,
PRIMARY KEY (group_id, command, sent_at_ms)
);
"""
# Cleared when the bot leaves a group, and rebuilt on a schema-version bump.
@ -95,5 +116,12 @@ EPHEMERAL_TABLES: Final = (
PERMANENT_TABLES: Final = (
"message_events",
"group_people",
# What the group wished existed. Neither can be recomputed from anything
# -- an idea somebody typed once is gone the moment it is dropped -- and
# forget_group() clears the ephemeral set on the transient group-listing
# gap discovery._permitted guards against, which must not cost a year of
# collected suggestions.
"unknown_commands",
"suggestions",
)
TABLES: Final = EPHEMERAL_TABLES + PERMANENT_TABLES

View File

@ -6,7 +6,9 @@ does not lose the day. The original bot kept all of it in module globals.
The message history itself is reached through ``store.events`` -- see
``signal_bot.stats.events``. It is kept apart because it is the one thing here
that is append-only and never discarded.
that is append-only and never discarded. ``store.suggestions`` is the second
such thing: what the group asked for and what it mistyped, which no amount of
recomputation can bring back once dropped.
"""
from __future__ import annotations
@ -17,6 +19,7 @@ from typing import TYPE_CHECKING, Self
from signal_bot.schema import EPHEMERAL_TABLES, SCHEMA, SCHEMA_VERSION
from signal_bot.stats.events import EventStore
from signal_bot.suggestions import SuggestionStore
if TYPE_CHECKING:
from collections.abc import Iterator
@ -32,6 +35,7 @@ class Store:
self._conn.row_factory = sqlite3.Row
self._apply_schema()
self.events = EventStore(self._conn)
self.suggestions = SuggestionStore(self._conn)
def _apply_schema(self) -> None:
"""Create the tables, rebuilding them when the file predates them.

View File

@ -0,0 +1,86 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""!zaproponuj -- file an idea for a command that does not exist yet.
The other half of what the dispatcher does with an unrecognised word. That
half is involuntary: it records what people mistyped. This one is somebody
deliberately writing a note to whoever maintains the bot, which is why it is
the only text this feature exports into the repository in full.
The idea is stored against the command word it asks for, so twelve people
asking for the same thing collapse into twelve rows under one name rather
than twelve unrelated sentences.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
from signal_bot.animals import load_aliases
from signal_bot.folding import fold
from signal_bot.unknown import SUGGEST_PRIMARY, WORD_SHAPE
if TYPE_CHECKING:
from signal_bot.context import CommandContext
from signal_bot.decorators import Decorator
from signal_bot.registry import CommandRegistry
COMMAND_NAME: Final = "suggest"
DESCRIPTION: Final = "zaproponuj nową komendę, np. !zaproponuj !gekon zdjęcie gekona"
USAGE: Final = f"Użycie: {SUGGEST_PRIMARY} !nazwa <co ma robić>"
NOT_A_NAME: Final = f"To nie wygląda jak nazwa komendy. {USAGE}"
ALREADY: Final = "{word} już działa. Napisz !pomoc, żeby zobaczyć wszystkie komendy."
THANKS: Final = "Zapisane: {word} — {body}"
async def _handle(ctx: CommandContext, registry: CommandRegistry) -> None:
"""Validate one suggestion and file it, or explain why it was not."""
if not ctx.args:
await ctx.reply(USAGE)
return
word = ctx.args[0]
word = fold(word if word.startswith("!") else f"!{word}")
if WORD_SHAPE.match(word) is None:
await ctx.reply(NOT_A_NAME)
return
# Asked without a group, i.e. "does anything claim this anywhere?" --
# the same question pack_add asks before minting a label. A command
# scoped to some other group must not be reported as free here, and
# must not be named either.
if registry.resolve(word) is not None:
await ctx.reply(ALREADY.format(word=word))
return
body = " ".join(ctx.args[1:]).strip()
if not body:
await ctx.reply(USAGE)
return
ctx.store.suggestions.record_suggestion(ctx.group_id, word, body, ctx.message.timestamp)
await ctx.reply(THANKS.format(word=word, body=body))
def register_suggest_command(
registry: CommandRegistry,
*,
decorate: Decorator | None = None,
aliases: dict[str, list[str]] | None = None,
) -> None:
"""Register !zaproponuj on the registry it also reads.
Registered with no ``groups``, and it has to stay that way: help_command
looks its entry up by name for every group it renders, so a scoped
command named in that table raises KeyError wherever it is not allowed.
"""
table = load_aliases() if aliases is None else aliases
async def handler(ctx: CommandContext) -> None:
await _handle(ctx, registry)
wrapped = decorate(handler) if decorate is not None else handler
registry.register(
COMMAND_NAME,
table[COMMAND_NAME],
wrapped,
DESCRIPTION,
takes_args=True,
)

126
signal_bot/suggestions.py Normal file
View File

@ -0,0 +1,126 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""What the group wished the bot could do, kept where git cannot reach it.
Two tables and one deliberate asymmetry between them. ``suggestions`` holds
notes somebody wrote *on purpose*, with !zaproponuj, knowing they were
writing to whoever maintains the bot. ``unknown_commands`` holds every "!"
word the registry did not recognise, body and all, which is a far less
considered kind of text -- it is full of half-typed names, and a misspelled
name is one the personal-data gate cannot know to refuse.
That asymmetry is why this lives in SQLite rather than in a tracked file.
The database is gitignored; the export the management page offers carries
the deliberate notes in full and the unrecognised words as counts only.
Shares the Store's connection, the same way stats.events.EventStore does.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import sqlite3
@dataclass(frozen=True, slots=True)
class Suggestion:
"""One idea somebody filed with !zaproponuj."""
group_id: str
command: str
body: str
sent_at_ms: int
@dataclass(frozen=True, slots=True)
class UnknownTally:
"""One unrecognised word, how often it was tried, and what was typed."""
group_id: str
word: str
hits: int
last_seen: int
bodies: tuple[str, ...]
class SuggestionStore:
"""Reads and writes the wish list. Shares the Store's connection."""
def __init__(self, conn: sqlite3.Connection) -> None:
"""Wrap an open connection whose schema has already been applied."""
self._conn = conn
def record_unknown(self, group_id: str, word: str, body: str, sent_at_ms: int) -> None:
"""Note one unrecognised "!" word, ignoring a redelivery of it.
``OR IGNORE`` against the envelope's own timestamp, not the wall
clock. signal-cli replays undelivered frames when the bot
reconnects; keyed on a clock reading every replay looks new, and
without OR IGNORE a genuine collision raises IntegrityError out of
the dispatcher, where there is nothing above it to catch one.
"""
self._conn.execute(
"INSERT OR IGNORE INTO unknown_commands"
" (group_id, word, body, sent_at_ms) VALUES (?, ?, ?, ?)",
(group_id, word, body, sent_at_ms),
)
self._conn.commit()
def record_suggestion(self, group_id: str, command: str, body: str, sent_at_ms: int) -> None:
"""File one idea against the command name it is asking for."""
self._conn.execute(
"INSERT OR IGNORE INTO suggestions"
" (group_id, command, body, sent_at_ms) VALUES (?, ?, ?, ?)",
(group_id, command, body, sent_at_ms),
)
self._conn.commit()
def filed(self) -> list[Suggestion]:
"""Every filed suggestion, newest first."""
rows = self._conn.execute(
"SELECT group_id, command, body, sent_at_ms FROM suggestions"
" ORDER BY sent_at_ms DESC, command ASC"
)
return [
Suggestion(
group_id=str(row["group_id"]),
command=str(row["command"]),
body=str(row["body"]),
sent_at_ms=int(row["sent_at_ms"]),
)
for row in rows
]
def unknown(self) -> list[UnknownTally]:
"""Every unrecognised word, most-tried first, with what was typed.
Two passes rather than one grouped query with ``group_concat``: a
body may itself contain the separator, and a tally that silently
splits somebody's sentence in half is worse than a second SELECT.
"""
tallies = self._conn.execute(
"SELECT group_id, word, COUNT(*) AS hits, MAX(sent_at_ms) AS last_seen"
" FROM unknown_commands GROUP BY group_id, word"
" ORDER BY hits DESC, last_seen DESC, word ASC"
).fetchall()
return [
UnknownTally(
group_id=str(row["group_id"]),
word=str(row["word"]),
hits=int(row["hits"]),
last_seen=int(row["last_seen"]),
bodies=self._bodies(str(row["group_id"]), str(row["word"])),
)
for row in tallies
]
def _bodies(self, group_id: str, word: str) -> tuple[str, ...]:
"""The distinct messages that produced one unrecognised word."""
rows = self._conn.execute(
"SELECT DISTINCT body FROM unknown_commands"
" WHERE group_id = ? AND word = ? ORDER BY body ASC",
(group_id, word),
)
return tuple(str(row["body"]) for row in rows)

134
signal_bot/unknown.py Normal file
View File

@ -0,0 +1,134 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""What to do with a "!" word the registry does not recognise.
Until now the answer was "nothing at all", which conflated three unrelated
situations the dispatcher could not tell apart: a word nobody registered, a
word registered to some *other* group, and a real trigger with junk after it.
The first two deserve an answer. The third is "!kot ładny", which has always
been silent and stays silent -- a bot that pipes up every time somebody adds
a word to a working command is a bot people mute.
Silence is decided by the *global* question -- does anything, in any group,
claim this word -- and the near-miss search then reads only the triggers live
in the asking group. Both halves are needed. The first keeps the album's
triggers, which are real people's names, from being echoed or recorded in a
group that has no album; the second keeps them out of a suggestion.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
from signal_bot.folding import fold
from signal_bot.nearest import nearest
from signal_bot.quoting import quote_of
if TYPE_CHECKING:
from collections.abc import Mapping
from signal_bot.models import IncomingMessage
from signal_bot.registry import Command, CommandRegistry
from signal_bot.store import Store
from signal_bot.transport_base import Transport
logger = logging.getLogger(__name__)
# What may count as a mistyped command. Anything else -- a bare "!", "!!",
# "!?", an emoji, a pasted paragraph -- is ordinary punctuation and is
# ignored entirely: no reply and no row.
#
# Digits are in the class on purpose. fold() maps 0134578 onto letters but
# leaves 2, 6 and 9 alone, so "!rok2" really does arrive with a digit in it,
# and a letters-only guard would swallow a perfectly plausible typo.
WORD_SHAPE: Final = re.compile(r"^![a-z0-9_]{2,32}$")
# The spelling every one of these messages points people at. It lives here
# rather than in suggest_command because that module needs WORD_SHAPE from
# this one, and two modules importing each other is not a seam. A test
# pins it against aliases.json so the two cannot drift.
SUGGEST_PRIMARY: Final = "!zaproponuj"
DID_YOU_MEAN: Final = "Nie znam {word}. Chodziło o {primary}?"
NO_MATCH: Final = "Nie znam {word}. Napisz {hint} {word} <co ma robić>, a zapiszę pomysł."
@dataclass(frozen=True, slots=True)
class Unknown:
"""A "!" word nothing claims here, and the message that carried it."""
word: str
body: str
def unknown_in(text: str | None, registry: CommandRegistry) -> Unknown | None:
"""Read an unrecognised command out of a message, if there is one.
Args:
text: The raw message body.
registry: The command registry, asked the *global* question.
Returns:
The unrecognised word and the message it came in, or None when the
message is not a command attempt at all.
"""
if not text:
return None
words = text.split()
if not words:
return None
word = fold(words[0])
if WORD_SHAPE.match(word) is None:
return None
# Claimed anywhere at all, not merely here -- ``resolve`` with no group
# is the same question pack_add asks before minting a label. This one
# check covers two very different cases:
#
# "!kot ładny" a real trigger with words after it. resolve_with_args
# already decided that means nothing for a command
# taking no arguments, and it is not this file's to
# revisit.
# "!alfa" an album command, in a group that has no album. The
# album's triggers are real people's names, and
# pack_scoping says it out loud: "a refusal would itself
# announce that the album exists". Answering here would
# announce it, and recording the row would put the name
# in the database and on the management page.
if registry.resolve(word) is not None:
return None
return Unknown(word=word, body=text)
def reply_for(word: str, live: Mapping[str, Command]) -> str:
"""What to say about one unrecognised word.
The suggestion prints ``Command.primary`` -- the same spelling !pomoc
shows -- rather than the folded trigger it matched, which is a skeleton
with the diacritics stripped off and reads as a second typo.
"""
hit = nearest(word, live)
if hit is None:
return NO_MATCH.format(word=word, hint=SUGGEST_PRIMARY)
return DID_YOU_MEAN.format(word=word, primary=live[hit].primary)
async def answer_unknown(
*,
message: IncomingMessage,
group_id: str,
registry: CommandRegistry,
store: Store,
transport: Transport,
) -> None:
"""Remember one unrecognised command and answer it, or do neither."""
unknown = unknown_in(message.text, registry)
if unknown is None:
return
live = registry.triggers_in(group_id)
# No word and no body in the log line. A misspelled name is still a
# name, log lines reach CI output, and the database is the record.
logger.debug("unrecognised command in %s", group_id)
store.suggestions.record_unknown(group_id, unknown.word, unknown.body, message.timestamp)
await transport.send_text(reply_for(unknown.word, live), group_id, quote_of(message))

View File

@ -106,6 +106,13 @@ def deps_at(
env_path=env_path,
dist_dir=dist,
album=album_at(tmp_path / "pack"),
# Both under tmp_path, and neither has a default. Config's own
# database_path is the *relative* "signal_bot.sqlite3" and pytest
# runs from the repo root, so a defaulted field here would point
# the suite at the operator's real database -- which Store would
# then open and, on a version mismatch, partly drop.
database_path=tmp_path / "signal_bot.sqlite3",
export_path=tmp_path / "export" / "DOCS-suggestions.md",
client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
port=PORT,
)

View File

@ -0,0 +1,121 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The dispatcher branch that used to be a bare `return`.
Split out of test_dispatcher.py at the 250-line cap. The seam is real: that
file is "does the right command run", this one is "what happens when none
does".
"""
from __future__ import annotations
import datetime as dt
from typing import TYPE_CHECKING
import pytest
from signal_bot.context import CommandContext
from signal_bot.discovery import GroupDirectory
from signal_bot.dispatcher import Dispatcher
from signal_bot.registry import CommandRegistry
from tests.conftest import WATCHED_GROUP, envelope
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.context import CommandContext
from signal_bot.discovery import GroupDirectory
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
NOW = dt.datetime(2026, 8, 31, 12, 0, tzinfo=dt.UTC)
@pytest.fixture
def registry() -> CommandRegistry:
"""One command, so "!cta" has something to be a near miss for."""
async def handler(ctx: CommandContext) -> None:
"""Answer nothing; only the routing is under test here."""
reg = CommandRegistry()
reg.register("cat", ["!cat"], handler, "cat description")
return reg
@pytest.fixture
def dispatcher(
config: Config,
directory: GroupDirectory,
registry: CommandRegistry,
store: Store,
transport: FakeTransport,
) -> Dispatcher:
"""The real dispatcher, wired the way build_bot wires it."""
return Dispatcher(
config=config,
directory=directory,
registry=registry,
store=store,
transport=transport,
clock=lambda: NOW,
)
async def test_an_unrecognised_command_is_answered_and_remembered(
dispatcher: Dispatcher, transport: FakeTransport, store: Store
) -> None:
"""The branch that used to be a bare `return`."""
await dispatcher.handle(envelope(text="!cta", timestamp=808))
assert "!cat" in (transport.sent[-1].text or "")
(row,) = store.suggestions.unknown()
assert row.word == "!cta"
async def test_an_unrecognised_command_is_still_not_counted_as_a_command(
dispatcher: Dispatcher, store: Store
) -> None:
"""The bot saying something about a word does not make the word a command."""
await dispatcher.handle(envelope(text="!cta"))
row = store.events.connection.execute(
"SELECT is_command FROM message_events WHERE group_id = ?", (WATCHED_GROUP,)
).fetchone()
assert int(row["is_command"]) == 0
async def test_the_bot_never_answers_its_own_message(
dispatcher: Dispatcher, transport: FakeTransport, store: Store
) -> None:
"""The loop guard, tested with the worst case rather than a polite one.
handle() checks is_own nowhere else, so an outbound frame carrying a
word is what would loop -- uncapped, because unknown commands are
answered every time by design.
"""
await dispatcher.handle(envelope(text="!cta", sync=True))
assert transport.sent == []
assert store.suggestions.unknown() == []
async def test_a_reaction_is_not_a_mistyped_command(
dispatcher: Dispatcher, transport: FakeTransport
) -> None:
"""should_count gates the branch, and it excludes annotations."""
await dispatcher.handle(envelope(text="!cta", extra="reaction"))
assert transport.sent == []
async def test_a_failure_while_answering_does_not_end_the_receive_loop(
dispatcher: Dispatcher, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Bot.listen has no try/except of its own.
This branch runs above the one that wraps a handler, so an unguarded
raise here would end the websocket consumer and the bot would go
silently deaf until the watchdog restarted it.
"""
async def explode(**_kwargs: object) -> None:
msg = "the container went away"
raise RuntimeError(msg)
monkeypatch.setattr("signal_bot.dispatcher.answer_unknown", explode)
await dispatcher.handle(envelope(text="!cta"))

View File

@ -32,6 +32,7 @@ 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 tests import pack_fixture
from tests.conftest import SECOND_GROUP, WATCHED_GROUP, envelope
@ -64,6 +65,7 @@ def full_registry() -> CommandRegistry:
register_animal_commands(registry, fetcher)
register_pope_command(registry, PortraitCache(fetcher, DEFAULT_COMMONS_API_URL))
register_stats_commands(registry)
register_suggest_command(registry)
register_help_command(registry)
return registry

135
tests/test_nearest.py Normal file
View File

@ -0,0 +1,135 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Edit distance, and the two rules that keep it from suggesting nonsense."""
from __future__ import annotations
import json
import pytest
from signal_bot.animals import ALIAS_FILE
from signal_bot.folding import fold
from signal_bot.nearest import LONG_BUDGET, MIN_CANDIDATE, SHORT_BUDGET, budget, distance, nearest
def live_triggers() -> dict[str, str]:
"""Every folded trigger the shipped aliases.json defines, to its command."""
document = json.loads(ALIAS_FILE.read_text(encoding="utf-8"))
return {
fold(trigger): name
for name, triggers in document.items()
if not name.startswith("_")
for trigger in triggers
}
@pytest.mark.parametrize(
("one", "other", "expected"),
[
("", "", 0),
("", "abc", 3),
("abc", "", 3),
("abc", "abc", 0),
("abc", "abd", 1),
("abc", "ab", 1),
("ab", "abc", 1),
# The whole reason this is not difflib: a swap is one edit.
("!cta", "!cat", 1),
("!ab", "!ba", 1),
# Adjacent only. "abc" -> "cba" is not one transposition.
("abc", "cba", 2),
],
)
def test_distance(one: str, other: str, expected: int) -> None:
"""Insertion, deletion, substitution and the adjacent swap."""
assert distance(one, other) == expected
def test_distance_is_symmetric() -> None:
"""Nothing downstream relies on argument order, so nothing may depend on it."""
assert distance("!gekno", "!gekon") == distance("!gekon", "!gekno")
def test_the_transposition_arm_is_not_taken_in_the_first_column() -> None:
"""The guard on both indices, reached with a one-character word.
The arm looks two cells back in each direction; without the guard this
is an IndexError rather than a distance.
"""
assert distance("a", "ba") == 1
def test_budget_is_tighter_for_short_words() -> None:
"""A three-letter word has no room to be wrong twice."""
assert budget(5) == SHORT_BUDGET
assert budget(6) == LONG_BUDGET
@pytest.mark.parametrize(
("typed", "expected"),
[
("!cta", "cat"),
("!kto", "cat"),
("!gekno", "gecko"),
("!papiesz", "pope"),
("!stat", "stats_today"),
("!rok2", "stats_year"),
("!korna", "stats_crown"),
],
)
def test_real_typos_of_real_commands_resolve(typed: str, expected: str) -> None:
"""Computed against the shipped alias table, not against a synthetic one."""
hit = nearest(fold(typed), live_triggers())
assert hit is not None
assert live_triggers()[hit] == expected
@pytest.mark.parametrize("typed", ["!xyz", "!zzzzzz", "!nosuchcommand"])
def test_a_word_meaning_nothing_suggests_nothing(typed: str) -> None:
"""Past the budget is silence, not a confident wrong answer."""
assert nearest(fold(typed), live_triggers()) is None
def test_nothing_registered_suggests_nothing() -> None:
"""The empty registry, which is what a bot with no commands has."""
assert nearest("!cta", []) is None
def test_exactly_at_the_budget_is_offered_and_one_past_it_is_not() -> None:
"""The boundary, pinned from both sides."""
assert nearest("!aaab", ["!aaaa"]) == "!aaaa"
assert nearest("!aabb", ["!aaaa"]) is None
# Six characters buys the second edit, and only the second.
assert nearest("!aaaabb", ["!aaaaaa"]) == "!aaaaaa"
assert nearest("!aaabbb", ["!aaaaaa"]) is None
def test_a_candidate_shorter_than_the_minimum_is_never_offered() -> None:
"""The !ja magnet.
"!ja" is one edit from roughly forty two-letter words. Without this rule
the bot answers "chodziło o !ja?" to !ba, !ca, !da and so on.
"""
assert len("!ja") < MIN_CANDIDATE
assert nearest("!ba", ["!ja"]) is None
assert nearest("!jo", live_triggers()) is None
def test_ties_break_on_length_then_alphabetically() -> None:
"""Otherwise the answer depends on registry iteration order."""
assert nearest("!aaaa", ["!aaaaa", "!aaab"]) == "!aaab"
assert nearest("!aaaa", ["!baaa", "!aaab"]) == "!aaab"
def test_no_two_commands_are_within_reach_of_each_other() -> None:
"""No alias of one command may be a near miss for a different command.
If two ever collide the suggestion becomes a coin toss, and the tie-break
that decides it is alphabetical -- i.e. meaningless to the person who
mistyped. This fails when a newly added alias creates that ambiguity.
"""
triggers = live_triggers()
for typed, owner in triggers.items():
hit = nearest(typed, {t: n for t, n in triggers.items() if t != typed})
if hit is not None:
assert triggers[hit] == owner, f"{typed} ({owner}) is a near miss for {hit}"

View File

@ -136,3 +136,25 @@ def test_nothing_resolves_out_of_an_empty_message() -> None:
assert registry.resolve_with_args(None, "g==") is None
assert registry.resolve_with_args(" ", "g==") is None
assert registry.resolve_with_args("!nobody has this", "g==") is None
def test_triggers_in_returns_only_what_this_group_actually_has() -> None:
"""What the near-miss search reads.
Scoped rather than global, and that is the whole point: a command
another group owns must not be offerable as a suggestion here, because
offering it would name it.
"""
registry = CommandRegistry()
everywhere = registry.register("cat", ["!kot", "!cat"], _noop, "kot")
scoped = registry.register("pack:p1", ["!alfa"], _noop, "zdjęcia", groups=frozenset({"g1"}))
here = registry.triggers_in("g1")
assert here == {"!kot": everywhere, "!cat": everywhere, "!alfa": scoped}
assert registry.triggers_in("g2") == {"!kot": everywhere, "!cat": everywhere}
def test_triggers_in_maps_a_folded_trigger_to_its_command() -> None:
"""Folded, so both sides of a comparison went through the same function."""
registry = CommandRegistry()
registry.register("pope", ["!papież"], _noop, "papież")
assert set(registry.triggers_in("g1")) == {"!papiez"}

View File

@ -0,0 +1,151 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The management page's read of the wish list, and the export it writes."""
from __future__ import annotations
import dataclasses
import sqlite3
from pathlib import Path
from typing import TYPE_CHECKING
from signal_bot.manage.routes_suggestions import (
EXPORT_BODY_LIMIT,
EXPORT_NAME,
default_export_path,
)
from signal_bot.store import Store
from tests.manage_fixture import client_for, deps_at
if TYPE_CHECKING:
import pytest
from signal_bot.manage.deps import ManageDeps
def seeded(tmp_path: Path) -> ManageDeps:
"""A management app pointed at a database with one of each row in it."""
deps = deps_at(tmp_path)
with Store(str(deps.database_path)) as store:
store.suggestions.record_suggestion("g1", "!pogoda", "pokaz pogode", 101)
store.suggestions.record_unknown("g1", "!cta", "!cta", 200)
store.suggestions.record_unknown("g1", "!cta", "!cta teraz", 201)
return deps
def test_the_fixture_never_points_at_the_real_database(tmp_path: Path) -> None:
"""Config.database_path is the *relative* "signal_bot.sqlite3".
pytest runs from the repository root, where the operator's real database
sits, and Store drops the ephemeral tables on a version mismatch. A
defaulted field on ManageDeps would therefore have let the suite delete
live cooldowns and announce state -- so the field has no default, and
this asserts the fixture supplies a temporary one.
"""
deps = deps_at(tmp_path)
assert deps.database_path.is_relative_to(tmp_path)
assert deps.export_path.is_relative_to(tmp_path)
def test_a_bot_that_has_never_run_is_not_an_error(tmp_path: Path) -> None:
"""No database yet means nothing collected, not a 500."""
deps = deps_at(tmp_path)
assert not deps.database_path.exists()
body = client_for(deps).get("/api/suggestions").json()
assert body == {"suggestions": [], "unknown": []}
def test_a_database_older_than_these_tables_is_not_an_error(tmp_path: Path) -> None:
"""Upgrading the bot must not make the page fail until it restarts."""
deps = deps_at(tmp_path)
sqlite3.connect(deps.database_path).close()
body = client_for(deps).get("/api/suggestions").json()
assert body == {"suggestions": [], "unknown": []}
def test_a_directory_where_the_database_should_be_is_not_an_error(tmp_path: Path) -> None:
"""is_file, not exists: a path that is not a file is still "nothing here"."""
deps = deps_at(tmp_path)
deps.database_path.mkdir()
assert client_for(deps).get("/api/suggestions").json()["unknown"] == []
def test_an_unreadable_database_is_not_an_error(tmp_path: Path) -> None:
"""Whatever is at the path, the page still renders."""
deps = deps_at(tmp_path)
deps.database_path.write_text("not a database", encoding="utf-8")
assert client_for(deps).get("/api/suggestions").json()["suggestions"] == []
def test_the_page_shows_ideas_and_tallies(tmp_path: Path) -> None:
"""Bodies included, because this response never leaves the loopback port."""
body = client_for(seeded(tmp_path)).get("/api/suggestions").json()
assert body["suggestions"][0]["command"] == "!pogoda"
assert body["suggestions"][0]["body"] == "pokaz pogode"
assert body["unknown"][0] == {
"bodies": ["!cta", "!cta teraz"],
"group_id": "g1",
"hits": 2,
"last_seen": 201,
"word": "!cta",
}
def test_the_export_keeps_notes_but_reduces_mistyped_words_to_counts(
tmp_path: Path,
) -> None:
"""The asymmetry this whole feature is arranged around.
A !zaproponuj body was written on purpose, to the maintainer. An
unrecognised body is half-typed chat, and a half-typed name is one the
personal-data gate cannot recognise -- so it becomes a number.
"""
deps = seeded(tmp_path)
answer = client_for(deps).post("/api/suggestions/export", json={}).json()
assert answer == {"path": str(deps.export_path), "suggestions": 1, "unknown": 1}
written = deps.export_path.read_text(encoding="utf-8")
assert "pokaz pogode" in written
assert "`!cta` x 2" in written
assert "!cta teraz" not in written
def test_the_export_says_so_when_there_is_nothing_to_say(tmp_path: Path) -> None:
"""An empty file that reads as empty, rather than two bare headings."""
deps = deps_at(tmp_path)
client_for(deps).post("/api/suggestions/export", json={})
assert deps.export_path.read_text(encoding="utf-8").count("_Nothing yet._") == 2
def test_a_very_long_body_is_truncated_in_the_export(tmp_path: Path) -> None:
"""One pasted paragraph must not bury the rest of the file."""
deps = deps_at(tmp_path)
with Store(str(deps.database_path)) as store:
store.suggestions.record_suggestion("g1", "!x", "a" * 500, 1)
client_for(deps).post("/api/suggestions/export", json={})
written = deps.export_path.read_text(encoding="utf-8")
assert "a" * EXPORT_BODY_LIMIT in written
assert "a" * (EXPORT_BODY_LIMIT + 1) not in written
def test_the_default_export_path_is_never_inside_the_repository(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A group-writable file in a public git tree is a leak and a wedge.
The personal-data gate would refuse every future commit once a real name
landed in it, and it cannot recognise the misspelled ones this file is
full of. So the export goes where the album goes.
"""
monkeypatch.setenv("STATE_DIRECTORY", str(tmp_path / "state"))
assert default_export_path() == tmp_path / "state" / EXPORT_NAME
monkeypatch.delenv("STATE_DIRECTORY")
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))
assert default_export_path() == tmp_path / "share" / "signal-bot" / EXPORT_NAME
monkeypatch.delenv("XDG_DATA_HOME")
assert default_export_path().is_relative_to(Path.home())
def test_the_export_does_not_need_its_directory_to_exist(tmp_path: Path) -> None:
"""First run on a fresh install, where the state directory is still empty."""
deps = dataclasses.replace(deps_at(tmp_path), export_path=tmp_path / "a" / "b" / EXPORT_NAME)
client_for(deps).post("/api/suggestions/export", json={})
assert (tmp_path / "a" / "b" / EXPORT_NAME).is_file()

View File

@ -0,0 +1,158 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""!zaproponuj: filing an idea from inside the group."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from signal_bot.animals import ALIAS_FILE, load_aliases
from signal_bot.context import CommandContext
from signal_bot.models import parse_envelope
from signal_bot.registry import CommandRegistry
from signal_bot.suggest_command import (
ALREADY,
COMMAND_NAME,
USAGE,
register_suggest_command,
)
from signal_bot.unknown import SUGGEST_PRIMARY
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
async def _noop(ctx: CommandContext) -> None:
"""A handler that does nothing."""
def test_the_pinned_spelling_is_the_one_aliases_json_actually_lists() -> None:
"""unknown.py quotes SUGGEST_PRIMARY without reading the file.
That is the trade -- no file read on the inbound path -- and this is the
check that stops the constant and the data drifting apart.
"""
assert load_aliases()[COMMAND_NAME][0] == SUGGEST_PRIMARY
def test_the_command_is_never_scoped_to_a_group() -> None:
"""help_command.GROUPS names it, and render_help looks every name up.
A scoped command in that table raises KeyError in every group it is not
allowed in, which would take !pomoc down rather than hide one line.
"""
registry = CommandRegistry()
register_suggest_command(registry)
(command,) = [c for c in registry.commands if c.name == COMMAND_NAME]
assert command.groups is None
assert command.takes_args
def test_every_alias_is_a_plausible_command_word_and_not_a_name() -> None:
"""_repo_vocabulary() subtracts these from the personal-data gate.
Every trigger listed here is permanently exempt from that half of the
check, so a name-shaped one would punch a hole in it.
"""
document = json.loads(ALIAS_FILE.read_text(encoding="utf-8"))
for trigger in document[COMMAND_NAME]:
assert trigger.startswith("!")
assert trigger[1:].isalpha()
async def _run(
registry: CommandRegistry,
store: Store,
transport: FakeTransport,
config: Config,
text: str,
) -> str:
"""Run !zaproponuj on one message and return what it answered."""
message = parse_envelope(envelope(text=text, timestamp=77))
assert message is not None
resolved = registry.resolve_with_args(text, WATCHED_GROUP)
assert resolved is not None
ctx = CommandContext(
message=message,
group_id=WATCHED_GROUP,
transport=transport,
store=store,
config=config,
command_name=resolved.command.name,
now=0.0,
day="2026-08-31",
args=resolved.args,
)
await resolved.command.handler(ctx)
return transport.sent[-1].text or ""
def _registry() -> CommandRegistry:
"""!zaproponuj, plus one existing command to collide with."""
registry = CommandRegistry()
registry.register("cat", ["!kot"], _noop, "kot")
register_suggest_command(registry)
return registry
async def test_no_arguments_gets_the_usage_line(
store: Store, transport: FakeTransport, config: Config
) -> None:
"""Somebody who typed the trigger alone is asking how it works."""
assert await _run(_registry(), store, transport, config, SUGGEST_PRIMARY) == USAGE
assert store.suggestions.filed() == []
async def test_a_name_without_a_description_gets_the_usage_line(
store: Store, transport: FakeTransport, config: Config
) -> None:
"""A bare word is not an idea; there would be nothing to act on."""
text = f"{SUGGEST_PRIMARY} !pogoda"
assert await _run(_registry(), store, transport, config, text) == USAGE
assert store.suggestions.filed() == []
async def test_something_that_is_not_a_command_word_is_refused(
store: Store, transport: FakeTransport, config: Config
) -> None:
"""And is refused without echoing it: it may well be somebody's name."""
text = f"{SUGGEST_PRIMARY} !! cokolwiek"
answer = await _run(_registry(), store, transport, config, text)
assert USAGE in answer
assert store.suggestions.filed() == []
async def test_an_existing_command_is_not_filed_again(
store: Store, transport: FakeTransport, config: Config
) -> None:
"""Asked globally, so a command scoped elsewhere is not reported free."""
text = f"{SUGGEST_PRIMARY} !kot cos innego"
answer = await _run(_registry(), store, transport, config, text)
assert answer == ALREADY.format(word="!kot")
assert store.suggestions.filed() == []
async def test_a_new_idea_is_filed_and_acknowledged(
store: Store, transport: FakeTransport, config: Config
) -> None:
"""The whole point: the row lands, keyed on the envelope's timestamp."""
text = f"{SUGGEST_PRIMARY} !pogoda pokaz pogode na jutro"
answer = await _run(_registry(), store, transport, config, text)
assert "!pogoda" in answer
(row,) = store.suggestions.filed()
assert row.command == "!pogoda"
assert row.body == "pokaz pogode na jutro"
assert row.sent_at_ms == 77
async def test_the_leading_bang_is_optional(
store: Store, transport: FakeTransport, config: Config
) -> None:
"""Half the group will type the name without it."""
text = f"{SUGGEST_PRIMARY} pogoda pokaz pogode"
await _run(_registry(), store, transport, config, text)
(row,) = store.suggestions.filed()
assert row.command == "!pogoda"

View File

@ -0,0 +1,90 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The wish-list tables: what they keep, and what a redelivery does to them."""
from __future__ import annotations
from typing import TYPE_CHECKING
from signal_bot.schema import EPHEMERAL_TABLES, PERMANENT_TABLES
if TYPE_CHECKING:
from signal_bot.store import Store
def test_nothing_collected_reads_as_nothing(store: Store) -> None:
"""Empty is a state, not an absence of tables."""
assert store.suggestions.filed() == []
assert store.suggestions.unknown() == []
def test_a_replayed_frame_does_not_become_a_second_row(store: Store) -> None:
"""signal-cli replays undelivered frames when the bot reconnects.
Keyed on the envelope's own timestamp rather than the wall clock, so a
replay collides with the row it already wrote and is ignored.
"""
for _ in range(3):
store.suggestions.record_unknown("g1", "!cta", "!cta", 500)
(row,) = store.suggestions.unknown()
assert row.hits == 1
def test_the_same_word_typed_again_later_counts_twice(store: Store) -> None:
"""A different message is a different row, which is what a tally is for."""
store.suggestions.record_unknown("g1", "!cta", "!cta", 500)
store.suggestions.record_unknown("g1", "!cta", "!cta znowu", 600)
(row,) = store.suggestions.unknown()
assert row.hits == 2
assert row.last_seen == 600
assert row.bodies == ("!cta", "!cta znowu")
def test_two_groups_never_share_a_tally(store: Store) -> None:
"""Every table in this schema is keyed by group first, and so is this one."""
store.suggestions.record_unknown("g1", "!cta", "!cta", 1)
store.suggestions.record_unknown("g2", "!cta", "!cta", 1)
assert {row.group_id for row in store.suggestions.unknown()} == {"g1", "g2"}
def test_tallies_come_back_most_tried_first(store: Store) -> None:
"""The page is a queue of work, so the loudest request sorts to the top."""
store.suggestions.record_unknown("g1", "!rare", "!rare", 1)
store.suggestions.record_unknown("g1", "!often", "!often", 2)
store.suggestions.record_unknown("g1", "!often", "!often", 3)
assert [row.word for row in store.suggestions.unknown()] == ["!often", "!rare"]
def test_suggestions_come_back_newest_first(store: Store) -> None:
"""The newest idea is the one most likely still worth acting on."""
store.suggestions.record_suggestion("g1", "!old", "stara", 1)
store.suggestions.record_suggestion("g1", "!new", "nowa", 2)
assert [row.command for row in store.suggestions.filed()] == ["!new", "!old"]
def test_two_people_can_ask_for_the_same_command(store: Store) -> None:
"""Different messages, so different rows -- twelve askers are twelve rows."""
store.suggestions.record_suggestion("g1", "!pogoda", "na jutro", 1)
store.suggestions.record_suggestion("g1", "!pogoda", "na tydzień", 2)
assert len(store.suggestions.filed()) == 2
def test_leaving_a_group_does_not_discard_the_wish_list(store: Store) -> None:
"""forget_group clears the ephemeral tables, and these are not among them.
A transient group listing that omits a member is the exact failure
discovery._permitted guards against, and it must not be able to delete
ideas nothing can recompute.
"""
store.suggestions.record_suggestion("g1", "!pogoda", "na jutro", 1)
store.suggestions.record_unknown("g1", "!cta", "!cta", 2)
store.forget_group("g1")
assert len(store.suggestions.filed()) == 1
assert len(store.suggestions.unknown()) == 1
def test_both_tables_are_declared_permanent() -> None:
"""The property above, asserted where it is actually configured."""
assert "suggestions" in PERMANENT_TABLES
assert "unknown_commands" in PERMANENT_TABLES
assert "suggestions" not in EPHEMERAL_TABLES
assert "unknown_commands" not in EPHEMERAL_TABLES

177
tests/test_unknown.py Normal file
View File

@ -0,0 +1,177 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""What the bot says about a "!" word it does not know, and what it keeps."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from signal_bot.models import parse_envelope
from signal_bot.registry import CommandRegistry
from signal_bot.unknown import (
SUGGEST_PRIMARY,
answer_unknown,
reply_for,
unknown_in,
)
from tests.conftest import SECOND_GROUP, WATCHED_GROUP, envelope
if TYPE_CHECKING:
from signal_bot.context import CommandContext
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
async def _noop(ctx: CommandContext) -> None:
"""A handler that does nothing."""
def loaded() -> CommandRegistry:
"""A registry with one global command and one scoped to a single group."""
registry = CommandRegistry()
registry.register("cat", ["!kot", "!cat"], _noop, "kot")
# The album's shape: scoped, and named after a person.
registry.register("pack:p1", ["!alfa"], _noop, "zdjęcia", groups=frozenset({WATCHED_GROUP}))
return registry
@pytest.mark.parametrize("text", [None, "", " ", "just chatting", "hej! super"])
def test_a_message_that_is_not_a_command_attempt_is_not_one(text: str | None) -> None:
"""Nothing without a leading "!" word reaches the rest of this module."""
assert unknown_in(text, loaded()) is None
@pytest.mark.parametrize("text", ["!", "!!", "!?", "!...", "!😀", "!x"])
def test_punctuation_shaped_like_a_command_is_ignored(text: str) -> None:
"""No reply and no row: this is ordinary typing, not a mistyped command."""
assert unknown_in(text, loaded()) is None
def test_a_real_trigger_with_words_after_it_stays_silent() -> None:
""" "!kot ładny" has always been silent and this feature does not change it."""
assert unknown_in("!kot ładny", loaded()) is None
def test_a_command_scoped_to_another_group_is_never_reported() -> None:
"""The album's privacy guarantee.
"!alfa" is a person's name. In a group with no album it is unclaimed
*there*, so a check scoped to the asking group would answer it and store
it -- announcing the album and putting the name in the database. The
check is global, so this is silence.
"""
assert unknown_in("!alfa", loaded()) is None
def test_an_unclaimed_word_is_reported_with_the_whole_message() -> None:
"""The word is folded; the body is kept exactly as it was typed."""
found = unknown_in("!CTA teraz proszę", loaded())
assert found is not None
assert found.word == "!cta"
assert found.body == "!CTA teraz proszę"
def test_a_styled_or_cyrillic_spelling_folds_before_anything_else() -> None:
"""The same folding the registry does, so both sides can only agree."""
found = unknown_in("!ᴄᴛᴀ", loaded())
assert found is not None
assert found.word == "!cta"
def test_a_digit_survives_folding_and_is_still_recorded() -> None:
"""fold() maps 0134578 onto letters but leaves 2, 6 and 9 alone.
A letters-only shape guard would silently drop "!rok2", which is exactly
the kind of request this feature exists to collect.
"""
found = unknown_in("!rok2", loaded())
assert found is not None
assert found.word == "!rok2"
def test_a_near_miss_is_answered_with_the_polish_spelling() -> None:
"""`triggers` are folded skeletons; only `primary` still reads correctly."""
registry = CommandRegistry()
registry.register("pope", ["!papież", "!pope"], _noop, "papież")
live = registry.triggers_in(WATCHED_GROUP)
assert "!papież" in reply_for("!papiesz", live)
assert "!papiez?" not in reply_for("!papiesz", live)
def test_a_word_with_no_near_miss_points_at_the_suggestion_command() -> None:
"""Otherwise there is nothing for the person to do with the refusal."""
text = reply_for("!nosuchcommand", loaded().triggers_in(WATCHED_GROUP))
assert SUGGEST_PRIMARY in text
assert "!nosuchcommand" in text
def test_a_suggestion_never_names_a_command_from_another_group() -> None:
""" "!alfaa" must not come back as "chodziło o !alfa?" where there is no album.
Asserted on the correction clause rather than on the bare name, because
the refusal quotes the word that was typed and "!alfa" is a substring of
"!alfaa" -- a naive check passes for the wrong reason.
"""
registry = loaded()
live = registry.triggers_in(SECOND_GROUP)
assert "!alfa" not in live
answer = reply_for("!alfaa", live)
assert "Chodziło" not in answer
assert SUGGEST_PRIMARY in answer
# ...and the album's own group is where the correction is allowed.
assert "Chodziło" in reply_for("!alfaa", registry.triggers_in(WATCHED_GROUP))
async def test_answering_records_the_token_and_the_body(
store: Store, transport: FakeTransport
) -> None:
"""One row, one reply, keyed on the envelope's own timestamp."""
message = parse_envelope(envelope(text="!cta", timestamp=4242))
assert message is not None
await answer_unknown(
message=message,
group_id=WATCHED_GROUP,
registry=loaded(),
store=store,
transport=transport,
)
assert len(transport.sent) == 1
assert "!kot" in (transport.sent[0].text or "")
(row,) = store.suggestions.unknown()
assert row.word == "!cta"
assert row.bodies == ("!cta",)
assert row.last_seen == 4242
async def test_answering_quotes_the_person_who_asked(
store: Store, transport: FakeTransport
) -> None:
"""A busy group has to be able to tell which "!cta" this is about."""
message = parse_envelope(envelope(text="!cta"))
assert message is not None
await answer_unknown(
message=message,
group_id=WATCHED_GROUP,
registry=loaded(),
store=store,
transport=transport,
)
assert transport.sent[0].quote is not None
async def test_a_message_that_is_not_an_attempt_writes_nothing(
store: Store, transport: FakeTransport
) -> None:
"""The early return, from the caller's side."""
message = parse_envelope(envelope(text="just chatting"))
assert message is not None
await answer_unknown(
message=message,
group_id=WATCHED_GROUP,
registry=loaded(),
store=store,
transport=transport,
)
assert transport.sent == []
assert store.suggestions.unknown() == []

View File

@ -43,6 +43,7 @@ import uvicorn
from signal_bot.config_env import load_config
from signal_bot.manage.app import build_app
from signal_bot.manage.deps import ManageDeps
from signal_bot.manage.routes_suggestions import default_export_path
from signal_bot.media_strip import ExiftoolStripper
from signal_bot.pack import default_dirs
from signal_bot.pack_album import Album
@ -94,11 +95,16 @@ def main() -> int:
assert_port_free("127.0.0.1", arguments.port)
album = Album(directory, untagged_only=arguments.untagged)
settings = load_config()
deps = ManageDeps(
config=load_config(),
config=settings,
env_path=REPO_ROOT / ".env",
dist_dir=DIST,
album=album,
# database_path is relative by default and the bot runs from the
# repo root; joining an absolute one leaves it alone.
database_path=REPO_ROOT / settings.database_path,
export_path=default_export_path(),
client=httpx.AsyncClient(),
port=arguments.port,
)

View File

@ -1,5 +1,13 @@
import type { Roster } from "./setup.ts";
import type { AlbumState, EnvironmentView, Group, Restart, Status } from "./types.ts";
import type {
AlbumState,
EnvironmentView,
Export,
Group,
Restart,
Status,
Suggestions,
} from "./types.ts";
// Every mutating call sends application/json, and it is not decoration: the
// server refuses anything else, because a cross-origin <form> cannot set
@ -48,6 +56,8 @@ async function send<T>(path: string, method: string, body: unknown): Promise<T>
export const api = {
album: async (): Promise<AlbumState> => get<AlbumState>("/api/album/state"),
env: async (): Promise<EnvironmentView> => get<EnvironmentView>("/api/env"),
exportSuggestions: async (): Promise<Export> =>
send<Export>("/api/suggestions/export", "POST", {}),
groups: async (): Promise<{ groups: Group[] }> => get<{ groups: Group[] }>("/api/groups"),
person: async (triggers: string[]): Promise<{ key: string }> =>
send<{ key: string }>("/api/album/person", "POST", { triggers }),
@ -58,6 +68,7 @@ export const api = {
setup: async (groups: string[], roster: Roster): Promise<unknown> =>
send("/api/album/setup", "POST", { groups, ...roster }),
status: async (): Promise<Status> => get<Status>("/api/status"),
suggestions: async (): Promise<Suggestions> => get<Suggestions>("/api/suggestions"),
tag: async (file: string, keys: string[]): Promise<unknown> =>
send("/api/album/tag", "POST", { file, keys }),
};

View File

@ -60,3 +60,32 @@ export interface Restart {
stderr: string;
stdout: string;
}
export interface Suggestion {
body: string;
command: string;
group_id: string;
sent_at_ms: number;
}
export interface UnknownCommand {
// Every distinct message that produced this word. Shown here and nowhere
// else: the export reduces unrecognised words to a count, because a
// half-typed name is one the personal-data gate cannot recognise.
bodies: string[];
group_id: string;
hits: number;
last_seen: number;
word: string;
}
export interface Suggestions {
suggestions: Suggestion[];
unknown: UnknownCommand[];
}
export interface Export {
path: string;
suggestions: number;
unknown: number;
}

View File

@ -3,9 +3,11 @@ import { useState } from "react";
import { Album } from "./views/album.tsx";
import { Health } from "./views/health.tsx";
import { Settings } from "./views/settings.tsx";
import { Suggestions } from "./views/suggestions.tsx";
const VIEWS = {
Health: Health,
Ideas: Suggestions,
Photos: Album,
Settings: Settings,
} as const;

View File

@ -3,7 +3,12 @@
// string matching group.<16+ base64 chars>, so the fixture group ids are
// short and obviously fake; and the real album's people are private, so the
// names here are letters of the NATO alphabet, as tests/pack_fixture.py does.
import type { AlbumState, EnvironmentView as EnvironmentView, Status } from "../api/types.ts";
import type {
AlbumState,
EnvironmentView as EnvironmentView,
Status,
Suggestions as SuggestionsView,
} from "../api/types.ts";
export const GROUP = { id: "group.FIXTURE-ALPHA", name: "alfa" };
@ -63,3 +68,15 @@ export const FRESH: AlbumState = {
people: [],
untagged_only: false,
};
// One filed idea and one mistyped word. The word is a real typo of a real
// command so the fixture reads the way the live table does; the bodies are
// invented, like every other value in this file.
export const SUGGESTIONS: SuggestionsView = {
suggestions: [{ body: "pokaz pogode", command: "!pogoda", group_id: GROUP.id, sent_at_ms: 101 }],
unknown: [
{ bodies: ["!cta", "!cta teraz"], group_id: GROUP.id, hits: 2, last_seen: 201, word: "!cta" },
],
};
export const NO_SUGGESTIONS: SuggestionsView = { suggestions: [], unknown: [] };

View File

@ -0,0 +1,63 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { NO_SUGGESTIONS, SUGGESTIONS } from "../test/fixtures.ts";
import { serve } from "../test/http.ts";
import { Suggestions } from "./suggestions.tsx";
afterEach(() => {
vi.unstubAllGlobals();
});
it("says it is reading before the answer arrives", () => {
serve({ "/api/suggestions": { body: SUGGESTIONS } });
render(<Suggestions />);
expect(screen.getByText("Reading the database…")).toBeInTheDocument();
});
it("lists what people asked for, with the text they wrote", async () => {
serve({ "/api/suggestions": { body: SUGGESTIONS } });
render(<Suggestions />);
await waitFor(() => {
expect(screen.getByTestId("suggestions")).toHaveTextContent("!pogoda");
});
expect(screen.getByTestId("suggestions")).toHaveTextContent("pokaz pogode");
});
it("lists mistyped words with a count and every body seen", async () => {
serve({ "/api/suggestions": { body: SUGGESTIONS } });
render(<Suggestions />);
await waitFor(() => {
expect(screen.getByTestId("unknown")).toHaveTextContent("!cta");
});
const unknown = screen.getByTestId("unknown");
expect(unknown).toHaveTextContent("2");
expect(unknown).toHaveTextContent("!cta teraz");
});
it("says so plainly when nothing has been collected yet", async () => {
serve({ "/api/suggestions": { body: NO_SUGGESTIONS } });
render(<Suggestions />);
await waitFor(() => {
expect(screen.getByTestId("no-suggestions")).toBeInTheDocument();
});
expect(screen.getByTestId("no-unknown")).toBeInTheDocument();
});
it("reports where the export went, so it is clear it left the repo", async () => {
serve({
"/api/suggestions": { body: SUGGESTIONS },
"/api/suggestions/export": {
body: { path: "/var/lib/signal-bot/DOCS-suggestions.md", suggestions: 1, unknown: 1 },
},
});
render(<Suggestions />);
await waitFor(() => {
expect(screen.getByTestId("suggestions")).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: "Export to a file" }));
await waitFor(() => {
expect(screen.getByTestId("exported")).toHaveTextContent("DOCS-suggestions.md");
});
});

View File

@ -0,0 +1,83 @@
import { useCallback, useEffect, useState } from "react";
import type { Export, Suggestions as State } from "../api/types.ts";
import { api } from "../api/client.ts";
// Everything on this page is chat text, and it stays on this page. The
// export deliberately carries the deliberate notes in full and the mistyped
// words as counts only -- see routes_suggestions.render_export for why.
export function Suggestions(): React.JSX.Element {
const [state, setState] = useState<null | State>(null);
const [written, setWritten] = useState<Export | null>(null);
const [busy, setBusy] = useState(false);
const refresh = useCallback(() => {
void api.suggestions().then(setState);
}, []);
useEffect(refresh, [refresh]);
const doExport = useCallback(() => {
setBusy(true);
void api.exportSuggestions().then((result) => {
setWritten(result);
setBusy(false);
});
}, []);
if (state === null) {
return <p>Reading the database…</p>;
}
return (
<section>
<h2>Suggestions</h2>
<h3>Asked for</h3>
{state.suggestions.length === 0 ? (
<p data-testid="no-suggestions">Nobody has used !zaproponuj yet.</p>
) : (
<ul data-testid="suggestions">
{state.suggestions.map((one) => {
return (
<li key={`${one.command}-${String(one.sent_at_ms)}`}>
<code>{one.command}</code> — {one.body}
</li>
);
})}
</ul>
)}
<h3>Mistyped, or tried and not there</h3>
{state.unknown.length === 0 ? (
<p data-testid="no-unknown">Nothing unrecognised yet.</p>
) : (
<ul data-testid="unknown">
{state.unknown.map((one) => {
return (
<li key={`${one.group_id}-${one.word}`}>
<code>{one.word}</code> x {one.hits}
<ul>
{one.bodies.map((body) => {
return (
<li key={body}>{body}</li>
);
})}
</ul>
</li>
);
})}
</ul>
)}
<button disabled={busy} onClick={doExport} type="button">
Export to a file
</button>
{written !== null && (
<p data-testid="exported">
wrote {String(written.suggestions)} suggestion(s) to {written.path}
</p>
)}
</section>
);
}