fix: classify choose() emoji keys by unicode properties, not length+ascii

_looks_unicode_emoji used 'len<=4 and not isascii', so a short non-ascii text label
(Sí/да/はい/café/确定) was misread as a unicode emoji and sent as a labelless component
emoji, which Discord 400s. It now treats a key as an emoji only when every codepoint is
emoji-composition material (So/Sk symbols, ZWJ, variation selectors, regional indicators,
keycap combiner) with at least one pictographic symbol - so a compound emoji (ZWJ family,
flag, skin-tone, keycap) stays one emoji while an i18n label is a label. The terminal
Discord 400 remains a documented live-gap (needs a gateway).

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
2026-07-06 19:59:56 -04:00
parent 79c2c7286f
commit bd27ef58f1
+40 -2
View File
@@ -10,6 +10,7 @@ treating a timeout as a normal None return.
from __future__ import annotations from __future__ import annotations
import logging import logging
import unicodedata
from typing import Any, Optional, Union from typing import Any, Optional, Union
import discord import discord
@@ -227,6 +228,43 @@ def _split_key(key: Any) -> "tuple[Optional[str], Optional[Union[str, discord.Pa
return str(key), None return str(key), None
def _is_emoji_codepoint(ch: str) -> bool:
"""whether a single character is emoji-composition material
covers the pictographic symbols themselves (category So), skin-tone modifiers (Sk),
and the glue that binds a compound emoji into one grapheme: ZWJ (U+200D), variation
selectors (U+FE00-FE0F), regional-indicator letters (flags, U+1F1E6-1F1FF), and the
keycap combiner (U+20E3). a plain letter/digit/space is none of these.
"""
code = ord(ch)
if ch == "" or ch == "":
return True
if 0xFE00 <= code <= 0xFE0F:
return True
if 0x1F1E6 <= code <= 0x1F1FF:
return True
return unicodedata.category(ch) in ("So", "Sk")
def _looks_unicode_emoji(value: str) -> bool: def _looks_unicode_emoji(value: str) -> bool:
"""heuristic: a short non-ascii token is treated as a unicode emoji key""" """whether value is a unicode emoji key rather than a text label
return bool(value) and len(value) <= 4 and not value.isascii()
true only when EVERY codepoint is emoji-composition material (see _is_emoji_codepoint)
and at least one is a pictographic symbol - so a compound emoji (ZWJ family, flag,
skin-tone) classifies as one emoji, while a short non-ascii text label (Sí/да/はい/確定/
café) is a label because its letters are not emoji codepoints. does not cap length:
a long emoji-only run is still an emoji, a run mixing letters and emoji is a label.
"""
if not value:
return False
# a keycap sequence is [0-9#*] + optional VS16 + U+20E3 - its base is an ascii digit/
# symbol (not So), so treat the whole run as an emoji when the keycap combiner is present
if value.endswith("") and all(c in "0123456789#*️⃣" for c in value):
return True
has_symbol = False
for ch in value:
if not _is_emoji_codepoint(ch):
return False
if unicodedata.category(ch) == "So":
has_symbol = True
return has_symbol