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 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
@@ -0,0 +1,5 @@
1
+ """Асинхронный бот для тех, кому нужен ``async def``: ``from wekon.aio import Bot``."""
2
+ from .bot import Bot, Context, MemoryStorage
3
+ from .client import Client
4
+
5
+ __all__ = ["Bot", "Client", "Context", "MemoryStorage"]
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)