Skip to main content

Library-agnostic universal inline keyboard builder for python Telegram bots APIs

Project description

Logo PyPI version Downloads Python versions license Telegram

Inline Keyboard Builder (v3.2.3) — Python

Universal inline keyboard builder for Telegram bots. Produces pure Telegram Bot API compliant JSON, usable with any library (python-telegram-bot, Aiogram, Pyrogram, Telebot…).

New in 3.2.3: Smart Validation & Warnings — ESLint-style rules, plugins, strict / warn / silent modes. Full reference: docs/validation.md.

Version 3 brings full feature parity with the JavaScript/TypeScript edition: add_callback_button_from_parts(), callback_data_parse(), preview(), paginated_list(), and strongly typed interfaces throughout.


Table of Contents


Version 3.2.3 — Smart Validation & Warnings

Native validation engine (parity with the JS package): catch Telegram API mistakes before build(), with structured diagnostics and custom rules.

Why use it

Without validation With v3.2.3
callback_data > 64 bytes → API error at runtime callback-data-too-long with row/column location
Pay button on a normal message incompatible-button-context when context is not invoice
Duplicate callback_data duplicate-callback-data warning
Team conventions (no debug: prefix) register_rule() / use(plugin)

Performance

  • Normalize once per validate() call — all rules share the same layout snapshot
  • Shared layout.py with the builder (no duplicated layout logic)
  • Active rules only — disabled rules are skipped
  • Zero extra runtime dependencies (stdlib + typing_extensions only)

Validation modes

Mode validate() build(validate=True, ...)
strict Returns result Raises ValidationError on errors
warn Returns diagnostics Never raises
silent Same as warn Same as warn

Built-in rules

Same rule IDs as JavaScript (kebab-case): callback-data-too-long, empty-button-text, invalid-url, empty-row, too-many-buttons-per-row, incompatible-button-context, inconsistent-configuration, duplicate-callback-data, unexpected-null-undefined, invalid-keyboard-structure.

API (snake_case)

from telegram_inline_keyboard_builder import (
    InlineKeyboardBuilder,
    ValidationError,
    RULE_IDS,
)

kb = InlineKeyboardBuilder()
kb.add_callback_button("OK", "menu:ok:1")

result = kb.validate(mode="warn", context_type="message")
if not result["ok"]:
    print(result["errors"], result["warnings"])

markup = kb.build(validate=True, validation_mode="strict")

kb.set_validation_mode("strict")
kb.set_validation_context("invoice")
kb.register_rule(my_rule)
kb.use(my_plugin)
kb.set_rules({"disabled": ["duplicate-callback-data"]})
kb.set_rule_severity("empty-row", "error")

Example — Aiogram + strict build

from aiogram.types import InlineKeyboardMarkup
from telegram_inline_keyboard_builder import InlineKeyboardBuilder, ValidationError

def build_menu() -> InlineKeyboardMarkup:
    kb = InlineKeyboardBuilder(2)
    kb.set_validation_mode("strict")
    kb.add_callback_button_from_parts("menu", "home", 1, "Home")
    kb.add_callback_button_from_parts("menu", "settings", 1, "Settings")
    try:
        return InlineKeyboardMarkup(**kb.build(validate=True, validation_mode="strict"))
    except ValidationError as e:
        for err in e.result["errors"]:
            print(err["rule_id"], err["message"])
        raise

Example — custom plugin

from telegram_inline_keyboard_builder import InlineKeyboardBuilder, ValidationRule

def no_debug_prefix(ctx):
    diagnostics = []
    for ref in ctx.normalized.flat:
        data = ref.button.get("callback_data")
        if isinstance(data, str) and data.startswith("debug:"):
            diagnostics.append({
                "rule_id": "no-debug-prefix",
                "message": "Remove debug: prefix before production",
                "severity": "error",
                "location": {
                    "row": ref.row_index,
                    "column": ref.column_index,
                    "flat_index": ref.flat_index,
                },
            })
    return diagnostics

kb = InlineKeyboardBuilder()
kb.use({
    "name": "production-guards",
    "rules": [ValidationRule(id="no-debug-prefix", run=no_debug_prefix)],
})

Tests

cd api/python
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest

Installation

pip install telegram-inline-keyboard-builder

Import

from telegram_inline_keyboard_builder import (
    InlineKeyboardBuilder,
    ValidationError,
    ValidationEngine,
    create_validation_engine,
    RULE_IDS,
)

# Optional: type hints
from telegram_inline_keyboard_builder import (
    ValidationResult,
    Diagnostic,
    ValidationRule,
    ValidationPlugin,
    PaginatedListOptions,
    ButtonConfig,
)

Package structure

telegram_inline_keyboard_builder/
├── __init__.py          # public exports
├── validator/           # Smart Validation engine (v3.2.3)
├── core/
│   ├── builder.py       # InlineKeyboardBuilder class
│   ├── layout.py        # shared layout engine
└── types/
    ├── __init__.py
    ├── buttons.py        # ButtonStyle, CallbackButton, UrlButton …
    └── utils.py          # PaginationLabels, PaginationConfig, PaginatedListOptions

Core concept

Telegram inline keyboards follow one universal schema.
This builder generates the keyboard directly in Telegram format and lets you pass the result to any Telegram library — no adapters, no wrappers, no framework coupling.

keyboard = builder.build()
# Always returns:
# { "reply_markup": { "inline_keyboard": [[...], [...]] } }

Public API

Constructor

InlineKeyboardBuilder(buttons_per_row: int = 2, auto_wrap_max_chars: int = 0)
Parameter Type Default Description
buttons_per_row int 2 Maximum buttons per row. Minimum 1.
auto_wrap_max_chars int 0 Auto line-break when a row exceeds this char count. 0 = disabled.

add_callback_button()

Adds an inline button that triggers a callback query.

builder.add_callback_button(
    text: str,
    callback_data: str,
    *,
    style: Literal["primary", "danger", "success"] | None = None,
    icon_custom_emoji_id: str | None = None,
) -> InlineKeyboardBuilder
builder.add_callback_button("✅ Confirm", "confirm:action")
builder.add_callback_button("🗑 Delete", "delete:42", style="danger")

add_callback_button_from_parts()

Builds a structured callback_data from scope, action, and id — no manual string concatenation.

builder.add_callback_button_from_parts(
    scope: str,
    action: str,
    id: str | int,
    text: str,
    *,
    style: str | None = None,
    icon_custom_emoji_id: str | None = None,
    separator: str = ":",
) -> InlineKeyboardBuilder
builder.add_callback_button_from_parts("post", "like", 101, "👍 Like", style="success")
# callback_data → "post:like:101"

add_url_button()

Adds an inline button that opens an external URL.

builder.add_url_button(
    text: str,
    url: str,
    *,
    style: str | None = None,
    icon_custom_emoji_id: str | None = None,
) -> InlineKeyboardBuilder
builder.add_url_button("📖 Documentation", "https://example.com")

add_pay_button()

Adds a Telegram payment button. Must only be used inside send_invoice.

builder.add_pay_button(text: str) -> InlineKeyboardBuilder

add_custom_button()

Adds a fully custom button dict for Telegram button types not covered by this library (e.g. switch_inline_query).

builder.add_custom_button(button_object: dict) -> InlineKeyboardBuilder
builder.add_custom_button({
    "text": "🔔 Share",
    "switch_inline_query": "hello",
})

add_buttons()

Add multiple buttons at once from a declarative list or grouped config.

# Flat list
builder.add_buttons([
    {"type": "callback", "text": "Yes", "data": "answer:yes"},
    {"type": "callback", "text": "No",  "data": "answer:no"},
])

# Grouped config (shared type)
builder.add_buttons({
    "type": "callback",
    "buttons": [
        {"text": "👍", "data": "vote:up"},
        {"text": "👎", "data": "vote:down"},
    ],
})

callback_data()

Build a callback_data string without adding a button.

data = builder.callback_data("user", "ban", 42)
# → "user:ban:42"

callback_data_parse()

Decode a callback_data string into its parts.
Useful inside callback handlers or unit tests.

builder.callback_data_parse(data: str, separator: str = ":") -> dict[str, str]
# → {"scope": str, "action": str, "id": str}
result = builder.callback_data_parse("post:like:101")
# → {"scope": "post", "action": "like", "id": "101"}

Raises ValueError if the string has fewer than three parts.


preview()

Returns a readable row-by-row representation of the keyboard — handy during development to verify layout before sending to Telegram.

print(builder.preview())
# Row 1: [👍 Like](callback:post:like:101) | [👎 Dislike](callback:post:dislike:101)
# Row 2: [📖 Docs](https://example.com)

paginated_list()

Transforms a complete list into a paginated inline keyboard with built-in navigation controls.

builder.paginated_list(options: PaginatedListOptions) -> InlineKeyboardBuilder

PaginatedListOptions

Key Type Description
items list[T] required Complete unsliced list of items.
page int required Current page number (starts at 1).
per_page int required Items displayed per page.
render Callable[[T], dict] required Maps one item to an InlineKeyboardButton dict.
pagination PaginationConfig required Navigation bar configuration.

PaginationConfig

Key Type Default Description
callback Callable[[int], str] required Returns callback_data for a given page number.
labels PaginationLabels Custom labels for navigation buttons.
show_edge_buttons bool False Show ⏮ / ⏭ to jump to the first and last page.
hide_if_single_page bool False Hide the navigation bar when total_pages == 1.
counter_callback str "ignore" callback_data for the central counter button (e.g. 2/5).

PaginationLabels

Key Default Description
previous "⬅️" Previous page button label.
next "➡️" Next page button label.
first "⏮" First page button label.
last "⏭" Last page button label.

Key behaviours

  • Empty list — returns self immediately; nothing is rendered.
  • Page overflow — page is silently clamped to total_pages.
  • Edge navigation — on page 1 the ⬅️ button shows ·⬅️· with callback_data="ignore". Same for ➡️ on the last page.
  • Validation — raises ValueError if items is not a list or callback is not callable.

Layout controls

builder.set_buttons_per_row(n: int)       -> InlineKeyboardBuilder
builder.set_auto_wrap_max_chars(n: int)   -> InlineKeyboardBuilder

Both can be called at any point in the chain to change layout for subsequent buttons.


new_row()

Force a row break at the current position.

builder.new_row() -> InlineKeyboardBuilder

Validation (v3.2.3)

builder.validate(mode="warn", context_type="message") -> ValidationResult
builder.register_rule(rule) -> InlineKeyboardBuilder
builder.use(plugin) -> InlineKeyboardBuilder
builder.set_rules(config) -> InlineKeyboardBuilder
builder.set_validation_mode("strict" | "warn" | "silent") -> InlineKeyboardBuilder
builder.set_validation_context("default" | "message" | "invoice" | "edit") -> InlineKeyboardBuilder

build()

Build and return the final Telegram reply_markup object.

keyboard = builder.build()
# → {"reply_markup": {"inline_keyboard": [[...], [...]]}}

keyboard = builder.build(validate=True, validation_mode="strict")
# raises ValidationError when errors exist

Usage examples

python-telegram-bot

from telegram import Update
from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes
from telegram_inline_keyboard_builder import InlineKeyboardBuilder

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    keyboard = (
        InlineKeyboardBuilder(buttons_per_row=2)
        .add_callback_button("✅ Confirm", "confirm_action", style="success")
        .add_url_button("🌍 Website", "https://example.com")
        .new_row()
        .add_callback_button("❌ Cancel", "cancel_action", style="danger")
        .build()
    )

    await update.message.reply_text(
        "Welcome 👋 Choose an option:",
        reply_markup=keyboard["reply_markup"],
    )

Aiogram

from aiogram import Bot, Dispatcher, Router
from aiogram.filters import CommandStart
from aiogram.types import Message, InlineKeyboardMarkup
from telegram_inline_keyboard_builder import InlineKeyboardBuilder

router = Router()

@router.message(CommandStart())
async def start(message: Message):
    raw = (
        InlineKeyboardBuilder(buttons_per_row=2)
        .add_callback_button_from_parts("post", "like",    1, "👍 Like",    style="success")
        .add_callback_button_from_parts("post", "dislike", 1, "👎 Dislike", style="danger")
        .new_row()
        .add_url_button("📖 Docs", "https://example.com")
        .build()
    )
    # Aiogram expects an InlineKeyboardMarkup object
    markup = InlineKeyboardMarkup(inline_keyboard=raw["reply_markup"]["inline_keyboard"])
    await message.answer("Choose:", reply_markup=markup)

Paginated product list

from telegram.ext import CallbackQueryHandler
from telegram_inline_keyboard_builder import InlineKeyboardBuilder

async def show_products(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query    = update.callback_query
    page     = int(query.data.split("_")[-1])          # e.g. "products_page_2"
    products = await db.get_all_products()              # full list

    keyboard = (
        InlineKeyboardBuilder()
        .paginated_list({
            "items":    products,
            "page":     page,
            "per_page": 5,
            "render":   lambda p: {
                "text":          f"🛍 {p.name}{p.price}€",
                "callback_data": f"product_view_{p.id}",
            },
            "pagination": {
                "callback":            lambda p: f"products_page_{p}",
                "hide_if_single_page": True,
            },
        })
        .build()
    )

    await query.edit_message_reply_markup(
        reply_markup=keyboard["reply_markup"]
    )

# Register the handler
app.add_handler(CallbackQueryHandler(show_products, pattern=r"^products_page_\d+$"))
# Render — page 2/9
[ 🛍 Shoes Nike — 89€   ]
[ 🛍 Adidas Bag — 45€   ]
[ 🛍 Casio Watch — 120€ ]
[ 🛍 Ray-Ban — 99€      ]
[ 🛍 NY Cap — 25€       ]
[  ⬅️  ][  2/9  ][  ➡️  ]

Paginated user list with edge buttons

For long lists (100+ items), show_edge_buttons lets users jump directly to the first or last page.

async def show_users(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    page  = int(query.data.split("_")[-1])
    users = await db.get_all_users()

    keyboard = (
        InlineKeyboardBuilder()
        .paginated_list({
            "items":    users,
            "page":     page,
            "per_page": 8,
            "render":   lambda u: {
                "text":          f"👤 {u.username} ({u.role})",
                "callback_data": f"user_info_{u.id}",
            },
            "pagination": {
                "callback":          lambda p: f"users_page_{p}",
                "show_edge_buttons": True,
                "labels": {
                    "previous": "◀️",
                    "next":     "▶️",
                    "first":    "⏮",
                    "last":     "⏭",
                },
            },
        })
        .build()
    )

    await query.edit_message_text(
        "👥 Users",
        reply_markup=keyboard["reply_markup"],
    )

app.add_handler(CallbackQueryHandler(show_users, pattern=r"^users_page_\d+$"))
# Render — page 1/13, ⏮ and ◀️ are dimmed
[ 👤 alice (admin) ]
[ 👤 bob (user)    ]
...
[ ·⏮· ][ ·◀️· ][ 1/13 ][ ▶️ ][ ⏭ ]

Dynamic search results

The search query is encoded directly in callback_data.

import re

async def show_search(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query  = update.callback_query
    match  = re.match(r"^search_(.+)_page_(\d+)$", query.data)
    term   = match.group(1)
    page   = int(match.group(2))
    results = await search(term)

    if not results:
        await query.answer("🚫 No results found")
        return

    keyboard = (
        InlineKeyboardBuilder()
        .paginated_list({
            "items":    results,
            "page":     page,
            "per_page": 4,
            "render":   lambda r: {
                "text":          f"📄 {r.title}",
                "callback_data": f"open_doc_{r.id}",
            },
            "pagination": {
                "callback":            lambda p: f"search_{term}_page_{p}",
                "counter_callback":    f"search_info_{term}",
                "hide_if_single_page": True,
            },
        })
        .build()
    )

    await query.edit_message_text(
        f'🔍 Results for "{term}"',
        reply_markup=keyboard["reply_markup"],
    )

app.add_handler(CallbackQueryHandler(show_search, pattern=r"^search_.+_page_\d+$"))

⚠️ Telegram limit: callback_data is capped at 64 bytes. If the search term can be long, store it in the conversation context (context.user_data["query"]) and encode only an ID in callback_data.


Premium button styles

Requires a Telegram Premium subscription for the bot owner.

keyboard = (
    InlineKeyboardBuilder(buttons_per_row=1)
    .add_callback_button("🔵 Primary", "action_1", style="primary")
    .add_callback_button("🟢 Success", "action_2", style="success")
    .add_callback_button("🔴 Danger",  "action_3", style="danger")
    .add_callback_button("Icon only",  "action_4", icon_custom_emoji_id="4963511421280192936")
    .add_callback_button("Icon + style", "action_5",
        style="success",
        icon_custom_emoji_id="4963511421280192936",
    )
    .build()
)

icon_custom_emoji_id only works when the bot owner has an active Telegram Premium subscription.


Payment buttons

⚠️ Telegram limitation

[!WARNING] Payment buttons must only be used with send_invoice. They must not appear in regular messages.

builder.add_pay_button("💳 Pay now")

Using a payment button outside an invoice causes a Telegram API error.


Common errors

Passing reply_markup incorrectly

keyboard = (
    InlineKeyboardBuilder()
    .add_callback_button("⚙️ Settings", "show_settings")
    .build()
)

# ✅ Correct — pass the inner reply_markup value
await update.message.reply_text("Menu:", reply_markup=keyboard["reply_markup"])

# ✅ Also correct — spread into options dict
await update.message.reply_text("Menu:", **{
    "reply_markup": keyboard["reply_markup"],
    "parse_mode": "HTML",
})

# ❌ Wrong — passes the outer wrapper dict
await update.message.reply_text("Menu:", reply_markup=keyboard)

Migration to v3.2.3

  • Fully backward compatiblebuild() without arguments is unchanged.
  • Opt in with validate() in tests, then build(validate=True, validation_mode="warn"), then strict in production.
  • Use set_validation_context("invoice") before keyboards with add_pay_button().

Migration to v3

v3 is fully backward compatible with v2. Existing code requires no changes.

All new features are opt-in. The constructor signature is unchanged.

What you did in v2 What you can use now (v3)
Manual callback_data strings add_callback_button_from_parts(scope, action, id)
print(builder.build()) to inspect builder.preview() — row-by-row readable output
Parsing callback_data by hand callback_data_parse(data){scope, action, id}
Manual pagination + handler code paginated_list({items, page, per_page, render, …})

v3 migration checklist

  • pip install telegram-inline-keyboard-builder --upgrade
  • Replace manual callback_data concatenations with add_callback_button_from_parts()
  • Replace hand-rolled pagination logic with paginated_list()
  • Use preview() during development to verify keyboard layout
  • Optionally add type hints using the exported types from telegram_inline_keyboard_builder.types

Support this project

This project is maintained in my free time. If it helped you, consider supporting it with a crypto donation ❤️

Crypto Address
USDT (TRC20) 0x607c1430601989d43c9CD2eeD9E516663e0BdD1F
USDC (Polygon/ETH) 0x607c1430601989d43c9CD2eeD9E516663e0BdD1F
Ethereum (ETH) 0x607c1430601989d43c9CD2eeD9E516663e0BdD1F
Bitcoin (BTC) bc1qmysepz6eerz2mqyx5dd0yy87c3gk6hccwla5x2
Tron (TRX) TE9RiTaDpx7DGZzCMw7qds51nzszKiyeR8
TON UQA1NPW4GqgIVa9R6lebN_0v64Q-Sz_nHrmK9LCk-FfdjVOH

🔹 Optional QR Codes for quick mobile donation

USDT (TRC20) USDT TRC20 QR

USDC USDC QR

Ethereum (ETH) ETH QR

Bitcoin (BTC) BTC QR

Tron (TRX) TRX QR

TON TON QR


Contribution

Contributions are welcome ❤️ Please open an issue before proposing major changes.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

telegram_inline_keyboard_builder-3.1.3.tar.gz (35.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

File details

Details for the file telegram_inline_keyboard_builder-3.1.3.tar.gz.

File metadata

File hashes

Hashes for telegram_inline_keyboard_builder-3.1.3.tar.gz
Algorithm Hash digest
SHA256 a24833bd8f5ba72393571fb1b438129334c829f470a87bb9209ff87dcd596a35
MD5 a240fd03bb6e3ab42a3a1e33054ad114
BLAKE2b-256 a4e2adb34dce855cadf92ad6ff222dacc9d0b56a58157da36b02618619ad3ad5

See more details on using hashes here.

File details

Details for the file telegram_inline_keyboard_builder-3.1.3-py3-none-any.whl.

File metadata

File hashes

Hashes for telegram_inline_keyboard_builder-3.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 a3dc33af96c0800f9d96595fbb2aa5e5bac66b11dbf87fcb28717b52336b2053
MD5 32cf705202bc21e3de90f2c5ade61b4e
BLAKE2b-256 e5c5659763dc0e413699d96e421e2318130b66afeffe69e49954364d5409d786

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page