Перейти к содержанию

Telegram-бот с TON Connect

Введение

Это руководство демонстрирует, как интегрировать TON Connect в Telegram-бота с использованием библиотеки tonutils — Python SDK для взаимодействия с TON.

Функциональность бота включает:

  • Подключение кошелька через QR-код или ссылку
  • Подписание произвольных данных
  • Отправку транзакций

Реализация спроектирована с учётом продакшн-практик: надёжное хранение сессий, асинхронная архитектура, защита от спама, минималистичный пользовательский интерфейс на inline-кнопках.

Tip

Перед началом рекомендуется ознакомиться с документацией Рецепты: Интеграция TON Connect

Подготовка

Перед началом работы выполните следующие шаги.

Создание Telegram-бота

  1. Откройте @BotFather в Telegram.
  2. Введите команду /newbot и следуйте инструкциям.
  3. Сохраните токен бота.

Создание манифеста TonConnect

Создайте JSON-файл, описывающий ваше приложение. Этот манифест будет отображаться в кошельке при подключении.

{
  "url": "<app-url>",                        // required
  "name": "<app-name>",                      // required
  "iconUrl": "<app-icon-url>",               // required
  "termsOfUseUrl": "<terms-of-use-url>",     // optional
  "privacyPolicyUrl": "<privacy-policy-url>" // optional
}

Note

Убедитесь, что файл доступен по-указанному URL.

Подробная спецификация доступна в официальной документации по манифесту.

Установка зависимостей

Создайте файл requirements.txt со следующим содержимым:

aiogram~=3.20.0
cachetools==5.5.2
environs==14.2.0
redis==6.2.0
tonutils==0.4.4

Установите все зависимости с помощью команды:

pip install -r requirements.txt

Конфигурация окружения

Создайте файл .env в корне проекта и укажите следующие переменные:

BOT_TOKEN=ваш_токен_бота
REDIS_DSN=redis://localhost:6379/0
TC_MANIFEST=https://ваш-домен/manifest.json

Описание переменных:

  • BOT_TOKEN — токен Telegram-бота, полученный через @BotFather.
  • REDIS_DSN — строка подключения к Redis, для хранения сессий и состояний.
  • TC_MANIFEST — HTTPS-ссылка на публично доступный манифест TON Connect.

Структура проекта

├── .env
├── requirements.txt
└── src/
    ├── utils/
    │   ├── __init__.py
    │   ├── keyboards.py
    │   ├── models.py
    │   ├── storage.py
    │   └── windows.py
    ├── __init__.py
    ├── __main__.py
    ├── events.py
    ├── handlers.py
    ├── middlewares.py
    └── session_manager.py

Реализация

Раздел состоит из отдельных модулей, каждый из которых отвечает за конкретную часть логики. Далее рассмотрим их по порядку.

Конфигурация

Файл: src/utils/models.py

Модуль отвечает за:

  • Загрузку конфигурационных переменных из .env файла.
  • Формирование объекта Context, содержащего основные зависимости.

Состав Context

  • bot: Bot — экземпляр Telegram-бота (aiogram).
  • state: FSMContext — контекст состояний пользователя.
  • tc: TonConnect — основной объект для работы с TON Connect.
  • connector: Connector — сессия, инициализированная для конкретного ID.

Использование

  • При запуске вызывается Config.load() — он загружает настройки из .env.
  • В middleware создаётся объект Context, который передаётся во все обработчики, предоставляя доступ к нужным зависимостям.
Пример кода
from __future__ import annotations

from dataclasses import dataclass

from aiogram import Bot
from aiogram.fsm.context import FSMContext
from environs import Env
from tonutils.tonconnect import TonConnect, Connector


@dataclass
class Context:
    """
    Aggregated context object passed throughout the bot's logic.

    :param bot: The bot instance used to send and receive messages.
    :param state: Finite State Machine context for user session management.
    :param tc: Instance of TonConnect for managing wallet connections.
    :param connector: Connector used to communicate with a specific wallet.
    """
    bot: Bot
    state: FSMContext
    tc: TonConnect
    connector: Connector


@dataclass
class Config:
    """
    Configuration data loaded from the environment.

    :param BOT_TOKEN: Telegram bot token.
    :param REDIS_DSN: Redis connection string for FSM or other caching.
    :param TC_MANIFEST: URL to the TonConnect manifest file.
    """
    BOT_TOKEN: str
    REDIS_DSN: str
    TC_MANIFEST: str

    @classmethod
    def load(cls) -> Config:
        """
        Loads configuration from environment variables using .env file.

        :return: An instance of Config populated with environment values.
        """
        env = Env()
        env.read_env()

        return cls(
            BOT_TOKEN=env.str("BOT_TOKEN"),
            REDIS_DSN=env.str("REDIS_DSN"),
            TC_MANIFEST=env.str("TC_MANIFEST"),
        )

Хранение сессий

Файл: src/utils/storage.py

В веб-версии TON Connect сессии кошельков сохраняются в localStorage. Однако в серверной среде на Python необходимо реализовать собственную систему хранения.

В этом проекте используется Redis: данные сессий сохраняются с привязкой к Telegram ID пользователя.

Задачи модуля

  • Сохранять данные TON Connect по Telegram ID.
  • Извлекать данные сессии при подключении пользователя.
  • Удалять сессии при отключении или очистке.

Такая реализация обеспечивает надёжное восстановление пользовательского состояния между сессиями.

Пример кода
from typing import Optional

from redis.asyncio import Redis
from tonutils.tonconnect import IStorage


class RedisStorage(IStorage):
    """
    Redis-based implementation of the IStorage interface.
    Used for storing TonConnect session data.

    :param redis_client: Redis connection instance.
    """

    def __init__(self, redis_client: Redis):
        self.redis = redis_client

    async def set_item(self, key: str, value: str) -> None:
        """
        Store a key-value pair in Redis.

        :param key: The key to store.
        :param value: The value to associate with the key.
        """
        async with self.redis.client() as client:
            await client.set(name=key, value=value)

    async def get_item(self, key: str, default_value: Optional[str] = None) -> Optional[str]:
        """
        Retrieve a value from Redis by key.

        :param key: The key to retrieve.
        :param default_value: Value to return if key is not found.
        :return: Retrieved value or default_value.
        """
        async with self.redis.client() as client:
            value = await client.get(name=key)
            return value if value else default_value

    async def remove_item(self, key: str) -> None:
        """
        Remove a key-value pair from Redis.

        :param key: The key to remove.
        """
        async with self.redis.client() as client:
            await client.delete(key)

Очистка сообщений

Файл: src/utils/__init__.py

Модуль отвечает за автоматическое удаление предыдущего сообщения пользователя перед отправкой нового. Это предотвращает накопление сообщений и обеспечивает чистый, лаконичный интерфейс.

Принцип работы

  • В FSM-состоянии пользователя хранится message_id последнего сообщения от бота.
  • Перед отправкой нового бот пытается удалить старое сообщение.
  • После отправки нового сохраняется его message_id для последующей очистки.

Это особенно полезно для ботов с динамическим UI (inline-кнопки, обновляемые окна).

Пример кода
from contextlib import suppress

from .models import Context


async def delete_last_message(context: Context, user_id: int, message_id: int) -> None:
    """
    Delete the previously stored message and store the new one for future cleanup.

    :param context: Current context with bot and FSM state.
    :param user_id: Telegram user ID.
    :param message_id: New message ID to store.
    """
    state_data = await context.state.get_data()
    last_message_id = state_data.get("last_message_id")

    if last_message_id is not None:
        with suppress(Exception):
            await context.bot.delete_message(chat_id=user_id, message_id=last_message_id)

    await context.state.update_data(last_message_id=message_id)

Клавиатуры

Файл: src/utils/keyboards.py

Модуль формирует inline-клавиатуры для взаимодействия пользователя с ботом. Он обеспечивает удобную навигацию и быстрое выполнение действий.

Основные типы клавиатур

  • Подключение кошелька — список доступных кошельков с кнопками подключения.
  • Подтверждение запроса — кнопки для открытия кошелька или отмены запроса.
  • Главное меню — отправка транзакции, пакетная отправка, подписание данных, отключение.
  • Выбор формата подписи — текст, бинарный или ячейка.
  • Возврат в меню — кнопка возвращения на главный экран.

Клавиатуры адаптированы под разные сценарии использования и обновляются в зависимости от состояния пользователя.

Пример кода
from typing import List

from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from tonutils.tonconnect.models import WalletApp


def connect_wallet(wallets: List[WalletApp], selected_wallet: WalletApp, connect_url: str) -> InlineKeyboardMarkup:
    """
    Build a keyboard for selecting a wallet and connecting it.

    :param wallets: List of available wallet apps.
    :param selected_wallet: Currently selected wallet app.
    :param connect_url: Connection URL for the selected wallet.
    :return: Inline keyboard with wallet selection and connect button.
    """
    wallets_button = [
        InlineKeyboardButton(
            text=f"• {wallet.name} •" if wallet.app_name == selected_wallet.app_name else wallet.name,
            callback_data=f"app_wallet:{wallet.app_name}",
        ) for wallet in wallets
    ]
    connect_wallet_button = InlineKeyboardButton(
        text=f"Connect {selected_wallet.name}",
        url=connect_url,
    )
    builder = InlineKeyboardBuilder()
    builder.row(connect_wallet_button)
    builder.row(*wallets_button, width=2)

    return builder.as_markup()


def confirm_request(url: str, wallet_name: str) -> InlineKeyboardMarkup:
    """
    Build a keyboard to confirm or cancel the current request.

    :param url: URL to open the wallet for confirmation.
    :param wallet_name: Name of the wallet.
    :return: Inline keyboard with confirm and cancel buttons.
    """
    return InlineKeyboardMarkup(
        inline_keyboard=[
            [InlineKeyboardButton(text=f"Open {wallet_name}", url=url)],
            [InlineKeyboardButton(text="Cancel", callback_data="cancel_transaction")],
        ]
    )


def choose_action() -> InlineKeyboardMarkup:
    """
    Build the main menu keyboard for wallet actions.

    :return: Inline keyboard with wallet action options.
    """
    builder = InlineKeyboardBuilder()
    builder.row(InlineKeyboardButton(text="Send Transaction", callback_data="send_transaction"))
    builder.row(InlineKeyboardButton(text="Send Batch Transaction", callback_data="send_batch_transaction"))
    builder.row(InlineKeyboardButton(text="Send Sign Data Request", callback_data="send_sign_data_request"))
    builder.row(InlineKeyboardButton(text="Disconnect Wallet", callback_data="disconnect_wallet"))

    return builder.as_markup()


def choose_sign_data_type() -> InlineKeyboardMarkup:
    """
    Build a keyboard to choose a sign data format.

    :return: Inline keyboard with sign data format options.
    """
    builder = InlineKeyboardBuilder()
    builder.add(InlineKeyboardButton(text="Text", callback_data="send_sign_data_request:text"))
    builder.add(InlineKeyboardButton(text="Binary", callback_data="send_sign_data_request:binary"))
    builder.add(InlineKeyboardButton(text="Cell", callback_data="send_sign_data_request:cell"))
    builder.row(InlineKeyboardButton(text="Main Menu", callback_data="main_menu"))

    return builder.as_markup()


def go_to_main_menu() -> InlineKeyboardMarkup:
    """
    Build a keyboard with a single button to return to the main menu.

    :return: Inline keyboard with a main menu button.
    """
    builder = InlineKeyboardBuilder()
    builder.row(InlineKeyboardButton(text="Main Menu", callback_data="main_menu"))

    return builder.as_markup()

Интерфейсные окна

Файл: src/utils/windows.py

Модуль управляет отображением сообщений и inline-клавиатур в Telegram-чате. Он реализует интерфейсные "экраны" — логически завершённые состояния взаимодействия с пользователем.

Основные функции

  • Экран подключения кошелька — отображает список кошельков, генерирует ton_proof, создаёт ссылку подключения и QR-код.
  • Главное меню — показывает адрес подключённого кошелька и действия: отправка, подпись, отключение.
  • Подтверждение запросов — предлагает пользователю подтвердить действие в приложении-кошельке.
  • Результаты операций — отображает хеши транзакций, результаты подписей и их проверку.
  • Обработка ошибок — сообщает об ошибках и предлагает повторить попытку или вернуться в меню.
Пример кода
import base64
import json

from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.utils.markdown import hide_link, hblockquote, hbold
from tonutils.tonconnect.models import Event, SendTransactionResponse, SignDataResponse
from tonutils.tonconnect.utils import generate_proof_payload

from ..utils import Context, delete_last_message
from ..utils import keyboards


async def connect_wallet(context: Context, user_id: int) -> None:
    """
    Show wallet selection and QR code for connection.

    :param context: Execution context.
    :param user_id: Telegram user ID.
    """
    state_data = await context.state.get_data()
    wallets = await context.tc.get_wallets()
    selected_wallet_name = state_data.get("selected_wallet", wallets[0].app_name)

    selected_wallet = next((w for w in wallets if w.app_name == selected_wallet_name), wallets[0])
    redirect_url = "https://t.me/tonconnect_demo_bot"
    ton_proof = generate_proof_payload()

    await context.state.update_data(ton_proof=ton_proof)
    context.connector.add_event_kwargs(Event.CONNECT, state=context.state)

    connect_url = await context.connector.connect_wallet(
        wallet_app=selected_wallet,
        redirect_url=redirect_url,
        ton_proof=ton_proof,
    )

    qrcode_url = (
        f"https://qrcode.ness.su/create?"
        f"box_size=20&border=7&image_padding=20"
        f"&data={base64.b64encode(connect_url.encode()).decode()}"
        f"&image_url={base64.b64encode(selected_wallet.image.encode()).decode()}"
    )

    text = f"{hide_link(qrcode_url)}<b>Connect your wallet!</b>"
    reply_markup = keyboards.connect_wallet(wallets, selected_wallet, connect_url)

    message = await context.bot.send_message(chat_id=user_id, text=text, reply_markup=reply_markup)
    await delete_last_message(context, user_id, message.message_id)


async def wallet_connected(context: Context, user_id: int) -> None:
    """
    Show connected wallet address and main menu.

    :param context: Execution context.
    :param user_id: Telegram user ID.
    """
    wallet_address = context.connector.wallet.account.address.to_str(is_bounceable=False)
    reply_markup = keyboards.choose_action()
    text = f"<b>Connected wallet:</b>\n{hblockquote(wallet_address)}\n\nChoose an action:"

    message = await context.bot.send_message(chat_id=user_id, text=text, reply_markup=reply_markup)
    await delete_last_message(context, user_id, message.message_id)


async def send_request(context: Context, user_id: int) -> None:
    """
    Prompt user to confirm the request in wallet.

    :param context: Execution context.
    :param user_id: Telegram user ID.
    """
    reply_markup = keyboards.confirm_request(
        url=context.connector.wallet_app.direct_url,
        wallet_name=context.connector.wallet_app.name,
    )
    text = "<b>Please confirm the request in your wallet.</b>"

    message = await context.bot.send_message(chat_id=user_id, text=text, reply_markup=reply_markup)
    await delete_last_message(context, user_id, message.message_id)


async def transaction_sent(context: Context, user_id: int, transaction: SendTransactionResponse) -> None:
    """
    Show transaction confirmation and details.

    :param context: Execution context.
    :param user_id: Telegram user ID.
    :param transaction: Transaction result.
    """
    text = (
        "<b>Transaction sent!</b>\n\n"
        f"Normalized hash:\n{hblockquote(transaction.normalized_hash)}\n"
        f"BoC:\n{hblockquote(transaction.boc)}\n"
    )
    reply_markup = keyboards.go_to_main_menu()

    message = await context.bot.send_message(chat_id=user_id, text=text, reply_markup=reply_markup)
    await delete_last_message(context, user_id, message.message_id)


async def choose_sign_data_type(context: Context, user_id: int) -> None:
    """
    Show menu to select data type for signing.

    :param context: Execution context.
    :param user_id: Telegram user ID.
    """
    text = "<b>Choose the type of data you want to sign:</b>"
    reply_markup = keyboards.choose_sign_data_type()

    message = await context.bot.send_message(chat_id=user_id, text=text, reply_markup=reply_markup)
    await delete_last_message(context, user_id, message.message_id)


async def sign_data_sent(context: Context, user_id: int, sign_data: SignDataResponse) -> None:
    """
    Show signed data result and verification.

    :param context: Execution context.
    :param user_id: Telegram user ID.
    :param sign_data: Sign data result.
    """
    is_signed = sign_data.verify_sign_data(context.connector.account.public_key)

    if is_signed:
        text = (
            "<b>Data successfully signed!</b>\n\n"
            f"Payload:\n{hblockquote(json.dumps(sign_data.result.payload.to_dict(), indent=4))}"
        )
    else:
        text = (
            "<b>Failed to verify the signed data.</b>\n"
            "The signature may be invalid or tampered."
        )

    reply_markup = keyboards.go_to_main_menu()

    message = await context.bot.send_message(chat_id=user_id, text=text, reply_markup=reply_markup)
    await delete_last_message(context, user_id, message.message_id)


async def error(context: Context, user_id: int, message_text: str, button_text: str, callback_data: str) -> None:
    """
    Show error message with a retry button.

    :param context: Execution context.
    :param user_id: Telegram user ID.
    :param message_text: Text to show in the error message.
    :param button_text: Text for the retry button.
    :param callback_data: Callback data for retry action.
    """
    builder = InlineKeyboardBuilder()
    builder.row(InlineKeyboardButton(text=button_text, callback_data=callback_data))
    reply_markup = builder.as_markup()

    message = await context.bot.send_message(chat_id=user_id, text=hbold(message_text), reply_markup=reply_markup)
    await delete_last_message(context, user_id, message.message_id)

Очистка сессий

Файл: src/session_manager.py

Модуль реализует фоновую задачу, которая отслеживает активность пользователей и приостанавливает неактивные SSE-соединения TON Connect, снижая нагрузку.

Основные механизмы

  • Метки времени последней активности пользователей сохраняются в Redis с использованием отсортированного множества (ZSET).
  • С определённым интервалом происходит обход пользователей, неактивных дольше установленного времени (session_lifetime).
  • Для неактивных вызывается метод pause_sse() на их коннекторе, что освобождает ресурсы.
  • После успешной приостановки запись о пользователе удаляется из Redis.

Параметры настройки

  • session_lifetime — максимальная длительность не активности, по умолчанию 1 час.
  • check_interval — периодичность проверки, по умолчанию 10 минут.
  • redis_key — ключ Redis, используемый для хранения временных меток активности.

Этот механизм полезен в продакшн-среде, где открытые соединения имеют стоимость и должны быть контролируемыми.

Пример кода
import asyncio
import time
from contextlib import suppress

from redis.asyncio import Redis
from tonutils.tonconnect import TonConnect


class TonConnectSessionManager:
    """
    Closes inactive TonConnect sessions using Redis-based activity tracking.
    """

    def __init__(
            self,
            redis: Redis,
            tc: TonConnect,
            session_lifetime: int = 3600,
            check_interval: int = 600,
            redis_key: str = "tonconnect:last_seen",
    ) -> None:
        """
        :param redis: Redis client instance.
        :param tc: TonConnect instance.
        :param session_lifetime: Inactivity threshold in seconds.
        :param check_interval: Interval between cleanup runs in seconds.
        :param redis_key: Redis sorted set key for storing user activity.
        """
        self.redis = redis
        self.tc = tc
        self.session_lifetime = session_lifetime
        self.check_interval = check_interval
        self.redis_key = redis_key
        self._running = False

    async def update(self, user_id: int) -> None:
        """
        Register user activity by storing a timestamp in Redis.

        :param user_id: Telegram user ID.
        """
        await self.redis.zadd(self.redis_key, {str(user_id): time.time()})

    async def _cleanup(self, cutoff: float) -> None:
        """
        Close sessions for users inactive since the given timestamp.

        :param cutoff: UNIX timestamp used as inactivity threshold.
        """
        user_ids = await self.redis.zrangebyscore(
            self.redis_key, min=0, max=cutoff, start=0, num=100
        )
        if not user_ids:
            return

        for raw_id in user_ids:
            user_id = int(raw_id)
            connector = await self.tc.get_connector(user_id)
            if connector and connector.connected and not connector.bridge.is_session_closed:
                with suppress(Exception):
                    await connector.bridge.pause_sse()

            await self.redis.zrem(self.redis_key, user_id)

    async def start(self) -> None:
        """
        Launch the background task for periodic session cleanup.
        """
        self._running = True
        while self._running:
            cutoff = time.time() - self.session_lifetime
            await self._cleanup(cutoff)

            await asyncio.sleep(self.check_interval)

    def run(self) -> None:
        loop = asyncio.get_running_loop()
        loop.create_task(self.start())

    def stop(self) -> None:
        """
        Stop the background cleanup loop.
        """
        self._running = False

Промежуточные слои

Файл: src/middlewares.py

Модуль реализует два ключевых промежуточных слоя (middleware) для Telegram-бота:

ContextMiddleware

  • Создаёт объект Context при каждом входящем обновлении.
  • Также обновляет метку активности пользователя — это позволяет корректно отслеживать и закрывать неактивные соединения.

ThrottlingMiddleware

  • Минимальная защита от спама.
  • Использует TTL-кеш, ограничивая частоту запросов от одного пользователя.
Пример кода
from contextlib import suppress
from typing import Callable, Dict, Any, Awaitable, Optional

from aiogram import BaseMiddleware, Bot, Dispatcher
from aiogram.fsm.context import FSMContext
from aiogram.types import TelegramObject, User
from cachetools import TTLCache
from tonutils.tonconnect import TonConnect

from .session_manager import TonConnectSessionManager
from .utils.models import Context


class ContextMiddleware(BaseMiddleware):
    """
    Middleware to inject a custom Context object into handler data.
    """

    def __init__(self, tc_session_manager: TonConnectSessionManager) -> None:
        self.tc_session_manager = tc_session_manager

    async def __call__(
            self,
            handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
            event: TelegramObject,
            data: Dict[str, Any],
    ) -> Any:
        """
        Inject context if event is from a valid user.

        :param handler: Event handler to call next.
        :param event: Incoming Telegram update.
        :param data: Handler context data.
        :return: Handler result.
        """
        user: User = data.get("event_from_user")

        if user and not user.is_bot:
            await self.tc_session_manager.update(user.id)

            bot: Bot = data.get("bot")
            tc: TonConnect = data.get("tc")
            state: FSMContext = data.get("state")
            connector = await tc.init_connector(user.id)

            context = Context(
                bot=bot,
                state=state,
                tc=tc,
                connector=connector,
            )

            tc["context"] = context
            data["context"] = context

        return await handler(event, data)


class ThrottlingMiddleware(BaseMiddleware):
    """
    Middleware to prevent spam by throttling user input.
    """

    def __init__(self, ttl: float = 0.7) -> None:
        """
        :param ttl: Time-to-live in seconds for each user.
        """
        self.cache = TTLCache(maxsize=10_000, ttl=ttl)

    async def __call__(
            self,
            handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
            event: TelegramObject,
            data: Dict[str, Any],
    ) -> Optional[Any]:
        """
        Block repeated events from the same user within TTL.

        :param handler: Event handler to call next.
        :param event: Incoming Telegram update.
        :param data: Handler context data.
        :return: Handler result or None if throttled.
        """
        user: Optional[User] = data.get("event_from_user")

        if user and user.id in self.cache:
            with suppress(Exception):
                await getattr(event, "message", None).delete()
            return None

        if user:
            self.cache[user.id] = None

        return await handler(event, data)


def register_middlewares(dp: Dispatcher) -> None:
    """
    Register all middlewares in the dispatcher.

    :param dp: Aiogram dispatcher instance.
    """
    dp.update.middleware.register(ContextMiddleware(dp["tc_session_manager"]))
    dp.update.middleware.register(ThrottlingMiddleware())


__all__ = ["register_middlewares"]

Обработка событий

Файл: src/events.py

Модуль отвечает за регистрацию и обработку событий TON Connect, а также обработку связанных с ними ошибок.

Основные события

  • Подключение кошелька (CONNECT)

    • Проверяет корректность ton_proof.
    • При успешной верификации отображает главное меню.
    • При ошибке — отключает сессию и предлагает повторное подключение.
  • Ошибки подключения

    • Обрабатываются случаи отклонения пользователем или таймаута.
    • Пользователю отправляется уведомление и предложена повторная попытка.
  • Отключение (DISCONNECT)

    • Обрабатывает как явное отключение, так и вынужденное (например, при недействительном proof).
    • Предлагается повторное подключение.
  • Ошибки отключения

    • Уведомляют пользователя об ошибке и не прерывают основной сценарий.
  • Отправка транзакции (TRANSACTION)

    • Показывает хеш транзакции при успехе.
    • В случае ошибки — объяснение и выбор: повторить или вернуться в меню.
  • Подпись данных (SIGN_DATA)

    • Показывает результат подписи и её проверку.
    • Обрабатывает ошибки и уведомляет пользователя.

Все события регистрируются через метод register_event у экземпляра TonConnect.

Пример кода
from tonutils.tonconnect import TonConnect
from tonutils.tonconnect.models import (
    Event,
    EventError,
    SendTransactionResponse,
    SignDataResponse,
    WalletInfo,
)
from tonutils.tonconnect.utils.exceptions import *

from .utils import Context, windows


async def connect_event(user_id: int, wallet: WalletInfo, context: Context) -> None:
    """
    Called when the wallet is connected.

    :param user_id: Telegram user ID.
    :param wallet: Connected wallet information.
    :param context: Execution context.
    """
    state_data = await context.state.get_data()
    stored_proof = state_data.get("ton_proof")

    if wallet.verify_proof_payload(stored_proof):
        await windows.wallet_connected(context, user_id)
    else:
        context.connector.add_event_kwargs(Event.DISCONNECT, failed_proof=True)
        await context.connector.disconnect_wallet()


async def connect_error(error: TonConnectError, user_id: int, context: Context) -> None:
    """
    Handle wallet connection errors.

    :param error: Exception from TonConnect.
    :param user_id: Telegram user ID.
    :param context: Execution context.
    """
    button_text, callback_data = "Try again", "connect_wallet"

    if isinstance(error, UserRejectsError):
        message_text = "You rejected the wallet connection."
    elif isinstance(error, RequestTimeoutError):
        message_text = "Connection request timed out."
    else:
        message_text = f"Connection error. Error: {error.message}"

    await windows.error(context, user_id, message_text, button_text, callback_data)


async def disconnect_event(user_id: int, context: Context, failed_proof: Optional[bool] = None) -> None:
    """
    Called when the wallet is disconnected.

    :param user_id: Telegram user ID.
    :param context: Execution context.
    :param failed_proof: Whether disconnection was triggered by invalid proof.
    """
    if failed_proof:
        message_text = "Wallet proof verification failed.\n\nPlease try again."
        await windows.error(context, user_id, message_text, "Try again", "connect_wallet")
    else:
        await windows.connect_wallet(context, user_id)


async def disconnect_error(error: TonConnectError, user_id: int, context: Context) -> None:
    """
    Handle wallet disconnection errors.

    :param error: Exception from TonConnect.
    :param user_id: Telegram user ID.
    :param context: Execution context.
    """
    button_text, callback_data = "Try again", "connect_wallet"

    if isinstance(error, UserRejectsError):
        message_text = "You rejected the wallet disconnection."
    elif isinstance(error, RequestTimeoutError):
        message_text = "Disconnect request timed out."
    else:
        message_text = f"Disconnect error. Error: {error.message}"

    await windows.error(context, user_id, message_text, button_text, callback_data)


async def transaction_event(user_id: int, transaction: SendTransactionResponse, context: Context) -> None:
    """
    Called when a transaction is sent successfully.

    :param user_id: Telegram user ID.
    :param transaction: Transaction result.
    :param context: Execution context.
    """
    await windows.transaction_sent(context, user_id, transaction)


async def transaction_error(error: TonConnectError, user_id: int, context: Context) -> None:
    """
    Handle errors during transaction.

    :param error: Exception from TonConnect.
    :param user_id: Telegram user ID.
    :param context: Execution context.
    """
    button_text, callback_data = "Try again", "main_menu"

    if isinstance(error, UserRejectsError):
        message_text = "You rejected the transaction."
    elif isinstance(error, RequestTimeoutError):
        message_text = "Transaction request timed out."
    else:
        message_text = f"Transaction error. Error: {error.message}"

    await windows.error(context, user_id, message_text, button_text, callback_data)


async def sign_data_event(user_id: int, sign_data: SignDataResponse, context: Context) -> None:
    """
    Called when sign data request completes successfully.

    :param user_id: Telegram user ID.
    :param sign_data: Sign data result.
    :param context: Execution context.
    """
    await windows.sign_data_sent(context, user_id, sign_data)


async def sign_data_error(error: TonConnectError, user_id: int, context: Context) -> None:
    """
    Handle errors during sign data request.

    :param error: Exception from TonConnect.
    :param user_id: Telegram user ID.
    :param context: Execution context.
    """
    button_text, callback_data = "Try again", "main_menu"

    if isinstance(error, UserRejectsError):
        message_text = "You rejected the data signing request."
    elif isinstance(error, RequestTimeoutError):
        message_text = "Data signing request timed out."
    else:
        message_text = f"Sign data error. Error: {error.message}"

    await windows.error(context, user_id, message_text, button_text, callback_data)


def register_events(tc: TonConnect) -> None:
    """
    Register all TonConnect event and error handlers.

    :param tc: TonConnect instance.
    """
    tc.register_event(Event.CONNECT, connect_event)
    tc.register_event(Event.DISCONNECT, disconnect_event)
    tc.register_event(Event.TRANSACTION, transaction_event)
    tc.register_event(Event.SIGN_DATA, sign_data_event)

    tc.register_event(EventError.CONNECT, connect_error)
    tc.register_event(EventError.DISCONNECT, disconnect_error)
    tc.register_event(EventError.TRANSACTION, transaction_error)
    tc.register_event(EventError.SIGN_DATA, sign_data_error)


__all__ = ["register_events"]

Обработчики Telegram

Файл: src/handlers.py

Модуль содержит Telegram-хендлеры, отвечающие за взаимодействие пользователя с ботом через команды и inline-кнопки.

Основные задачи

  • Обработка команды /start

    • Если кошелёк ещё не подключён — запускает окно выбора и подключения кошелька.
    • Если уже подключён — открывает главное меню с действиями.
  • Обработка inline callback’ов

    • Выбор кошелька и инициализация подключения.
    • Навигация по меню: главное меню, подключение, отключение.
    • Отмена активных запросов: транзакций и подписей.
    • Отправка одиночной транзакции.
    • Отправка batch-транзакции.
    • Запрос подписи данных.
Пример кода
from aiogram import Dispatcher
from aiogram.filters import CommandStart
from aiogram.types import CallbackQuery, Message
from pytoniq_core import begin_cell
from tonutils.tonconnect.models import (
    SignDataPayloadText,
    SignDataPayloadBinary,
    SignDataPayloadCell,
)
from tonutils.tonconnect.utils.exceptions import *
from tonutils.wallet.messages import TransferMessage

from .utils import windows, Context


async def start_command(message: Message, context: Context) -> None:
    """
    Handle /start command. Launch wallet connection or main menu.

    :param message: Incoming /start message.
    :param context: Execution context.
    """
    state_data = await context.state.get_data()
    rpc_request_id = state_data.get("rpc_request_id")

    if context.connector.is_request_pending(rpc_request_id):
        context.connector.cancel_pending_request(rpc_request_id)

    if not context.connector.connected:
        await windows.connect_wallet(context, message.from_user.id)
    else:
        await windows.wallet_connected(context, message.from_user.id)


async def callback_query_handler(callback_query: CallbackQuery, context: Context) -> None:
    """
    Handle all inline callback actions.

    :param callback_query: Incoming callback query.
    :param context: Execution context.
    """
    state_data = await context.state.get_data()
    rpc_request_id = state_data.get("rpc_request_id")
    data = callback_query.data

    if data.startswith("app_wallet:"):
        selected_wallet = data.split(":")[1]
        await context.state.update_data(selected_wallet=selected_wallet)
        await windows.connect_wallet(context, callback_query.from_user.id)

    elif data == "main_menu":
        await windows.wallet_connected(context, callback_query.from_user.id)

    elif data == "connect_wallet":
        await windows.connect_wallet(context, callback_query.from_user.id)

    elif data == "disconnect_wallet":
        await context.connector.disconnect_wallet()

    elif data == "cancel_transaction":
        if context.connector.pending_request_context(rpc_request_id):
            context.connector.cancel_pending_request(rpc_request_id)
        await windows.wallet_connected(context, callback_query.from_user.id)

    elif data == "send_transaction":
        rpc_request_id = await context.connector.send_transfer(
            destination=context.connector.account.address,
            amount=0.000000001,
            body="Hello from tonutils!",
        )
        await windows.send_request(context, callback_query.from_user.id)
        await context.state.update_data(rpc_request_id=rpc_request_id)

    elif data == "send_batch_transaction":
        max_messages = context.connector.device.get_max_supported_messages(context.connector.wallet)
        messages = [
            TransferMessage(
                destination=context.connector.account.address,
                amount=0.000000001,
                body="Hello from tonutils!",
            ) for _ in range(max_messages)
        ]
        rpc_request_id = await context.connector.send_batch_transfer(messages)
        await windows.send_request(context, callback_query.from_user.id)
        await context.state.update_data(rpc_request_id=rpc_request_id)

    elif data == "send_sign_data_request":
        await windows.choose_sign_data_type(context, callback_query.from_user.id)

    elif data.startswith("send_sign_data_request:"):
        payload_type = data.split(":")[1]
        payload_data = "Hello from tonutils!"

        if payload_type == "text":
            payload = SignDataPayloadText(text=payload_data)
        elif payload_type == "binary":
            payload = SignDataPayloadBinary(bytes=payload_data.encode())
        else:
            schema = "text_comment#00000000 text:Snakedata = InMsgBody;"
            cell = begin_cell().store_uint(0, 32).store_snake_string(payload_data).end_cell()
            payload = SignDataPayloadCell(cell=cell, schema=schema)

        try:
            context.connector.device.verify_sign_data_feature(
                context.connector.wallet, payload,
            )
            rpc_request_id = await context.connector.sign_data(payload)
            await context.state.update_data(rpc_request_id=rpc_request_id)
            await windows.send_request(context, callback_query.from_user.id)
        except WalletNotSupportFeatureError:
            await callback_query.answer("Your wallet does not support the sign data feature!", show_alert=True)

    await callback_query.answer()


def register_handlers(dp: Dispatcher) -> None:
    """
    Register bot handlers.

    :param dp: Aiogram dispatcher.
    """
    dp.message.register(start_command, CommandStart())
    dp.callback_query.register(callback_query_handler)


__all__ = ["register_handlers"]

Точка входа

Файл: src/__main__.py

Этот модуль является финальной сборочной точкой, связывающей все компоненты системы.

Основные задачи

  • Загружает конфигурацию из .env с помощью Config.load().
  • Создаёт подключения к Redis:
    • для хранения FSM-состояний,
    • для хранения сессий TonConnect.
  • Инициализирует объекты Bot, TonConnect, Dispatcher.
  • Регистрирует:
    • хендлеры Telegram-команд и inline-кнопок,
    • подписки на события TonConnect,
    • промежуточные слои.
  • Запускает TonConnectSessionManager — фоновую задачу очистки неактивных сессий.
  • Запускает polling — цикл обработки входящих Telegram-обновлений.
Пример кода
import logging

from aiogram import Dispatcher, Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.storage.redis import RedisStorage as BotStorage
from redis.asyncio import Redis
from tonutils.tonconnect import TonConnect

from .events import register_events
from .handlers import register_handlers
from .middlewares import register_middlewares
from .session_manager import TonConnectSessionManager
from .utils.models import Config
from .utils.storage import RedisStorage as TCStorage


async def main() -> None:
    """
    Entry point for the bot application.
    Initializes config, Redis, TonConnect, dispatcher, and starts polling.
    """
    logging.basicConfig(level=logging.INFO)
    config = Config.load()

    # Redis connections for Aiogram FSM and TonConnect storage
    redis = Redis.from_url(url=config.REDIS_DSN)
    bot_storage = BotStorage(redis)
    tc_storage = TCStorage(redis)

    # Bot setup
    props = DefaultBotProperties(parse_mode="HTML")
    bot = Bot(token=config.BOT_TOKEN, default=props)

    # TonConnect setup
    tc = TonConnect(storage=tc_storage, manifest_url=config.TC_MANIFEST)
    tc_session_manager = TonConnectSessionManager(redis=redis, tc=tc)

    # Dispatcher
    dp = Dispatcher(storage=bot_storage, tc=tc, tc_session_manager=tc_session_manager)

    # Register handlers, events, and middleware
    register_events(tc)
    register_handlers(dp)
    register_middlewares(dp)
    tc_session_manager.run()

    # Start polling
    await dp.start_polling(bot)


if __name__ == "__main__":
    import asyncio

    asyncio.run(main())

Запуск бота

  • Убедитесь, что в корне проекта находится файл .env с необходимыми переменными окружения.
  • Убедитесь, что сервер Redis запущен и доступен по адресу, указанному в REDIS_DSN.
  • Запустите бота командой:

    python -m src
    

Заключение

Этот бот представляет собой надёжную и расширяемую основу для интеграции TON Connect в Telegram. Он реализует ключевые возможности подключения кошельков, отправки транзакций и подписи данных, а также обеспечивает устойчивое хранение сессий и экономию ресурсов за счёт управления SSE-соединениями. Архитектура проекта модульная и легко адаптируется под задачи конкретного приложения.

Полный исходный код доступен по ссылке: tonconnect-demo-bot

См. Также