mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 14:17:31 +01:00
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:
parent
486c478618
commit
6ec746fad2
@ -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
|
||||
|
||||
@ -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 —
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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.
|
||||
|
||||
9
fake_signal/scenarios/chatter_is_silent.yaml
Normal file
9
fake_signal/scenarios/chatter_is_silent.yaml
Normal 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: []
|
||||
8
fake_signal/scenarios/did_you_mean.yaml
Normal file
8
fake_signal/scenarios/did_you_mean.yaml
Normal 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" }
|
||||
7
fake_signal/scenarios/suggest.yaml
Normal file
7
fake_signal/scenarios/suggest.yaml
Normal 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" }
|
||||
@ -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" }
|
||||
|
||||
9
fake_signal/scenarios/unknown_command_scoping.yaml
Normal file
9
fake_signal/scenarios/unknown_command_scoping.yaml
Normal 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: []
|
||||
@ -103,5 +103,12 @@
|
||||
"!losuj",
|
||||
"!random",
|
||||
"!rand"
|
||||
],
|
||||
"suggest": [
|
||||
"!zaproponuj",
|
||||
"!propozycja",
|
||||
"!sugestia",
|
||||
"!suggest",
|
||||
"!pomysl"
|
||||
]
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -40,6 +40,7 @@ HEADING: Final = "Komendy"
|
||||
GROUPS: Final[tuple[tuple[str, tuple[str, ...]], ...]] = (
|
||||
("Obrazki", ("cat", "dog", "gecko")),
|
||||
("Papież", ("pope",)),
|
||||
("Propozycje", ("suggest",)),
|
||||
(
|
||||
"Statystyki",
|
||||
(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
162
signal_bot/manage/routes_suggestions.py
Normal file
162
signal_bot/manage/routes_suggestions.py
Normal 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
115
signal_bot/nearest.py
Normal 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]
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
86
signal_bot/suggest_command.py
Normal file
86
signal_bot/suggest_command.py
Normal 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
126
signal_bot/suggestions.py
Normal 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
134
signal_bot/unknown.py
Normal 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))
|
||||
@ -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,
|
||||
)
|
||||
|
||||
121
tests/test_dispatcher_unknown.py
Normal file
121
tests/test_dispatcher_unknown.py
Normal 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"))
|
||||
@ -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
135
tests/test_nearest.py
Normal 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}"
|
||||
@ -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"}
|
||||
|
||||
151
tests/test_routes_suggestions.py
Normal file
151
tests/test_routes_suggestions.py
Normal 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()
|
||||
158
tests/test_suggest_command.py
Normal file
158
tests/test_suggest_command.py
Normal 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"
|
||||
90
tests/test_suggestions_store.py
Normal file
90
tests/test_suggestions_store.py
Normal 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
177
tests/test_unknown.py
Normal 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() == []
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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 }),
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: [] };
|
||||
|
||||
63
web/src/views/suggestions.test.tsx
Normal file
63
web/src/views/suggestions.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
83
web/src/views/suggestions.tsx
Normal file
83
web/src/views/suggestions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user