From 108b7510986a2de04e2e2b5cb00c88e8cf732a43 Mon Sep 17 00:00:00 2001 From: donrhmexe Date: Tue, 7 Apr 2026 16:24:30 +0200 Subject: [PATCH] feat(telegram): generic inline keyboard buttons for gateway commands Add a reusable inline keyboard button system for Telegram gateway commands, complementing the existing model picker. Architecture: - BasePlatformAdapter.send_inline_options() with text fallback - TelegramAdapter override renders native InlineKeyboardButton - Generic callback routing: 'cmd:args' -> synthetic '/cmd args' - Dedicated cpg: prefix for /commands pagination (edit-in-place) - Cancel button (mc) and approval buttons (approve:once/session/always/deny) - gateway_runner ref set on all adapters for cross-reference Commands with inline buttons: - /commands: paginated Prev/Next that edits in-place (like model picker) - /personality: picker with all configured personalities - /rollback: checkpoint list picker - /resume: named session picker - Dangerous command approval: Allow Once/Session/Always/Deny buttons Fixes: - Drop ParseMode.MARKDOWN from callback edits (skill descriptions with unbalanced backticks break Telegram's markdown parser) - Truncate skill descriptions to 120 chars in /commands pages - Use getattr for dynamically-set gateway_runner attribute --- gateway/platforms/base.py | 26 ++++ gateway/platforms/telegram.py | 223 ++++++++++++++++++++++++++++++---- gateway/run.py | 105 +++++++++++++--- 3 files changed, 312 insertions(+), 42 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 66fc5bac22f4..53729fdcefee 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -628,6 +628,32 @@ async def edit_message( """ return SendResult(success=False, error="Not supported") + async def send_inline_options( + self, + chat_id: str, + text: str, + options: list, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a message with interactive options. + + *options* is a list of rows. Each row is a list of dicts with + ``"label"`` (display text) and ``"data"`` (callback identifier):: + + [[{"label": "High", "data": "reasoning:high"}, + {"label": "Low", "data": "reasoning:low"}], + [{"label": "Cancel", "data": "mc"}]] + + Platform adapters override this to render native interactive + elements (inline keyboards, buttons, select menus). The default + implementation renders a plain text list and sends via ``send()``. + """ + lines = [text, ""] + for row in options: + labels = " ".join(f"• {opt['label']}" for opt in row) + lines.append(labels) + return await self.send(chat_id, "\n".join(lines), metadata=metadata) + async def send_typing(self, chat_id: str, metadata=None) -> None: """ Send a typing indicator. diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 355bf3aee761..baef798dd447 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -979,6 +979,63 @@ async def edit_message( ) return SendResult(success=False, error=str(e)) + 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: + """Send button-based exec approval prompt for a dangerous command. + + Called by the gateway's approval notify callback (which already + checks for this method via ``hasattr``). Same interface as + Discord's ``send_exec_approval``. + """ + cmd_preview = command[:200] + "..." if len(command) > 200 else command + text = ( + f"⚠️ *Approval required:*\n" + f"```\n{cmd_preview}\n```\n" + f"Reason: {description}" + ) + options = [ + [ + {"label": "✓ Allow Once", "data": f"approve:once:{session_key}"}, + {"label": "Allow Session", "data": f"approve:session:{session_key}"}, + ], + [ + {"label": "Always Allow", "data": f"approve:always:{session_key}"}, + {"label": "✗ Deny", "data": f"approve:deny:{session_key}"}, + ], + ] + return await self.send_inline_options(chat_id, text, options, metadata) + + async def send_inline_options(self, chat_id, text, options, metadata=None): + """Send a message with inline keyboard buttons. + + Overrides the base text fallback with native Telegram buttons. + """ + if not self._bot: + return SendResult(success=False, error="Not connected") + try: + buttons = [] + for row in options: + buttons.append([ + InlineKeyboardButton(opt["label"], callback_data=opt["data"]) + for opt in row + ]) + keyboard = InlineKeyboardMarkup(buttons) + thread_id = metadata.get("thread_id") if metadata else None + msg = await self._bot.send_message( + chat_id=int(chat_id), + text=text, + parse_mode=ParseMode.MARKDOWN, + reply_markup=keyboard, + message_thread_id=int(thread_id) if thread_id else None, + ) + return SendResult(success=True, message_id=str(msg.message_id)) + except Exception as e: + logger.warning("[%s] send_inline_options 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 = "", @@ -1308,7 +1365,15 @@ def get_label(slug): async def _handle_callback_query( self, update: "Update", context: "ContextTypes.DEFAULT_TYPE" ) -> None: - """Handle inline keyboard button clicks.""" + """Handle inline keyboard button clicks. + + Routes by callback_data prefix: + - ``mp:/mm:/mb/mx/mg:`` — model picker (upstream) + - ``update_prompt:`` — hermes update yes/no + - ``approve:`` — dangerous command approval + - ``mc`` — generic cancel (edit message, remove buttons) + - ``cmd:args`` — generic: synthetic ``/cmd args`` through _message_handler + """ query = update.callback_query if not query or not query.data: return @@ -1322,32 +1387,146 @@ async def _handle_callback_query( return # --- Update prompt callbacks --- - if not data.startswith("update_prompt:"): + if data.startswith("update_prompt:"): + answer = data.split(":", 1)[1] + await query.answer(text=f"Sent '{answer}' to the update process.") + label = "Yes" if answer == "y" else "No" + try: + await query.edit_message_text( + text=f"⚕ Update prompt answered: *{label}*", + parse_mode=ParseMode.MARKDOWN, + reply_markup=None, + ) + except Exception: + pass + try: + from hermes_constants import get_hermes_home + home = get_hermes_home() + response_path = home / ".update_response" + tmp = response_path.with_suffix(".tmp") + tmp.write_text(answer) + tmp.replace(response_path) + logger.info("Telegram update prompt answered '%s' by user %s", + answer, getattr(query.from_user, "id", "unknown")) + except Exception as exc: + logger.error("Failed to write update response from callback: %s", exc) + return + + # --- Cancel: edit message, remove buttons --- + if data == "mc": + await query.answer(text="Cancelled") + try: + await query.edit_message_text("Cancelled.") + except Exception: + pass + return + + # --- Commands pagination: edit in-place --- + if data.startswith("cpg:"): + await self._on_commands_page_callback(query, data) return - answer = data.split(":", 1)[1] # "y" or "n" - await query.answer(text=f"Sent '{answer}' to the update process.") - # Edit the message to show the choice and remove buttons - label = "Yes" if answer == "y" else "No" + + # --- Approval: special handler --- + if data.startswith("approve:"): + await self._on_approve_callback(query, data) + return + + # --- Generic: cmd:args → synthetic /cmd args --- + await self._on_generic_callback(query, data) + + async def _on_generic_callback(self, query, data: str) -> None: + """Generic callback: ``cmd:args`` → synthetic ``/cmd args``. + + Constructs a synthetic MessageEvent and feeds it through the + existing ``_message_handler`` (GatewayRunner._handle_message). + Edits the keyboard message with the response. + """ + await query.answer() + cmd, _, args = data.partition(":") + synthetic_text = f"/{cmd} {args}".strip() + + from gateway.session import SessionSource + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id=str(query.message.chat_id), + user_id=str(query.from_user.id) if query.from_user else "", + ) + synthetic = MessageEvent(text=synthetic_text, source=source) + try: - await query.edit_message_text( - text=f"⚕ Update prompt answered: *{label}*", - parse_mode=ParseMode.MARKDOWN, - reply_markup=None, - ) + response = await self._message_handler(synthetic) + except Exception as exc: + logger.warning("[%s] generic callback handler failed: %s", self.name, exc) + response = None + + try: + text = response or "✓ Done" + await query.edit_message_text(text) except Exception: - pass # non-fatal if edit fails - # Write the response file + pass + + async def _on_commands_page_callback(self, query, data: str) -> None: + """Handle ``cpg:`` callbacks — edit message in-place with new page.""" + await query.answer() try: - from hermes_constants import get_hermes_home - home = get_hermes_home() - response_path = home / ".update_response" - tmp = response_path.with_suffix(".tmp") - tmp.write_text(answer) - tmp.replace(response_path) - logger.info("Telegram update prompt answered '%s' by user %s", - answer, getattr(query.from_user, "id", "unknown")) + page = int(data.split(":", 1)[1]) + except (ValueError, IndexError): + return + + if not getattr(self, "gateway_runner", None): + return + + text, nav, _, _ = self.gateway_runner._build_commands_page(page, is_telegram=True) + buttons = [] + if nav: + nav.append({"label": "✕ Close", "data": "mc"}) + buttons.append([ + InlineKeyboardButton(opt["label"], callback_data=opt["data"]) + for opt in nav + ]) + keyboard = InlineKeyboardMarkup(buttons) if buttons else None + + try: + await query.edit_message_text( + text, reply_markup=keyboard, + ) except Exception as exc: - logger.error("Failed to write update response from callback: %s", exc) + logger.warning("[%s] commands page edit failed: %s", self.name, exc) + + async def _on_approve_callback(self, query, data: str) -> None: + """Handle ``approve::`` callbacks. + + Calls ``resolve_gateway_approval()`` directly — same pattern as + Discord's ExecApprovalView. + """ + parts = data.split(":", 2) + if len(parts) != 3: + await query.answer(text="Invalid callback") + return + _, choice, session_key = parts + + await query.answer(text="Processing...") + from tools.approval import resolve_gateway_approval, has_blocking_approval + + if not has_blocking_approval(session_key): + try: + await query.edit_message_text("⚠️ Approval expired — agent is no longer waiting.") + except Exception: + pass + return + + if choice == "deny": + count = resolve_gateway_approval(session_key, "deny") + label = "Denied" + else: + count = resolve_gateway_approval(session_key, choice) + scope = {"once": "", "session": " (for session)", "always": " (permanently)"} + label = f"Approved{scope.get(choice, '')}" + + try: + await query.edit_message_text(f"✅ {label}. Agent resuming...") + except Exception: + pass async def send_voice( self, diff --git a/gateway/run.py b/gateway/run.py index 7a45be62d473..0eb37f361dea 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1128,6 +1128,7 @@ async def start(self) -> bool: adapter.set_message_handler(self._handle_message) adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) adapter.set_session_store(self.session_store) + adapter.gateway_runner = self # Try to connect logger.info("Connecting to %s...", platform.value) @@ -1426,6 +1427,7 @@ async def _platform_reconnect_watcher(self) -> None: adapter.set_message_handler(self._handle_message) adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) adapter.set_session_store(self.session_store) + adapter.gateway_runner = self success = await adapter.connect() if success: @@ -3411,20 +3413,10 @@ async def _handle_help_command(self, event: MessageEvent) -> str: pass return "\n".join(lines) - async def _handle_commands_command(self, event: MessageEvent) -> str: - """Handle /commands [page] - paginated list of all commands and skills.""" + def _build_commands_page(self, page: int = 1, is_telegram: bool = False): + """Build a commands list page. Returns (text, nav_buttons, total_pages, page).""" from hermes_cli.commands import gateway_help_lines - raw_args = event.get_command_args().strip() - if raw_args: - try: - requested_page = int(raw_args) - except ValueError: - return "Usage: `/commands [page]`" - else: - requested_page = 1 - - # Build combined entry list: built-in commands + skill commands entries = list(gateway_help_lines()) try: from agent.skill_commands import get_skill_commands @@ -3432,19 +3424,21 @@ async def _handle_commands_command(self, event: MessageEvent) -> str: if skill_cmds: entries.append("") entries.append("⚡ **Skill Commands**:") + max_desc = 120 if is_telegram else 300 for cmd in sorted(skill_cmds): desc = skill_cmds[cmd].get("description", "").strip() or "Skill command" + if len(desc) > max_desc: + desc = desc[:max_desc].rstrip() + "…" entries.append(f"`{cmd}` — {desc}") except Exception: pass if not entries: - return "No commands available." + return "No commands available.", [], 1, 1 - from gateway.config import Platform - page_size = 15 if event.source.platform == Platform.TELEGRAM else 20 + page_size = 15 if is_telegram else 20 total_pages = max(1, (len(entries) + page_size - 1) // page_size) - page = max(1, min(requested_page, total_pages)) + page = max(1, min(page, total_pages)) start = (page - 1) * page_size page_entries = entries[start:start + page_size] @@ -3453,16 +3447,50 @@ async def _handle_commands_command(self, event: MessageEvent) -> str: "", *page_entries, ] + nav = [] if total_pages > 1: + if page > 1: + nav.append({"label": "◀ Prev", "data": f"cpg:{page - 1}"}) + if page < total_pages: + nav.append({"label": "Next ▶", "data": f"cpg:{page + 1}"}) + + return "\n".join(lines), nav, total_pages, page + + async def _handle_commands_command(self, event: MessageEvent) -> str: + """Handle /commands [page] - paginated list of all commands and skills.""" + raw_args = event.get_command_args().strip() + if raw_args: + try: + requested_page = int(raw_args) + except ValueError: + return "Usage: `/commands [page]`" + else: + requested_page = 1 + + from gateway.config import Platform + is_telegram = event.source.platform == Platform.TELEGRAM + text, nav, total_pages, page = self._build_commands_page(requested_page, is_telegram) + + if nav: + adapter = self.adapters.get(event.source.platform) + if adapter: + nav.append({"label": "✕ Close", "data": "mc"}) + await adapter.send_inline_options( + event.source.chat_id, text, [nav], + metadata={"thread_id": event.source.thread_id} if event.source.thread_id else None, + ) + return None + # Fallback: text nav nav_parts = [] if page > 1: nav_parts.append(f"`/commands {page - 1}` ← prev") if page < total_pages: nav_parts.append(f"next → `/commands {page + 1}`") - lines.extend(["", " | ".join(nav_parts)]) + text += "\n\n" + " | ".join(nav_parts) + if page != requested_page: - lines.append(f"_(Requested page {requested_page} was out of range, showing page {page}.)_") - return "\n".join(lines) + text += f"\n_(Requested page {requested_page} was out of range, showing page {page}.)_" + return text async def _handle_model_command(self, event: MessageEvent) -> Optional[str]: """Handle /model command — switch model for this session. @@ -3853,6 +3881,18 @@ async def _handle_personality_command(self, event: MessageEvent) -> str: return "No personalities configured in `~/.hermes/config.yaml`" if not args: + text = "🎭 **Choose a personality:**" + adapter = self.adapters.get(event.source.platform) + if adapter: + options = [[{"label": f"✕ {n}" if n == "none" else n, "data": f"personality:{n}"}] + for n in ["none"] + list(personalities.keys())] + options.append([{"label": "✕ Cancel", "data": "mc"}]) + await adapter.send_inline_options( + event.source.chat_id, text, options, + metadata={"thread_id": event.source.thread_id} if event.source.thread_id else None, + ) + return None + # Fallback: text list lines = ["🎭 **Available Personalities**\n"] lines.append("• `none` — (no personality overlay)") for name, prompt in personalities.items(): @@ -4437,6 +4477,18 @@ async def _handle_rollback_command(self, event: MessageEvent) -> str: if not arg: checkpoints = mgr.list_checkpoints(cwd) + adapter = self.adapters.get(event.source.platform) + if adapter and checkpoints: + text = "🔄 **Restore a checkpoint:**" + options = [[{"label": f"#{i+1} {cp.get('reason', 'checkpoint')[:30]}", + "data": f"rollback:{i+1}"}] + for i, cp in enumerate(checkpoints[:8])] + options.append([{"label": "✕ Cancel", "data": "mc"}]) + await adapter.send_inline_options( + event.source.chat_id, text, options, + metadata={"thread_id": event.source.thread_id} if event.source.thread_id else None, + ) + return None return format_checkpoint_list(checkpoints, cwd) # Restore by number or hash @@ -4817,7 +4869,7 @@ def _save_config_key(key_path: str, value): return False if not args: - # Show current state + # Show current state with inline buttons rc = self._reasoning_config if rc is None: level = "medium (default)" @@ -5074,6 +5126,19 @@ async def _handle_resume_command(self, event: MessageEvent) -> str: "Use `/title My Session` to name your current session, " "then `/resume My Session` to return to it later." ) + # Try inline buttons + adapter = self.adapters.get(source.platform) + if adapter: + text = "📋 **Resume a session:**" + options = [[{"label": s["title"][:40], "data": f"resume:{s['title']}"}] + for s in titled[:8]] + options.append([{"label": "✕ Cancel", "data": "mc"}]) + await adapter.send_inline_options( + source.chat_id, text, options, + metadata={"thread_id": source.thread_id} if source.thread_id else None, + ) + return None + # Fallback: text list lines = ["📋 **Named Sessions**\n"] for s in titled[:10]: title = s["title"]