Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -1609,6 +1611,13 @@ async def _handle_callback_query(
return
data = query.data

# --- Commands picker callbacks ---
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)
return

# --- Model picker callbacks ---
if data.startswith(("mp:", "mm:", "mb", "mx", "mg:")):
chat_id = str(query.message.chat_id) if query.message else None
Expand Down Expand Up @@ -1704,6 +1713,206 @@ 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."""
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]
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),
"",
)
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:"):
try:
new_page = int(data.split(":", 1)[1])
except (ValueError, IndexError):
await query.answer()
return
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"),
)
await query.answer() if result.success 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.

Expand Down
27 changes: 27 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down