From bd27ef58f1a512473491f1d7884b61b2b66aad57 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Mon, 6 Jul 2026 19:59:56 -0400 Subject: [PATCH] fix: classify choose() emoji keys by unicode properties, not length+ascii MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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 --- src/dpy_commons/prompts.py | 42 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/dpy_commons/prompts.py b/src/dpy_commons/prompts.py index 88c9825..44ba9b8 100644 --- a/src/dpy_commons/prompts.py +++ b/src/dpy_commons/prompts.py @@ -10,6 +10,7 @@ treating a timeout as a normal None return. from __future__ import annotations import logging +import unicodedata from typing import Any, Optional, Union import discord @@ -227,6 +228,43 @@ def _split_key(key: Any) -> "tuple[Optional[str], Optional[Union[str, discord.Pa 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: - """heuristic: a short non-ascii token is treated as a unicode emoji key""" - return bool(value) and len(value) <= 4 and not value.isascii() + """whether value is a unicode emoji key rather than a text label + + 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