diff --git a/docs/developing.md b/docs/developing.md index ce4cd93..54ce59e 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -67,7 +67,7 @@ The Telegram adapter module containing: - `/cancel` routes by reply-to progress message id (accepts extra text) - `/{engine}` on the first line selects the engine for new threads - Resume parsing polls all runners via `AutoRouter.resolve_resume()` and routes to the first match -- Bot command menu is synced on startup (`cancel` + engine commands) +- Bot command menu is synced on startup (`cancel` + engine + project commands, capped at 100) ### `transport.py` - Transport protocol diff --git a/docs/specification.md b/docs/specification.md index a26afb1..b842c4c 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -362,8 +362,12 @@ Takopi SHOULD keep the bot’s slash-command menu in sync at startup by calling * The command list MUST include: * `cancel` — cancel the current run * one entry per configured engine + * one entry per configured project alias that is a valid Telegram command * The command list MUST NOT include commands the bot does not support. * Command descriptions SHOULD be terse and lowercase. +* The command list SHOULD be capped at 100 entries per Telegram's limit; if the + config exceeds that limit, implementations SHOULD warn and truncate while + still handling all commands at runtime. ## 9. Testing requirements (MUST) diff --git a/src/takopi/telegram/bridge.py b/src/takopi/telegram/bridge.py index 767f65d..5f64304 100644 --- a/src/takopi/telegram/bridge.py +++ b/src/takopi/telegram/bridge.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Awaitable, Callable from dataclasses import dataclass, field - +import re import anyio @@ -36,6 +36,13 @@ from .render import prepare_telegram logger = get_logger(__name__) +_COMMAND_RE = re.compile(r"^[a-z0-9_]{1,32}$") +_MAX_BOT_COMMANDS = 100 + + +def _is_valid_bot_command(command: str) -> bool: + return bool(_COMMAND_RE.fullmatch(command)) + def _is_cancel_command(text: str) -> bool: stripped = text.strip() @@ -280,7 +287,9 @@ def _resolve_message( ) -def _build_bot_commands(router: AutoRouter) -> list[dict[str, str]]: +def _build_bot_commands( + router: AutoRouter, projects: ProjectsConfig +) -> list[dict[str, str]]: commands: list[dict[str, str]] = [] seen: set[str] = set() for entry in router.available_entries: @@ -289,13 +298,34 @@ def _build_bot_commands(router: AutoRouter) -> list[dict[str, str]]: continue commands.append({"command": cmd, "description": f"start {cmd}"}) seen.add(cmd) + for alias, project in projects.projects.items(): + cmd = alias.lower() + if cmd in seen: + continue + if not _is_valid_bot_command(cmd): + logger.debug( + "startup.command_menu.skip_project", + alias=project.alias, + ) + continue + commands.append({"command": cmd, "description": f"project {cmd}"}) + seen.add(cmd) if "cancel" not in seen: commands.append({"command": "cancel", "description": "cancel run"}) + if len(commands) > _MAX_BOT_COMMANDS: + logger.warning( + "startup.command_menu.too_many", + count=len(commands), + limit=_MAX_BOT_COMMANDS, + ) + commands = commands[:_MAX_BOT_COMMANDS] + if not any(cmd["command"] == "cancel" for cmd in commands): + commands[-1] = {"command": "cancel", "description": "cancel run"} return commands async def _set_command_menu(cfg: TelegramBridgeConfig) -> None: - commands = _build_bot_commands(cfg.router) + commands = _build_bot_commands(cfg.router, cfg.projects) if not commands: return try: diff --git a/tests/test_telegram_bridge.py b/tests/test_telegram_bridge.py index 8616bb0..69851da 100644 --- a/tests/test_telegram_bridge.py +++ b/tests/test_telegram_bridge.py @@ -15,7 +15,7 @@ from takopi.telegram.bridge import ( run_main_loop, ) from takopi.context import RunContext -from takopi.config import ProjectConfig, ProjectsConfig +from takopi.config import ProjectConfig, ProjectsConfig, empty_projects_config from takopi.runner_bridge import ExecBridgeConfig, RunningTask from takopi.markdown import MarkdownPresenter from takopi.model import EngineId, ResumeToken @@ -237,12 +237,63 @@ def test_build_bot_commands_includes_cancel_and_engine() -> None: [Return(answer="ok")], engine=CODEX_ENGINE, resume_value="sid" ) router = _make_router(runner) - commands = _build_bot_commands(router) + commands = _build_bot_commands(router, empty_projects_config()) assert {"command": "cancel", "description": "cancel run"} in commands assert any(cmd["command"] == "codex" for cmd in commands) +def test_build_bot_commands_includes_projects() -> None: + runner = ScriptRunner( + [Return(answer="ok")], engine=CODEX_ENGINE, resume_value="sid" + ) + router = _make_router(runner) + projects = ProjectsConfig( + projects={ + "good": ProjectConfig( + alias="good", + path=Path("."), + worktrees_dir=Path(".worktrees"), + ), + "bad-name": ProjectConfig( + alias="bad-name", + path=Path("."), + worktrees_dir=Path(".worktrees"), + ), + }, + default_project=None, + ) + + commands = _build_bot_commands(router, projects) + + assert any(cmd["command"] == "good" for cmd in commands) + assert not any(cmd["command"] == "bad-name" for cmd in commands) + + +def test_build_bot_commands_caps_total() -> None: + runner = ScriptRunner( + [Return(answer="ok")], engine=CODEX_ENGINE, resume_value="sid" + ) + router = _make_router(runner) + projects = ProjectsConfig( + projects={ + f"proj{i}": ProjectConfig( + alias=f"proj{i}", + path=Path("."), + worktrees_dir=Path(".worktrees"), + ) + for i in range(150) + }, + default_project=None, + ) + + commands = _build_bot_commands(router, projects) + + assert len(commands) == 100 + assert any(cmd["command"] == "codex" for cmd in commands) + assert any(cmd["command"] == "cancel" for cmd in commands) + + @pytest.mark.anyio async def test_telegram_transport_passes_replace_and_wait() -> None: bot = _FakeBot()