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.
- lo_aiogram-0.1.0/LICENSE +21 -0
- lo_aiogram-0.1.0/PKG-INFO +49 -0
- lo_aiogram-0.1.0/README.md +38 -0
- lo_aiogram-0.1.0/pyproject.toml +16 -0
- lo_aiogram-0.1.0/setup.cfg +4 -0
- lo_aiogram-0.1.0/src/lo_aiogram/__init__.py +258 -0
- lo_aiogram-0.1.0/src/lo_aiogram/contract.json +1304 -0
- lo_aiogram-0.1.0/src/lo_aiogram.egg-info/PKG-INFO +49 -0
- lo_aiogram-0.1.0/src/lo_aiogram.egg-info/SOURCES.txt +10 -0
- lo_aiogram-0.1.0/src/lo_aiogram.egg-info/dependency_links.txt +1 -0
- lo_aiogram-0.1.0/src/lo_aiogram.egg-info/requires.txt +1 -0
- lo_aiogram-0.1.0/src/lo_aiogram.egg-info/top_level.txt +1 -0
lo_aiogram-0.1.0/LICENSE
ADDED
|
@@ -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,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
|