feat(telegram): inline command picker — search every command and skill, no menu cap

Telegram's BotCommand menu is hard-capped (100/scope, ~4KB payload; Hermes
defaults to 60 slots), so most skill commands can never appear in the /
menu. Inline mode has no such cap: typing @botname <query> in any chat now
returns a live, searchable picker over EVERY core command, plugin command,
and installed skill — results computed per keystroke, paginated 50 at a
time. The Telegram analog of Discord's dynamic /skill autocomplete
(#18741).

- plugins/platforms/telegram/inline_picker.py: PTB-free catalog/rank/
  pagination logic (unit-testable without python-telegram-bot). First
  query token filters; the remainder is carried into the sent command as
  its argument (@bot plan migrate auth → sends /plan migrate auth).
- adapter: InlineQueryHandler registration (inert until the bot owner
  enables inline mode via BotFather /setinline) + _handle_inline_query
  with the same auth path as inline-button callbacks — unauthorized users
  get an empty list, so the installed-skill catalog is not leaked to
  arbitrary users (inline queries arrive from any chat).
- Tap-to-send dispatches through the existing command path: the sent
  message starts with /, which reaches the bot even under default privacy
  mode. Zero new dispatch code.
- Docs: telegram.md inline-picker section incl. the one-time BotFather
  /setinline setup.
This commit is contained in:
Teknium
2026-08-29 20:19:59 -07:00
parent 58523f284c
commit 5bdaea64ed
4 changed files with 530 additions and 1 deletions

View File

@@ -150,6 +150,7 @@ try:
Application,
CommandHandler,
CallbackQueryHandler,
InlineQueryHandler,
MessageHandler as TelegramMessageHandler,
ContextTypes,
TypeHandler,
@@ -169,6 +170,7 @@ except ImportError:
Application = Any
CommandHandler = Any
CallbackQueryHandler = Any
InlineQueryHandler = Any
TypeHandler = Any
TelegramMessageHandler = Any
HTTPXRequest = Any
@@ -342,7 +344,7 @@ def check_telegram_requirements() -> bool:
"""
global TELEGRAM_AVAILABLE, Update, Bot, Message, InlineKeyboardButton
global InlineKeyboardMarkup, LinkPreviewOptions, Application
global CommandHandler, CallbackQueryHandler, TelegramMessageHandler
global CommandHandler, CallbackQueryHandler, InlineQueryHandler, TelegramMessageHandler
global ContextTypes, filters, ParseMode, ChatType, HTTPXRequest, TypeHandler
if TELEGRAM_AVAILABLE:
return True
@@ -361,6 +363,7 @@ def check_telegram_requirements() -> bool:
from telegram.ext import (
Application as _App, CommandHandler as _CH,
CallbackQueryHandler as _CQH,
InlineQueryHandler as _IQH,
MessageHandler as _MH,
ContextTypes as _CT, filters as _filters,
TypeHandler as _TH,
@@ -378,6 +381,7 @@ def check_telegram_requirements() -> bool:
Application = _App
CommandHandler = _CH
CallbackQueryHandler = _CQH
InlineQueryHandler = _IQH
TelegramMessageHandler = _MH
ContextTypes = _CT
filters = _filters
@@ -4377,6 +4381,12 @@ class TelegramAdapter(BasePlatformAdapter):
))
# Handle inline keyboard button callbacks (update prompts)
app.add_handler(CallbackQueryHandler(self._handle_callback_query))
# Inline command picker (@botname <query>) — searchable, uncapped
# access to every command/skill. Inert until the bot owner enables
# inline mode via BotFather /setinline (Telegram never delivers
# inline_query updates otherwise), so registering unconditionally
# is safe.
app.add_handler(InlineQueryHandler(self._handle_inline_query))
# gateway_platform_event observer (see _on_platform_update); group 99 so
# it observes alongside, never displaces, the core handlers.
app.add_handler(TypeHandler(Update, self._on_platform_update), group=99)
@@ -7199,6 +7209,93 @@ class TelegramAdapter(BasePlatformAdapter):
except Exception:
pass
async def _handle_inline_query(
self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"
) -> None:
"""Answer ``@botname <query>`` with a searchable command/skill picker.
The BotCommand menu is capped (100/scope, ~4KB payload; 60-slot
Hermes default), so most skill commands can never appear in the
``/`` menu. Inline mode is uncapped: results are computed live per
keystroke and paginated 50 at a time (Telegram's per-answer max) —
the Telegram analog of Discord's dynamic ``/skill`` autocomplete.
Tapping a result sends the command text (``/plan <args>``) into the
chat as the user. Command-prefixed messages reach the bot even under
default privacy mode, and dispatch flows through the existing
command path — this handler only ever *offers* text, so it is
read-only by construction.
Authorization: results are only served to users who pass the same
auth path as inline-button callbacks (allowlists, pairing,
multiplex profiles). Unauthorized queries get an empty result list
— the catalog of installed skills is not leaked to arbitrary users
who can type ``@botname`` from any chat (inline queries arrive from
ANY chat, including ones the bot is not a member of).
"""
inline_query = getattr(update, "inline_query", None)
if inline_query is None:
return
from_user = getattr(inline_query, "from_user", None)
user_id = str(getattr(from_user, "id", "") or "").strip()
try:
authorized = bool(user_id) and self._is_callback_user_authorized(
user_id,
# Inline queries carry no chat context — authorize on the
# user identity alone, as a DM-shaped source.
chat_id=user_id,
chat_type="private",
user_name=getattr(from_user, "username", None),
)
except Exception:
logger.debug("[%s] inline picker auth check failed", self.name, exc_info=True)
authorized = False
if not authorized:
try:
from plugins.platforms.telegram.inline_picker import (
CACHE_TIME_SECONDS as _deny_cache,
)
await inline_query.answer([], cache_time=_deny_cache, is_personal=True)
except Exception:
logger.debug("[%s] inline picker empty answer failed", self.name, exc_info=True)
return
try:
from telegram import InlineQueryResultArticle, InputTextMessageContent
from plugins.platforms.telegram.inline_picker import (
CACHE_TIME_SECONDS as _CACHE,
build_inline_results,
)
results, next_offset = build_inline_results(
getattr(inline_query, "query", "") or "",
offset=getattr(inline_query, "offset", "") or "",
)
articles = [
InlineQueryResultArticle(
id=r["id"],
title=r["title"],
description=r["description"],
input_message_content=InputTextMessageContent(r["message_text"]),
)
for r in results
]
await inline_query.answer(
articles,
cache_time=_CACHE,
# Catalogs differ per user (auth, per-platform disabled
# skills) — never let Telegram share cached pages across
# users.
is_personal=True,
next_offset=next_offset,
)
except Exception:
logger.debug("[%s] inline picker answer failed", self.name, exc_info=True)
async def _handle_callback_query(
self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"
) -> None:

View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Telegram inline command picker — searchable access to EVERY command/skill.
Telegram's BotCommand menu is capped (100 per scope, ~4KB payload; Hermes
defaults to 60 slots), so most skill commands can never appear in the ``/``
menu. Inline mode has no such cap: typing ``@yourbot <query>`` in any chat
asks the bot for results live, per keystroke, paginated 50 at a time — the
same trick Discord's ``/skill`` autocomplete uses (options fetched
dynamically, nothing pre-registered).
Tapping a result sends the command text (e.g. ``/plan migrate the auth``)
into the chat as the user. Because the sent message starts with ``/``, the
bot receives it even under Telegram's default privacy mode ("messages with
commands meant for the bot" are always delivered), and it dispatches through
the existing command path — zero new dispatch code.
This module is PTB-object-free on purpose: it returns plain dicts so the
catalog/filter/pagination logic is unit-testable without python-telegram-bot
installed. The adapter converts dicts to ``InlineQueryResultArticle``.
Setup note (docs): inline mode must be enabled once per bot via BotFather's
``/setinline``. Until then Telegram never delivers ``inline_query`` updates,
so the registered handler is inert — safe to ship enabled by default.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Tuple
logger = logging.getLogger(__name__)
# Telegram hard limit: max 50 results per answerInlineQuery call.
PAGE_SIZE = 50
# Results depend on the caller's auth and the install's skill set — never
# share cached results across users, and keep the cache short so freshly
# installed skills appear quickly.
CACHE_TIME_SECONDS = 10
def collect_inline_catalog() -> List[Dict[str, str]]:
"""Return every dispatchable command as ``{name, description}`` dicts.
Sources, deduped in priority order (first occurrence wins):
1. Core gateway-visible ``CommandDef`` commands (Telegram-sanitized
names, same gating as the BotCommand menu).
2. Plugin slash commands + built-in skill commands via the shared
collector — with ``max_slots=None`` so NOTHING is trimmed. This is
the whole point: the inline picker has no cap.
Skill entries honor the same filtering as the menu (hub excluded,
per-platform disabled excluded, external-dir allowlist).
"""
catalog: List[Dict[str, str]] = []
seen: set[str] = set()
try:
from hermes_cli.commands import (
_collect_gateway_skill_entries,
_sanitize_telegram_name,
telegram_bot_commands,
)
except Exception: # pragma: no cover - defensive
logger.debug("inline picker: commands registry unavailable", exc_info=True)
return catalog
try:
for name, desc in telegram_bot_commands():
if name and name not in seen:
seen.add(name)
catalog.append({"name": name, "description": desc or ""})
except Exception:
logger.debug("inline picker: core command collection failed", exc_info=True)
try:
entries, _hidden = _collect_gateway_skill_entries(
platform="telegram",
max_slots=None, # inline mode has no cap — collect everything
reserved_names=set(seen),
desc_limit=100,
sanitize_name=_sanitize_telegram_name,
)
for entry in entries:
# Entry shape is (name, desc, cmd_key[, raw_name]) — tolerate both.
name, desc = entry[0], entry[1]
if name and name not in seen:
seen.add(name)
catalog.append({"name": name, "description": desc or ""})
except Exception:
logger.debug("inline picker: skill/plugin collection failed", exc_info=True)
return catalog
def filter_catalog(catalog: List[Dict[str, str]], term: str) -> List[Dict[str, str]]:
"""Rank *catalog* against *term*: prefix > name-substring > description.
Empty term returns the full catalog in its collection order (core first,
then plugins, then skills alphabetically) — the "browse" view.
"""
term = (term or "").strip().lower().lstrip("/")
if not term:
return list(catalog)
prefix: List[Dict[str, str]] = []
name_sub: List[Dict[str, str]] = []
desc_sub: List[Dict[str, str]] = []
# Treat hyphens/underscores as equivalent, mirroring command dispatch.
norm_term = term.replace("-", "_")
for item in catalog:
norm_name = item["name"].lower().replace("-", "_")
if norm_name.startswith(norm_term):
prefix.append(item)
elif norm_term in norm_name:
name_sub.append(item)
elif term in (item.get("description") or "").lower():
desc_sub.append(item)
return prefix + name_sub + desc_sub
def build_inline_results(
query: str,
offset: str = "",
page_size: int = PAGE_SIZE,
) -> Tuple[List[Dict[str, Any]], str]:
"""Build one page of inline results for *query*.
The first whitespace-separated token of *query* filters the catalog; any
remainder is carried into the sent command as its argument. Example:
``@bot plan migrate auth to OIDC`` → filter ``plan``, and tapping the
``/plan`` result sends ``/plan migrate auth to OIDC``.
Returns ``(results, next_offset)`` where each result is
``{"id", "title", "description", "message_text"}`` and *next_offset* is
``""`` when this is the last page (Telegram's stop signal).
"""
query = (query or "").strip()
parts = query.split(None, 1)
term = parts[0] if parts else ""
args = parts[1].strip() if len(parts) > 1 else ""
matches = filter_catalog(collect_inline_catalog(), term)
try:
start = int(offset) if offset else 0
except (TypeError, ValueError):
start = 0
page = matches[start:start + page_size]
next_offset = str(start + page_size) if len(matches) > start + page_size else ""
results: List[Dict[str, Any]] = []
for item in page:
message_text = f"/{item['name']}"
if args:
message_text += f" {args}"
results.append(
{
# Offset-scoped ids stay unique across pages of one query.
"id": f"{start}:{item['name']}"[:64],
"title": f"/{item['name']}",
"description": (item.get("description") or "")[:100],
"message_text": message_text[:4096],
}
)
return results, next_offset

View File

@@ -0,0 +1,250 @@
"""Tests for the Telegram inline command picker (@botname <query>).
Covers the PTB-free logic module ``plugins/platforms/telegram/inline_picker``
(catalog collection, ranking, pagination) and the adapter's
``_handle_inline_query`` (auth gate, personal caching, empty-answer on
deny). The picker exists because the BotCommand menu is capped (60-slot
Hermes default / 100 API max, ~4KB payload) while inline mode is uncapped —
every command and skill must be reachable through it.
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import Platform, PlatformConfig
from plugins.platforms.telegram.inline_picker import (
PAGE_SIZE,
build_inline_results,
collect_inline_catalog,
filter_catalog,
)
# ---------------------------------------------------------------------------
# Logic module — no PTB required
# ---------------------------------------------------------------------------
class TestFilterCatalog:
CATALOG = [
{"name": "help", "description": "Show available commands"},
{"name": "plan", "description": "Write a markdown implementation plan"},
{"name": "plugin_tools", "description": "List plugin tools"},
{"name": "songsee", "description": "Audio spectrograms and planning aids"},
]
def test_empty_term_returns_everything_in_order(self):
assert filter_catalog(self.CATALOG, "") == self.CATALOG
assert filter_catalog(self.CATALOG, " ") == self.CATALOG
def test_prefix_beats_substring_beats_description(self):
ranked = filter_catalog(self.CATALOG, "plan")
names = [i["name"] for i in ranked]
# prefix match first, then description matches ("...plan" in
# plugin_tools description? no — songsee mentions planning).
assert names[0] == "plan"
assert "songsee" in names # description hit ranks after name hits
def test_hyphen_underscore_equivalent(self):
catalog = [{"name": "gif_search", "description": "Find GIFs"}]
assert filter_catalog(catalog, "gif-se")[0]["name"] == "gif_search"
def test_leading_slash_stripped(self):
assert filter_catalog(self.CATALOG, "/plan")[0]["name"] == "plan"
def test_no_match_returns_empty(self):
assert filter_catalog(self.CATALOG, "zzzznope") == []
class TestBuildInlineResults:
def _fake_catalog(self, n):
return [
{"name": f"cmd-{i:03d}", "description": f"desc {i}"} for i in range(n)
]
def test_first_page_and_next_offset(self):
with patch(
"plugins.platforms.telegram.inline_picker.collect_inline_catalog",
return_value=self._fake_catalog(PAGE_SIZE + 10),
):
results, next_offset = build_inline_results("", offset="")
assert len(results) == PAGE_SIZE
assert next_offset == str(PAGE_SIZE)
def test_last_page_signals_stop_with_empty_offset(self):
with patch(
"plugins.platforms.telegram.inline_picker.collect_inline_catalog",
return_value=self._fake_catalog(PAGE_SIZE + 10),
):
results, next_offset = build_inline_results("", offset=str(PAGE_SIZE))
assert len(results) == 10
assert next_offset == ""
def test_args_after_first_token_carry_into_message_text(self):
with patch(
"plugins.platforms.telegram.inline_picker.collect_inline_catalog",
return_value=[{"name": "plan", "description": "Plan mode"}],
):
results, _ = build_inline_results("plan migrate auth to OIDC")
assert results[0]["message_text"] == "/plan migrate auth to OIDC"
assert results[0]["title"] == "/plan"
def test_bare_query_sends_bare_command(self):
with patch(
"plugins.platforms.telegram.inline_picker.collect_inline_catalog",
return_value=[{"name": "plan", "description": "Plan mode"}],
):
results, _ = build_inline_results("plan")
assert results[0]["message_text"] == "/plan"
def test_garbage_offset_treated_as_zero(self):
with patch(
"plugins.platforms.telegram.inline_picker.collect_inline_catalog",
return_value=self._fake_catalog(3),
):
results, _ = build_inline_results("", offset="not-a-number")
assert len(results) == 3
def test_result_ids_unique_across_pages(self):
catalog = self._fake_catalog(PAGE_SIZE * 2)
with patch(
"plugins.platforms.telegram.inline_picker.collect_inline_catalog",
return_value=catalog,
):
page1, off = build_inline_results("", offset="")
page2, _ = build_inline_results("", offset=off)
ids = {r["id"] for r in page1} | {r["id"] for r in page2}
assert len(ids) == PAGE_SIZE * 2
class TestCollectInlineCatalog:
def test_catalog_is_uncapped_and_includes_all_skills(self, tmp_path, monkeypatch):
"""The whole point: unlike the 60-slot menu, EVERY skill appears."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
skills = tmp_path / "skills"
names = [f"filler-{i:02d}" for i in range(70)] + ["zzz-last-skill"]
for n in names:
d = skills / n
d.mkdir(parents=True)
(d / "SKILL.md").write_text(
f"---\nname: {n}\ndescription: test skill {n}\n---\n# {n}\n"
)
# SKILLS_DIR is resolved at import time — in a full-suite run it
# points at an earlier test's HERMES_HOME, so pin it (both the
# scanner in agent.skill_commands and the prefix allowlist in
# _collect_gateway_skill_entries import it from tools.skills_tool).
from tools import skills_tool
monkeypatch.setattr(skills_tool, "SKILLS_DIR", skills)
catalog = collect_inline_catalog()
got = {i["name"] for i in catalog}
# Late-alphabet skill that the capped menu would trim is present.
assert "zzz_last_skill" in got or "zzz-last-skill" in got
# All 71 skills present (names are telegram-sanitized).
assert sum(1 for n in got if n.startswith("filler_")) == 70
# Core commands present too.
assert "help" in got and "plan" in got
def test_no_duplicate_names(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "skills").mkdir()
catalog = collect_inline_catalog()
names = [i["name"] for i in catalog]
assert len(names) == len(set(names))
# ---------------------------------------------------------------------------
# Adapter handler — telegram mock from tests/gateway/conftest.py
# ---------------------------------------------------------------------------
def _make_adapter(authorized=True):
from plugins.platforms.telegram.adapter import TelegramAdapter
adapter = object.__new__(TelegramAdapter)
adapter.platform = Platform.TELEGRAM
adapter.config = PlatformConfig(enabled=True, token="***", extra={})
adapter._is_callback_user_authorized = MagicMock(return_value=authorized)
return adapter
def _inline_update(query="", offset="", user_id=42):
inline_query = SimpleNamespace(
query=query,
offset=offset,
from_user=SimpleNamespace(id=user_id, username="tester"),
answer=AsyncMock(),
)
return SimpleNamespace(inline_query=inline_query)
@pytest.mark.asyncio
async def test_inline_query_authorized_answers_with_articles():
adapter = _make_adapter(authorized=True)
update = _inline_update(query="plan")
with patch(
"plugins.platforms.telegram.inline_picker.collect_inline_catalog",
return_value=[{"name": "plan", "description": "Plan mode"}],
):
await adapter._handle_inline_query(update, None)
update.inline_query.answer.assert_awaited_once()
args, kwargs = update.inline_query.answer.call_args
assert len(args[0]) == 1
assert kwargs["is_personal"] is True
assert kwargs["next_offset"] == ""
@pytest.mark.asyncio
async def test_inline_query_unauthorized_gets_empty_results():
adapter = _make_adapter(authorized=False)
update = _inline_update(query="plan")
await adapter._handle_inline_query(update, None)
update.inline_query.answer.assert_awaited_once()
args, kwargs = update.inline_query.answer.call_args
assert args[0] == []
assert kwargs["is_personal"] is True
@pytest.mark.asyncio
async def test_inline_query_missing_user_denied():
adapter = _make_adapter(authorized=True)
update = _inline_update(query="plan")
update.inline_query.from_user = None
await adapter._handle_inline_query(update, None)
args, _kwargs = update.inline_query.answer.call_args
assert args[0] == []
@pytest.mark.asyncio
async def test_inline_query_pagination_offset_passthrough():
adapter = _make_adapter(authorized=True)
catalog = [
{"name": f"cmd-{i:03d}", "description": ""} for i in range(PAGE_SIZE + 5)
]
update = _inline_update(query="", offset=str(PAGE_SIZE))
with patch(
"plugins.platforms.telegram.inline_picker.collect_inline_catalog",
return_value=catalog,
):
await adapter._handle_inline_query(update, None)
args, kwargs = update.inline_query.answer.call_args
assert len(args[0]) == 5
assert kwargs["next_offset"] == ""
@pytest.mark.asyncio
async def test_inline_query_answer_failure_is_swallowed():
adapter = _make_adapter(authorized=True)
update = _inline_update(query="plan")
update.inline_query.answer = AsyncMock(side_effect=RuntimeError("boom"))
# Must not raise — inline answering is best-effort.
await adapter._handle_inline_query(update, None)
@pytest.mark.asyncio
async def test_inline_query_none_update_is_noop():
adapter = _make_adapter(authorized=True)
await adapter._handle_inline_query(SimpleNamespace(inline_query=None), None)

View File

@@ -107,6 +107,22 @@ Priority is applied to the **combined** candidate list (core commands, plugin co
Telegram allows up to 100 BotCommands, but large command payloads can fail. Hermes defaults to 60 for reliability and clamps configured values to `1..100`; use `/commands` for the full command list.
### Inline command picker: search every command (no cap)
The `/` menu is capped, but Telegram's **inline mode** is not. Once enabled, type `@yourbotname` followed by a search term in any chat to get a live, searchable picker over **every** Hermes command and installed skill — results are computed per keystroke and paginated, so nothing is ever trimmed:
```
@yourbotname plan → tap the /plan result to send it
@yourbotname plan migrate auth to OIDC → sends /plan migrate auth to OIDC
@yourbotname pdf → finds skills matching "pdf" by name or description
```
The first word filters the catalog; everything after it is carried into the sent command as its argument. Tapping a result sends the command as a normal message from you, so it dispatches through the standard command path (command-prefixed messages reach the bot even with privacy mode on).
**One-time setup:** inline mode is off by default for every Telegram bot. Enable it in [@BotFather](https://t.me/BotFather) with `/setinline` (pick your bot, set any placeholder text, e.g. `Search commands and skills...`). Until then, Telegram never delivers inline queries and the picker stays inert.
Results are only served to users who pass your gateway allowlist — unauthorized users get an empty list, so your installed skill catalog is not exposed to strangers (inline queries can be sent from any chat, even ones the bot is not in).
## Step 3: Privacy Mode (Critical for Groups)
Telegram bots have a **privacy mode** that is **enabled by default**. This is the single most common source of confusion when using bots in groups.