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