# 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()