feat: add project aliases to telegram command menu (#67)

This commit is contained in:
banteg
2026-01-08 11:26:09 +04:00
committed by GitHub
parent d606833603
commit c0579a4ebd
4 changed files with 91 additions and 6 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ The Telegram adapter module containing:
- `/cancel` routes by reply-to progress message id (accepts extra text) - `/cancel` routes by reply-to progress message id (accepts extra text)
- `/{engine}` on the first line selects the engine for new threads - `/{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 - 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 ### `transport.py` - Transport protocol
+4
View File
@@ -362,8 +362,12 @@ Takopi SHOULD keep the bots slash-command menu in sync at startup by calling
* The command list MUST include: * The command list MUST include:
* `cancel` — cancel the current run * `cancel` — cancel the current run
* one entry per configured engine * 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. * The command list MUST NOT include commands the bot does not support.
* Command descriptions SHOULD be terse and lowercase. * 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) ## 9. Testing requirements (MUST)
+33 -3
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
import re
import anyio import anyio
@@ -36,6 +36,13 @@ from .render import prepare_telegram
logger = get_logger(__name__) 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: def _is_cancel_command(text: str) -> bool:
stripped = text.strip() 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]] = [] commands: list[dict[str, str]] = []
seen: set[str] = set() seen: set[str] = set()
for entry in router.available_entries: for entry in router.available_entries:
@@ -289,13 +298,34 @@ def _build_bot_commands(router: AutoRouter) -> list[dict[str, str]]:
continue continue
commands.append({"command": cmd, "description": f"start {cmd}"}) commands.append({"command": cmd, "description": f"start {cmd}"})
seen.add(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: if "cancel" not in seen:
commands.append({"command": "cancel", "description": "cancel run"}) 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 return commands
async def _set_command_menu(cfg: TelegramBridgeConfig) -> None: 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: if not commands:
return return
try: try:
+53 -2
View File
@@ -15,7 +15,7 @@ from takopi.telegram.bridge import (
run_main_loop, run_main_loop,
) )
from takopi.context import RunContext 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.runner_bridge import ExecBridgeConfig, RunningTask
from takopi.markdown import MarkdownPresenter from takopi.markdown import MarkdownPresenter
from takopi.model import EngineId, ResumeToken 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" [Return(answer="ok")], engine=CODEX_ENGINE, resume_value="sid"
) )
router = _make_router(runner) 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 {"command": "cancel", "description": "cancel run"} in commands
assert any(cmd["command"] == "codex" for cmd 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 @pytest.mark.anyio
async def test_telegram_transport_passes_replace_and_wait() -> None: async def test_telegram_transport_passes_replace_and_wait() -> None:
bot = _FakeBot() bot = _FakeBot()