fix: commands resolve when typed on another alphabet's keyboard
Some checks are pending
CI / check (push) Waiting to run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-30 14:22:43 +02:00
parent b7b1f8f1c4
commit e4b19dd5d1
4 changed files with 229 additions and 2 deletions

View File

@ -0,0 +1,5 @@
name: "!gecko typed on a Russian keyboard still resolves"
inject:
- { text: "!gеckо", uuid: uuid-bob, name: Bob }
expect:
- { kind: image, to: reply }

118
signal_bot/folding.py Normal file
View File

@ -0,0 +1,118 @@
# 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()

View File

@ -12,6 +12,7 @@ from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from signal_bot.folding import fold
from signal_bot.quoting import quote_of
if TYPE_CHECKING:
@ -81,7 +82,7 @@ class CommandRegistry:
Raises:
DuplicateTriggerError: A trigger is already claimed.
"""
normalised = frozenset(t.strip().lower() for t in triggers)
normalised = frozenset(fold(t) for t in triggers)
for trigger in sorted(normalised):
if trigger in self._by_trigger:
msg = f"trigger {trigger!r} is already registered"
@ -96,7 +97,9 @@ class CommandRegistry:
"""Return the command a message body triggers, or None."""
if not text:
return None
candidate = text.strip().lower()
# Folded, not just lowercased: a Cyrillic "o" is a different
# codepoint from a Latin one and used to resolve to nothing at all.
candidate = fold(text)
if not candidate.startswith(COMMAND_PREFIX):
return None
return self._by_trigger.get(candidate)

101
tests/test_folding.py Normal file
View File

@ -0,0 +1,101 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Folding a typed command down to a comparable skeleton.
Every non-ASCII literal here is an escape. These tests exist because two
characters looked identical, so a reader must be able to see which codepoint
each case actually means.
"""
from __future__ import annotations
import pytest
from signal_bot.folding import TABLE, fold
# The regression itself: `!gecko` as it arrives from a Russian keyboard, with
# U+0435 for the `e` and U+043E for the `o`.
RUSSIAN_GECKO = "!g\u0435ck\u043e"
def test_the_cyrillic_gecko_that_started_this() -> None:
assert fold(RUSSIAN_GECKO) == "!gecko"
assert RUSSIAN_GECKO != "!gecko"
@pytest.mark.parametrize(
("typed", "expected"),
[
# Cyrillic
("!\u043a\u043e\u0442", "!kot"),
("!\u0441\u0430\u0442", "!cat"),
("!d\u043eg", "!dog"),
# Greek
("!d\u03bfg", "!dog"),
("!\u03bau\u03bf\u03c4", "!kuot"),
# Latin small capitals
("!\u1d0b\u1d0f\u1d1b", "!kot"),
# Latin letters that are not ASCII
("!d\u00f8g", "!dog"),
("!p\u0131es", "!pies"),
# Leetspeak
("!g3ck0", "!gecko"),
("!d0g", "!dog"),
("!c4t", "!cat"),
("!p13s", "!pies"),
("!5zczek", "!szczek"),
("!ca7", "!cat"),
# Compatibility forms NFKC already handles
("!\uff44\uff4f\uff47", "!dog"),
("!\U0001d5f8\U0001d5fc\U0001d601", "!kot"),
# Diacritics decompose away
("!papie\u017c", "!papiez"),
("!k\u00f3t", "!kot"),
# Invisible characters are dropped
("!d\u200bog", "!dog"),
("!d\u200dog", "!dog"),
("!dog\u00ad", "!dog"),
("!\ufeffdog", "!dog"),
# Case and surrounding whitespace
(" !DOG ", "!dog"),
("!Dog", "!dog"),
(" !dog ", "!dog"),
],
)
def test_fold_maps_lookalikes_onto_ascii(typed: str, expected: str) -> None:
assert fold(typed) == expected
def test_fold_keeps_internal_whitespace() -> None:
"""Argument support (0.0.6) splits on it, so folding must not eat it."""
assert fold("!suggest !gecko co ma robic") == "!suggest !gecko co ma robic"
def test_fold_leaves_ordinary_text_alone() -> None:
assert fold("!dog") == "!dog"
assert fold("hello there") == "hello there"
assert fold("") == ""
def test_fold_is_idempotent() -> None:
"""Folding twice must equal folding once, or triggers drift from input."""
for typed in (RUSSIAN_GECKO, "!g3ck0", "!\u1d0b\u1d0f\u1d1b", "!papie\u017c"):
assert fold(fold(typed)) == fold(typed)
def test_fold_does_not_collapse_genuinely_different_commands() -> None:
"""The table must not be so eager that distinct commands become one."""
assert fold("!cat") != fold("!dog")
assert fold("!kot") != fold("!kots")
assert fold("!gekon") != fold("!geko")
def test_every_mapping_target_is_ascii() -> None:
"""A mapping onto another non-ASCII character would need folding again."""
for replacement in TABLE.values():
assert replacement.isascii()
def test_table_covers_both_alphabets_that_bit_us() -> None:
assert TABLE[0x043E] == "o"
assert TABLE[0x0435] == "e"
assert TABLE[0x03BF] == "o"