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,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())
|