Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 31 additions & 9 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ def __init__(self, id: int) -> None: # noqa: A002 - matches discord API
# 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",
Expand Down Expand Up @@ -117,11 +120,18 @@ def __init__(self, id: int) -> None: # noqa: A002 - matches discord API
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,
Expand Down Expand Up @@ -6293,7 +6303,10 @@ def _build_provider_select(self):
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,
)
Expand Down Expand Up @@ -6330,8 +6343,14 @@ def _build_model_select(self, provider_slug: str):
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:
Expand Down Expand Up @@ -6610,15 +6629,18 @@ def __init__(
# 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
Expand All @@ -6631,11 +6653,11 @@ def __init__(
(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,
Expand Down
14 changes: 14 additions & 0 deletions tests/gateway/test_discord_clarify_buttons.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
DiscordAdapter,
)
from gateway.config import PlatformConfig # noqa: E402
from gateway.platforms.base import utf16_len # noqa: E402


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -130,6 +131,19 @@ def test_truncates_long_choice_label(self):
# 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.
Expand Down
53 changes: 53 additions & 0 deletions tests/gateway/test_discord_model_picker.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import pytest

from gateway.platforms.base import utf16_len
from plugins.platforms.discord.adapter import ModelPickerView


Expand Down Expand Up @@ -82,6 +83,58 @@ async def edit_original_response(**kwargs):
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] = []
Expand Down