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,921 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import collections
5
+ import json
6
+ import logging
7
+ import os
8
+ import socket
9
+ import time
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any, Coroutine, Optional, Sequence
12
+
13
+ if TYPE_CHECKING:
14
+ from .state import TelegramTopicRecord
15
+
16
+ from ...core.locks import process_alive
17
+ from ...core.logging_utils import log_event
18
+ from ...core.state import now_iso
19
+ from ...housekeeping import HousekeepingConfig, run_housekeeping_for_roots
20
+ from ...manifest import load_manifest
21
+ from ...voice import VoiceConfig, VoiceService
22
+ from ..app_server.supervisor import WorkspaceAppServerSupervisor
23
+ from .adapter import (
24
+ TelegramBotClient,
25
+ TelegramCallbackQuery,
26
+ TelegramDocument,
27
+ TelegramMessage,
28
+ TelegramPhotoSize,
29
+ TelegramUpdate,
30
+ TelegramUpdatePoller,
31
+ )
32
+ from .commands_registry import build_command_payloads, diff_command_lists
33
+ from .config import (
34
+ TelegramBotConfig,
35
+ TelegramBotConfigError,
36
+ TelegramBotLockError,
37
+ TelegramMediaCandidate,
38
+ )
39
+ from .constants import (
40
+ CACHE_CLEANUP_INTERVAL_SECONDS,
41
+ COALESCE_BUFFER_TTL_SECONDS,
42
+ DEFAULT_INTERRUPT_TIMEOUT_SECONDS,
43
+ DEFAULT_WORKSPACE_STATE_ROOT,
44
+ MODEL_PENDING_TTL_SECONDS,
45
+ OVERSIZE_WARNING_TTL_SECONDS,
46
+ PENDING_APPROVAL_TTL_SECONDS,
47
+ REASONING_BUFFER_TTL_SECONDS,
48
+ SELECTION_STATE_TTL_SECONDS,
49
+ TURN_PREVIEW_TTL_SECONDS,
50
+ UPDATE_ID_PERSIST_INTERVAL_SECONDS,
51
+ TurnKey,
52
+ )
53
+ from .dispatch import dispatch_update
54
+ from .handlers import callbacks as callback_handlers
55
+ from .handlers import messages as message_handlers
56
+ from .handlers.approvals import TelegramApprovalHandlers
57
+ from .handlers.commands import build_command_specs
58
+ from .handlers.commands_runtime import TelegramCommandHandlers
59
+ from .handlers.messages import _CoalescedBuffer
60
+ from .handlers.selections import TelegramSelectionHandlers
61
+ from .helpers import (
62
+ ModelOption,
63
+ _lock_payload_summary,
64
+ _read_lock_payload,
65
+ _split_topic_key,
66
+ _telegram_lock_path,
67
+ _with_conversation_id,
68
+ )
69
+ from .notifications import TelegramNotificationHandlers
70
+ from .outbox import TelegramOutboxManager
71
+ from .runtime import TelegramRuntimeHelpers
72
+ from .state import (
73
+ TelegramStateStore,
74
+ TopicRouter,
75
+ )
76
+ from .transport import TelegramMessageTransport
77
+ from .types import (
78
+ CompactState,
79
+ ModelPickerState,
80
+ PendingApproval,
81
+ ReviewCommitSelectionState,
82
+ SelectionState,
83
+ TurnContext,
84
+ )
85
+ from .voice import TelegramVoiceManager
86
+
87
+
88
+ class TelegramBotService(
89
+ TelegramRuntimeHelpers,
90
+ TelegramMessageTransport,
91
+ TelegramNotificationHandlers,
92
+ TelegramApprovalHandlers,
93
+ TelegramSelectionHandlers,
94
+ TelegramCommandHandlers,
95
+ ):
96
+ def __init__(
97
+ self,
98
+ config: TelegramBotConfig,
99
+ *,
100
+ logger: Optional[logging.Logger] = None,
101
+ hub_root: Optional[Path] = None,
102
+ manifest_path: Optional[Path] = None,
103
+ voice_config: Optional[VoiceConfig] = None,
104
+ voice_service: Optional[VoiceService] = None,
105
+ housekeeping_config: Optional[HousekeepingConfig] = None,
106
+ update_repo_url: Optional[str] = None,
107
+ update_repo_ref: Optional[str] = None,
108
+ ) -> None:
109
+ self._config = config
110
+ self._logger = logger or logging.getLogger(__name__)
111
+ self._hub_root = hub_root
112
+ self._manifest_path = manifest_path
113
+ self._update_repo_url = update_repo_url
114
+ self._update_repo_ref = update_repo_ref
115
+ self._allowlist = config.allowlist()
116
+ self._store = TelegramStateStore(
117
+ config.state_file, default_approval_mode=config.defaults.approval_mode
118
+ )
119
+ self._router = TopicRouter(self._store)
120
+ self._app_server_state_root = Path(DEFAULT_WORKSPACE_STATE_ROOT).expanduser()
121
+ self._app_server_supervisor = WorkspaceAppServerSupervisor(
122
+ config.app_server_command,
123
+ state_root=self._app_server_state_root,
124
+ env_builder=self._build_workspace_env,
125
+ approval_handler=self._handle_approval_request,
126
+ notification_handler=self._handle_app_server_notification,
127
+ logger=self._logger,
128
+ max_handles=config.app_server_max_handles,
129
+ idle_ttl_seconds=config.app_server_idle_ttl_seconds,
130
+ )
131
+ self._bot = TelegramBotClient(config.bot_token or "", logger=self._logger)
132
+ self._poller = TelegramUpdatePoller(
133
+ self._bot, allowed_updates=config.poll_allowed_updates
134
+ )
135
+ self._model_options: dict[str, ModelPickerState] = {}
136
+ self._model_pending: dict[str, ModelOption] = {}
137
+ self._voice_config = voice_config
138
+ self._voice_service = voice_service
139
+ self._housekeeping_config = housekeeping_config
140
+ if self._voice_service is None and voice_config is not None:
141
+ try:
142
+ self._voice_service = VoiceService(voice_config, logger=self._logger)
143
+ except Exception as exc:
144
+ log_event(
145
+ self._logger,
146
+ logging.WARNING,
147
+ "telegram.voice.init_failed",
148
+ exc=exc,
149
+ )
150
+ self._turn_semaphore: Optional[asyncio.Semaphore] = None
151
+ self._turn_contexts: dict[TurnKey, TurnContext] = {}
152
+ self._reasoning_buffers: dict[str, str] = {}
153
+ self._turn_preview_text: dict[TurnKey, str] = {}
154
+ self._turn_preview_updated_at: dict[TurnKey, float] = {}
155
+ self._oversize_warnings: set[TurnKey] = set()
156
+ self._pending_approvals: dict[str, PendingApproval] = {}
157
+ self._resume_options: dict[str, SelectionState] = {}
158
+ self._bind_options: dict[str, SelectionState] = {}
159
+ self._update_options: dict[str, SelectionState] = {}
160
+ self._update_confirm_options: dict[str, bool] = {}
161
+ self._review_commit_options: dict[str, ReviewCommitSelectionState] = {}
162
+ self._review_commit_subjects: dict[str, dict[str, str]] = {}
163
+ self._pending_review_custom: dict[str, dict[str, Any]] = {}
164
+ self._compact_pending: dict[str, CompactState] = {}
165
+ self._coalesced_buffers: dict[str, _CoalescedBuffer] = {}
166
+ self._coalesce_locks: dict[str, asyncio.Lock] = {}
167
+ self._outbox_inflight: set[str] = set()
168
+ self._outbox_lock: Optional[asyncio.Lock] = None
169
+ self._bot_username: Optional[str] = None
170
+ self._token_usage_by_thread: "collections.OrderedDict[str, dict[str, Any]]" = (
171
+ collections.OrderedDict()
172
+ )
173
+ self._token_usage_by_turn: "collections.OrderedDict[str, dict[str, Any]]" = (
174
+ collections.OrderedDict()
175
+ )
176
+ self._outbox_task: Optional[asyncio.Task[None]] = None
177
+ self._cache_cleanup_task: Optional[asyncio.Task[None]] = None
178
+ self._cache_timestamps: dict[str, dict[object, float]] = {}
179
+ self._last_update_ids: dict[str, int] = {}
180
+ self._last_update_persisted_at: dict[str, float] = {}
181
+ self._spawned_tasks: set[asyncio.Task[Any]] = set()
182
+ self._outbox_manager = TelegramOutboxManager(
183
+ self._store,
184
+ send_message=self._send_message,
185
+ edit_message_text=self._edit_message_text,
186
+ delete_message=self._delete_message,
187
+ logger=self._logger,
188
+ )
189
+ self._voice_manager = TelegramVoiceManager(
190
+ self._config,
191
+ self._store,
192
+ voice_config=self._voice_config,
193
+ voice_service=self._voice_service,
194
+ send_message=self._send_message,
195
+ edit_message_text=self._edit_message_text,
196
+ send_progress_message=self._send_voice_progress_message,
197
+ deliver_transcript=self._deliver_voice_transcript,
198
+ download_file=self._download_telegram_file,
199
+ logger=self._logger,
200
+ )
201
+ self._voice_task: Optional[asyncio.Task[None]] = None
202
+ self._housekeeping_task: Optional[asyncio.Task[None]] = None
203
+ self._command_specs = build_command_specs(self)
204
+ self._instance_lock_path: Optional[Path] = None
205
+
206
+ def _housekeeping_roots(self) -> list[Path]:
207
+ roots: set[Path] = set()
208
+ try:
209
+ state = self._store.load()
210
+ for record in state.topics.values():
211
+ if isinstance(record.workspace_path, str) and record.workspace_path:
212
+ roots.add(Path(record.workspace_path).expanduser().resolve())
213
+ except Exception as exc:
214
+ log_event(
215
+ self._logger,
216
+ logging.WARNING,
217
+ "telegram.housekeeping.state_failed",
218
+ exc=exc,
219
+ )
220
+ if self._hub_root and self._manifest_path and self._manifest_path.exists():
221
+ try:
222
+ manifest = load_manifest(self._manifest_path, self._hub_root)
223
+ for repo in manifest.repos:
224
+ roots.add((self._hub_root / repo.path).resolve())
225
+ except Exception as exc:
226
+ log_event(
227
+ self._logger,
228
+ logging.WARNING,
229
+ "telegram.housekeeping.manifest_failed",
230
+ exc=exc,
231
+ )
232
+ if self._config.root:
233
+ roots.add(self._config.root.resolve())
234
+ return sorted(roots)
235
+
236
+ async def _housekeeping_loop(self) -> None:
237
+ config = self._housekeeping_config
238
+ if config is None or not config.enabled:
239
+ return
240
+ interval = max(config.interval_seconds, 1)
241
+ while True:
242
+ try:
243
+ roots = self._housekeeping_roots()
244
+ if roots:
245
+ await asyncio.to_thread(
246
+ run_housekeeping_for_roots,
247
+ config,
248
+ roots,
249
+ self._logger,
250
+ )
251
+ await self._app_server_supervisor.prune_idle()
252
+ except Exception as exc:
253
+ log_event(
254
+ self._logger,
255
+ logging.WARNING,
256
+ "telegram.housekeeping.failed",
257
+ exc=exc,
258
+ )
259
+ await asyncio.sleep(interval)
260
+
261
+ def _ensure_outbox_lock(self) -> asyncio.Lock:
262
+ loop = asyncio.get_running_loop()
263
+ lock = self._outbox_lock
264
+ lock_loop = getattr(lock, "_loop", None) if lock else None
265
+ if (
266
+ lock is None
267
+ or lock_loop is None
268
+ or lock_loop is not loop
269
+ or lock_loop.is_closed()
270
+ ):
271
+ lock = asyncio.Lock()
272
+ self._outbox_lock = lock
273
+ return lock
274
+
275
+ async def _mark_outbox_inflight(self, record_id: str) -> bool:
276
+ lock = self._ensure_outbox_lock()
277
+ async with lock:
278
+ if record_id in self._outbox_inflight:
279
+ return False
280
+ self._outbox_inflight.add(record_id)
281
+ return True
282
+
283
+ async def _clear_outbox_inflight(self, record_id: str) -> None:
284
+ lock = self._ensure_outbox_lock()
285
+ async with lock:
286
+ self._outbox_inflight.discard(record_id)
287
+
288
+ def _acquire_instance_lock(self) -> None:
289
+ token = self._config.bot_token
290
+ if not token:
291
+ raise TelegramBotLockError("missing telegram bot token")
292
+ lock_path = _telegram_lock_path(token)
293
+ payload = {
294
+ "pid": os.getpid(),
295
+ "started_at": now_iso(),
296
+ "host": socket.gethostname(),
297
+ "cwd": os.getcwd(),
298
+ "config_root": str(self._config.root),
299
+ }
300
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
301
+ try:
302
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
303
+ except FileExistsError as exc:
304
+ existing = _read_lock_payload(lock_path)
305
+ pid = existing.get("pid") if isinstance(existing, dict) else None
306
+ if isinstance(pid, int) and process_alive(pid):
307
+ log_event(
308
+ self._logger,
309
+ logging.ERROR,
310
+ "telegram.lock.contended",
311
+ lock_path=str(lock_path),
312
+ **_lock_payload_summary(existing),
313
+ )
314
+ raise TelegramBotLockError(
315
+ "Telegram bot already running for this token."
316
+ ) from exc
317
+ try:
318
+ lock_path.unlink()
319
+ except OSError:
320
+ pass
321
+ try:
322
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
323
+ except FileExistsError as exc:
324
+ existing = _read_lock_payload(lock_path)
325
+ log_event(
326
+ self._logger,
327
+ logging.ERROR,
328
+ "telegram.lock.contended",
329
+ lock_path=str(lock_path),
330
+ **_lock_payload_summary(existing),
331
+ )
332
+ raise TelegramBotLockError(
333
+ "Telegram bot already running for this token."
334
+ ) from exc
335
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
336
+ handle.write(json.dumps(payload) + "\n")
337
+ self._instance_lock_path = lock_path
338
+ log_event(
339
+ self._logger,
340
+ logging.INFO,
341
+ "telegram.lock.acquired",
342
+ lock_path=str(lock_path),
343
+ **_lock_payload_summary(payload),
344
+ )
345
+
346
+ def _release_instance_lock(self) -> None:
347
+ lock_path = self._instance_lock_path
348
+ if lock_path is None:
349
+ return
350
+ existing = _read_lock_payload(lock_path)
351
+ if isinstance(existing, dict):
352
+ pid = existing.get("pid")
353
+ if isinstance(pid, int) and pid != os.getpid():
354
+ return
355
+ try:
356
+ lock_path.unlink()
357
+ except OSError:
358
+ pass
359
+ self._instance_lock_path = None
360
+
361
+ def _ensure_turn_semaphore(self) -> asyncio.Semaphore:
362
+ if self._turn_semaphore is None:
363
+ self._turn_semaphore = asyncio.Semaphore(
364
+ self._config.concurrency.max_parallel_turns
365
+ )
366
+ return self._turn_semaphore
367
+
368
+ async def run_polling(self) -> None:
369
+ if self._config.mode != "polling":
370
+ raise TelegramBotConfigError(
371
+ f"Unsupported telegram_bot.mode '{self._config.mode}'"
372
+ )
373
+ self._config.validate()
374
+ self._acquire_instance_lock()
375
+ # Bind the semaphore to the running loop to avoid cross-loop await failures.
376
+ self._turn_semaphore = asyncio.Semaphore(
377
+ self._config.concurrency.max_parallel_turns
378
+ )
379
+ self._outbox_manager.start()
380
+ self._voice_manager.start()
381
+ try:
382
+ await self._prime_bot_identity()
383
+ await self._register_bot_commands()
384
+ await self._restore_pending_approvals()
385
+ await self._outbox_manager.restore()
386
+ await self._voice_manager.restore()
387
+ self._prime_poller_offset()
388
+ self._outbox_task = asyncio.create_task(self._outbox_manager.run_loop())
389
+ self._voice_task = asyncio.create_task(self._voice_manager.run_loop())
390
+ self._housekeeping_task = asyncio.create_task(self._housekeeping_loop())
391
+ self._cache_cleanup_task = asyncio.create_task(self._cache_cleanup_loop())
392
+ log_event(
393
+ self._logger,
394
+ logging.INFO,
395
+ "telegram.bot.started",
396
+ mode=self._config.mode,
397
+ poll_timeout=self._config.poll_timeout_seconds,
398
+ allowed_updates=list(self._config.poll_allowed_updates),
399
+ allowed_chats=len(self._config.allowed_chat_ids),
400
+ allowed_users=len(self._config.allowed_user_ids),
401
+ require_topics=self._config.require_topics,
402
+ media_enabled=self._config.media.enabled,
403
+ media_images=self._config.media.images,
404
+ media_voice=self._config.media.voice,
405
+ poller_offset=self._poller.offset,
406
+ )
407
+ try:
408
+ await self._maybe_send_update_status_notice()
409
+ except Exception as exc:
410
+ log_event(
411
+ self._logger,
412
+ logging.WARNING,
413
+ "telegram.update.notify_failed",
414
+ exc=exc,
415
+ )
416
+ try:
417
+ await self._maybe_send_compact_status_notice()
418
+ except Exception as exc:
419
+ log_event(
420
+ self._logger,
421
+ logging.WARNING,
422
+ "telegram.compact.notify_failed",
423
+ exc=exc,
424
+ )
425
+ while True:
426
+ updates = []
427
+ try:
428
+ updates = await self._poller.poll(
429
+ timeout=self._config.poll_timeout_seconds
430
+ )
431
+ if self._poller.offset is not None:
432
+ self._record_poll_offset(updates)
433
+ except Exception as exc:
434
+ log_event(
435
+ self._logger,
436
+ logging.WARNING,
437
+ "telegram.poll.failed",
438
+ exc=exc,
439
+ )
440
+ await asyncio.sleep(1.0)
441
+ continue
442
+ for update in updates:
443
+ self._spawn_task(dispatch_update(self, update))
444
+ finally:
445
+ try:
446
+ if self._outbox_task is not None:
447
+ self._outbox_task.cancel()
448
+ try:
449
+ await self._outbox_task
450
+ except asyncio.CancelledError:
451
+ pass
452
+ if self._voice_task is not None:
453
+ self._voice_task.cancel()
454
+ try:
455
+ await self._voice_task
456
+ except asyncio.CancelledError:
457
+ pass
458
+ if self._housekeeping_task is not None:
459
+ self._housekeeping_task.cancel()
460
+ try:
461
+ await self._housekeeping_task
462
+ except asyncio.CancelledError:
463
+ pass
464
+ if self._cache_cleanup_task is not None:
465
+ self._cache_cleanup_task.cancel()
466
+ try:
467
+ await self._cache_cleanup_task
468
+ except asyncio.CancelledError:
469
+ pass
470
+ if self._spawned_tasks:
471
+ for task in list(self._spawned_tasks):
472
+ task.cancel()
473
+ await asyncio.gather(*self._spawned_tasks, return_exceptions=True)
474
+ finally:
475
+ try:
476
+ await self._bot.close()
477
+ except Exception as exc:
478
+ log_event(
479
+ self._logger,
480
+ logging.WARNING,
481
+ "telegram.bot.close_failed",
482
+ exc=exc,
483
+ )
484
+ try:
485
+ await self._app_server_supervisor.close_all()
486
+ except Exception as exc:
487
+ log_event(
488
+ self._logger,
489
+ logging.WARNING,
490
+ "telegram.app_server.close_failed",
491
+ exc=exc,
492
+ )
493
+ self._release_instance_lock()
494
+
495
+ async def _prime_bot_identity(self) -> None:
496
+ try:
497
+ payload = await self._bot.get_me()
498
+ except Exception:
499
+ return
500
+ if isinstance(payload, dict):
501
+ username = payload.get("username")
502
+ if isinstance(username, str) and username:
503
+ self._bot_username = username
504
+
505
+ async def _register_bot_commands(self) -> None:
506
+ registration = self._config.command_registration
507
+ if not registration.enabled:
508
+ log_event(
509
+ self._logger,
510
+ logging.DEBUG,
511
+ "telegram.commands.disabled",
512
+ )
513
+ return
514
+ desired, invalid = build_command_payloads(self._command_specs)
515
+ if invalid:
516
+ log_event(
517
+ self._logger,
518
+ logging.WARNING,
519
+ "telegram.commands.invalid",
520
+ invalid=invalid,
521
+ )
522
+ if not desired:
523
+ log_event(
524
+ self._logger,
525
+ logging.WARNING,
526
+ "telegram.commands.empty",
527
+ )
528
+ return
529
+ if len(desired) > 100:
530
+ log_event(
531
+ self._logger,
532
+ logging.WARNING,
533
+ "telegram.commands.truncated",
534
+ desired_count=len(desired),
535
+ )
536
+ desired = desired[:100]
537
+ for scope_spec in registration.scopes:
538
+ scope = scope_spec.scope
539
+ language_code = scope_spec.language_code
540
+ try:
541
+ current = await self._bot.get_my_commands(
542
+ scope=scope,
543
+ language_code=language_code,
544
+ )
545
+ except Exception as exc:
546
+ log_event(
547
+ self._logger,
548
+ logging.WARNING,
549
+ "telegram.commands.get_failed",
550
+ scope=scope,
551
+ language_code=language_code,
552
+ exc=exc,
553
+ )
554
+ continue
555
+ diff = diff_command_lists(desired, current)
556
+ if not diff.needs_update:
557
+ log_event(
558
+ self._logger,
559
+ logging.DEBUG,
560
+ "telegram.commands.up_to_date",
561
+ scope=scope,
562
+ language_code=language_code,
563
+ )
564
+ continue
565
+ try:
566
+ updated = await self._bot.set_my_commands(
567
+ desired,
568
+ scope=scope,
569
+ language_code=language_code,
570
+ )
571
+ except Exception as exc:
572
+ log_event(
573
+ self._logger,
574
+ logging.WARNING,
575
+ "telegram.commands.set_failed",
576
+ scope=scope,
577
+ language_code=language_code,
578
+ exc=exc,
579
+ )
580
+ continue
581
+ log_event(
582
+ self._logger,
583
+ logging.INFO,
584
+ "telegram.commands.updated",
585
+ scope=scope,
586
+ language_code=language_code,
587
+ updated=updated,
588
+ added=diff.added,
589
+ removed=diff.removed,
590
+ changed=diff.changed,
591
+ order_changed=diff.order_changed,
592
+ )
593
+
594
+ def _prime_poller_offset(self) -> None:
595
+ last_update_id = self._store.get_last_update_id_global()
596
+ if not isinstance(last_update_id, int) or isinstance(last_update_id, bool):
597
+ return
598
+ offset = last_update_id + 1
599
+ self._poller.set_offset(offset)
600
+ log_event(
601
+ self._logger,
602
+ logging.INFO,
603
+ "telegram.poll.offset.init",
604
+ stored_global_update_id=last_update_id,
605
+ poller_offset=offset,
606
+ )
607
+
608
+ def _record_poll_offset(self, updates: Sequence[TelegramUpdate]) -> None:
609
+ offset = self._poller.offset
610
+ if offset is None:
611
+ return
612
+ last_update_id = offset - 1
613
+ if last_update_id < 0:
614
+ return
615
+ stored = self._store.update_last_update_id_global(last_update_id)
616
+ if updates:
617
+ max_update_id = max(update.update_id for update in updates)
618
+ log_event(
619
+ self._logger,
620
+ logging.INFO,
621
+ "telegram.poll.offset.updated",
622
+ incoming_update_id=max_update_id,
623
+ stored_global_update_id=stored,
624
+ poller_offset=offset,
625
+ )
626
+
627
+ def _spawn_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
628
+ task: asyncio.Task[Any] = asyncio.create_task(coro)
629
+ self._spawned_tasks.add(task)
630
+ task.add_done_callback(self._log_task_result)
631
+ return task
632
+
633
+ def _log_task_result(self, task: asyncio.Future) -> None:
634
+ if isinstance(task, asyncio.Task):
635
+ self._spawned_tasks.discard(task)
636
+ try:
637
+ task.result()
638
+ except asyncio.CancelledError:
639
+ return
640
+ except Exception as exc:
641
+ log_event(self._logger, logging.WARNING, "telegram.task.failed", exc=exc)
642
+
643
+ def _touch_cache_timestamp(self, cache_name: str, key: object) -> None:
644
+ cache = self._cache_timestamps.setdefault(cache_name, {})
645
+ cache[key] = time.monotonic()
646
+
647
+ def _evict_expired_cache_entries(self, cache_name: str, ttl_seconds: float) -> None:
648
+ cache = self._cache_timestamps.get(cache_name)
649
+ if not cache:
650
+ return
651
+ now = time.monotonic()
652
+ expired: list[object] = []
653
+ for key, updated_at in cache.items():
654
+ if (now - updated_at) > ttl_seconds:
655
+ expired.append(key)
656
+ if not expired:
657
+ return
658
+ for key in expired:
659
+ cache.pop(key, None)
660
+ if cache_name == "reasoning_buffers":
661
+ self._reasoning_buffers.pop(key, None)
662
+ elif cache_name == "turn_preview":
663
+ self._turn_preview_text.pop(key, None)
664
+ self._turn_preview_updated_at.pop(key, None)
665
+ elif cache_name == "oversize_warnings":
666
+ self._oversize_warnings.discard(key)
667
+ elif cache_name == "coalesced_buffers":
668
+ self._coalesced_buffers.pop(key, None)
669
+ self._coalesce_locks.pop(key, None)
670
+ elif cache_name == "resume_options":
671
+ self._resume_options.pop(key, None)
672
+ elif cache_name == "bind_options":
673
+ self._bind_options.pop(key, None)
674
+ elif cache_name == "update_options":
675
+ self._update_options.pop(key, None)
676
+ elif cache_name == "update_confirm_options":
677
+ self._update_confirm_options.pop(key, None)
678
+ elif cache_name == "review_commit_options":
679
+ self._review_commit_options.pop(key, None)
680
+ elif cache_name == "review_commit_subjects":
681
+ self._review_commit_subjects.pop(key, None)
682
+ elif cache_name == "pending_review_custom":
683
+ self._pending_review_custom.pop(key, None)
684
+ elif cache_name == "compact_pending":
685
+ self._compact_pending.pop(key, None)
686
+ elif cache_name == "model_options":
687
+ self._model_options.pop(key, None)
688
+ elif cache_name == "model_pending":
689
+ self._model_pending.pop(key, None)
690
+ elif cache_name == "pending_approvals":
691
+ self._pending_approvals.pop(key, None)
692
+
693
+ async def _cache_cleanup_loop(self) -> None:
694
+ interval = max(CACHE_CLEANUP_INTERVAL_SECONDS, 1.0)
695
+ while True:
696
+ await asyncio.sleep(interval)
697
+ self._evict_expired_cache_entries(
698
+ "reasoning_buffers", REASONING_BUFFER_TTL_SECONDS
699
+ )
700
+ self._evict_expired_cache_entries("turn_preview", TURN_PREVIEW_TTL_SECONDS)
701
+ self._evict_expired_cache_entries(
702
+ "oversize_warnings", OVERSIZE_WARNING_TTL_SECONDS
703
+ )
704
+ self._evict_expired_cache_entries(
705
+ "coalesced_buffers", COALESCE_BUFFER_TTL_SECONDS
706
+ )
707
+ self._evict_expired_cache_entries(
708
+ "resume_options", SELECTION_STATE_TTL_SECONDS
709
+ )
710
+ self._evict_expired_cache_entries(
711
+ "bind_options", SELECTION_STATE_TTL_SECONDS
712
+ )
713
+ self._evict_expired_cache_entries(
714
+ "update_options", SELECTION_STATE_TTL_SECONDS
715
+ )
716
+ self._evict_expired_cache_entries(
717
+ "update_confirm_options", SELECTION_STATE_TTL_SECONDS
718
+ )
719
+ self._evict_expired_cache_entries(
720
+ "review_commit_options", SELECTION_STATE_TTL_SECONDS
721
+ )
722
+ self._evict_expired_cache_entries(
723
+ "review_commit_subjects", SELECTION_STATE_TTL_SECONDS
724
+ )
725
+ self._evict_expired_cache_entries(
726
+ "pending_review_custom", SELECTION_STATE_TTL_SECONDS
727
+ )
728
+ self._evict_expired_cache_entries(
729
+ "compact_pending", SELECTION_STATE_TTL_SECONDS
730
+ )
731
+ self._evict_expired_cache_entries(
732
+ "model_options", SELECTION_STATE_TTL_SECONDS
733
+ )
734
+ self._evict_expired_cache_entries(
735
+ "model_pending", MODEL_PENDING_TTL_SECONDS
736
+ )
737
+ self._evict_expired_cache_entries(
738
+ "pending_approvals", PENDING_APPROVAL_TTL_SECONDS
739
+ )
740
+
741
+ async def _interrupt_timeout_check(
742
+ self, key: str, turn_id: str, message_id: int
743
+ ) -> None:
744
+ await asyncio.sleep(DEFAULT_INTERRUPT_TIMEOUT_SECONDS)
745
+ runtime = self._router.runtime_for(key)
746
+ if runtime.current_turn_id != turn_id:
747
+ return
748
+ if runtime.interrupt_message_id != message_id:
749
+ return
750
+ if runtime.interrupt_turn_id != turn_id:
751
+ return
752
+ chat_id, _thread_id = _split_topic_key(key)
753
+ await self._edit_message_text(
754
+ chat_id,
755
+ message_id,
756
+ "Still stopping... (30s). If this is stuck, try /interrupt again.",
757
+ )
758
+ runtime.interrupt_requested = False
759
+
760
+ async def _dispatch_interrupt_request(
761
+ self,
762
+ *,
763
+ turn_id: str,
764
+ codex_thread_id: Optional[str],
765
+ runtime: Any,
766
+ chat_id: int,
767
+ thread_id: Optional[int],
768
+ ) -> None:
769
+ key = self._resolve_topic_key(chat_id, thread_id)
770
+ record = self._router.get_topic(key)
771
+ client = await self._client_for_workspace(
772
+ record.workspace_path if record else None
773
+ )
774
+ if client is None:
775
+ runtime.interrupt_requested = False
776
+ if runtime.interrupt_message_id is not None:
777
+ await self._edit_message_text(
778
+ chat_id,
779
+ runtime.interrupt_message_id,
780
+ "Interrupt failed (app-server error).",
781
+ )
782
+ runtime.interrupt_message_id = None
783
+ runtime.interrupt_turn_id = None
784
+ return
785
+ try:
786
+ await client.turn_interrupt(turn_id, thread_id=codex_thread_id)
787
+ except Exception as exc:
788
+ log_event(
789
+ self._logger,
790
+ logging.WARNING,
791
+ "telegram.interrupt.failed",
792
+ chat_id=chat_id,
793
+ thread_id=thread_id,
794
+ turn_id=turn_id,
795
+ exc=exc,
796
+ )
797
+ if (
798
+ runtime.interrupt_message_id is not None
799
+ and runtime.interrupt_turn_id == turn_id
800
+ ):
801
+ await self._edit_message_text(
802
+ chat_id,
803
+ runtime.interrupt_message_id,
804
+ "Interrupt failed (app-server error).",
805
+ )
806
+ runtime.interrupt_message_id = None
807
+ runtime.interrupt_turn_id = None
808
+ runtime.interrupt_requested = False
809
+
810
+ async def _handle_message(self, message: TelegramMessage) -> None:
811
+ await message_handlers.handle_message(self, message)
812
+
813
+ def _should_bypass_topic_queue(self, message: TelegramMessage) -> bool:
814
+ return message_handlers.should_bypass_topic_queue(self, message)
815
+
816
+ async def _handle_edited_message(self, message: TelegramMessage) -> None:
817
+ await message_handlers.handle_edited_message(self, message)
818
+
819
+ async def _handle_message_inner(
820
+ self, message: TelegramMessage, *, topic_key: Optional[str] = None
821
+ ) -> None:
822
+ await message_handlers.handle_message_inner(self, message, topic_key=topic_key)
823
+
824
+ def _coalesce_key_for_topic(self, key: str, user_id: Optional[int]) -> str:
825
+ return message_handlers.coalesce_key_for_topic(self, key, user_id)
826
+
827
+ def _coalesce_key(self, message: TelegramMessage) -> str:
828
+ return message_handlers.coalesce_key(self, message)
829
+
830
+ async def _buffer_coalesced_message(
831
+ self, message: TelegramMessage, text: str
832
+ ) -> None:
833
+ await message_handlers.buffer_coalesced_message(self, message, text)
834
+
835
+ async def _coalesce_flush_after(self, key: str) -> None:
836
+ await message_handlers.coalesce_flush_after(self, key)
837
+
838
+ async def _flush_coalesced_message(self, message: TelegramMessage) -> None:
839
+ await message_handlers.flush_coalesced_message(self, message)
840
+
841
+ async def _flush_coalesced_key(self, key: str) -> None:
842
+ await message_handlers.flush_coalesced_key(self, key)
843
+
844
+ def _build_coalesced_message(self, buffer: _CoalescedBuffer) -> TelegramMessage:
845
+ return message_handlers.build_coalesced_message(buffer)
846
+
847
+ def _message_has_media(self, message: TelegramMessage) -> bool:
848
+ return message_handlers.message_has_media(message)
849
+
850
+ def _select_photo(
851
+ self, photos: Sequence[TelegramPhotoSize]
852
+ ) -> Optional[TelegramPhotoSize]:
853
+ return message_handlers.select_photo(photos)
854
+
855
+ def _document_is_image(self, document: TelegramDocument) -> bool:
856
+ return message_handlers.document_is_image(document)
857
+
858
+ def _select_image_candidate(
859
+ self, message: TelegramMessage
860
+ ) -> Optional[TelegramMediaCandidate]:
861
+ return message_handlers.select_image_candidate(message)
862
+
863
+ def _select_voice_candidate(
864
+ self, message: TelegramMessage
865
+ ) -> Optional[TelegramMediaCandidate]:
866
+ return message_handlers.select_voice_candidate(message)
867
+
868
+ async def _handle_media_message(
869
+ self, message: TelegramMessage, runtime: Any, caption_text: str
870
+ ) -> None:
871
+ await message_handlers.handle_media_message(
872
+ self, message, runtime, caption_text
873
+ )
874
+
875
+ def _with_conversation_id(
876
+ self, message: str, *, chat_id: int, thread_id: Optional[int]
877
+ ) -> str:
878
+ return _with_conversation_id(message, chat_id=chat_id, thread_id=thread_id)
879
+
880
+ def _should_process_update(self, key: str, update_id: int) -> bool:
881
+ if not isinstance(update_id, int):
882
+ return True
883
+ if isinstance(update_id, bool):
884
+ return True
885
+ last_id = self._last_update_ids.get(key)
886
+ if last_id is None:
887
+ record = self._store.get_topic(key)
888
+ last_id = record.last_update_id if record else None
889
+ if isinstance(last_id, int) and not isinstance(last_id, bool):
890
+ self._last_update_ids[key] = last_id
891
+ else:
892
+ last_id = None
893
+ if isinstance(last_id, int) and update_id <= last_id:
894
+ return False
895
+ self._last_update_ids[key] = update_id
896
+ self._maybe_persist_update_id(key, update_id)
897
+ return True
898
+
899
+ def _maybe_persist_update_id(self, key: str, update_id: int) -> None:
900
+ now = time.monotonic()
901
+ last_persisted = self._last_update_persisted_at.get(key, 0.0)
902
+ if (now - last_persisted) < UPDATE_ID_PERSIST_INTERVAL_SECONDS:
903
+ return
904
+
905
+ def apply(record: "TelegramTopicRecord") -> None:
906
+ record.last_update_id = update_id
907
+
908
+ self._store.update_topic(key, apply)
909
+ self._last_update_persisted_at[key] = now
910
+
911
+ async def _handle_callback(self, callback: TelegramCallbackQuery) -> None:
912
+ await callback_handlers.handle_callback(self, callback)
913
+
914
+ def _enqueue_topic_work(
915
+ self, key: str, work: Any, *, force_queue: bool = False
916
+ ) -> None:
917
+ runtime = self._router.runtime_for(key)
918
+ if force_queue or self._config.concurrency.per_topic_queue:
919
+ self._spawn_task(runtime.queue.enqueue(work))
920
+ else:
921
+ self._spawn_task(work())