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 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()