refactor: simplify runtime, config, and telegram (#85)
This commit is contained in:
@@ -45,7 +45,7 @@ def test_chat_id_command_uses_config_token(monkeypatch) -> None:
|
||||
settings = TakopiSettings.model_validate(
|
||||
{
|
||||
"transport": "telegram",
|
||||
"transports": {"telegram": {"bot_token": "config-token"}},
|
||||
"transports": {"telegram": {"bot_token": "config-token", "chat_id": 123}},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(cli, "_load_settings_optional", lambda: (settings, Path("x")))
|
||||
|
||||
+10
-11
@@ -4,38 +4,37 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from takopi.config import ConfigError
|
||||
from takopi.config_store import read_raw_toml, write_raw_toml
|
||||
from takopi.config import ConfigError, read_config, write_config
|
||||
|
||||
|
||||
def test_read_write_raw_toml_round_trip(tmp_path: Path) -> None:
|
||||
def test_read_write_config_round_trip(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
payload = {
|
||||
"default_engine": "codex",
|
||||
"projects": {"z80": {"path": "/tmp/repo"}},
|
||||
}
|
||||
|
||||
write_raw_toml(payload, config_path)
|
||||
loaded = read_raw_toml(config_path)
|
||||
write_config(payload, config_path)
|
||||
loaded = read_config(config_path)
|
||||
|
||||
assert loaded == payload
|
||||
|
||||
|
||||
def test_read_raw_toml_missing_file(tmp_path: Path) -> None:
|
||||
def test_read_config_missing_file(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "missing.toml"
|
||||
with pytest.raises(ConfigError, match="Missing config file"):
|
||||
read_raw_toml(config_path)
|
||||
read_config(config_path)
|
||||
|
||||
|
||||
def test_read_raw_toml_invalid_toml(tmp_path: Path) -> None:
|
||||
def test_read_config_invalid_toml(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
config_path.write_text("nope = [", encoding="utf-8")
|
||||
with pytest.raises(ConfigError, match="Malformed TOML"):
|
||||
read_raw_toml(config_path)
|
||||
read_config(config_path)
|
||||
|
||||
|
||||
def test_read_raw_toml_non_file(tmp_path: Path) -> None:
|
||||
def test_read_config_non_file(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "config_dir"
|
||||
config_path.mkdir()
|
||||
with pytest.raises(ConfigError, match="exists but is not a file"):
|
||||
read_raw_toml(config_path)
|
||||
read_config(config_path)
|
||||
|
||||
@@ -4,8 +4,8 @@ import anyio
|
||||
import pytest
|
||||
|
||||
import takopi.config_watch as config_watch
|
||||
from takopi.config_watch import ConfigReload, _config_status, watch_config
|
||||
from takopi.config import empty_projects_config
|
||||
from takopi.config_watch import ConfigReload, config_status, watch_config
|
||||
from takopi.config import ProjectsConfig
|
||||
from takopi.router import AutoRouter, RunnerEntry
|
||||
from takopi.runtime_loader import RuntimeSpec
|
||||
from takopi.runners.mock import Return, ScriptRunner
|
||||
@@ -15,19 +15,23 @@ from takopi.transport_runtime import TransportRuntime
|
||||
|
||||
def test_config_status_variants(tmp_path: Path) -> None:
|
||||
missing = tmp_path / "missing.toml"
|
||||
status, signature = _config_status(missing)
|
||||
status, signature = config_status(missing)
|
||||
assert status == "missing"
|
||||
assert signature is None
|
||||
|
||||
directory = tmp_path / "config.d"
|
||||
directory.mkdir()
|
||||
status, signature = _config_status(directory)
|
||||
status, signature = config_status(directory)
|
||||
assert status == "invalid"
|
||||
assert signature is None
|
||||
|
||||
config_file = tmp_path / "takopi.toml"
|
||||
config_file.write_text('transport = "telegram"\n', encoding="utf-8")
|
||||
status, signature = _config_status(config_file)
|
||||
config_file.write_text(
|
||||
'transport = "telegram"\n\n[transports.telegram]\n'
|
||||
'bot_token = "token"\nchat_id = 123\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
status, signature = config_status(config_file)
|
||||
assert status == "ok"
|
||||
assert signature is not None
|
||||
|
||||
@@ -47,7 +51,7 @@ async def test_watch_config_applies_runtime(
|
||||
)
|
||||
runtime = TransportRuntime(
|
||||
router=router,
|
||||
projects=empty_projects_config(),
|
||||
projects=ProjectsConfig(projects={}, default_project=None),
|
||||
config_path=resolved_path,
|
||||
)
|
||||
|
||||
@@ -58,12 +62,17 @@ async def test_watch_config_applies_runtime(
|
||||
)
|
||||
new_spec = RuntimeSpec(
|
||||
router=new_router,
|
||||
projects=empty_projects_config(),
|
||||
projects=ProjectsConfig(projects={}, default_project=None),
|
||||
allowlist=None,
|
||||
plugin_configs=None,
|
||||
)
|
||||
reload = ConfigReload(
|
||||
settings=TakopiSettings.model_validate({"transport": "telegram"}),
|
||||
settings=TakopiSettings.model_validate(
|
||||
{
|
||||
"transport": "telegram",
|
||||
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
|
||||
}
|
||||
),
|
||||
runtime_spec=new_spec,
|
||||
config_path=resolved_path,
|
||||
)
|
||||
|
||||
@@ -87,7 +87,7 @@ def test_require_telegram_rejects_empty_token(tmp_path) -> None:
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ConfigError, match="bot token"):
|
||||
with pytest.raises(ConfigError, match="bot_token"):
|
||||
settings, _ = load_settings(config_path)
|
||||
require_telegram(settings, config_path)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from takopi.model import (
|
||||
StartedEvent,
|
||||
TakopiEvent,
|
||||
)
|
||||
from takopi.runners.codex import CodexRunner, _find_exec_only_flag
|
||||
from takopi.runners.codex import CodexRunner, find_exec_only_flag
|
||||
|
||||
CODEX_ENGINE = EngineId("codex")
|
||||
|
||||
@@ -159,7 +159,7 @@ def test_codex_exec_flags_after_exec() -> None:
|
||||
],
|
||||
)
|
||||
def test_find_exec_only_flag(extra_args: list[str], expected: str | None) -> None:
|
||||
assert _find_exec_only_flag(extra_args) == expected
|
||||
assert find_exec_only_flag(extra_args) == expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -49,9 +49,13 @@ def test_check_setup_marks_missing_config(monkeypatch, tmp_path: Path) -> None:
|
||||
assert result.config_path == onboarding.HOME_CONFIG_PATH
|
||||
|
||||
|
||||
def test_check_setup_marks_invalid_chat_id(monkeypatch, tmp_path: Path) -> None:
|
||||
def test_check_setup_marks_invalid_bot_token(monkeypatch, tmp_path: Path) -> None:
|
||||
backend = engines.get_backend("codex")
|
||||
monkeypatch.setattr(onboarding.shutil, "which", lambda _name: "/usr/bin/codex")
|
||||
|
||||
def _fail_require(*_args, **_kwargs):
|
||||
raise onboarding.ConfigError("Missing bot token")
|
||||
|
||||
monkeypatch.setattr(
|
||||
onboarding,
|
||||
"load_settings",
|
||||
@@ -59,12 +63,13 @@ def test_check_setup_marks_invalid_chat_id(monkeypatch, tmp_path: Path) -> None:
|
||||
TakopiSettings.model_validate(
|
||||
{
|
||||
"transport": "telegram",
|
||||
"transports": {"telegram": {"bot_token": "token", "chat_id": None}},
|
||||
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
|
||||
}
|
||||
),
|
||||
tmp_path / "takopi.toml",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(onboarding, "require_telegram", _fail_require)
|
||||
|
||||
result = onboarding.check_setup(backend)
|
||||
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from takopi.config import dump_toml
|
||||
from takopi.telegram import onboarding
|
||||
from takopi.backends import EngineBackend
|
||||
|
||||
|
||||
def test_mask_token_short() -> None:
|
||||
assert onboarding._mask_token("short") == "*****"
|
||||
assert onboarding.mask_token("short") == "*****"
|
||||
|
||||
|
||||
def test_mask_token_long() -> None:
|
||||
token = "123456789:ABCdefGH"
|
||||
masked = onboarding._mask_token(token)
|
||||
masked = onboarding.mask_token(token)
|
||||
assert masked.startswith("123456789")
|
||||
assert masked.endswith("defGH")
|
||||
assert "..." in masked
|
||||
|
||||
|
||||
def test_render_config_escapes() -> None:
|
||||
config = onboarding._render_config(
|
||||
'token"with\\quote',
|
||||
123,
|
||||
"codex",
|
||||
config = dump_toml(
|
||||
{
|
||||
"default_engine": "codex",
|
||||
"transport": "telegram",
|
||||
"transports": {
|
||||
"telegram": {
|
||||
"bot_token": 'token"with\\quote',
|
||||
"chat_id": 123,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
assert 'default_engine = "codex"' in config
|
||||
assert 'transport = "telegram"' in config
|
||||
@@ -82,9 +90,9 @@ def test_interactive_setup_writes_config(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"]))
|
||||
|
||||
def _fake_run(func, *args, **kwargs):
|
||||
if func is onboarding._get_bot_info:
|
||||
if func is onboarding.get_bot_info:
|
||||
return {"username": "my_bot"}
|
||||
if func is onboarding._wait_for_chat:
|
||||
if func is onboarding.wait_for_chat:
|
||||
return onboarding.ChatInfo(
|
||||
chat_id=123,
|
||||
username="alice",
|
||||
@@ -127,9 +135,9 @@ def test_interactive_setup_preserves_projects(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"]))
|
||||
|
||||
def _fake_run(func, *args, **kwargs):
|
||||
if func is onboarding._get_bot_info:
|
||||
if func is onboarding.get_bot_info:
|
||||
return {"username": "my_bot"}
|
||||
if func is onboarding._wait_for_chat:
|
||||
if func is onboarding.wait_for_chat:
|
||||
return onboarding.ChatInfo(
|
||||
chat_id=123,
|
||||
username="alice",
|
||||
@@ -164,9 +172,9 @@ def test_interactive_setup_no_agents_aborts(monkeypatch, tmp_path) -> None:
|
||||
)
|
||||
|
||||
def _fake_run(func, *args, **kwargs):
|
||||
if func is onboarding._get_bot_info:
|
||||
if func is onboarding.get_bot_info:
|
||||
return {"username": "my_bot"}
|
||||
if func is onboarding._wait_for_chat:
|
||||
if func is onboarding.wait_for_chat:
|
||||
return onboarding.ChatInfo(
|
||||
chat_id=123,
|
||||
username="alice",
|
||||
@@ -202,9 +210,9 @@ def test_interactive_setup_recovers_from_malformed_toml(monkeypatch, tmp_path) -
|
||||
monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"]))
|
||||
|
||||
def _fake_run(func, *args, **kwargs):
|
||||
if func is onboarding._get_bot_info:
|
||||
if func is onboarding.get_bot_info:
|
||||
return {"username": "my_bot"}
|
||||
if func is onboarding._wait_for_chat:
|
||||
if func is onboarding.wait_for_chat:
|
||||
return onboarding.ChatInfo(
|
||||
chat_id=123,
|
||||
username="alice",
|
||||
@@ -230,9 +238,9 @@ def test_interactive_setup_recovers_from_malformed_toml(monkeypatch, tmp_path) -
|
||||
|
||||
def test_capture_chat_id_with_token(monkeypatch) -> None:
|
||||
def _fake_run(func, *args, **kwargs):
|
||||
if func is onboarding._get_bot_info:
|
||||
if func is onboarding.get_bot_info:
|
||||
return {"username": "my_bot"}
|
||||
if func is onboarding._wait_for_chat:
|
||||
if func is onboarding.wait_for_chat:
|
||||
return onboarding.ChatInfo(
|
||||
chat_id=456,
|
||||
username=None,
|
||||
@@ -257,7 +265,7 @@ def test_capture_chat_id_prompts_for_token(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
def _fake_run(func, *args, **kwargs):
|
||||
if func is onboarding._wait_for_chat:
|
||||
if func is onboarding.wait_for_chat:
|
||||
return onboarding.ChatInfo(
|
||||
chat_id=789,
|
||||
username="alice",
|
||||
|
||||
@@ -4,13 +4,16 @@ import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from takopi import cli
|
||||
from takopi.config import ConfigError
|
||||
from takopi.config_store import read_raw_toml
|
||||
from takopi.config import ConfigError, read_config
|
||||
from takopi.settings import TakopiSettings
|
||||
|
||||
|
||||
def _base_config() -> dict:
|
||||
return {"transports": {"telegram": {"bot_token": "token", "chat_id": 123}}}
|
||||
|
||||
|
||||
def test_parse_projects_rejects_engine_alias() -> None:
|
||||
config = {"projects": {"codex": {"path": "/tmp/repo"}}}
|
||||
config = {**_base_config(), "projects": {"codex": {"path": "/tmp/repo"}}}
|
||||
with pytest.raises(ConfigError, match="aliases must not match engine ids"):
|
||||
settings = TakopiSettings.model_validate(config)
|
||||
settings.to_projects_config(
|
||||
@@ -21,7 +24,7 @@ def test_parse_projects_rejects_engine_alias() -> None:
|
||||
|
||||
|
||||
def test_parse_projects_default_project_must_exist() -> None:
|
||||
config = {"default_project": "z80", "projects": {}}
|
||||
config = {**_base_config(), "default_project": "z80", "projects": {}}
|
||||
with pytest.raises(ConfigError, match="default_project"):
|
||||
settings = TakopiSettings.model_validate(config)
|
||||
settings.to_projects_config(
|
||||
@@ -33,6 +36,11 @@ def test_parse_projects_default_project_must_exist() -> None:
|
||||
|
||||
def test_init_writes_project(monkeypatch, tmp_path) -> None:
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
config_path.write_text(
|
||||
'transport = "telegram"\n\n[transports.telegram]\n'
|
||||
'bot_token = "token"\nchat_id = 123\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr("takopi.config.HOME_CONFIG_PATH", config_path)
|
||||
monkeypatch.setattr(cli, "resolve_default_base", lambda _: "main")
|
||||
monkeypatch.setattr(cli, "_load_settings_optional", lambda: (None, None))
|
||||
@@ -67,7 +75,7 @@ def test_init_migrates_legacy_config(monkeypatch, tmp_path) -> None:
|
||||
result = runner.invoke(cli.create_app(), ["init", "z80"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
raw = read_raw_toml(config_path)
|
||||
raw = read_config(config_path)
|
||||
assert "bot_token" not in raw
|
||||
assert "chat_id" not in raw
|
||||
assert raw["transport"] == "telegram"
|
||||
@@ -77,7 +85,10 @@ def test_init_migrates_legacy_config(monkeypatch, tmp_path) -> None:
|
||||
|
||||
|
||||
def test_projects_default_engine_unknown() -> None:
|
||||
config = {"projects": {"z80": {"path": "/tmp/repo", "default_engine": "nope"}}}
|
||||
config = {
|
||||
**_base_config(),
|
||||
"projects": {"z80": {"path": "/tmp/repo", "default_engine": "nope"}},
|
||||
}
|
||||
settings = TakopiSettings.model_validate(config)
|
||||
with pytest.raises(ConfigError, match="projects.z80.default_engine"):
|
||||
settings.to_projects_config(
|
||||
@@ -120,7 +131,9 @@ def test_projects_chat_id_must_be_unique() -> None:
|
||||
|
||||
def test_projects_relative_path_resolves(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
settings = TakopiSettings.model_validate({"projects": {"z80": {"path": "repo"}}})
|
||||
settings = TakopiSettings.model_validate(
|
||||
{**_base_config(), "projects": {"z80": {"path": "repo"}}}
|
||||
)
|
||||
projects = settings.to_projects_config(
|
||||
config_path=config_path,
|
||||
engine_ids=["codex"],
|
||||
|
||||
@@ -11,9 +11,19 @@ def test_build_runtime_spec_minimal(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(runtime_loader.shutil, "which", lambda _cmd: "/bin/echo")
|
||||
settings = TakopiSettings.model_validate({"transport": "telegram"})
|
||||
settings = TakopiSettings.model_validate(
|
||||
{
|
||||
"transport": "telegram",
|
||||
"watch_config": True,
|
||||
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
|
||||
}
|
||||
)
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
config_path.write_text('transport = "telegram"\n', encoding="utf-8")
|
||||
config_path.write_text(
|
||||
'transport = "telegram"\n\n[transports.telegram]\n'
|
||||
'bot_token = "token"\nchat_id = 123\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
spec = runtime_loader.build_runtime_spec(
|
||||
settings=settings,
|
||||
@@ -23,10 +33,16 @@ def test_build_runtime_spec_minimal(
|
||||
assert spec.router.default_engine == settings.default_engine
|
||||
runtime = spec.to_runtime(config_path=config_path)
|
||||
assert runtime.default_engine == settings.default_engine
|
||||
assert runtime.watch_config is True
|
||||
|
||||
|
||||
def test_resolve_default_engine_unknown(tmp_path: Path) -> None:
|
||||
settings = TakopiSettings.model_validate({"transport": "telegram"})
|
||||
settings = TakopiSettings.model_validate(
|
||||
{
|
||||
"transport": "telegram",
|
||||
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
|
||||
}
|
||||
)
|
||||
with pytest.raises(ConfigError, match="Unknown default engine"):
|
||||
runtime_loader.resolve_default_engine(
|
||||
override="unknown",
|
||||
|
||||
+19
-15
@@ -4,8 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from takopi.config import ConfigError
|
||||
from takopi.config_store import read_raw_toml
|
||||
from takopi.config import ConfigError, read_config
|
||||
from takopi.settings import (
|
||||
TakopiSettings,
|
||||
load_settings,
|
||||
@@ -38,8 +37,7 @@ def test_load_settings_from_toml(tmp_path: Path) -> None:
|
||||
assert token == "token"
|
||||
assert chat_id == 123
|
||||
|
||||
dumped = settings.model_dump()
|
||||
assert dumped["transports"]["telegram"]["bot_token"] == "token"
|
||||
assert settings.transports.telegram.bot_token == "token"
|
||||
|
||||
|
||||
def test_env_overrides_toml(tmp_path: Path, monkeypatch) -> None:
|
||||
@@ -67,7 +65,7 @@ def test_legacy_keys_migrated(tmp_path: Path) -> None:
|
||||
|
||||
assert loaded_path == config_path
|
||||
assert settings.transports.telegram.chat_id == 123
|
||||
raw = read_raw_toml(config_path)
|
||||
raw = read_config(config_path)
|
||||
assert "bot_token" not in raw
|
||||
assert "chat_id" not in raw
|
||||
assert raw["transports"]["telegram"]["bot_token"] == "token"
|
||||
@@ -100,7 +98,10 @@ def test_validate_settings_data_rejects_empty_default_engine(tmp_path: Path) ->
|
||||
|
||||
def test_validate_settings_data_rejects_empty_default_project(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
data = {"default_project": " "}
|
||||
data = {
|
||||
"default_project": " ",
|
||||
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
|
||||
}
|
||||
|
||||
with pytest.raises(ConfigError, match="default_project"):
|
||||
validate_settings_data(data, config_path=config_path)
|
||||
@@ -108,7 +109,10 @@ def test_validate_settings_data_rejects_empty_default_project(tmp_path: Path) ->
|
||||
|
||||
def test_validate_settings_data_rejects_empty_project_path(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
data = {"projects": {"z80": {"path": " "}}}
|
||||
data = {
|
||||
"projects": {"z80": {"path": " "}},
|
||||
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
|
||||
}
|
||||
|
||||
with pytest.raises(ConfigError, match="path"):
|
||||
validate_settings_data(data, config_path=config_path)
|
||||
@@ -172,14 +176,14 @@ def test_transport_config_telegram_and_extra(tmp_path: Path) -> None:
|
||||
settings.transport_config("discord", config_path=config_path)
|
||||
|
||||
|
||||
def test_bot_token_none_allowed() -> None:
|
||||
settings = TakopiSettings.model_validate(
|
||||
{
|
||||
"transport": "telegram",
|
||||
"transports": {"telegram": {"bot_token": None, "chat_id": 123}},
|
||||
}
|
||||
)
|
||||
assert settings.transports.telegram.bot_token is None
|
||||
def test_bot_token_none_rejected(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
data = {
|
||||
"transport": "telegram",
|
||||
"transports": {"telegram": {"bot_token": None, "chat_id": 123}},
|
||||
}
|
||||
with pytest.raises(ConfigError, match="bot_token"):
|
||||
validate_settings_data(data, config_path=config_path)
|
||||
|
||||
|
||||
def test_require_telegram_rejects_non_telegram_transport(tmp_path: Path) -> None:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from takopi.config import ConfigError
|
||||
from takopi.settings import TakopiSettings, validate_settings_data
|
||||
|
||||
|
||||
def test_settings_strips_and_expands_transport_config(tmp_path: Path) -> None:
|
||||
settings = TakopiSettings.model_validate(
|
||||
{
|
||||
"transport": " telegram ",
|
||||
"plugins": {"enabled": [" foo "]},
|
||||
"transports": {"telegram": {"bot_token": " token ", "chat_id": 123}},
|
||||
}
|
||||
)
|
||||
|
||||
assert settings.transport == "telegram"
|
||||
assert settings.plugins.enabled == ["foo"]
|
||||
assert settings.transports.telegram.bot_token == "token"
|
||||
|
||||
|
||||
def test_settings_rejects_bool_chat_id(tmp_path: Path) -> None:
|
||||
data = {
|
||||
"transport": "telegram",
|
||||
"transports": {"telegram": {"bot_token": "token", "chat_id": True}},
|
||||
}
|
||||
|
||||
with pytest.raises(ConfigError, match="chat_id"):
|
||||
validate_settings_data(data, config_path=tmp_path / "takopi.toml")
|
||||
@@ -5,7 +5,7 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from takopi.config import ConfigError, empty_projects_config
|
||||
from takopi.config import ProjectsConfig
|
||||
from takopi.model import EngineId
|
||||
from takopi.router import AutoRouter, RunnerEntry
|
||||
from takopi.runners.mock import Return, ScriptRunner
|
||||
@@ -25,7 +25,11 @@ def test_build_startup_message_includes_missing_engines(tmp_path: Path) -> None:
|
||||
],
|
||||
default_engine=codex,
|
||||
)
|
||||
runtime = TransportRuntime(router=router, projects=empty_projects_config())
|
||||
runtime = TransportRuntime(
|
||||
router=router,
|
||||
projects=ProjectsConfig(projects={}, default_project=None),
|
||||
watch_config=True,
|
||||
)
|
||||
|
||||
message = telegram_backend._build_startup_message(
|
||||
runtime, startup_pwd=str(tmp_path)
|
||||
@@ -54,7 +58,11 @@ def test_telegram_backend_build_and_run_wires_config(
|
||||
entries=[RunnerEntry(engine=codex, runner=runner, available=True)],
|
||||
default_engine=codex,
|
||||
)
|
||||
runtime = TransportRuntime(router=router, projects=empty_projects_config())
|
||||
runtime = TransportRuntime(
|
||||
router=router,
|
||||
projects=ProjectsConfig(projects={}, default_project=None),
|
||||
watch_config=True,
|
||||
)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
@@ -91,8 +99,7 @@ def test_telegram_backend_build_and_run_wires_config(
|
||||
cfg = captured["cfg"]
|
||||
kwargs = captured["kwargs"]
|
||||
assert cfg.chat_id == 321
|
||||
assert cfg.voice_transcription is not None
|
||||
assert cfg.voice_transcription.enabled is True
|
||||
assert cfg.voice_transcription is True
|
||||
assert cfg.files.enabled is True
|
||||
assert cfg.files.allowed_user_ids == frozenset({1, 2})
|
||||
assert cfg.topics.enabled is True
|
||||
@@ -101,12 +108,10 @@ def test_telegram_backend_build_and_run_wires_config(
|
||||
assert kwargs["transport_id"] == "telegram"
|
||||
|
||||
|
||||
def test_build_files_config_rejects_non_dict(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
transport_config: dict[str, object] = {"files": ["nope"]}
|
||||
def test_build_files_config_defaults() -> None:
|
||||
cfg = telegram_backend._build_files_config({})
|
||||
|
||||
with pytest.raises(ConfigError, match="transports.telegram.files"):
|
||||
telegram_backend._build_files_config(
|
||||
transport_config,
|
||||
config_path=config_path,
|
||||
)
|
||||
assert cfg.enabled is False
|
||||
assert cfg.auto_put is True
|
||||
assert cfg.uploads_dir == "incoming"
|
||||
assert cfg.allowed_user_ids == frozenset()
|
||||
|
||||
@@ -7,23 +7,26 @@ import pytest
|
||||
|
||||
from takopi import commands, plugins
|
||||
import takopi.telegram.bridge as bridge
|
||||
import takopi.telegram.loop as telegram_loop
|
||||
import takopi.telegram.commands as telegram_commands
|
||||
import takopi.telegram.topics as telegram_topics
|
||||
from takopi.directives import parse_directives
|
||||
from takopi.telegram.bridge import (
|
||||
TelegramBridgeConfig,
|
||||
TelegramFilesConfig,
|
||||
TelegramPresenter,
|
||||
TelegramTransport,
|
||||
_build_bot_commands,
|
||||
_handle_callback_cancel,
|
||||
_handle_cancel,
|
||||
_is_cancel_command,
|
||||
_send_with_resume,
|
||||
build_bot_commands,
|
||||
handle_callback_cancel,
|
||||
handle_cancel,
|
||||
is_cancel_command,
|
||||
send_with_resume,
|
||||
run_main_loop,
|
||||
)
|
||||
from takopi.telegram.client import BotClient
|
||||
from takopi.telegram.topic_state import TopicStateStore, resolve_state_path
|
||||
from takopi.context import RunContext
|
||||
from takopi.config import ProjectConfig, ProjectsConfig, empty_projects_config
|
||||
from takopi.config import ProjectConfig, ProjectsConfig
|
||||
from takopi.runner_bridge import ExecBridgeConfig, RunningTask
|
||||
from takopi.markdown import MarkdownPresenter
|
||||
from takopi.model import EngineId, ResumeToken
|
||||
@@ -42,6 +45,10 @@ from tests.plugin_fixtures import FakeEntryPoint, install_entrypoints
|
||||
CODEX_ENGINE = EngineId("codex")
|
||||
|
||||
|
||||
def _empty_projects() -> ProjectsConfig:
|
||||
return ProjectsConfig(projects={}, default_project=None)
|
||||
|
||||
|
||||
def _make_router(runner) -> AutoRouter:
|
||||
return AutoRouter(
|
||||
entries=[RunnerEntry(engine=runner.engine, runner=runner)],
|
||||
@@ -288,7 +295,7 @@ def _make_cfg(
|
||||
)
|
||||
runtime = TransportRuntime(
|
||||
router=_make_router(runner),
|
||||
projects=empty_projects_config(),
|
||||
projects=_empty_projects(),
|
||||
)
|
||||
return TelegramBridgeConfig(
|
||||
bot=_FakeBot(),
|
||||
@@ -303,7 +310,7 @@ def test_parse_directives_inline_engine() -> None:
|
||||
directives = parse_directives(
|
||||
"/claude do it",
|
||||
engine_ids=("codex", "claude"),
|
||||
projects=empty_projects_config(),
|
||||
projects=_empty_projects(),
|
||||
)
|
||||
assert directives.engine == "claude"
|
||||
assert directives.prompt == "do it"
|
||||
@@ -313,7 +320,7 @@ def test_parse_directives_newline() -> None:
|
||||
directives = parse_directives(
|
||||
"/codex\nhello",
|
||||
engine_ids=("codex", "claude"),
|
||||
projects=empty_projects_config(),
|
||||
projects=_empty_projects(),
|
||||
)
|
||||
assert directives.engine == "codex"
|
||||
assert directives.prompt == "hello"
|
||||
@@ -323,7 +330,7 @@ def test_parse_directives_ignores_unknown() -> None:
|
||||
directives = parse_directives(
|
||||
"/unknown hi",
|
||||
engine_ids=("codex", "claude"),
|
||||
projects=empty_projects_config(),
|
||||
projects=_empty_projects(),
|
||||
)
|
||||
assert directives.engine is None
|
||||
assert directives.prompt == "/unknown hi"
|
||||
@@ -333,7 +340,7 @@ def test_parse_directives_bot_suffix() -> None:
|
||||
directives = parse_directives(
|
||||
"/claude@bunny_agent_bot hi",
|
||||
engine_ids=("claude",),
|
||||
projects=empty_projects_config(),
|
||||
projects=_empty_projects(),
|
||||
)
|
||||
assert directives.engine == "claude"
|
||||
assert directives.prompt == "hi"
|
||||
@@ -343,7 +350,7 @@ def test_parse_directives_only_first_non_empty_line() -> None:
|
||||
directives = parse_directives(
|
||||
"hello\n/claude hi",
|
||||
engine_ids=("codex", "claude"),
|
||||
projects=empty_projects_config(),
|
||||
projects=_empty_projects(),
|
||||
)
|
||||
assert directives.engine is None
|
||||
assert directives.prompt == "hello\n/claude hi"
|
||||
@@ -355,9 +362,9 @@ def test_build_bot_commands_includes_cancel_and_engine() -> None:
|
||||
)
|
||||
runtime = TransportRuntime(
|
||||
router=_make_router(runner),
|
||||
projects=empty_projects_config(),
|
||||
projects=_empty_projects(),
|
||||
)
|
||||
commands = _build_bot_commands(runtime)
|
||||
commands = build_bot_commands(runtime)
|
||||
|
||||
assert {"command": "cancel", "description": "cancel run"} in commands
|
||||
assert {"command": "file", "description": "upload or fetch files"} in commands
|
||||
@@ -386,7 +393,7 @@ def test_build_bot_commands_includes_projects() -> None:
|
||||
)
|
||||
|
||||
runtime = TransportRuntime(router=router, projects=projects)
|
||||
commands = _build_bot_commands(runtime)
|
||||
commands = build_bot_commands(runtime)
|
||||
|
||||
assert any(cmd["command"] == "good" for cmd in commands)
|
||||
assert not any(cmd["command"] == "bad-name" for cmd in commands)
|
||||
@@ -413,10 +420,10 @@ def test_build_bot_commands_includes_command_plugins(monkeypatch) -> None:
|
||||
runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
|
||||
runtime = TransportRuntime(
|
||||
router=_make_router(runner),
|
||||
projects=empty_projects_config(),
|
||||
projects=_empty_projects(),
|
||||
)
|
||||
|
||||
commands_list = _build_bot_commands(runtime)
|
||||
commands_list = build_bot_commands(runtime)
|
||||
|
||||
assert {"command": "pingcmd", "description": "ping command"} in commands_list
|
||||
|
||||
@@ -439,7 +446,7 @@ def test_build_bot_commands_caps_total() -> None:
|
||||
)
|
||||
|
||||
runtime = TransportRuntime(router=router, projects=projects)
|
||||
commands = _build_bot_commands(runtime)
|
||||
commands = build_bot_commands(runtime)
|
||||
|
||||
assert len(commands) == 100
|
||||
assert any(cmd["command"] == "codex" for cmd in commands)
|
||||
@@ -667,7 +674,7 @@ async def test_handle_cancel_without_reply_prompts_user() -> None:
|
||||
)
|
||||
running_tasks: dict = {}
|
||||
|
||||
await _handle_cancel(cfg, msg, running_tasks)
|
||||
await handle_cancel(cfg, msg, running_tasks)
|
||||
|
||||
assert len(transport.send_calls) == 1
|
||||
assert "reply to the progress message" in transport.send_calls[0]["message"].text
|
||||
@@ -688,7 +695,7 @@ async def test_handle_cancel_with_no_progress_message_says_nothing_running() ->
|
||||
)
|
||||
running_tasks: dict = {}
|
||||
|
||||
await _handle_cancel(cfg, msg, running_tasks)
|
||||
await handle_cancel(cfg, msg, running_tasks)
|
||||
|
||||
assert len(transport.send_calls) == 1
|
||||
assert "nothing is currently running" in transport.send_calls[0]["message"].text
|
||||
@@ -710,7 +717,7 @@ async def test_handle_cancel_with_finished_task_says_nothing_running() -> None:
|
||||
)
|
||||
running_tasks: dict = {}
|
||||
|
||||
await _handle_cancel(cfg, msg, running_tasks)
|
||||
await handle_cancel(cfg, msg, running_tasks)
|
||||
|
||||
assert len(transport.send_calls) == 1
|
||||
assert "nothing is currently running" in transport.send_calls[0]["message"].text
|
||||
@@ -733,7 +740,7 @@ async def test_handle_cancel_cancels_running_task() -> None:
|
||||
|
||||
running_task = RunningTask()
|
||||
running_tasks = {MessageRef(channel_id=123, message_id=progress_id): running_task}
|
||||
await _handle_cancel(cfg, msg, running_tasks)
|
||||
await handle_cancel(cfg, msg, running_tasks)
|
||||
|
||||
assert running_task.cancel_requested.is_set() is True
|
||||
assert len(transport.send_calls) == 0 # No error message sent
|
||||
@@ -759,7 +766,7 @@ async def test_handle_cancel_only_cancels_matching_progress_message() -> None:
|
||||
MessageRef(channel_id=123, message_id=2): task_second,
|
||||
}
|
||||
|
||||
await _handle_cancel(cfg, msg, running_tasks)
|
||||
await handle_cancel(cfg, msg, running_tasks)
|
||||
|
||||
assert task_first.cancel_requested.is_set() is True
|
||||
assert task_second.cancel_requested.is_set() is False
|
||||
@@ -824,7 +831,9 @@ async def test_handle_file_put_writes_file(tmp_path: Path) -> None:
|
||||
),
|
||||
)
|
||||
|
||||
await bridge._handle_file_put(cfg, msg, "/proj uploads/hello.txt", None, None)
|
||||
await telegram_commands._handle_file_put(
|
||||
cfg, msg, "/proj uploads/hello.txt", None, None
|
||||
)
|
||||
|
||||
target = tmp_path / "uploads" / "hello.txt"
|
||||
assert target.read_bytes() == payload
|
||||
@@ -883,7 +892,7 @@ async def test_handle_file_get_sends_document_for_allowed_user(
|
||||
chat_type="supergroup",
|
||||
)
|
||||
|
||||
await bridge._handle_file_get(cfg, msg, "/proj hello.txt", None, None)
|
||||
await telegram_commands._handle_file_get(cfg, msg, "/proj hello.txt", None, None)
|
||||
|
||||
assert bot.document_calls
|
||||
assert bot.document_calls[0]["filename"] == "hello.txt"
|
||||
@@ -906,7 +915,7 @@ async def test_handle_callback_cancel_cancels_running_task() -> None:
|
||||
sender_id=123,
|
||||
)
|
||||
|
||||
await _handle_callback_cancel(cfg, query, running_tasks)
|
||||
await handle_callback_cancel(cfg, query, running_tasks)
|
||||
|
||||
assert running_task.cancel_requested.is_set() is True
|
||||
assert len(transport.send_calls) == 0
|
||||
@@ -928,7 +937,7 @@ async def test_handle_callback_cancel_without_task_acknowledges() -> None:
|
||||
sender_id=123,
|
||||
)
|
||||
|
||||
await _handle_callback_cancel(cfg, query, {})
|
||||
await handle_callback_cancel(cfg, query, {})
|
||||
|
||||
assert len(transport.send_calls) == 0
|
||||
bot = cast(_FakeBot, cfg.bot)
|
||||
@@ -937,9 +946,9 @@ async def test_handle_callback_cancel_without_task_acknowledges() -> None:
|
||||
|
||||
|
||||
def test_cancel_command_accepts_extra_text() -> None:
|
||||
assert _is_cancel_command("/cancel now") is True
|
||||
assert _is_cancel_command("/cancel@takopi please") is True
|
||||
assert _is_cancel_command("/cancelled") is False
|
||||
assert is_cancel_command("/cancel now") is True
|
||||
assert is_cancel_command("/cancel@takopi please") is True
|
||||
assert is_cancel_command("/cancelled") is False
|
||||
|
||||
|
||||
def test_resolve_message_accepts_backticked_ctx_line() -> None:
|
||||
@@ -971,24 +980,21 @@ def test_topic_title_matches_command_syntax() -> None:
|
||||
transport = _FakeTransport()
|
||||
cfg = _make_cfg(transport)
|
||||
|
||||
title = bridge._topic_title(
|
||||
cfg=cfg,
|
||||
title = telegram_topics._topic_title(
|
||||
runtime=cfg.runtime,
|
||||
context=RunContext(project="takopi", branch="master"),
|
||||
)
|
||||
|
||||
assert title == "takopi @master"
|
||||
|
||||
title = bridge._topic_title(
|
||||
cfg=cfg,
|
||||
title = telegram_topics._topic_title(
|
||||
runtime=cfg.runtime,
|
||||
context=RunContext(project="takopi", branch=None),
|
||||
)
|
||||
|
||||
assert title == "takopi"
|
||||
|
||||
title = bridge._topic_title(
|
||||
cfg=cfg,
|
||||
title = telegram_topics._topic_title(
|
||||
runtime=cfg.runtime,
|
||||
context=RunContext(project=None, branch="main"),
|
||||
)
|
||||
@@ -1006,8 +1012,7 @@ def test_topic_title_projects_scope_includes_project() -> None:
|
||||
),
|
||||
)
|
||||
|
||||
title = bridge._topic_title(
|
||||
cfg=cfg,
|
||||
title = telegram_topics._topic_title(
|
||||
runtime=cfg.runtime,
|
||||
context=RunContext(project="takopi", branch="master"),
|
||||
)
|
||||
@@ -1028,7 +1033,7 @@ async def test_maybe_rename_topic_updates_title(tmp_path: Path) -> None:
|
||||
topic_title="takopi @old",
|
||||
)
|
||||
|
||||
await bridge._maybe_rename_topic(
|
||||
await telegram_topics._maybe_rename_topic(
|
||||
cfg,
|
||||
store,
|
||||
chat_id=123,
|
||||
@@ -1058,7 +1063,7 @@ async def test_maybe_rename_topic_skips_when_title_matches(tmp_path: Path) -> No
|
||||
)
|
||||
snapshot = await store.get_thread(123, 77)
|
||||
|
||||
await bridge._maybe_rename_topic(
|
||||
await telegram_topics._maybe_rename_topic(
|
||||
cfg,
|
||||
store,
|
||||
chat_id=123,
|
||||
@@ -1096,7 +1101,7 @@ async def test_send_with_resume_waits_for_token() -> None:
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(trigger_resume)
|
||||
await _send_with_resume(
|
||||
await send_with_resume(
|
||||
cfg,
|
||||
enqueue,
|
||||
running_task,
|
||||
@@ -1138,7 +1143,7 @@ async def test_send_with_resume_reports_when_missing() -> None:
|
||||
running_task = RunningTask()
|
||||
running_task.done.set()
|
||||
|
||||
await _send_with_resume(
|
||||
await send_with_resume(
|
||||
cfg,
|
||||
enqueue,
|
||||
running_task,
|
||||
@@ -1175,7 +1180,7 @@ async def test_run_main_loop_routes_reply_to_running_resume() -> None:
|
||||
)
|
||||
runtime = TransportRuntime(
|
||||
router=_make_router(runner),
|
||||
projects=empty_projects_config(),
|
||||
projects=_empty_projects(),
|
||||
)
|
||||
cfg = TelegramBridgeConfig(
|
||||
bot=bot,
|
||||
@@ -1311,7 +1316,7 @@ async def test_run_main_loop_replies_in_same_thread() -> None:
|
||||
)
|
||||
runtime = TransportRuntime(
|
||||
router=_make_router(runner),
|
||||
projects=empty_projects_config(),
|
||||
projects=_empty_projects(),
|
||||
)
|
||||
cfg = TelegramBridgeConfig(
|
||||
bot=bot,
|
||||
@@ -1489,7 +1494,7 @@ async def test_run_main_loop_handles_command_plugins(monkeypatch) -> None:
|
||||
)
|
||||
runtime = TransportRuntime(
|
||||
router=_make_router(runner),
|
||||
projects=empty_projects_config(),
|
||||
projects=_empty_projects(),
|
||||
)
|
||||
cfg = TelegramBridgeConfig(
|
||||
bot=bot,
|
||||
@@ -1714,7 +1719,7 @@ async def test_run_main_loop_refreshes_command_ids(monkeypatch) -> None:
|
||||
return []
|
||||
return ["late_cmd"]
|
||||
|
||||
monkeypatch.setattr(bridge, "list_command_ids", _list_command_ids)
|
||||
monkeypatch.setattr(telegram_loop, "list_command_ids", _list_command_ids)
|
||||
|
||||
transport = _FakeTransport()
|
||||
bot = _FakeBot()
|
||||
@@ -1726,7 +1731,7 @@ async def test_run_main_loop_refreshes_command_ids(monkeypatch) -> None:
|
||||
)
|
||||
runtime = TransportRuntime(
|
||||
router=_make_router(runner),
|
||||
projects=empty_projects_config(),
|
||||
projects=_empty_projects(),
|
||||
)
|
||||
cfg = TelegramBridgeConfig(
|
||||
bot=bot,
|
||||
|
||||
Reference in New Issue
Block a user