REPOSITORY / ScuroNeko/Laniakea
Wiki
wip
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
These instructions apply only to the wiki repository in this directory.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
The wiki is the structured documentation layer for Laniakea.
|
||||||
|
It should complement the main `README.md` rather than duplicate it line-by-line.
|
||||||
|
|
||||||
|
## Writing goals
|
||||||
|
- Optimize for navigation and fast orientation.
|
||||||
|
- Prefer short pages with strong links over giant all-in-one documents.
|
||||||
|
- Explain design intent, API boundaries, defaults, and migration-relevant behavior.
|
||||||
|
- Keep examples practical and consistent with the current codebase.
|
||||||
|
- Sort information by importance, necessity, and expected popularity of use.
|
||||||
|
- Put setup and first-use material before advanced extension points.
|
||||||
|
- Apply that ordering consistently on `Home.md`, when adding new pages, and inside each page when listing methods, types, structures, or workflows.
|
||||||
|
- Documentation should be as detailed as needed to remove ambiguity.
|
||||||
|
- Add examples whenever they materially improve understanding.
|
||||||
|
|
||||||
|
## Content rules
|
||||||
|
- Reflect the current public API from the main repository.
|
||||||
|
- When behavior is version-sensitive, mention the relevant version explicitly.
|
||||||
|
- Prefer English page titles and stable page names.
|
||||||
|
- Keep Home.md concise and link-focused.
|
||||||
|
- Avoid restating low-value godoc verbatim unless the wiki adds context.
|
||||||
|
|
||||||
|
## Page structure
|
||||||
|
- Start each page with a one-paragraph summary.
|
||||||
|
- Then list the most important concepts or decisions first.
|
||||||
|
- Use short sections and flat bullet lists.
|
||||||
|
- Link related pages whenever a topic crosses page boundaries.
|
||||||
|
- Within a page, explain the most necessary and most frequently used APIs before less common or advanced ones.
|
||||||
|
- Prefer complete explanations over minimal notes when the topic has real usage nuance.
|
||||||
|
|
||||||
|
## Maintenance rules
|
||||||
|
- When adding a new page, update `Home.md` if the page is user-relevant.
|
||||||
|
- Prefer editing existing pages over creating overlapping pages.
|
||||||
|
- Keep terminology aligned with the code and README.
|
||||||
|
|
||||||
|
## Commit discipline
|
||||||
|
- Wiki commit messages should follow the same repository rule:
|
||||||
|
1. one short summary line;
|
||||||
|
2. up to three additional high-signal lines.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Bot Lifecycle
|
||||||
|
|
||||||
|
This page explains how a bot is configured, started, stopped, and why a `Bot` instance is single-use.
|
||||||
|
|
||||||
|
## This page should cover
|
||||||
|
- `BotOpts`, defaults, and startup validation;
|
||||||
|
- `Run`, `RunWithContext`, `Close`, and `CloseRemote`;
|
||||||
|
- workers, polling, and shutdown behavior;
|
||||||
|
- which configuration must be finalized before `Run`.
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
# Commands and Plugins
|
||||||
|
|
||||||
|
Laniakea organizes most bot behavior through plugins.
|
||||||
|
|
||||||
|
If you understand how plugins, commands, payload handlers, and update handlers fit together, the rest of the library becomes much easier to reason about.
|
||||||
|
|
||||||
|
## The important model first
|
||||||
|
|
||||||
|
The normal layering is:
|
||||||
|
- `Bot` owns runtime, update flow, logging, and API clients
|
||||||
|
- `Plugin` groups related handlers
|
||||||
|
- commands handle text commands like `/start`
|
||||||
|
- payload handlers handle inline-button callback payloads
|
||||||
|
- update handlers handle non-command Telegram updates
|
||||||
|
|
||||||
|
In practice, most bots start with:
|
||||||
|
- one or more plugins
|
||||||
|
- a few commands
|
||||||
|
- optional plugin middleware
|
||||||
|
- maybe payload handlers once inline keyboards appear
|
||||||
|
|
||||||
|
## Plugins
|
||||||
|
|
||||||
|
A plugin is a named group of:
|
||||||
|
- commands
|
||||||
|
- payload handlers
|
||||||
|
- update handlers
|
||||||
|
- shared middleware
|
||||||
|
- optional plugin logger and close hook
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
plugin := laniakea.NewPlugin[laniakea.NoDB]("admin")
|
||||||
|
```
|
||||||
|
|
||||||
|
Use plugins to group functionality by concern:
|
||||||
|
- `admin`
|
||||||
|
- `payments`
|
||||||
|
- `profile`
|
||||||
|
- `support`
|
||||||
|
|
||||||
|
That keeps command registration and middleware ownership clear.
|
||||||
|
|
||||||
|
## Command handlers
|
||||||
|
|
||||||
|
The command handler signature is:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func(ctx *laniakea.MsgContext, db T) error
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
- `ctx` is the current message/update context
|
||||||
|
- `db` is the dependency value of the bot’s generic type `T`
|
||||||
|
|
||||||
|
Return:
|
||||||
|
- `nil` on success
|
||||||
|
- `error` when the centralized bot error flow should handle failure
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func start(ctx *laniakea.MsgContext, db *App) error {
|
||||||
|
ctx.Answer("Welcome")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Registering commands
|
||||||
|
|
||||||
|
Create a command with `NewCommand(...)` and add it to a plugin:
|
||||||
|
|
||||||
|
```go
|
||||||
|
plugin := laniakea.NewPlugin[*App]("main")
|
||||||
|
plugin.AddCommand(plugin.NewCommand(start, "start"))
|
||||||
|
```
|
||||||
|
|
||||||
|
The command name:
|
||||||
|
- must not include the slash
|
||||||
|
- is matched against the parsed command token
|
||||||
|
|
||||||
|
So:
|
||||||
|
- `"start"` matches `/start`
|
||||||
|
- `"help"` matches `/help`
|
||||||
|
|
||||||
|
## The easiest command example
|
||||||
|
|
||||||
|
```go
|
||||||
|
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||||
|
if ctx.Text == "" {
|
||||||
|
ctx.Answer("Usage: /echo <text>")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Answer(ctx.Text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.AddCommand(plugin.NewCommand(echo, "echo"))
|
||||||
|
```
|
||||||
|
|
||||||
|
For `/echo hello world`:
|
||||||
|
- `ctx.Text == "hello world"`
|
||||||
|
- `ctx.Args == []string{"hello", "world"}`
|
||||||
|
|
||||||
|
## Commands with argument validation
|
||||||
|
|
||||||
|
You can declare command arguments using `CommandArg`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
plugin.AddCommand(
|
||||||
|
plugin.NewCommand(banUser, "ban",
|
||||||
|
laniakea.NewCommandArg("user_id").
|
||||||
|
SetValueType(laniakea.CommandValueIntType).
|
||||||
|
SetRequired(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This lets the framework validate:
|
||||||
|
- required argument presence
|
||||||
|
- integer/string/bool shape
|
||||||
|
- custom regex-based restrictions through the argument configuration
|
||||||
|
|
||||||
|
If validation fails, the command does not run and the bot error path is used.
|
||||||
|
|
||||||
|
## Payload handlers
|
||||||
|
|
||||||
|
Payload handlers are for callback data coming from inline keyboard buttons.
|
||||||
|
|
||||||
|
Register them with `NewPayload(...)` or `AddPayload(...)`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func confirmDelete(ctx *laniakea.MsgContext, db *App) error {
|
||||||
|
ctx.EditCallback("Deleted", nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.AddPayload(plugin.NewPayload(confirmDelete, "delete.confirm"))
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload handlers:
|
||||||
|
- are triggered by callback payload command names
|
||||||
|
- use the same handler signature as normal commands
|
||||||
|
- receive parsed payload args in `ctx.Args`
|
||||||
|
|
||||||
|
Use payloads when the trigger source is a button press, not a text command.
|
||||||
|
|
||||||
|
## Update handlers
|
||||||
|
|
||||||
|
Update handlers are for Telegram updates outside the normal command/payload flow.
|
||||||
|
|
||||||
|
Register them with `AddUpdateHandler(...)`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
plugin.AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *laniakea.MsgContext, db *App) error {
|
||||||
|
// handle inline query here
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the right tool for update types like:
|
||||||
|
- `inline_query`
|
||||||
|
- `chosen_inline_result`
|
||||||
|
- `poll`
|
||||||
|
- `chat_member`
|
||||||
|
- other non-command updates
|
||||||
|
|
||||||
|
Important:
|
||||||
|
- `message`
|
||||||
|
- `channel_post`
|
||||||
|
- `callback_query`
|
||||||
|
|
||||||
|
stay on the command/payload flow and are not meant to be registered through `AddUpdateHandler(...)`.
|
||||||
|
|
||||||
|
## The runtime flow
|
||||||
|
|
||||||
|
For text commands:
|
||||||
|
|
||||||
|
1. Telegram update arrives
|
||||||
|
2. bot prepares `MsgContext`
|
||||||
|
3. bot middleware runs
|
||||||
|
4. matching plugin is found
|
||||||
|
5. plugin middleware runs
|
||||||
|
6. command argument validation runs
|
||||||
|
7. command-specific middleware runs
|
||||||
|
8. command handler runs
|
||||||
|
9. returned error, if any, goes through centralized error handling
|
||||||
|
|
||||||
|
For callback payloads, the same idea applies, except the trigger comes from decoded callback data instead of text command parsing.
|
||||||
|
|
||||||
|
## Middleware placement
|
||||||
|
|
||||||
|
You have two main middleware levels:
|
||||||
|
|
||||||
|
### Plugin middleware
|
||||||
|
|
||||||
|
Added with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
plugin.AddMiddleware(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this for logic shared by most handlers in the plugin.
|
||||||
|
|
||||||
|
### Command-specific middleware
|
||||||
|
|
||||||
|
Added with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
plugin.NewCommand(handler, "name").Use(middleware)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this when only one command or payload needs the check.
|
||||||
|
|
||||||
|
See [[Middleware]] for behavior details.
|
||||||
|
|
||||||
|
## Good plugin boundaries
|
||||||
|
|
||||||
|
Good plugin grouping usually follows one of these patterns:
|
||||||
|
- by business domain: `billing`, `admin`, `profile`
|
||||||
|
- by update source: `inline`, `support`, `moderation`
|
||||||
|
- by ownership: one plugin per subsystem or package
|
||||||
|
|
||||||
|
Avoid one giant plugin for the entire bot unless the bot is very small.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example: admin command with plugin middleware
|
||||||
|
|
||||||
|
```go
|
||||||
|
admin := laniakea.NewPlugin[*App]("admin")
|
||||||
|
admin.AddMiddleware(laniakea.NewMiddleware("admin-only", func(ctx *laniakea.MsgContext, app *App) bool {
|
||||||
|
if !app.IsAdmin(ctx.FromID) {
|
||||||
|
ctx.Answer("Access denied")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}))
|
||||||
|
|
||||||
|
admin.AddCommand(admin.NewCommand(func(ctx *laniakea.MsgContext, app *App) error {
|
||||||
|
ctx.Answer("Banned")
|
||||||
|
return nil
|
||||||
|
}, "ban"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: payload handler for inline keyboard callback
|
||||||
|
|
||||||
|
```go
|
||||||
|
plugin.AddPayload(plugin.NewPayload(func(ctx *laniakea.MsgContext, app *App) error {
|
||||||
|
ctx.AnswerCbQueryText("Accepted")
|
||||||
|
ctx.EditCallback("Done", nil)
|
||||||
|
return nil
|
||||||
|
}, "approve"))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common mistakes
|
||||||
|
|
||||||
|
### Putting the slash into command names
|
||||||
|
|
||||||
|
Wrong:
|
||||||
|
|
||||||
|
```go
|
||||||
|
plugin.NewCommand(start, "/start")
|
||||||
|
```
|
||||||
|
|
||||||
|
Right:
|
||||||
|
|
||||||
|
```go
|
||||||
|
plugin.NewCommand(start, "start")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Treating payloads like commands
|
||||||
|
|
||||||
|
Payloads are not matched from text messages. They come from button callback data.
|
||||||
|
|
||||||
|
### Using `AddUpdateHandler(...)` for command updates
|
||||||
|
|
||||||
|
Reserved update types like `message`, `channel_post`, and `callback_query` belong to the normal command/payload pipeline.
|
||||||
|
|
||||||
|
### Returning errors for normal user-facing branching
|
||||||
|
|
||||||
|
Not every branch is an error. For normal usage failures, often the better pattern is:
|
||||||
|
|
||||||
|
```go
|
||||||
|
ctx.Answer("Usage: /ban <id>")
|
||||||
|
return nil
|
||||||
|
```
|
||||||
|
|
||||||
|
Return an error when the failure is genuinely exceptional or when you want the centralized error flow.
|
||||||
|
|
||||||
|
## When to use what
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- commands for slash-prefixed user messages
|
||||||
|
- payload handlers for inline button callbacks
|
||||||
|
- update handlers for other Telegram update types
|
||||||
|
- plugin middleware for shared checks
|
||||||
|
- command middleware for narrow, local checks
|
||||||
|
|
||||||
|
## Where to go next
|
||||||
|
- Read [[MsgContext]] next to understand what handlers can do once they are triggered.
|
||||||
|
- Read [[Inline-Keyboards-and-Payloads]] if you are starting to use buttons and callback data.
|
||||||
|
- Read [[Middleware]] for execution-order and async details.
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
# Drafts
|
||||||
|
|
||||||
|
Drafts provide a staged way to accumulate and then flush messages.
|
||||||
|
|
||||||
|
## This page should cover
|
||||||
|
- `DraftProvider`, `Draft`, `Push`, and `Flush`;
|
||||||
|
- random vs linear draft IDs;
|
||||||
|
- validation and send-time behavior;
|
||||||
|
- when drafts are better than direct replies.
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# FAQ
|
||||||
|
|
||||||
|
This page should answer the recurring design and usage questions around Laniakea.
|
||||||
|
|
||||||
|
## This page should cover
|
||||||
|
- why handlers return `error`;
|
||||||
|
- why `Bot` is single-use;
|
||||||
|
- why `AnswerLong` is separate from `Answer`;
|
||||||
|
- why both JSON and Base64 payloads exist;
|
||||||
|
- how to choose between high-level helpers and `tgapi`.
|
||||||
+261
@@ -0,0 +1,261 @@
|
|||||||
|
# Getting Started
|
||||||
|
|
||||||
|
Start here if you are integrating Laniakea into a new bot for the first time.
|
||||||
|
|
||||||
|
This page covers the shortest path to a working bot, the minimum concepts you need to understand, and the most important defaults that affect startup and runtime behavior.
|
||||||
|
|
||||||
|
## What you need first
|
||||||
|
- Go 1.24 or newer
|
||||||
|
- a Telegram bot token from `@BotFather`
|
||||||
|
- a module that can import `git.nix13.pw/scuroneko/laniakea`
|
||||||
|
|
||||||
|
Install the module with one of:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get git.nix13.pw/scuroneko/laniakea
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get github.com/scuroneko/laniakea
|
||||||
|
```
|
||||||
|
|
||||||
|
## The smallest useful bot
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"git.nix13.pw/scuroneko/laniakea"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ping(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||||
|
ctx.Answer("Pong")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
bot, err := laniakea.NewBot[laniakea.NoDB](&laniakea.BotOpts{
|
||||||
|
Token: "TOKEN",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer bot.Close()
|
||||||
|
|
||||||
|
plugin := laniakea.NewPlugin[laniakea.NoDB]("main")
|
||||||
|
plugin.AddCommand(plugin.NewCommand(ping, "ping"))
|
||||||
|
|
||||||
|
bot.AddPlugins(plugin)
|
||||||
|
|
||||||
|
if err := bot.Run(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the user sends `/ping`, the bot replies with `Pong`.
|
||||||
|
|
||||||
|
## The first things to understand
|
||||||
|
|
||||||
|
### 1. `NewBot[T]` uses a generic dependency type
|
||||||
|
|
||||||
|
The type parameter `T` is the shared dependency context passed into handlers, middleware, and runners.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- `laniakea.NoDB` when you do not need dependency injection
|
||||||
|
- a pointer type like `*sql.DB`, `*Store`, or `*App` when you do
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type App struct {
|
||||||
|
Users *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
app := &App{Users: db}
|
||||||
|
|
||||||
|
bot, err := laniakea.NewBot[*App](opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.DatabaseContext(app)
|
||||||
|
```
|
||||||
|
|
||||||
|
Pointer types are usually the right default for shared application state.
|
||||||
|
|
||||||
|
### 2. Commands live inside plugins
|
||||||
|
|
||||||
|
Laniakea does not register commands directly on the bot. The normal flow is:
|
||||||
|
- create a bot
|
||||||
|
- create one or more plugins
|
||||||
|
- add commands and middleware to plugins
|
||||||
|
- register plugins on the bot
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
plugin := laniakea.NewPlugin[laniakea.NoDB]("admin")
|
||||||
|
plugin.AddCommand(plugin.NewCommand(ping, "ping"))
|
||||||
|
bot.AddPlugins(plugin)
|
||||||
|
```
|
||||||
|
|
||||||
|
See [[Commands-and-Plugins]] for the full model.
|
||||||
|
|
||||||
|
### 3. Handlers return `error`
|
||||||
|
|
||||||
|
The command handler signature is:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func(ctx *laniakea.MsgContext, db T) error
|
||||||
|
```
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- do your normal work inside the handler
|
||||||
|
- return `nil` on success
|
||||||
|
- return an error when you want centralized bot error handling
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func profile(ctx *laniakea.MsgContext, db *App) error {
|
||||||
|
user, err := db.LoadUser(ctx.FromID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Answerf("Hello, %s", user.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. `Run()` is not reusable
|
||||||
|
|
||||||
|
A `Bot` instance is single-use.
|
||||||
|
|
||||||
|
After `Run()` or `RunWithContext(...)` returns:
|
||||||
|
- do not call `Run()` again on the same bot
|
||||||
|
- create a new bot instance for the next run
|
||||||
|
|
||||||
|
This is an intentional lifecycle rule, not a temporary limitation.
|
||||||
|
|
||||||
|
See [[Bot-Lifecycle]] for details.
|
||||||
|
|
||||||
|
### 5. Always close the bot
|
||||||
|
|
||||||
|
`RunWithContext(...)` and `Run()` do not replace `Close()`.
|
||||||
|
|
||||||
|
You should still release bot-owned resources explicitly:
|
||||||
|
|
||||||
|
```go
|
||||||
|
defer bot.Close()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended startup sequence
|
||||||
|
|
||||||
|
For most bots, this order is the least surprising:
|
||||||
|
|
||||||
|
1. Build `BotOpts`
|
||||||
|
2. Call `NewBot[T](opts)`
|
||||||
|
3. Attach database context, localization, or other configuration
|
||||||
|
4. Create plugins
|
||||||
|
5. Add commands, payloads, and middleware to plugins
|
||||||
|
6. Register plugins with `AddPlugins(...)`
|
||||||
|
7. Optionally call `AutoGenerateCommands()`
|
||||||
|
8. Call `Run()` or `RunWithContext(...)`
|
||||||
|
9. Call `Close()` when done
|
||||||
|
|
||||||
|
## A slightly more realistic example
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"git.nix13.pw/scuroneko/laniakea"
|
||||||
|
)
|
||||||
|
|
||||||
|
type App struct{}
|
||||||
|
|
||||||
|
func echo(ctx *laniakea.MsgContext, app *App) error {
|
||||||
|
if ctx.Text == "" {
|
||||||
|
ctx.Answer("Send some text after the command.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Answer(ctx.Text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
bot, err := laniakea.NewBot[*App](&laniakea.BotOpts{
|
||||||
|
Token: "TOKEN",
|
||||||
|
Debug: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer bot.Close()
|
||||||
|
|
||||||
|
bot.DatabaseContext(&App{})
|
||||||
|
|
||||||
|
plugin := laniakea.NewPlugin[*App]("main")
|
||||||
|
plugin.AddCommand(plugin.NewCommand(echo, "echo"))
|
||||||
|
|
||||||
|
bot.AddPlugins(plugin)
|
||||||
|
|
||||||
|
if err := bot.AutoGenerateCommands(); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
if err := bot.Run(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This bot:
|
||||||
|
- enables debug logging
|
||||||
|
- injects an application dependency
|
||||||
|
- registers a command through a plugin
|
||||||
|
- generates Telegram command metadata
|
||||||
|
- echoes the command text back to the user
|
||||||
|
|
||||||
|
## Common first-run pitfalls
|
||||||
|
|
||||||
|
### Missing token
|
||||||
|
|
||||||
|
`NewBot(...)` validates the token configuration and returns an error if it is missing.
|
||||||
|
|
||||||
|
### No plugins
|
||||||
|
|
||||||
|
Running a bot without registered plugins is invalid. The bot expects at least one plugin before start.
|
||||||
|
|
||||||
|
### No prefixes
|
||||||
|
|
||||||
|
The bot also requires command prefixes. If you do not configure them, the default is usually `"/"`.
|
||||||
|
|
||||||
|
### Forgetting that handlers receive parsed command text
|
||||||
|
|
||||||
|
For commands, `ctx.Text` contains the text after the command itself, not the original raw message.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- incoming message: `/echo hello world`
|
||||||
|
- command name: `echo`
|
||||||
|
- `ctx.Text`: `hello world`
|
||||||
|
- `ctx.Args`: `[]string{"hello", "world"}`
|
||||||
|
|
||||||
|
### Using value types for shared dependencies
|
||||||
|
|
||||||
|
This often works, but it is easy to accidentally copy state.
|
||||||
|
|
||||||
|
Prefer pointer types unless you have a strong reason not to.
|
||||||
|
|
||||||
|
## Where to go next
|
||||||
|
- Read [[Commands-and-Plugins]] next if you want to build the handler layer correctly.
|
||||||
|
- Read [[MsgContext]] next if you want to understand reply, edit, callback, and draft helpers.
|
||||||
|
- Read [[Bot-Lifecycle]] if you need shutdown, worker, or startup details.
|
||||||
+30
-1
@@ -1 +1,30 @@
|
|||||||
Добро пожаловать в вики.
|
# Laniakea Wiki
|
||||||
|
|
||||||
|
Laniakea is a Go framework and Telegram Bot API wrapper built around plugins, typed handlers, middleware, and explicit control over update and reply flow.
|
||||||
|
|
||||||
|
Use this wiki as the structured companion to the README: start with setup, then move through commands, context, keyboards, and lower-level API usage.
|
||||||
|
|
||||||
|
Current fill plan: [[Page-Priority]]
|
||||||
|
|
||||||
|
## Start here
|
||||||
|
- [[Getting-Started]]
|
||||||
|
- [[Commands-and-Plugins]]
|
||||||
|
- [[MsgContext]]
|
||||||
|
|
||||||
|
## Core API
|
||||||
|
- [[Inline-Keyboards-and-Payloads]]
|
||||||
|
- [[tgapi-Overview]]
|
||||||
|
- [[Bot-Lifecycle]]
|
||||||
|
- [[Middleware]]
|
||||||
|
|
||||||
|
## Changes and troubleshooting
|
||||||
|
- [[Migration]]
|
||||||
|
- [[FAQ]]
|
||||||
|
|
||||||
|
## Additional topics
|
||||||
|
- [[Drafts]]
|
||||||
|
- [[Localization]]
|
||||||
|
- [[Rate-Limiting]]
|
||||||
|
- [[Recipes]]
|
||||||
|
- [[Semver-and-Releases]]
|
||||||
|
- [[Page-Priority]]
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Inline Keyboards and Payloads
|
||||||
|
|
||||||
|
This page documents how Laniakea builds inline keyboards and encodes callback payloads.
|
||||||
|
|
||||||
|
## This page should cover
|
||||||
|
- `InlineKeyboard` builders and row layout;
|
||||||
|
- JSON vs Base64 callback payloads;
|
||||||
|
- bot default payload type vs keyboard-local override;
|
||||||
|
- strict payload mode and tolerant fallback behavior;
|
||||||
|
- payload size and compatibility guidance.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Localization
|
||||||
|
|
||||||
|
Localization in Laniakea is centered around `L10n` and key-based translation lookup.
|
||||||
|
|
||||||
|
## This page should cover
|
||||||
|
- fallback language behavior;
|
||||||
|
- attaching localization to the bot;
|
||||||
|
- `MsgContext.Translate`;
|
||||||
|
- organizing dictionaries and keeping them maintainable.
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Middleware
|
||||||
|
|
||||||
|
Middleware lets you run logic before commands, payloads, and update handlers.
|
||||||
|
|
||||||
|
## This page should cover
|
||||||
|
- synchronous vs asynchronous middleware;
|
||||||
|
- stop/continue behavior;
|
||||||
|
- ordering expectations;
|
||||||
|
- race and mutation caveats for async middleware;
|
||||||
|
- where middleware fits relative to plugins and handlers.
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
# Migration
|
||||||
|
|
||||||
|
Use this page to track version-to-version changes that affect existing bots.
|
||||||
|
|
||||||
|
## This page should cover
|
||||||
|
- breaking changes by release;
|
||||||
|
- migration notes for major release-candidate milestones;
|
||||||
|
- changed defaults and behavior contracts;
|
||||||
|
- links to [[Semver-and-Releases]] and `CHANGELOG.md`.
|
||||||
+358
@@ -0,0 +1,358 @@
|
|||||||
|
# MsgContext
|
||||||
|
|
||||||
|
`MsgContext` is the runtime object passed into command handlers, payload handlers, middleware, and update handlers.
|
||||||
|
|
||||||
|
It gives you access to:
|
||||||
|
- the incoming update
|
||||||
|
- the current message and sender
|
||||||
|
- parsed command or payload arguments
|
||||||
|
- reply, edit, delete, callback, draft, and localization helpers
|
||||||
|
|
||||||
|
If you write handlers, `MsgContext` is the API surface you will use most often.
|
||||||
|
|
||||||
|
## The fields you will use first
|
||||||
|
|
||||||
|
### `Text`
|
||||||
|
|
||||||
|
`ctx.Text` is the parsed text payload after the command name.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- incoming message: `/echo hello world`
|
||||||
|
- command: `echo`
|
||||||
|
- `ctx.Text == "hello world"`
|
||||||
|
|
||||||
|
This is usually the easiest field to use for simple commands.
|
||||||
|
|
||||||
|
### `Args`
|
||||||
|
|
||||||
|
`ctx.Args` is the tokenized version of `ctx.Text`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- `ctx.Text == "hello world"`
|
||||||
|
- `ctx.Args == []string{"hello", "world"}`
|
||||||
|
|
||||||
|
Use this when you want simple positional arguments.
|
||||||
|
|
||||||
|
### `Msg`
|
||||||
|
|
||||||
|
`ctx.Msg` points to the current Telegram message when the current update has one.
|
||||||
|
|
||||||
|
You will often use it for:
|
||||||
|
- chat ID
|
||||||
|
- thread ID
|
||||||
|
- original message metadata
|
||||||
|
|
||||||
|
Not every update has a message. For some non-message update types, `ctx.Msg` is nil.
|
||||||
|
|
||||||
|
### `From` and `FromID`
|
||||||
|
|
||||||
|
`ctx.From` is the sender user when one exists.
|
||||||
|
|
||||||
|
`ctx.FromID` is the same sender’s numeric ID, extracted for convenience.
|
||||||
|
|
||||||
|
Use `FromID` when you only need the identifier and do not want to keep checking for nil.
|
||||||
|
|
||||||
|
## The helpers you will use most often
|
||||||
|
|
||||||
|
### `Answer`
|
||||||
|
|
||||||
|
Use `Answer(...)` for the normal “reply with text” case.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func start(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||||
|
ctx.Answer("Welcome")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the default high-level reply helper for plain text.
|
||||||
|
|
||||||
|
### `AnswerLong`
|
||||||
|
|
||||||
|
Use `AnswerLong(...)` when plain text may exceed Telegram’s message limit.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func help(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||||
|
ctx.AnswerLong(buildLargeHelpText())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Important:
|
||||||
|
- this is explicit on purpose
|
||||||
|
- normal `Answer(...)` keeps single-message semantics
|
||||||
|
- `AnswerLong(...)` may send multiple messages
|
||||||
|
|
||||||
|
### `Keyboard`
|
||||||
|
|
||||||
|
Use `Keyboard(...)` when you want to send a message with an inline keyboard.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func menu(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||||
|
kb := ctx.NewInlineKeyboard(2).
|
||||||
|
AddCallbackButton("Profile", "profile.open").
|
||||||
|
AddCallbackButton("Settings", "settings.open")
|
||||||
|
|
||||||
|
ctx.Keyboard("Choose an action", kb)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `KeyboardLong`
|
||||||
|
|
||||||
|
Use `KeyboardLong(...)` when plain text may be too long and the keyboard should stay attached to the final chunk.
|
||||||
|
|
||||||
|
This is useful for:
|
||||||
|
- long help text
|
||||||
|
- generated summaries
|
||||||
|
- reports with an action button at the end
|
||||||
|
|
||||||
|
## Markdown helpers
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- `AnswerMarkdown(...)`
|
||||||
|
- `KeyboardMarkdown(...)`
|
||||||
|
- `EditCallbackMarkdown(...)`
|
||||||
|
- other `...Markdown` variants
|
||||||
|
|
||||||
|
Important rule:
|
||||||
|
- user input must be escaped before passing it into MarkdownV2 helpers
|
||||||
|
|
||||||
|
Use `laniakea.EscapeMarkdownV2(...)` for this.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func whoami(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||||
|
name := laniakea.EscapeMarkdownV2(ctx.From.FirstName)
|
||||||
|
ctx.AnswerMarkdown("*User:* " + name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Editing and deleting
|
||||||
|
|
||||||
|
Once you already have an `AnswerMessage`, you can edit or delete it.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func slowTask(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||||
|
msg := ctx.Answer("Working...")
|
||||||
|
if msg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// do work
|
||||||
|
|
||||||
|
msg.Edit("Done")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Available patterns include:
|
||||||
|
- `Edit(...)`
|
||||||
|
- `EditMarkdown(...)`
|
||||||
|
- `EditCaption(...)`
|
||||||
|
- `Delete()`
|
||||||
|
|
||||||
|
These methods assume a single concrete message target.
|
||||||
|
|
||||||
|
That is why multi-message helpers like `AnswerLong(...)` are separate APIs.
|
||||||
|
|
||||||
|
## Callback-specific helpers
|
||||||
|
|
||||||
|
When handling inline button callbacks, these helpers are especially useful.
|
||||||
|
|
||||||
|
### `EditCallback`
|
||||||
|
|
||||||
|
Edits the callback-linked message.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func approve(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||||
|
ctx.EditCallback("Approved", nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `AnswerCbQuery`
|
||||||
|
|
||||||
|
Acknowledges the callback query itself.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- `AnswerCbQuery()` for empty acknowledgement
|
||||||
|
- `AnswerCbQueryText(...)` for a short notice
|
||||||
|
- `AnswerCbQueryAlert(...)` for a visible alert
|
||||||
|
- `AnswerCbQueryUrl(...)` for redirect behavior
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func approve(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||||
|
ctx.AnswerCbQueryText("Saved")
|
||||||
|
ctx.EditCallback("Saved", nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `CallbackDelete`
|
||||||
|
|
||||||
|
Deletes the message that triggered the callback.
|
||||||
|
|
||||||
|
Use this only when that behavior is really clear to the user.
|
||||||
|
|
||||||
|
## Photos and captions
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- `AnswerPhoto(...)`
|
||||||
|
- `AnswerPhotoKeyboard(...)`
|
||||||
|
- `AnswerPhotoMarkdown(...)`
|
||||||
|
|
||||||
|
These helpers are for sending a photo with an optional caption.
|
||||||
|
|
||||||
|
Caption rules differ from normal message text:
|
||||||
|
- captions have a smaller Telegram limit
|
||||||
|
- caption editing uses the caption-specific edit helpers
|
||||||
|
|
||||||
|
## Drafts
|
||||||
|
|
||||||
|
`MsgContext` also exposes draft creation helpers:
|
||||||
|
- `NewDraft()`
|
||||||
|
- `NewDraftMarkdown()`
|
||||||
|
|
||||||
|
Drafts are useful when:
|
||||||
|
- a response is built incrementally
|
||||||
|
- you want to stage text before flushing
|
||||||
|
- the workflow benefits from draft IDs or batching behavior
|
||||||
|
|
||||||
|
For ordinary one-shot replies, `Answer(...)` is simpler.
|
||||||
|
|
||||||
|
See [[Drafts]] for the full model.
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```go
|
||||||
|
ctx.Translate("some.key")
|
||||||
|
```
|
||||||
|
|
||||||
|
This looks up text using the current user’s language when available and falls back to the configured default language.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ping(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||||
|
ctx.Answer(ctx.Translate("ping.answer"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [[Localization]] for setup and dictionary structure.
|
||||||
|
|
||||||
|
## `NewInlineKeyboard`
|
||||||
|
|
||||||
|
The recommended way to build keyboards inside handlers is:
|
||||||
|
|
||||||
|
```go
|
||||||
|
kb := ctx.NewInlineKeyboard(2)
|
||||||
|
```
|
||||||
|
|
||||||
|
This is important because it inherits the bot’s default payload configuration automatically.
|
||||||
|
|
||||||
|
A keyboard can still override its own payload type locally if needed.
|
||||||
|
|
||||||
|
See [[Inline-Keyboards-and-Payloads]].
|
||||||
|
|
||||||
|
## Sending chat actions
|
||||||
|
|
||||||
|
Use `SendAction(...)` to show activity like typing or uploading.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func report(ctx *laniakea.MsgContext, db *App) error {
|
||||||
|
ctx.SendAction(tgapi.ChatActionTyping)
|
||||||
|
text, err := db.BuildReport(ctx.FromID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx.AnswerLong(text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is especially useful for slower handlers.
|
||||||
|
|
||||||
|
## Error handling inside handlers
|
||||||
|
|
||||||
|
A common pattern is:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func profile(ctx *laniakea.MsgContext, db *App) error {
|
||||||
|
user, err := db.LoadUser(ctx.FromID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx.Answer(user.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You usually do not call `ctx.Error(...)` directly in normal handlers unless you intentionally want immediate explicit error messaging there.
|
||||||
|
|
||||||
|
The more idiomatic pattern is:
|
||||||
|
- return `error`
|
||||||
|
- let the bot’s centralized error flow handle it
|
||||||
|
|
||||||
|
## Common pitfalls
|
||||||
|
|
||||||
|
### `ctx.Msg` can be nil
|
||||||
|
|
||||||
|
Do not assume every update has a message object.
|
||||||
|
|
||||||
|
This especially matters in custom update handlers.
|
||||||
|
|
||||||
|
### `Answer(...)` and `Edit(...)` are not interchangeable
|
||||||
|
|
||||||
|
`Answer(...)` creates a new message.
|
||||||
|
|
||||||
|
`Edit(...)` changes an existing one and requires a valid target.
|
||||||
|
|
||||||
|
### Long replies are explicit
|
||||||
|
|
||||||
|
If the text may exceed Telegram’s normal message limit, use `AnswerLong(...)` or `KeyboardLong(...)`.
|
||||||
|
|
||||||
|
### Markdown helpers require escaping
|
||||||
|
|
||||||
|
Do not pass raw user input to MarkdownV2 methods without escaping.
|
||||||
|
|
||||||
|
### Callback helpers only make sense in callback flow
|
||||||
|
|
||||||
|
Methods like `EditCallback(...)` and `AnswerCbQueryText(...)` depend on callback-specific context.
|
||||||
|
|
||||||
|
## A practical example
|
||||||
|
|
||||||
|
```go
|
||||||
|
func settings(ctx *laniakea.MsgContext, db *App) error {
|
||||||
|
kb := ctx.NewInlineKeyboard(1).
|
||||||
|
AddCallbackButton("Enable notifications", "settings.notifications.enable").
|
||||||
|
AddCallbackButton("Disable notifications", "settings.notifications.disable")
|
||||||
|
|
||||||
|
ctx.Keyboard("Notification settings", kb)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This example uses:
|
||||||
|
- a handler
|
||||||
|
- `ctx.NewInlineKeyboard(...)`
|
||||||
|
- callback payload routing
|
||||||
|
- `ctx.Keyboard(...)`
|
||||||
|
|
||||||
|
That is a typical Laniakea interaction pattern.
|
||||||
|
|
||||||
|
## Where to go next
|
||||||
|
- Read [[Inline-Keyboards-and-Payloads]] if you are building button-driven flows.
|
||||||
|
- Read [[Commands-and-Plugins]] if you want the full handler registration model.
|
||||||
|
- Read [[Drafts]] if you need staged or multi-step message assembly.
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# Page Priority
|
||||||
|
|
||||||
|
This page tracks the recommended fill order for the wiki while documentation is still being built out.
|
||||||
|
|
||||||
|
## Priority 1
|
||||||
|
- [[Getting-Started]]
|
||||||
|
- [[Commands-and-Plugins]]
|
||||||
|
- [[MsgContext]]
|
||||||
|
- [[Inline-Keyboards-and-Payloads]]
|
||||||
|
- [[tgapi-Overview]]
|
||||||
|
|
||||||
|
## Priority 2
|
||||||
|
- [[Bot-Lifecycle]]
|
||||||
|
- [[Middleware]]
|
||||||
|
- [[Migration]]
|
||||||
|
- [[FAQ]]
|
||||||
|
|
||||||
|
## Priority 3
|
||||||
|
- [[Drafts]]
|
||||||
|
- [[Localization]]
|
||||||
|
- [[Rate-Limiting]]
|
||||||
|
- [[Recipes]]
|
||||||
|
|
||||||
|
## Priority 4
|
||||||
|
- [[Semver-and-Releases]]
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Keep `Home.md` ordered the same way.
|
||||||
|
- Within each page, explain the most necessary and most frequently used APIs first.
|
||||||
|
- Add new pages into this list before writing large amounts of content for them.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Rate Limiting
|
||||||
|
|
||||||
|
This page explains how Laniakea applies request throttling and reacts to Telegram rate-limit responses.
|
||||||
|
|
||||||
|
## This page should cover
|
||||||
|
- configured limiter behavior;
|
||||||
|
- drop mode vs waiting;
|
||||||
|
- `retry_after` handling;
|
||||||
|
- practical tuning guidelines for different bot sizes.
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Recipes
|
||||||
|
|
||||||
|
This page should collect short, task-focused examples for common bot patterns.
|
||||||
|
|
||||||
|
## This page should cover
|
||||||
|
- admin-only commands;
|
||||||
|
- callback button flows;
|
||||||
|
- long replies;
|
||||||
|
- file uploads;
|
||||||
|
- localized commands;
|
||||||
|
- custom update handlers.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Semver and Releases
|
||||||
|
|
||||||
|
This page is for maintainers and contributors who need the project’s release rules in one place.
|
||||||
|
|
||||||
|
## This page should cover
|
||||||
|
- what counts as public API;
|
||||||
|
- what is considered a breaking change;
|
||||||
|
- how version numbers are chosen;
|
||||||
|
- how changelog sections map to published tags.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# tgapi Overview
|
||||||
|
|
||||||
|
`tgapi` is the lower-level Telegram API layer used by Laniakea.
|
||||||
|
|
||||||
|
## This page should cover
|
||||||
|
- the difference between `API` and `Uploader`;
|
||||||
|
- when to use typed helpers vs low-level request builders;
|
||||||
|
- context-aware methods;
|
||||||
|
- request/response modeling and Telegram wire compatibility;
|
||||||
|
- where `tgapi` ends and higher-level bot behavior begins.
|
||||||
Reference in New Issue
Block a user