feat: add project aliases to telegram command menu (#67)
This commit is contained in:
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -362,8 +362,12 @@ Takopi SHOULD keep the bot’s 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user