yee88 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. takopi/__init__.py +1 -0
  2. takopi/api.py +116 -0
  3. takopi/backends.py +25 -0
  4. takopi/backends_helpers.py +14 -0
  5. takopi/cli/__init__.py +228 -0
  6. takopi/cli/config.py +320 -0
  7. takopi/cli/doctor.py +173 -0
  8. takopi/cli/init.py +113 -0
  9. takopi/cli/onboarding_cmd.py +126 -0
  10. takopi/cli/plugins.py +196 -0
  11. takopi/cli/run.py +419 -0
  12. takopi/cli/topic.py +355 -0
  13. takopi/commands.py +134 -0
  14. takopi/config.py +142 -0
  15. takopi/config_migrations.py +124 -0
  16. takopi/config_watch.py +146 -0
  17. takopi/context.py +9 -0
  18. takopi/directives.py +146 -0
  19. takopi/engines.py +53 -0
  20. takopi/events.py +170 -0
  21. takopi/ids.py +17 -0
  22. takopi/lockfile.py +158 -0
  23. takopi/logging.py +283 -0
  24. takopi/markdown.py +298 -0
  25. takopi/model.py +77 -0
  26. takopi/plugins.py +312 -0
  27. takopi/presenter.py +25 -0
  28. takopi/progress.py +99 -0
  29. takopi/router.py +113 -0
  30. takopi/runner.py +712 -0
  31. takopi/runner_bridge.py +619 -0
  32. takopi/runners/__init__.py +1 -0
  33. takopi/runners/claude.py +483 -0
  34. takopi/runners/codex.py +656 -0
  35. takopi/runners/mock.py +221 -0
  36. takopi/runners/opencode.py +505 -0
  37. takopi/runners/pi.py +523 -0
  38. takopi/runners/run_options.py +39 -0
  39. takopi/runners/tool_actions.py +90 -0
  40. takopi/runtime_loader.py +207 -0
  41. takopi/scheduler.py +159 -0
  42. takopi/schemas/__init__.py +1 -0
  43. takopi/schemas/claude.py +238 -0
  44. takopi/schemas/codex.py +169 -0
  45. takopi/schemas/opencode.py +51 -0
  46. takopi/schemas/pi.py +117 -0
  47. takopi/settings.py +360 -0
  48. takopi/telegram/__init__.py +20 -0
  49. takopi/telegram/api_models.py +37 -0
  50. takopi/telegram/api_schemas.py +152 -0
  51. takopi/telegram/backend.py +163 -0
  52. takopi/telegram/bridge.py +425 -0
  53. takopi/telegram/chat_prefs.py +242 -0
  54. takopi/telegram/chat_sessions.py +112 -0
  55. takopi/telegram/client.py +409 -0
  56. takopi/telegram/client_api.py +539 -0
  57. takopi/telegram/commands/__init__.py +12 -0
  58. takopi/telegram/commands/agent.py +196 -0
  59. takopi/telegram/commands/cancel.py +116 -0
  60. takopi/telegram/commands/dispatch.py +111 -0
  61. takopi/telegram/commands/executor.py +449 -0
  62. takopi/telegram/commands/file_transfer.py +586 -0
  63. takopi/telegram/commands/handlers.py +45 -0
  64. takopi/telegram/commands/media.py +143 -0
  65. takopi/telegram/commands/menu.py +139 -0
  66. takopi/telegram/commands/model.py +215 -0
  67. takopi/telegram/commands/overrides.py +159 -0
  68. takopi/telegram/commands/parse.py +30 -0
  69. takopi/telegram/commands/plan.py +16 -0
  70. takopi/telegram/commands/reasoning.py +234 -0
  71. takopi/telegram/commands/reply.py +23 -0
  72. takopi/telegram/commands/topics.py +332 -0
  73. takopi/telegram/commands/trigger.py +143 -0
  74. takopi/telegram/context.py +140 -0
  75. takopi/telegram/engine_defaults.py +86 -0
  76. takopi/telegram/engine_overrides.py +105 -0
  77. takopi/telegram/files.py +178 -0
  78. takopi/telegram/loop.py +1822 -0
  79. takopi/telegram/onboarding.py +1088 -0
  80. takopi/telegram/outbox.py +177 -0
  81. takopi/telegram/parsing.py +239 -0
  82. takopi/telegram/render.py +198 -0
  83. takopi/telegram/state_store.py +88 -0
  84. takopi/telegram/topic_state.py +334 -0
  85. takopi/telegram/topics.py +256 -0
  86. takopi/telegram/trigger_mode.py +68 -0
  87. takopi/telegram/types.py +63 -0
  88. takopi/telegram/voice.py +110 -0
  89. takopi/transport.py +53 -0
  90. takopi/transport_runtime.py +323 -0
  91. takopi/transports.py +76 -0
  92. takopi/utils/__init__.py +1 -0
  93. takopi/utils/git.py +87 -0
  94. takopi/utils/json_state.py +21 -0
  95. takopi/utils/paths.py +47 -0
  96. takopi/utils/streams.py +44 -0
  97. takopi/utils/subprocess.py +86 -0
  98. takopi/worktrees.py +135 -0
  99. yee88-0.1.0.dist-info/METADATA +116 -0
  100. yee88-0.1.0.dist-info/RECORD +103 -0
  101. yee88-0.1.0.dist-info/WHEEL +4 -0
  102. yee88-0.1.0.dist-info/entry_points.txt +11 -0
  103. yee88-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ # Headless JSONL schema derived from tag rust-v0.77.0 (git 112f40e91c12af0f7146d7e03f20283516a8af0b).
4
+
5
+ from typing import Any, Literal
6
+
7
+ import msgspec
8
+
9
+ type CommandExecutionStatus = Literal[
10
+ "in_progress",
11
+ "completed",
12
+ "failed",
13
+ "declined",
14
+ ]
15
+ type PatchApplyStatus = Literal[
16
+ "in_progress",
17
+ "completed",
18
+ "failed",
19
+ ]
20
+ type PatchChangeKind = Literal[
21
+ "add",
22
+ "delete",
23
+ "update",
24
+ ]
25
+ type McpToolCallStatus = Literal[
26
+ "in_progress",
27
+ "completed",
28
+ "failed",
29
+ ]
30
+
31
+
32
+ class Usage(msgspec.Struct, kw_only=True):
33
+ input_tokens: int
34
+ cached_input_tokens: int
35
+ output_tokens: int
36
+
37
+
38
+ class ThreadError(msgspec.Struct, kw_only=True):
39
+ message: str
40
+
41
+
42
+ class ThreadStarted(msgspec.Struct, tag="thread.started", kw_only=True):
43
+ thread_id: str
44
+
45
+
46
+ class TurnStarted(msgspec.Struct, tag="turn.started", kw_only=True):
47
+ pass
48
+
49
+
50
+ class TurnCompleted(msgspec.Struct, tag="turn.completed", kw_only=True):
51
+ usage: Usage
52
+
53
+
54
+ class TurnFailed(msgspec.Struct, tag="turn.failed", kw_only=True):
55
+ error: ThreadError
56
+
57
+
58
+ class StreamError(msgspec.Struct, tag="error", kw_only=True):
59
+ message: str
60
+
61
+
62
+ class AgentMessageItem(msgspec.Struct, tag="agent_message", kw_only=True):
63
+ id: str
64
+ text: str
65
+
66
+
67
+ class ReasoningItem(msgspec.Struct, tag="reasoning", kw_only=True):
68
+ id: str
69
+ text: str
70
+
71
+
72
+ class CommandExecutionItem(msgspec.Struct, tag="command_execution", kw_only=True):
73
+ id: str
74
+ command: str
75
+ aggregated_output: str
76
+ exit_code: int | None
77
+ status: CommandExecutionStatus
78
+
79
+
80
+ class FileUpdateChange(msgspec.Struct, kw_only=True):
81
+ path: str
82
+ kind: PatchChangeKind
83
+
84
+
85
+ class FileChangeItem(msgspec.Struct, tag="file_change", kw_only=True):
86
+ id: str
87
+ changes: list[FileUpdateChange]
88
+ status: PatchApplyStatus
89
+
90
+
91
+ class McpToolCallItemResult(msgspec.Struct, kw_only=True):
92
+ content: list[dict[str, Any]]
93
+ structured_content: Any
94
+
95
+
96
+ class McpToolCallItemError(msgspec.Struct, kw_only=True):
97
+ message: str
98
+
99
+
100
+ class McpToolCallItem(msgspec.Struct, tag="mcp_tool_call", kw_only=True):
101
+ id: str
102
+ server: str
103
+ tool: str
104
+ arguments: Any
105
+ result: McpToolCallItemResult | None
106
+ error: McpToolCallItemError | None
107
+ status: McpToolCallStatus
108
+
109
+
110
+ class WebSearchItem(msgspec.Struct, tag="web_search", kw_only=True):
111
+ id: str
112
+ query: str
113
+
114
+
115
+ class ErrorItem(msgspec.Struct, tag="error", kw_only=True):
116
+ id: str
117
+ message: str
118
+
119
+
120
+ class TodoItem(msgspec.Struct, kw_only=True):
121
+ text: str
122
+ completed: bool
123
+
124
+
125
+ class TodoListItem(msgspec.Struct, tag="todo_list", kw_only=True):
126
+ id: str
127
+ items: list[TodoItem]
128
+
129
+
130
+ type ThreadItem = (
131
+ AgentMessageItem
132
+ | ReasoningItem
133
+ | CommandExecutionItem
134
+ | FileChangeItem
135
+ | McpToolCallItem
136
+ | WebSearchItem
137
+ | TodoListItem
138
+ | ErrorItem
139
+ )
140
+
141
+
142
+ class ItemStarted(msgspec.Struct, tag="item.started", kw_only=True):
143
+ item: ThreadItem
144
+
145
+
146
+ class ItemUpdated(msgspec.Struct, tag="item.updated", kw_only=True):
147
+ item: ThreadItem
148
+
149
+
150
+ class ItemCompleted(msgspec.Struct, tag="item.completed", kw_only=True):
151
+ item: ThreadItem
152
+
153
+
154
+ type ThreadEvent = (
155
+ ThreadStarted
156
+ | TurnStarted
157
+ | TurnCompleted
158
+ | TurnFailed
159
+ | ItemStarted
160
+ | ItemUpdated
161
+ | ItemCompleted
162
+ | StreamError
163
+ )
164
+
165
+ _DECODER = msgspec.json.Decoder(ThreadEvent)
166
+
167
+
168
+ def decode_event(data: bytes | str) -> ThreadEvent:
169
+ return _DECODER.decode(data)
@@ -0,0 +1,51 @@
1
+ """Msgspec models and decoder for opencode --format json output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import msgspec
8
+
9
+
10
+ class _Event(msgspec.Struct, tag_field="type", forbid_unknown_fields=False):
11
+ pass
12
+
13
+
14
+ class StepStart(_Event, tag="step_start"):
15
+ timestamp: int | None = None
16
+ sessionID: str | None = None
17
+ part: dict[str, Any] | None = None
18
+
19
+
20
+ class StepFinish(_Event, tag="step_finish"):
21
+ timestamp: int | None = None
22
+ sessionID: str | None = None
23
+ part: dict[str, Any] | None = None
24
+
25
+
26
+ class ToolUse(_Event, tag="tool_use"):
27
+ timestamp: int | None = None
28
+ sessionID: str | None = None
29
+ part: dict[str, Any] | None = None
30
+
31
+
32
+ class Text(_Event, tag="text"):
33
+ timestamp: int | None = None
34
+ sessionID: str | None = None
35
+ part: dict[str, Any] | None = None
36
+
37
+
38
+ class Error(_Event, tag="error"):
39
+ timestamp: int | None = None
40
+ sessionID: str | None = None
41
+ error: Any = None
42
+ message: Any = None
43
+
44
+
45
+ type OpenCodeEvent = StepStart | StepFinish | ToolUse | Text | Error
46
+
47
+ _DECODER = msgspec.json.Decoder(OpenCodeEvent)
48
+
49
+
50
+ def decode_event(line: str | bytes) -> OpenCodeEvent:
51
+ return _DECODER.decode(line)
takopi/schemas/pi.py ADDED
@@ -0,0 +1,117 @@
1
+ """Msgspec models and decoder for pi --mode json output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import msgspec
8
+
9
+
10
+ class _Event(msgspec.Struct, tag_field="type", forbid_unknown_fields=False):
11
+ pass
12
+
13
+
14
+ class SessionHeader(_Event, tag="session"):
15
+ id: str | None = None
16
+ version: int | None = None
17
+ timestamp: str | None = None
18
+ cwd: str | None = None
19
+ parentSession: str | None = None
20
+
21
+
22
+ class AgentStart(_Event, tag="agent_start"):
23
+ pass
24
+
25
+
26
+ class AgentEnd(_Event, tag="agent_end"):
27
+ messages: list[dict[str, Any]]
28
+
29
+
30
+ class MessageEnd(_Event, tag="message_end"):
31
+ message: dict[str, Any]
32
+
33
+
34
+ class MessageStart(_Event, tag="message_start"):
35
+ message: dict[str, Any] | None = None
36
+
37
+
38
+ class MessageUpdate(_Event, tag="message_update"):
39
+ message: dict[str, Any] | None = None
40
+ assistantMessageEvent: dict[str, Any] | None = None
41
+
42
+
43
+ class TurnStart(_Event, tag="turn_start"):
44
+ pass
45
+
46
+
47
+ class TurnEnd(_Event, tag="turn_end"):
48
+ message: dict[str, Any] | None = None
49
+ toolResults: list[dict[str, Any]] | None = None
50
+
51
+
52
+ class ToolExecutionStart(_Event, tag="tool_execution_start"):
53
+ toolCallId: str
54
+ toolName: str | None = None
55
+ args: dict[str, Any] = msgspec.field(default_factory=dict)
56
+
57
+
58
+ class ToolExecutionUpdate(_Event, tag="tool_execution_update"):
59
+ toolCallId: str | None = None
60
+ toolName: str | None = None
61
+ args: dict[str, Any] = msgspec.field(default_factory=dict)
62
+ partialResult: Any = None
63
+
64
+
65
+ class ToolExecutionEnd(_Event, tag="tool_execution_end"):
66
+ toolCallId: str
67
+ toolName: str | None = None
68
+ result: Any = None
69
+ isError: bool = False
70
+
71
+
72
+ class AutoCompactionStart(_Event, tag="auto_compaction_start"):
73
+ reason: str | None = None
74
+
75
+
76
+ class AutoCompactionEnd(_Event, tag="auto_compaction_end"):
77
+ result: dict[str, Any] | None = None
78
+ aborted: bool | None = None
79
+ willRetry: bool | None = None
80
+
81
+
82
+ class AutoRetryStart(_Event, tag="auto_retry_start"):
83
+ attempt: int | None = None
84
+ maxAttempts: int | None = None
85
+ delayMs: int | None = None
86
+ errorMessage: str | None = None
87
+
88
+
89
+ class AutoRetryEnd(_Event, tag="auto_retry_end"):
90
+ success: bool | None = None
91
+ attempt: int | None = None
92
+ finalError: str | None = None
93
+
94
+
95
+ type PiEvent = (
96
+ SessionHeader
97
+ | AgentStart
98
+ | AgentEnd
99
+ | MessageStart
100
+ | MessageUpdate
101
+ | MessageEnd
102
+ | TurnStart
103
+ | TurnEnd
104
+ | ToolExecutionStart
105
+ | ToolExecutionUpdate
106
+ | ToolExecutionEnd
107
+ | AutoCompactionStart
108
+ | AutoCompactionEnd
109
+ | AutoRetryStart
110
+ | AutoRetryEnd
111
+ )
112
+
113
+ _DECODER = msgspec.json.Decoder(PiEvent)
114
+
115
+
116
+ def decode_event(line: str | bytes) -> PiEvent:
117
+ return _DECODER.decode(line)
takopi/settings.py ADDED
@@ -0,0 +1,360 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated, Any, ClassVar, Literal
5
+ from collections.abc import Iterable
6
+
7
+ from pydantic import (
8
+ BaseModel,
9
+ ConfigDict,
10
+ Field,
11
+ ValidationError,
12
+ StringConstraints,
13
+ field_validator,
14
+ model_validator,
15
+ )
16
+ from pydantic.types import StrictInt
17
+ from pydantic_settings import BaseSettings, SettingsConfigDict
18
+ from pydantic_settings.sources import TomlConfigSettingsSource
19
+
20
+ from .config import (
21
+ ConfigError,
22
+ HOME_CONFIG_PATH,
23
+ ProjectConfig,
24
+ ProjectsConfig,
25
+ )
26
+ from .config_migrations import migrate_config_file
27
+
28
+
29
+ NonEmptyStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
30
+
31
+
32
+ def _normalize_engine_id(
33
+ value: str,
34
+ *,
35
+ engine_ids: Iterable[str],
36
+ config_path: Path,
37
+ label: str,
38
+ ) -> str:
39
+ engine_map = {engine.lower(): engine for engine in engine_ids}
40
+ engine = engine_map.get(value.lower())
41
+ if engine is None:
42
+ available = ", ".join(sorted(engine_map.values()))
43
+ raise ConfigError(
44
+ f"Unknown `{label}` {value!r} in {config_path}. Available: {available}."
45
+ )
46
+ return engine
47
+
48
+
49
+ def _normalize_project_path(value: str, *, config_path: Path) -> Path:
50
+ path = Path(value).expanduser()
51
+ if not path.is_absolute():
52
+ path = config_path.parent / path
53
+ return path
54
+
55
+
56
+ class TelegramTopicsSettings(BaseModel):
57
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
58
+
59
+ enabled: bool = False
60
+ scope: Literal["auto", "main", "projects", "all"] = "auto"
61
+
62
+
63
+ class TelegramFilesSettings(BaseModel):
64
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
65
+
66
+ max_upload_bytes: ClassVar[int] = 20 * 1024 * 1024
67
+ max_download_bytes: ClassVar[int] = 50 * 1024 * 1024
68
+
69
+ enabled: bool = False
70
+ auto_put: bool = True
71
+ auto_put_mode: Literal["upload", "prompt"] = "upload"
72
+ uploads_dir: NonEmptyStr = "incoming"
73
+ allowed_user_ids: list[StrictInt] = Field(default_factory=list)
74
+ deny_globs: list[NonEmptyStr] = Field(
75
+ default_factory=lambda: [
76
+ ".git/**",
77
+ ".env",
78
+ ".envrc",
79
+ "**/*.pem",
80
+ "**/.ssh/**",
81
+ ]
82
+ )
83
+
84
+ @field_validator("uploads_dir")
85
+ @classmethod
86
+ def _validate_uploads_dir(cls, value: str) -> str:
87
+ if Path(value).is_absolute():
88
+ raise ValueError("files.uploads_dir must be a relative path")
89
+ return value
90
+
91
+
92
+ class TelegramTransportSettings(BaseModel):
93
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
94
+
95
+ bot_token: NonEmptyStr
96
+ chat_id: StrictInt
97
+ allowed_user_ids: list[StrictInt] = Field(default_factory=list)
98
+ message_overflow: Literal["trim", "split"] = "trim"
99
+ voice_transcription: bool = False
100
+ voice_max_bytes: StrictInt = 10 * 1024 * 1024
101
+ voice_transcription_model: NonEmptyStr = "gpt-4o-mini-transcribe"
102
+ voice_transcription_base_url: NonEmptyStr | None = None
103
+ voice_transcription_api_key: NonEmptyStr | None = None
104
+ session_mode: Literal["stateless", "chat"] = "stateless"
105
+ show_resume_line: bool = True
106
+ forward_coalesce_s: float = Field(default=1.0, ge=0)
107
+ media_group_debounce_s: float = Field(default=1.0, ge=0)
108
+ topics: TelegramTopicsSettings = Field(default_factory=TelegramTopicsSettings)
109
+ files: TelegramFilesSettings = Field(default_factory=TelegramFilesSettings)
110
+
111
+
112
+ class TransportsSettings(BaseModel):
113
+ telegram: TelegramTransportSettings
114
+
115
+ model_config = ConfigDict(extra="allow")
116
+
117
+
118
+ class PluginsSettings(BaseModel):
119
+ enabled: list[NonEmptyStr] = Field(default_factory=list)
120
+
121
+ model_config = ConfigDict(extra="allow", str_strip_whitespace=True)
122
+
123
+
124
+ class ProjectSettings(BaseModel):
125
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
126
+
127
+ path: NonEmptyStr
128
+ worktrees_dir: NonEmptyStr = ".worktrees"
129
+ default_engine: NonEmptyStr | None = None
130
+ worktree_base: NonEmptyStr | None = None
131
+ chat_id: StrictInt | None = None
132
+ system_prompt: str | None = None
133
+
134
+
135
+ class TakopiSettings(BaseSettings):
136
+ model_config = SettingsConfigDict(
137
+ extra="allow",
138
+ env_prefix="TAKOPI__",
139
+ env_nested_delimiter="__",
140
+ str_strip_whitespace=True,
141
+ )
142
+
143
+ watch_config: bool = False
144
+ default_engine: NonEmptyStr = "codex"
145
+ default_project: NonEmptyStr | None = None
146
+ system_prompt: str | None = (
147
+ "你是我的专业秘书,请用中文回复。"
148
+ "每次会话结束以后,都请叫我老板,用简短、调皮的职场回复,告诉我你的任务结果!"
149
+ )
150
+ projects: dict[str, ProjectSettings] = Field(default_factory=dict)
151
+
152
+ transport: NonEmptyStr = "telegram"
153
+ transports: TransportsSettings
154
+
155
+ plugins: PluginsSettings = Field(default_factory=PluginsSettings)
156
+
157
+ @model_validator(mode="before")
158
+ @classmethod
159
+ def _reject_legacy_telegram_keys(cls, data: Any) -> Any:
160
+ if isinstance(data, dict) and ("bot_token" in data or "chat_id" in data):
161
+ raise ValueError(
162
+ "Move bot_token/chat_id under [transports.telegram] "
163
+ 'and set transport = "telegram".'
164
+ )
165
+ return data
166
+
167
+ @classmethod
168
+ def settings_customise_sources(
169
+ cls,
170
+ settings_cls,
171
+ init_settings,
172
+ env_settings,
173
+ dotenv_settings,
174
+ file_secret_settings,
175
+ ):
176
+ return (
177
+ init_settings,
178
+ env_settings,
179
+ dotenv_settings,
180
+ TomlConfigSettingsSource(settings_cls),
181
+ file_secret_settings,
182
+ )
183
+
184
+ def engine_config(self, engine_id: str, *, config_path: Path) -> dict[str, Any]:
185
+ extra = self.model_extra or {}
186
+ raw = extra.get(engine_id)
187
+ if raw is None:
188
+ return {}
189
+ if not isinstance(raw, dict):
190
+ raise ConfigError(
191
+ f"Invalid `{engine_id}` config in {config_path}; expected a table."
192
+ )
193
+ return raw
194
+
195
+ def transport_config(
196
+ self, transport_id: str, *, config_path: Path
197
+ ) -> dict[str, Any]:
198
+ if transport_id == "telegram":
199
+ return self.transports.telegram.model_dump()
200
+ extra = self.transports.model_extra or {}
201
+ raw = extra.get(transport_id)
202
+ if raw is None:
203
+ return {}
204
+ if not isinstance(raw, dict):
205
+ raise ConfigError(
206
+ f"Invalid `transports.{transport_id}` in {config_path}; "
207
+ "expected a table."
208
+ )
209
+ return raw
210
+
211
+ def to_projects_config(
212
+ self,
213
+ *,
214
+ config_path: Path,
215
+ engine_ids: Iterable[str],
216
+ reserved: Iterable[str] = ("cancel",),
217
+ ) -> ProjectsConfig:
218
+ default_project = self.default_project
219
+ default_chat_id = self.transports.telegram.chat_id
220
+
221
+ reserved_lower = {value.lower() for value in reserved}
222
+ engine_map = {engine.lower(): engine for engine in engine_ids}
223
+ projects: dict[str, ProjectConfig] = {}
224
+ chat_map: dict[int, str] = {}
225
+
226
+ for raw_alias, entry in self.projects.items():
227
+ alias = raw_alias
228
+ alias_key = alias.lower()
229
+ if alias_key in engine_map or alias_key in reserved_lower:
230
+ raise ConfigError(
231
+ f"Invalid project alias {alias!r} in {config_path}; "
232
+ "aliases must not match engine ids or reserved commands."
233
+ )
234
+ if alias_key in projects:
235
+ raise ConfigError(
236
+ f"Duplicate project alias {alias!r} in {config_path}."
237
+ )
238
+
239
+ path = _normalize_project_path(entry.path, config_path=config_path)
240
+
241
+ worktrees_dir = Path(entry.worktrees_dir).expanduser()
242
+
243
+ default_engine = None
244
+ if entry.default_engine is not None:
245
+ default_engine = _normalize_engine_id(
246
+ entry.default_engine,
247
+ engine_ids=engine_ids,
248
+ config_path=config_path,
249
+ label=f"projects.{alias}.default_engine",
250
+ )
251
+
252
+ worktree_base = entry.worktree_base
253
+
254
+ chat_id = entry.chat_id
255
+ if chat_id is not None:
256
+ if chat_id == default_chat_id:
257
+ raise ConfigError(
258
+ f"Invalid `projects.{alias}.chat_id` in {config_path}; "
259
+ "must not match transports.telegram.chat_id."
260
+ )
261
+ if chat_id in chat_map:
262
+ existing = chat_map[chat_id]
263
+ raise ConfigError(
264
+ f"Duplicate `projects.*.chat_id` {chat_id} in {config_path}; "
265
+ f"already used by {existing!r}."
266
+ )
267
+ chat_map[chat_id] = alias_key
268
+
269
+ projects[alias_key] = ProjectConfig(
270
+ alias=alias,
271
+ path=path,
272
+ worktrees_dir=worktrees_dir,
273
+ default_engine=default_engine,
274
+ worktree_base=worktree_base,
275
+ chat_id=chat_id,
276
+ system_prompt=entry.system_prompt,
277
+ )
278
+
279
+ if default_project is not None:
280
+ default_key = default_project.lower()
281
+ if default_key not in projects:
282
+ raise ConfigError(
283
+ f"Invalid `default_project` {default_project!r} in {config_path}; "
284
+ "no matching project alias found."
285
+ )
286
+ default_project = default_key
287
+
288
+ return ProjectsConfig(
289
+ projects=projects,
290
+ default_project=default_project,
291
+ global_system_prompt=self.system_prompt,
292
+ chat_map=chat_map,
293
+ )
294
+
295
+
296
+ def load_settings(path: str | Path | None = None) -> tuple[TakopiSettings, Path]:
297
+ cfg_path = _resolve_config_path(path)
298
+ _ensure_config_file(cfg_path)
299
+ migrate_config_file(cfg_path)
300
+ return _load_settings_from_path(cfg_path), cfg_path
301
+
302
+
303
+ def load_settings_if_exists(
304
+ path: str | Path | None = None,
305
+ ) -> tuple[TakopiSettings, Path] | None:
306
+ cfg_path = _resolve_config_path(path)
307
+ if cfg_path.exists():
308
+ if not cfg_path.is_file():
309
+ raise ConfigError(
310
+ f"Config path {cfg_path} exists but is not a file."
311
+ ) from None
312
+ migrate_config_file(cfg_path)
313
+ return _load_settings_from_path(cfg_path), cfg_path
314
+ return None
315
+
316
+
317
+ def validate_settings_data(
318
+ data: dict[str, Any], *, config_path: Path
319
+ ) -> TakopiSettings:
320
+ try:
321
+ return TakopiSettings.model_validate(data)
322
+ except ValidationError as exc:
323
+ raise ConfigError(f"Invalid config in {config_path}: {exc}") from exc
324
+
325
+
326
+ def require_telegram(settings: TakopiSettings, config_path: Path) -> tuple[str, int]:
327
+ if settings.transport != "telegram":
328
+ raise ConfigError(
329
+ f"Unsupported transport {settings.transport!r} in {config_path} "
330
+ "(telegram only for now)."
331
+ )
332
+ tg = settings.transports.telegram
333
+ return tg.bot_token, tg.chat_id
334
+
335
+
336
+ def _resolve_config_path(path: str | Path | None) -> Path:
337
+ return Path(path).expanduser() if path else HOME_CONFIG_PATH
338
+
339
+
340
+ def _ensure_config_file(cfg_path: Path) -> None:
341
+ if cfg_path.exists() and not cfg_path.is_file():
342
+ raise ConfigError(f"Config path {cfg_path} exists but is not a file.") from None
343
+ if not cfg_path.exists():
344
+ raise ConfigError(f"Missing config file {cfg_path}.") from None
345
+
346
+
347
+ def _load_settings_from_path(cfg_path: Path) -> TakopiSettings:
348
+ cfg = dict(TakopiSettings.model_config)
349
+ cfg["toml_file"] = cfg_path
350
+ Bound = type(
351
+ "TakopiSettingsBound",
352
+ (TakopiSettings,),
353
+ {"model_config": SettingsConfigDict(**cfg)},
354
+ )
355
+ try:
356
+ return Bound()
357
+ except ValidationError as exc:
358
+ raise ConfigError(f"Invalid config in {cfg_path}: {exc}") from exc
359
+ except Exception as exc: # pragma: no cover - safety net
360
+ raise ConfigError(f"Failed to load config {cfg_path}: {exc}") from exc
@@ -0,0 +1,20 @@
1
+ """Telegram-specific clients and adapters."""
2
+
3
+ from .client import parse_incoming_update, poll_incoming
4
+ from .types import (
5
+ TelegramCallbackQuery,
6
+ TelegramDocument,
7
+ TelegramIncomingMessage,
8
+ TelegramIncomingUpdate,
9
+ TelegramVoice,
10
+ )
11
+
12
+ __all__ = [
13
+ "TelegramCallbackQuery",
14
+ "TelegramDocument",
15
+ "TelegramIncomingMessage",
16
+ "TelegramIncomingUpdate",
17
+ "TelegramVoice",
18
+ "parse_incoming_update",
19
+ "poll_incoming",
20
+ ]