lo-aiogram 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LO contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: lo-aiogram
3
+ Version: 0.1.0
4
+ Summary: LO compatibility session for aiogram 3
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: aiogram<4,>=3.31
10
+ Dynamic: license-file
11
+
12
+ # lo-aiogram
13
+
14
+ LO session and request middleware for aiogram 3.31–3.x. Compatibility translations live in this package; the native LO SDK has no aiogram dependency.
15
+
16
+ ```python
17
+ import os
18
+ from aiogram import Bot
19
+ from lo_aiogram import LoAiohttpSession
20
+
21
+ bot = Bot(os.environ["LO_BOT_TOKEN"], session=LoAiohttpSession())
22
+ # Existing message.reply(), reply_video() and reply_media_group() handlers stay intact.
23
+ ```
24
+
25
+ Close the session when the bot stops. Use HTTPS for custom `base_url`; only tests may explicitly set `allow_insecure_loopback=True` for loopback HTTP. Single files and thumbnails use parameter-named parts, including on older LO installations. Album files use `attach://` references.
26
+
27
+ The middleware reads the packaged server contract. It omits unsupported fields and logs each omission once, without credentials. Cached video IDs lose upload-only metadata. The first video call reads installation capabilities from `getMe`, and refreshes them on subsequent video calls after five minutes. A known disabled upload goes directly to a document. Older installations without capabilities use a five-minute cache after the first explicit refusal, then probe the video method again. A video preparation 429 gets one bounded wait and repeat; another 429 sends a document without disabling future video uploads. Files used for these explicit fallbacks must be replayable (`BufferedInputFile` or `FSInputFile`); a consumed custom producer cannot be reconstructed.
28
+
29
+ A rejected mixed album is split in order into homogeneous photo/document groups and individual videos. This creates several messages, so partial delivery is possible if a later send fails. Other sends, timeouts and network failures are never retried. Upload requests do not take the generic unsupported-field retry. Cached-video requests use the same one-field explicit-refusal fallback as other requests without files; a second refusal propagates. The middleware returns aiogram results, including the actual document when video falls back.
30
+
31
+ LO currently has no typing action. The first exact `sendChatAction` 501 logs an omission; subsequent calls return `True` locally. This means compatibility handling completed, not that a typing indicator appeared. Other 501 errors propagate. HTTP 5xx responses without a valid API envelope become `TelegramServerError` with no HTML detail.
32
+
33
+ To control or reset learned installation limits:
34
+
35
+ ```python
36
+ from lo_aiogram import LoBotApiCompat
37
+
38
+ compat = LoBotApiCompat(feature_ttl=300, max_video_retry_after=30)
39
+ session = LoAiohttpSession(compatibility=False)
40
+ session.middleware(compat)
41
+ bot = Bot(os.environ["LO_BOT_TOKEN"], session=session)
42
+ compat.reset(bot) # after an installation upgrade or configuration change
43
+ ```
44
+
45
+ Caches are isolated by API endpoint and credential. Only the newest started capability request may update the cache; failed refreshes preserve established state, and `reset(bot)` invalidates pending capability responses. Structured failure reasons take precedence over legacy description matching. Set `probe_capabilities=False` only when the application handles installation discovery itself. Secretary operations with `business_connection_id` are rejected locally: use the native SDK with explicit LO consent context. They must never silently become ordinary bot sends.
46
+
47
+ The bundled contract records its source commit. It describes accepted requests, not storage, chat permissions, production rollout or installation flags. Conformance tests use the strict LO emulator; live acceptance remains a separate check.
48
+
49
+ Contract provenance is checked against Git source digests. Local verification may use `--allow-working-tree`; its manifest has `source.commit: null`, an explicit base commit and source fingerprint. Such a fixture is unreleased and does not identify a deployed server revision.
@@ -0,0 +1,38 @@
1
+ # lo-aiogram
2
+
3
+ LO session and request middleware for aiogram 3.31–3.x. Compatibility translations live in this package; the native LO SDK has no aiogram dependency.
4
+
5
+ ```python
6
+ import os
7
+ from aiogram import Bot
8
+ from lo_aiogram import LoAiohttpSession
9
+
10
+ bot = Bot(os.environ["LO_BOT_TOKEN"], session=LoAiohttpSession())
11
+ # Existing message.reply(), reply_video() and reply_media_group() handlers stay intact.
12
+ ```
13
+
14
+ Close the session when the bot stops. Use HTTPS for custom `base_url`; only tests may explicitly set `allow_insecure_loopback=True` for loopback HTTP. Single files and thumbnails use parameter-named parts, including on older LO installations. Album files use `attach://` references.
15
+
16
+ The middleware reads the packaged server contract. It omits unsupported fields and logs each omission once, without credentials. Cached video IDs lose upload-only metadata. The first video call reads installation capabilities from `getMe`, and refreshes them on subsequent video calls after five minutes. A known disabled upload goes directly to a document. Older installations without capabilities use a five-minute cache after the first explicit refusal, then probe the video method again. A video preparation 429 gets one bounded wait and repeat; another 429 sends a document without disabling future video uploads. Files used for these explicit fallbacks must be replayable (`BufferedInputFile` or `FSInputFile`); a consumed custom producer cannot be reconstructed.
17
+
18
+ A rejected mixed album is split in order into homogeneous photo/document groups and individual videos. This creates several messages, so partial delivery is possible if a later send fails. Other sends, timeouts and network failures are never retried. Upload requests do not take the generic unsupported-field retry. Cached-video requests use the same one-field explicit-refusal fallback as other requests without files; a second refusal propagates. The middleware returns aiogram results, including the actual document when video falls back.
19
+
20
+ LO currently has no typing action. The first exact `sendChatAction` 501 logs an omission; subsequent calls return `True` locally. This means compatibility handling completed, not that a typing indicator appeared. Other 501 errors propagate. HTTP 5xx responses without a valid API envelope become `TelegramServerError` with no HTML detail.
21
+
22
+ To control or reset learned installation limits:
23
+
24
+ ```python
25
+ from lo_aiogram import LoBotApiCompat
26
+
27
+ compat = LoBotApiCompat(feature_ttl=300, max_video_retry_after=30)
28
+ session = LoAiohttpSession(compatibility=False)
29
+ session.middleware(compat)
30
+ bot = Bot(os.environ["LO_BOT_TOKEN"], session=session)
31
+ compat.reset(bot) # after an installation upgrade or configuration change
32
+ ```
33
+
34
+ Caches are isolated by API endpoint and credential. Only the newest started capability request may update the cache; failed refreshes preserve established state, and `reset(bot)` invalidates pending capability responses. Structured failure reasons take precedence over legacy description matching. Set `probe_capabilities=False` only when the application handles installation discovery itself. Secretary operations with `business_connection_id` are rejected locally: use the native SDK with explicit LO consent context. They must never silently become ordinary bot sends.
35
+
36
+ The bundled contract records its source commit. It describes accepted requests, not storage, chat permissions, production rollout or installation flags. Conformance tests use the strict LO emulator; live acceptance remains a separate check.
37
+
38
+ Contract provenance is checked against Git source digests. Local verification may use `--allow-working-tree`; its manifest has `source.commit: null`, an explicit base commit and source fingerprint. Such a fixture is unreleased and does not identify a deployed server revision.
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "lo-aiogram"
7
+ version = "0.1.0"
8
+ description = "LO compatibility session for aiogram 3"
9
+ requires-python = ">=3.11"
10
+ dependencies = ["aiogram>=3.31,<4"]
11
+ license = "MIT"
12
+ readme = "README.md"
13
+ license-files = ["LICENSE"]
14
+
15
+ [tool.setuptools.package-data]
16
+ lo_aiogram = ["contract.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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