Skip to content

ilyabrin/tgbase

Repository files navigation

tgbase - Go Telegram Bot Template

🇷🇺 Читать на русском

CI Status Codecov Go Report Card License: MIT

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.


Quick start

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.go

Project structure

tgbase/
├── 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

Configuration (config.yaml)

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 ID

Environment variable overrides: TELEGRAM_TOKEN, POSTGRES_DSN, REDIS_ADDR.


Bot creation API

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

Adding handlers

All handlers live in internal/bot/handler.goRegisterHandlers():

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.

Handler pattern

// 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))
    }
}

Middleware (pkg/middleware)

// 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

Broadcast

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)

Pagination (pkg/pagination)

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

Deep links (pkg/deeplink)

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"

Inline mode

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.


Keyboards (pkg/keyboard)

Inline keyboard

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

Reply keyboard

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

FSM - conversation flows (internal/fsm)

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.


Database

// 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)

Soft delete

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.


Payments - Telegram Stars

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

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.


Testing

# All tests (Redis mocked - no server needed)
go test ./...

# With real Redis (integration tests)
docker-compose up -d redis
go test ./...

Docker

docker compose up -d        # start bot + Redis
docker compose logs -f bot  # tail logs
docker compose down         # stop

About

Project Template for Go Telegram Bot (tgbase)

Topics

Resources

Stars

1 star

Watchers

1 watching

Forks

Packages

 
 
 

Contributors