mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:17:02 +01:00
fix: commands resolve when typed on another alphabet's keyboard
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6f5a703824
commit
b70730fc7a
5
fake_signal/scenarios/cyrillic_homoglyph.yaml
Normal file
5
fake_signal/scenarios/cyrillic_homoglyph.yaml
Normal 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
118
signal_bot/folding.py
Normal 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()
|
||||
@ -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
101
tests/test_folding.py
Normal 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"
|
||||
Loading…
Reference in New Issue
Block a user