From 7f8128b1bf6109a8776e1a7422d082fc18a0cac1 Mon Sep 17 00:00:00 2001 From: S2P2 <1064779+S2P2@users.noreply.github.com> Date: Mon, 1 Jun 2026 01:04:54 +0700 Subject: [PATCH 1/2] feat(eko): add quick replies for exec approvals --- gateway/run.py | 24 ++++ plugins/platforms/eko/README.md | 4 +- plugins/platforms/eko/adapter.py | 144 +++++++++++++++++++ plugins/platforms/eko/client.py | 32 +++++ tests/gateway/test_approve_deny_commands.py | 39 ++++++ tests/gateway/test_eko_plugin.py | 146 ++++++++++++++++++++ 6 files changed, 388 insertions(+), 1 deletion(-) diff --git a/gateway/run.py b/gateway/run.py index 09f6f990bc7f..7ff1c7fc10d5 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7165,6 +7165,14 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # clearly moved on. _slash_confirm_mod.clear_if_stale(_quick_key) + if _tool_approval_live: + _approval_command = self._approval_command_for_text_reply(event.text) + if _approval_command: + _approval_event = dataclasses.replace(event, text=_approval_command) + if _approval_command.startswith("/approve"): + return await self._handle_approve_command(_approval_event) + return await self._handle_deny_command(_approval_event) + # PRIORITY handling when an agent is already running for this session. # Default behavior is to interrupt immediately so user text/stop messages # are handled with minimal latency. @@ -14113,6 +14121,22 @@ def _reply_anchor_for_event(event: MessageEvent) -> Optional[str]: _APPROVAL_TIMEOUT_SECONDS = 300 # 5 minutes + @staticmethod + def _approval_command_for_text_reply(text: str) -> Optional[str]: + """Map button-like text replies to approval slash commands. + + Some platforms (Eko quick replies) deliver taps as ordinary text, not + callbacks. Only exact labels are accepted, and callers should only use + this while a dangerous-command approval is actually pending. + """ + normalized = " ".join((text or "").strip().lower().split()) + return { + "approve once": "/approve", + "approve session": "/approve session", + "approve always": "/approve always", + "deny": "/deny", + }.get(normalized) + async def _handle_approve_command(self, event: MessageEvent) -> Optional[str]: """Handle /approve command — unblock waiting agent thread(s). diff --git a/plugins/platforms/eko/README.md b/plugins/platforms/eko/README.md index a47f099872aa..5f69f98ed797 100644 --- a/plugins/platforms/eko/README.md +++ b/plugins/platforms/eko/README.md @@ -335,6 +335,9 @@ targets. Without the explicit format, standalone delivery falls back to DM push. - Webhook signature verification via `X-Eko-Signature` (HMAC-SHA256-Base64) - Image receiving: download inbound pictures, cache locally, vision tool integration - Image sending: native multipart upload with reply token + push fallback +- Selectable prompts: clarify choices, slash confirmations, and dangerous + command approvals use Eko quick replies via `/bot/v1/message/quickreply` + when a reply token is available, with text fallback otherwise - File sending: push files to users via multipart upload - Sticker webhook events: surface `[sticker]` placeholder @@ -358,7 +361,6 @@ None currently. | Feature | Description | Notes | |---------|-------------|-------| -| Quick reply buttons | Tap-to-respond options for users | Eko supports it via `/bot/v1/message/quickreply` | | Compact tool progress | One-shot progress message on no-edit platforms | Issue #32 (pended — core gateway change) | ### Low priority diff --git a/plugins/platforms/eko/adapter.py b/plugins/platforms/eko/adapter.py index 7a0d44f4cfd1..c712a0cd608a 100644 --- a/plugins/platforms/eko/adapter.py +++ b/plugins/platforms/eko/adapter.py @@ -671,6 +671,150 @@ def _has_mention_trigger(self, text: str) -> bool: # Outbound send (text) # ------------------------------------------------------------------ + async def send_exec_approval( + self, + chat_id: str, + command: str, + session_key: str, + description: str = "dangerous command", + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Render dangerous-command approval as Eko quick replies. + + Eko quick replies are reply-token only. If no fresh token is + available, return unsupported so the gateway sends its text fallback. + Quick-reply taps arrive as ordinary text; gateway text handling maps + these labels to the same approval actions as typed slash commands. + """ + if not self._client: + return SendResult(success=False, error="Eko adapter not connected") + + token, used_reply = self._consume_reply_token(chat_id) + if not used_reply: + return SendResult(success=False, error="No Eko reply token available") + + cmd_preview = command[:3800] + "..." if len(command) > 3800 else command + prompt = ( + "⚠️ Command Approval Required\n\n" + f"```{cmd_preview}```\n\n" + f"Reason: {description}" + ) + + try: + await self._client.reply_quick_reply( + token, + prompt, + ["Approve Once", "Approve Session", "Approve Always", "Deny"], + ) + except Exception as exc: + logger.debug( + "Eko: exec-approval quick reply failed, falling back to text prompt: %s", + exc, + ) + return SendResult(success=False, error=str(exc), retryable=True) + + return SendResult(success=True, message_id=token) + + async def send_slash_confirm( + self, + chat_id: str, + title: str, + message: str, + session_key: str, + confirm_id: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Render slash confirmations as Eko quick replies when possible. + + Eko quick replies are reply-token only. If no fresh token is + available, return unsupported so the gateway sends its text fallback. + Quick-reply taps arrive as ordinary text; the gateway already maps + "Approve Once" / "Always Approve" / "Cancel" to confirm choices. + """ + if not self._client: + return SendResult(success=False, error="Eko adapter not connected") + + token, used_reply = self._consume_reply_token(chat_id) + if not used_reply: + return SendResult(success=False, error="No Eko reply token available") + + try: + await self._client.reply_quick_reply( + token, + message, + ["Approve Once", "Always Approve", "Cancel"], + ) + except Exception as exc: + logger.debug( + "Eko: slash-confirm quick reply failed, falling back to text prompt: %s", + exc, + ) + return SendResult(success=False, error=str(exc), retryable=True) + + return SendResult(success=True, message_id=token) + + 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 clarify choices as Eko quick replies when possible. + + Eko quick replies are reply-token only. If no fresh token is + available, fall back to the base numbered-text prompt. + """ + if not choices or not self._client: + return await super().send_clarify( + chat_id=chat_id, + question=question, + choices=choices, + clarify_id=clarify_id, + session_key=session_key, + metadata=metadata, + ) + + token, used_reply = self._consume_reply_token(chat_id) + if not used_reply: + return await super().send_clarify( + chat_id=chat_id, + question=question, + choices=choices, + clarify_id=clarify_id, + session_key=session_key, + metadata=metadata, + ) + + try: + await self._client.reply_quick_reply( + token, + question, + [str(c) for c in choices], + ) + except Exception as exc: + logger.debug( + "Eko: quick reply failed, falling back to text prompt: %s", + exc, + ) + return await super().send_clarify( + chat_id=chat_id, + question=question, + choices=choices, + clarify_id=clarify_id, + session_key=session_key, + metadata=metadata, + ) + + # Eko quick-reply taps arrive back as ordinary text messages with a + # fresh reply token. Mark this clarify as text-capturing so the + # gateway resolves it instead of starting a new agent turn. + from tools.clarify_gateway import mark_awaiting_text + mark_awaiting_text(clarify_id) + return SendResult(success=True, message_id=token) + async def send( self, chat_id: str, diff --git a/plugins/platforms/eko/client.py b/plugins/platforms/eko/client.py index 992816d0a572..e974852d22b6 100644 --- a/plugins/platforms/eko/client.py +++ b/plugins/platforms/eko/client.py @@ -254,6 +254,38 @@ async def reply_text(self, reply_token: str, message: str) -> None: data.add_field("replyToken", reply_token) await self._request_form("/bot/v1/message/text", data=data) + async def reply_quick_reply( + self, + reply_token: str, + message: str, + choices: List[str], + ) -> None: + """Send a quick-reply prompt using a reply token.""" + items = [ + { + "data": {"text": choice}, + "type": "label", + "value": choice, + } + for choice in choices + ] + await self._request_json_post( + "/bot/v1/message/quickreply", + json={ + "replyToken": reply_token, + "message": { + "data": message, + "meta": { + "quickreply": { + "template": "default", + "items": items, + } + }, + }, + }, + expect_json=False, + ) + async def push_text(self, uid: str, message: str) -> None: """Push a text message to a user by uid.""" await self._request_json_post( diff --git a/tests/gateway/test_approve_deny_commands.py b/tests/gateway/test_approve_deny_commands.py index 1c996b2baee0..df6f937e282b 100644 --- a/tests/gateway/test_approve_deny_commands.py +++ b/tests/gateway/test_approve_deny_commands.py @@ -190,6 +190,28 @@ def test_clear_session_denies_and_signals_all_entries(self): assert session_key not in _gateway_queues +# ------------------------------------------------------------------ +# Quick-reply label mapping +# ------------------------------------------------------------------ + + +class TestApprovalQuickReplyLabels: + + def test_maps_exact_eko_labels_to_slash_commands(self): + from gateway.run import GatewayRunner + + assert GatewayRunner._approval_command_for_text_reply("Approve Once") == "/approve" + assert GatewayRunner._approval_command_for_text_reply("Approve Session") == "/approve session" + assert GatewayRunner._approval_command_for_text_reply("Approve Always") == "/approve always" + assert GatewayRunner._approval_command_for_text_reply("Deny") == "/deny" + + def test_ignores_non_label_text(self): + from gateway.run import GatewayRunner + + assert GatewayRunner._approval_command_for_text_reply("yes") is None + assert GatewayRunner._approval_command_for_text_reply("approve") is None + + # ------------------------------------------------------------------ # /approve command # ------------------------------------------------------------------ @@ -260,6 +282,23 @@ async def test_approve_no_pending(self): result = await runner._handle_approve_command(_make_event("/approve")) assert "No pending command" in result + @pytest.mark.asyncio + async def test_text_quick_reply_label_resolves_session_scope(self): + """Eko quick-reply labels are ordinary text, not slash commands.""" + from tools.approval import _ApprovalEntry, _gateway_queues + + runner = _make_runner() + source = _make_source() + session_key = runner._session_key_for_source(source) + + entry = _ApprovalEntry({"command": "test"}) + _gateway_queues[session_key] = [entry] + + result = await runner._handle_message(_make_event("Approve Session")) + + assert "session" in result.lower() + assert entry.result == "session" + @pytest.mark.asyncio async def test_approve_stale_old_style_pending(self): """Old-style _pending_approvals without blocking event reports expired.""" diff --git a/tests/gateway/test_eko_plugin.py b/tests/gateway/test_eko_plugin.py index 0be6682fa30b..47ec0f889cc3 100644 --- a/tests/gateway/test_eko_plugin.py +++ b/tests/gateway/test_eko_plugin.py @@ -642,6 +642,131 @@ async def test_not_connected_returns_error(self): assert "not connected" in result.error +# --------------------------------------------------------------------------- +# 5a. Selectable quick replies +# --------------------------------------------------------------------------- + +class TestExecApprovalQuickReplies: + + @pytest.mark.asyncio + async def test_send_exec_approval_uses_quick_reply_with_reply_token(self): + adapter = EkoAdapter.__new__(EkoAdapter) + adapter._reply_tokens = {"chat1": ("tok_abc", time.time() + 50)} + adapter._client = MagicMock(reply_quick_reply=AsyncMock()) + + result = await adapter.send_exec_approval( + chat_id="chat1", + command="rm -rf /tmp/example", + session_key="sk-eko", + description="test approval", + ) + + assert result.success + adapter._client.reply_quick_reply.assert_called_once() + args = adapter._client.reply_quick_reply.call_args.args + assert args[0] == "tok_abc" + assert "rm -rf /tmp/example" in args[1] + assert "test approval" in args[1] + assert args[2] == [ + "Approve Once", + "Approve Session", + "Approve Always", + "Deny", + ] + assert "chat1" not in adapter._reply_tokens + + @pytest.mark.asyncio + async def test_send_exec_approval_without_reply_token_uses_text_fallback(self): + adapter = EkoAdapter.__new__(EkoAdapter) + adapter._reply_tokens = {} + adapter._client = MagicMock(reply_quick_reply=AsyncMock()) + + result = await adapter.send_exec_approval( + chat_id="chat1", + command="rm -rf /tmp/example", + session_key="sk-eko", + ) + + assert not result.success + adapter._client.reply_quick_reply.assert_not_called() + + +class TestSlashConfirmQuickReplies: + + @pytest.mark.asyncio + async def test_send_slash_confirm_uses_quick_reply_with_reply_token(self): + adapter = EkoAdapter.__new__(EkoAdapter) + adapter._reply_tokens = {"chat1": ("tok_abc", time.time() + 50)} + adapter._client = MagicMock(reply_quick_reply=AsyncMock()) + + result = await adapter.send_slash_confirm( + chat_id="chat1", + title="/new", + message="Confirm /new?", + session_key="sk-eko", + confirm_id="confirm-1", + ) + + assert result.success + adapter._client.reply_quick_reply.assert_called_once_with( + "tok_abc", + "Confirm /new?", + ["Approve Once", "Always Approve", "Cancel"], + ) + assert "chat1" not in adapter._reply_tokens + + @pytest.mark.asyncio + async def test_send_slash_confirm_without_reply_token_uses_text_fallback(self): + adapter = EkoAdapter.__new__(EkoAdapter) + adapter._reply_tokens = {} + adapter._client = MagicMock(reply_quick_reply=AsyncMock()) + + result = await adapter.send_slash_confirm( + chat_id="chat1", + title="/new", + message="Confirm /new?", + session_key="sk-eko", + confirm_id="confirm-1", + ) + + assert not result.success + adapter._client.reply_quick_reply.assert_not_called() + + +class TestClarifyQuickReplies: + + @pytest.mark.asyncio + async def test_send_clarify_uses_quick_reply_with_reply_token(self): + from tools import clarify_gateway as cm + + cm.clear_session("sk-eko") + cm.register("cid-eko", "sk-eko", "Pick one?", ["A", "B"]) + + adapter = EkoAdapter.__new__(EkoAdapter) + adapter._reply_tokens = {"chat1": ("tok_abc", time.time() + 50)} + adapter._client = MagicMock(reply_quick_reply=AsyncMock()) + + try: + result = await adapter.send_clarify( + chat_id="chat1", + question="Pick one?", + choices=["A", "B"], + clarify_id="cid-eko", + session_key="sk-eko", + ) + + assert result.success + adapter._client.reply_quick_reply.assert_called_once_with( + "tok_abc", "Pick one?", ["A", "B"] + ) + assert "chat1" not in adapter._reply_tokens + pending = cm.get_pending_for_session("sk-eko") + assert pending is not None + assert pending.awaiting_text is True + finally: + cm.clear_session("sk-eko") + + # --------------------------------------------------------------------------- # 5b. Outbound chunking # --------------------------------------------------------------------------- @@ -1051,6 +1176,27 @@ async def test_reply_picture_sends_multipart(self): call_args = mock_session.post.call_args assert call_args[0][0].endswith("/bot/v1/message/picture") + @pytest.mark.asyncio + async def test_reply_quick_reply_sends_json(self): + mock_aiohttp = _mock_aiohttp_for_post(200) + client = _make_eko_client() + + with patch.dict("sys.modules", {"aiohttp": mock_aiohttp}): + await client.reply_quick_reply("reply_tok", "Pick one?", ["A", "B"]) + + mock_session = mock_aiohttp.ClientSession.return_value + mock_session.post.assert_called_once() + call_args = mock_session.post.call_args + assert call_args[0][0].endswith("/bot/v1/message/quickreply") + payload = call_args.kwargs["json"] + assert payload["replyToken"] == "reply_tok" + assert payload["message"]["data"] == "Pick one?" + items = payload["message"]["meta"]["quickreply"]["items"] + assert items == [ + {"data": {"text": "A"}, "type": "label", "value": "A"}, + {"data": {"text": "B"}, "type": "label", "value": "B"}, + ] + @pytest.mark.asyncio async def test_push_file_sends_multipart(self): mock_aiohttp = _mock_aiohttp_for_post(200) From 9a45c74f26aa1ff0cb6c4bac8470aced2512b69e Mon Sep 17 00:00:00 2001 From: S2P2 <1064779+S2P2@users.noreply.github.com> Date: Mon, 1 Jun 2026 01:25:02 +0700 Subject: [PATCH 2/2] fix(eko): set quick-reply value to slash commands so taps bypass agent-active queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach mapped approval labels in gateway/run.py, but plain-text quick-reply taps were silently queued by base.py before reaching the runner. Setting value=/approve etc. makes the tap arrive as a real slash command that bypasses the queue via the existing command-dispatch path. Removes _approval_command_for_text_reply() and its intercept block from gateway/run.py — fewer lines, no new bypass path. --- gateway/run.py | 24 ------------- plugins/platforms/eko/adapter.py | 12 ++++--- plugins/platforms/eko/client.py | 12 +++++-- tests/gateway/test_approve_deny_commands.py | 39 --------------------- tests/gateway/test_eko_plugin.py | 17 ++++++--- 5 files changed, 30 insertions(+), 74 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 7ff1c7fc10d5..09f6f990bc7f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7165,14 +7165,6 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # clearly moved on. _slash_confirm_mod.clear_if_stale(_quick_key) - if _tool_approval_live: - _approval_command = self._approval_command_for_text_reply(event.text) - if _approval_command: - _approval_event = dataclasses.replace(event, text=_approval_command) - if _approval_command.startswith("/approve"): - return await self._handle_approve_command(_approval_event) - return await self._handle_deny_command(_approval_event) - # PRIORITY handling when an agent is already running for this session. # Default behavior is to interrupt immediately so user text/stop messages # are handled with minimal latency. @@ -14121,22 +14113,6 @@ def _reply_anchor_for_event(event: MessageEvent) -> Optional[str]: _APPROVAL_TIMEOUT_SECONDS = 300 # 5 minutes - @staticmethod - def _approval_command_for_text_reply(text: str) -> Optional[str]: - """Map button-like text replies to approval slash commands. - - Some platforms (Eko quick replies) deliver taps as ordinary text, not - callbacks. Only exact labels are accepted, and callers should only use - this while a dangerous-command approval is actually pending. - """ - normalized = " ".join((text or "").strip().lower().split()) - return { - "approve once": "/approve", - "approve session": "/approve session", - "approve always": "/approve always", - "deny": "/deny", - }.get(normalized) - async def _handle_approve_command(self, event: MessageEvent) -> Optional[str]: """Handle /approve command — unblock waiting agent thread(s). diff --git a/plugins/platforms/eko/adapter.py b/plugins/platforms/eko/adapter.py index c712a0cd608a..be2073767fad 100644 --- a/plugins/platforms/eko/adapter.py +++ b/plugins/platforms/eko/adapter.py @@ -683,8 +683,9 @@ async def send_exec_approval( Eko quick replies are reply-token only. If no fresh token is available, return unsupported so the gateway sends its text fallback. - Quick-reply taps arrive as ordinary text; gateway text handling maps - these labels to the same approval actions as typed slash commands. + Button ``value`` fields are set to slash commands (e.g. ``/approve``) + so the tap arrives as a real command that bypasses the agent-active + queue in base.py. """ if not self._client: return SendResult(success=False, error="Eko adapter not connected") @@ -705,6 +706,7 @@ async def send_exec_approval( token, prompt, ["Approve Once", "Approve Session", "Approve Always", "Deny"], + values=["/approve", "/approve session", "/approve always", "/deny"], ) except Exception as exc: logger.debug( @@ -728,8 +730,9 @@ async def send_slash_confirm( Eko quick replies are reply-token only. If no fresh token is available, return unsupported so the gateway sends its text fallback. - Quick-reply taps arrive as ordinary text; the gateway already maps - "Approve Once" / "Always Approve" / "Cancel" to confirm choices. + Button ``value`` fields are set to slash commands (e.g. ``/approve``) + so the tap arrives as a real command that bypasses the agent-active + queue in base.py. """ if not self._client: return SendResult(success=False, error="Eko adapter not connected") @@ -743,6 +746,7 @@ async def send_slash_confirm( token, message, ["Approve Once", "Always Approve", "Cancel"], + values=["/approve", "/always", "/cancel"], ) except Exception as exc: logger.debug( diff --git a/plugins/platforms/eko/client.py b/plugins/platforms/eko/client.py index e974852d22b6..87289c7b808c 100644 --- a/plugins/platforms/eko/client.py +++ b/plugins/platforms/eko/client.py @@ -259,15 +259,21 @@ async def reply_quick_reply( reply_token: str, message: str, choices: List[str], + values: Optional[List[str]] = None, ) -> None: - """Send a quick-reply prompt using a reply token.""" + """Send a quick-reply prompt using a reply token. + + ``choices`` sets the display label (``data.text``). ``values`` sets + the reply payload (``value``). When ``values`` is omitted, each + choice is used as its own value (backward-compatible). + """ items = [ { "data": {"text": choice}, "type": "label", - "value": choice, + "value": (values[i] if values else choice), } - for choice in choices + for i, choice in enumerate(choices) ] await self._request_json_post( "/bot/v1/message/quickreply", diff --git a/tests/gateway/test_approve_deny_commands.py b/tests/gateway/test_approve_deny_commands.py index df6f937e282b..1c996b2baee0 100644 --- a/tests/gateway/test_approve_deny_commands.py +++ b/tests/gateway/test_approve_deny_commands.py @@ -190,28 +190,6 @@ def test_clear_session_denies_and_signals_all_entries(self): assert session_key not in _gateway_queues -# ------------------------------------------------------------------ -# Quick-reply label mapping -# ------------------------------------------------------------------ - - -class TestApprovalQuickReplyLabels: - - def test_maps_exact_eko_labels_to_slash_commands(self): - from gateway.run import GatewayRunner - - assert GatewayRunner._approval_command_for_text_reply("Approve Once") == "/approve" - assert GatewayRunner._approval_command_for_text_reply("Approve Session") == "/approve session" - assert GatewayRunner._approval_command_for_text_reply("Approve Always") == "/approve always" - assert GatewayRunner._approval_command_for_text_reply("Deny") == "/deny" - - def test_ignores_non_label_text(self): - from gateway.run import GatewayRunner - - assert GatewayRunner._approval_command_for_text_reply("yes") is None - assert GatewayRunner._approval_command_for_text_reply("approve") is None - - # ------------------------------------------------------------------ # /approve command # ------------------------------------------------------------------ @@ -282,23 +260,6 @@ async def test_approve_no_pending(self): result = await runner._handle_approve_command(_make_event("/approve")) assert "No pending command" in result - @pytest.mark.asyncio - async def test_text_quick_reply_label_resolves_session_scope(self): - """Eko quick-reply labels are ordinary text, not slash commands.""" - from tools.approval import _ApprovalEntry, _gateway_queues - - runner = _make_runner() - source = _make_source() - session_key = runner._session_key_for_source(source) - - entry = _ApprovalEntry({"command": "test"}) - _gateway_queues[session_key] = [entry] - - result = await runner._handle_message(_make_event("Approve Session")) - - assert "session" in result.lower() - assert entry.result == "session" - @pytest.mark.asyncio async def test_approve_stale_old_style_pending(self): """Old-style _pending_approvals without blocking event reports expired.""" diff --git a/tests/gateway/test_eko_plugin.py b/tests/gateway/test_eko_plugin.py index 47ec0f889cc3..24e756bb5cbe 100644 --- a/tests/gateway/test_eko_plugin.py +++ b/tests/gateway/test_eko_plugin.py @@ -663,7 +663,7 @@ async def test_send_exec_approval_uses_quick_reply_with_reply_token(self): assert result.success adapter._client.reply_quick_reply.assert_called_once() - args = adapter._client.reply_quick_reply.call_args.args + args, kwargs = adapter._client.reply_quick_reply.call_args assert args[0] == "tok_abc" assert "rm -rf /tmp/example" in args[1] assert "test approval" in args[1] @@ -673,6 +673,12 @@ async def test_send_exec_approval_uses_quick_reply_with_reply_token(self): "Approve Always", "Deny", ] + assert kwargs["values"] == [ + "/approve", + "/approve session", + "/approve always", + "/deny", + ] assert "chat1" not in adapter._reply_tokens @pytest.mark.asyncio @@ -712,6 +718,7 @@ async def test_send_slash_confirm_uses_quick_reply_with_reply_token(self): "tok_abc", "Confirm /new?", ["Approve Once", "Always Approve", "Cancel"], + values=["/approve", "/always", "/cancel"], ) assert "chat1" not in adapter._reply_tokens @@ -1182,7 +1189,9 @@ async def test_reply_quick_reply_sends_json(self): client = _make_eko_client() with patch.dict("sys.modules", {"aiohttp": mock_aiohttp}): - await client.reply_quick_reply("reply_tok", "Pick one?", ["A", "B"]) + await client.reply_quick_reply( + "reply_tok", "Pick one?", ["A", "B"], values=["/a", "/b"], + ) mock_session = mock_aiohttp.ClientSession.return_value mock_session.post.assert_called_once() @@ -1193,8 +1202,8 @@ async def test_reply_quick_reply_sends_json(self): assert payload["message"]["data"] == "Pick one?" items = payload["message"]["meta"]["quickreply"]["items"] assert items == [ - {"data": {"text": "A"}, "type": "label", "value": "A"}, - {"data": {"text": "B"}, "type": "label", "value": "B"}, + {"data": {"text": "A"}, "type": "label", "value": "/a"}, + {"data": {"text": "B"}, "type": "label", "value": "/b"}, ] @pytest.mark.asyncio