Laniakea
猫Laniakea
A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It simplifies bot development with a clean plugin system, middleware support, automatic command generation, and built-in rate limiting.
✨ Features
- Simple & Intuitive API: Designed for ease of use, based on practical examples.
- Plugin System: Organize your bot's functionality into independent, reusable plugins.
- Command Handling: Easily register commands and extract arguments.
- Middleware Support: Run code before or after commands (e.g., logging, access control).
- Automatic Command Generation: Generate help and command lists automatically.
- Built-in Rate Limiting: Protect your bot from hitting Telegram API limits (supports
retry_afterhandling). - Context-Aware: Pass custom application data or state contexts to your handlers.
- Configurable API: Mix
Set...andAdd...helpers to configure bots clearly (for example,bot.SetErrorTemplate(...).AddPlugins(...)). - Polling and Webhook Runtime: Run bots through long polling with
Run()/RunWithContext(...)or through a bot-owned webhook server withRunWebhookWithContext(...).
📦 Installation
go get git.scuroneko.dev/scuroneko/laniakea
or
go get github.com/scuroneko/laniakea
🚀 Quick Start (with step-by-step explanation)
Here is a minimal echo/ping bot example with detailed comments.
package main
import (
"log"
"git.scuroneko.dev/scuroneko/laniakea" // Import the Laniakea library
)
// echo is a command handler function.
// It receives two parameters:
// - ctx: the message context (contains info about the message, sender, chat, etc.)
// - data: your shared application data (here we use NoData, a placeholder for no shared data)
func echo(ctx *laniakea.MessageContext, data laniakea.NoData) error {
// Answer the user with the text they sent, without any command prefix.
// ctx.Text contains the user's message with the command part stripped off.
ctx.Answer(ctx.Text) // User input WITHOUT command
return nil
}
func main() {
// 1. Create bot options. Replace "TOKEN" with your actual bot token from @BotFather.
opts := &laniakea.BotOpts{Token: "TOKEN"}
// 2. Initialize a new bot instance.
// We use laniakea.NoData as the application data type (no shared data needed for this example).
bot, err := laniakea.NewBot[laniakea.NoData](opts)
if err != nil {
log.Fatal(err)
}
// Ensure bot resources are cleaned up on exit.
defer bot.Close()
// 3. Create a new plugin named "ping".
// Plugins help group related commands and middlewares.
p := laniakea.NewPlugin[laniakea.NoData]("ping")
// 4. Add a command to the plugin.
// p.Command("echo", echo) creates a command that triggers the 'echo' function on the "/echo" command.
p.Command("echo", echo)
// 5. Add another command using an anonymous function (closure).
// This command simply replies "Pong" when the user sends "/ping".
p.Command("ping", func(ctx *laniakea.MessageContext, data laniakea.NoData) error {
ctx.Answer("Pong")
return nil
})
// 6. Configure the bot with a custom error template and add the plugin.
// SetErrorTemplate sets a format string for errors (where %s will be replaced by the actual error).
// AddPlugins(p) registers our "ping" plugin with the bot.
bot = bot.SetErrorTemplate("Error\n\n%s").AddPlugins(p)
// 7. Automatically generate commands like /start, /help, and a list of all registered commands.
// This is optional but very useful for most bots.
if err := bot.AutoGenerateCommands(); err != nil {
log.Println(err)
}
// 8. Start the bot, listening for updates (long polling).
if err := bot.Run(); err != nil {
log.Fatal(err)
}
}
How It Works
BotOpts: Holds configuration like the API token.NewBot[T]: Creates a bot instance. The type parameter T allows you to pass custom shared application data (for example, *sql.DB or a service container) that will be available in all handlers. Use laniakea.NoData if you don't need it.NewPlugin: Creates a logical group for commands and middlewares.Command: Creates and registers a command. The first argument is the command name without the slash, the second is the handler function (func(*MessageContext, T) error).- Handler Functions: Receive *MessageContext (message details, methods like Answer) and your custom application data T, and return an error for centralized error handling.
SetErrorTemplate: Sets a template for error messages. The %s placeholder is replaced by the actual error.AutoGenerateCommands: Registers plugin-defined commands with Telegram across the supported scopes.Run(): Starts the bot's update polling loop and returns an error if startup or polling fails.RunWebhookWithContext(...): Starts the bot-owned webhook runtime when Telegram should deliver updates over HTTP instead of long polling.- A
Botinstance is single-use. AfterRun(),RunWithContext(), orRunWebhookWithContext()returns, create a new bot instance for the next session.
File-Based Config
BotOpts can also be loaded from or saved to config files through the file codec API.
Built in:
BotOptsFileJSONCodecfor JSON files.
Example:
codec := laniakea.BotOptsFileJSONCodec{}
opts, err := laniakea.LoadBotOptsFile(codec, "config.json")
if err != nil {
log.Fatal(err)
}
bot, err := laniakea.NewBot[laniakea.NoData](opts)
if err != nil {
log.Fatal(err)
}
Placeholders like {{ TG_TOKEN }} inside the file are expanded from environment variables before decoding.
You can also implement your own codec for other formats by satisfying BotOptsFileCodec.
Only JSON is supported out of the box right now. If you want another format such as TOML, use BotOptsFileJSONCodec as the reference implementation for your own codec.
See the full guide in the wiki: Bot Options and Configuration
Webhook Runtime
Laniakea also supports a bot-owned webhook runtime through RunWebhookWithContext(...) and RunWebhook(...).
Use it when:
- Telegram should push updates to your HTTP endpoint instead of your bot polling for them.
- You want webhook-delivered updates to reuse the same internal queue, worker pool, runners, and single-use lifecycle as polling.
- You want Laniakea to register the webhook and own the local HTTP server.
Production notes:
- Set
BotWebhookOpts.SecretTokenfor request authentication. BotWebhookOpts.SecretTokenis required whenBotWebhookOpts.UseStatusPathis enabled.- Keep
BotWebhookOpts.Pathspecific instead of serving webhook traffic on/. - If you switch an existing deployment from webhook mode to long polling, delete the webhook first with
CloseWebhook()ortgapi.DeleteWebhook(...). Telegram keeps webhook delivery active until it is removed. - Use
RunWebhookWithContext(...)with a cancelable context, then callClose()after runtime shutdown.
See the full guide in the wiki: Webhook Runtime
📖 Core Concepts
Plugins
Plugins are the main way to organize code. A plugin can have multiple commands and middlewares.
plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.Command("ban", banUser)
bot.AddPlugins(plugin)
Commands
A command is a function that handles a specific bot command (e.g., /start).
func myHandler(ctx *laniakea.MessageContext, db *MyDB) error {
// Access command arguments via ctx.Args ([]string)
// Reply to the user: ctx.Answer("some text")
return nil
}
MessageContext
Provides access to the incoming message and useful reply methods:
Answer(text string) *AnswerMessage: Sends a message with parse_mode none.AnswerLong(text string) []*AnswerMessage: Splits long plain text into multiple messages.AnswerMarkdown(text string) *AnswerMessage: Sends a message formatted with MarkdownV2 (you handle escaping).Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage: Sends a message with parse_mode none and inline keyboard.KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage: Splits long plain text into multiple messages and attaches the keyboard to the final chunk.KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard.AnswerPhoto(photoID, text string) *AnswerMessage: Sends a message with photo with parse_mode none.AnswerPhotoMarkdown(photoID, text string) *AnswerMessage: Sends a photo with MarkdownV2 caption (you handle escaping).EditCallback(text string, keyboard *InlineKeyboard) *AnswerMessage: Edits message with parse_mode none after clicking inline button.EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage: Edits a message formatted with MarkdownV2 (you handle escaping) after clicking inline button.SendAction(action tgapi.ChatActionType): Sends a “typing”, “uploading photo”, etc., action.- Fields:
Text,Args,From,FromID,Msg,InlineMsgID,CallbackQueryID, etc. - And more methods and fields!
tgapi: API and Uploader
tgapi provides two clients:
APIfor JSON requests (e.g.,SendMessage,EditMessageText, methods using file_id/URL).Uploaderfor multipart uploads (e.g.,SendPhoto,SendDocument,SendVideowith binary files).
This split keeps method intent explicit: JSON-only calls go through API, file uploads go through Uploader.
For advanced cases, tgapi.NewRequest(...) and tgapi.NewUploaderRequest(...) remain public as low-level escape hatches. They are intentionally less safe than method-specific helpers: callers must supply the correct Telegram method name and compatible request/response types themselves.
App Data
The T in NewBot[T] is a powerful feature. You can pass any type, but shared dependencies such as database pools, service containers, or API clients should usually use a pointer type.
type MyDB struct { /* ... */ }
db := &MyDB{...}
bot, err := laniakea.NewBot[*MyDB](opts)
if err != nil {
log.Fatal(err)
}
bot.SetAppData(db)
Scenes and Sessions
Scenes model multi-step conversations inside a plugin. Each active scene is stored in a session keyed by scope, so you can isolate flows per user, per chat, or per user-chat pair.
plugin := laniakea.NewPlugin[MyDB]("signup")
plugin.Scene("signup").
SetScope(laniakea.SceneScopeUserChat).
SetEntry("ask_name").
OnStep("ask_name", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) {
if ctx.Text == "" {
ctx.Answer("What is your name?")
return ctx.Stay(), nil
}
if err := ctx.SaveData(struct {
Name string `json:"name"`
}{Name: ctx.Text}); err != nil {
return laniakea.SceneResult{}, err
}
ctx.Answer("Nice to meet you.")
return ctx.Next("done"), nil
}).
OnStep("done", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) {
return ctx.Exit(), nil
})
- Use
ctx.EnterScene("signup")to enter the configured entry step. - Use
ctx.EnterSceneStep("signup", "done")when you need an explicit starting step. - Return
ctx.Stay(),ctx.Next(step),ctx.Exit(), orctx.Pass()from scene handlers to control flow. SceneActionPasskeeps the current session unchanged and continues normal bot routing.- Use
SceneContext.SaveData(...)andSceneContext.BindData(...)for JSON session state. - Use
SceneScopeUser,SceneScopeChat, orSceneScopeUserChatdepending on how widely a conversation should be shared.
⏱️ Runners
Runners are background tasks that execute alongside the bot runtime. They are registered before the bot starts and launched automatically when the bot starts.
import "time"
// One-shot runner — fires once in a goroutine when the bot starts (default).
bot.AddRunner(
laniakea.NewRunner("seed-cache", func(b *laniakea.Bot[*MyDB]) error {
return b.GetAppData().SeedCache()
}),
)
// Periodic runner — fires every 10 minutes in a goroutine.
bot.AddRunner(
laniakea.NewRunner("refresh-stats", func(b *laniakea.Bot[*MyDB]) error {
return b.GetAppData().RefreshStats()
}).Every(10 * time.Minute),
)
// Synchronous one-shot — blocks runtime startup until it completes.
bot.AddRunner(
laniakea.NewRunner("migrate", func(b *laniakea.Bot[*MyDB]) error {
return b.GetAppData().Migrate()
}).Async(false),
)
Builder methods:
Async(bool) *Runner[T]— iftrue(default), runs in a goroutine; iffalse, blocks runtime startup.Every(time.Duration) *Runner[T]— sets the repeat interval. Zero (default) means run once; positive value repeats. Periodic runners requireAsync(true).
🧩 Middleware
Middleware are functions that run before a command handler. They are perfect for cross-cutting concerns like logging, access control, rate limiting, or modifying the context.
Signature
A middleware function has the same signature as a command handler, but it must return a bool:
func(ctx *MessageContext, db T) bool
- If it returns true, the next middleware (or the command) will be executed.
- If it returns false, the execution chain stops immediately (the command will not run).
Adding Middleware
Use AddMiddleware on a plugin to add one or more shared middleware functions. They are executed in the order they are added.
plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware))
plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware))
plugin.Command("ban", banUser)
Example Middlewares
- Logging Middleware – logs every command execution.
func loggingMiddleware(ctx *laniakea.MessageContext, db *MyDB) bool {
log.Printf("User %d executed command: %s", ctx.FromID, ctx.Msg.Text)
return true // continue to next middleware/command
}
- Admin-Only Middleware – restricts access to users with a specific role.
func adminOnlyMiddleware(ctx *laniakea.MessageContext, db *MyDB) bool {
if !db.IsAdmin(ctx.FromID) { // assume db has IsAdmin method
ctx.Answer("⛔ Access denied. Admins only.")
return false // stop execution
}
return true
}
Important Notes
- Middleware can modify the MessageContext (e.g., add custom fields) before the command runs.
⚙️ Advanced Configuration
- Inline Keyboards: Build keyboards using
laniakea.NewInlineKeyboardJSON,laniakea.NewInlineKeyboardBase64, orlaniakea.NewInlineKeyboard.Bot.SetPayloadType(...)defines the default payload format, andInlineKeyboard.SetPayloadType(...)overrides it for one keyboard. - Rate Limiting: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully.
- Localization:
L10nis safe for concurrent use once attached to the bot. - Custom Update Handlers: Use
plugin.AddUpdateHandler(...)for Telegram update types that are not part of the command/payload flow. - Lifecycle:
RunWithContext(...)andRunWebhookWithContext(...)do not callClose()for you. Shut the bot down explicitly, and create a freshBotfor the next run.
Telegram Update Handling
- Commands and payloads are handled through plugins.
- Non-command updates can be routed with
plugin.AddUpdateHandler(updateType, handler). message,channel_post, andcallback_querystay on the command/payload flow.tgapi.Updateexposes a derivedTypefield after JSON unmarshalling so handlers can inspect the effective update kind directly.
📝 License
This project is licensed under the GNU General Public License v3.0 — see the LICENSE file for details.
📚 Learn More
✅ Built with ❤️ by scuroneko
