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