fix(discord): bound component labels by UTF-16 units

This commit is contained in:
ooiuuii 2026-06-29 23:08:03 +08:00 committed by Teknium
parent b8ce583e05
commit 87be36c240
3 changed files with 98 additions and 9 deletions

View file

@ -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,

View file

@ -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.

View file

@ -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] = []