@@ -368,17 +368,28 @@ def test_trailing_text_split_uses_utf16_offsets(self):
368368 """The command/trailing-text split is computed on UTF-16 code-unit
369369 offsets, not Python code points.
370370
371- Telegram reports ``length`` in UTF-16 code units. The entity
371+ Telegram reports ``length`` in UTF-16 code units. The command token
372372 ``/p😀g`` spans 4 code points but 5 UTF-16 units (the astral emoji is
373- a surrogate pair), so the trailing-text split starts at unit 5. A
374- naive code-point slice — ``text[5:]`` — over-advances and drops the
375- leading ``h``; the UTF-16-aware slice keeps it.
373+ a surrogate pair). The trailing text abuts the token with **no
374+ separating space** (``/p😀ghello``), so the split offset cannot be
375+ masked by a ``lstrip`` after the fact:
376+
377+ * UTF-16-aware split at unit ``offset + length == 5`` lands exactly on
378+ the ``h`` and yields ``"hello"``.
379+ * A naive Python code-point slice ``text[5:]`` over-advances (the
380+ emoji counts as one code point, not two) and yields ``"ello"`` —
381+ the leading ``h`` is silently dropped.
382+
383+ Because there is no whitespace at the boundary, the two paths diverge
384+ in the final result, so this test FAILS against a naive
385+ ``text[entity_length:]`` slice.
376386 """
377387 adapter , _ = _slash_adapter_and_chat ()
378- # "/p😀g" = / p <emoji=2 units> g = 4 code points but 5 UTF-16 units.
388+ # "/p😀g" = / p <emoji=2 units> g = 4 code points but 5 UTF-16 units;
389+ # "hello" follows immediately with no separator.
379390 result = adapter .parse_slash_command (
380391 _sample_message (
381- text = "/p😀g hello " ,
392+ text = "/p😀ghello " ,
382393 entities = [{"type" : "bot_command" , "offset" : 0 , "length" : 5 }],
383394 )
384395 )
@@ -388,12 +399,130 @@ def test_entity_text_split_naive_codepoint_would_diverge(self):
388399 """Guards the UTF-16 split against a naive ``str`` slice regression.
389400
390401 ``_slice_utf16`` and a naive code-point slice must diverge for astral
391- text, proving the helper is load-bearing (not a no-op on ASCII)."""
402+ text, proving the helper is load-bearing (not a no-op on ASCII).
403+ With the trailing text abutting the token (no separator), the two
404+ slices return different strings that no ``lstrip`` can reconcile."""
392405 adapter , _ = _slash_adapter_and_chat ()
393- text = "/p😀g hello"
394- assert adapter ._slice_utf16 (text , 5 ) == " hello"
406+ text = "/p😀ghello"
407+ # UTF-16-aware slice at code-unit 5 lands on the "h".
408+ assert adapter ._slice_utf16 (text , 5 ) == "hello"
395409 # The naive code-point slice over-advances and eats the leading "h".
396- assert text [5 :] == "hello"
410+ assert text [5 :] == "ello"
411+
412+ @pytest .mark .asyncio
413+ async def test_at_bot_targeting_is_case_insensitive (self ):
414+ """``/ping@<MixedCase>`` still routes to the slash handler when the
415+ casing differs from ``user_name`` — the ``.lower()`` normalization on
416+ both sides is load-bearing (mutating it to a case-sensitive ``!=``
417+ drops this command to ``process_message``)."""
418+ adapter , chat = _slash_adapter_and_chat () # user_name == "mybot"
419+ body = json .dumps (
420+ {
421+ "update_id" : 7 ,
422+ "message" : _sample_message (
423+ text = "/ping@MyBot hello" ,
424+ entities = [{"type" : "bot_command" , "offset" : 0 , "length" : 11 }],
425+ ),
426+ }
427+ )
428+
429+ response = await adapter .handle_webhook (_make_request (body ))
430+ assert response ["status" ] == 200
431+
432+ assert chat .process_slash_command .call_count == 1
433+ chat .process_message .assert_not_called ()
434+ event = chat .process_slash_command .call_args .args [0 ]
435+ assert event .command == "/ping"
436+ assert event .text == "hello"
437+
438+ @pytest .mark .asyncio
439+ async def test_edited_message_with_bot_command_does_not_route_to_slash (self ):
440+ """Slash gating is scoped to ``update.message`` only: an
441+ ``edited_message`` carrying a leading ``bot_command`` entity routes to
442+ the regular message path, never the slash handler (mutating the gate
443+ to read ``edited_message`` would mis-route the edit)."""
444+ adapter , chat = _slash_adapter_and_chat ()
445+ body = json .dumps (
446+ {
447+ "update_id" : 8 ,
448+ "edited_message" : _sample_message (
449+ text = "/ping@mybot hello" ,
450+ entities = [{"type" : "bot_command" , "offset" : 0 , "length" : 11 }],
451+ ),
452+ }
453+ )
454+
455+ response = await adapter .handle_webhook (_make_request (body ))
456+ assert response ["status" ] == 200
457+
458+ chat .process_slash_command .assert_not_called ()
459+ assert chat .process_message .call_count == 1
460+
461+ @pytest .mark .asyncio
462+ async def test_channel_post_with_bot_command_does_not_route_to_slash (self ):
463+ """A ``channel_post`` carrying a leading ``bot_command`` entity routes
464+ to the regular message path, not the slash handler — slash gating only
465+ reads ``update.message`` (mirrors upstream ``update.message``)."""
466+ adapter , chat = _slash_adapter_and_chat ()
467+ body = json .dumps (
468+ {
469+ "update_id" : 9 ,
470+ "channel_post" : _sample_message (
471+ text = "/ping@mybot hello" ,
472+ entities = [{"type" : "bot_command" , "offset" : 0 , "length" : 11 }],
473+ ),
474+ }
475+ )
476+
477+ response = await adapter .handle_webhook (_make_request (body ))
478+ assert response ["status" ] == 200
479+
480+ chat .process_slash_command .assert_not_called ()
481+ assert chat .process_message .call_count == 1
482+
483+ @pytest .mark .asyncio
484+ async def test_command_addressed_only_to_another_bot_routes_to_message (self ):
485+ """``/@bot`` (empty command name) yields no slash command — the
486+ ``if not command_name: return None`` guard sends it to
487+ ``process_message`` (matches upstream's ``if (!commandName)``)."""
488+ adapter , chat = _slash_adapter_and_chat ()
489+ body = json .dumps (
490+ {
491+ "update_id" : 10 ,
492+ "message" : _sample_message (
493+ text = "/@mybot hello" ,
494+ entities = [{"type" : "bot_command" , "offset" : 0 , "length" : 7 }],
495+ ),
496+ }
497+ )
498+
499+ response = await adapter .handle_webhook (_make_request (body ))
500+ assert response ["status" ] == 200
501+
502+ chat .process_slash_command .assert_not_called ()
503+ assert chat .process_message .call_count == 1
504+
505+ @pytest .mark .asyncio
506+ async def test_bare_slash_routes_to_message (self ):
507+ """A bare ``/`` (no command name) yields no slash command and routes
508+ to ``process_message`` — the empty-``command_name`` guard, plus the
509+ ``startswith('/')`` / ``offset == 0`` gating, all hold."""
510+ adapter , chat = _slash_adapter_and_chat ()
511+ body = json .dumps (
512+ {
513+ "update_id" : 11 ,
514+ "message" : _sample_message (
515+ text = "/ hello" ,
516+ entities = [{"type" : "bot_command" , "offset" : 0 , "length" : 1 }],
517+ ),
518+ }
519+ )
520+
521+ response = await adapter .handle_webhook (_make_request (body ))
522+ assert response ["status" ] == 200
523+
524+ chat .process_slash_command .assert_not_called ()
525+ assert chat .process_message .call_count == 1
397526
398527
399528# ---------------------------------------------------------------------------
0 commit comments