feat(discord): render clarify choices as buttons

Brings Discord to parity with Telegram on the clarify tool's interactive
UX. Overrides BasePlatformAdapter.send_clarify on DiscordAdapter to attach
a button view when choices are present.

  - ClarifyChoiceView: one discord.ui.Button per choice (max 24, Discord's
    25-component view cap leaves one slot for Other) plus a final
    'Other (type answer)' button.
  - Numeric click -> tools.clarify_gateway.resolve_gateway_clarify(
    clarify_id, choice_text) using the canonical choice text from the
    gateway entry (falls back to the button label if the entry vanished).
  - Other click -> tools.clarify_gateway.mark_awaiting_text(clarify_id) so
    the gateway's text-intercept captures the next user message in this
    session as the response.
  - Auth via the shared _component_check_auth helper (same OR-semantics as
    ExecApprovalView / SlashConfirmView / UpdatePromptView / ModelPickerView).
  - Open-ended (no choices) path renders the prompt as a plain embed and
    relies on the existing text-intercept resolution.
  - Single-use: first valid click disables every button and updates the
    embed footer with who answered and what they chose.

No changes to BasePlatformAdapter.send_clarify or the gateway's
clarify_callback wiring -- the existing scaffolding already drives all
adapters; Discord just inherits the default text fallback today and gains
buttons by virtue of this override.

Test conftest extended: _FakeEmbed gains add_field() / set_footer() stubs
so tests can construct embedded views without monkey-patching per-test.

Original PR: #19249 by @LeonSGP43. This is a reshape of the contributor's
work onto current main's clarify infrastructure (clarify_id + entry-based
resolution shared with Telegram, instead of a parallel on_answer-closure
mechanism). The button view structure and UX shape are preserved.

Tests: 14 new tests in tests/gateway/test_discord_clarify_buttons.py.
391/391 existing Discord gateway tests still pass.

Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
This commit is contained in:
teknium1 2026-05-13 23:08:12 -07:00 committed by Teknium
parent c75e1a03f9
commit 1dca6a6960
3 changed files with 679 additions and 0 deletions

View file

@ -3896,6 +3896,84 @@ class DiscordAdapter(BasePlatformAdapter):
except Exception as e:
return SendResult(success=False, error=str(e))
async def send_clarify(
self,
chat_id: str,
question: str,
choices: Optional[list],
clarify_id: str,
session_key: str,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Render a clarify prompt with one Discord button per choice.
Multi-choice mode (``choices`` non-empty): renders a button per option
plus a final "✏️ Other (type answer)" button. Picking "Other" flips
the clarify entry into text-capture mode so the next user message in
the session becomes the response. Numeric clicks resolve immediately
via ``resolve_gateway_clarify(clarify_id, choice_text)``.
Open-ended mode (``choices`` empty/None): renders the question as
plain embed text no buttons. The gateway's text-intercept captures
the next message in this session and resolves the clarify.
"""
if not self._client or not DISCORD_AVAILABLE:
return SendResult(success=False, error="Not connected")
try:
target_id = chat_id
if metadata and metadata.get("thread_id"):
target_id = metadata["thread_id"]
channel = self._client.get_channel(int(target_id))
if not channel:
channel = await self._client.fetch_channel(int(target_id))
# Discord embed description limit is 4096; trim conservatively.
max_desc = 4088
body = str(question or "").strip()
if len(body) > max_desc:
body = body[: max_desc - 3] + "..."
embed = discord.Embed(
title="❓ Hermes needs your input",
description=body,
color=discord.Color.orange(),
)
clean_choices = [
str(c).strip() for c in (choices or []) if c is not None and str(c).strip()
]
# Discord allows up to 5 buttons per row, 5 rows per view = 25.
# We reserve one slot for the "Other" button, so cap at 24 choices.
clean_choices = clean_choices[:24]
if clean_choices:
embed.add_field(
name="Choices",
value="Pick one below, or click ✏️ Other to type a custom answer.",
inline=False,
)
view = ClarifyChoiceView(
choices=clean_choices,
clarify_id=clarify_id,
allowed_user_ids=self._allowed_user_ids,
allowed_role_ids=self._allowed_role_ids,
)
else:
embed.add_field(
name="Reply",
value="Reply in this channel with your answer.",
inline=False,
)
view = None
msg = await channel.send(embed=embed, view=view) if view else await channel.send(embed=embed)
return SendResult(success=True, message_id=str(msg.id))
except Exception as e:
logger.warning("[%s] send_clarify failed: %s", self.name, e)
return SendResult(success=False, error=str(e))
async def send_update_prompt(
self, chat_id: str, prompt: str, default: str = "",
session_key: str = "",
@ -5138,3 +5216,188 @@ if DISCORD_AVAILABLE:
async def on_timeout(self):
self.resolved = True
self.clear_items()
class ClarifyChoiceView(discord.ui.View):
"""Interactive button view for the clarify tool's multiple-choice prompts.
Renders one button per choice (max 24) plus a final `` Other`` button.
Picking a numeric choice resolves the gateway clarify entry immediately;
picking ``Other`` flips the entry into text-capture mode so the next
user message in the session becomes the response (the gateway's
text-intercept handles the resolution).
Auth gating mirrors ``ExecApprovalView`` only users/roles in the
Discord adapter's allowlist may answer. Single-use: after the first
valid click all buttons disable and the embed updates to show who
answered and what they chose.
"""
def __init__(
self,
choices: List[str],
clarify_id: str,
allowed_user_ids: set,
allowed_role_ids: Optional[set] = None,
):
super().__init__(timeout=300) # 5-minute timeout
self.choices = list(choices)[:24]
self.clarify_id = clarify_id
self.allowed_user_ids = allowed_user_ids
self.allowed_role_ids = allowed_role_ids or set()
self.resolved = False
for index, choice in enumerate(self.choices):
# Discord button labels are capped at 80 chars.
label_body = choice if len(choice) <= 75 else choice[:72] + "..."
button = discord.ui.Button(
label=f"{index + 1}. {label_body}",
style=discord.ButtonStyle.primary,
custom_id=f"clarify:{clarify_id}:{index}",
)
button.callback = self._make_choice_callback(index, choice)
self.add_item(button)
other_btn = discord.ui.Button(
label="✏️ Other (type answer)",
style=discord.ButtonStyle.secondary,
custom_id=f"clarify:{clarify_id}:other",
)
other_btn.callback = self._on_other
self.add_item(other_btn)
def _check_auth(self, interaction: "discord.Interaction") -> bool:
return _component_check_auth(
interaction, self.allowed_user_ids, self.allowed_role_ids,
)
def _make_choice_callback(self, index: int, choice: str):
async def _callback(interaction: "discord.Interaction"):
await self._resolve_choice(interaction, index, choice)
return _callback
async def _resolve_choice(
self,
interaction: "discord.Interaction",
index: int,
choice: str,
) -> None:
"""Resolve the clarify with a chosen option."""
if self.resolved:
await interaction.response.send_message(
"This prompt has already been answered~", ephemeral=True,
)
return
if not self._check_auth(interaction):
await interaction.response.send_message(
"You're not authorized to answer this prompt~", ephemeral=True,
)
return
self.resolved = True
for child in self.children:
child.disabled = True
embed = interaction.message.embeds[0] if (
interaction.message and interaction.message.embeds
) else None
if embed:
user = getattr(interaction, "user", None)
display_name = getattr(user, "display_name", "user")
embed.color = discord.Color.green()
embed.set_footer(text=f"Answered by {display_name}: {choice}")
try:
await interaction.response.edit_message(embed=embed, view=self)
except Exception:
logger.debug(
"Discord clarify edit_message failed for %s",
self.clarify_id,
exc_info=True,
)
try:
await interaction.response.defer()
except Exception:
pass
# Resolve via the gateway clarify primitive — same mechanism as
# Telegram. Look up the canonical choice text from the entry so
# we round-trip the original value, not a button-label variant.
resolved_text: Optional[str] = None
try:
from tools.clarify_gateway import _entries as _clarify_entries # type: ignore
entry = _clarify_entries.get(self.clarify_id)
if entry and entry.choices and 0 <= index < len(entry.choices):
resolved_text = entry.choices[index]
except Exception:
resolved_text = None
if resolved_text is None:
resolved_text = choice
try:
from tools.clarify_gateway import resolve_gateway_clarify
resolved = resolve_gateway_clarify(self.clarify_id, resolved_text)
logger.info(
"Discord clarify button resolved (id=%s, choice=%r, user=%s, ok=%s)",
self.clarify_id, resolved_text,
getattr(getattr(interaction, "user", None), "display_name", "?"),
resolved,
)
except Exception as exc:
logger.error(
"Discord clarify resolve_gateway_clarify failed (id=%s): %s",
self.clarify_id, exc,
)
async def _on_other(self, interaction: "discord.Interaction") -> None:
"""Flip the clarify entry into text-capture mode."""
if self.resolved:
await interaction.response.send_message(
"This prompt has already been answered~", ephemeral=True,
)
return
if not self._check_auth(interaction):
await interaction.response.send_message(
"You're not authorized to answer this prompt~", ephemeral=True,
)
return
# Don't pop the entry — the gateway's text-intercept needs it
# until the user actually types. Just mark it as awaiting text
# and disable the buttons so the user can't double-click.
try:
from tools.clarify_gateway import mark_awaiting_text
mark_awaiting_text(self.clarify_id)
except Exception as exc:
logger.warning(
"Discord clarify mark_awaiting_text failed (id=%s): %s",
self.clarify_id, exc,
)
self.resolved = True
for child in self.children:
child.disabled = True
embed = interaction.message.embeds[0] if (
interaction.message and interaction.message.embeds
) else None
if embed:
user = getattr(interaction, "user", None)
display_name = getattr(user, "display_name", "user")
embed.color = discord.Color.blue()
embed.set_footer(
text=f"Awaiting typed response from {display_name}",
)
try:
await interaction.response.edit_message(embed=embed, view=self)
except Exception:
try:
await interaction.response.defer()
except Exception:
pass
async def on_timeout(self):
self.resolved = True
for child in self.children:
child.disabled = True