Skip to content

TON Connect Telegram Bot

Introduction

This guide demonstrates how to integrate TON Connect into a Telegram bot using tonutils — a Python SDK designed for seamless interaction with TON.

The bot provides the following functionality:

  • Connect a wallet via QR code or link
  • Sign arbitrary data
  • Send transactions

The implementation follows production best practices: persistent session storage, asynchronous architecture, anti-spam protection, and a minimal UI using inline buttons.

Tip

Before getting started, it's recommended to review the documentation Cookbook: TON Connect Integration

Setup

Before getting started, complete the following steps.

Creating a Telegram Bot

  1. Open @BotFather in Telegram.
  2. Send the /newbot command and follow the prompts.
  3. Save the bot token.

Create TonConnect Manifest

Create a JSON file describing your application. This manifest is displayed in the wallet during connection.

{
  "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

Ensure this file is publicly accessible via its URL.

Refer to the official manifest documentation for detailed specifications.

Installing Dependencies

Create a requirements.txt file with the following contents:

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

Install all dependencies by running:

pip install -r requirements.txt

Environment Configuration

Create a .env file in the root directory of your project and define the following variables:

BOT_TOKEN=your_bot_token
REDIS_DSN=redis://localhost:6379/0
TC_MANIFEST=https://your-domain.com/manifest.json

Description of variables:

  • BOT_TOKEN — Telegram bot token obtained from @BotFather.
  • REDIS_DSN — Redis connection string used to store sessions and states.
  • TC_MANIFEST — HTTPS URL pointing to the publicly available TON Connect manifest.

Project Structure

├── .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

Implementation

The project is split into separate modules, each handling a specific responsibility. Let's go through them step by step.

Configuration

File: src/utils/models.py

This module is responsible for:

  • Loading configuration values from the .env file.
  • Creating the Context class that holds essential dependencies.

Context contents

  • bot: Bot — instance of the Telegram bot (aiogram).
  • state: FSMContext — finite-state machine context per user.
  • tc: TonConnect — main interface to interact with TON wallets.
  • connector: Connector — user-specific connection session.

Usage

  • At startup, Config.load() is called to load environment variables.
  • The middleware creates a Context instance and injects it into all handlers for convenient access to shared resources.
Code example
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"),
        )

Session Storage

File: src/utils/storage.py

In the web version of TON Connect, wallet sessions are stored using localStorage. In a Python server environment, you need to implement a custom session storage solution.

This project uses Redis, where TON Connect sessions are stored and retrieved using the user's Telegram ID as the key.

Module responsibilities

  • Store TON Connect session data by Telegram ID.
  • Retrieve session data when the user reconnects.
  • Delete session data upon disconnect or cleanup.

This approach ensures reliable persistence and recovery of user session state.

Code example
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)

Message Cleanup

File: src/utils/__init__.py

This module ensures that the user's previous message is deleted before a new one is sent. It prevents clutter in the chat and helps maintain a clean, minimal user interface.

How it works

  • The user's FSM context stores the message_id of the last message sent by the bot.
  • Before sending a new message, the bot tries to delete the previous one.
  • After sending, it stores the new message_id for the next cleanup cycle.

This is especially useful for bots with dynamic inline UIs that refresh often.

Code example
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)

Keyboards

File: src/utils/keyboards.py

This module is responsible for generating inline keyboards for user interaction. It ensures fast navigation and a clean, responsive interface.

Main keyboard types

  • Wallet connection — a list of available wallets with connect buttons.
  • Request confirmation — buttons for opening the wallet or canceling the current request.
  • Main action menu — send transaction, batch transfer, sign data, disconnect.
  • Signature format selection — choose between text, binary, or cell.
  • Back to menu — return to the main screen.

The keyboards are dynamically adjusted based on user context and current workflow.

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

UI Windows

File: src/utils/windows.py

This module handles communication with the user via Telegram messages and inline keyboards. It implements logical UI “screens” that represent different interaction states.

Key responsibilities

  • Wallet connection screen — shows the list of wallets, generates ton_proof, builds a connect link and QR code.
  • Main menu — displays the connected wallet address and offers actions: send, sign, disconnect.
  • Request confirmation — prompts the user to confirm the action inside their wallet app.
  • Result display — shows transaction hashes, signature data, and verification results.
  • Error handling — informs the user of failures and offers options to retry or return.
Code example
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)

Session Cleanup

File: src/session_manager.py

This module implements a background task that monitors user activity and closes inactive TON Connect SSE sessions to reduce resource usage.

Key mechanisms

  • User last activity timestamps are stored in Redis using a sorted set (ZSET).
  • The cleaner periodically scans for users who have been inactive for longer than session_lifetime.
  • For each inactive user, pause_sse() is called on their connector to suspend the open connection.
  • After pausing, the user record is removed from Redis.

Configuration parameters

  • session_lifetime — maximum allowed inactivity duration (default: 1 hour).
  • check_interval — interval between cleanup iterations (default: 10 minutes).
  • redis_key — Redis key used to track last activity timestamps.

This mechanism is essential in production environments where open connections must be optimized for scalability.

Code example
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

Middleware

File: src/middlewares.py

This module defines two essential middlewares for the Telegram bot:

ContextMiddleware

  • Creates a Context object for each incoming update.
  • It also updates the user's last activity timestamp, allowing accurate session tracking and cleanup.

ThrottlingMiddleware

  • A simple anti-spam mechanism.
  • Uses a TTL-based cache to block repeated requests from the same user within a short interval.
Code example
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"]

Event Handling

File: src/events.py

This module registers and handles all TON Connect events, along with associated errors.

Main event types

  • Wallet connection (CONNECT)

    • Verifies ton_proof to confirm wallet ownership.
    • On success, displays the main action menu.
    • On failure, disconnects and prompts the user to retry.
  • Connection errors

    • Handles timeouts and request rejections.
    • Notifies the user and provides an option to reconnect.
  • Wallet disconnection (DISCONNECT)

    • Handles both manual and forced disconnects (e.g. invalid proof).
    • Prompts the user to reconnect.
  • Disconnection errors

    • Notifies the user without interrupting the session flow.
  • Transaction (TRANSACTION)

    • Displays transaction hash on success.
    • Shows error message with options to retry or return to menu on failure.
  • Sign data (SIGN_DATA)

    • Shows signature results and verification status.
    • Informs the user of any issues during the signing process.

All event handlers are registered using the register_event method of the TonConnect instance.

Code example
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 Handlers

File: src/handlers.py

This module contains Telegram handlers for user interactions via commands and inline buttons.

Main responsibilities

  • Handling the /start command

    • If no wallet is connected — initiates wallet selection and connection flow.
    • If already connected — displays the main menu with available actions.
  • Handling inline callback queries

    • Selecting a wallet and starting the connection process.
    • Navigating through menus: main menu, connect, disconnect.
    • Cancelling active requests: transactions or signing.
    • Sending a single transaction.
    • Sending a batch transaction.
    • Requesting data signing.
Code example
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"]

Entry Point

File: src/__main__.py

This module serves as the glue for the entire application and initiates the bot lifecycle.

Main responsibilities

  • Loads configuration from .env using Config.load().
  • Establishes Redis connections:
    • for FSM state storage,
    • for TonConnect session persistence.
  • Initializes key components Bot, TonConnect, Dispatcher:
  • Registers:
    • Telegram command and callback handlers,
    • TonConnect event listeners,
    • middlewares.
  • Starts the background TonConnectSessionManager task to suspend inactive sessions.
  • Launches polling to process incoming Telegram updates.
Code example
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())

Running the Bot

  • Ensure the .env file is present in the project root with all required environment variables.
  • Make sure the Redis server is running and reachable at the REDIS_DSN address.
  • Start the bot using:

    python -m src
    

Conclusion

This bot provides a solid and extensible foundation for integrating TON Connect into Telegram. It implements essential functionality for wallet connection, transaction sending, and data signing, while also supporting session persistence and SSE-based resource optimization. The modular architecture makes it easy to adapt and extend for various use cases.

Full source code is available at: tonconnect-demo-bot

See also