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,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from ...core.logging_utils import log_event
|
|
8
|
+
from .constants import (
|
|
9
|
+
STREAM_PREVIEW_PREFIX,
|
|
10
|
+
TELEGRAM_MAX_MESSAGE_LENGTH,
|
|
11
|
+
THINKING_PREVIEW_MAX_LEN,
|
|
12
|
+
THINKING_PREVIEW_MIN_EDIT_INTERVAL_SECONDS,
|
|
13
|
+
TOKEN_USAGE_CACHE_LIMIT,
|
|
14
|
+
TOKEN_USAGE_TURN_CACHE_LIMIT,
|
|
15
|
+
)
|
|
16
|
+
from .helpers import (
|
|
17
|
+
_coerce_id,
|
|
18
|
+
_extract_first_bold_span,
|
|
19
|
+
_extract_turn_thread_id,
|
|
20
|
+
_truncate_text,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TelegramNotificationHandlers:
|
|
25
|
+
async def _handle_app_server_notification(self, message: dict[str, Any]) -> None:
|
|
26
|
+
method = message.get("method")
|
|
27
|
+
params_raw = message.get("params")
|
|
28
|
+
params: dict[str, Any] = params_raw if isinstance(params_raw, dict) else {}
|
|
29
|
+
if method == "car/app_server/oversizedMessageDropped":
|
|
30
|
+
turn_id = _coerce_id(params.get("turnId"))
|
|
31
|
+
thread_id = params.get("threadId")
|
|
32
|
+
turn_key = (
|
|
33
|
+
self._resolve_turn_key(turn_id, thread_id=thread_id)
|
|
34
|
+
if turn_id
|
|
35
|
+
else None
|
|
36
|
+
)
|
|
37
|
+
if turn_key is None and len(self._turn_contexts) == 1:
|
|
38
|
+
turn_key = next(iter(self._turn_contexts.keys()))
|
|
39
|
+
if turn_key is None:
|
|
40
|
+
log_event(
|
|
41
|
+
self._logger,
|
|
42
|
+
logging.WARNING,
|
|
43
|
+
"telegram.app_server.oversize.context_missing",
|
|
44
|
+
inferred_turn_id=turn_id,
|
|
45
|
+
inferred_thread_id=thread_id,
|
|
46
|
+
)
|
|
47
|
+
return
|
|
48
|
+
if turn_key in self._oversize_warnings:
|
|
49
|
+
return
|
|
50
|
+
ctx = self._turn_contexts.get(turn_key)
|
|
51
|
+
if ctx is None:
|
|
52
|
+
return
|
|
53
|
+
self._oversize_warnings.add(turn_key)
|
|
54
|
+
self._touch_cache_timestamp("oversize_warnings", turn_key)
|
|
55
|
+
byte_limit = params.get("byteLimit")
|
|
56
|
+
limit_mb = None
|
|
57
|
+
if isinstance(byte_limit, int) and byte_limit > 0:
|
|
58
|
+
limit_mb = max(1, byte_limit // (1024 * 1024))
|
|
59
|
+
limit_text = f"{limit_mb}MB" if limit_mb else "the size limit"
|
|
60
|
+
aborted = bool(params.get("aborted"))
|
|
61
|
+
if aborted:
|
|
62
|
+
warning = (
|
|
63
|
+
f"Warning: Codex output exceeded {limit_text} and kept growing, "
|
|
64
|
+
"so CAR restarted the app-server to recover. Avoid huge stdout "
|
|
65
|
+
"(use head/tail, filters, or redirect to a file)."
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
warning = (
|
|
69
|
+
f"Warning: Codex output exceeded {limit_text} and was dropped to "
|
|
70
|
+
"keep the session alive. Avoid huge stdout (use head/tail, "
|
|
71
|
+
"filters, or redirect to a file)."
|
|
72
|
+
)
|
|
73
|
+
if len(warning) > TELEGRAM_MAX_MESSAGE_LENGTH:
|
|
74
|
+
warning = warning[: TELEGRAM_MAX_MESSAGE_LENGTH - 3].rstrip() + "..."
|
|
75
|
+
await self._send_message_with_outbox(
|
|
76
|
+
ctx.chat_id,
|
|
77
|
+
warning,
|
|
78
|
+
thread_id=ctx.thread_id,
|
|
79
|
+
reply_to=ctx.reply_to_message_id,
|
|
80
|
+
placeholder_id=ctx.placeholder_message_id,
|
|
81
|
+
)
|
|
82
|
+
return
|
|
83
|
+
if method == "thread/tokenUsage/updated":
|
|
84
|
+
thread_id = params.get("threadId")
|
|
85
|
+
turn_id = _coerce_id(params.get("turnId"))
|
|
86
|
+
token_usage = params.get("tokenUsage")
|
|
87
|
+
if not isinstance(thread_id, str) or not isinstance(token_usage, dict):
|
|
88
|
+
return
|
|
89
|
+
self._token_usage_by_thread[thread_id] = token_usage
|
|
90
|
+
self._token_usage_by_thread.move_to_end(thread_id)
|
|
91
|
+
while len(self._token_usage_by_thread) > TOKEN_USAGE_CACHE_LIMIT:
|
|
92
|
+
self._token_usage_by_thread.popitem(last=False)
|
|
93
|
+
if turn_id:
|
|
94
|
+
self._token_usage_by_turn[turn_id] = token_usage
|
|
95
|
+
self._token_usage_by_turn.move_to_end(turn_id)
|
|
96
|
+
while len(self._token_usage_by_turn) > TOKEN_USAGE_TURN_CACHE_LIMIT:
|
|
97
|
+
self._token_usage_by_turn.popitem(last=False)
|
|
98
|
+
return
|
|
99
|
+
if method == "item/reasoning/summaryTextDelta":
|
|
100
|
+
item_id = _coerce_id(params.get("itemId"))
|
|
101
|
+
turn_id = _coerce_id(params.get("turnId"))
|
|
102
|
+
thread_id = _extract_turn_thread_id(params)
|
|
103
|
+
delta = params.get("delta")
|
|
104
|
+
if not item_id or not turn_id or not isinstance(delta, str):
|
|
105
|
+
return
|
|
106
|
+
buffer = self._reasoning_buffers.get(item_id, "")
|
|
107
|
+
buffer = f"{buffer}{delta}"
|
|
108
|
+
self._reasoning_buffers[item_id] = buffer
|
|
109
|
+
self._touch_cache_timestamp("reasoning_buffers", item_id)
|
|
110
|
+
preview = _extract_first_bold_span(buffer)
|
|
111
|
+
if preview:
|
|
112
|
+
await self._update_placeholder_preview(
|
|
113
|
+
turn_id, preview, thread_id=thread_id
|
|
114
|
+
)
|
|
115
|
+
return
|
|
116
|
+
if method == "item/reasoning/summaryPartAdded":
|
|
117
|
+
item_id = _coerce_id(params.get("itemId"))
|
|
118
|
+
if not item_id:
|
|
119
|
+
return
|
|
120
|
+
buffer = self._reasoning_buffers.get(item_id, "")
|
|
121
|
+
buffer = f"{buffer}\n\n"
|
|
122
|
+
self._reasoning_buffers[item_id] = buffer
|
|
123
|
+
self._touch_cache_timestamp("reasoning_buffers", item_id)
|
|
124
|
+
return
|
|
125
|
+
if method == "item/completed":
|
|
126
|
+
item = params.get("item") if isinstance(params, dict) else None
|
|
127
|
+
if not isinstance(item, dict) or item.get("type") != "reasoning":
|
|
128
|
+
return
|
|
129
|
+
item_id = _coerce_id(item.get("id") or params.get("itemId"))
|
|
130
|
+
if item_id:
|
|
131
|
+
self._reasoning_buffers.pop(item_id, None)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
async def _update_placeholder_preview(
|
|
135
|
+
self, turn_id: str, preview: str, *, thread_id: Optional[str] = None
|
|
136
|
+
) -> None:
|
|
137
|
+
turn_key = self._resolve_turn_key(turn_id, thread_id=thread_id)
|
|
138
|
+
if turn_key is None:
|
|
139
|
+
return
|
|
140
|
+
ctx = self._turn_contexts.get(turn_key)
|
|
141
|
+
if ctx is None or ctx.placeholder_message_id is None:
|
|
142
|
+
return
|
|
143
|
+
normalized = " ".join(preview.split()).strip()
|
|
144
|
+
if not normalized:
|
|
145
|
+
return
|
|
146
|
+
normalized = _truncate_text(normalized, THINKING_PREVIEW_MAX_LEN)
|
|
147
|
+
if normalized == self._turn_preview_text.get(turn_key):
|
|
148
|
+
return
|
|
149
|
+
now = time.monotonic()
|
|
150
|
+
last_updated = self._turn_preview_updated_at.get(turn_key, 0.0)
|
|
151
|
+
if (now - last_updated) < THINKING_PREVIEW_MIN_EDIT_INTERVAL_SECONDS:
|
|
152
|
+
return
|
|
153
|
+
self._turn_preview_text[turn_key] = normalized
|
|
154
|
+
self._turn_preview_updated_at[turn_key] = now
|
|
155
|
+
self._touch_cache_timestamp("turn_preview", turn_key)
|
|
156
|
+
if STREAM_PREVIEW_PREFIX:
|
|
157
|
+
message_text = f"{STREAM_PREVIEW_PREFIX} {normalized}"
|
|
158
|
+
else:
|
|
159
|
+
message_text = normalized
|
|
160
|
+
await self._edit_message_text(
|
|
161
|
+
ctx.chat_id,
|
|
162
|
+
ctx.placeholder_message_id,
|
|
163
|
+
message_text,
|
|
164
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Awaitable, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from ...core.logging_utils import log_event
|
|
8
|
+
from ...core.state import now_iso
|
|
9
|
+
from .constants import (
|
|
10
|
+
OUTBOX_IMMEDIATE_RETRY_DELAYS,
|
|
11
|
+
OUTBOX_MAX_ATTEMPTS,
|
|
12
|
+
OUTBOX_RETRY_INTERVAL_SECONDS,
|
|
13
|
+
)
|
|
14
|
+
from .state import OutboxRecord, TelegramStateStore
|
|
15
|
+
|
|
16
|
+
SendMessageFn = Callable[..., Awaitable[None]]
|
|
17
|
+
EditMessageFn = Callable[..., Awaitable[bool]]
|
|
18
|
+
DeleteMessageFn = Callable[..., Awaitable[bool]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TelegramOutboxManager:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
store: TelegramStateStore,
|
|
25
|
+
*,
|
|
26
|
+
send_message: SendMessageFn,
|
|
27
|
+
edit_message_text: EditMessageFn,
|
|
28
|
+
delete_message: DeleteMessageFn,
|
|
29
|
+
logger: logging.Logger,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._store = store
|
|
32
|
+
self._send_message = send_message
|
|
33
|
+
self._edit_message_text = edit_message_text
|
|
34
|
+
self._delete_message = delete_message
|
|
35
|
+
self._logger = logger
|
|
36
|
+
self._inflight: set[str] = set()
|
|
37
|
+
self._lock: Optional[asyncio.Lock] = None
|
|
38
|
+
|
|
39
|
+
def start(self) -> None:
|
|
40
|
+
self._inflight = set()
|
|
41
|
+
self._lock = asyncio.Lock()
|
|
42
|
+
|
|
43
|
+
async def restore(self) -> None:
|
|
44
|
+
records = self._store.list_outbox()
|
|
45
|
+
if not records:
|
|
46
|
+
return
|
|
47
|
+
log_event(
|
|
48
|
+
self._logger,
|
|
49
|
+
logging.INFO,
|
|
50
|
+
"telegram.outbox.restore",
|
|
51
|
+
count=len(records),
|
|
52
|
+
)
|
|
53
|
+
await self._flush(records)
|
|
54
|
+
|
|
55
|
+
async def run_loop(self) -> None:
|
|
56
|
+
while True:
|
|
57
|
+
await asyncio.sleep(OUTBOX_RETRY_INTERVAL_SECONDS)
|
|
58
|
+
try:
|
|
59
|
+
records = self._store.list_outbox()
|
|
60
|
+
if records:
|
|
61
|
+
await self._flush(records)
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
log_event(
|
|
64
|
+
self._logger,
|
|
65
|
+
logging.WARNING,
|
|
66
|
+
"telegram.outbox.flush_failed",
|
|
67
|
+
exc=exc,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def send_message_with_outbox(
|
|
71
|
+
self,
|
|
72
|
+
record: OutboxRecord,
|
|
73
|
+
) -> bool:
|
|
74
|
+
self._store.enqueue_outbox(record)
|
|
75
|
+
log_event(
|
|
76
|
+
self._logger,
|
|
77
|
+
logging.INFO,
|
|
78
|
+
"telegram.outbox.enqueued",
|
|
79
|
+
record_id=record.record_id,
|
|
80
|
+
chat_id=record.chat_id,
|
|
81
|
+
thread_id=record.thread_id,
|
|
82
|
+
)
|
|
83
|
+
for delay in OUTBOX_IMMEDIATE_RETRY_DELAYS:
|
|
84
|
+
if await self._attempt_send(record):
|
|
85
|
+
return True
|
|
86
|
+
current = self._store.get_outbox(record.record_id)
|
|
87
|
+
if current is None:
|
|
88
|
+
return False
|
|
89
|
+
if current.attempts >= OUTBOX_MAX_ATTEMPTS:
|
|
90
|
+
return False
|
|
91
|
+
await asyncio.sleep(delay)
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
async def _flush(self, records: list[OutboxRecord]) -> None:
|
|
95
|
+
for record in records:
|
|
96
|
+
if record.attempts >= OUTBOX_MAX_ATTEMPTS:
|
|
97
|
+
log_event(
|
|
98
|
+
self._logger,
|
|
99
|
+
logging.WARNING,
|
|
100
|
+
"telegram.outbox.gave_up",
|
|
101
|
+
record_id=record.record_id,
|
|
102
|
+
chat_id=record.chat_id,
|
|
103
|
+
thread_id=record.thread_id,
|
|
104
|
+
attempts=record.attempts,
|
|
105
|
+
)
|
|
106
|
+
self._store.delete_outbox(record.record_id)
|
|
107
|
+
if record.placeholder_message_id is not None:
|
|
108
|
+
await self._edit_message_text(
|
|
109
|
+
record.chat_id,
|
|
110
|
+
record.placeholder_message_id,
|
|
111
|
+
"Delivery failed after retries. Please resend.",
|
|
112
|
+
)
|
|
113
|
+
continue
|
|
114
|
+
await self._attempt_send(record)
|
|
115
|
+
|
|
116
|
+
async def _attempt_send(self, record: OutboxRecord) -> bool:
|
|
117
|
+
current = self._store.get_outbox(record.record_id)
|
|
118
|
+
if current is None:
|
|
119
|
+
return False
|
|
120
|
+
record = current
|
|
121
|
+
if not await self._mark_inflight(record.record_id):
|
|
122
|
+
return False
|
|
123
|
+
try:
|
|
124
|
+
await self._send_message(
|
|
125
|
+
record.chat_id,
|
|
126
|
+
record.text,
|
|
127
|
+
thread_id=record.thread_id,
|
|
128
|
+
reply_to=record.reply_to_message_id,
|
|
129
|
+
)
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
record.attempts += 1
|
|
132
|
+
record.last_error = str(exc)[:500]
|
|
133
|
+
record.last_attempt_at = now_iso()
|
|
134
|
+
self._store.update_outbox(record)
|
|
135
|
+
log_event(
|
|
136
|
+
self._logger,
|
|
137
|
+
logging.WARNING,
|
|
138
|
+
"telegram.outbox.send_failed",
|
|
139
|
+
record_id=record.record_id,
|
|
140
|
+
chat_id=record.chat_id,
|
|
141
|
+
thread_id=record.thread_id,
|
|
142
|
+
attempts=record.attempts,
|
|
143
|
+
exc=exc,
|
|
144
|
+
)
|
|
145
|
+
return False
|
|
146
|
+
finally:
|
|
147
|
+
await self._clear_inflight(record.record_id)
|
|
148
|
+
self._store.delete_outbox(record.record_id)
|
|
149
|
+
if record.placeholder_message_id is not None:
|
|
150
|
+
await self._delete_message(record.chat_id, record.placeholder_message_id)
|
|
151
|
+
log_event(
|
|
152
|
+
self._logger,
|
|
153
|
+
logging.INFO,
|
|
154
|
+
"telegram.outbox.delivered",
|
|
155
|
+
record_id=record.record_id,
|
|
156
|
+
chat_id=record.chat_id,
|
|
157
|
+
thread_id=record.thread_id,
|
|
158
|
+
)
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
async def _mark_inflight(self, record_id: str) -> bool:
|
|
162
|
+
if self._lock is None:
|
|
163
|
+
self._lock = asyncio.Lock()
|
|
164
|
+
async with self._lock:
|
|
165
|
+
if record_id in self._inflight:
|
|
166
|
+
return False
|
|
167
|
+
self._inflight.add(record_id)
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
async def _clear_inflight(self, record_id: str) -> None:
|
|
171
|
+
if self._lock is None:
|
|
172
|
+
return
|
|
173
|
+
async with self._lock:
|
|
174
|
+
self._inflight.discard(record_id)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
_CODE_BLOCK_RE = re.compile(r"```(?:[^\n`]*)\n(.*?)```", re.DOTALL)
|
|
7
|
+
_INLINE_CODE_RE = re.compile(r"`([^`\n]+)`")
|
|
8
|
+
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
|
|
9
|
+
_MARKDOWN_ESCAPE_RE = re.compile(r"([_*\[\]\(\)`])")
|
|
10
|
+
_MARKDOWN_V2_ESCAPE_RE = re.compile(r"([_*\[\]\(\)~`>#+\-=|{}.!\\])")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _format_telegram_html(text: str) -> str:
|
|
14
|
+
if not text:
|
|
15
|
+
return ""
|
|
16
|
+
parts: list[str] = []
|
|
17
|
+
last = 0
|
|
18
|
+
for match in _CODE_BLOCK_RE.finditer(text):
|
|
19
|
+
parts.append(_format_telegram_inline(text[last : match.start()]))
|
|
20
|
+
code = match.group(1)
|
|
21
|
+
parts.append("<pre><code>")
|
|
22
|
+
parts.append(html.escape(code, quote=False))
|
|
23
|
+
parts.append("</code></pre>")
|
|
24
|
+
last = match.end()
|
|
25
|
+
parts.append(_format_telegram_inline(text[last:]))
|
|
26
|
+
return "".join(parts)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _format_telegram_inline(text: str) -> str:
|
|
30
|
+
if not text:
|
|
31
|
+
return ""
|
|
32
|
+
placeholders: list[str] = []
|
|
33
|
+
|
|
34
|
+
def _replace_code(match: re.Match[str]) -> str:
|
|
35
|
+
placeholders.append(html.escape(match.group(1), quote=False))
|
|
36
|
+
return f"\x00CODE{len(placeholders) - 1}\x00"
|
|
37
|
+
|
|
38
|
+
text = _INLINE_CODE_RE.sub(_replace_code, text)
|
|
39
|
+
escaped = html.escape(text, quote=False)
|
|
40
|
+
escaped = _BOLD_RE.sub(lambda match: f"<b>{match.group(1)}</b>", escaped)
|
|
41
|
+
for idx, code in enumerate(placeholders):
|
|
42
|
+
token = f"\x00CODE{idx}\x00"
|
|
43
|
+
escaped = escaped.replace(token, f"<code>{code}</code>")
|
|
44
|
+
return escaped
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _escape_markdown_text(text: str, *, version: str) -> str:
|
|
48
|
+
if not text:
|
|
49
|
+
return ""
|
|
50
|
+
if version == "MarkdownV2":
|
|
51
|
+
return _MARKDOWN_V2_ESCAPE_RE.sub(r"\\\1", text)
|
|
52
|
+
return _MARKDOWN_ESCAPE_RE.sub(r"\\\1", text)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _escape_markdown_code(text: str, *, version: str) -> str:
|
|
56
|
+
if not text:
|
|
57
|
+
return ""
|
|
58
|
+
if version == "MarkdownV2":
|
|
59
|
+
return text.replace("\\", "\\\\").replace("`", "\\`")
|
|
60
|
+
return text.replace("`", "\\`")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _format_telegram_markdown(text: str, version: str) -> str:
|
|
64
|
+
if not text:
|
|
65
|
+
return ""
|
|
66
|
+
parts: list[str] = []
|
|
67
|
+
last = 0
|
|
68
|
+
for match in _CODE_BLOCK_RE.finditer(text):
|
|
69
|
+
parts.append(
|
|
70
|
+
_format_telegram_markdown_inline(text[last : match.start()], version)
|
|
71
|
+
)
|
|
72
|
+
code = _escape_markdown_code(match.group(1), version=version)
|
|
73
|
+
parts.append(f"```\n{code}\n```")
|
|
74
|
+
last = match.end()
|
|
75
|
+
parts.append(_format_telegram_markdown_inline(text[last:], version))
|
|
76
|
+
return "".join(parts)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _format_telegram_markdown_inline(text: str, version: str) -> str:
|
|
80
|
+
if not text:
|
|
81
|
+
return ""
|
|
82
|
+
code_placeholders: list[str] = []
|
|
83
|
+
bold_placeholders: list[str] = []
|
|
84
|
+
|
|
85
|
+
def _replace_code(match: re.Match[str]) -> str:
|
|
86
|
+
code_placeholders.append(_escape_markdown_code(match.group(1), version=version))
|
|
87
|
+
return f"\x00CODE{len(code_placeholders) - 1}\x00"
|
|
88
|
+
|
|
89
|
+
def _replace_bold(match: re.Match[str]) -> str:
|
|
90
|
+
bold_placeholders.append(_escape_markdown_text(match.group(1), version=version))
|
|
91
|
+
return f"\x00BOLD{len(bold_placeholders) - 1}\x00"
|
|
92
|
+
|
|
93
|
+
text = _INLINE_CODE_RE.sub(_replace_code, text)
|
|
94
|
+
text = _BOLD_RE.sub(_replace_bold, text)
|
|
95
|
+
escaped = _escape_markdown_text(text, version=version)
|
|
96
|
+
for idx, bold in enumerate(bold_placeholders):
|
|
97
|
+
token = f"\x00BOLD{idx}\x00"
|
|
98
|
+
escaped = escaped.replace(token, f"*{bold}*")
|
|
99
|
+
for idx, code in enumerate(code_placeholders):
|
|
100
|
+
token = f"\x00CODE{idx}\x00"
|
|
101
|
+
escaped = escaped.replace(token, f"`{code}`")
|
|
102
|
+
return escaped
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _extract_retry_after_seconds(exc: Exception) -> Optional[int]:
|
|
10
|
+
current: Optional[BaseException] = exc
|
|
11
|
+
while current is not None:
|
|
12
|
+
if isinstance(current, httpx.HTTPStatusError):
|
|
13
|
+
header = current.response.headers.get("Retry-After")
|
|
14
|
+
if header and header.isdigit():
|
|
15
|
+
return int(header)
|
|
16
|
+
try:
|
|
17
|
+
payload = current.response.json()
|
|
18
|
+
except Exception:
|
|
19
|
+
payload = None
|
|
20
|
+
if isinstance(payload, dict):
|
|
21
|
+
parameters = payload.get("parameters")
|
|
22
|
+
if isinstance(parameters, dict):
|
|
23
|
+
retry_after = parameters.get("retry_after")
|
|
24
|
+
if isinstance(retry_after, int):
|
|
25
|
+
return retry_after
|
|
26
|
+
message = (
|
|
27
|
+
str(payload.get("description")) if isinstance(payload, dict) else ""
|
|
28
|
+
)
|
|
29
|
+
match = re.search(r"retry after (\d+)", message.lower())
|
|
30
|
+
if match:
|
|
31
|
+
return int(match.group(1))
|
|
32
|
+
message = str(current)
|
|
33
|
+
match = re.search(r"retry after (\d+)", message.lower())
|
|
34
|
+
if match:
|
|
35
|
+
return int(match.group(1))
|
|
36
|
+
current = current.__cause__ or current.__context__
|
|
37
|
+
return None
|