lo-aiogram 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.
lo_aiogram/__init__.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""aiogram compatibility belongs in this adapter, outside the native LO SDK."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from aiohttp import FormData
|
|
11
|
+
from aiogram.client.session.aiohttp import AiohttpSession
|
|
12
|
+
from aiogram.client.default import Default
|
|
13
|
+
from aiogram.client.telegram import TelegramAPIServer
|
|
14
|
+
from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter, TelegramServerError
|
|
15
|
+
from aiogram.methods import GetMe, SendDocument, SendMediaGroup, SendPhoto, SendVideo
|
|
16
|
+
from aiogram.types import InputFile, InputMediaDocument, InputMediaPhoto
|
|
17
|
+
from importlib.resources import files
|
|
18
|
+
|
|
19
|
+
CONTRACT = json.loads(files(__package__).joinpath("contract.json").read_text())
|
|
20
|
+
|
|
21
|
+
__all__ = ["LoAiohttpSession", "LoBotApiCompat"]
|
|
22
|
+
log = logging.getLogger("lo_aiogram")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LoAiohttpSession(AiohttpSession):
|
|
26
|
+
"""Names single-upload parts by their parameter, including legacy installations."""
|
|
27
|
+
def __init__(self, *, base_url="https://api.lo.ink", allow_insecure_loopback=False, compatibility=True, **kwargs):
|
|
28
|
+
parsed = urlsplit(base_url)
|
|
29
|
+
if not parsed.hostname or base_url.strip() != base_url or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.scheme != "https" and not (allow_insecure_loopback and parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1", "::1")):
|
|
30
|
+
raise ValueError("Use HTTPS or explicitly enabled loopback HTTP")
|
|
31
|
+
super().__init__(api=TelegramAPIServer.from_base(base_url.rstrip("/")), **kwargs)
|
|
32
|
+
if compatibility:
|
|
33
|
+
self.middleware(LoBotApiCompat())
|
|
34
|
+
|
|
35
|
+
def build_form_data(self, bot, method):
|
|
36
|
+
form = FormData(quote_fields=False)
|
|
37
|
+
attachments = {}
|
|
38
|
+
for key, original in method.model_dump(warnings=False).items():
|
|
39
|
+
original = getattr(method, key, original)
|
|
40
|
+
if isinstance(original, InputFile):
|
|
41
|
+
form.add_field(key, original.read(bot), filename=original.filename or key)
|
|
42
|
+
else:
|
|
43
|
+
value = self.prepare_value(original, bot=bot, files=attachments)
|
|
44
|
+
if value is not None and value != "":
|
|
45
|
+
form.add_field(key, value)
|
|
46
|
+
for key, value in attachments.items():
|
|
47
|
+
form.add_field(key, value.read(bot), filename=value.filename or key)
|
|
48
|
+
return form
|
|
49
|
+
|
|
50
|
+
def check_response(self, bot, method, status_code, content):
|
|
51
|
+
if status_code >= 500:
|
|
52
|
+
try:
|
|
53
|
+
parsed = json.loads(content)
|
|
54
|
+
except (ValueError, TypeError):
|
|
55
|
+
parsed = None
|
|
56
|
+
if not isinstance(parsed, dict) or not isinstance(parsed.get("ok"), bool):
|
|
57
|
+
raise TelegramServerError(method=method, message="LO Bot API is temporarily unavailable")
|
|
58
|
+
try:
|
|
59
|
+
return super().check_response(bot, method, status_code, content)
|
|
60
|
+
except (TelegramBadRequest, TelegramServerError) as error:
|
|
61
|
+
try:
|
|
62
|
+
parameters = json.loads(content).get("parameters", {})
|
|
63
|
+
except (ValueError, TypeError, AttributeError):
|
|
64
|
+
parameters = {}
|
|
65
|
+
if isinstance(parameters, dict):
|
|
66
|
+
reason = parameters.get("reason")
|
|
67
|
+
parameter = parameters.get("parameter")
|
|
68
|
+
if reason in ("unsupported_parameter", "upload_only", "feature_disabled", "method_not_implemented"):
|
|
69
|
+
error.lo_reason = reason
|
|
70
|
+
if isinstance(parameter, str) and parameter.isidentifier():
|
|
71
|
+
error.lo_parameter = parameter
|
|
72
|
+
raise
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class LoBotApiCompat:
|
|
76
|
+
def __init__(self, *, feature_ttl=300, max_video_retry_after=30, capability_refresh_interval=300, probe_capabilities=True, sleep=asyncio.sleep, clock=time.monotonic):
|
|
77
|
+
if not math.isfinite(feature_ttl) or feature_ttl <= 0 or not math.isfinite(max_video_retry_after) or not 0 <= max_video_retry_after <= 300:
|
|
78
|
+
raise ValueError("Invalid feature cache or retry limit")
|
|
79
|
+
if not math.isfinite(capability_refresh_interval) or capability_refresh_interval <= 0:
|
|
80
|
+
raise ValueError("Invalid capability refresh interval")
|
|
81
|
+
self.capability_refresh_interval, self.probe_capabilities = capability_refresh_interval, probe_capabilities
|
|
82
|
+
self.next_capability_check = {}
|
|
83
|
+
self._capability_owners = {}
|
|
84
|
+
self.feature_ttl, self.max_video_retry_after = feature_ttl, max_video_retry_after
|
|
85
|
+
self.sleep, self.clock = sleep, clock
|
|
86
|
+
self.video_disabled, self.chat_action_disabled, self.unsupported, self.warned = {}, set(), {}, set()
|
|
87
|
+
|
|
88
|
+
def scope(self, bot):
|
|
89
|
+
# Separate credentials for the same bot generation, without retaining raw tokens.
|
|
90
|
+
return hashlib.sha256((bot.session.api.base + "\0" + bot.token).encode()).hexdigest()
|
|
91
|
+
|
|
92
|
+
def reset(self, bot):
|
|
93
|
+
"""Discard learned installation limits after an operator changes the server."""
|
|
94
|
+
scope = self.scope(bot)
|
|
95
|
+
self.video_disabled.pop(scope, None)
|
|
96
|
+
self.next_capability_check.pop(scope, None)
|
|
97
|
+
self._capability_owners.pop(scope, None)
|
|
98
|
+
self.chat_action_disabled.discard(scope)
|
|
99
|
+
self.unsupported = {key: value for key, value in self.unsupported.items() if key[0] != scope}
|
|
100
|
+
self.warned = {key for key in self.warned if key[0] != scope}
|
|
101
|
+
|
|
102
|
+
async def _read_capabilities(self, make_request, bot, method, scope):
|
|
103
|
+
# Fence older completions, including after reset or a failed refresh.
|
|
104
|
+
owner = object()
|
|
105
|
+
self._capability_owners[scope] = owner
|
|
106
|
+
identity = await make_request(bot, method)
|
|
107
|
+
if self._capability_owners.get(scope) is owner:
|
|
108
|
+
self.remember_capabilities(scope, identity)
|
|
109
|
+
return identity
|
|
110
|
+
|
|
111
|
+
def remember_capabilities(self, scope, identity):
|
|
112
|
+
self.next_capability_check[scope] = self.clock() + self.capability_refresh_interval
|
|
113
|
+
capabilities = getattr(identity, "capabilities", None)
|
|
114
|
+
if isinstance(capabilities, dict):
|
|
115
|
+
enabled = capabilities.get("video_uploads")
|
|
116
|
+
if enabled is True:
|
|
117
|
+
self.video_disabled.pop(scope, None)
|
|
118
|
+
elif enabled is False:
|
|
119
|
+
self.video_disabled[scope] = self.clock() + self.capability_refresh_interval
|
|
120
|
+
|
|
121
|
+
def warn(self, scope, method, field):
|
|
122
|
+
key = (scope, method, field)
|
|
123
|
+
if key not in self.warned:
|
|
124
|
+
self.warned.add(key)
|
|
125
|
+
log.warning("LO omitted unsupported parameter %s.%s", method, field)
|
|
126
|
+
|
|
127
|
+
def filtered(self, bot, method):
|
|
128
|
+
scope, name = self.scope(bot), method.__api_method__
|
|
129
|
+
definition = CONTRACT["methods"].get(name)
|
|
130
|
+
values = method.model_dump(warnings=False)
|
|
131
|
+
if getattr(method, "business_connection_id", None) is not None:
|
|
132
|
+
raise ValueError("LO secretary operations require the native SDK; the compatibility adapter cannot supply consent context")
|
|
133
|
+
allowed = definition.get("parameters") if definition else None
|
|
134
|
+
omitted = set(self.unsupported.get((scope, name), ()))
|
|
135
|
+
if allowed is not None and definition["implemented"] and not definition.get("allowUnknownParameters"):
|
|
136
|
+
omitted.update(key for key in values if key not in allowed and getattr(method, key, None) is not None)
|
|
137
|
+
if name == "sendVideo" and not isinstance(method.video, InputFile):
|
|
138
|
+
omitted.update(definition["uploadOnly"])
|
|
139
|
+
updates = {}
|
|
140
|
+
for key in omitted:
|
|
141
|
+
value = getattr(method, key, None)
|
|
142
|
+
if isinstance(value, Default):
|
|
143
|
+
value = bot.default[value.name]
|
|
144
|
+
if value is not None:
|
|
145
|
+
self.warn(scope, name, key)
|
|
146
|
+
updates[key] = None
|
|
147
|
+
if name == "sendMediaGroup":
|
|
148
|
+
media = []
|
|
149
|
+
for index, item in enumerate(method.media):
|
|
150
|
+
item_omitted = {key: None for key in item.model_dump() if key not in CONTRACT["inputMedia"]["parameters"]}
|
|
151
|
+
if index > 0:
|
|
152
|
+
item_omitted.update(caption=None, parse_mode=None, caption_entities=None)
|
|
153
|
+
media.append(item.model_copy(update=item_omitted))
|
|
154
|
+
updates["media"] = media
|
|
155
|
+
return method.model_copy(update=updates)
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def has_files(method):
|
|
159
|
+
def visit(value):
|
|
160
|
+
if isinstance(value, InputFile):
|
|
161
|
+
return True
|
|
162
|
+
if isinstance(value, dict):
|
|
163
|
+
return any(visit(item) for item in value.values())
|
|
164
|
+
if isinstance(value, (list, tuple)):
|
|
165
|
+
return any(visit(item) for item in value)
|
|
166
|
+
if hasattr(type(value), "model_fields"):
|
|
167
|
+
return any(visit(getattr(value, key, None)) for key in type(value).model_fields)
|
|
168
|
+
return False
|
|
169
|
+
return visit(method)
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def video_document(method):
|
|
173
|
+
return SendDocument(chat_id=method.chat_id, document=method.video, caption=method.caption, parse_mode=method.parse_mode, caption_entities=method.caption_entities, reply_markup=method.reply_markup)
|
|
174
|
+
|
|
175
|
+
async def send_video(self, make_request, bot, method, scope):
|
|
176
|
+
uploaded = isinstance(method.video, InputFile)
|
|
177
|
+
if uploaded and self.video_disabled.get(scope, 0) > self.clock():
|
|
178
|
+
return await make_request(bot, self.filtered(bot, self.video_document(method)))
|
|
179
|
+
try:
|
|
180
|
+
return await make_request(bot, method)
|
|
181
|
+
except TelegramBadRequest as error:
|
|
182
|
+
if uploaded and (getattr(error, "lo_reason", None) == "feature_disabled" and getattr(error, "lo_parameter", None) == "video" or error.message == "Bad Request: video must be a file identifier"):
|
|
183
|
+
self.video_disabled[scope] = self.clock() + self.feature_ttl
|
|
184
|
+
return await make_request(bot, self.filtered(bot, self.video_document(method)))
|
|
185
|
+
raise
|
|
186
|
+
except TelegramRetryAfter as error:
|
|
187
|
+
if not uploaded or not 0 <= error.retry_after <= self.max_video_retry_after:
|
|
188
|
+
raise
|
|
189
|
+
await self.sleep(error.retry_after)
|
|
190
|
+
try:
|
|
191
|
+
return await make_request(bot, method)
|
|
192
|
+
except TelegramRetryAfter:
|
|
193
|
+
return await make_request(bot, self.filtered(bot, self.video_document(method)))
|
|
194
|
+
|
|
195
|
+
async def split_album(self, make_request, bot, method, scope):
|
|
196
|
+
result = []
|
|
197
|
+
pending = []
|
|
198
|
+
async def flush():
|
|
199
|
+
if not pending:
|
|
200
|
+
return
|
|
201
|
+
if len(pending) > 1:
|
|
202
|
+
normalized = [item.model_copy(update={"caption": None, "parse_mode": None, "caption_entities": None}) if index else item for index, item in enumerate(pending)]
|
|
203
|
+
response = await make_request(bot, SendMediaGroup(chat_id=method.chat_id, media=normalized))
|
|
204
|
+
result.extend(response)
|
|
205
|
+
else:
|
|
206
|
+
item = pending[0]
|
|
207
|
+
cls = SendPhoto if item.type == "photo" else SendDocument
|
|
208
|
+
field = "photo" if item.type == "photo" else "document"
|
|
209
|
+
response = await make_request(bot, cls(chat_id=method.chat_id, **{field: item.media}, caption=item.caption, parse_mode=item.parse_mode, caption_entities=item.caption_entities))
|
|
210
|
+
result.append(response)
|
|
211
|
+
pending.clear()
|
|
212
|
+
for item in method.media:
|
|
213
|
+
if item.type == "video":
|
|
214
|
+
await flush()
|
|
215
|
+
result.append(await self.send_video(make_request, bot, self.filtered(bot, SendVideo(chat_id=method.chat_id, video=item.media, caption=item.caption, parse_mode=item.parse_mode, caption_entities=item.caption_entities, duration=item.duration, width=item.width, height=item.height, thumbnail=item.thumbnail, supports_streaming=item.supports_streaming)), scope))
|
|
216
|
+
else:
|
|
217
|
+
cls = InputMediaPhoto if item.type == "photo" else InputMediaDocument
|
|
218
|
+
converted = cls(media=item.media, caption=item.caption, parse_mode=item.parse_mode, caption_entities=item.caption_entities)
|
|
219
|
+
if pending and pending[0].type != converted.type:
|
|
220
|
+
await flush()
|
|
221
|
+
pending.append(converted)
|
|
222
|
+
await flush()
|
|
223
|
+
return result
|
|
224
|
+
|
|
225
|
+
async def __call__(self, make_request, bot, method):
|
|
226
|
+
scope, name = self.scope(bot), method.__api_method__
|
|
227
|
+
original = method
|
|
228
|
+
method = self.filtered(bot, method)
|
|
229
|
+
if name == "getMe":
|
|
230
|
+
return await self._read_capabilities(make_request, bot, method, scope)
|
|
231
|
+
if name == "sendVideo" and self.probe_capabilities and self.next_capability_check.get(scope, 0) <= self.clock():
|
|
232
|
+
await self._read_capabilities(make_request, bot, GetMe(), scope)
|
|
233
|
+
if name == "sendChatAction" and scope in self.chat_action_disabled:
|
|
234
|
+
return True
|
|
235
|
+
try:
|
|
236
|
+
if name == "sendVideo":
|
|
237
|
+
return await self.send_video(make_request, bot, method, scope)
|
|
238
|
+
return await make_request(bot, method)
|
|
239
|
+
except TelegramServerError as error:
|
|
240
|
+
if name == "sendChatAction" and (getattr(error, "lo_reason", None) == "method_not_implemented" or error.message == "Method not implemented: sendChatAction"):
|
|
241
|
+
self.chat_action_disabled.add(scope)
|
|
242
|
+
self.warn(scope, name, "action")
|
|
243
|
+
return True
|
|
244
|
+
raise
|
|
245
|
+
except TelegramBadRequest as error:
|
|
246
|
+
if name == "sendMediaGroup" and all(item.type in ("photo", "document", "video") for item in original.media) and error.message in ("Bad Request: media type video is not supported yet", "Bad Request: a media group must contain items of one type"):
|
|
247
|
+
return await self.split_album(make_request, bot, original, scope)
|
|
248
|
+
# Retry one field refusal only when no file producer would be replayed.
|
|
249
|
+
prefix, suffix = "Bad Request: ", " is not supported yet"
|
|
250
|
+
field = getattr(error, "lo_parameter", None) if getattr(error, "lo_reason", None) == "unsupported_parameter" else None
|
|
251
|
+
if field is None and error.message.startswith(prefix) and error.message.endswith(suffix):
|
|
252
|
+
field = error.message[len(prefix):-len(suffix)]
|
|
253
|
+
if not self.has_files(method) and isinstance(field, str):
|
|
254
|
+
if field.isidentifier() and field in type(method).model_fields and getattr(method, field, None) is not None:
|
|
255
|
+
self.unsupported.setdefault((scope, name), set()).add(field)
|
|
256
|
+
self.warn(scope, name, field)
|
|
257
|
+
return await make_request(bot, method.model_copy(update={field: None}))
|
|
258
|
+
raise
|