mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
119 lines
4.8 KiB
Python
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()
|