wekon 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.
- wekon/__init__.py +20 -0
- wekon/aio.py +5 -0
- wekon/bot.py +538 -0
- wekon/cards.py +263 -0
- wekon/client.py +524 -0
- wekon/errors.py +68 -0
- wekon/inline.py +55 -0
- wekon/keyboards.py +224 -0
- wekon/payments.py +217 -0
- wekon/py.typed +0 -0
- wekon/simple.py +175 -0
- wekon/types.py +240 -0
- wekon-0.1.0.dist-info/METADATA +123 -0
- wekon-0.1.0.dist-info/RECORD +16 -0
- wekon-0.1.0.dist-info/WHEEL +4 -0
- wekon-0.1.0.dist-info/licenses/LICENSE +21 -0
wekon/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""wekon — библиотека для ботов Wekon."""
|
|
2
|
+
from . import inline, payments
|
|
3
|
+
from .bot import Context, MemoryStorage
|
|
4
|
+
from .cards import Card, Form, cell, gap, item, mark, option, stat, step, tag
|
|
5
|
+
from .client import Client, bind, check_login
|
|
6
|
+
from .errors import (AuthError, BadRequest, Conflict, Forbidden, NetworkError, NotFound, NotSupported,
|
|
7
|
+
RateLimited, ServerError, WekonError)
|
|
8
|
+
from .keyboards import (ForceReply, Keyboard, RemoveKeyboard, ReplyKeyboard, button, copy_button, form_button,
|
|
9
|
+
inline_button, login_button, url_button)
|
|
10
|
+
from .simple import Bot
|
|
11
|
+
from .types import Attachment, CallbackQuery, Chat, InlineQuery, Message, Sent, Update, User
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Bot", "Client", "Context", "MemoryStorage", "Card", "Form", "Keyboard", "ReplyKeyboard", "RemoveKeyboard", "ForceReply",
|
|
16
|
+
"button", "url_button", "copy_button", "form_button", "inline_button", "login_button", "cell", "gap", "item", "mark", "option",
|
|
17
|
+
"stat", "step", "tag", "bind", "check_login", "inline", "payments", "WekonError", "AuthError", "BadRequest", "Conflict", "Forbidden",
|
|
18
|
+
"NetworkError", "NotFound", "NotSupported", "RateLimited", "ServerError", "Attachment", "CallbackQuery", "Chat", "InlineQuery",
|
|
19
|
+
"Message", "Sent", "Update", "User",
|
|
20
|
+
]
|
wekon/aio.py
ADDED
wekon/bot.py
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""Бот: обработчики событий, контекст, получение событий через опрос и вебхук."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import functools
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
import signal
|
|
11
|
+
from collections import OrderedDict, deque
|
|
12
|
+
from typing import Any, Awaitable, Callable, Iterable, Optional
|
|
13
|
+
|
|
14
|
+
from .client import Client
|
|
15
|
+
from .errors import Conflict, WekonError
|
|
16
|
+
from .keyboards import ForceReply, RemoveKeyboard, keys_of
|
|
17
|
+
from .types import CallbackQuery, Chat, Message, Sent, Update, User
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("wekon")
|
|
20
|
+
|
|
21
|
+
Handler = Callable[["Context"], Awaitable[Any]]
|
|
22
|
+
|
|
23
|
+
#: Сколько последних событий помнить: доставка «как минимум один раз».
|
|
24
|
+
SEEN_UPDATES = 4096
|
|
25
|
+
WEBHOOK_SECRET_HEADER = "x-wekon-bot-api-secret-token"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def compile_pattern(pattern: str) -> "re.Pattern[str]":
|
|
29
|
+
"""``"buy:{item}"`` → регулярное выражение: ``{имя}`` попадёт в ``ctx.params``."""
|
|
30
|
+
parts = re.split(r"\{(\w+)\}", pattern)
|
|
31
|
+
source = "".join(re.escape(part) if index % 2 == 0 else f"(?P<{part}>[^:]+)" for index, part in enumerate(parts))
|
|
32
|
+
return re.compile(f"^{source}$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MemoryStorage:
|
|
36
|
+
"""Состояние диалогов в памяти: теряется при перезапуске.
|
|
37
|
+
|
|
38
|
+
Для работы в проде замените своим классом с теми же методами (Redis, база).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self.states: dict = {}
|
|
43
|
+
|
|
44
|
+
async def get(self, key: tuple) -> tuple:
|
|
45
|
+
return self.states.get(key, (None, {}))
|
|
46
|
+
|
|
47
|
+
async def set(self, key: tuple, state: Optional[str], data: dict) -> None:
|
|
48
|
+
if state is None:
|
|
49
|
+
self.states.pop(key, None)
|
|
50
|
+
else:
|
|
51
|
+
self.states[key] = (state, data)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Context:
|
|
55
|
+
"""Всё о текущем событии и короткие действия с ним."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, bot: "Bot", update: Update) -> None:
|
|
58
|
+
self.bot = bot
|
|
59
|
+
self.update = update
|
|
60
|
+
self.message: Optional[Message] = update.message
|
|
61
|
+
self.callback: Optional[CallbackQuery] = update.callback
|
|
62
|
+
self.inline_query = update.inline_query
|
|
63
|
+
self.user: User = (self.message or self.callback or self.inline_query or update).user
|
|
64
|
+
self.chat: Optional[Chat] = self.message.chat if self.message else self.callback.chat if self.callback else None
|
|
65
|
+
#: Части команды после неё: ``/buy 5 red`` → ``["5", "red"]``.
|
|
66
|
+
self.args: list = []
|
|
67
|
+
#: Значения из шаблона: ``buy:{item}`` → ``{"item": "42"}``.
|
|
68
|
+
self.params: dict = {}
|
|
69
|
+
self.answered = False
|
|
70
|
+
self._state_key = (self.chat_id, self.user.id)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def chat_id(self) -> int:
|
|
74
|
+
return self.chat.id if self.chat else self.update.chat_id
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def text(self) -> str:
|
|
78
|
+
return self.message.text if self.message else ""
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def data(self) -> str:
|
|
82
|
+
"""Данные нажатой кнопки."""
|
|
83
|
+
return self.callback.data if self.callback else ""
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def value(self) -> Optional[str]:
|
|
87
|
+
"""Новое значение элемента карточки."""
|
|
88
|
+
return self.callback.value if self.callback else None
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def form(self) -> dict:
|
|
92
|
+
"""Ответы формы."""
|
|
93
|
+
return (self.callback.form if self.callback else None) or {}
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def picked(self) -> tuple:
|
|
97
|
+
"""Выбор нескольких пунктов: ``"r3s5:1"`` → ``("r3s5", True)``."""
|
|
98
|
+
name, _, state = (self.value or "").partition(":")
|
|
99
|
+
return name, state == "1"
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def payload(self) -> dict:
|
|
103
|
+
"""Содержимое события как на сервере: для реакций, опросов, участников."""
|
|
104
|
+
return self.update.payload
|
|
105
|
+
|
|
106
|
+
def __getattr__(self, name: str) -> Any:
|
|
107
|
+
# Любая отправка в этот чат: ctx.send_video(...), ctx.send_poll(...).
|
|
108
|
+
if name.startswith("send_") and hasattr(type(self.bot), name):
|
|
109
|
+
return functools.partial(getattr(self.bot, name), self.chat_id)
|
|
110
|
+
raise AttributeError(name)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def message_id(self) -> int:
|
|
114
|
+
if self.message:
|
|
115
|
+
return self.message.id
|
|
116
|
+
return self.callback.message_id if self.callback else 0
|
|
117
|
+
|
|
118
|
+
# --- ответы ---
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _keyboard(options: dict) -> dict:
|
|
122
|
+
"""Простые записи: ``buttons=`` под сообщением, ``keys=`` вместо клавиатуры."""
|
|
123
|
+
if "buttons" in options:
|
|
124
|
+
options["keyboard"] = options.pop("buttons")
|
|
125
|
+
if "keys" in options:
|
|
126
|
+
keys = options.pop("keys")
|
|
127
|
+
options["keyboard"] = keys_of(keys) if keys else RemoveKeyboard()
|
|
128
|
+
return options
|
|
129
|
+
|
|
130
|
+
async def reply(self, text: Optional[str] = None, **options: Any) -> Sent:
|
|
131
|
+
"""Написать в этот чат: текст, ``card=``, ``buttons=``, ``keys=``, ``html=True`` и общие настройки.
|
|
132
|
+
|
|
133
|
+
``buttons=[("Да", "yes"), ("Сайт", "https://…")]``, ``keys=["Да", "Нет"]``; ``keys=[]`` убирает клавиатуру.
|
|
134
|
+
"""
|
|
135
|
+
return await self.bot.send_message(self.chat_id, text, **self._keyboard(options))
|
|
136
|
+
|
|
137
|
+
async def edit(self, text: Optional[str] = None, **options: Any) -> dict:
|
|
138
|
+
"""Изменить сообщение с нажатой кнопкой (или ``message_id=``)."""
|
|
139
|
+
options = self._keyboard(options)
|
|
140
|
+
message_id = options.pop("message_id", self.message_id)
|
|
141
|
+
inline_id = self.callback.inline_message_id if self.callback else self.update.payload.get("inline_message_id", "")
|
|
142
|
+
return await self.bot.edit_message(self.chat_id, message_id, text, inline_message_id=inline_id or None, **options)
|
|
143
|
+
|
|
144
|
+
async def answer(self, text: Optional[str] = None, **options: Any) -> Any:
|
|
145
|
+
"""Ответить на нажатие: всплывашка, ``alert=True`` — окно, ``near_button=True`` — у кнопки."""
|
|
146
|
+
self.answered = True
|
|
147
|
+
return await self.bot.answer_callback(self.callback.id, text, **options)
|
|
148
|
+
|
|
149
|
+
async def delete(self) -> Any:
|
|
150
|
+
return await self.bot.delete_message(self.chat_id, self.message_id)
|
|
151
|
+
|
|
152
|
+
async def typing(self) -> Any:
|
|
153
|
+
return await self.bot.send_chat_action(self.chat_id)
|
|
154
|
+
|
|
155
|
+
async def react(self, *emoji_ids: str) -> Any:
|
|
156
|
+
return await self.bot.react(self.chat_id, self.message_id, *emoji_ids)
|
|
157
|
+
|
|
158
|
+
async def download(self, file: Any = None, path: Any = None) -> Any:
|
|
159
|
+
"""Скачать присланный файл: без ``file`` — первое вложение сообщения."""
|
|
160
|
+
target = file if file is not None else (self.message.file if self.message else None)
|
|
161
|
+
if target is None:
|
|
162
|
+
raise ValueError("В сообщении нет файла")
|
|
163
|
+
return await self.bot.download(target, path)
|
|
164
|
+
|
|
165
|
+
async def answer_inline(self, results: Iterable[dict], **options: Any) -> Any:
|
|
166
|
+
return await self.bot.answer_inline(self.inline_query.id, results, **options)
|
|
167
|
+
|
|
168
|
+
async def ask(self, text: str, *, timeout: float = 300, placeholder: Optional[str] = None) -> Optional[str]:
|
|
169
|
+
"""Задать вопрос и дождаться ответа: ``name = msg.ask("Как вас зовут?")``.
|
|
170
|
+
|
|
171
|
+
``None`` — истекло время или человек ввёл команду. Ожидание живёт в памяти
|
|
172
|
+
программы; для длинных анкет надёжнее ``ask_later`` с шагами диалога.
|
|
173
|
+
"""
|
|
174
|
+
waiter = asyncio.get_running_loop().create_future()
|
|
175
|
+
self.bot._waiters[self._state_key] = waiter
|
|
176
|
+
try:
|
|
177
|
+
await self.reply(text, keyboard=ForceReply(placeholder))
|
|
178
|
+
return await asyncio.wait_for(waiter, timeout)
|
|
179
|
+
except asyncio.TimeoutError:
|
|
180
|
+
return None
|
|
181
|
+
finally:
|
|
182
|
+
self.bot._waiters.pop(self._state_key, None)
|
|
183
|
+
|
|
184
|
+
async def ask_later(self, text: str, state: str, *, placeholder: Optional[str] = None, **data: Any) -> Sent:
|
|
185
|
+
"""Задать вопрос: ответ попадёт в ``@bot.message(state=...)``, переживает обработчик."""
|
|
186
|
+
await self.set_state(state, **data)
|
|
187
|
+
return await self.reply(text, keyboard=ForceReply(placeholder))
|
|
188
|
+
|
|
189
|
+
# --- диалог ---
|
|
190
|
+
|
|
191
|
+
async def set_state(self, state: str, **data: Any) -> None:
|
|
192
|
+
"""Шаг диалога с этим человеком; ``data`` дополняет сохранённое."""
|
|
193
|
+
_, saved = await self.bot.storage.get(self._state_key)
|
|
194
|
+
await self.bot.storage.set(self._state_key, state, {**saved, **data})
|
|
195
|
+
|
|
196
|
+
async def get_data(self) -> dict:
|
|
197
|
+
"""Данные текущего диалога."""
|
|
198
|
+
return (await self.bot.storage.get(self._state_key))[1]
|
|
199
|
+
|
|
200
|
+
async def clear_state(self) -> None:
|
|
201
|
+
await self.bot.storage.set(self._state_key, None, {})
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Bot(Client):
|
|
205
|
+
"""Бот Wekon.
|
|
206
|
+
|
|
207
|
+
>>> bot = Bot("shop_bot") # ключ из WEKON_BOT_API_KEY
|
|
208
|
+
>>> @bot.command("start")
|
|
209
|
+
... async def start(ctx):
|
|
210
|
+
... await ctx.reply("Привет!")
|
|
211
|
+
>>> bot.run()
|
|
212
|
+
|
|
213
|
+
Простая версия без async — ``wekon.Bot`` (см. simple.py).
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
def __init__(self, nik: str, api_key: Optional[str] = None, *, storage: Any = None, **client: Any) -> None:
|
|
217
|
+
super().__init__(nik, api_key, **client)
|
|
218
|
+
self.storage = storage or MemoryStorage()
|
|
219
|
+
self._commands: dict = {}
|
|
220
|
+
self._messages: list = []
|
|
221
|
+
self._callbacks: list = []
|
|
222
|
+
self._forms: list = []
|
|
223
|
+
self._events: dict = {}
|
|
224
|
+
self._error: Optional[Handler] = None
|
|
225
|
+
self._middleware: list = []
|
|
226
|
+
self._seen: OrderedDict = OrderedDict()
|
|
227
|
+
self._queues: dict = {}
|
|
228
|
+
self._workers: set = set()
|
|
229
|
+
self._waiters: dict = {}
|
|
230
|
+
|
|
231
|
+
# --- регистрация ---
|
|
232
|
+
|
|
233
|
+
def command(self, *names: str) -> Callable[[Handler], Handler]:
|
|
234
|
+
"""Команда: ``@bot.command("start", "help")``. Части после неё — в ``ctx.args``."""
|
|
235
|
+
def register(handler: Handler) -> Handler:
|
|
236
|
+
for name in names:
|
|
237
|
+
self._commands[name.lstrip("/").lower()] = handler
|
|
238
|
+
return handler
|
|
239
|
+
return register
|
|
240
|
+
|
|
241
|
+
def message(self, text: Optional[str] = None, *, regex: Optional[str] = None, state: Optional[str] = None,
|
|
242
|
+
types: Optional[Iterable[str]] = None) -> Callable[[Handler], Handler]:
|
|
243
|
+
"""Сообщение: без условий — любое. ``text`` — точный текст, ``regex`` — шаблон,
|
|
244
|
+
``state`` — шаг диалога, ``types`` — тип события (``photo``, ``document``…)."""
|
|
245
|
+
compiled = re.compile(regex) if regex else None
|
|
246
|
+
kinds = set(types) if types else None
|
|
247
|
+
|
|
248
|
+
def register(handler: Handler) -> Handler:
|
|
249
|
+
self._messages.append((text, compiled, state, kinds, handler))
|
|
250
|
+
return handler
|
|
251
|
+
return register
|
|
252
|
+
|
|
253
|
+
def callback(self, *patterns: str) -> Callable[[Handler], Handler]:
|
|
254
|
+
"""Нажатие: ``@bot.callback("buy:{item}")``. Без шаблона — все остальные нажатия."""
|
|
255
|
+
def register(handler: Handler) -> Handler:
|
|
256
|
+
for pattern in patterns or (None,):
|
|
257
|
+
self._callbacks.append((compile_pattern(pattern) if pattern else None, handler))
|
|
258
|
+
return handler
|
|
259
|
+
return register
|
|
260
|
+
|
|
261
|
+
def form(self, *patterns: str) -> Callable[[Handler], Handler]:
|
|
262
|
+
"""Отправленная форма; ответы в ``ctx.form``."""
|
|
263
|
+
def register(handler: Handler) -> Handler:
|
|
264
|
+
for pattern in patterns:
|
|
265
|
+
self._forms.append((compile_pattern(pattern), handler))
|
|
266
|
+
return handler
|
|
267
|
+
return register
|
|
268
|
+
|
|
269
|
+
def on(self, *types: str) -> Callable[[Handler], Handler]:
|
|
270
|
+
"""Любой тип события: ``@bot.on("reaction")``. Содержимое — в ``ctx.update.payload``."""
|
|
271
|
+
def register(handler: Handler) -> Handler:
|
|
272
|
+
for kind in types:
|
|
273
|
+
self._events.setdefault(kind, []).append(handler)
|
|
274
|
+
return handler
|
|
275
|
+
return register
|
|
276
|
+
|
|
277
|
+
def inline(self) -> Callable[[Handler], Handler]:
|
|
278
|
+
"""Запрос ``@бот текст``: ``ctx.inline_query``, ответ — ``ctx.answer_inline``."""
|
|
279
|
+
return self.on("inline_query")
|
|
280
|
+
|
|
281
|
+
def edited(self) -> Callable[[Handler], Handler]:
|
|
282
|
+
return self.on("edited_message")
|
|
283
|
+
|
|
284
|
+
def error(self, handler: Handler) -> Handler:
|
|
285
|
+
"""Ошибки обработчиков: исключение лежит в ``ctx.exception``."""
|
|
286
|
+
self._error = handler
|
|
287
|
+
return handler
|
|
288
|
+
|
|
289
|
+
def middleware(self, handler: Callable[["Context", Callable], Awaitable[Any]]) -> Callable:
|
|
290
|
+
"""Код вокруг всех обработчиков: ``async def f(ctx, call_next)``."""
|
|
291
|
+
self._middleware.append(handler)
|
|
292
|
+
return handler
|
|
293
|
+
|
|
294
|
+
# --- обработка ---
|
|
295
|
+
|
|
296
|
+
async def _find_message(self, ctx: Context) -> Optional[Handler]:
|
|
297
|
+
message = ctx.message
|
|
298
|
+
text = message.text.strip()
|
|
299
|
+
if text.startswith("/"):
|
|
300
|
+
head, *args = text.split()
|
|
301
|
+
name, _, target = head[1:].partition("@")
|
|
302
|
+
if (not target or target.lower() == self.nik.lower()) and name.lower() in self._commands:
|
|
303
|
+
ctx.args = args
|
|
304
|
+
return self._commands[name.lower()]
|
|
305
|
+
state, _ = await self.storage.get(ctx._state_key)
|
|
306
|
+
# Шаг диалога важнее общих обработчиков.
|
|
307
|
+
ordered = sorted(self._messages, key=lambda entry: entry[2] is None)
|
|
308
|
+
for wanted, compiled, need_state, kinds, handler in ordered:
|
|
309
|
+
if need_state is not None and need_state != state:
|
|
310
|
+
continue
|
|
311
|
+
if kinds is not None and message.kind not in kinds:
|
|
312
|
+
continue
|
|
313
|
+
if wanted is not None and wanted != text:
|
|
314
|
+
continue
|
|
315
|
+
if compiled is not None:
|
|
316
|
+
match = compiled.search(text)
|
|
317
|
+
if not match:
|
|
318
|
+
continue
|
|
319
|
+
ctx.params = match.groupdict()
|
|
320
|
+
return handler
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
def _find_callback(self, ctx: Context) -> Optional[Handler]:
|
|
324
|
+
entries = self._forms + self._callbacks if ctx.callback.form is not None else self._callbacks
|
|
325
|
+
for pattern, handler in entries:
|
|
326
|
+
if pattern is None:
|
|
327
|
+
return handler
|
|
328
|
+
match = pattern.match(ctx.data)
|
|
329
|
+
if match:
|
|
330
|
+
ctx.params = match.groupdict()
|
|
331
|
+
return handler
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
async def _run_handler(self, ctx: Context, handler: Handler) -> None:
|
|
335
|
+
async def chain(index: int) -> Any:
|
|
336
|
+
if index == len(self._middleware):
|
|
337
|
+
return await handler(ctx)
|
|
338
|
+
return await self._middleware[index](ctx, lambda: chain(index + 1))
|
|
339
|
+
await chain(0)
|
|
340
|
+
|
|
341
|
+
async def process_update(self, raw: dict) -> None:
|
|
342
|
+
"""Обработать одно событие. Ошибка обработчика не останавливает бота."""
|
|
343
|
+
update = Update.parse(raw)
|
|
344
|
+
if update.id and update.id in self._seen:
|
|
345
|
+
return
|
|
346
|
+
if update.id:
|
|
347
|
+
self._seen[update.id] = True
|
|
348
|
+
while len(self._seen) > SEEN_UPDATES:
|
|
349
|
+
self._seen.popitem(last=False)
|
|
350
|
+
ctx = Context(self, update)
|
|
351
|
+
handlers: list = []
|
|
352
|
+
if update.type in Update.MESSAGE_TYPES:
|
|
353
|
+
found = await self._find_message(ctx)
|
|
354
|
+
handlers = [found] if found else []
|
|
355
|
+
elif update.type == "callback_query":
|
|
356
|
+
found = self._find_callback(ctx)
|
|
357
|
+
handlers = [found] if found else []
|
|
358
|
+
handlers += self._events.get(update.type, [])
|
|
359
|
+
try:
|
|
360
|
+
for handler in handlers:
|
|
361
|
+
await self._run_handler(ctx, handler)
|
|
362
|
+
except Exception as error: # обработчик разработчика может упасть как угодно
|
|
363
|
+
ctx.exception = error
|
|
364
|
+
if self._error:
|
|
365
|
+
await self._error(ctx)
|
|
366
|
+
else:
|
|
367
|
+
log.exception("Ошибка обработчика события %s", update.type)
|
|
368
|
+
finally:
|
|
369
|
+
# Без ответа у человека крутился бы индикатор на кнопке.
|
|
370
|
+
if ctx.callback and not ctx.answered:
|
|
371
|
+
try:
|
|
372
|
+
await self.answer_callback(ctx.callback.id)
|
|
373
|
+
except WekonError as error:
|
|
374
|
+
log.warning("Не удалось ответить на нажатие: %s", error)
|
|
375
|
+
|
|
376
|
+
def _enqueue(self, raw: dict) -> None:
|
|
377
|
+
"""События одного чата идут по порядку, разные чаты — одновременно."""
|
|
378
|
+
if self._answers_question(raw):
|
|
379
|
+
return
|
|
380
|
+
key = Update.parse(raw).chat_id or object()
|
|
381
|
+
queue = self._queues.get(key)
|
|
382
|
+
if queue is not None:
|
|
383
|
+
queue.append(raw)
|
|
384
|
+
return
|
|
385
|
+
self._queues[key] = deque([raw])
|
|
386
|
+
task = asyncio.ensure_future(self._drain(key))
|
|
387
|
+
self._workers.add(task)
|
|
388
|
+
task.add_done_callback(self._workers.discard)
|
|
389
|
+
|
|
390
|
+
def _answers_question(self, raw: dict) -> bool:
|
|
391
|
+
"""Сообщение отвечает на ``ctx.ask``: оно не идёт в обработчики. Команда прерывает вопрос."""
|
|
392
|
+
update = Update.parse(raw)
|
|
393
|
+
message = update.message if update.type in Update.MESSAGE_TYPES else None
|
|
394
|
+
waiter = self._waiters.get((message.chat.id, message.user.id)) if message else None
|
|
395
|
+
if waiter is None or waiter.done():
|
|
396
|
+
return False
|
|
397
|
+
if message.text.startswith("/"):
|
|
398
|
+
waiter.set_result(None)
|
|
399
|
+
return False
|
|
400
|
+
waiter.set_result(message.text)
|
|
401
|
+
return True
|
|
402
|
+
|
|
403
|
+
async def _drain(self, key: Any) -> None:
|
|
404
|
+
queue = self._queues[key]
|
|
405
|
+
try:
|
|
406
|
+
while queue:
|
|
407
|
+
await self.process_update(queue.popleft())
|
|
408
|
+
finally:
|
|
409
|
+
del self._queues[key]
|
|
410
|
+
|
|
411
|
+
async def wait_idle(self) -> None:
|
|
412
|
+
"""Дождаться обработки всех принятых событий."""
|
|
413
|
+
while self._workers:
|
|
414
|
+
await asyncio.wait(list(self._workers))
|
|
415
|
+
# Уступаем циклу: завершённые задачи убирает их колбэк, а без этой
|
|
416
|
+
# паузы он бы не запустился, и цикл крутился бы вечно.
|
|
417
|
+
await asyncio.sleep(0)
|
|
418
|
+
|
|
419
|
+
# --- запуск ---
|
|
420
|
+
|
|
421
|
+
async def start_polling(self, *, allowed_updates: Optional[Iterable[str]] = None, delete_webhook: bool = False) -> None:
|
|
422
|
+
"""Получать события опросом, пока задачу не отменят."""
|
|
423
|
+
if delete_webhook:
|
|
424
|
+
await self.delete_webhook()
|
|
425
|
+
me = await self.get_me()
|
|
426
|
+
log.info("Бот @%s запущен", me.get("nik_name", self.nik))
|
|
427
|
+
offset, pause = 0, 1
|
|
428
|
+
try:
|
|
429
|
+
while True:
|
|
430
|
+
try:
|
|
431
|
+
updates = await self.get_updates(offset, allowed_updates=allowed_updates)
|
|
432
|
+
pause = 1
|
|
433
|
+
except Conflict as error:
|
|
434
|
+
if error.code == "webhook_active":
|
|
435
|
+
raise
|
|
436
|
+
log.warning("Бот уже запущен в другом месте: ждём")
|
|
437
|
+
await asyncio.sleep(5)
|
|
438
|
+
continue
|
|
439
|
+
except WekonError as error:
|
|
440
|
+
if error.status in (401, 403):
|
|
441
|
+
raise
|
|
442
|
+
log.warning("Сервер недоступен, повтор через %s с: %s", pause, error)
|
|
443
|
+
await asyncio.sleep(pause)
|
|
444
|
+
pause = min(pause * 2, 30)
|
|
445
|
+
continue
|
|
446
|
+
for raw in updates:
|
|
447
|
+
offset = max(offset, int(raw.get("update_id") or 0))
|
|
448
|
+
self._enqueue(raw)
|
|
449
|
+
finally:
|
|
450
|
+
await self.wait_idle()
|
|
451
|
+
|
|
452
|
+
def run(self, **options: Any) -> None:
|
|
453
|
+
"""Запустить бота и работать до Ctrl+C. Опции — как у ``start_polling``."""
|
|
454
|
+
async def main() -> None:
|
|
455
|
+
task = asyncio.ensure_future(self.start_polling(**options))
|
|
456
|
+
loop = asyncio.get_running_loop()
|
|
457
|
+
for name in ("SIGINT", "SIGTERM"):
|
|
458
|
+
try:
|
|
459
|
+
loop.add_signal_handler(getattr(signal, name), task.cancel)
|
|
460
|
+
except (NotImplementedError, AttributeError):
|
|
461
|
+
pass # Windows: остановит KeyboardInterrupt
|
|
462
|
+
try:
|
|
463
|
+
await task
|
|
464
|
+
except asyncio.CancelledError:
|
|
465
|
+
pass
|
|
466
|
+
finally:
|
|
467
|
+
await self.close()
|
|
468
|
+
try:
|
|
469
|
+
asyncio.run(main())
|
|
470
|
+
except KeyboardInterrupt:
|
|
471
|
+
pass
|
|
472
|
+
|
|
473
|
+
def asgi(self, *, secret: Optional[str] = None, path: str = "/wekon", payments: Optional[dict] = None) -> Callable:
|
|
474
|
+
"""ASGI-приложение для вебхука: ``uvicorn module:app``.
|
|
475
|
+
|
|
476
|
+
``secret`` — тот же, что в ``set_webhook``. ``payments`` — ``{"/pay/yookassa": касса}``:
|
|
477
|
+
уведомления касс на этом же сервере, статус счёта поставится сам.
|
|
478
|
+
"""
|
|
479
|
+
payments = payments or {}
|
|
480
|
+
|
|
481
|
+
async def app(scope: dict, receive: Callable, send: Callable) -> None:
|
|
482
|
+
if scope["type"] != "http":
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
async def respond(status: int, body: bytes = b"", kind: bytes = b"text/plain") -> None:
|
|
486
|
+
await send({"type": "http.response.start", "status": status, "headers": [(b"content-type", kind)]})
|
|
487
|
+
await send({"type": "http.response.body", "body": body})
|
|
488
|
+
|
|
489
|
+
raw = b""
|
|
490
|
+
while True:
|
|
491
|
+
chunk = await receive()
|
|
492
|
+
raw += chunk.get("body", b"")
|
|
493
|
+
if not chunk.get("more_body"):
|
|
494
|
+
break
|
|
495
|
+
headers = {key.decode().lower(): value.decode() for key, value in scope.get("headers", [])}
|
|
496
|
+
route = scope.get("path", "")
|
|
497
|
+
if scope.get("method") != "POST":
|
|
498
|
+
return await respond(405)
|
|
499
|
+
if route in payments:
|
|
500
|
+
try:
|
|
501
|
+
event = await payments[route].parse_notification(raw, headers)
|
|
502
|
+
except (ValueError, KeyError):
|
|
503
|
+
return await respond(400)
|
|
504
|
+
if event is not None:
|
|
505
|
+
await self.apply_payment(event)
|
|
506
|
+
reply = payments[route].notification_reply(event)
|
|
507
|
+
return await respond(200, reply.encode(), b"application/json" if reply.startswith("{") else b"text/plain")
|
|
508
|
+
if route != path:
|
|
509
|
+
return await respond(404)
|
|
510
|
+
if secret and not hmac.compare_digest(headers.get(WEBHOOK_SECRET_HEADER, ""), secret):
|
|
511
|
+
return await respond(401)
|
|
512
|
+
try:
|
|
513
|
+
self._enqueue(json.loads(raw))
|
|
514
|
+
except ValueError:
|
|
515
|
+
return await respond(400)
|
|
516
|
+
await respond(200)
|
|
517
|
+
return app
|
|
518
|
+
|
|
519
|
+
# --- оплата ---
|
|
520
|
+
|
|
521
|
+
async def send_payment(self, chat_id: int, provider: Any, order_id: str, amount: int, title: str, **invoice: Any) -> Sent:
|
|
522
|
+
"""Создать платёж в кассе и прислать карточку счёта. ``amount`` — в копейках."""
|
|
523
|
+
payment = await provider.create(order_id, amount, title, expires_at=invoice.get("expires_at"))
|
|
524
|
+
sent = await self.send_invoice(chat_id, title, amount=amount, pay_url=payment.url, **invoice)
|
|
525
|
+
await self.storage.set(("invoice", order_id), "invoice", {"chat_id": chat_id, "message_id": sent.message_id})
|
|
526
|
+
return sent
|
|
527
|
+
|
|
528
|
+
async def apply_payment(self, event: Any) -> None:
|
|
529
|
+
"""Поставить статус счёта по уведомлению кассы."""
|
|
530
|
+
_, invoice = await self.storage.get(("invoice", event.order_id))
|
|
531
|
+
if not invoice:
|
|
532
|
+
log.warning("Счёт заказа %s не найден", event.order_id)
|
|
533
|
+
return
|
|
534
|
+
await self.set_invoice_status(invoice["chat_id"], invoice["message_id"], event.status)
|
|
535
|
+
for handler in self._events.get("payment", []):
|
|
536
|
+
ctx = Context(self, Update(0, "payment", {"chat_id": invoice["chat_id"], "event": event}))
|
|
537
|
+
ctx.payment = event
|
|
538
|
+
await self._run_handler(ctx, handler)
|