Developer-friendly template for building Telegram bots in Go. Clone, configure, add handlers.
Batteries included: database (Postgres/SQLite), Redis, FSM, middleware, i18n, payments (Telegram Stars), inline + reply keyboards, pagination, broadcast, deep links, inline mode, Docker.
git clone https://github.com/ilyabrin/tgbase && cd tgbase
go mod edit -module github.com/yourname/yourbot # rename the module to your own
go mod tidy
# set your token in config.yaml
go run cmd/app/main.gotgbase/
├── cmd/app/main.go # entry point - wires everything together
├── config/
│ └── config.go # YAML config + env overrides
├── internal/
│ ├── bot/
│ │ ├── bot.go # Bot type, functional options, Run/Start
│ │ ├── broadcast.go # Broadcast - send to multiple users
│ │ ├── handler.go # RegisterHandlers - add your handlers here
│ │ └── handlers/
│ │ ├── inline.go # Inline mode (OnQuery)
│ │ ├── payment.go # Telegram Stars payments
│ │ ├── registration.go # FSM multi-step flow demo
│ │ ├── start.go # /start
│ │ └── text.go # OnText fallback
│ ├── database/
│ │ ├── database.go # Database + SoftDeleteDatabase interfaces
│ │ ├── postgres.go # PostgreSQL implementation
│ │ └── sqlite.go # SQLite implementation
│ ├── fsm/
│ │ ├── fsm.go # FSM: New, Route, On, SetState, GetData…
│ │ └── storage.go # Storage interface, RedisStorage, MemoryStorage
│ ├── i18n/
│ │ └── i18n.go # go-i18n wrapper, locales/*.yaml
│ └── redis/
│ ├── redis.go # Client, Config, NewRedisClient, NewMockClient
│ └── real_redis.go # go-redis/v9 implementation
├── pkg/
│ ├── deeplink/deeplink.go # Parse /start payload, Build deep link URLs
│ ├── keyboard/keyboard.go # Inline + reply keyboard builders
│ ├── logger/logger.go # stdlib logger wrapper
│ ├── middleware/middleware.go # AdminOnly, Logger, RateLimit, Recover
│ └── pagination/pagination.go # Inline ← N/M → keyboard + Page() helper
├── config.yaml
├── docker-compose.yml
└── Dockerfile
database:
type: sqlite # or "postgres"
postgres:
dsn: "host=localhost user=postgres password=secret dbname=app port=5432 sslmode=disable"
sqlite:
path: "app.db"
redis:
enabled: true
addr: "localhost:6379"
password: ""
db: 0
telegram:
token: "YOUR_BOT_TOKEN"
admin_ids:
- 123456789 # your Telegram user IDEnvironment variable overrides: TELEGRAM_TOKEN, POSTGRES_DSN, REDIS_ADDR.
bot.New accepts functional options - pass only what you need:
b, err := bot.New(cfg.Telegram.Token,
bot.WithDB(db),
bot.WithRedis(redisClient),
bot.WithI18n(locale),
bot.WithAdminIDs(cfg.Telegram.AdminIDs),
bot.WithLogger(logger),
bot.WithErrorHandler(func(err error, c telebot.Context) {
if c != nil {
c.Send("Something went wrong, please try again.")
}
}),
bot.WithWebhook(":8080"), // optional: switch from polling to webhook
bot.WithPollerTimeout(30*time.Second), // optional: default 10s
)Start the bot (blocks until SIGINT/SIGTERM):
b.RegisterHandlers()
b.Run()All handlers live in internal/bot/handler.go → RegisterHandlers():
func (b *Bot) RegisterHandlers() {
b.Use(middleware.Logger(b.logger))
b.Use(middleware.RateLimit(30, time.Minute))
b.Handle("/start", handlers.StartHandler(b.i18n))
b.Handle("/help", myHelpHandler)
// admin-only
b.Handle("/ban", banHandler, middleware.AdminOnly(b.adminIDs))
}b.Handle returns *Bot for chaining. b.Use registers global middleware.
// internal/bot/handlers/my_command.go
func MyHandler(i18n *i18n.I18n) telebot.HandlerFunc {
return func(c telebot.Context) error {
lang := c.Sender().LanguageCode
if lang == "" {
lang = "en"
}
return c.Send(i18n.Localize(lang, "my_key", nil))
}
}// Global - applies to all handlers
b.Use(middleware.Recover(logger)) // always first
b.Use(middleware.Logger(logger))
b.Use(middleware.RateLimit(5, time.Minute))
// Per-handler
b.Handle("/admin", adminHandler, middleware.AdminOnly(adminIDs))
// Custom reject message
b.Use(middleware.RateLimit(3, time.Minute, func(c telebot.Context) error {
return c.Send("Too many messages, please wait.")
}))| Middleware | Description |
|---|---|
Recover(l) |
Catch handler panics, log, return error |
AdminOnly(ids, onReject?) |
Allow only listed user IDs |
Logger(l) |
Log every update: user ID, name, text/callback |
RateLimit(n, per, onReject?) |
Per-user sliding window rate limiter |
Send a message to multiple users with automatic rate limiting (default 50 ms between sends ≈ 20 msg/s, safely below Telegram's 30 msg/s cap). Stops early on context cancellation.
result := b.Broadcast(ctx, userIDs, "🔔 Announcement text",
bot.WithBroadcastDelay(50*time.Millisecond), // optional, 50ms is default
bot.WithBroadcastOnError(func(id int64, err error) {
log.Printf("failed to send to %d: %v", id, err)
}),
)
fmt.Printf("sent: %d, failed: %d\n", result.Sent, result.Failed)Build ← N/M → navigation keyboards for DB result sets.
import "tgbase/pkg/pagination"
pg := pagination.New(totalUsers, 10) // 10 per page
// In list handler:
items := fetchPage(pg.Offset(page), pg.PageSize)
c.Send(renderList(items), pg.Keyboard(page, "users"))
// Register callbacks:
b.Handle("\fusers_prev", usersPageHandler)
b.Handle("\fusers_next", usersPageHandler)
func usersPageHandler(c telebot.Context) error {
page, _ := pagination.Page(c) // parse target page from callback
items := fetchPage(pg.Offset(page), pg.PageSize)
return c.Edit(renderList(items), pg.Keyboard(page, "users"))
}| Function | Description |
|---|---|
New(total, pageSize) |
Create a pager |
.Pages() |
Total page count |
.Offset(page) |
SQL OFFSET for page (1-indexed) |
.Keyboard(page, prefix) |
Build prev/N/M/next inline row; nil if 1 page |
Page(c) |
Parse target page from callback context |
import "tgbase/pkg/deeplink"
// /start handler - works for both plain /start and /start <payload>
b.Handle("/start", func(c telebot.Context) error {
payload := deeplink.Parse(c) // "" for plain /start
if payload == "" {
return c.Send("Welcome!")
}
return c.Send("You came via: " + payload)
})
// Generate a shareable deep link
link := deeplink.Build("mybot", "ref_42")
// → "https://t.me/mybot?start=ref_42"Lets users type @botname <query> in any chat and pick a result to send.
Handler registered automatically in RegisterHandlers. Customise
InlineHandler() in internal/bot/handlers/inline.go.
func InlineHandler() telebot.HandlerFunc {
return func(c telebot.Context) error {
text := c.Query().Text
results := telebot.Results{
&telebot.ArticleResult{
ResultBase: telebot.ResultBase{ID: "r0"},
Title: text,
Description: "Send this to the chat",
Text: text,
},
}
return c.Answer(&telebot.QueryResponse{Results: results, CacheTime: 60})
}
}Note: enable inline mode in @BotFather → Bot Settings → Inline Mode.
Buttons displayed inside a message.
kb := keyboard.New().
Row(keyboard.Btn("Buy", "btn_buy"), keyboard.Btn("Cancel", "btn_cancel")).
Row(keyboard.URL("Website", "https://example.com")).
Build()
c.Send("Choose:", kb)
// Register callback handlers
b.Handle("\fbtn_buy", buyHandler)
b.Handle("\fbtn_cancel", cancelHandler)| Function | Description |
|---|---|
Btn(text, unique, data?) |
Callback button; handler key: "\f"+unique |
URL(text, url) |
Button that opens a URL |
New() |
Create a builder |
.Row(btns...) |
Append a row; chainable |
.Build() |
Return *telebot.ReplyMarkup |
Persistent button row shown above the message input field.
kb := keyboard.Reply().
Row("Profile", "Settings").
Row("Help").
OneTime().
Build()
c.Send("Choose:", kb)
// Reply buttons send their text as a plain OnText message — handle with c.Text()
b.Handle(telebot.OnText, func(c telebot.Context) error {
switch c.Text() {
case "Profile":
return c.Send("Your profile...")
case "Settings":
return c.Send("Settings menu...")
case "Help":
return c.Send("Help text...")
}
return nil
})
// Remove keyboard
c.Send("Done!", keyboard.Remove())| Method | Description |
|---|---|
Reply() |
Create builder (resize enabled by default) |
.Row(texts...) |
Append a row of text buttons |
.OneTime() |
Hide after first use |
.Persistent() |
Keep visible between messages |
.Placeholder() |
Input field hint text |
.Build() |
Return *telebot.ReplyMarkup |
Remove() |
Remove current reply keyboard |
f := fsm.New(fsm.NewRedisStorage(b.redis, fsm.WithTTL(3600))).
Fallback(handlers.TextHandler(b.i18n))
b.Handle("/register", func(c telebot.Context) error {
f.SetState(c, "ask_name")
return c.Send("What's your name?")
})
b.Handle(telebot.OnText, f.Route(
fsm.On("ask_name", func(c telebot.Context) error {
f.SetStateData(c, "ask_age", c.Text()) // save name, advance state
return c.Send("How old are you?")
}),
fsm.On("ask_age", func(c telebot.Context) error {
name, _ := f.GetData(c)
f.ClearState(c)
return c.Send("Done, " + name + "!")
}),
))Use fsm.NewMemoryStorage() in tests - no Redis required.
// Auto-selects Postgres or SQLite based on config
db, err := database.FromConfig(ctx, cfg)
// Core operations
db.Exec(ctx, query, args...)
db.Query(ctx, query, args...)
db.QueryRow(ctx, query, args...)
// CRUD helpers
db.Insert(ctx, "users", map[string]any{"name": "Alice", "age": 30})
db.Update(ctx, "users", map[string]any{"age": 31}, "name = $1", "Alice")
db.Delete(ctx, "users", "id = $1", userID)
db.Select(ctx, "users", []string{"id", "name"}, "active = $1", true)For tables with a deleted_at column, type-assert to database.SoftDeleteDatabase:
sdb := db.(database.SoftDeleteDatabase)
sdb.SoftDelete(ctx, "users", "id = $1", userID) // sets deleted_at = now()
sdb.Restore(ctx, "users", "id = $1", userID) // clears deleted_at
sdb.HardDelete(ctx, "users", "id = $1", userID) // DELETE FROM …
sdb.SelectDeleted(ctx, "users", cols, "id = $1", userID)Both PostgresDB and SQLiteDB implement SoftDeleteDatabase.
product := handlers.StarProduct{
Title: "Premium",
Description: "Unlock all features for 30 days",
Payload: "premium_1month",
Stars: 100,
}
b.Handle("/buy", handlers.SendInvoice(product))
b.Handle(telebot.OnCheckout, handlers.PreCheckout())
b.Handle(telebot.OnPayment, handlers.PaymentSuccess(b.db))Required table:
CREATE TABLE IF NOT EXISTS payments (
id SERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
telegram_charge_id TEXT NOT NULL UNIQUE,
payload TEXT NOT NULL,
stars INT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);redis.Set(ctx, "key", "value", ttlSeconds) // 0 = no expiry
value, _ := redis.Get(ctx, "key")
redis.Del(ctx, "key")
exists, _ := redis.Exists(ctx, "key")
n, _ := redis.Incr(ctx, "counter")
redis.HSet(ctx, "user:42", "name", "Alice")
name, _ := redis.HGet(ctx, "user:42", "name")
all, _ := redis.HGetAll(ctx, "user:42")Use redis.NewMockClient() in tests - no Redis server required.
# All tests (Redis mocked - no server needed)
go test ./...
# With real Redis (integration tests)
docker-compose up -d redis
go test ./...docker compose up -d # start bot + Redis
docker compose logs -f bot # tail logs
docker compose down # stop