Wgram 0.2.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.
- wgram/__init__.py +32 -0
- wgram/api.py +175 -0
- wgram/bot.py +1032 -0
- wgram/exceptions.py +30 -0
- wgram/inputs.py +124 -0
- wgram/keyboards.py +233 -0
- wgram/py.typed +0 -0
- wgram/router.py +266 -0
- wgram/types.py +718 -0
- wgram/utils.py +53 -0
- wgram-0.2.0.dist-info/METADATA +817 -0
- wgram-0.2.0.dist-info/RECORD +15 -0
- wgram-0.2.0.dist-info/WHEEL +5 -0
- wgram-0.2.0.dist-info/licenses/LICENSE +21 -0
- wgram-0.2.0.dist-info/top_level.txt +1 -0
wgram/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""wgram: максимально простая библиотека для Telegram-ботов."""
|
|
2
|
+
|
|
3
|
+
from .bot import Bot
|
|
4
|
+
from .exceptions import NetworkError, TelegramAPIError, WgramError
|
|
5
|
+
from .inputs import InlineArticle, InlinePhoto, InputMedia
|
|
6
|
+
from .keyboards import (Button, ForceReply, InlineButton, InlineKeyboard, Keyboard,
|
|
7
|
+
RemoveKeyboard)
|
|
8
|
+
from .router import ANY_STATE
|
|
9
|
+
from .types import (CallbackQuery, Chat, ChatMember, ChatMemberUpdate, Contact, Dice,
|
|
10
|
+
InlineQuery, Location, Media, Message, Poll, PollAnswer,
|
|
11
|
+
PreCheckoutQuery, Update, User)
|
|
12
|
+
from .utils import escape_html, escape_md, link, mention
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"start", "Bot",
|
|
16
|
+
"Keyboard", "Button", "InlineKeyboard", "InlineButton", "RemoveKeyboard", "ForceReply",
|
|
17
|
+
"InputMedia", "InlineArticle", "InlinePhoto", "ANY_STATE",
|
|
18
|
+
"Message", "CallbackQuery", "InlineQuery", "PollAnswer", "ChatMemberUpdate",
|
|
19
|
+
"PreCheckoutQuery", "Update", "User", "Chat", "ChatMember", "Media", "Location",
|
|
20
|
+
"Contact", "Poll", "Dice",
|
|
21
|
+
"escape_html", "escape_md", "mention", "link",
|
|
22
|
+
"TelegramAPIError", "NetworkError", "WgramError",
|
|
23
|
+
]
|
|
24
|
+
__version__ = "0.2.0"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def start(token: str, parse=None) -> Bot:
|
|
28
|
+
"""Создаёт бота. Ничего не запускает: запуск делает bot.run().
|
|
29
|
+
|
|
30
|
+
parse - форматирование текста по умолчанию: 'html', 'md' или None.
|
|
31
|
+
"""
|
|
32
|
+
return Bot(token, parse=parse)
|
wgram/api.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Тонкая обёртка над aiohttp для вызова методов Telegram Bot API."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
from .exceptions import NetworkError, TelegramAPIError
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("wgram")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Upload:
|
|
17
|
+
"""Файл для загрузки в Telegram (байты в памяти или путь на диске)."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, name, data=None, path=None):
|
|
20
|
+
self.name = name
|
|
21
|
+
self.data = data
|
|
22
|
+
self.path = path
|
|
23
|
+
|
|
24
|
+
def load(self):
|
|
25
|
+
if self.data is None and self.path is not None:
|
|
26
|
+
with open(self.path, "rb") as f:
|
|
27
|
+
self.data = f.read()
|
|
28
|
+
return self.data
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def split_file(value, default_name="file") -> Tuple[Optional[str], Optional[Upload]]:
|
|
32
|
+
"""Определяет, что за файл передан.
|
|
33
|
+
|
|
34
|
+
Возвращает (ref, upload):
|
|
35
|
+
ref - строка для JSON (file_id или URL), если загружать не нужно
|
|
36
|
+
upload - Upload, если файл надо загрузить (путь, байты, открытый файл)
|
|
37
|
+
"""
|
|
38
|
+
if isinstance(value, Upload):
|
|
39
|
+
return None, value
|
|
40
|
+
if isinstance(value, (bytes, bytearray)):
|
|
41
|
+
return None, Upload(default_name, data=bytes(value))
|
|
42
|
+
if hasattr(value, "read"):
|
|
43
|
+
data = value.read()
|
|
44
|
+
if isinstance(data, str):
|
|
45
|
+
raise TypeError("Файл нужно открывать в бинарном режиме: open(path, 'rb')")
|
|
46
|
+
name = os.path.basename(getattr(value, "name", "") or "") or default_name
|
|
47
|
+
return None, Upload(name, data=data)
|
|
48
|
+
if isinstance(value, os.PathLike):
|
|
49
|
+
value = os.fspath(value)
|
|
50
|
+
if isinstance(value, str):
|
|
51
|
+
if value.startswith(("http://", "https://")):
|
|
52
|
+
return value, None
|
|
53
|
+
if os.path.isfile(value):
|
|
54
|
+
return None, Upload(os.path.basename(value), path=value)
|
|
55
|
+
return value, None # считаем, что это file_id
|
|
56
|
+
raise TypeError("Файл: ожидался путь, URL, file_id, bytes или открытый файл, "
|
|
57
|
+
"а не {!r}".format(type(value).__name__))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _form_value(v) -> str:
|
|
61
|
+
if isinstance(v, bool):
|
|
62
|
+
return "true" if v else "false"
|
|
63
|
+
if isinstance(v, (dict, list, tuple)):
|
|
64
|
+
return json.dumps(v, ensure_ascii=False)
|
|
65
|
+
return str(v)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TelegramAPI:
|
|
69
|
+
def __init__(self, token: str, base_url: str = "https://api.telegram.org"):
|
|
70
|
+
self._token = token
|
|
71
|
+
self._base = base_url.rstrip("/")
|
|
72
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
73
|
+
|
|
74
|
+
def _get_session(self) -> aiohttp.ClientSession:
|
|
75
|
+
# Сессия создаётся лениво, уже внутри работающего event loop
|
|
76
|
+
if self._session is None or self._session.closed:
|
|
77
|
+
self._session = aiohttp.ClientSession()
|
|
78
|
+
return self._session
|
|
79
|
+
|
|
80
|
+
def _hide_token(self, text: str) -> str:
|
|
81
|
+
"""Токен не должен попадать в логи и тексты ошибок."""
|
|
82
|
+
return text.replace(self._token, "***")
|
|
83
|
+
|
|
84
|
+
async def call(self, method: str, params: Optional[Dict[str, Any]] = None,
|
|
85
|
+
files: Optional[Dict[str, Upload]] = None,
|
|
86
|
+
request_timeout: Optional[float] = None) -> Any:
|
|
87
|
+
"""Вызывает метод Telegram API и возвращает поле result.
|
|
88
|
+
|
|
89
|
+
При ответе 429 (слишком часто) сам ждёт retry_after и повторяет запрос.
|
|
90
|
+
"""
|
|
91
|
+
params = {k: v for k, v in (params or {}).items() if v is not None}
|
|
92
|
+
if request_timeout is None:
|
|
93
|
+
request_timeout = 300 if files else 60
|
|
94
|
+
if files:
|
|
95
|
+
loop = asyncio.get_running_loop()
|
|
96
|
+
for up in files.values():
|
|
97
|
+
if up.data is None:
|
|
98
|
+
await loop.run_in_executor(None, up.load)
|
|
99
|
+
for attempt in range(3):
|
|
100
|
+
try:
|
|
101
|
+
return await self._call_once(method, params, files, request_timeout)
|
|
102
|
+
except TelegramAPIError as e:
|
|
103
|
+
if e.error_code == 429 and e.retry_after and attempt < 2:
|
|
104
|
+
delay = min(float(e.retry_after), 30.0) + 0.5
|
|
105
|
+
logger.warning("Telegram просит подождать %s с (429), повтор", delay)
|
|
106
|
+
await asyncio.sleep(delay)
|
|
107
|
+
continue
|
|
108
|
+
raise
|
|
109
|
+
|
|
110
|
+
async def _call_once(self, method, params, files, request_timeout):
|
|
111
|
+
url = "{}/bot{}/{}".format(self._base, self._token, method)
|
|
112
|
+
timeout = aiohttp.ClientTimeout(total=request_timeout)
|
|
113
|
+
try:
|
|
114
|
+
session = self._get_session()
|
|
115
|
+
if files:
|
|
116
|
+
form = aiohttp.FormData()
|
|
117
|
+
for k, v in params.items():
|
|
118
|
+
form.add_field(k, _form_value(v))
|
|
119
|
+
for field_name, up in files.items():
|
|
120
|
+
form.add_field(field_name, up.data, filename=up.name,
|
|
121
|
+
content_type="application/octet-stream")
|
|
122
|
+
request = session.post(url, data=form, timeout=timeout)
|
|
123
|
+
else:
|
|
124
|
+
request = session.post(url, json=params, timeout=timeout)
|
|
125
|
+
async with request as resp:
|
|
126
|
+
data = await resp.json(content_type=None)
|
|
127
|
+
except asyncio.TimeoutError as e:
|
|
128
|
+
raise NetworkError("Telegram не ответил вовремя") from e
|
|
129
|
+
except aiohttp.ClientError as e:
|
|
130
|
+
raise NetworkError(
|
|
131
|
+
"Сетевая ошибка: " + self._hide_token(str(e) or e.__class__.__name__)
|
|
132
|
+
) from None
|
|
133
|
+
except ValueError as e:
|
|
134
|
+
raise NetworkError("Некорректный ответ Telegram: " + self._hide_token(str(e))) from None
|
|
135
|
+
|
|
136
|
+
if not isinstance(data, dict) or not data.get("ok"):
|
|
137
|
+
info = data if isinstance(data, dict) else {}
|
|
138
|
+
extra = info.get("parameters") or {}
|
|
139
|
+
raise TelegramAPIError(
|
|
140
|
+
description=info.get("description", "Неизвестная ошибка Telegram"),
|
|
141
|
+
error_code=info.get("error_code"),
|
|
142
|
+
retry_after=extra.get("retry_after"),
|
|
143
|
+
parameters=extra,
|
|
144
|
+
)
|
|
145
|
+
return data.get("result")
|
|
146
|
+
|
|
147
|
+
async def get_updates(self, offset: Optional[int] = None, timeout: int = 30,
|
|
148
|
+
allowed_updates: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
|
149
|
+
"""Long polling: Telegram держит соединение до timeout секунд."""
|
|
150
|
+
params: Dict[str, Any] = {"timeout": timeout}
|
|
151
|
+
if offset is not None:
|
|
152
|
+
params["offset"] = offset
|
|
153
|
+
if allowed_updates is not None:
|
|
154
|
+
params["allowed_updates"] = allowed_updates
|
|
155
|
+
return await self.call("getUpdates", params, request_timeout=timeout + 15)
|
|
156
|
+
|
|
157
|
+
async def download(self, file_path: str) -> bytes:
|
|
158
|
+
"""Скачивает файл по file_path из ответа getFile."""
|
|
159
|
+
url = "{}/file/bot{}/{}".format(self._base, self._token, file_path)
|
|
160
|
+
try:
|
|
161
|
+
timeout = aiohttp.ClientTimeout(total=300)
|
|
162
|
+
async with self._get_session().get(url, timeout=timeout) as resp:
|
|
163
|
+
if resp.status != 200:
|
|
164
|
+
raise NetworkError("Не удалось скачать файл (HTTP {})".format(resp.status))
|
|
165
|
+
return await resp.read()
|
|
166
|
+
except asyncio.TimeoutError as e:
|
|
167
|
+
raise NetworkError("Скачивание файла: таймаут") from e
|
|
168
|
+
except aiohttp.ClientError as e:
|
|
169
|
+
raise NetworkError(
|
|
170
|
+
"Сетевая ошибка: " + self._hide_token(str(e) or e.__class__.__name__)
|
|
171
|
+
) from None
|
|
172
|
+
|
|
173
|
+
async def close(self) -> None:
|
|
174
|
+
if self._session is not None and not self._session.closed:
|
|
175
|
+
await self._session.close()
|