feat: add per-project chat routing (#76)

This commit is contained in:
banteg
2026-01-10 01:22:20 +04:00
committed by GitHub
parent 7ffb99d779
commit 81618e48e4
12 changed files with 299 additions and 14 deletions
+31
View File
@@ -87,6 +87,37 @@ def test_projects_default_engine_unknown() -> None:
)
def test_projects_chat_id_cannot_match_transport_chat_id() -> None:
config = {
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
"projects": {"z80": {"path": "/tmp/repo", "chat_id": 123}},
}
settings = TakopiSettings.model_validate(config)
with pytest.raises(ConfigError, match="chat_id"):
settings.to_projects_config(
config_path=Path("takopi.toml"),
engine_ids=["codex"],
reserved=("cancel",),
)
def test_projects_chat_id_must_be_unique() -> None:
config = {
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
"projects": {
"a": {"path": "/tmp/a", "chat_id": -10},
"b": {"path": "/tmp/b", "chat_id": -10},
},
}
settings = TakopiSettings.model_validate(config)
with pytest.raises(ConfigError, match="chat_id"):
settings.to_projects_config(
config_path=Path("takopi.toml"),
engine_ids=["codex"],
reserved=("cancel",),
)
def test_projects_relative_path_resolves(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
settings = TakopiSettings.model_validate({"projects": {"z80": {"path": "repo"}}})
+84
View File
@@ -911,6 +911,90 @@ async def test_run_main_loop_command_uses_project_default_engine(
assert transport.send_calls[-1]["message"].text == "ran:pi"
@pytest.mark.anyio
async def test_run_main_loop_command_defaults_to_chat_project(
monkeypatch,
) -> None:
class _Command:
id = "auto_ctx"
description = "auto context"
async def handle(self, ctx):
result = await ctx.executor.run_one(
commands.RunRequest(prompt="hello"),
mode="capture",
)
return commands.CommandResult(text=f"ran:{result.engine}")
entrypoints = [
FakeEntryPoint(
"auto_ctx",
"takopi.commands.auto_ctx:BACKEND",
plugins.COMMAND_GROUP,
loader=_Command,
)
]
install_entrypoints(monkeypatch, entrypoints)
transport = _FakeTransport()
bot = _FakeBot()
codex_runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
pi_runner = ScriptRunner([Return(answer="ok")], engine=EngineId("pi"))
router = AutoRouter(
entries=[
RunnerEntry(engine=codex_runner.engine, runner=codex_runner),
RunnerEntry(engine=pi_runner.engine, runner=pi_runner),
],
default_engine=codex_runner.engine,
)
projects = ProjectsConfig(
projects={
"proj": ProjectConfig(
alias="proj",
path=Path("."),
worktrees_dir=Path(".worktrees"),
default_engine=pi_runner.engine,
chat_id=-42,
)
},
default_project=None,
chat_map={-42: "proj"},
)
runtime = TransportRuntime(
router=router,
projects=projects,
)
exec_cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
)
cfg = TelegramBridgeConfig(
bot=bot,
runtime=runtime,
chat_id=123,
startup_msg="",
exec_cfg=exec_cfg,
)
async def poller(_cfg: TelegramBridgeConfig):
yield TelegramIncomingMessage(
transport="telegram",
chat_id=-42,
message_id=1,
text="/auto_ctx",
reply_to_message_id=None,
reply_to_text=None,
sender_id=123,
)
await run_main_loop(cfg, poller)
assert codex_runner.calls == []
assert len(pi_runner.calls) == 1
assert transport.send_calls[-1]["message"].text == "ran:pi"
@pytest.mark.anyio
async def test_run_main_loop_refreshes_command_ids(monkeypatch) -> None:
class _Command:
+28
View File
@@ -43,3 +43,31 @@ def test_resolve_engine_prefers_override() -> None:
context=RunContext(project="proj"),
)
assert engine == "codex"
def test_resolve_message_defaults_to_chat_project() -> None:
codex = ScriptRunner([Return(answer="ok")], engine="codex")
router = AutoRouter(
entries=[RunnerEntry(engine=codex.engine, runner=codex)],
default_engine=codex.engine,
)
project = ProjectConfig(
alias="proj",
path=Path("."),
worktrees_dir=Path(".worktrees"),
chat_id=-42,
)
projects = ProjectsConfig(
projects={"proj": project},
default_project=None,
chat_map={-42: "proj"},
)
runtime = TransportRuntime(router=router, projects=projects)
resolved = runtime.resolve_message(
text="hello",
reply_text=None,
chat_id=-42,
)
assert resolved.context == RunContext(project="proj", branch=None)