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.
- codex_autorunner/__init__.py +3 -0
- codex_autorunner/bootstrap.py +151 -0
- codex_autorunner/cli.py +886 -0
- codex_autorunner/codex_cli.py +79 -0
- codex_autorunner/codex_runner.py +17 -0
- codex_autorunner/core/__init__.py +1 -0
- codex_autorunner/core/about_car.py +125 -0
- codex_autorunner/core/codex_runner.py +100 -0
- codex_autorunner/core/config.py +1465 -0
- codex_autorunner/core/doc_chat.py +547 -0
- codex_autorunner/core/docs.py +37 -0
- codex_autorunner/core/engine.py +720 -0
- codex_autorunner/core/git_utils.py +206 -0
- codex_autorunner/core/hub.py +756 -0
- codex_autorunner/core/injected_context.py +9 -0
- codex_autorunner/core/locks.py +57 -0
- codex_autorunner/core/logging_utils.py +158 -0
- codex_autorunner/core/notifications.py +465 -0
- codex_autorunner/core/optional_dependencies.py +41 -0
- codex_autorunner/core/prompt.py +107 -0
- codex_autorunner/core/prompts.py +275 -0
- codex_autorunner/core/request_context.py +21 -0
- codex_autorunner/core/runner_controller.py +116 -0
- codex_autorunner/core/runner_process.py +29 -0
- codex_autorunner/core/snapshot.py +576 -0
- codex_autorunner/core/state.py +156 -0
- codex_autorunner/core/update.py +567 -0
- codex_autorunner/core/update_runner.py +44 -0
- codex_autorunner/core/usage.py +1221 -0
- codex_autorunner/core/utils.py +108 -0
- codex_autorunner/discovery.py +102 -0
- codex_autorunner/housekeeping.py +423 -0
- codex_autorunner/integrations/__init__.py +1 -0
- codex_autorunner/integrations/app_server/__init__.py +6 -0
- codex_autorunner/integrations/app_server/client.py +1386 -0
- codex_autorunner/integrations/app_server/supervisor.py +206 -0
- codex_autorunner/integrations/github/__init__.py +10 -0
- codex_autorunner/integrations/github/service.py +889 -0
- codex_autorunner/integrations/telegram/__init__.py +1 -0
- codex_autorunner/integrations/telegram/adapter.py +1401 -0
- codex_autorunner/integrations/telegram/commands_registry.py +104 -0
- codex_autorunner/integrations/telegram/config.py +450 -0
- codex_autorunner/integrations/telegram/constants.py +154 -0
- codex_autorunner/integrations/telegram/dispatch.py +162 -0
- codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
- codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
- codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
- codex_autorunner/integrations/telegram/helpers.py +2084 -0
- codex_autorunner/integrations/telegram/notifications.py +164 -0
- codex_autorunner/integrations/telegram/outbox.py +174 -0
- codex_autorunner/integrations/telegram/rendering.py +102 -0
- codex_autorunner/integrations/telegram/retry.py +37 -0
- codex_autorunner/integrations/telegram/runtime.py +270 -0
- codex_autorunner/integrations/telegram/service.py +921 -0
- codex_autorunner/integrations/telegram/state.py +1223 -0
- codex_autorunner/integrations/telegram/transport.py +318 -0
- codex_autorunner/integrations/telegram/types.py +57 -0
- codex_autorunner/integrations/telegram/voice.py +413 -0
- codex_autorunner/manifest.py +150 -0
- codex_autorunner/routes/__init__.py +53 -0
- codex_autorunner/routes/base.py +470 -0
- codex_autorunner/routes/docs.py +275 -0
- codex_autorunner/routes/github.py +197 -0
- codex_autorunner/routes/repos.py +121 -0
- codex_autorunner/routes/sessions.py +137 -0
- codex_autorunner/routes/shared.py +137 -0
- codex_autorunner/routes/system.py +175 -0
- codex_autorunner/routes/terminal_images.py +107 -0
- codex_autorunner/routes/voice.py +128 -0
- codex_autorunner/server.py +23 -0
- codex_autorunner/spec_ingest.py +113 -0
- codex_autorunner/static/app.js +95 -0
- codex_autorunner/static/autoRefresh.js +209 -0
- codex_autorunner/static/bootstrap.js +105 -0
- codex_autorunner/static/bus.js +23 -0
- codex_autorunner/static/cache.js +52 -0
- codex_autorunner/static/constants.js +48 -0
- codex_autorunner/static/dashboard.js +795 -0
- codex_autorunner/static/docs.js +1514 -0
- codex_autorunner/static/env.js +99 -0
- codex_autorunner/static/github.js +168 -0
- codex_autorunner/static/hub.js +1511 -0
- codex_autorunner/static/index.html +622 -0
- codex_autorunner/static/loader.js +28 -0
- codex_autorunner/static/logs.js +690 -0
- codex_autorunner/static/mobileCompact.js +300 -0
- codex_autorunner/static/snapshot.js +116 -0
- codex_autorunner/static/state.js +87 -0
- codex_autorunner/static/styles.css +4966 -0
- codex_autorunner/static/tabs.js +50 -0
- codex_autorunner/static/terminal.js +21 -0
- codex_autorunner/static/terminalManager.js +3535 -0
- codex_autorunner/static/todoPreview.js +25 -0
- codex_autorunner/static/types.d.ts +8 -0
- codex_autorunner/static/utils.js +597 -0
- codex_autorunner/static/vendor/LICENSE.xterm +24 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
- codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
- codex_autorunner/static/vendor/xterm.css +209 -0
- codex_autorunner/static/vendor/xterm.js +2 -0
- codex_autorunner/static/voice.js +591 -0
- codex_autorunner/voice/__init__.py +39 -0
- codex_autorunner/voice/capture.py +349 -0
- codex_autorunner/voice/config.py +167 -0
- codex_autorunner/voice/provider.py +66 -0
- codex_autorunner/voice/providers/__init__.py +7 -0
- codex_autorunner/voice/providers/openai_whisper.py +345 -0
- codex_autorunner/voice/resolver.py +36 -0
- codex_autorunner/voice/service.py +210 -0
- codex_autorunner/web/__init__.py +1 -0
- codex_autorunner/web/app.py +1037 -0
- codex_autorunner/web/hub_jobs.py +181 -0
- codex_autorunner/web/middleware.py +552 -0
- codex_autorunner/web/pty_session.py +357 -0
- codex_autorunner/web/runner_manager.py +25 -0
- codex_autorunner/web/schemas.py +253 -0
- codex_autorunner/web/static_assets.py +430 -0
- codex_autorunner/web/terminal_sessions.py +78 -0
- codex_autorunner/workspace.py +16 -0
- codex_autorunner-0.1.0.dist-info/METADATA +240 -0
- codex_autorunner-0.1.0.dist-info/RECORD +147 -0
- codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
- codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
- codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|