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