From 87be36c240ecc14c4b47584b401fc236acba549f Mon Sep 17 00:00:00 2001 From: ooiuuii Date: Mon, 29 Jun 2026 23:08:03 +0800 Subject: [PATCH] fix(discord): bound component labels by UTF-16 units --- plugins/platforms/discord/adapter.py | 40 ++++++++++---- tests/gateway/test_discord_clarify_buttons.py | 14 +++++ tests/gateway/test_discord_model_picker.py | 53 +++++++++++++++++++ 3 files changed, 98 insertions(+), 9 deletions(-) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 92955e631a0..380ef131511 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -52,6 +52,9 @@ _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0 # every slash command — not just the overflow ones. We keep the desired set # at or below this limit at registration time. _DISCORD_MAX_APP_COMMANDS = 100 +_DISCORD_SELECT_FIELD_LIMIT = 100 +_DISCORD_BUTTON_LABEL_LIMIT = 80 +_DISCORD_ELLIPSIS = "\u2026" _DISCORD_NONCONVERSATIONAL_METADATA_KEYS = frozenset({ "non_conversational", "non_conversational_history", @@ -117,11 +120,18 @@ from gateway.platforms.base import ( cache_document_from_bytes, SUPPORTED_DOCUMENT_TYPES, _TEXT_INJECT_EXTENSIONS, + _prefix_within_utf16_limit, + utf16_len, validate_inbound_media_size, ) from tools.url_safety import is_safe_url +def _truncate_discord_component_text(text: str, limit: int) -> str: + """Return text within Discord's UTF-16 component field budget.""" + return _prefix_within_utf16_limit(str(text or ""), max(0, limit)) + + async def _wait_for_ready_or_bot_exit( ready_event: asyncio.Event, bot_task: asyncio.Task, @@ -6888,7 +6898,10 @@ def _define_discord_view_classes() -> None: desc = "current" if p.get("is_current") else None options.append( discord.SelectOption( - label=label[:100], + label=_truncate_discord_component_text( + label, + _DISCORD_SELECT_FIELD_LIMIT, + ), value=p["slug"], description=desc, ) @@ -6925,8 +6938,14 @@ def _define_discord_view_classes() -> None: short = model_id.split("/")[-1] if "/" in model_id else model_id options.append( discord.SelectOption( - label=short[:100], - value=model_id[:100], + label=_truncate_discord_component_text( + short, + _DISCORD_SELECT_FIELD_LIMIT, + ), + value=_truncate_discord_component_text( + model_id, + _DISCORD_SELECT_FIELD_LIMIT, + ), ) ) if not options: @@ -7205,15 +7224,18 @@ def _define_discord_view_classes() -> None: # budget (hyphen, comma, period, paren) # 3. Hard cut at the budget limit (last resort) prefix = f"{index + 1}. " - budget = 80 - len(prefix) - if len(choice) <= budget: + budget = _DISCORD_BUTTON_LABEL_LIMIT - utf16_len(prefix) + if utf16_len(choice) <= budget: label_body = choice else: - truncated = choice[: budget - 1].rstrip() + truncated = _prefix_within_utf16_limit( + choice, + max(0, budget - utf16_len(_DISCORD_ELLIPSIS)), + ).rstrip() cut_at = -1 # 1. Last space in the trailing half of the budget. space = truncated.rfind(" ") - if space >= budget // 2: + if space >= len(truncated) // 2: cut_at = space # 2. Soft boundary — only if no word boundary found. # Find the latest soft boundary in the trailing half @@ -7226,11 +7248,11 @@ def _define_discord_view_classes() -> None: (truncated.rfind(s) for s in ("-", ",", ".", ")")), default=-1, ) - if latest_soft >= budget // 2: + if latest_soft >= len(truncated) // 2: cut_at = latest_soft + 1 if cut_at > 0: truncated = truncated[:cut_at] - label_body = truncated.rstrip() + "…" + label_body = truncated.rstrip() + _DISCORD_ELLIPSIS button = discord.ui.Button( label=f"{prefix}{label_body}", style=discord.ButtonStyle.primary, diff --git a/tests/gateway/test_discord_clarify_buttons.py b/tests/gateway/test_discord_clarify_buttons.py index b8b5dc10ed2..a72d225c618 100644 --- a/tests/gateway/test_discord_clarify_buttons.py +++ b/tests/gateway/test_discord_clarify_buttons.py @@ -30,6 +30,7 @@ from plugins.platforms.discord.adapter import ( # noqa: E402 DiscordAdapter, ) from gateway.config import PlatformConfig # noqa: E402 +from gateway.platforms.base import utf16_len # noqa: E402 # --------------------------------------------------------------------------- @@ -130,6 +131,19 @@ class TestClarifyChoiceViewConstruction: # Final label total <= 80 (Discord cap on button labels) assert len(first_label) <= 80 + def test_truncates_emoji_choice_label_by_utf16_limit(self): + long_choice = "\U0001f600" * 80 + view = ClarifyChoiceView( + choices=[long_choice], + clarify_id="cidEmoji", + allowed_user_ids=set(), + ) + + first_label = view.children[0].label + assert first_label.startswith("1. ") + assert first_label.endswith("\u2026") + assert utf16_len(first_label) <= 80 + def test_truncates_long_choice_label_breaks_on_word_boundary(self): # Long choice with spaces — should cut at the last whole word so the # trailing text stays readable on Discord mobile. diff --git a/tests/gateway/test_discord_model_picker.py b/tests/gateway/test_discord_model_picker.py index 86025f4e429..369dcb9a977 100644 --- a/tests/gateway/test_discord_model_picker.py +++ b/tests/gateway/test_discord_model_picker.py @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock import pytest +from gateway.platforms.base import utf16_len from plugins.platforms.discord.adapter import ModelPickerView @@ -82,6 +83,58 @@ async def test_model_picker_clears_controls_before_running_switch_callback(): interaction.edit_original_response.assert_awaited_once() +def test_model_picker_provider_labels_fit_discord_utf16_limit(): + provider_name = "Provider " + ("\U0001f600" * 80) + + view = ModelPickerView( + providers=[ + { + "slug": "emoji", + "name": provider_name, + "models": ["gpt-5-mini"], + "total_models": 1, + "is_current": False, + } + ], + current_model="gpt-5-mini", + current_provider="emoji", + session_key="session-1", + on_model_selected=AsyncMock(return_value="ok"), + allowed_user_ids={"123"}, + ) + + provider_select = view.children[0] + option = provider_select.options[0] + assert utf16_len(option.label) <= 100 + + +def test_model_picker_model_labels_and_values_fit_discord_utf16_limit(): + model_id = "emoji/" + ("\U0001f600" * 80) + + view = ModelPickerView( + providers=[ + { + "slug": "emoji", + "name": "Emoji", + "models": [model_id], + "total_models": 1, + "is_current": False, + } + ], + current_model="gpt-5-mini", + current_provider="emoji", + session_key="session-1", + on_model_selected=AsyncMock(return_value="ok"), + allowed_user_ids={"123"}, + ) + + view._build_model_select("emoji") + model_select = view.children[0] + option = model_select.options[0] + assert utf16_len(option.label) <= 100 + assert utf16_len(option.value) <= 100 + + @pytest.mark.asyncio async def test_expensive_model_requires_confirmation(monkeypatch): events: list[object] = []