Bote is a powerful wrapper for Telebot v4 that simplifies building interactive Telegram bots with smart message management, user state tracking, and advanced keyboard handling.
- Smart Message Management — main, head, notification, error, and history message lifecycle
- User State Tracking — per-message states with text input handling
- Interactive Keyboards — inline keyboard builder with automatic row layout
- Service Bots — stateless callback routing for channel publishers and admin-approval bots (no per-user conversation)
- Middleware Support — user-level and chat-type middlewares
- Internationalization — built-in multi-language message provider
- Privacy & Encryption — optional strict mode with AES-256 encrypted user IDs
- Persistence — pluggable storage with ordered async writes via gorder
- Webhook Support — built-in webhook server with TLS, secret token, IP filtering, and rate limiting
- Prometheus Metrics — updates, handlers, errors, active users, session length, webhooks
- Bot Restart Recovery — automatic re-initialization of user messages via state map
- Context-based API — clean handler interface with
Contextfor all operations
go get github.com/maxbolgarin/botepackage main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"github.com/maxbolgarin/bote"
)
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
log.Fatalln("TELEGRAM_BOT_TOKEN is not set")
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
b, err := bote.New(ctx, token)
if err != nil {
log.Fatalln(err)
}
stopCh := b.Start(ctx, startHandler, nil)
<-stopCh
}
func startHandler(ctx bote.Context) error {
kb := bote.InlineBuilder(3, bote.OneBytePerRune,
ctx.Btn("Option 1", option1Handler),
ctx.Btn("Option 2", option2Handler),
ctx.Btn("Option 3", option3Handler),
)
return ctx.SendMain(bote.NoChange, "Welcome! Choose an option:", kb)
}
func option1Handler(ctx bote.Context) error {
return ctx.EditMain(bote.NoChange, "You selected Option 1", nil)
}
func option2Handler(ctx bote.Context) error {
return ctx.EditMain(bote.NoChange, "You selected Option 2", nil)
}
func option3Handler(ctx bote.Context) error {
return ctx.EditMain(bote.NoChange, "You selected Option 3", nil)
}Bote manages five message types per user, each with automatic lifecycle handling:
| Type | Description | Behavior |
|---|---|---|
| Main | Primary interactive message | Previous main becomes history on new send |
| Head | Optional message above main | Deleted when new main is sent |
| Notification | Temporary user notification | Old notification auto-deleted on new one |
| Error | Error feedback | Auto-deleted on next user action |
| History | Previous main messages | Tracked for editing and cleanup |
Each message has an associated state. States control which handler runs when a user interacts with an old message after a bot restart.
// Define your states as a string type implementing the State interface
type AppState string
func (s AppState) String() string { return string(s) }
func (s AppState) IsText() bool { return s == StateAwaitingInput }
func (s AppState) NotChanged() bool { return false }
const (
StateMenu AppState = "menu"
StateSettings AppState = "settings"
StateAwaitingInput AppState = "awaiting_input" // IsText() returns true
)Built-in states: bote.NoChange, bote.FirstRequest, bote.Unknown, bote.Disabled.
Every handler receives a Context that provides:
func myHandler(ctx bote.Context) error {
// User info
ctx.User().ID()
ctx.User().Username()
ctx.User().Language()
ctx.User().StateMain()
// Message operations
ctx.SendMain(state, "text", keyboard)
ctx.EditMain(state, "text", keyboard)
ctx.Send(state, "main text", "head text", mainKb, headKb)
ctx.SendNotification("info", nil)
ctx.SendError("something went wrong")
// Callback data
ctx.ButtonID()
ctx.Data() // raw: "a|b|c"
ctx.DataParsed() // []string{"a", "b", "c"}
// Text input
ctx.Text()
// Custom values (persisted)
ctx.User().SetValue("key", value)
val, ok := ctx.User().GetValue("key")
return nil
}b, err := bote.New(ctx, token,
bote.WithDefaultLanguage("en"),
bote.WithLogger(myLogger),
bote.WithUserDB(myStorage),
bote.WithMsgsProvider(myMessages),
bote.WithDebugIncomingUpdates(),
)b, err := bote.New(ctx, token, func(opts *bote.Options) {
opts.Config = bote.Config{
Bot: bote.BotConfig{
DefaultLanguage: "en",
DeleteMessages: bote.Ptr(true),
NoPreview: true,
},
Log: bote.LogConfig{
Enable: bote.Ptr(true),
LogUpdates: bote.Ptr(true),
},
}
opts.UserDB = myStorage
opts.Msgs = myMessages
})All config fields can be set via BOTE_* environment variables (e.g., BOTE_DEFAULT_LANGUAGE=en).
// Single row
kb := bote.SingleRow(
ctx.Btn("Yes", yesHandler),
ctx.Btn("No", noHandler),
)
// Auto-layout with column count and rune type
kb := bote.InlineBuilder(2, bote.TwoBytesPerRune,
ctx.Btn("Option A", handlerA),
ctx.Btn("Option B", handlerB),
ctx.Btn("Option C", handlerC),
ctx.Btn("Option D", handlerD),
)
// Manual builder
kb := bote.NewKeyboard(3)
kb.Add(ctx.Btn("One", h1))
kb.Add(ctx.Btn("Two", h2))
kb.StartNewRow()
kb.Add(ctx.Btn("Three", h3))
kb.AddFooter(ctx.Btn("Back", backHandler))
markup := kb.CreateInlineMarkup()
// Buttons with callback data
ctx.Btn("Delete", deleteHandler, userID, itemID)
// In handler: ctx.DataParsed() returns []string{userID, itemID}Rune size types for automatic row sizing: OneBytePerRune (English), TwoBytesPerRune (Cyrillic), FourBytesPerRune (emoji).
Register text-expecting states and set a text handler:
// In your state definition
func (s AppState) IsText() bool {
return s == StateAwaitingName || s == StateAwaitingEmail
}
// Set the text handler
b.SetTextHandler(func(ctx bote.Context) error {
text := ctx.Text()
switch ctx.User().StateMain() {
case StateAwaitingName:
ctx.User().SetValue("name", text)
return ctx.EditMain(StateAwaitingEmail, "Now enter your email:", nil)
case StateAwaitingEmail:
ctx.User().SetValue("email", text)
name, _ := ctx.User().GetValue("name")
msg := bote.FB("Name: ") + name.(string) + "\n" + bote.FB("Email: ") + text
return ctx.EditMain(StateMenu, msg, menuKeyboard(ctx))
default:
return ctx.SendNotification("Send /start to begin", nil)
}
})
// Trigger text input by sending a message with a text state
func askNameHandler(ctx bote.Context) error {
return ctx.SendMain(StateAwaitingName, "Enter your name:", nil)
}Implement UsersStorage to persist user data between restarts:
type MyStorage struct {
db *sql.DB
}
func (s *MyStorage) Insert(ctx context.Context, user bote.UserModel) error {
// Insert user into database
return nil
}
func (s *MyStorage) Find(ctx context.Context, id bote.FullUserID) (bote.UserModel, bool, error) {
// Find user by ID (use id.IDPlain or id.IDHMAC depending on privacy mode)
return bote.UserModel{}, false, nil
}
func (s *MyStorage) UpdateAsync(id bote.FullUserID, diff *bote.UserModelDiff) {
// Apply partial update. Bote wraps this with an ordered queue (gorder),
// so updates are guaranteed to arrive in order per user.
// You can use a simple synchronous DB call here.
}
b, err := bote.New(ctx, token, bote.WithUserDB(&MyStorage{db: db}))Provide a state map so users can continue from where they left off:
stateMap := map[bote.State]bote.InitBundle{
StateMenu: {
Handler: menuHandler,
},
StateSettings: {
Handler: settingsHandler,
},
StateAwaitingName: {
Handler: askNameHandler,
},
}
stopCh := b.Start(ctx, startHandler, stateMap)When a user clicks a button on an old message after a restart, bote looks up the message's state in this map and runs the corresponding handler to rebuild the UI.
// User-level middleware (private chats only)
b.AddUserMiddleware(func(upd *tele.Update, user bote.User) bool {
log.Printf("User %d made an action", user.ID())
return true // return false to drop the update
})
// Chat-type middleware
b.AddMiddleware(func(upd *tele.Update) bool {
// Rate limiting, analytics, etc.
return true
}, tele.ChatGroup, tele.ChatSuperGroup)b, err := bote.New(ctx, token,
bote.WithWebhook("https://example.com/webhook", ":8443"),
bote.WithWebhookSecretToken("my-secret"),
bote.WithWebhookRateLimit(100, 200),
bote.WithWebhookSecurityHeaders(),
bote.WithWebhookAllowedTelegramIPs(),
)Options: WithWebhookCertificate, WithWebhookGenerateCertificate, WithWebhookAllowedIPs, WithWebhookMetrics.
Strict privacy mode encrypts user IDs with AES-256 and stores only HMAC for lookups:
encKey := "hex-encoded-32-byte-key"
hmacKey := "hex-encoded-32-byte-key"
b, err := bote.New(ctx, token,
bote.WithStrictPrivacyMode(&encKey, nil, &hmacKey, nil),
)In strict mode: no usernames or names are stored, user IDs are encrypted in the database, and logs show only HMAC prefixes.
import "github.com/prometheus/client_golang/prometheus"
registry := prometheus.NewRegistry()
b, err := bote.New(ctx, token,
bote.WithMetricsConfig(bote.MetricsConfig{
Registry: registry,
}),
)Tracked metrics: bote_updates_total, bote_handlers_in_flight, bote_handler_duration_seconds, bote_errors_total, bote_messages_send_total, bote_users_current_active, bote_users_session_length_seconds, and webhook metrics.
msg := bote.FB("Bold") + " and " + bote.FI("italic") + "\n"
msg += bote.FC("code") + " or " + bote.FP("pre", "go") + "\n"
msg += bote.FS("strikethrough") + " " + bote.FU("underline")
// Using the builder
b := bote.NewBuilder()
b.Writeln(bote.FB("User Profile"))
b.Writeln("")
b.Writeln("Name: " + name)
b.Writeln("Email: " + email)
b.WriteIf(isAdmin, bote.FB("Admin"))
msg = b.String()FB = bold, FI = italic, FC = code, FP = pre, FS = strikethrough, FU = underline.
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"strconv"
"syscall"
"github.com/maxbolgarin/bote"
)
type State string
func (s State) String() string { return string(s) }
func (s State) IsText() bool { return s == StateAddingTask }
func (s State) NotChanged() bool { return false }
const (
StateMenu State = "menu"
StateAddingTask State = "adding_task"
StateViewTasks State = "view_tasks"
)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
b, err := bote.New(ctx, os.Getenv("TELEGRAM_BOT_TOKEN"))
if err != nil {
log.Fatalln(err)
}
b.SetTextHandler(textHandler)
stopCh := b.Start(ctx, menuHandler, map[bote.State]bote.InitBundle{
StateMenu: {Handler: menuHandler},
StateAddingTask: {Handler: addTaskHandler},
StateViewTasks: {Handler: viewTasksHandler},
})
<-stopCh
}
func menuHandler(ctx bote.Context) error {
kb := bote.InlineBuilder(1, bote.OneBytePerRune,
ctx.Btn("Add Task", addTaskHandler),
ctx.Btn("View Tasks", viewTasksHandler),
)
return ctx.SendMain(StateMenu, bote.FB("Todo List")+"\nChoose an action:", kb)
}
func addTaskHandler(ctx bote.Context) error {
kb := bote.SingleRow(ctx.Btn("Cancel", menuHandler))
return ctx.EditMain(StateAddingTask, "Enter your task:", kb)
}
func viewTasksHandler(ctx bote.Context) error {
tasks, ok := ctx.User().GetValue("tasks")
if !ok || len(tasks.([]string)) == 0 {
kb := bote.SingleRow(ctx.Btn("Add Task", addTaskHandler))
return ctx.EditMain(StateViewTasks, "No tasks yet!", kb)
}
b := bote.NewBuilder()
b.Writeln(bote.FB("Your Tasks:"))
b.Writeln("")
for i, task := range tasks.([]string) {
b.Writeln(fmt.Sprintf("%d. %s", i+1, task))
}
kb := bote.InlineBuilder(1, bote.OneBytePerRune,
ctx.Btn("Add Task", addTaskHandler),
ctx.Btn("Clear All", func(ctx bote.Context) error {
ctx.User().DeleteValue("tasks")
return viewTasksHandler(ctx)
}),
ctx.Btn("Back", menuHandler),
)
return ctx.EditMain(StateViewTasks, b.String(), kb)
}
func textHandler(ctx bote.Context) error {
if ctx.User().StateMain() != StateAddingTask {
return nil
}
task := ctx.Text()
tasks, ok := ctx.User().GetValue("tasks")
var list []string
if ok {
list = tasks.([]string)
}
list = append(list, task)
ctx.User().SetValue("tasks", list)
ctx.SendNotification("Task added: "+bote.FI(task), nil)
return viewTasksHandler(ctx)
}Bote can handle messages in groups and channels alongside private chats:
// Register a handler for text messages in any chat
b.Handle(tele.OnText, func(ctx bote.Context) error {
if !ctx.IsPrivate() {
// Group/channel message
if ctx.IsMentioned() {
return ctx.SendInChat(ctx.ChatID(), 0, "Hello from the bot!", nil)
}
return nil
}
// Private message — handled by text handler
return nil
})Bote's main flow is built around per-user conversations (the message lifecycle + state map). But some bots have no conversation at all: they post to a channel and react to inline-button taps from a channel, group, or admin chat — for example a content publisher with an Approve / Reject review step. For these, registering a button handler per user-message doesn't fit: the tapper may not be a tracked user, and the message lives in a channel, not a private chat.
The service-bot API solves this with a stateless callback router. You register one handler per button action once, then build as many buttons for that action as you like — each carrying its own payload. A tap is routed to the handler by the button's stable id (derived from its name), regardless of who tapped it or where.
| Action | What it does |
|---|---|
bot.RegisterButton(name, handler) |
Register a stateless handler for a button action. Call once (e.g. at startup). |
bot.NewButton(name, data...) |
Build a button for a registered action, carrying a per-message payload. No Context needed. |
kb.AddURL(text, url) |
Add an inline URL button (opens a link, no callback). Handy for "View source" / deep-link CTAs. |
kb.AddURLRow(text, url) |
Same, as a new row. |
The payload passed to NewButton arrives in the handler via ctx.Data() (single value) or
ctx.DataParsed() (multiple values). The stateless router is consulted after the per-user
button map, so service buttons and normal per-user buttons can coexist as long as their names
differ.
Use the chat-targeted helpers to post and edit by chat id (channels are large negative ids like
-100…); they return the message id so you can edit the message later:
msgID, err := bot.SendInChat(adminChatID, 0, "draft text", keyboard) // returns the message id
err = bot.EditInChat(adminChatID, msgID, "updated text", keyboard) // edit it later
err = bot.DeleteInChat(adminChatID, msgID)A bot that posts a draft to an admin chat with Approve / Reject buttons, and on approve republishes it to a public channel with a "View source" link:
package main
import (
"context"
"log"
"os"
"os/signal"
"strconv"
"sync"
"syscall"
"github.com/maxbolgarin/bote"
)
// In a real bot these come from a database; here an in-memory store keyed by draft id.
var (
mu sync.Mutex
drafts = map[string]string{"1": "<b>Hello</b> from a service bot!"}
)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
adminChatID, _ := strconv.ParseInt(os.Getenv("ADMIN_CHAT_ID"), 10, 64)
channelID, _ := strconv.ParseInt(os.Getenv("CHANNEL_ID"), 10, 64)
b, err := bote.New(ctx, os.Getenv("TELEGRAM_BOT_TOKEN"))
if err != nil {
log.Fatalln(err)
}
// Register the two actions once. The draft id arrives as the button payload.
b.RegisterButton("approve", func(ctx bote.Context) error {
id := ctx.Data()
mu.Lock()
text := drafts[id]
mu.Unlock()
// Publish to the public channel with a URL button.
kb := bote.NewKeyboard()
kb.AddURL("View source", "https://example.com/"+id)
if _, err := ctx.SendInChat(channelID, 0, text, kb.CreateInlineMarkup()); err != nil {
return err
}
// Strip the buttons on the admin message to mark it done.
return ctx.EditInChat(ctx.ChatID(), ctx.MessageID(), "✅ approved", bote.EmptyKeyboard)
})
b.RegisterButton("reject", func(ctx bote.Context) error {
return ctx.EditInChat(ctx.ChatID(), ctx.MessageID(), "✖ rejected", bote.EmptyKeyboard)
})
// Push a draft to the admin chat for review.
mu.Lock()
draft := drafts["1"]
mu.Unlock()
kb := bote.NewKeyboard()
kb.AddRow(b.NewButton("approve", "1"), b.NewButton("reject", "1"))
if _, err := b.SendInChat(adminChatID, 0, draft, kb.CreateInlineMarkup()); err != nil {
log.Fatalln(err)
}
// A service bot has no per-user start flow; a no-op start handler is fine.
stopCh := b.Start(ctx, func(bote.Context) error { return nil }, nil)
select {
case <-stopCh:
case <-ctx.Done():
}
}The bot must be an admin with post rights in both the admin chat and the public channel. See
examples/service for the runnable version.
See the full API documentation on pkg.go.dev.
This project is licensed under the MIT License. See the LICENSE file for details.