All work
Concept
Bot Kit — Telegram bot template
A lead-capture bot template: menu, step-by-step form, admin notifications — ready for bebb Cloud.
- Role
- Backend, DevOps
- Year
- 2026
- Stack
- Pythonaiogram 3RedisGitbebb Cloud
The challenge
Small businesses often need a simple lead bot, but most bot builders are expensive and closed. A clean, easy-to-adapt template was the goal.
What I built
- An aiogram 3 bot with a state machine: menu, step-by-step form, confirmation.
- Leads are forwarded to the admin via DM; state is kept in Redis.
- Token and settings live only in environment variables, never in code.
- Ready for bebb Cloud: Git deploy, variables in the portal, Redis for state.
Source code
"""Bot Kit — шаблон Telegram-бота для заявок (aiogram 3).
Меню → покрокова форма (ім'я, контакт, задача) → підтвердження → заявка адміну в особисті.
Налаштування лише зі змінних середовища: BOT_TOKEN, ADMIN_ID, REDIS_URL (необов'язково).
"""
import asyncio
import html
import logging
import os
from aiogram import Bot, Dispatcher, F, Router
from aiogram.client.default import DefaultBotProperties
from aiogram.filters import Command, CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.storage.base import BaseStorage
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
router = Router()
class Lead(StatesGroup):
name = State()
contact = State()
task = State()
confirm = State()
def kb(*rows: list[tuple[str, str]]) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=t, callback_data=d) for t, d in row] for row in rows])
MENU = kb([("📝 Залишити заявку", "lead")], [("💼 Послуги", "services"), ("📞 Контакти", "contacts")])
@router.message(CommandStart())
async def start(message: Message, state: FSMContext) -> None:
await state.clear()
await message.answer("Привіт! Я допоможу залишити заявку — це займе хвилину.", reply_markup=MENU)
@router.callback_query(F.data == "services")
async def services(cb: CallbackQuery) -> None:
await cb.message.answer("Сайти, боти, сервери й супровід. Натисніть «Залишити заявку» — розповімо деталі.", reply_markup=MENU)
await cb.answer()
@router.callback_query(F.data == "contacts")
async def contacts(cb: CallbackQuery) -> None:
await cb.message.answer("Пишіть сюди, у бот, — ми відповідаємо протягом дня.", reply_markup=MENU)
await cb.answer()
@router.callback_query(F.data == "lead")
async def lead_start(cb: CallbackQuery, state: FSMContext) -> None:
await state.set_state(Lead.name)
await cb.message.answer("Як до вас звертатися?")
await cb.answer()
@router.message(Command("cancel"))
async def cancel(message: Message, state: FSMContext) -> None:
await state.clear()
await message.answer("Скасовано. Повернутися до меню — /start")
@router.message(Lead.name, F.text)
async def lead_name(message: Message, state: FSMContext) -> None:
await state.update_data(name=message.text[:100])
await state.set_state(Lead.contact)
await message.answer("Як з вами зв'язатися? Телефон, пошта або @нік.")
@router.message(Lead.contact, F.text)
async def lead_contact(message: Message, state: FSMContext) -> None:
await state.update_data(contact=message.text[:100])
await state.set_state(Lead.task)
await message.answer("Коротко опишіть задачу — кількох речень достатньо.")
@router.message(Lead.task, F.text)
async def lead_task(message: Message, state: FSMContext) -> None:
data = await state.update_data(task=message.text[:1500])
await state.set_state(Lead.confirm)
await message.answer(summary(data) + "\n\nНадіслати?", reply_markup=kb([("✅ Надіслати", "send"), ("✏️ Заново", "lead")]))
@router.callback_query(Lead.confirm, F.data == "send")
async def lead_send(cb: CallbackQuery, state: FSMContext, bot: Bot, admin_id: int) -> None:
data = await state.get_data()
user = cb.from_user
who = f"@{user.username}" if user.username else f"id {user.id}"
await bot.send_message(admin_id, f"🆕 <b>Нова заявка</b> від {html.escape(who)}\n\n{summary(data)}")
await state.clear()
await cb.message.edit_text("Дякуємо! Заявку отримано — відповімо найближчим часом. 🙌")
await cb.answer()
def summary(data: dict) -> str:
esc = lambda k: html.escape(str(data.get(k, "—"))) # noqa: E731 — ввід користувача не має стати розміткою
return f"<b>Ім'я:</b> {esc('name')}\n<b>Контакт:</b> {esc('contact')}\n<b>Задача:</b> {esc('task')}"
def storage() -> BaseStorage:
"""Redis зберігає незавершені форми між перезапусками; без REDIS_URL — пам'ять процесу."""
url = os.getenv("REDIS_URL")
if url:
from aiogram.fsm.storage.redis import RedisStorage
return RedisStorage.from_url(url)
return MemoryStorage()
def build() -> tuple[Bot, Dispatcher]:
bot = Bot(os.environ["BOT_TOKEN"], default=DefaultBotProperties(parse_mode="HTML"))
dp = Dispatcher(storage=storage(), admin_id=int(os.environ["ADMIN_ID"]))
dp.include_router(router)
return bot, dp
async def main() -> None:
logging.basicConfig(level=logging.INFO)
bot, dp = build()
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main()) Outcome
- The whole conversation (start → form → lead to admin) is covered by an offline automated test; user input is escaped.
- The full source is on this page: take it and adapt it to your business.
What’s next
In-bot payments and a lead admin panel.