Skip to content

fix(custom_emojis): reject non-emoji signs as fallback (№, ™, lone keycap) - #315

Merged
chigwell merged 2 commits into
chigwell:mainfrom
axisrow:ao/telegram-mcp-4/up-299
Oct 16, 2026
Merged

chigwell merged 2 commits into
chigwell:mainfrom
axisrow:ao/telegram-mcp-4/up-299

Conversation

@axisrow

@axisrow axisrow commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Summary

The custom_emojis fallback validation (_looks_like_emoji, from #280) accepted any non-alnum character ≥ U+2100. Text signs — № (U+2116), ™ (U+2122), math operators (∉) — therefore passed validation and reached the Telegram API as MessageEntityCustomEmoji fallbacks attached to plain text. A lone combining keycap (U+20E3) also slipped through via the "no keycap in text" floor bypass.

The U+2100 floor is replaced by an emoji-block check (_is_emoji_char): arrows, misc technical, enclosed alphanumerics, geometric shapes, misc symbols, dingbats, misc symbols and arrows, the astral emoji blocks, plus the VS16/ZWJ sequence components — with the RGI emoji that live between the blocks (ℹ U+2139, ⤴/⤵ U+2934–U+2935, 〰 U+3030, 〽 U+303D, ㊗/㊙ U+3297/U+3299) listed explicitly. The keycap mark now counts only through its keycap base. U+2139 additionally needs an exemption in the letter/digit check: it is the only letter-form emoji (General_Category Ll, not So), so isalnum() is True for it.

Fixes #299

Before

send_message(chat_id="me", message="test1 №", custom_emojis=[{"emoji": "№", "id": 123}])
-> "Message sent successfully."   # the invalid entity went to the API

send_message(chat_id="me", message="test2 #", custom_emojis=[{"emoji": "#", "id": 123}])
-> "custom_emojis emoji '#' must be the fallback emoji alone, without text or spaces."

_looks_like_emoji classified signs and emoji by one threshold:

  • _looks_like_emoji('№') → True (wrong)
  • _looks_like_emoji('™') → True (wrong)
  • _looks_like_emoji('⃣') → True (wrong, lone keycap mark)
  • _looks_like_emoji('#') → False (correct)
  • _looks_like_emoji('😀') → True (correct)

A structurally identical request with only the fallback character changed either sent an invalid entity or produced a validation error — the validator could not tell a sign from an emoji.

After

Non-emoji signs are refused with the standard validation error before anything reaches the API:

send_message(chat_id="me", message="test1 №", custom_emojis=[{"emoji": "№", "id": 123}])
-> "custom_emojis emoji '№' must be the fallback emoji alone, without text or spaces."
  • _looks_like_emoji('№') → False
  • _looks_like_emoji('™') → False
  • _looks_like_emoji('⃣') → False (a keycap mark now counts only through its base)
  • _looks_like_emoji('∉') → False (math operators no longer pass)
  • _looks_like_emoji('#') → False (unchanged)
  • _looks_like_emoji('😀') → True (unchanged)
  • _looks_like_emoji('™️'), ('☀️'), ('ℹ️'), ('⤴️'), ('〽️'), ('㊙️') → True (text-presentation signs and RGI emoji between the blocks stay valid with an explicit U+FE0F)

Tests

  • tests/test_send_custom_emojis.py::test_non_emoji_signs_are_refused — №, ™, lone U+20E3, ∉, # are refused.
  • tests/test_send_custom_emojis.py::test_text_presentation_signs_with_vs16_are_accepted — ™️, ☀️, ℹ️, ⤴️, 〽️, ㊙️ are accepted, offsets intact.
  • Full suite: pytest -q → 1226 passed.

Risks

  • The block list is coarse by design (matches the validator's "cheap fallback check" contract): a few non-emoji symbols inside emoji-adjacent blocks (e.g. ⌘) are still accepted — same class as before, but the letterlike/math sign family from the issue is gone.

🤖 Generated with Claude Code

…ycap)

_looks_like_emoji accepted any non-alnum character >= U+2100, so text
signs like № (U+2116), ™ (U+2122) and math operators passed validation
and reached the API as MessageEntityCustomEmoji fallbacks. It also let
a lone combining keycap (U+20E3) through via the "no keycap in text"
floor bypass.

Replace the U+2100 floor with an emoji-block check (_is_emoji_char):
arrows, misc technical, enclosed alphanumerics, geometric shapes, misc
symbols, dingbats, misc symbols and arrows, plus the astral emoji
blocks, and the VS16/ZWJ sequence components — with the RGI emoji that
live between the blocks (ℹ U+2139, ⤴/⤵ U+2934–U+2935, 〰 U+3030, 〽
U+303D, ㊗/㊙ U+3297/U+3299) listed explicitly. The keycap mark now
counts only through its keycap base. U+2139 additionally needs an
exemption in the letter/digit check: it is the only letter-form emoji
(General_Category Ll, not So), so isalnum() is True for it.
Text-presentation signs (™, ☀) remain valid with an explicit U+FE0F, so
no real emoji sequence is rejected.

Co-Authored-By: Claude Code <[email protected]>

@chigwell chigwell left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/chigwell/telegram-mcp/actions/runs/38845210770/job/116889206449?pr=315

Run black --check .
would reformat /home/runner/work/telegram-mcp/telegram-mcp/telegram_mcp/tools/messages.py

Oh no! 💥 💔 💥
1 file would be reformatted, 91 files would be left unchanged.
Error: Process completed with exit code 1.

Same reformat as landed on main in b5149a1: this file was written
under a pre-24.x black that kept hand-wrapped call sites; current
black collapses them. No behavior change.

Co-Authored-By: Claude Code <[email protected]>
@chigwell
chigwell merged commit 5aa9859 into chigwell:main Oct 16, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

custom_emojis fallback validation accepts non-emoji signs (№, ™) - U+2100 threshold

2 participants