signal-bot/signal_bot/folding.py
Krzysztof kuhy Rudnicki b70730fc7a fix: commands resolve when typed on another alphabet's keyboard
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:22:43 +02:00

119 lines
4.8 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
r"""Fold a typed command down to a comparable skeleton.
A Signal group is full of phone keyboards, and a phone keyboard decides
which alphabet you are in. On 2026-08-29 a member typed `!gecko` on a
Russian layout: the `e` was U+0435 CYRILLIC SMALL LETTER IE and the `o` was
U+043E CYRILLIC SMALL LETTER O. Both are pixel-identical to the Latin
letters, both compare unequal to them, so the command resolved to nothing
and the bot stayed silent -- no error, no cooldown line, nothing to see.
The same trap covers Greek omicron, small capitals, fullwidth forms,
zero-width joiners pasted in from elsewhere, and plain leetspeak.
The fix is to compare *skeletons* rather than codepoints, and to fold both
sides -- the registered trigger and the incoming text -- through the same
function. Folding both sides is what makes it safe: any alias that already
worked still folds to whatever it folded to before, so this can widen what
matches but can never narrow it.
Every mapping below is written as an explicit `\\uXXXX` escape. A table of
confusables whose whole point is that you cannot tell the characters apart
must not be reviewed by eye.
"""
from __future__ import annotations
import unicodedata
from typing import Final
# Cyrillic letters that are visually identical to a Latin one. This is the
# set that actually bit us; Russian and Ukrainian layouts reach all of it.
_CYRILLIC: Final = (
"\u0430\u0432\u0435\u0455\u0456\u0458"
"\u043a\u043c\u043d\u043e\u0440\u0441"
"\u0442\u0443\u0445\u0501\u051b\u051d"
)
_CYRILLIC_LATIN: Final = "abesijkmhopctyxdqw"
# Greek. Less likely from a keyboard, more likely from a copy-paste.
_GREEK: Final = (
"\u03b1\u03b2\u03b3\u03b5\u03b6\u03b7"
"\u03b9\u03ba\u03bc\u03bd\u03bf\u03c1"
"\u03c4\u03c5\u03c7\u03c9"
)
_GREEK_LATIN: Final = "abyeznikmvoptuxw"
# Latin small capitals. NFKC leaves these alone -- they are letters in their
# own right, not compatibility forms -- so aliases.json had to spell out
# "!ᴋᴏᴛ" by hand. With this table it no longer has to.
_SMALL_CAPS: Final = (
"\u1d00\u0299\u1d04\u1d05\u1d07\u0262"
"\u029c\u026a\u1d0a\u1d0b\u029f\u1d0d"
"\u0274\u1d0f\u1d18\u0280\ua731\u1d1b"
"\u1d1c\u1d20\u1d21\u028f\u1d22"
)
_SMALL_CAPS_LATIN: Final = "abcdeghijklmnoprstuvwyz"
# Latin letters outside ASCII that read as an ASCII one. Diacritics are
# handled by decomposition instead; these carry their difference in the
# letterform itself, so no amount of normalising removes it.
_LATIN_LOOKALIKE: Final = "\u0131\u0237\u0142\u00f8\u0111\u00f0"
_LATIN_LOOKALIKE_ASCII: Final = "ijlodd"
# Leetspeak. Deliberately conservative: 1 is read as i (which is what
# "!p1es" wants) and not as l, and 6/9 are left alone because they are
# genuinely ambiguous. The cost of this table is that a command name can
# never contain a digit -- there are none today, and `!ping` is the only
# planned one, so that is a trade worth making.
_LEET: Final = "0134578"
_LEET_LATIN: Final = "oieastb"
# Invisible characters. They survive NFKC, they are impossible to see in a
# chat client, and they arrive whenever text has been copied out of a web
# page or another messenger.
_INVISIBLE: Final = frozenset("\u00ad\u180e\u200b\u200c\u200d\u200e\u200f\u2060\ufeff")
def _table() -> dict[int, str]:
"""Build the single translation table, once, at import.
Raises:
ValueError: a mapping and its replacements have different lengths,
which would silently mis-map every character after the gap.
"""
mapping: dict[str, str] = {}
for source, target in (
(_CYRILLIC, _CYRILLIC_LATIN),
(_GREEK, _GREEK_LATIN),
(_SMALL_CAPS, _SMALL_CAPS_LATIN),
(_LATIN_LOOKALIKE, _LATIN_LOOKALIKE_ASCII),
(_LEET, _LEET_LATIN),
):
mapping.update(dict(zip(source, target, strict=True)))
return str.maketrans(mapping)
TABLE: Final = _table()
def fold(text: str) -> str:
"""Reduce text to the skeleton the registry compares against.
Applied to registered triggers and to incoming messages alike, so the
two can only ever disagree about characters this function does not
touch.
"""
# NFKC first: it collapses the mathematical alphabets, fullwidth forms
# and non-breaking spaces that aliases.json used to enumerate by hand.
compatible = unicodedata.normalize("NFKC", text)
# Then decompose, so an accent becomes a combining mark of its own and
# can be dropped -- this is what turns "papież" into "papiez" and
# what defuses the zalgo-styled alias.
decomposed = unicodedata.normalize("NFD", compatible)
stripped = "".join(
character
for character in decomposed
if not unicodedata.combining(character) and character not in _INVISIBLE
)
return stripped.casefold().translate(TABLE).strip()