An open API service indexing awesome lists of open source software.

https://github.com/ilyabrin/tgbase

Project Template for Go Telegram Bot (tgbase)
https://github.com/ilyabrin/tgbase

framework telegram telegrambot template template-generic-repo template-project template-repository

Last synced: about 12 hours ago
JSON representation

Project Template for Go Telegram Bot (tgbase)

Awesome Lists containing this project

README

          

# tgbase - Go Telegram Bot Template

> πŸ‡·πŸ‡Ί [Π§ΠΈΡ‚Π°Ρ‚ΡŒ Π½Π° русском](README_RU.md)

[![CI Status](https://github.com/ilyabrin/tgbase/actions/workflows/ci.yml/badge.svg)](https://github.com/ilyabrin/tgbase/actions/workflows/ci.yml)
[![Codecov](https://codecov.io/gh/ilyabrin/tgbase/branch/main/graph/badge.svg)](https://codecov.io/gh/ilyabrin/tgbase)
[![Go Report Card](https://goreportcard.com/badge/github.com/ilyabrin/tgbase)](https://goreportcard.com/report/github.com/ilyabrin/tgbase)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/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

```bash
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

```text
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`)

```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:

```go
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):

```go
b.RegisterHandlers()
b.Run()
```

---

## Adding handlers

All handlers live in `internal/bot/handler.go` β†’ `RegisterHandlers()`:

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

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

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

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

```go
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`)

```go
import "tgbase/pkg/deeplink"

// /start handler - works for both plain /start and /start
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 ` in any chat and pick a result to send.
Handler registered automatically in `RegisterHandlers`. Customise
`InlineHandler()` in `internal/bot/handlers/inline.go`.

```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](https://t.me/BotFather) β†’ Bot Settings β†’ Inline Mode.

---

## Keyboards (`pkg/keyboard`)

### Inline keyboard

Buttons displayed inside a message.

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

```go
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`)

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

```go
// 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`:

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

```go
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:

```sql
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

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

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

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

---

## Docker

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