From 5bdaea64ed2e18c9e81fc047dcb4d989a9ccea89 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:19:59 -0700 Subject: [PATCH] =?UTF-8?q?feat(telegram):=20inline=20command=20picker=20?= =?UTF-8?q?=E2=80=94=20search=20every=20command=20and=20skill,=20no=20menu?= =?UTF-8?q?=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- plugins/platforms/telegram/adapter.py | 99 ++++++- plugins/platforms/telegram/inline_picker.py | 166 ++++++++++++ tests/gateway/test_telegram_inline_picker.py | 250 ++++++++++++++++++ website/docs/user-guide/messaging/telegram.md | 16 ++ 4 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 plugins/platforms/telegram/inline_picker.py create mode 100644 tests/gateway/test_telegram_inline_picker.py diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index d6a9b97c16..c782bfe8be 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -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 ) — 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 `` 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 ``) 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: diff --git a/plugins/platforms/telegram/inline_picker.py b/plugins/platforms/telegram/inline_picker.py new file mode 100644 index 0000000000..ae6eb39e37 --- /dev/null +++ b/plugins/platforms/telegram/inline_picker.py @@ -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 `` 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 diff --git a/tests/gateway/test_telegram_inline_picker.py b/tests/gateway/test_telegram_inline_picker.py new file mode 100644 index 0000000000..61e4356695 --- /dev/null +++ b/tests/gateway/test_telegram_inline_picker.py @@ -0,0 +1,250 @@ +"""Tests for the Telegram inline command picker (@botname ). + +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) diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 5f3dfb3e6d..cd651d7df5 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -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.