From 0f66d21b8bb1ef35c173e5fab2f2e75ee58fc946 Mon Sep 17 00:00:00 2001 From: SoRcKwYo Date: Tue, 21 Apr 2026 18:51:35 +0800 Subject: [PATCH 1/3] test(telegram): /commands shows inline keyboard button picker with in-place pagination --- gateway/platforms/telegram.py | 135 ++++++++++++++++++++++++++++++++++ gateway/run.py | 34 +++++++++ 2 files changed, 169 insertions(+) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index bec0d690a3b..c41ab3420f2 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -251,6 +251,8 @@ def __init__(self, config: PlatformConfig): self._model_picker_state: Dict[str, dict] = {} # Approval button state: message_id → session_key self._approval_state: Dict[int, str] = {} + # Commands picker state per chat + self._commands_picker_state: Dict[str, dict] = {} @staticmethod def _is_callback_user_authorized(user_id: str) -> bool: @@ -1609,6 +1611,13 @@ async def _handle_callback_query( return data = query.data + # --- Commands picker callbacks --- + if data.startswith(("mc:", "cp:")): + chat_id = str(query.message.chat_id) if query.message else None + if chat_id: + await self._handle_commands_picker_callback(query, data, chat_id) + return + # --- Model picker callbacks --- if data.startswith(("mp:", "mm:", "mb", "mx", "mg:")): chat_id = str(query.message.chat_id) if query.message else None @@ -1704,6 +1713,132 @@ async def _handle_callback_query( except Exception as exc: logger.error("Failed to write update response from callback: %s", exc) + async def send_commands_picker( + self, + chat_id: str, + commands: List[Dict[str, str]], + current_page: int = 0, + on_command_selected: Optional[callable] = None, + metadata: Optional[Dict[str, Any]] = None, + edit_message_id: Optional[int] = None, + ) -> SendResult: + """Send an inline keyboard picker listing available /commands. + + Each command is a button; clicking shows its description as a toast. + Pagination buttons (◄ / ►) edit the message in-place. + """ + if not self._bot: + return SendResult(success=False, error="Not connected") + + PAGE_SIZE = 8 + total_pages = max(1, (len(commands) + PAGE_SIZE - 1) // PAGE_SIZE) + page = max(0, min(current_page, total_pages - 1)) + start = page * PAGE_SIZE + page_cmds = commands[start: start + PAGE_SIZE] + + buttons: List[InlineKeyboardButton] = [] + for cmd in page_cmds: + name = cmd.get("command", "") + buttons.append(InlineKeyboardButton(f"/{name}", callback_data=f"mc:{name}:{page}")) + + nav: List[InlineKeyboardButton] = [] + if page > 0: + nav.append(InlineKeyboardButton("◄ Prev", callback_data=f"cp:{page - 1}")) + if page < total_pages - 1: + nav.append(InlineKeyboardButton("Next ►", callback_data=f"cp:{page + 1}")) + rows = [buttons[i:i + 2] for i in range(0, len(buttons), 2)] + if nav: + rows.append(nav) + keyboard = InlineKeyboardMarkup(rows) + + caption = ( + f"⚙ Available Commands ({page + 1}/{total_pages})\n\n" + f"_Click a command to see its description._" + ) + + try: + if edit_message_id is not None: + await self._bot.edit_message_text( + chat_id=int(chat_id), + message_id=edit_message_id, + text=caption, + parse_mode=ParseMode.MARKDOWN, + reply_markup=keyboard, + ) + msg_id = edit_message_id + else: + msg = await self._bot.send_message( + chat_id=int(chat_id), + text=caption, + parse_mode=ParseMode.MARKDOWN, + reply_markup=keyboard, + message_thread_id=self._message_thread_id_for_send( + self._metadata_thread_id(metadata) + ), + ) + msg_id = msg.message_id + self._commands_picker_state[chat_id] = { + "page": page, + "total_pages": total_pages, + "commands": commands, + "on_command_selected": on_command_selected, + "message_id": msg_id, + } + return SendResult(success=True, message_id=str(msg_id)) + except Exception as exc: + logger.error("Failed to send commands picker: %s", exc) + return SendResult(success=False, error=str(exc)) + + async def _handle_commands_picker_callback( + self, query: "CallbackQuery", data: str, chat_id: str + ) -> None: + """Handle inline keyboard callbacks from the commands picker.""" + if data.startswith("mc:"): + parts = data.split(":", 2) + if len(parts) == 3: + cmd_name = parts[1] + state = self._commands_picker_state.get(chat_id, {}) + cmd_desc = next( + (c.get("description", "") for c in state.get("commands", []) if c.get("command") == cmd_name), + "", + ) + on_selected = state.get("on_command_selected") + if on_selected: + try: + result_text = on_selected(cmd_name, cmd_desc) + await query.answer(text=result_text, show_alert=False) + except Exception: + await query.answer(text=f"/{cmd_name}: {cmd_desc}", show_alert=True) + else: + await query.answer(text=f"/{cmd_name}: {cmd_desc}" if cmd_desc else f"/{cmd_name}", show_alert=True) + return + + if data.startswith("cp:"): + try: + new_page = int(data.split(":", 1)[1]) + except (ValueError, IndexError): + await query.answer() + return + state = self._commands_picker_state.get(chat_id, {}) + commands = state.get("commands", []) + if not commands: + await query.answer() + return + result = await self.send_commands_picker( + chat_id=chat_id, + commands=commands, + current_page=new_page, + on_command_selected=state.get("on_command_selected"), + edit_message_id=state.get("message_id"), + ) + if result.success: + await query.answer() + else: + await query.answer(text="Failed to update picker.") + return + + await query.answer() + def _missing_media_path_error(self, label: str, path: str) -> str: """Build an actionable file-not-found error for gateway MEDIA delivery. diff --git a/gateway/run.py b/gateway/run.py index f68e71c9afb..3410082247e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5288,6 +5288,33 @@ async def _handle_help_command(self, event: MessageEvent) -> str: async def _handle_commands_command(self, event: MessageEvent) -> str: """Handle /commands [page] - paginated list of all commands and skills.""" from hermes_cli.commands import gateway_help_lines + from gateway.config import Platform + + # For Telegram: show interactive inline keyboard picker instead of text. + if event.source.platform == Platform.TELEGRAM: + adapter = self.adapters.get(event.source.platform) + if adapter and hasattr(adapter, "send_commands_picker"): + import re as _re + cmd_dicts = [] + for line in gateway_help_lines(): + m = _re.match(r"`(/\w+)`\s*[-\u2013]\s*(.+)", line) + if m: + cmd_dicts.append({"command": m.group(1).lstrip("/"), "description": m.group(2).strip()}) + try: + from agent.skill_commands import get_skill_commands + for cmd, info in sorted((get_skill_commands() or {}).items()): + cmd_dicts.append({"command": cmd.lstrip("/"), "description": info.get("description", "Skill command")}) + except Exception: + pass + if cmd_dicts: + meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None + await adapter.send_commands_picker( + chat_id=event.source.chat_id or event.source.user_id, + commands=cmd_dicts, + current_page=0, + metadata=meta, + ) + return "" raw_args = event.get_command_args().strip() if raw_args: @@ -10582,6 +10609,13 @@ async def _notify_long_running(): # new message). updated_history = result.get("messages", history) + # Strip any dangling user message at the end of updated_history. + # When an interrupt fires mid-turn the interrupted user message + # may already be appended; passing it through would create two + # consecutive user messages and break strict role-alternation + # chat templates (e.g. gemma / local models → HTTP 500). + while updated_history and updated_history[-1].get("role") == "user": + updated_history = updated_history[:-1] next_source = source next_message = pending next_message_id = None From 81f71458529707974b19476f488b94bf75df790d Mon Sep 17 00:00:00 2001 From: SoRcKwYo Date: Tue, 21 Apr 2026 19:46:33 +0800 Subject: [PATCH 2/3] feat(telegram): /commands picker shows description with Use/Cancel buttons --- gateway/platforms/telegram.py | 106 +++++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 16 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index c41ab3420f2..e28028ee514 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -1612,7 +1612,7 @@ async def _handle_callback_query( data = query.data # --- Commands picker callbacks --- - if data.startswith(("mc:", "cp:")): + if data.startswith(("mc:", "cp:", "cu:", "cc:")): chat_id = str(query.message.chat_id) if query.message else None if chat_id: await self._handle_commands_picker_callback(query, data, chat_id) @@ -1793,24 +1793,102 @@ async def _handle_commands_picker_callback( self, query: "CallbackQuery", data: str, chat_id: str ) -> None: """Handle inline keyboard callbacks from the commands picker.""" + state = self._commands_picker_state.get(chat_id, {}) + if data.startswith("mc:"): + # Command button tapped — show description + Use / Cancel buttons in-place parts = data.split(":", 2) if len(parts) == 3: cmd_name = parts[1] - state = self._commands_picker_state.get(chat_id, {}) + page = int(parts[2]) if parts[2].isdigit() else 0 cmd_desc = next( (c.get("description", "") for c in state.get("commands", []) if c.get("command") == cmd_name), "", ) - on_selected = state.get("on_command_selected") - if on_selected: - try: - result_text = on_selected(cmd_name, cmd_desc) - await query.answer(text=result_text, show_alert=False) - except Exception: - await query.answer(text=f"/{cmd_name}: {cmd_desc}", show_alert=True) - else: - await query.answer(text=f"/{cmd_name}: {cmd_desc}" if cmd_desc else f"/{cmd_name}", show_alert=True) + text = ( + f"\u2699 `/{cmd_name}`\n\n" + f"{cmd_desc}\n\n" + f"_Tap **Use** to run this command, or **Back** to return._" + ) + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("\u25b6 Use", callback_data=f"cu:{cmd_name}:{page}"), + InlineKeyboardButton("\u2717 Cancel", callback_data=f"cc:{page}"), + ] + ]) + try: + msg_id = state.get("message_id") + if msg_id: + await self._bot.edit_message_text( + chat_id=int(chat_id), + message_id=msg_id, + text=text, + parse_mode=ParseMode.MARKDOWN, + reply_markup=keyboard, + ) + await query.answer() + except Exception as exc: + logger.warning("Commands picker mc edit failed: %s", exc) + await query.answer(text=f"/{cmd_name}: {cmd_desc}", show_alert=True) + return + + if data.startswith("cu:"): + # Use — dispatch the command as a synthetic message event + parts = data.split(":", 2) + if len(parts) >= 2: + cmd_name = parts[1] + try: + msg_id = state.get("message_id") + if msg_id: + await self._bot.edit_message_text( + chat_id=int(chat_id), + message_id=msg_id, + text=f"\u25b6 Running `/{cmd_name}`\u2026", + parse_mode=ParseMode.MARKDOWN, + reply_markup=None, + ) + except Exception: + pass + await query.answer() + try: + user = getattr(query, "from_user", None) + msg = query.message + source = self.build_source( + chat_id=chat_id, + chat_name=getattr(getattr(msg, "chat", None), "title", None), + chat_type="dm", + user_id=str(user.id) if user else chat_id, + user_name=user.full_name if user else None, + thread_id=str(msg.message_thread_id) if msg and msg.message_thread_id else None, + ) + synthetic = MessageEvent( + text=f"/{cmd_name}", + message_type=MessageType.COMMAND, + source=source, + ) + await self.handle_message(synthetic) + except Exception as exc: + logger.error("Failed to dispatch command from picker: %s", exc) + return + + if data.startswith("cc:"): + # Cancel — return to the picker page + try: + page = int(data.split(":", 1)[1]) + except (ValueError, IndexError): + page = 0 + commands = state.get("commands", []) + if not commands: + await query.answer() + return + result = await self.send_commands_picker( + chat_id=chat_id, + commands=commands, + current_page=page, + on_command_selected=state.get("on_command_selected"), + edit_message_id=state.get("message_id"), + ) + await query.answer() if result.success else await query.answer(text="Failed to update picker.") return if data.startswith("cp:"): @@ -1819,7 +1897,6 @@ async def _handle_commands_picker_callback( except (ValueError, IndexError): await query.answer() return - state = self._commands_picker_state.get(chat_id, {}) commands = state.get("commands", []) if not commands: await query.answer() @@ -1831,10 +1908,7 @@ async def _handle_commands_picker_callback( on_command_selected=state.get("on_command_selected"), edit_message_id=state.get("message_id"), ) - if result.success: - await query.answer() - else: - await query.answer(text="Failed to update picker.") + await query.answer() if result.success else await query.answer(text="Failed to update picker.") return await query.answer() From 493ef90fa7ffd391229ebcf37d3dbb87680d0bc8 Mon Sep 17 00:00:00 2001 From: SoRcKwYo Date: Wed, 22 Apr 2026 14:03:01 +0800 Subject: [PATCH 3/3] fix(telegram): remove unrelated updated_history patch from commands picker PR This code was accidentally included via cherry-pick and has no relation to the /commands inline keyboard picker feature. --- gateway/run.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 3410082247e..377517ebaa0 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10609,13 +10609,6 @@ async def _notify_long_running(): # new message). updated_history = result.get("messages", history) - # Strip any dangling user message at the end of updated_history. - # When an interrupt fires mid-turn the interrupted user message - # may already be appended; passing it through would create two - # consecutive user messages and break strict role-alternation - # chat templates (e.g. gemma / local models → HTTP 500). - while updated_history and updated_history[-1].get("role") == "user": - updated_history = updated_history[:-1] next_source = source next_message = pending next_message_id = None