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,1401 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Awaitable, Callable, Iterable, Optional, Sequence, Union
7
+
8
+ import httpx
9
+
10
+ from ...core.logging_utils import log_event
11
+ from .constants import TELEGRAM_CALLBACK_DATA_LIMIT, TELEGRAM_MAX_MESSAGE_LENGTH
12
+ from .retry import _extract_retry_after_seconds
13
+
14
+ _RATE_LIMIT_BUFFER_SECONDS = 0.0
15
+
16
+ INTERRUPT_ALIASES = {
17
+ "^c",
18
+ "ctrl-c",
19
+ "ctrl+c",
20
+ "esc",
21
+ "escape",
22
+ "/stop",
23
+ }
24
+
25
+
26
+ class TelegramAPIError(Exception):
27
+ """Raised when the Telegram Bot API returns an error."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class TelegramPhotoSize:
32
+ file_id: str
33
+ file_unique_id: Optional[str]
34
+ width: int
35
+ height: int
36
+ file_size: Optional[int]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class TelegramDocument:
41
+ file_id: str
42
+ file_unique_id: Optional[str]
43
+ file_name: Optional[str]
44
+ mime_type: Optional[str]
45
+ file_size: Optional[int]
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class TelegramAudio:
50
+ file_id: str
51
+ file_unique_id: Optional[str]
52
+ duration: Optional[int]
53
+ file_name: Optional[str]
54
+ mime_type: Optional[str]
55
+ file_size: Optional[int]
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class TelegramVoice:
60
+ file_id: str
61
+ file_unique_id: Optional[str]
62
+ duration: Optional[int]
63
+ mime_type: Optional[str]
64
+ file_size: Optional[int]
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class TelegramMessageEntity:
69
+ type: str
70
+ offset: int
71
+ length: int
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class TelegramMessage:
76
+ update_id: int
77
+ message_id: int
78
+ chat_id: int
79
+ thread_id: Optional[int]
80
+ from_user_id: Optional[int]
81
+ text: Optional[str]
82
+ date: Optional[int]
83
+ is_topic_message: bool
84
+ is_edited: bool = False
85
+ caption: Optional[str] = None
86
+ entities: tuple[TelegramMessageEntity, ...] = field(default_factory=tuple)
87
+ caption_entities: tuple[TelegramMessageEntity, ...] = field(default_factory=tuple)
88
+ photos: tuple[TelegramPhotoSize, ...] = field(default_factory=tuple)
89
+ document: Optional[TelegramDocument] = None
90
+ audio: Optional[TelegramAudio] = None
91
+ voice: Optional[TelegramVoice] = None
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class TelegramCallbackQuery:
96
+ update_id: int
97
+ callback_id: str
98
+ from_user_id: Optional[int]
99
+ data: Optional[str]
100
+ message_id: Optional[int]
101
+ chat_id: Optional[int]
102
+ thread_id: Optional[int]
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class TelegramUpdate:
107
+ update_id: int
108
+ message: Optional[TelegramMessage]
109
+ callback: Optional[TelegramCallbackQuery]
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class TelegramCommand:
114
+ name: str
115
+ args: str
116
+ raw: str
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class TelegramAllowlist:
121
+ allowed_chat_ids: set[int]
122
+ allowed_user_ids: set[int]
123
+ require_topic: bool = False
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class InlineButton:
128
+ text: str
129
+ callback_data: str
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class ApprovalCallback:
134
+ decision: str
135
+ request_id: str
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class ResumeCallback:
140
+ thread_id: str
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class BindCallback:
145
+ repo_id: str
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class ModelCallback:
150
+ model_id: str
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class EffortCallback:
155
+ effort: str
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class UpdateCallback:
160
+ target: str
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class UpdateConfirmCallback:
165
+ decision: str
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class ReviewCommitCallback:
170
+ sha: str
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class CancelCallback:
175
+ kind: str
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class CompactCallback:
180
+ action: str
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class PageCallback:
185
+ kind: str
186
+ page: int
187
+
188
+
189
+ def parse_command(
190
+ text: Optional[str],
191
+ *,
192
+ entities: Optional[Sequence[TelegramMessageEntity]] = None,
193
+ bot_username: Optional[str] = None,
194
+ ) -> Optional[TelegramCommand]:
195
+ if not text:
196
+ return None
197
+ if not entities:
198
+ return None
199
+ command_entity = next(
200
+ (
201
+ entity
202
+ for entity in entities
203
+ if entity.type == "bot_command" and entity.offset == 0
204
+ ),
205
+ None,
206
+ )
207
+ if command_entity is None:
208
+ return None
209
+ if command_entity.length <= 1:
210
+ return None
211
+ if command_entity.length > len(text):
212
+ return None
213
+ command_text = text[: command_entity.length]
214
+ if not command_text.startswith("/"):
215
+ return None
216
+ command = command_text.lstrip("/")
217
+ tail = text[command_entity.length :].strip()
218
+ if not command:
219
+ return None
220
+ if "@" in command:
221
+ name, _, target = command.partition("@")
222
+ if bot_username and target.lower() != bot_username.lower():
223
+ return None
224
+ command = name
225
+ return TelegramCommand(name=command.lower(), args=tail.strip(), raw=text.strip())
226
+
227
+
228
+ def is_interrupt_alias(text: Optional[str]) -> bool:
229
+ if not text:
230
+ return False
231
+ normalized = text.strip().lower()
232
+ if normalized in INTERRUPT_ALIASES:
233
+ return True
234
+ return normalized == "/interrupt"
235
+
236
+
237
+ def parse_update(update: dict[str, Any]) -> Optional[TelegramUpdate]:
238
+ update_id = update.get("update_id")
239
+ if not isinstance(update_id, int):
240
+ return None
241
+ message = _parse_message(update_id, update.get("message"), edited=False)
242
+ if message is None:
243
+ message = _parse_message(update_id, update.get("edited_message"), edited=True)
244
+ callback = _parse_callback(update_id, update.get("callback_query"))
245
+ if message is None and callback is None:
246
+ return None
247
+ return TelegramUpdate(update_id=update_id, message=message, callback=callback)
248
+
249
+
250
+ def _parse_message(
251
+ update_id: int, payload: Any, *, edited: bool = False
252
+ ) -> Optional[TelegramMessage]:
253
+ if not isinstance(payload, dict):
254
+ return None
255
+ message_id = payload.get("message_id")
256
+ chat = payload.get("chat")
257
+ if not isinstance(message_id, int) or not isinstance(chat, dict):
258
+ return None
259
+ chat_id = chat.get("id")
260
+ if not isinstance(chat_id, int):
261
+ return None
262
+ thread_id = payload.get("message_thread_id")
263
+ if thread_id is not None and not isinstance(thread_id, int):
264
+ thread_id = None
265
+ sender = payload.get("from")
266
+ from_user_id = sender.get("id") if isinstance(sender, dict) else None
267
+ if from_user_id is not None and not isinstance(from_user_id, int):
268
+ from_user_id = None
269
+ text = payload.get("text")
270
+ if text is not None and not isinstance(text, str):
271
+ text = None
272
+ caption = payload.get("caption")
273
+ if caption is not None and not isinstance(caption, str):
274
+ caption = None
275
+ entities = _parse_entities(payload.get("entities"))
276
+ caption_entities = _parse_entities(payload.get("caption_entities"))
277
+ photos = _parse_photo_sizes(payload.get("photo"))
278
+ document = _parse_document(payload.get("document"))
279
+ audio = _parse_audio(payload.get("audio"))
280
+ voice = _parse_voice(payload.get("voice"))
281
+ date = payload.get("date")
282
+ if date is not None and not isinstance(date, int):
283
+ date = None
284
+ is_topic_message = bool(payload.get("is_topic_message"))
285
+ return TelegramMessage(
286
+ update_id=update_id,
287
+ message_id=message_id,
288
+ chat_id=chat_id,
289
+ thread_id=thread_id,
290
+ from_user_id=from_user_id,
291
+ text=text,
292
+ date=date,
293
+ is_topic_message=is_topic_message,
294
+ is_edited=edited,
295
+ caption=caption,
296
+ entities=entities,
297
+ caption_entities=caption_entities,
298
+ photos=photos,
299
+ document=document,
300
+ audio=audio,
301
+ voice=voice,
302
+ )
303
+
304
+
305
+ def _parse_callback(update_id: int, payload: Any) -> Optional[TelegramCallbackQuery]:
306
+ if not isinstance(payload, dict):
307
+ return None
308
+ callback_id = payload.get("id")
309
+ if not isinstance(callback_id, str):
310
+ return None
311
+ sender = payload.get("from")
312
+ from_user_id = sender.get("id") if isinstance(sender, dict) else None
313
+ if from_user_id is not None and not isinstance(from_user_id, int):
314
+ from_user_id = None
315
+ data = payload.get("data")
316
+ if data is not None and not isinstance(data, str):
317
+ data = None
318
+ message = payload.get("message")
319
+ message_id = None
320
+ chat_id = None
321
+ thread_id = None
322
+ if isinstance(message, dict):
323
+ message_id = message.get("message_id")
324
+ chat = message.get("chat")
325
+ if isinstance(chat, dict):
326
+ chat_id = chat.get("id")
327
+ thread_id = message.get("message_thread_id")
328
+ if message_id is not None and not isinstance(message_id, int):
329
+ message_id = None
330
+ if chat_id is not None and not isinstance(chat_id, int):
331
+ chat_id = None
332
+ if thread_id is not None and not isinstance(thread_id, int):
333
+ thread_id = None
334
+ return TelegramCallbackQuery(
335
+ update_id=update_id,
336
+ callback_id=callback_id,
337
+ from_user_id=from_user_id,
338
+ data=data,
339
+ message_id=message_id,
340
+ chat_id=chat_id,
341
+ thread_id=thread_id,
342
+ )
343
+
344
+
345
+ def _parse_photo_sizes(payload: Any) -> tuple[TelegramPhotoSize, ...]:
346
+ if not isinstance(payload, list):
347
+ return ()
348
+ sizes: list[TelegramPhotoSize] = []
349
+ for item in payload:
350
+ if not isinstance(item, dict):
351
+ continue
352
+ file_id = item.get("file_id")
353
+ if not isinstance(file_id, str) or not file_id:
354
+ continue
355
+ file_unique_id = item.get("file_unique_id")
356
+ if file_unique_id is not None and not isinstance(file_unique_id, str):
357
+ file_unique_id = None
358
+ width = item.get("width")
359
+ height = item.get("height")
360
+ if not isinstance(width, int) or not isinstance(height, int):
361
+ continue
362
+ file_size = item.get("file_size")
363
+ if file_size is not None and not isinstance(file_size, int):
364
+ file_size = None
365
+ sizes.append(
366
+ TelegramPhotoSize(
367
+ file_id=file_id,
368
+ file_unique_id=file_unique_id,
369
+ width=width,
370
+ height=height,
371
+ file_size=file_size,
372
+ )
373
+ )
374
+ return tuple(sizes)
375
+
376
+
377
+ def _parse_document(payload: Any) -> Optional[TelegramDocument]:
378
+ if not isinstance(payload, dict):
379
+ return None
380
+ file_id = payload.get("file_id")
381
+ if not isinstance(file_id, str) or not file_id:
382
+ return None
383
+ file_unique_id = payload.get("file_unique_id")
384
+ if file_unique_id is not None and not isinstance(file_unique_id, str):
385
+ file_unique_id = None
386
+ file_name = payload.get("file_name")
387
+ if file_name is not None and not isinstance(file_name, str):
388
+ file_name = None
389
+ mime_type = payload.get("mime_type")
390
+ if mime_type is not None and not isinstance(mime_type, str):
391
+ mime_type = None
392
+ file_size = payload.get("file_size")
393
+ if file_size is not None and not isinstance(file_size, int):
394
+ file_size = None
395
+ return TelegramDocument(
396
+ file_id=file_id,
397
+ file_unique_id=file_unique_id,
398
+ file_name=file_name,
399
+ mime_type=mime_type,
400
+ file_size=file_size,
401
+ )
402
+
403
+
404
+ def _parse_audio(payload: Any) -> Optional[TelegramAudio]:
405
+ if not isinstance(payload, dict):
406
+ return None
407
+ file_id = payload.get("file_id")
408
+ if not isinstance(file_id, str) or not file_id:
409
+ return None
410
+ file_unique_id = payload.get("file_unique_id")
411
+ if file_unique_id is not None and not isinstance(file_unique_id, str):
412
+ file_unique_id = None
413
+ duration = payload.get("duration")
414
+ if duration is not None and not isinstance(duration, int):
415
+ duration = None
416
+ file_name = payload.get("file_name")
417
+ if file_name is not None and not isinstance(file_name, str):
418
+ file_name = None
419
+ mime_type = payload.get("mime_type")
420
+ if mime_type is not None and not isinstance(mime_type, str):
421
+ mime_type = None
422
+ file_size = payload.get("file_size")
423
+ if file_size is not None and not isinstance(file_size, int):
424
+ file_size = None
425
+ return TelegramAudio(
426
+ file_id=file_id,
427
+ file_unique_id=file_unique_id,
428
+ duration=duration,
429
+ file_name=file_name,
430
+ mime_type=mime_type,
431
+ file_size=file_size,
432
+ )
433
+
434
+
435
+ def _parse_voice(payload: Any) -> Optional[TelegramVoice]:
436
+ if not isinstance(payload, dict):
437
+ return None
438
+ file_id = payload.get("file_id")
439
+ if not isinstance(file_id, str) or not file_id:
440
+ return None
441
+ file_unique_id = payload.get("file_unique_id")
442
+ if file_unique_id is not None and not isinstance(file_unique_id, str):
443
+ file_unique_id = None
444
+ duration = payload.get("duration")
445
+ if duration is not None and not isinstance(duration, int):
446
+ duration = None
447
+ mime_type = payload.get("mime_type")
448
+ if mime_type is not None and not isinstance(mime_type, str):
449
+ mime_type = None
450
+ file_size = payload.get("file_size")
451
+ if file_size is not None and not isinstance(file_size, int):
452
+ file_size = None
453
+ return TelegramVoice(
454
+ file_id=file_id,
455
+ file_unique_id=file_unique_id,
456
+ duration=duration,
457
+ mime_type=mime_type,
458
+ file_size=file_size,
459
+ )
460
+
461
+
462
+ def _parse_entities(payload: Any) -> tuple[TelegramMessageEntity, ...]:
463
+ if not isinstance(payload, list):
464
+ return ()
465
+ entities: list[TelegramMessageEntity] = []
466
+ for item in payload:
467
+ if not isinstance(item, dict):
468
+ continue
469
+ kind = item.get("type")
470
+ offset = item.get("offset")
471
+ length = item.get("length")
472
+ if not isinstance(kind, str):
473
+ continue
474
+ if not isinstance(offset, int) or not isinstance(length, int):
475
+ continue
476
+ entities.append(TelegramMessageEntity(type=kind, offset=offset, length=length))
477
+ return tuple(entities)
478
+
479
+
480
+ def allowlist_allows(update: TelegramUpdate, allowlist: TelegramAllowlist) -> bool:
481
+ if not allowlist.allowed_chat_ids or not allowlist.allowed_user_ids:
482
+ return False
483
+ chat_id = None
484
+ user_id = None
485
+ thread_id = None
486
+ if update.message:
487
+ chat_id = update.message.chat_id
488
+ user_id = update.message.from_user_id
489
+ thread_id = update.message.thread_id
490
+ elif update.callback:
491
+ chat_id = update.callback.chat_id
492
+ user_id = update.callback.from_user_id
493
+ thread_id = update.callback.thread_id
494
+ if chat_id is None or user_id is None:
495
+ return False
496
+ if chat_id not in allowlist.allowed_chat_ids:
497
+ return False
498
+ if user_id not in allowlist.allowed_user_ids:
499
+ return False
500
+ if allowlist.require_topic and thread_id is None:
501
+ return False
502
+ return True
503
+
504
+
505
+ def chunk_message(
506
+ text: Optional[str],
507
+ *,
508
+ max_len: int = TELEGRAM_MAX_MESSAGE_LENGTH,
509
+ with_numbering: bool = True,
510
+ ) -> list[str]:
511
+ if not text:
512
+ return []
513
+ if max_len <= 0:
514
+ raise ValueError("max_len must be positive")
515
+ if len(text) <= max_len:
516
+ return [text]
517
+ parts = _split_text(text, max_len)
518
+ if not with_numbering or len(parts) == 1:
519
+ return parts
520
+ parts = _apply_numbering(text, max_len)
521
+ return parts
522
+
523
+
524
+ def _apply_numbering(text: str, max_len: int) -> list[str]:
525
+ parts = _split_text(text, max_len)
526
+ total = len(parts)
527
+ while True:
528
+ prefix_len = len(_part_prefix(total, total))
529
+ allowed = max_len - prefix_len
530
+ if allowed <= 0:
531
+ raise ValueError("max_len too small for numbering")
532
+ parts = _split_text(text, allowed)
533
+ new_total = len(parts)
534
+ if new_total == total:
535
+ break
536
+ total = new_total
537
+ return [f"{_part_prefix(idx, total)}{chunk}" for idx, chunk in enumerate(parts, 1)]
538
+
539
+
540
+ def _part_prefix(index: int, total: int) -> str:
541
+ return f"Part {index}/{total}\n"
542
+
543
+
544
+ def _split_text(text: str, limit: int) -> list[str]:
545
+ parts: list[str] = []
546
+ remaining = text
547
+ while remaining:
548
+ if len(remaining) <= limit:
549
+ parts.append(remaining)
550
+ break
551
+ cut = remaining.rfind("\n", 0, limit + 1)
552
+ if cut == -1:
553
+ cut = remaining.rfind(" ", 0, limit + 1)
554
+ if cut <= 0:
555
+ cut = limit
556
+ chunk = remaining[:cut]
557
+ remaining = remaining[cut:]
558
+ parts.append(chunk)
559
+ return parts
560
+
561
+
562
+ def build_inline_keyboard(
563
+ rows: Sequence[Sequence[InlineButton]],
564
+ ) -> dict[str, Any]:
565
+ keyboard: list[list[dict[str, str]]] = []
566
+ for row in rows:
567
+ keyboard.append(
568
+ [
569
+ {"text": button.text, "callback_data": button.callback_data}
570
+ for button in row
571
+ ]
572
+ )
573
+ return {"inline_keyboard": keyboard}
574
+
575
+
576
+ def encode_approval_callback(decision: str, request_id: str) -> str:
577
+ data = f"appr:{decision}:{request_id}"
578
+ _validate_callback_data(data)
579
+ return data
580
+
581
+
582
+ def encode_resume_callback(thread_id: str) -> str:
583
+ data = f"resume:{thread_id}"
584
+ _validate_callback_data(data)
585
+ return data
586
+
587
+
588
+ def encode_bind_callback(repo_id: str) -> str:
589
+ data = f"bind:{repo_id}"
590
+ _validate_callback_data(data)
591
+ return data
592
+
593
+
594
+ def encode_model_callback(model_id: str) -> str:
595
+ data = f"model:{model_id}"
596
+ _validate_callback_data(data)
597
+ return data
598
+
599
+
600
+ def encode_effort_callback(effort: str) -> str:
601
+ data = f"effort:{effort}"
602
+ _validate_callback_data(data)
603
+ return data
604
+
605
+
606
+ def encode_update_callback(target: str) -> str:
607
+ data = f"update:{target}"
608
+ _validate_callback_data(data)
609
+ return data
610
+
611
+
612
+ def encode_update_confirm_callback(decision: str) -> str:
613
+ data = f"update_confirm:{decision}"
614
+ _validate_callback_data(data)
615
+ return data
616
+
617
+
618
+ def encode_review_commit_callback(sha: str) -> str:
619
+ data = f"review_commit:{sha}"
620
+ _validate_callback_data(data)
621
+ return data
622
+
623
+
624
+ def encode_cancel_callback(kind: str) -> str:
625
+ data = f"cancel:{kind}"
626
+ _validate_callback_data(data)
627
+ return data
628
+
629
+
630
+ def encode_page_callback(kind: str, page: int) -> str:
631
+ data = f"page:{kind}:{page}"
632
+ _validate_callback_data(data)
633
+ return data
634
+
635
+
636
+ def encode_compact_callback(action: str) -> str:
637
+ data = f"compact:{action}"
638
+ _validate_callback_data(data)
639
+ return data
640
+
641
+
642
+ def parse_callback_data(
643
+ data: Optional[str],
644
+ ) -> Optional[
645
+ Union[
646
+ ApprovalCallback,
647
+ ResumeCallback,
648
+ BindCallback,
649
+ ModelCallback,
650
+ EffortCallback,
651
+ UpdateCallback,
652
+ UpdateConfirmCallback,
653
+ ReviewCommitCallback,
654
+ CancelCallback,
655
+ CompactCallback,
656
+ PageCallback,
657
+ ]
658
+ ]:
659
+ if not data:
660
+ return None
661
+ if data.startswith("appr:"):
662
+ _, _, rest = data.partition(":")
663
+ decision, sep, request_id = rest.partition(":")
664
+ if not decision or not sep or not request_id:
665
+ return None
666
+ return ApprovalCallback(decision=decision, request_id=request_id)
667
+ if data.startswith("resume:"):
668
+ _, _, thread_id = data.partition(":")
669
+ if not thread_id:
670
+ return None
671
+ return ResumeCallback(thread_id=thread_id)
672
+ if data.startswith("bind:"):
673
+ _, _, repo_id = data.partition(":")
674
+ if not repo_id:
675
+ return None
676
+ return BindCallback(repo_id=repo_id)
677
+ if data.startswith("model:"):
678
+ _, _, model_id = data.partition(":")
679
+ if not model_id:
680
+ return None
681
+ return ModelCallback(model_id=model_id)
682
+ if data.startswith("effort:"):
683
+ _, _, effort = data.partition(":")
684
+ if not effort:
685
+ return None
686
+ return EffortCallback(effort=effort)
687
+ if data.startswith("update:"):
688
+ _, _, target = data.partition(":")
689
+ if not target:
690
+ return None
691
+ return UpdateCallback(target=target)
692
+ if data.startswith("update_confirm:"):
693
+ _, _, decision = data.partition(":")
694
+ if not decision:
695
+ return None
696
+ return UpdateConfirmCallback(decision=decision)
697
+ if data.startswith("review_commit:"):
698
+ _, _, sha = data.partition(":")
699
+ if not sha:
700
+ return None
701
+ return ReviewCommitCallback(sha=sha)
702
+ if data.startswith("cancel:"):
703
+ _, _, kind = data.partition(":")
704
+ if not kind:
705
+ return None
706
+ return CancelCallback(kind=kind)
707
+ if data.startswith("compact:"):
708
+ _, _, action = data.partition(":")
709
+ if not action:
710
+ return None
711
+ return CompactCallback(action=action)
712
+ if data.startswith("page:"):
713
+ _, _, rest = data.partition(":")
714
+ kind, sep, page = rest.partition(":")
715
+ if not kind or not sep or not page:
716
+ return None
717
+ if not page.isdigit():
718
+ return None
719
+ return PageCallback(kind=kind, page=int(page))
720
+ return None
721
+
722
+
723
+ def build_approval_keyboard(
724
+ request_id: str, *, include_session: bool = False
725
+ ) -> dict[str, Any]:
726
+ rows: list[list[InlineButton]] = [
727
+ [
728
+ InlineButton("Accept", encode_approval_callback("accept", request_id)),
729
+ InlineButton("Decline", encode_approval_callback("decline", request_id)),
730
+ ],
731
+ [InlineButton("Cancel", encode_approval_callback("cancel", request_id))],
732
+ ]
733
+ if include_session:
734
+ rows[0].insert(
735
+ 1,
736
+ InlineButton(
737
+ "Accept session", encode_approval_callback("accept_session", request_id)
738
+ ),
739
+ )
740
+ return build_inline_keyboard(rows)
741
+
742
+
743
+ def build_resume_keyboard(
744
+ options: Sequence[tuple[str, str]],
745
+ *,
746
+ page_button: Optional[tuple[str, str]] = None,
747
+ include_cancel: bool = False,
748
+ ) -> dict[str, Any]:
749
+ rows = [
750
+ [InlineButton(label, encode_resume_callback(thread_id))]
751
+ for thread_id, label in options
752
+ ]
753
+ if page_button:
754
+ label, callback_data = page_button
755
+ rows.append([InlineButton(label, callback_data)])
756
+ if include_cancel:
757
+ rows.append([InlineButton("Cancel", encode_cancel_callback("resume"))])
758
+ return build_inline_keyboard(rows)
759
+
760
+
761
+ def build_model_keyboard(
762
+ options: Sequence[tuple[str, str]],
763
+ *,
764
+ page_button: Optional[tuple[str, str]] = None,
765
+ include_cancel: bool = False,
766
+ ) -> dict[str, Any]:
767
+ rows = [
768
+ [InlineButton(label, encode_model_callback(model_id))]
769
+ for model_id, label in options
770
+ ]
771
+ if page_button:
772
+ label, callback_data = page_button
773
+ rows.append([InlineButton(label, callback_data)])
774
+ if include_cancel:
775
+ rows.append([InlineButton("Cancel", encode_cancel_callback("model"))])
776
+ return build_inline_keyboard(rows)
777
+
778
+
779
+ def build_compact_keyboard() -> dict[str, Any]:
780
+ rows = [
781
+ [
782
+ InlineButton(
783
+ "Start new thread with this summary",
784
+ encode_compact_callback("apply"),
785
+ )
786
+ ],
787
+ [InlineButton("Cancel", encode_compact_callback("cancel"))],
788
+ ]
789
+ return build_inline_keyboard(rows)
790
+
791
+
792
+ def build_effort_keyboard(
793
+ options: Sequence[tuple[str, str]],
794
+ *,
795
+ include_cancel: bool = False,
796
+ ) -> dict[str, Any]:
797
+ rows = [
798
+ [InlineButton(label, encode_effort_callback(effort))]
799
+ for effort, label in options
800
+ ]
801
+ if include_cancel:
802
+ rows.append([InlineButton("Cancel", encode_cancel_callback("model"))])
803
+ return build_inline_keyboard(rows)
804
+
805
+
806
+ def build_update_keyboard(
807
+ options: Sequence[tuple[str, str]],
808
+ *,
809
+ include_cancel: bool = False,
810
+ ) -> dict[str, Any]:
811
+ rows = [
812
+ [InlineButton(label, encode_update_callback(target))]
813
+ for target, label in options
814
+ ]
815
+ if include_cancel:
816
+ rows.append([InlineButton("Cancel", encode_cancel_callback("update"))])
817
+ return build_inline_keyboard(rows)
818
+
819
+
820
+ def build_update_confirm_keyboard() -> dict[str, Any]:
821
+ rows = [
822
+ [
823
+ InlineButton("Yes, continue", encode_update_confirm_callback("yes")),
824
+ InlineButton("Cancel", encode_cancel_callback("update-confirm")),
825
+ ]
826
+ ]
827
+ return build_inline_keyboard(rows)
828
+
829
+
830
+ def build_review_commit_keyboard(
831
+ options: Sequence[tuple[str, str]],
832
+ *,
833
+ page_button: Optional[tuple[str, str]] = None,
834
+ include_cancel: bool = False,
835
+ ) -> dict[str, Any]:
836
+ rows = [
837
+ [InlineButton(label, encode_review_commit_callback(sha))]
838
+ for sha, label in options
839
+ ]
840
+ if page_button:
841
+ label, callback_data = page_button
842
+ rows.append([InlineButton(label, callback_data)])
843
+ if include_cancel:
844
+ rows.append([InlineButton("Cancel", encode_cancel_callback("review-commit"))])
845
+ return build_inline_keyboard(rows)
846
+
847
+
848
+ def build_bind_keyboard(
849
+ options: Sequence[tuple[str, str]],
850
+ *,
851
+ page_button: Optional[tuple[str, str]] = None,
852
+ include_cancel: bool = False,
853
+ ) -> dict[str, Any]:
854
+ rows = [
855
+ [InlineButton(label, encode_bind_callback(repo_id))]
856
+ for repo_id, label in options
857
+ ]
858
+ if page_button:
859
+ label, callback_data = page_button
860
+ rows.append([InlineButton(label, callback_data)])
861
+ if include_cancel:
862
+ rows.append([InlineButton("Cancel", encode_cancel_callback("bind"))])
863
+ return build_inline_keyboard(rows)
864
+
865
+
866
+ def _validate_callback_data(data: str) -> None:
867
+ if len(data.encode("utf-8")) > TELEGRAM_CALLBACK_DATA_LIMIT:
868
+ raise ValueError("callback_data exceeds Telegram limit")
869
+
870
+
871
+ def next_update_offset(
872
+ updates: Iterable[dict[str, Any]], current: Optional[int]
873
+ ) -> Optional[int]:
874
+ max_update_id = None
875
+ for update in updates:
876
+ update_id = update.get("update_id")
877
+ if isinstance(update_id, int):
878
+ if max_update_id is None or update_id > max_update_id:
879
+ max_update_id = update_id
880
+ if max_update_id is None:
881
+ return current
882
+ return max_update_id + 1
883
+
884
+
885
+ class TelegramBotClient:
886
+ def __init__(
887
+ self,
888
+ bot_token: str,
889
+ *,
890
+ timeout_seconds: float = 30.0,
891
+ logger: Optional[logging.Logger] = None,
892
+ client: Optional[httpx.AsyncClient] = None,
893
+ ) -> None:
894
+ self._base_url = f"https://api.telegram.org/bot{bot_token}"
895
+ self._file_base_url = f"https://api.telegram.org/file/bot{bot_token}"
896
+ self._logger = logger or logging.getLogger(__name__)
897
+ if client is None:
898
+ self._client = httpx.AsyncClient(timeout=timeout_seconds)
899
+ self._owns_client = True
900
+ else:
901
+ self._client = client
902
+ self._owns_client = False
903
+ self._rate_limit_until: Optional[float] = None
904
+ self._rate_limit_lock: Optional[asyncio.Lock] = None
905
+ self._rate_limit_lock_loop: Optional[asyncio.AbstractEventLoop] = None
906
+
907
+ async def close(self) -> None:
908
+ if self._owns_client:
909
+ await self._client.aclose()
910
+
911
+ async def __aenter__(self) -> "TelegramBotClient":
912
+ return self
913
+
914
+ async def __aexit__(self, *_exc_info) -> None:
915
+ await self.close()
916
+
917
+ async def get_updates(
918
+ self,
919
+ *,
920
+ offset: Optional[int] = None,
921
+ timeout: int = 30,
922
+ allowed_updates: Optional[Sequence[str]] = None,
923
+ ) -> list[dict[str, Any]]:
924
+ log_event(
925
+ self._logger,
926
+ logging.DEBUG,
927
+ "telegram.request",
928
+ method="getUpdates",
929
+ offset=offset,
930
+ timeout=timeout,
931
+ allowed_updates=list(allowed_updates) if allowed_updates else None,
932
+ )
933
+ payload: dict[str, Any] = {"timeout": timeout}
934
+ if offset is not None:
935
+ payload["offset"] = offset
936
+ if allowed_updates:
937
+ payload["allowed_updates"] = list(allowed_updates)
938
+ result = await self._request("getUpdates", payload)
939
+ if not isinstance(result, list):
940
+ return []
941
+ return [item for item in result if isinstance(item, dict)]
942
+
943
+ async def send_message(
944
+ self,
945
+ chat_id: Union[int, str],
946
+ text: str,
947
+ *,
948
+ message_thread_id: Optional[int] = None,
949
+ reply_to_message_id: Optional[int] = None,
950
+ reply_markup: Optional[dict[str, Any]] = None,
951
+ parse_mode: Optional[str] = None,
952
+ disable_web_page_preview: bool = True,
953
+ ) -> dict[str, Any]:
954
+ if len(text) > TELEGRAM_MAX_MESSAGE_LENGTH:
955
+ responses = await self.send_message_chunks(
956
+ chat_id,
957
+ text,
958
+ message_thread_id=message_thread_id,
959
+ reply_to_message_id=reply_to_message_id,
960
+ reply_markup=reply_markup,
961
+ parse_mode=parse_mode,
962
+ disable_web_page_preview=disable_web_page_preview,
963
+ )
964
+ return responses[0] if responses else {}
965
+ return await self._send_message_raw(
966
+ chat_id,
967
+ text,
968
+ message_thread_id=message_thread_id,
969
+ reply_to_message_id=reply_to_message_id,
970
+ reply_markup=reply_markup,
971
+ parse_mode=parse_mode,
972
+ disable_web_page_preview=disable_web_page_preview,
973
+ )
974
+
975
+ async def _send_message_raw(
976
+ self,
977
+ chat_id: Union[int, str],
978
+ text: str,
979
+ *,
980
+ message_thread_id: Optional[int] = None,
981
+ reply_to_message_id: Optional[int] = None,
982
+ reply_markup: Optional[dict[str, Any]] = None,
983
+ parse_mode: Optional[str] = None,
984
+ disable_web_page_preview: bool = True,
985
+ ) -> dict[str, Any]:
986
+ log_event(
987
+ self._logger,
988
+ logging.INFO,
989
+ "telegram.send_message",
990
+ chat_id=chat_id,
991
+ thread_id=message_thread_id,
992
+ reply_to_message_id=reply_to_message_id,
993
+ text_len=len(text),
994
+ has_markup=reply_markup is not None,
995
+ parse_mode=parse_mode,
996
+ disable_web_page_preview=disable_web_page_preview,
997
+ )
998
+ payload: dict[str, Any] = {
999
+ "chat_id": chat_id,
1000
+ "text": text,
1001
+ "disable_web_page_preview": disable_web_page_preview,
1002
+ }
1003
+ if message_thread_id is not None:
1004
+ payload["message_thread_id"] = message_thread_id
1005
+ if reply_to_message_id is not None:
1006
+ payload["reply_to_message_id"] = reply_to_message_id
1007
+ if reply_markup is not None:
1008
+ payload["reply_markup"] = reply_markup
1009
+ if parse_mode is not None:
1010
+ payload["parse_mode"] = parse_mode
1011
+ result = await self._request("sendMessage", payload)
1012
+ return result if isinstance(result, dict) else {}
1013
+
1014
+ async def send_document(
1015
+ self,
1016
+ chat_id: Union[int, str],
1017
+ document: bytes,
1018
+ *,
1019
+ filename: str,
1020
+ message_thread_id: Optional[int] = None,
1021
+ reply_to_message_id: Optional[int] = None,
1022
+ caption: Optional[str] = None,
1023
+ parse_mode: Optional[str] = None,
1024
+ ) -> dict[str, Any]:
1025
+ log_event(
1026
+ self._logger,
1027
+ logging.INFO,
1028
+ "telegram.send_document",
1029
+ chat_id=chat_id,
1030
+ thread_id=message_thread_id,
1031
+ reply_to_message_id=reply_to_message_id,
1032
+ filename=filename,
1033
+ bytes_len=len(document),
1034
+ parse_mode=parse_mode,
1035
+ )
1036
+ data: dict[str, Any] = {"chat_id": chat_id}
1037
+ if message_thread_id is not None:
1038
+ data["message_thread_id"] = message_thread_id
1039
+ if reply_to_message_id is not None:
1040
+ data["reply_to_message_id"] = reply_to_message_id
1041
+ if caption is not None:
1042
+ data["caption"] = caption
1043
+ if parse_mode is not None:
1044
+ data["parse_mode"] = parse_mode
1045
+ files = {"document": (filename, document, "text/plain")}
1046
+ result = await self._request_multipart("sendDocument", data, files)
1047
+ return result if isinstance(result, dict) else {}
1048
+
1049
+ async def get_me(self) -> dict[str, Any]:
1050
+ log_event(self._logger, logging.DEBUG, "telegram.request", method="getMe")
1051
+ result = await self._request("getMe", {})
1052
+ return result if isinstance(result, dict) else {}
1053
+
1054
+ async def get_file(self, file_id: str) -> dict[str, Any]:
1055
+ log_event(self._logger, logging.DEBUG, "telegram.request", method="getFile")
1056
+ result = await self._request("getFile", {"file_id": file_id})
1057
+ return result if isinstance(result, dict) else {}
1058
+
1059
+ async def get_my_commands(
1060
+ self,
1061
+ *,
1062
+ scope: Optional[dict[str, Any]] = None,
1063
+ language_code: Optional[str] = None,
1064
+ ) -> list[dict[str, Any]]:
1065
+ log_event(
1066
+ self._logger,
1067
+ logging.DEBUG,
1068
+ "telegram.request",
1069
+ method="getMyCommands",
1070
+ scope=scope,
1071
+ language_code=language_code,
1072
+ )
1073
+ payload: dict[str, Any] = {}
1074
+ if scope is not None:
1075
+ payload["scope"] = scope
1076
+ if language_code is not None:
1077
+ payload["language_code"] = language_code
1078
+ result = await self._request("getMyCommands", payload)
1079
+ if not isinstance(result, list):
1080
+ return []
1081
+ return [item for item in result if isinstance(item, dict)]
1082
+
1083
+ async def set_my_commands(
1084
+ self,
1085
+ commands: Sequence[dict[str, str]],
1086
+ *,
1087
+ scope: Optional[dict[str, Any]] = None,
1088
+ language_code: Optional[str] = None,
1089
+ ) -> bool:
1090
+ log_event(
1091
+ self._logger,
1092
+ logging.INFO,
1093
+ "telegram.set_my_commands",
1094
+ command_count=len(commands),
1095
+ scope=scope,
1096
+ language_code=language_code,
1097
+ )
1098
+ payload: dict[str, Any] = {"commands": list(commands)}
1099
+ if scope is not None:
1100
+ payload["scope"] = scope
1101
+ if language_code is not None:
1102
+ payload["language_code"] = language_code
1103
+ result = await self._request("setMyCommands", payload)
1104
+ return bool(result) if isinstance(result, bool) else False
1105
+
1106
+ async def download_file(self, file_path: str) -> bytes:
1107
+ url = f"{self._file_base_url}/{file_path}"
1108
+ log_event(
1109
+ self._logger, logging.INFO, "telegram.file.download", file_path=file_path
1110
+ )
1111
+ try:
1112
+ response = await self._client.get(url)
1113
+ response.raise_for_status()
1114
+ return response.content
1115
+ except Exception as exc:
1116
+ log_event(
1117
+ self._logger,
1118
+ logging.WARNING,
1119
+ "telegram.file.download_failed",
1120
+ file_path=file_path,
1121
+ exc=exc,
1122
+ )
1123
+ raise TelegramAPIError("Telegram file download failed") from exc
1124
+
1125
+ async def send_message_chunks(
1126
+ self,
1127
+ chat_id: Union[int, str],
1128
+ text: str,
1129
+ *,
1130
+ message_thread_id: Optional[int] = None,
1131
+ reply_to_message_id: Optional[int] = None,
1132
+ reply_markup: Optional[dict[str, Any]] = None,
1133
+ parse_mode: Optional[str] = None,
1134
+ disable_web_page_preview: bool = True,
1135
+ max_len: int = TELEGRAM_MAX_MESSAGE_LENGTH,
1136
+ ) -> list[dict[str, Any]]:
1137
+ chunks = chunk_message(text, max_len=max_len, with_numbering=True)
1138
+ if not chunks:
1139
+ return []
1140
+ responses: list[dict[str, Any]] = []
1141
+ log_event(
1142
+ self._logger,
1143
+ logging.INFO,
1144
+ "telegram.send_message.chunks",
1145
+ chat_id=chat_id,
1146
+ thread_id=message_thread_id,
1147
+ reply_to_message_id=reply_to_message_id,
1148
+ parts=len(chunks),
1149
+ total_len=len(text),
1150
+ )
1151
+ for idx, chunk in enumerate(chunks):
1152
+ response = await self._send_message_raw(
1153
+ chat_id,
1154
+ chunk,
1155
+ message_thread_id=message_thread_id,
1156
+ reply_to_message_id=reply_to_message_id if idx == 0 else None,
1157
+ reply_markup=reply_markup if idx == 0 else None,
1158
+ parse_mode=parse_mode,
1159
+ disable_web_page_preview=disable_web_page_preview,
1160
+ )
1161
+ responses.append(response)
1162
+ return responses
1163
+
1164
+ async def edit_message_text(
1165
+ self,
1166
+ chat_id: Union[int, str],
1167
+ message_id: int,
1168
+ text: str,
1169
+ *,
1170
+ reply_markup: Optional[dict[str, Any]] = None,
1171
+ parse_mode: Optional[str] = None,
1172
+ disable_web_page_preview: bool = True,
1173
+ ) -> dict[str, Any]:
1174
+ log_event(
1175
+ self._logger,
1176
+ logging.INFO,
1177
+ "telegram.edit_message",
1178
+ chat_id=chat_id,
1179
+ message_id=message_id,
1180
+ text_len=len(text),
1181
+ has_markup=reply_markup is not None,
1182
+ parse_mode=parse_mode,
1183
+ disable_web_page_preview=disable_web_page_preview,
1184
+ )
1185
+ payload: dict[str, Any] = {
1186
+ "chat_id": chat_id,
1187
+ "message_id": message_id,
1188
+ "text": text,
1189
+ "disable_web_page_preview": disable_web_page_preview,
1190
+ }
1191
+ if reply_markup is not None:
1192
+ payload["reply_markup"] = reply_markup
1193
+ if parse_mode is not None:
1194
+ payload["parse_mode"] = parse_mode
1195
+ result = await self._request("editMessageText", payload)
1196
+ return result if isinstance(result, dict) else {}
1197
+
1198
+ async def delete_message(
1199
+ self,
1200
+ chat_id: Union[int, str],
1201
+ message_id: int,
1202
+ ) -> bool:
1203
+ log_event(
1204
+ self._logger,
1205
+ logging.INFO,
1206
+ "telegram.delete_message",
1207
+ chat_id=chat_id,
1208
+ message_id=message_id,
1209
+ )
1210
+ payload: dict[str, Any] = {"chat_id": chat_id, "message_id": message_id}
1211
+ result = await self._request("deleteMessage", payload)
1212
+ return bool(result) if isinstance(result, bool) else False
1213
+
1214
+ async def answer_callback_query(
1215
+ self,
1216
+ callback_query_id: str,
1217
+ *,
1218
+ text: Optional[str] = None,
1219
+ show_alert: bool = False,
1220
+ ) -> dict[str, Any]:
1221
+ log_event(
1222
+ self._logger,
1223
+ logging.INFO,
1224
+ "telegram.answer_callback",
1225
+ callback_query_id=callback_query_id,
1226
+ text_len=len(text) if text else 0,
1227
+ show_alert=show_alert,
1228
+ )
1229
+ payload: dict[str, Any] = {"callback_query_id": callback_query_id}
1230
+ if text is not None:
1231
+ payload["text"] = text
1232
+ if show_alert:
1233
+ payload["show_alert"] = True
1234
+ result = await self._request("answerCallbackQuery", payload)
1235
+ return result if isinstance(result, dict) else {}
1236
+
1237
+ async def _request(self, method: str, payload: dict[str, Any]) -> Any:
1238
+ url = f"{self._base_url}/{method}"
1239
+
1240
+ async def send() -> httpx.Response:
1241
+ return await self._client.post(url, json=payload)
1242
+
1243
+ return await self._request_with_retry(method, send)
1244
+
1245
+ async def _request_multipart(
1246
+ self, method: str, data: dict[str, Any], files: dict[str, Any]
1247
+ ) -> Any:
1248
+ url = f"{self._base_url}/{method}"
1249
+
1250
+ async def send() -> httpx.Response:
1251
+ return await self._client.post(url, data=data, files=files)
1252
+
1253
+ return await self._request_with_retry(method, send)
1254
+
1255
+ async def _request_with_retry(
1256
+ self, method: str, send: Callable[[], Awaitable[httpx.Response]]
1257
+ ) -> Any:
1258
+ while True:
1259
+ await self._wait_for_rate_limit(method)
1260
+ try:
1261
+ response = await send()
1262
+ response.raise_for_status()
1263
+ payload = response.json()
1264
+ except Exception as exc:
1265
+ retry_after = _extract_retry_after_seconds(exc)
1266
+ if retry_after is not None:
1267
+ await self._apply_rate_limit(method, retry_after)
1268
+ continue
1269
+ log_event(
1270
+ self._logger,
1271
+ logging.WARNING,
1272
+ "telegram.request.failed",
1273
+ method=method,
1274
+ exc=exc,
1275
+ )
1276
+ raise TelegramAPIError("Telegram request failed") from exc
1277
+ if not isinstance(payload, dict) or not payload.get("ok"):
1278
+ retry_after = self._retry_after_from_payload(payload)
1279
+ if retry_after is not None:
1280
+ await self._apply_rate_limit(method, retry_after)
1281
+ continue
1282
+ description = (
1283
+ payload.get("description") if isinstance(payload, dict) else None
1284
+ )
1285
+ raise TelegramAPIError(description or "Telegram API returned error")
1286
+ return payload.get("result")
1287
+
1288
+ def _retry_after_from_payload(self, payload: Any) -> Optional[int]:
1289
+ if not isinstance(payload, dict):
1290
+ return None
1291
+ parameters = payload.get("parameters")
1292
+ if isinstance(parameters, dict):
1293
+ retry_after = parameters.get("retry_after")
1294
+ if isinstance(retry_after, int):
1295
+ return retry_after
1296
+ description = payload.get("description")
1297
+ if isinstance(description, str) and description:
1298
+ return _extract_retry_after_seconds(Exception(description))
1299
+ return None
1300
+
1301
+ def _ensure_rate_limit_lock(self) -> asyncio.Lock:
1302
+ loop = asyncio.get_running_loop()
1303
+ lock = self._rate_limit_lock
1304
+ lock_loop = self._rate_limit_lock_loop
1305
+ if (
1306
+ lock is None
1307
+ or lock_loop is None
1308
+ or lock_loop is not loop
1309
+ or lock_loop.is_closed()
1310
+ ):
1311
+ lock = asyncio.Lock()
1312
+ self._rate_limit_lock = lock
1313
+ self._rate_limit_lock_loop = loop
1314
+ self._rate_limit_until = None
1315
+ return lock
1316
+
1317
+ async def _apply_rate_limit(self, method: str, retry_after: int) -> None:
1318
+ delay = float(retry_after)
1319
+ loop = asyncio.get_running_loop()
1320
+ until = loop.time() + delay + _RATE_LIMIT_BUFFER_SECONDS
1321
+ lock = self._ensure_rate_limit_lock()
1322
+ async with lock:
1323
+ if self._rate_limit_until is None or until > self._rate_limit_until:
1324
+ self._rate_limit_until = until
1325
+ log_event(
1326
+ self._logger,
1327
+ logging.INFO,
1328
+ "telegram.rate_limit.hit",
1329
+ method=method,
1330
+ retry_after=retry_after,
1331
+ )
1332
+ await self._wait_for_rate_limit(method, min_delay=delay)
1333
+
1334
+ async def _wait_for_rate_limit(
1335
+ self, method: str, min_delay: Optional[float] = None
1336
+ ) -> None:
1337
+ lock = self._ensure_rate_limit_lock()
1338
+ async with lock:
1339
+ until = self._rate_limit_until
1340
+ if until is None:
1341
+ return
1342
+ loop = asyncio.get_running_loop()
1343
+ delay = until - loop.time()
1344
+ if min_delay is not None and delay < min_delay:
1345
+ delay = min_delay
1346
+ if delay <= 0:
1347
+ async with lock:
1348
+ if self._rate_limit_until == until:
1349
+ self._rate_limit_until = None
1350
+ return
1351
+ log_event(
1352
+ self._logger,
1353
+ logging.INFO,
1354
+ "telegram.rate_limit.wait",
1355
+ method=method,
1356
+ delay_seconds=delay,
1357
+ )
1358
+ await asyncio.sleep(delay)
1359
+ async with lock:
1360
+ if self._rate_limit_until == until:
1361
+ self._rate_limit_until = None
1362
+
1363
+
1364
+ class TelegramUpdatePoller:
1365
+ def __init__(
1366
+ self,
1367
+ client: TelegramBotClient,
1368
+ *,
1369
+ allowed_updates: Optional[Sequence[str]] = None,
1370
+ offset: Optional[int] = None,
1371
+ ) -> None:
1372
+ self._client = client
1373
+ self._offset: Optional[int] = None
1374
+ self._allowed_updates = list(allowed_updates) if allowed_updates else None
1375
+ if isinstance(offset, int) and not isinstance(offset, bool):
1376
+ self._offset = offset
1377
+
1378
+ @property
1379
+ def offset(self) -> Optional[int]:
1380
+ return self._offset
1381
+
1382
+ def set_offset(self, offset: Optional[int]) -> None:
1383
+ if offset is None:
1384
+ return
1385
+ if not isinstance(offset, int) or isinstance(offset, bool):
1386
+ return
1387
+ self._offset = offset
1388
+
1389
+ async def poll(self, *, timeout: int = 30) -> list[TelegramUpdate]:
1390
+ updates = await self._client.get_updates(
1391
+ offset=self._offset,
1392
+ timeout=timeout,
1393
+ allowed_updates=self._allowed_updates,
1394
+ )
1395
+ self._offset = next_update_offset(updates, self._offset)
1396
+ parsed: list[TelegramUpdate] = []
1397
+ for update in updates:
1398
+ parsed_update = parse_update(update)
1399
+ if parsed_update is not None:
1400
+ parsed.append(parsed_update)
1401
+ return parsed