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)
- Host: GitHub
- URL: https://github.com/ilyabrin/tgbase
- Owner: ilyabrin
- Created: 2025-05-12T22:48:27.000Z (about 1 year ago)
- Default Branch: master
- Last Pushed: 2026-06-23T19:29:50.000Z (15 days ago)
- Last Synced: 2026-06-23T21:14:48.419Z (15 days ago)
- Topics: framework, telegram, telegrambot, template, template-generic-repo, template-project, template-repository
- Language: Go
- Homepage:
- Size: 223 KB
- Stars: 0
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# tgbase - Go Telegram Bot Template
> π·πΊ [Π§ΠΈΡΠ°ΡΡ Π½Π° ΡΡΡΡΠΊΠΎΠΌ](README_RU.md)
[](https://github.com/ilyabrin/tgbase/actions/workflows/ci.yml)
[](https://codecov.io/gh/ilyabrin/tgbase)
[](https://goreportcard.com/report/github.com/ilyabrin/tgbase)
[](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
```