fix(discord): mirror all interactive prompt payloads into message content

Extends the send_exec_approval embed-invisibility fix to its three
sibling prompt surfaces — send_slash_confirm, send_clarify, and
send_update_prompt — via a shared _self_contained_prompt_content()
helper. All four interactive views now carry their payload in plain
content next to the buttons; the embed stays as progressive
enhancement for clients that render it. Adds gold to the conftest
discord Color mock (update prompt is the only gold user).
This commit is contained in:
teknium1 2026-07-07 06:01:49 -07:00 committed by Teknium
parent 2acbdd1848
commit 009b42d008
3 changed files with 171 additions and 3 deletions

View file

@ -5429,6 +5429,29 @@ class DiscordAdapter(BasePlatformAdapter):
)
return None
def _self_contained_prompt_content(
self, header: str, body: str, *, code_block: bool = False, tail: str = ""
) -> str:
"""Build plain message content that mirrors an embed's payload.
Discord embeds can be invisible or visually separated from the
component row on some clients (notably web/mobile), so interactive
prompts must carry their payload in plain ``content`` next to the
buttons. The embed stays as progressive enhancement.
"""
body = str(body or "")
if code_block:
prefix = f"{header}\n```bash\n"
suffix = f"\n```{tail}"
else:
prefix = f"{header}\n\n"
suffix = tail
truncated_suffix = "\n... [truncated]"
budget = max(0, self.MAX_MESSAGE_LENGTH - len(prefix) - len(suffix))
if len(body) > budget:
body = body[: max(0, budget - len(truncated_suffix))] + truncated_suffix
return f"{prefix}{body}{suffix}"
async def send_exec_approval(
self, chat_id: str, command: str, session_key: str,
description: str = "dangerous command",
@ -5535,6 +5558,11 @@ class DiscordAdapter(BasePlatformAdapter):
description=body,
color=discord.Color.orange(),
)
# Mirror the payload in plain content — embeds are invisible on
# some clients (see send_exec_approval).
content = self._self_contained_prompt_content(
f"**{title or 'Confirm'}**", message
)
view = SlashConfirmView(
session_key=session_key,
@ -5543,7 +5571,7 @@ class DiscordAdapter(BasePlatformAdapter):
allowed_role_ids=self._allowed_role_ids,
)
msg = await channel.send(embed=embed, view=view)
msg = await channel.send(content=content, embed=embed, view=view)
view._message = msg # store for on_timeout expiration editing
return SendResult(success=True, message_id=str(msg.id))
except Exception as e:
@ -5657,7 +5685,18 @@ class DiscordAdapter(BasePlatformAdapter):
)
view = None
msg = await channel.send(embed=embed, view=view) if view else await channel.send(embed=embed)
# Mirror the question in plain content — embeds are invisible on
# some clients (see send_exec_approval).
clarify_tail = (
"\n\nPick one below, or click ✏️ Other to type a custom answer."
if clean_choices
else "\n\nReply in this channel with your answer."
)
content = self._self_contained_prompt_content(
"❓ **Hermes needs your input**", str(question or "").strip(),
tail=clarify_tail,
)
msg = await channel.send(content=content, embed=embed, view=view) if view else await channel.send(content=content, embed=embed)
if view:
view._message = msg # store for on_timeout expiration editing
return SendResult(success=True, message_id=str(msg.id))
@ -5694,7 +5733,12 @@ class DiscordAdapter(BasePlatformAdapter):
allowed_user_ids=self._allowed_user_ids,
allowed_role_ids=self._allowed_role_ids,
)
msg = await channel.send(embed=embed, view=view)
# Mirror the prompt in plain content — embeds are invisible on
# some clients (see send_exec_approval).
content = self._self_contained_prompt_content(
"⚕ **Update Needs Your Input**", f"{prompt}{default_hint}"
)
msg = await channel.send(content=content, embed=embed, view=view)
view._message = msg # store for on_timeout expiration editing
if _metadata_marks_nonconversational(metadata):
self._nonconversational_messages.mark_many([str(msg.id)])

View file

@ -191,6 +191,7 @@ def _ensure_discord_mock() -> None:
discord_mod.Color = SimpleNamespace(
orange=lambda: 1, green=lambda: 2, blue=lambda: 3,
red=lambda: 4, purple=lambda: 5, greyple=lambda: 6,
gold=lambda: 7,
)
# app_commands — needed by _register_slash_commands auto-registration

View file

@ -0,0 +1,123 @@
"""Sibling coverage for the embed-invisibility fix (send_exec_approval got it
in the same PR): slash confirm, clarify, and update prompts must also mirror
their payload into plain message content, since embeds don't render on some
Discord clients (web/mobile)."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.discord.adapter import DiscordAdapter
def _capture_channel(adapter):
sent = {}
async def fake_send(**kwargs):
sent.update(kwargs)
return SimpleNamespace(id=1234)
channel = SimpleNamespace(send=AsyncMock(side_effect=fake_send))
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: channel,
fetch_channel=AsyncMock(),
)
return sent
@pytest.mark.asyncio
async def test_slash_confirm_mirrors_message_into_content():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
sent = _capture_channel(adapter)
result = await adapter.send_slash_confirm(
chat_id="555",
title="Reset session?",
message="This will clear the current conversation history.",
session_key="discord:555",
confirm_id="c1",
)
assert result.success is True
assert sent["view"] is not None
assert sent["embed"] is not None
assert "Reset session?" in sent["content"]
assert "clear the current conversation history" in sent["content"]
@pytest.mark.asyncio
async def test_slash_confirm_truncates_long_message_in_content():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
sent = _capture_channel(adapter)
result = await adapter.send_slash_confirm(
chat_id="555",
title="Confirm",
message="y" * 5000,
session_key="discord:555",
confirm_id="c2",
)
assert result.success is True
assert len(sent["content"]) <= adapter.MAX_MESSAGE_LENGTH
assert "... [truncated]" in sent["content"]
@pytest.mark.asyncio
async def test_clarify_with_choices_mirrors_question_into_content():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
sent = _capture_channel(adapter)
result = await adapter.send_clarify(
chat_id="555",
question="Which environment should I deploy to?",
choices=["staging", "production"],
clarify_id="cl1",
session_key="discord:555",
)
assert result.success is True
assert sent["view"] is not None
assert "Hermes needs your input" in sent["content"]
assert "Which environment should I deploy to?" in sent["content"]
assert "Pick one below" in sent["content"]
@pytest.mark.asyncio
async def test_clarify_without_choices_mirrors_question_and_reply_hint():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
sent = _capture_channel(adapter)
result = await adapter.send_clarify(
chat_id="555",
question="What should the cron schedule be?",
choices=[],
clarify_id="cl2",
session_key="discord:555",
)
assert result.success is True
assert sent.get("view") is None
assert "What should the cron schedule be?" in sent["content"]
assert "Reply in this channel" in sent["content"]
@pytest.mark.asyncio
async def test_update_prompt_mirrors_prompt_into_content():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
sent = _capture_channel(adapter)
result = await adapter.send_update_prompt(
chat_id="555",
prompt="Restore stashed changes?",
default="yes",
session_key="discord:555",
)
assert result.success is True
assert sent["view"] is not None
assert "Update Needs Your Input" in sent["content"]
assert "Restore stashed changes?" in sent["content"]
assert "(default: yes)" in sent["content"]