codex-autorunner 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 (147) hide show
  1. codex_autorunner/__init__.py +3 -0
  2. codex_autorunner/bootstrap.py +151 -0
  3. codex_autorunner/cli.py +886 -0
  4. codex_autorunner/codex_cli.py +79 -0
  5. codex_autorunner/codex_runner.py +17 -0
  6. codex_autorunner/core/__init__.py +1 -0
  7. codex_autorunner/core/about_car.py +125 -0
  8. codex_autorunner/core/codex_runner.py +100 -0
  9. codex_autorunner/core/config.py +1465 -0
  10. codex_autorunner/core/doc_chat.py +547 -0
  11. codex_autorunner/core/docs.py +37 -0
  12. codex_autorunner/core/engine.py +720 -0
  13. codex_autorunner/core/git_utils.py +206 -0
  14. codex_autorunner/core/hub.py +756 -0
  15. codex_autorunner/core/injected_context.py +9 -0
  16. codex_autorunner/core/locks.py +57 -0
  17. codex_autorunner/core/logging_utils.py +158 -0
  18. codex_autorunner/core/notifications.py +465 -0
  19. codex_autorunner/core/optional_dependencies.py +41 -0
  20. codex_autorunner/core/prompt.py +107 -0
  21. codex_autorunner/core/prompts.py +275 -0
  22. codex_autorunner/core/request_context.py +21 -0
  23. codex_autorunner/core/runner_controller.py +116 -0
  24. codex_autorunner/core/runner_process.py +29 -0
  25. codex_autorunner/core/snapshot.py +576 -0
  26. codex_autorunner/core/state.py +156 -0
  27. codex_autorunner/core/update.py +567 -0
  28. codex_autorunner/core/update_runner.py +44 -0
  29. codex_autorunner/core/usage.py +1221 -0
  30. codex_autorunner/core/utils.py +108 -0
  31. codex_autorunner/discovery.py +102 -0
  32. codex_autorunner/housekeeping.py +423 -0
  33. codex_autorunner/integrations/__init__.py +1 -0
  34. codex_autorunner/integrations/app_server/__init__.py +6 -0
  35. codex_autorunner/integrations/app_server/client.py +1386 -0
  36. codex_autorunner/integrations/app_server/supervisor.py +206 -0
  37. codex_autorunner/integrations/github/__init__.py +10 -0
  38. codex_autorunner/integrations/github/service.py +889 -0
  39. codex_autorunner/integrations/telegram/__init__.py +1 -0
  40. codex_autorunner/integrations/telegram/adapter.py +1401 -0
  41. codex_autorunner/integrations/telegram/commands_registry.py +104 -0
  42. codex_autorunner/integrations/telegram/config.py +450 -0
  43. codex_autorunner/integrations/telegram/constants.py +154 -0
  44. codex_autorunner/integrations/telegram/dispatch.py +162 -0
  45. codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
  46. codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
  47. codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
  48. codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
  49. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
  50. codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
  51. codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
  52. codex_autorunner/integrations/telegram/helpers.py +2084 -0
  53. codex_autorunner/integrations/telegram/notifications.py +164 -0
  54. codex_autorunner/integrations/telegram/outbox.py +174 -0
  55. codex_autorunner/integrations/telegram/rendering.py +102 -0
  56. codex_autorunner/integrations/telegram/retry.py +37 -0
  57. codex_autorunner/integrations/telegram/runtime.py +270 -0
  58. codex_autorunner/integrations/telegram/service.py +921 -0
  59. codex_autorunner/integrations/telegram/state.py +1223 -0
  60. codex_autorunner/integrations/telegram/transport.py +318 -0
  61. codex_autorunner/integrations/telegram/types.py +57 -0
  62. codex_autorunner/integrations/telegram/voice.py +413 -0
  63. codex_autorunner/manifest.py +150 -0
  64. codex_autorunner/routes/__init__.py +53 -0
  65. codex_autorunner/routes/base.py +470 -0
  66. codex_autorunner/routes/docs.py +275 -0
  67. codex_autorunner/routes/github.py +197 -0
  68. codex_autorunner/routes/repos.py +121 -0
  69. codex_autorunner/routes/sessions.py +137 -0
  70. codex_autorunner/routes/shared.py +137 -0
  71. codex_autorunner/routes/system.py +175 -0
  72. codex_autorunner/routes/terminal_images.py +107 -0
  73. codex_autorunner/routes/voice.py +128 -0
  74. codex_autorunner/server.py +23 -0
  75. codex_autorunner/spec_ingest.py +113 -0
  76. codex_autorunner/static/app.js +95 -0
  77. codex_autorunner/static/autoRefresh.js +209 -0
  78. codex_autorunner/static/bootstrap.js +105 -0
  79. codex_autorunner/static/bus.js +23 -0
  80. codex_autorunner/static/cache.js +52 -0
  81. codex_autorunner/static/constants.js +48 -0
  82. codex_autorunner/static/dashboard.js +795 -0
  83. codex_autorunner/static/docs.js +1514 -0
  84. codex_autorunner/static/env.js +99 -0
  85. codex_autorunner/static/github.js +168 -0
  86. codex_autorunner/static/hub.js +1511 -0
  87. codex_autorunner/static/index.html +622 -0
  88. codex_autorunner/static/loader.js +28 -0
  89. codex_autorunner/static/logs.js +690 -0
  90. codex_autorunner/static/mobileCompact.js +300 -0
  91. codex_autorunner/static/snapshot.js +116 -0
  92. codex_autorunner/static/state.js +87 -0
  93. codex_autorunner/static/styles.css +4966 -0
  94. codex_autorunner/static/tabs.js +50 -0
  95. codex_autorunner/static/terminal.js +21 -0
  96. codex_autorunner/static/terminalManager.js +3535 -0
  97. codex_autorunner/static/todoPreview.js +25 -0
  98. codex_autorunner/static/types.d.ts +8 -0
  99. codex_autorunner/static/utils.js +597 -0
  100. codex_autorunner/static/vendor/LICENSE.xterm +24 -0
  101. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
  102. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
  103. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
  104. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
  105. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
  106. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
  107. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
  108. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
  109. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
  110. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
  111. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
  112. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
  113. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
  114. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
  115. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
  116. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
  117. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
  118. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
  119. codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
  120. codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
  121. codex_autorunner/static/vendor/xterm.css +209 -0
  122. codex_autorunner/static/vendor/xterm.js +2 -0
  123. codex_autorunner/static/voice.js +591 -0
  124. codex_autorunner/voice/__init__.py +39 -0
  125. codex_autorunner/voice/capture.py +349 -0
  126. codex_autorunner/voice/config.py +167 -0
  127. codex_autorunner/voice/provider.py +66 -0
  128. codex_autorunner/voice/providers/__init__.py +7 -0
  129. codex_autorunner/voice/providers/openai_whisper.py +345 -0
  130. codex_autorunner/voice/resolver.py +36 -0
  131. codex_autorunner/voice/service.py +210 -0
  132. codex_autorunner/web/__init__.py +1 -0
  133. codex_autorunner/web/app.py +1037 -0
  134. codex_autorunner/web/hub_jobs.py +181 -0
  135. codex_autorunner/web/middleware.py +552 -0
  136. codex_autorunner/web/pty_session.py +357 -0
  137. codex_autorunner/web/runner_manager.py +25 -0
  138. codex_autorunner/web/schemas.py +253 -0
  139. codex_autorunner/web/static_assets.py +430 -0
  140. codex_autorunner/web/terminal_sessions.py +78 -0
  141. codex_autorunner/workspace.py +16 -0
  142. codex_autorunner-0.1.0.dist-info/METADATA +240 -0
  143. codex_autorunner-0.1.0.dist-info/RECORD +147 -0
  144. codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
  145. codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
  146. codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
  147. codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any, Iterable, Mapping, Sequence
6
+
7
+ from .handlers.commands import CommandSpec
8
+
9
+ _COMMAND_NAME_RE = re.compile(r"^[a-z0-9_]{1,32}$")
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class TelegramCommandDiff:
14
+ added: list[str]
15
+ removed: list[str]
16
+ changed: list[str]
17
+ order_changed: bool
18
+
19
+ @property
20
+ def needs_update(self) -> bool:
21
+ return bool(self.added or self.removed or self.changed or self.order_changed)
22
+
23
+
24
+ def build_command_payloads(
25
+ command_specs: Mapping[str, CommandSpec],
26
+ ) -> tuple[list[dict[str, str]], list[str]]:
27
+ commands: list[dict[str, str]] = []
28
+ invalid: list[str] = []
29
+ for spec in command_specs.values():
30
+ name = _normalize_name(spec.name)
31
+ if not name or not _COMMAND_NAME_RE.fullmatch(name):
32
+ invalid.append(spec.name)
33
+ continue
34
+ description = _normalize_description(spec.description)
35
+ if not description:
36
+ description = name
37
+ commands.append({"command": name, "description": description})
38
+ return commands, invalid
39
+
40
+
41
+ def diff_command_lists(
42
+ desired: Iterable[Mapping[str, Any]],
43
+ current: Sequence[Mapping[str, Any]],
44
+ ) -> TelegramCommandDiff:
45
+ desired_norm = _normalize_payloads(desired)
46
+ current_norm = _normalize_payloads(current)
47
+
48
+ desired_map = _payload_map(desired_norm)
49
+ current_map = _payload_map(current_norm)
50
+
51
+ desired_order = [name for name, _desc in desired_norm]
52
+ current_order = [name for name, _desc in current_norm]
53
+
54
+ added = [name for name in desired_order if name not in current_map]
55
+ removed = [name for name in current_order if name not in desired_map]
56
+ changed = [
57
+ name
58
+ for name in desired_order
59
+ if name in current_map and desired_map.get(name) != current_map.get(name)
60
+ ]
61
+
62
+ order_changed = False
63
+ if not (added or removed or changed):
64
+ filtered_current_order = [name for name in current_order if name in desired_map]
65
+ order_changed = desired_order != filtered_current_order
66
+
67
+ return TelegramCommandDiff(
68
+ added=added,
69
+ removed=removed,
70
+ changed=changed,
71
+ order_changed=order_changed,
72
+ )
73
+
74
+
75
+ def _normalize_name(name: str) -> str:
76
+ return name.strip().lower()
77
+
78
+
79
+ def _normalize_description(description: str) -> str:
80
+ return description.strip()
81
+
82
+
83
+ def _normalize_payloads(
84
+ commands: Iterable[Mapping[str, Any]],
85
+ ) -> list[tuple[str, str]]:
86
+ normalized: list[tuple[str, str]] = []
87
+ for item in commands:
88
+ command = item.get("command")
89
+ description = item.get("description")
90
+ if not isinstance(command, str) or not isinstance(description, str):
91
+ continue
92
+ name = _normalize_name(command)
93
+ if not name:
94
+ continue
95
+ normalized.append((name, _normalize_description(description)))
96
+ return normalized
97
+
98
+
99
+ def _payload_map(commands: Sequence[tuple[str, str]]) -> dict[str, str]:
100
+ mapping: dict[str, str] = {}
101
+ for name, description in commands:
102
+ if name not in mapping:
103
+ mapping[name] = description
104
+ return mapping
@@ -0,0 +1,450 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import shlex
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Iterable, Optional
9
+
10
+ from .adapter import TelegramAllowlist
11
+ from .state import APPROVAL_MODE_YOLO, normalize_approval_mode
12
+
13
+ DEFAULT_ALLOWED_UPDATES = ("message", "edited_message", "callback_query")
14
+ DEFAULT_POLL_TIMEOUT_SECONDS = 30
15
+ DEFAULT_SAFE_APPROVAL_POLICY = "on-request"
16
+ DEFAULT_YOLO_APPROVAL_POLICY = "never"
17
+ DEFAULT_YOLO_SANDBOX_POLICY = "dangerFullAccess"
18
+ DEFAULT_PARSE_MODE = "HTML"
19
+ DEFAULT_STATE_FILE = ".codex-autorunner/telegram_state.json"
20
+ DEFAULT_APP_SERVER_COMMAND = ["codex", "app-server"]
21
+ DEFAULT_APP_SERVER_MAX_HANDLES = 20
22
+ DEFAULT_APP_SERVER_IDLE_TTL_SECONDS = 3600
23
+ DEFAULT_APPROVAL_TIMEOUT_SECONDS = 300.0
24
+ DEFAULT_MEDIA_MAX_IMAGE_BYTES = 10 * 1024 * 1024
25
+ DEFAULT_MEDIA_MAX_VOICE_BYTES = 10 * 1024 * 1024
26
+ DEFAULT_MEDIA_MAX_FILE_BYTES = 10 * 1024 * 1024
27
+ DEFAULT_MEDIA_IMAGE_PROMPT = "Describe the image."
28
+ DEFAULT_SHELL_TIMEOUT_MS = 120_000
29
+ DEFAULT_SHELL_MAX_OUTPUT_CHARS = 3800
30
+
31
+ PARSE_MODE_ALIASES = {
32
+ "html": "HTML",
33
+ "markdown": "Markdown",
34
+ "markdownv2": "MarkdownV2",
35
+ }
36
+
37
+
38
+ class TelegramBotConfigError(Exception):
39
+ """Raised when telegram bot config is invalid."""
40
+
41
+
42
+ class TelegramBotLockError(Exception):
43
+ """Raised when another telegram bot instance already holds the lock."""
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class TelegramBotDefaults:
48
+ approval_mode: str
49
+ approval_policy: Optional[str]
50
+ sandbox_policy: Optional[str]
51
+ yolo_approval_policy: str
52
+ yolo_sandbox_policy: str
53
+
54
+ def policies_for_mode(self, mode: str) -> tuple[Optional[str], Optional[str]]:
55
+ normalized = normalize_approval_mode(mode, default=APPROVAL_MODE_YOLO)
56
+ if normalized == APPROVAL_MODE_YOLO:
57
+ return self.yolo_approval_policy, self.yolo_sandbox_policy
58
+ return self.approval_policy, self.sandbox_policy
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class TelegramBotConcurrency:
63
+ max_parallel_turns: int
64
+ per_topic_queue: bool
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class TelegramBotMediaConfig:
69
+ enabled: bool
70
+ images: bool
71
+ voice: bool
72
+ files: bool
73
+ max_image_bytes: int
74
+ max_voice_bytes: int
75
+ max_file_bytes: int
76
+ image_prompt: str
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class TelegramBotShellConfig:
81
+ enabled: bool
82
+ timeout_ms: int
83
+ max_output_chars: int
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class TelegramBotCommandScope:
88
+ scope: dict[str, Any]
89
+ language_code: str
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class TelegramBotCommandRegistration:
94
+ enabled: bool
95
+ scopes: list[TelegramBotCommandScope]
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class TelegramMediaCandidate:
100
+ kind: str
101
+ file_id: str
102
+ file_name: Optional[str]
103
+ mime_type: Optional[str]
104
+ file_size: Optional[int]
105
+ duration: Optional[int] = None
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class TelegramBotConfig:
110
+ root: Path
111
+ enabled: bool
112
+ mode: str
113
+ bot_token_env: str
114
+ chat_id_env: str
115
+ parse_mode: Optional[str]
116
+ debug_prefix_context: bool
117
+ bot_token: Optional[str]
118
+ allowed_chat_ids: set[int]
119
+ allowed_user_ids: set[int]
120
+ require_topics: bool
121
+ defaults: TelegramBotDefaults
122
+ concurrency: TelegramBotConcurrency
123
+ media: TelegramBotMediaConfig
124
+ shell: TelegramBotShellConfig
125
+ command_registration: TelegramBotCommandRegistration
126
+ state_file: Path
127
+ app_server_command_env: str
128
+ app_server_command: list[str]
129
+ app_server_max_handles: Optional[int]
130
+ app_server_idle_ttl_seconds: Optional[int]
131
+ poll_timeout_seconds: int
132
+ poll_allowed_updates: list[str]
133
+
134
+ @classmethod
135
+ def from_raw(
136
+ cls,
137
+ raw: Optional[dict[str, Any]],
138
+ *,
139
+ root: Path,
140
+ env: Optional[dict[str, str]] = None,
141
+ ) -> "TelegramBotConfig":
142
+ env = env or dict(os.environ)
143
+ cfg: dict[str, Any] = raw if isinstance(raw, dict) else {}
144
+ enabled = bool(cfg.get("enabled", False))
145
+ mode = str(cfg.get("mode", "polling"))
146
+ bot_token_env = str(cfg.get("bot_token_env", "CAR_TELEGRAM_BOT_TOKEN"))
147
+ chat_id_env = str(cfg.get("chat_id_env", "CAR_TELEGRAM_CHAT_ID"))
148
+ parse_mode_raw = (
149
+ cfg.get("parse_mode") if "parse_mode" in cfg else DEFAULT_PARSE_MODE
150
+ )
151
+ parse_mode = _normalize_parse_mode(parse_mode_raw)
152
+ debug_raw_value = cfg.get("debug")
153
+ debug_raw: dict[str, Any] = (
154
+ debug_raw_value if isinstance(debug_raw_value, dict) else {}
155
+ )
156
+ debug_prefix_context = bool(debug_raw.get("prefix_context", False))
157
+ bot_token = env.get(bot_token_env)
158
+
159
+ allowed_chat_ids = set(_parse_int_list(cfg.get("allowed_chat_ids")))
160
+ allowed_chat_ids.update(_parse_int_list(env.get(chat_id_env)))
161
+ allowed_user_ids = set(_parse_int_list(cfg.get("allowed_user_ids")))
162
+
163
+ require_topics = bool(cfg.get("require_topics", False))
164
+
165
+ defaults_raw_value = cfg.get("defaults")
166
+ defaults_raw: dict[str, Any] = (
167
+ defaults_raw_value if isinstance(defaults_raw_value, dict) else {}
168
+ )
169
+ approval_mode = normalize_approval_mode(
170
+ defaults_raw.get("approval_mode"), default=APPROVAL_MODE_YOLO
171
+ )
172
+ approval_policy = defaults_raw.get(
173
+ "approval_policy", DEFAULT_SAFE_APPROVAL_POLICY
174
+ )
175
+ sandbox_policy = defaults_raw.get("sandbox_policy")
176
+ if sandbox_policy is not None:
177
+ sandbox_policy = str(sandbox_policy)
178
+ yolo_approval_policy = str(
179
+ defaults_raw.get("yolo_approval_policy", DEFAULT_YOLO_APPROVAL_POLICY)
180
+ )
181
+ yolo_sandbox_policy = str(
182
+ defaults_raw.get("yolo_sandbox_policy", DEFAULT_YOLO_SANDBOX_POLICY)
183
+ )
184
+ defaults = TelegramBotDefaults(
185
+ approval_mode=approval_mode,
186
+ approval_policy=(
187
+ str(approval_policy) if approval_policy is not None else None
188
+ ),
189
+ sandbox_policy=sandbox_policy,
190
+ yolo_approval_policy=yolo_approval_policy,
191
+ yolo_sandbox_policy=yolo_sandbox_policy,
192
+ )
193
+
194
+ concurrency_raw_value = cfg.get("concurrency")
195
+ concurrency_raw: dict[str, Any] = (
196
+ concurrency_raw_value if isinstance(concurrency_raw_value, dict) else {}
197
+ )
198
+ max_parallel_turns = int(concurrency_raw.get("max_parallel_turns", 4))
199
+ if max_parallel_turns <= 0:
200
+ max_parallel_turns = 1
201
+ per_topic_queue = bool(concurrency_raw.get("per_topic_queue", True))
202
+ concurrency = TelegramBotConcurrency(
203
+ max_parallel_turns=max_parallel_turns,
204
+ per_topic_queue=per_topic_queue,
205
+ )
206
+
207
+ media_raw_value = cfg.get("media")
208
+ media_raw: dict[str, Any] = (
209
+ media_raw_value if isinstance(media_raw_value, dict) else {}
210
+ )
211
+ media_enabled = bool(media_raw.get("enabled", True))
212
+ media_images = bool(media_raw.get("images", True))
213
+ media_voice = bool(media_raw.get("voice", True))
214
+ media_files = bool(media_raw.get("files", True))
215
+ max_image_bytes = int(
216
+ media_raw.get("max_image_bytes", DEFAULT_MEDIA_MAX_IMAGE_BYTES)
217
+ )
218
+ if max_image_bytes <= 0:
219
+ max_image_bytes = DEFAULT_MEDIA_MAX_IMAGE_BYTES
220
+ max_voice_bytes = int(
221
+ media_raw.get("max_voice_bytes", DEFAULT_MEDIA_MAX_VOICE_BYTES)
222
+ )
223
+ if max_voice_bytes <= 0:
224
+ max_voice_bytes = DEFAULT_MEDIA_MAX_VOICE_BYTES
225
+ max_file_bytes = int(
226
+ media_raw.get("max_file_bytes", DEFAULT_MEDIA_MAX_FILE_BYTES)
227
+ )
228
+ if max_file_bytes <= 0:
229
+ max_file_bytes = DEFAULT_MEDIA_MAX_FILE_BYTES
230
+ image_prompt = str(
231
+ media_raw.get("image_prompt", DEFAULT_MEDIA_IMAGE_PROMPT)
232
+ ).strip()
233
+ if not image_prompt:
234
+ image_prompt = DEFAULT_MEDIA_IMAGE_PROMPT
235
+ media = TelegramBotMediaConfig(
236
+ enabled=media_enabled,
237
+ images=media_images,
238
+ voice=media_voice,
239
+ files=media_files,
240
+ max_image_bytes=max_image_bytes,
241
+ max_voice_bytes=max_voice_bytes,
242
+ max_file_bytes=max_file_bytes,
243
+ image_prompt=image_prompt,
244
+ )
245
+
246
+ shell_raw_value = cfg.get("shell")
247
+ shell_raw: dict[str, Any] = (
248
+ shell_raw_value if isinstance(shell_raw_value, dict) else {}
249
+ )
250
+ shell_enabled = bool(shell_raw.get("enabled", False))
251
+ shell_timeout_ms = int(shell_raw.get("timeout_ms", DEFAULT_SHELL_TIMEOUT_MS))
252
+ if shell_timeout_ms <= 0:
253
+ shell_timeout_ms = DEFAULT_SHELL_TIMEOUT_MS
254
+ shell_max_output_chars = int(
255
+ shell_raw.get("max_output_chars", DEFAULT_SHELL_MAX_OUTPUT_CHARS)
256
+ )
257
+ if shell_max_output_chars <= 0:
258
+ shell_max_output_chars = DEFAULT_SHELL_MAX_OUTPUT_CHARS
259
+ shell = TelegramBotShellConfig(
260
+ enabled=shell_enabled,
261
+ timeout_ms=shell_timeout_ms,
262
+ max_output_chars=shell_max_output_chars,
263
+ )
264
+
265
+ command_reg_raw_value = cfg.get("command_registration")
266
+ command_reg_raw: dict[str, Any] = (
267
+ command_reg_raw_value if isinstance(command_reg_raw_value, dict) else {}
268
+ )
269
+ command_reg_enabled = bool(command_reg_raw.get("enabled", True))
270
+ scopes = _parse_command_scopes(command_reg_raw.get("scopes"))
271
+ command_registration = TelegramBotCommandRegistration(
272
+ enabled=command_reg_enabled, scopes=scopes
273
+ )
274
+
275
+ state_file = Path(cfg.get("state_file", DEFAULT_STATE_FILE))
276
+ if not state_file.is_absolute():
277
+ state_file = (root / state_file).resolve()
278
+
279
+ app_server_command_env = str(
280
+ cfg.get("app_server_command_env", "CAR_TELEGRAM_APP_SERVER_COMMAND")
281
+ )
282
+ app_server_command: list[str] = []
283
+ if app_server_command_env:
284
+ env_command = env.get(app_server_command_env)
285
+ if env_command:
286
+ app_server_command = _parse_command(env_command)
287
+ if not app_server_command:
288
+ app_server_command = _parse_command(cfg.get("app_server_command"))
289
+ if not app_server_command:
290
+ app_server_command = list(DEFAULT_APP_SERVER_COMMAND)
291
+
292
+ app_server_raw_value = cfg.get("app_server")
293
+ app_server_raw: dict[str, Any] = (
294
+ app_server_raw_value if isinstance(app_server_raw_value, dict) else {}
295
+ )
296
+ app_server_max_handles = int(
297
+ app_server_raw.get("max_handles", DEFAULT_APP_SERVER_MAX_HANDLES)
298
+ )
299
+ if app_server_max_handles <= 0:
300
+ app_server_max_handles = None
301
+ app_server_idle_ttl_seconds = int(
302
+ app_server_raw.get("idle_ttl_seconds", DEFAULT_APP_SERVER_IDLE_TTL_SECONDS)
303
+ )
304
+ if app_server_idle_ttl_seconds <= 0:
305
+ app_server_idle_ttl_seconds = None
306
+
307
+ polling_raw_value = cfg.get("polling")
308
+ polling_raw: dict[str, Any] = (
309
+ polling_raw_value if isinstance(polling_raw_value, dict) else {}
310
+ )
311
+ poll_timeout_seconds = int(
312
+ polling_raw.get("timeout_seconds", DEFAULT_POLL_TIMEOUT_SECONDS)
313
+ )
314
+ allowed_updates = polling_raw.get("allowed_updates")
315
+ if isinstance(allowed_updates, list):
316
+ poll_allowed_updates = [str(item) for item in allowed_updates if item]
317
+ else:
318
+ poll_allowed_updates = list(DEFAULT_ALLOWED_UPDATES)
319
+
320
+ return cls(
321
+ root=root,
322
+ enabled=enabled,
323
+ mode=mode,
324
+ bot_token_env=bot_token_env,
325
+ chat_id_env=chat_id_env,
326
+ parse_mode=parse_mode,
327
+ debug_prefix_context=debug_prefix_context,
328
+ bot_token=bot_token,
329
+ allowed_chat_ids=allowed_chat_ids,
330
+ allowed_user_ids=allowed_user_ids,
331
+ require_topics=require_topics,
332
+ defaults=defaults,
333
+ concurrency=concurrency,
334
+ media=media,
335
+ shell=shell,
336
+ command_registration=command_registration,
337
+ state_file=state_file,
338
+ app_server_command_env=app_server_command_env,
339
+ app_server_command=app_server_command,
340
+ app_server_max_handles=app_server_max_handles,
341
+ app_server_idle_ttl_seconds=app_server_idle_ttl_seconds,
342
+ poll_timeout_seconds=poll_timeout_seconds,
343
+ poll_allowed_updates=poll_allowed_updates,
344
+ )
345
+
346
+ def validate(self) -> None:
347
+ issues: list[str] = []
348
+ if not self.bot_token:
349
+ issues.append(f"missing bot token env '{self.bot_token_env}'")
350
+ if not self.allowed_chat_ids:
351
+ issues.append(
352
+ "no allowed chat ids configured (set allowed_chat_ids or chat_id_env)"
353
+ )
354
+ if not self.allowed_user_ids:
355
+ issues.append("no allowed user ids configured (set allowed_user_ids)")
356
+ if not self.app_server_command:
357
+ issues.append("app_server_command must be set")
358
+ if self.poll_timeout_seconds <= 0:
359
+ issues.append("poll_timeout_seconds must be greater than 0")
360
+ if issues:
361
+ raise TelegramBotConfigError("; ".join(issues))
362
+
363
+ def allowlist(self) -> TelegramAllowlist:
364
+ return TelegramAllowlist(
365
+ allowed_chat_ids=self.allowed_chat_ids,
366
+ allowed_user_ids=self.allowed_user_ids,
367
+ require_topic=self.require_topics,
368
+ )
369
+
370
+
371
+ def _parse_command(raw: Any) -> list[str]:
372
+ if isinstance(raw, list):
373
+ return [str(item) for item in raw if item]
374
+ if isinstance(raw, str):
375
+ return [part for part in shlex.split(raw) if part]
376
+ return []
377
+
378
+
379
+ def _parse_int_list(raw: Any) -> list[int]:
380
+ values: list[int] = []
381
+ if raw is None:
382
+ return values
383
+ if isinstance(raw, int):
384
+ return [raw]
385
+ if isinstance(raw, str):
386
+ parts = [part for part in re.split(r"[,\s]+", raw.strip()) if part]
387
+ for part in parts:
388
+ try:
389
+ values.append(int(part))
390
+ except ValueError:
391
+ continue
392
+ return values
393
+ if isinstance(raw, Iterable):
394
+ for item in raw:
395
+ values.extend(_parse_int_list(item))
396
+ return values
397
+
398
+
399
+ def _normalize_parse_mode(raw: Any) -> Optional[str]:
400
+ if raw is None:
401
+ return None
402
+ cleaned = str(raw).strip()
403
+ if not cleaned:
404
+ return None
405
+ return PARSE_MODE_ALIASES.get(cleaned.lower(), cleaned)
406
+
407
+
408
+ def _parse_command_scopes(raw: Any) -> list[TelegramBotCommandScope]:
409
+ scopes: list[TelegramBotCommandScope] = []
410
+ if raw is None:
411
+ raw = [
412
+ {"type": "default", "language_code": ""},
413
+ {"type": "all_group_chats", "language_code": ""},
414
+ ]
415
+ if isinstance(raw, list):
416
+ for item in raw:
417
+ scope_payload: dict[str, Any] = {"type": "default"}
418
+ language_code = ""
419
+ if isinstance(item, str):
420
+ scope_payload = {"type": item}
421
+ elif isinstance(item, dict):
422
+ if isinstance(item.get("scope"), dict):
423
+ scope_payload = dict(item.get("scope", {}))
424
+ else:
425
+ scope_payload = {
426
+ "type": (
427
+ str(item.get("type", "default"))
428
+ if item.get("type") is not None
429
+ else "default"
430
+ )
431
+ }
432
+ for key, value in item.items():
433
+ if key in ("scope", "type", "language_code"):
434
+ continue
435
+ scope_payload[key] = value
436
+ language_code_raw = item.get("language_code", "")
437
+ if language_code_raw is not None:
438
+ language_code = str(language_code_raw)
439
+ if "type" not in scope_payload:
440
+ scope_payload["type"] = "default"
441
+ scopes.append(
442
+ TelegramBotCommandScope(
443
+ scope=scope_payload, language_code=language_code
444
+ )
445
+ )
446
+ if not scopes:
447
+ scopes.append(
448
+ TelegramBotCommandScope(scope={"type": "default"}, language_code="")
449
+ )
450
+ return scopes