diff --git a/src/takopi/settings.py b/src/takopi/settings.py index 2328199..f20f933 100644 --- a/src/takopi/settings.py +++ b/src/takopi/settings.py @@ -5,6 +5,7 @@ from typing import Annotated, Any, ClassVar, Literal from collections.abc import Iterable from pydantic import ( + BeforeValidator, BaseModel, ConfigDict, Field, @@ -53,6 +54,15 @@ def _normalize_project_path(value: str, *, config_path: Path) -> Path: return path +def _coerce_chat_id(value: Any) -> Any: + if isinstance(value, str): + return int(value.strip()) + return value + + +ChatId = Annotated[StrictInt, BeforeValidator(_coerce_chat_id)] + + class TelegramTopicsSettings(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) @@ -93,7 +103,7 @@ class TelegramTransportSettings(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) bot_token: NonEmptyStr - chat_id: StrictInt + chat_id: ChatId allowed_user_ids: list[StrictInt] = Field(default_factory=list) message_overflow: Literal["trim", "split"] = "trim" voice_transcription: bool = False @@ -128,7 +138,7 @@ class ProjectSettings(BaseModel): worktrees_dir: NonEmptyStr = ".worktrees" default_engine: NonEmptyStr | None = None worktree_base: NonEmptyStr | None = None - chat_id: StrictInt | None = None + chat_id: ChatId | None = None class TakopiSettings(BaseSettings): diff --git a/tests/test_exec_bridge.py b/tests/test_exec_bridge.py index 32ff833..c793c25 100644 --- a/tests/test_exec_bridge.py +++ b/tests/test_exec_bridge.py @@ -92,8 +92,8 @@ def test_require_telegram_rejects_empty_token(tmp_path) -> None: require_telegram(settings, config_path) -def test_load_settings_rejects_string_chat_id(tmp_path) -> None: - from takopi.config import ConfigError +def test_load_settings_accepts_string_chat_id(tmp_path) -> None: + from takopi.settings import require_telegram config_path = tmp_path / "takopi.toml" config_path.write_text( @@ -102,8 +102,9 @@ def test_load_settings_rejects_string_chat_id(tmp_path) -> None: encoding="utf-8", ) - with pytest.raises(ConfigError, match="chat_id"): - load_settings(config_path) + settings, _ = load_settings(config_path) + _, chat_id = require_telegram(settings, config_path) + assert chat_id == 123 def test_codex_extract_resume_finds_command() -> None: diff --git a/tests/test_projects_config.py b/tests/test_projects_config.py index d708315..f9d69d1 100644 --- a/tests/test_projects_config.py +++ b/tests/test_projects_config.py @@ -130,6 +130,22 @@ def test_projects_chat_id_must_be_unique() -> None: ) +def test_projects_string_chat_id_is_coerced() -> None: + config = { + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + "projects": {"z80": {"path": "/tmp/repo", "chat_id": "-10"}}, + } + settings = TakopiSettings.model_validate(config) + projects = settings.to_projects_config( + config_path=Path("takopi.toml"), + engine_ids=["codex"], + reserved=RESERVED_CHAT_COMMANDS, + ) + + assert projects.projects["z80"].chat_id == -10 + assert projects.chat_map[-10] == "z80" + + def test_projects_relative_path_resolves(tmp_path: Path) -> None: config_path = tmp_path / "takopi.toml" settings = TakopiSettings.model_validate( diff --git a/tests/test_settings_contract.py b/tests/test_settings_contract.py index c303ade..01bd3fb 100644 --- a/tests/test_settings_contract.py +++ b/tests/test_settings_contract.py @@ -28,3 +28,13 @@ def test_settings_rejects_bool_chat_id(tmp_path: Path) -> None: with pytest.raises(ConfigError, match="chat_id"): validate_settings_data(data, config_path=tmp_path / "takopi.toml") + + +def test_settings_rejects_float_chat_id(tmp_path: Path) -> None: + data = { + "transport": "telegram", + "transports": {"telegram": {"bot_token": "token", "chat_id": 123.0}}, + } + + with pytest.raises(ConfigError, match="chat_id"): + validate_settings_data(data, config_path=tmp_path / "takopi.toml")