$ sudo teach IT
Модуль 6 · Polling, Webhook & Filters

Урок 6.1 — Invoice Polling

Изучим механизм polling для отслеживания оплаты инвойсов: декораторы, ручной poll(), автоматический start_polling() и полный жизненный цикл инвойса.

Polling (опрос) — это механизм, при котором ваш клиент периодически отправляет запросы к Crypto Pay API, чтобы узнать, изменился ли статус инвойса. В aiosend polling реализован через декораторы @cp.invoice_paid() и @cp.invoice_expired(), а также методы invoice.poll() и cp.start_polling(). В этом уроке мы разберём все способы отслеживать оплату счетов.

🔄

Что такое Polling в aiosend

Polling — это активный опрос сервера на предмет обновлений. В контексте aiosend polling означает, что ваш код периодически проверяет статус инвойса (или чека) через Crypto Pay API, пока он не будет оплачен или не истечёт.

aiosend предоставляет два подхода к polling:

Автоматический (декораторы)

  • @cp.invoice_paid()
  • @cp.invoice_expired()
  • Регистрируются на старте
  • Запуск через cp.start_polling()
  • Обработка всех инвойсов сразу

Ручной (метод poll)

  • invoice.poll()
  • Ждёт оплаты конкретного инвойса
  • Возвращает обновлённый Invoice
  • Таймаут и задержка из PollingConfig
  • Подходит для простых сценариев

В основе обоих подходов лежит один и тот же внутренний механизм: периодические запросы к getInvoices с фильтрацией по статусу. Разница лишь в уровне абстракции.

✅

Декоратор @cp.invoice_paid()

Декоратор @cp.invoice_paid() регистрирует функцию-обработчик, которая будет вызвана, когда инвойс будет оплачен. Это основной способ реакции на оплату в aiosend.

Сигнатура обработчика:

Python · Сигнатура обработчика invoice_paid
@cp.invoice_paid()
async def on_invoice_paid(invoice: Invoice, **kwargs) -> None:
    """Обработчик оплаченного инвойса.
    
    Аргументы:
        invoice: объект Invoice с обновлённым статусом PAID
        **kwargs: дополнительные аргументы от фильтров
    """
    pass

Параметр invoice содержит полную информацию о счёте: сумму, валюту, кто оплатил, когда, скрытое сообщение и т.д. **kwargs используется для передачи данных из фильтров (мы рассмотрим их в уроке 6.4).

Python · Простейший пример с invoice_paid
import asyncio
from aiosend import CryptoPay

cp = CryptoPay(token="YOUR_TOKEN")

@cp.invoice_paid()
async def on_paid(invoice, **kwargs):
    print(f"✅ Инвойс #{invoice.invoice_id} оплачен!")
    print(f"   Сумма: {invoice.amount} {invoice.asset}")
    print(f"   Покупатель: {invoice.paid_by_user_id}")
    print(f"   Время оплаты: {invoice.paid_at}")
    if invoice.hidden_message:
        print(f"   Секретное сообщение: {invoice.hidden_message}")

@cp.invoice_expired()
async def on_expired(invoice, **kwargs):
    print(f"❌ Инвойс #{invoice.invoice_id} истёк")

async def main():
    # Создаём инвойс
    invoice = await cp.create_invoice(
        amount=10,
        asset="USDT",
        description="Тестовый платёж",
        hidden_message="Спасибо за оплату!",
    )
    print(f"Счёт создан: {invoice.bot_invoice_url}")
    
    # Запускаем polling
    await cp.start_polling()

asyncio.run(main())

Когда инвойс будет оплачен покупателем, aiosend автоматически обнаружит это через polling и вызовет on_paid. Если срок инвойса истечёт — будет вызван on_expired.

🔍 Как работает invoice_paid под капотом?

Декоратор добавляет функцию-обработчик в PollingManager. Когда запускается start_polling(), менеджер в цикле вызывает getInvoices(status="paid") и для каждого нового оплаченного инвойса вызывает зарегистрированные обработчики. Аналогично для expired — через getInvoices(status="expired").

⏰

Декоратор @cp.invoice_expired()

Декоратор @cp.invoice_expired() работает аналогично invoice_paid, но реагирует на инвойсы, у которых истёк срок оплаты. Это важно для очистки и обработки просроченных счетов.

Python · Сигнатура обработчика invoice_expired
@cp.invoice_expired()
async def on_invoice_expired(invoice: Invoice, **kwargs) -> None:
    """Обработчик истёкшего инвойса.
    
    Аргументы:
        invoice: объект Invoice со статусом EXPIRED
        **kwargs: дополнительные аргументы от фильтров
    """
    # Логика обработки просрочки:
    # - отмена заказа в БД
    # - возврат средств (если была предоплата)
    # - уведомление пользователя
    pass

⚠️ Важно: порядок обработки

aiosend обрабатывает инвойсы в порядке их создания (по created_at). Если вы создали 10 инвойсов, и 5 из них оплачены, а 5 просрочены — polling сначала найдёт оплаченные, потом просроченные по мере поступления. Обработчики вызываются последовательно, но вы можете сделать их асинхронными для параллельной обработки.

На практике invoice_expired используется для:

  • Помечения заказа как «не оплачен» в вашей БД
  • Отправки уведомления покупателю о просрочке
  • Освобождения зарезервированных товаров/услуг
  • Логирования статистики просроченных платежей
Python · Полный пример с обоими декораторами
import asyncio
import logging
from datetime import datetime
from aiosend import CryptoPay
from aiosend.polling import PollingConfig

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

cp = CryptoPay(
    token="YOUR_TOKEN",
    polling_config=PollingConfig(timeout=120, delay=2),
)

paid_orders = set()

@cp.invoice_paid()
async def handle_paid(invoice, **kwargs):
    logger.info(f"💵 Инвойс {invoice.invoice_id} оплачен!")
    order_id = invoice.payload or "unknown"
    paid_orders.add(order_id)
    # Здесь: выдача товара, активация подписки и т.д.
    await process_order(order_id, invoice)

@cp.invoice_expired()
async def handle_expired(invoice, **kwargs):
    logger.warning(f"⏰ Инвойс {invoice.invoice_id} просрочен!")
    order_id = invoice.payload or "unknown"
    # Здесь: отмена заказа, уведомление пользователя
    await cancel_order(order_id)

async def process_order(order_id, invoice):
    # Имитация обработки заказа
    await asyncio.sleep(0.5)
    logger.info(f"✅ Заказ {order_id} обработан!")

async def cancel_order(order_id):
    # Имитация отмены
    logger.info(f"❌ Заказ {order_id} отменён из-за просрочки")

async def main():
    # Создаём тестовый инвойс
    invoice = await cp.create_invoice(
        amount=25,
        asset="USDT",
        description="Премиум-доступ",
        payload=f"order_{datetime.now().timestamp()}",
        expires_in=120,  # 2 минуты на оплату
    )
    logger.info(f"Счёт: {invoice.bot_invoice_url}")
    logger.info(f"Ожидание оплаты... (даётся 2 минуты)")
    
    await cp.start_polling()

if __name__ == "__main__":
    asyncio.run(main())
⏳

Метод invoice.poll() — ручное ожидание оплаты

Метод invoice.poll() позволяет дождаться оплаты конкретного инвойса. Это простой способ, когда вам не нужны декораторы и вы хотите просто «заморозить» выполнение до оплаты.

Python · Сигнатура invoice.poll()
async def poll(
    self,
    timeout: int | None = None,
    delay: float | None = None,
) -> Invoice:
    """Ожидает оплаты инвойса.
    
    Аргументы:
        timeout: максимальное время ожидания (переопределяет PollingConfig)
        delay: задержка между проверками (переопределяет PollingConfig)
    
    Возвращает:
        Invoice с обновлённым статусом (PAID или EXPIRED)
    """
    ...

Метод poll() внутри циклически вызывает get_invoices(invoice_ids=[self.invoice_id]) и проверяет статус. Как только статус меняется на PAID или EXPIRED, метод возвращает обновлённый объект Invoice.

Python · Использование invoice.poll()
import asyncio
from aiosend import CryptoPay

async def main():
    cp = CryptoPay(token="YOUR_TOKEN")
    
    # Создаём инвойс
    invoice = await cp.create_invoice(
        amount=5,
        asset="USDT",
        description="Оплата за кофе ☕",
    )
    print(f"Счёт: {invoice.bot_invoice_url}")
    print("Ожидаем оплату...")
    
    # Ждём оплаты (блокирует выполнение до оплаты)
    result = await invoice.poll(timeout=300, delay=2)
    
    if result.status == "paid":
        print(f"✅ Оплачено! Сумма: {result.paid_amount} {result.paid_asset}")
    else:
        print(f"❌ Счёт истёк: {result.status}")

asyncio.run(main())

💡 Когда использовать invoice.poll()?

Метод poll() удобен для простых скриптов и консольных утилит. Например, в Telegram-боте вы можете создать инвойс и сразу запустить poll() в отдельной таске для каждого пользователя. Однако для высоконагруженных систем предпочтительнее использовать декораторы, так как они масштабируются лучше.

Параметры timeout и delay переопределяют соответствующие значения из PollingConfig клиента. Если не переданы — используются значения по умолчанию (300 секунд timeout, 2 секунды delay).

🚀

Метод cp.start_polling() — запуск поллинга

Метод cp.start_polling() запускает бесконечный цикл опроса API. Он обрабатывает все зарегистрированные через декораторы обработчики (@cp.invoice_paid(), @cp.invoice_expired(), @cp.check_activated() и т.д.).

Python · Сигнатура start_polling()
async def start_polling(
    self,
    polling_config: PollingConfig | None = None,
    reset_webhook: bool = True,
) -> None:
    """Запускает polling-цикл.
    
    Аргументы:
        polling_config: конфигурация (переопределяет переданную в конструктор)
        reset_webhook: если True, сначала сбрасывает вебхуки
    """
    ...

Как только start_polling() запущен, он работает до тех пор, пока не будет прерван (например, KeyboardInterrupt). Внутри он использует PollingManager, который управляет циклом:

Python · Псевдокод работы start_polling()
async def start_polling(self, polling_config=None, reset_webhook=True):
    config = polling_config or self._polling_config
    
    if reset_webhook:
        await self._delete_webhook()
    
    manager = PollingManager(self, config)
    await manager.run()
    
# PollingManager.run() внутри:
async def run(self):
    while True:
        try:
            # Проверяем новые оплаченные инвойсы
            paid = await self.client.get_invoices(status="paid")
            for invoice in paid:
                await self._process_paid(invoice)
            
            # Проверяем новые просроченные инвойсы
            expired = await self.client.get_invoices(status="expired")
            for invoice in expired:
                await self._process_expired(invoice)
            
            # Проверяем новые чеки (см. урок 6.2)
            activated = await self.client.get_checks(status="activated")
            for check in activated:
                await self._process_activated(check)
            
            # Ждём перед следующим запросом
            await asyncio.sleep(self.config.delay)
            
        except asyncio.CancelledError:
            break
        except Exception as e:
            logger.error(f"Polling error: {e}")
            await asyncio.sleep(self.config.delay)

⚠️ start_polling() и reset_webhook=True

По умолчанию reset_webhook=True. Это значит, что перед запуском polling aiosend отправит запрос deleteWebhook в Crypto Pay API, чтобы отключить вебхуки. Если вы используете вебхуки, передайте reset_webhook=False.

Python · Запуск polling с кастомной конфигурацией
import asyncio
from aiosend import CryptoPay
from aiosend.polling import PollingConfig

cp = CryptoPay(
    token="YOUR_TOKEN",
    polling_config=PollingConfig(timeout=600, delay=3),
)

@cp.invoice_paid()
async def on_paid(invoice, **kwargs):
    print(f"Инвойс {invoice.invoice_id} оплачен!")

async def main():
    # Переопределяем конфиг при запуске
    await cp.start_polling(
        polling_config=PollingConfig(timeout=120, delay=1),
        reset_webhook=False,
    )

# Этот код будет работать вечно, пока не прервать его
asyncio.run(main())
🔁

Жизненный цикл инвойса с polling

Давайте проследим полный путь инвойса от создания до финального статуса с использованием polling:

Диаграмма жизненного цикла инвойса

           ┌─────────────────────────────────────────────┐
           │        1. Создание инвойса                    │
           │  cp.create_invoice(amount, asset, ...)       │
           │         → status = ACTIVE                    │
           └────────────────┬────────────────────────────┘
                            │
                            ▼
           ┌─────────────────────────────────────────────┐
           │        2. Запуск Polling                      │
           │  cp.start_polling()  или  invoice.poll()     │
           │  → периодический опрос getInvoices()         │
           │  → задержка между запросами (delay=2s)       │
           └────────────────┬────────────────────────────┘
                            │
              ┌─────────────┴─────────────┐
              ▼                           ▼
   ┌─────────────────────┐   ┌─────────────────────┐
   │ 3a. Оплачен          │   │ 3b. Истёк срок      │
   │ Покупатель платит    │   │ expires_in истекло   │
   │ → status = PAID     │   │ → status = EXPIRED  │
   │ Вызов on_paid()     │   │ Вызов on_expired()  │
   │ invoice_paid декоратор│  │ invoice_expired     │
   └─────────────────────┘   └─────────────────────┘
              ▼                           ▼
   ┌─────────────────────┐   ┌─────────────────────┐
   │ 4a. Обработка        │   │ 4b. Обработка       │
   │ Выдача товара/услуги │   │ Отмена заказа       │
   │ Активация подписки   │   │ Уведомление         │
   │ Логирование          │   │ Освобождение товара │
   └─────────────────────┘   └─────────────────────┘

Разберём каждый шаг подробно:

Шаг Что происходит Статус инвойса Код
1 Создание счёта ACTIVE create_invoice()
2 Запуск polling, ожидание ACTIVE start_polling()
3a Покупатель оплатил PAID Декоратор @cp.invoice_paid()
3b Срок истёк EXPIRED Декоратор @cp.invoice_expired()
4a Бизнес-логика: выдача товара PAID Ваша функция-обработчик
4b Бизнес-логика: отмена EXPIRED Ваша функция-обработчик

Важно понимать: poll() и start_polling() используют один и тот же внутренний механизм — PollingManager. Разница в том, что poll() ждёт конкретный инвойс, а start_polling() обрабатывает все инвойсы через декораторы.

💡 Polling in background

Вы можете запустить start_polling() как фоновую задачу с помощью asyncio.create_task(). Это позволяет вашему приложению продолжать работу (например, отвечать на команды бота) параллельно с polling.

🧩

Полный пример: бот с обработкой платежей

Давайте соберём всё вместе и напишем полноценный скрипт, который создаёт инвойс, запускает polling в фоне и обрабатывает оплату/просрочку.

Python · Полный пример с фоновым polling
import asyncio
import logging
from aiosend import CryptoPay
from aiosend.polling import PollingConfig
from aiosend.exceptions import APIError

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger(__name__)

class PaymentBot:
    """Простой бот для приёма платежей через Crypto Pay."""
    
    def __init__(self, token: str):
        self.cp = CryptoPay(
            token=token,
            polling_config=PollingConfig(timeout=300, delay=2),
        )
        self._register_handlers()
    
    def _register_handlers(self):
        @self.cp.invoice_paid()
        async def on_paid(invoice, **kwargs):
            logger.info(f"💰 Получен платёж #{invoice.invoice_id}")
            logger.info(f"   Сумма: {invoice.amount} {invoice.asset}")
            logger.info(f"   Payload: {invoice.payload}")
            
            # Извлекаем user_id из payload
            if invoice.payload and invoice.payload.startswith("user_"):
                user_id = invoice.payload.split("_")[1]
                logger.info(f"   Пользователь #{user_id} оплатил!")
                # Здесь: активация подписки, отправка уведомления и т.д.
                await self.activate_subscription(int(user_id), invoice)
        
        @self.cp.invoice_expired()
        async def on_expired(invoice, **kwargs):
            logger.warning(f"⏰ Платёж #{invoice.invoice_id} просрочен")
            if invoice.payload and invoice.payload.startswith("user_"):
                user_id = invoice.payload.split("_")[1]
                await self.notify_expired(int(user_id), invoice)
    
    async def activate_subscription(self, user_id: int, invoice):
        """Активирует подписку для пользователя."""
        # Имитация бизнес-логики
        await asyncio.sleep(0.3)
        logger.info(f"   ✅ Подписка активирована для user #{user_id}")
    
    async def notify_expired(self, user_id: int, invoice):
        """Уведомляет об истечении срока."""
        logger.info(f"   📧 Уведомление отправлено user #{user_id}")
    
    async def create_payment(self, user_id: int, amount: float,
                            description: str = "") -> str:
        """Создаёт инвойс для пользователя."""
        invoice = await self.cp.create_invoice(
            amount=amount,
            asset="USDT",
            description=description or f"Оплата для user #{user_id}",
            payload=f"user_{user_id}",
            expires_in=600,  # 10 минут
        )
        logger.info(f"📄 Счёт создан: #{invoice.invoice_id}")
        return invoice.bot_invoice_url
    
    async def run(self):
        """Запускает polling в фоне."""
        logger.info("🚀 Запуск polling...")
        try:
            await self.cp.start_polling()
        except asyncio.CancelledError:
            logger.info("Polling остановлен")

async def main():
    bot = PaymentBot(token="YOUR_TOKEN")
    
    # Создаём тестовый платёж
    url = await bot.create_payment(
        user_id=12345,
        amount=15,
        description="Премиум-доступ на 1 месяц",
    )
    print(f"Ссылка на оплату: {url}")
    
    # Запускаем обработку платежей
    polling_task = asyncio.create_task(bot.run())
    
    # Ждём завершения (по Ctrl+C)
    try:
        await polling_task
    except KeyboardInterrupt:
        polling_task.cancel()
        await polling_task

if __name__ == "__main__":
    asyncio.run(main())

✅ Что мы получили?

Класс PaymentBot инкапсулирует всю логику работы с платежами: создание инвойсов, обработку оплат и просрочек. Мы можем создать несколько экземпляров для разных токенов или добавить методы для разных типов платежей. Polling работает в фоновой задаче, не блокируя основной поток.

📎

Дополнительные примеры использования

Рассмотрим ещё несколько практических сценариев, демонстрирующих гибкость polling в aiosend.

Пример: Множественная обработка с фильтрацией

Вы можете зарегистрировать несколько обработчиков на одно и то же событие. Все они будут вызваны последовательно:

Python · Несколько обработчиков invoice_paid
import asyncio
from aiosend import CryptoPay
from aiosend.polling import PollingConfig

cp = CryptoPay(
    token="YOUR_TOKEN",
    polling_config=PollingConfig(timeout=300, delay=2),
)

# Первый обработчик — логирование
@cp.invoice_paid()
async def log_payment(invoice, **kwargs):
    print(f"[LOG] Инвойс #{invoice.invoice_id} оплачен")
    print(f"[LOG] Сумма: {invoice.amount} {invoice.asset}")

# Второй обработчик — бизнес-логика
@cp.invoice_paid()
async def process_payment(invoice, **kwargs):
    print(f"[PROCESS] Обработка заказа для инвойса #{invoice.invoice_id}")
    await asyncio.sleep(0.5)  # имитация работы
    print(f"[PROCESS] Заказ обработан!")

# Третий обработчик — уведомление
@cp.invoice_paid()
async def notify_user(invoice, **kwargs):
    print(f"[NOTIFY] Отправка уведомления о платеже #{invoice.invoice_id}")

async def main():
    invoice = await cp.create_invoice(amount=10, asset="USDT")
    print(f"Счёт: {invoice.bot_invoice_url}")
    await cp.start_polling()

asyncio.run(main())

Пример: Polling с таймаутом и retry

Иногда нужно перезапустить polling при ошибках. Вот шаблон с обработкой исключений:

Python · Polling с retry
import asyncio
import logging
from aiosend import CryptoPay
from aiosend.polling import PollingConfig
from aiosend.exceptions import APIError

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

async def run_polling_with_retry(cp: CryptoPay, max_retries: int = 3):
    """Запускает polling с автоматическим перезапуском при ошибках."""
    retries = 0
    while retries < max_retries:
        try:
            logger.info(f"Запуск polling (попытка {retries + 1})")
            await cp.start_polling()
        except APIError as e:
            retries += 1
            logger.error(f"Ошибка API: {e}")
            if retries >= max_retries:
                logger.critical("Превышено количество попыток")
                raise
            wait = 2 ** retries  # exponential backoff
            logger.info(f"Повтор через {wait} секунд...")
            await asyncio.sleep(wait)
        except asyncio.CancelledError:
            logger.info("Polling остановлен")
            break

@cp.invoice_paid()
async def on_paid(invoice, **kwargs):
    logger.info(f"Инвойс #{invoice.invoice_id} оплачен!")

async def main():
    cp = CryptoPay(token="YOUR_TOKEN")
    try:
        await run_polling_with_retry(cp)
    except KeyboardInterrupt:
        logger.info("Завершение...")

asyncio.run(main())
⚠️

Ошибки и граничные случаи

Рассмотрим типичные проблемы при работе с polling и способы их решения.

Ошибка 1: PollingTimeoutError

Инвойс не был оплачен в течение polling_config.timeout.

# invoice.poll() с маленьким таймаутом
invoice = await cp.create_invoice(amount=10, asset="USDT")
try:
    result = await invoice.poll(timeout=10)  # всего 10 секунд
except PollingTimeoutError:
    print("Не дождались оплаты за 10 секунд")

# Решение: увеличьте timeout или используйте декораторы

Ошибка 2: Двойная обработка одного инвойса

Если вы зарегистрируете несколько @cp.invoice_paid(), все они будут вызваны для одного инвойса.

@cp.invoice_paid()
async def handler1(invoice, **kwargs):
    print("Handler 1")  # будет вызван

@cp.invoice_paid()
async def handler2(invoice, **kwargs):
    print("Handler 2")  # тоже будет вызван!

# Оба обработчика получат один и тот же инвойс.
# Если нужна единственная обработка — используйте
# флаги в БД или ID инвойса.

Ошибка 3: Забыли запустить start_polling()

Декораторы регистрируют обработчики, но они не будут вызваны, пока не запущен start_polling().

cp = CryptoPay("TOKEN")

@cp.invoice_paid()
async def on_paid(invoice, **kwargs):
    print("Никогда не будет вызвано :(")

# Забыли:
# await cp.start_polling()

# Решение: всегда вызывайте start_polling() после регистрации
# всех обработчиков

Граничный случай: Инвойс оплачен до запуска polling

Если покупатель оплатил инвойс до того, как вы запустили start_polling(), обработчик invoice_paid всё равно будет вызван, потому что при запуске polling проверяет все оплаченные инвойсы. Это гарантирует, что ни один платёж не будет пропущен.

📌

Что важно запомнить

1️⃣
Два подхода к polling: декораторы (@cp.invoice_paid(), @cp.invoice_expired()) для автоматической обработки и invoice.poll() для ручного ожидания конкретного инвойса.
2️⃣
start_polling() обязателен. Без него декораторы не будут работать. Запускайте polling после регистрации всех обработчиков.
3️⃣
Сигнатура обработчика: async def handler(invoice: Invoice, **kwargs). invoice содержит все данные о счёте, **kwargs — для фильтров.
4️⃣
PollingConfig: timeout (макс. время ожидания) и delay (задержка между запросами). По умолчанию 300s / 2s.
5️⃣
reset_webhook=True по умолчанию. Если используете вебхуки, передавайте reset_webhook=False.
💻

Практическая задача

Задача: Система обработки платежей с polling

Напишите скрипт payment_system.py, который:

  1. Создаёт клиента CryptoPay с кастомным PollingConfig (timeout=600, delay=3).
  2. Регистрирует два обработчика через декораторы: invoice_paid и invoice_expired.
  3. В обработчике paid выводит: ID инвойса, сумму, валюту, payload, время оплаты.
  4. В обработчике expired выводит: ID инвойса, причину просрочки, предлагает создать новый счёт.
  5. Создаёт три инвойса с разными суммами и payload (для разных товаров).
  6. Запускает polling и обрабатывает все платежи.
  7. Реализует корректную обработку KeyboardInterrupt для graceful shutdown.

Ожидаемый вывод при оплате инвойса:

✅ Инвойс #12345 оплачен!
   Сумма: 25.00 USDT
   Payload: subscription_premium
   Оплачено в: 2024-12-01T12:34:56+00:00

❌ Инвойс #12346 истёк
   Payload: subscription_basic
   Создайте новый счёт для этого пользователя

Урок 6.1: Invoice Polling

8 вопросов