Compare commits
3 Commits
v1.0.0-bet
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
6ba8520bb7
|
|||
|
e4203e8fc0
|
|||
|
c179a3f5f0
|
21
README.md
21
README.md
@@ -124,15 +124,21 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
|
|||||||
|
|
||||||
Provides access to the incoming message and useful reply methods:
|
Provides access to the incoming message and useful reply methods:
|
||||||
|
|
||||||
- `Answer(text string)`: Sends a plain text message, automatically escaping MarkdownV2.
|
- `Answer(text string) *AnswerMessage`: Sends a message with parse_mode none.
|
||||||
- `AnswerMarkdown(text string)`: Sends a message formatted with MarkdownV2 (you handle escaping).
|
- `AnswerMarkdown(text string) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping).
|
||||||
- `AnswerText(text string)`: Sends a message with no parse_mode.
|
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard.
|
||||||
- `SendChatAction(action string)`: Sends a "typing", "uploading photo", etc., action.
|
- `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 message formatted with MarkdownV2 (you handle escaping) with.
|
||||||
|
- `EditCallback(text string)`: Edits message with parse_mode none after clicking inline button.
|
||||||
|
- `EditCallbackMarkdown(text string)`: Edits a message formatted with MarkdownV2 (you handle escaping) after clicking inline button.
|
||||||
|
- `SendChatAction(action string)`: Sends a “typing”, “uploading photo”, etc., action.
|
||||||
- Fields: `Text`, `Args`, `From`, `Chat`, `Msg`, etc.
|
- Fields: `Text`, `Args`, `From`, `Chat`, `Msg`, etc.
|
||||||
|
- And more methods and fields!
|
||||||
|
|
||||||
### Database Context
|
### Database Context
|
||||||
|
|
||||||
The `T` in `NewBot[T]` is a powerful feature. You can pass any type (like a database connection pool) and it will be available in every command and middleware handler.
|
The `T` in `NewBot[T]` is a powerful feature. You can pass any type (like a database connection pool), and it will be available in every command and middleware handler.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type MyDB struct { /* ... */ }
|
type MyDB struct { /* ... */ }
|
||||||
@@ -185,16 +191,15 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
|||||||
|
|
||||||
### Important Notes
|
### Important Notes
|
||||||
- Middleware can modify the MsgContext (e.g., add custom fields) before the command runs.
|
- Middleware can modify the MsgContext (e.g., add custom fields) before the command runs.
|
||||||
- If you need to run code after a command, you can call it from within the command itself or use a defer statement inside the middleware that wraps the next call (more advanced).
|
|
||||||
|
|
||||||
## ⚙️ Advanced Configuration
|
## ⚙️ Advanced Configuration
|
||||||
- **Inline Keyboards**: Build keyboards using laniakea.NewKeyboard() and AddRow().
|
- **Inline Keyboards**: Build keyboards using laniakea.NewKeyboard().
|
||||||
- **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully.
|
- **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully.
|
||||||
- **Custom HTTP Client**: Provide your own http.Client in BotOpts for fine-tuned control.
|
- **Custom HTTP Client**: Provide your own http.Client in BotOpts for fine-tuned control.
|
||||||
|
|
||||||
## 📝 License
|
## 📝 License
|
||||||
|
|
||||||
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
|
This project is licensed under the GNU General Public License v3.0 — see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
## 📚 Learn More
|
## 📚 Learn More
|
||||||
[GoDoc](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea)
|
[GoDoc](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea)
|
||||||
|
|||||||
13
README_RU.md
13
README_RU.md
@@ -124,11 +124,17 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
|
|||||||
### Контекст сообщения (MsgContext)
|
### Контекст сообщения (MsgContext)
|
||||||
Предоставляет доступ к входящему сообщению и полезные методы для ответа:
|
Предоставляет доступ к входящему сообщению и полезные методы для ответа:
|
||||||
|
|
||||||
- `Answer(text string)`: Отправляет обычный текст, автоматически экранируя MarkdownV2.
|
- `Answer(text string)`: Отправляет сообщение с parse_mode none.
|
||||||
- `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне).
|
- `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне).
|
||||||
- `AnswerText(text string)`: Отправляет сообщение без parse_mode.
|
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой.
|
||||||
|
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой.
|
||||||
|
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none.
|
||||||
|
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне).
|
||||||
|
- `EditCallback(text string)`: Редактирует сообщение, форматируя его в MarkdownV2 (экранирование на вашей стороне), после нажатия Inline кнопки.
|
||||||
|
- `EditCallbackMarkdown(text string)`: Редактирует сообщение с parse_mode none после нажатия Inline кнопки.
|
||||||
- `SendChatAction(action string)`: Отправляет действие "печатает", "загружает фото" и т.д.
|
- `SendChatAction(action string)`: Отправляет действие "печатает", "загружает фото" и т.д.
|
||||||
- Поля: `Text`, `Args`, `From`, `Chat`, `Msg` и другие.
|
- Поля: `Text`, `Args`, `From`, `Chat`, `Msg` и другие.
|
||||||
|
- И много других методов и полей!
|
||||||
|
|
||||||
### Контекст базы данных (Database Context)
|
### Контекст базы данных (Database Context)
|
||||||
Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип (например, пул соединений с БД), и он будет доступен в каждом обработчике команды и中间件.
|
Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип (например, пул соединений с БД), и он будет доступен в каждом обработчике команды и中间件.
|
||||||
@@ -184,10 +190,9 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
|||||||
|
|
||||||
### Важные замечания
|
### Важные замечания
|
||||||
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
|
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
|
||||||
- Если нужно выполнить код после команды, это можно сделать внутри самой команды или использовать отложенный вызов (defer) в middleware, который оборачивает следующий вызов (более продвинутый подход).
|
|
||||||
|
|
||||||
## ⚙️ Расширенная настройка
|
## ⚙️ Расширенная настройка
|
||||||
**Инлайн-клавиатуры**: Создавайте клавиатуры с помощью laniakea.NewKeyboard() и AddRow().
|
**Инлайн-клавиатуры**: Создавайте клавиатуры с помощью laniakea.NewKeyboard().
|
||||||
**Ограничение запросов**: Передайте настроенный utils.RateLimiter через BotOpts для корректной обработки лимитов Telegram.
|
**Ограничение запросов**: Передайте настроенный utils.RateLimiter через BotOpts для корректной обработки лимитов Telegram.
|
||||||
**Пользовательский HTTP-клиент**: Предоставьте свой http.Client в BotOpts для точного контроля.
|
**Пользовательский HTTP-клиент**: Предоставьте свой http.Client в BotOpts для точного контроля.
|
||||||
|
|
||||||
|
|||||||
17
bot.go
17
bot.go
@@ -163,7 +163,7 @@ func LoadPrefixesFromEnv() []string {
|
|||||||
// bot := NewBot[MyDB](opts).DatabaseContext(&myDB)
|
// bot := NewBot[MyDB](opts).DatabaseContext(&myDB)
|
||||||
//
|
//
|
||||||
// Use NoDB if no database is needed.
|
// Use NoDB if no database is needed.
|
||||||
type DbContext interface{}
|
type DbContext any
|
||||||
|
|
||||||
// NoDB is a placeholder type for bots that do not use a database.
|
// NoDB is a placeholder type for bots that do not use a database.
|
||||||
// Use Bot[NoDB] to indicate no dependency injection is required.
|
// Use Bot[NoDB] to indicate no dependency injection is required.
|
||||||
@@ -257,6 +257,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
|
|||||||
bot := &Bot[T]{
|
bot := &Bot[T]{
|
||||||
updateOffset: 0,
|
updateOffset: 0,
|
||||||
errorTemplate: "%s",
|
errorTemplate: "%s",
|
||||||
|
payloadType: BotPayloadBase64,
|
||||||
updateQueue: updateQueue,
|
updateQueue: updateQueue,
|
||||||
api: api,
|
api: api,
|
||||||
uploader: uploader,
|
uploader: uploader,
|
||||||
@@ -598,12 +599,18 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.ExecRunners()
|
bot.ExecRunners(ctx)
|
||||||
|
|
||||||
bot.logger.Infoln("Bot running. Press CTRL+C to exit.")
|
bot.logger.Infoln("Bot running. Press CTRL+C to exit.")
|
||||||
|
|
||||||
// Start update polling in a goroutine
|
// Start update polling in a goroutine
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
bot.logger.Errorln(fmt.Sprintf("panic in update polling: %v", r))
|
||||||
|
}
|
||||||
|
close(bot.updateQueue)
|
||||||
|
}()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -617,6 +624,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, u := range updates {
|
for _, u := range updates {
|
||||||
|
u := u // copy loop variable to avoid race condition
|
||||||
select {
|
select {
|
||||||
case bot.updateQueue <- &u:
|
case bot.updateQueue <- &u:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -630,11 +638,12 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
|
|||||||
// Start worker pool for concurrent update handling
|
// Start worker pool for concurrent update handling
|
||||||
pool := pond.NewPool(16)
|
pool := pond.NewPool(16)
|
||||||
for update := range bot.updateQueue {
|
for update := range bot.updateQueue {
|
||||||
update := update // capture loop variable
|
u := update // capture loop variable
|
||||||
pool.Submit(func() {
|
pool.Submit(func() {
|
||||||
bot.handle(update)
|
bot.handle(u)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
pool.Stop() // Wait for all tasks to complete and stop the pool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the bot using a background context.
|
// Run starts the bot using a background context.
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ var ErrTooManyCommands = errors.New("too many commands. max 100")
|
|||||||
//
|
//
|
||||||
// Command{command: "start", description: "Start the bot", args: []Arg{{text: "name", required: false}}}
|
// Command{command: "start", description: "Start the bot", args: []Arg{{text: "name", required: false}}}
|
||||||
// → Description: "Start the bot. Usage: /start [name]"
|
// → Description: "Start the bot. Usage: /start [name]"
|
||||||
|
// Command{command: "echo", description: "Echo user input", args: []Arg{{text: "name", required: true}}}
|
||||||
|
// → Description: "Echo user input. Usage: /echo <input>"
|
||||||
func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
|
func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
|
||||||
desc := ""
|
desc := ""
|
||||||
if len(cmd.description) > 0 {
|
if len(cmd.description) > 0 {
|
||||||
@@ -50,16 +52,15 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
|
|||||||
var descArgs []string
|
var descArgs []string
|
||||||
for _, a := range cmd.args {
|
for _, a := range cmd.args {
|
||||||
if a.required {
|
if a.required {
|
||||||
descArgs = append(descArgs, a.text)
|
descArgs = append(descArgs, fmt.Sprintf("<%s>", a.text))
|
||||||
} else {
|
} else {
|
||||||
descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text))
|
descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usage := fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " "))
|
||||||
if desc != "" {
|
if desc != "" {
|
||||||
desc = fmt.Sprintf("%s. Usage: /%s %s", desc, cmd.command, strings.Join(descArgs, " "))
|
desc = fmt.Sprintf("%s. %s", desc, usage)
|
||||||
} else {
|
|
||||||
desc = fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " "))
|
|
||||||
}
|
}
|
||||||
return tgapi.BotCommand{Command: cmd.command, Description: desc}
|
return tgapi.BotCommand{Command: cmd.command, Description: desc}
|
||||||
}
|
}
|
||||||
|
|||||||
17
drafts.go
17
drafts.go
@@ -30,6 +30,7 @@ package laniakea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||||
@@ -68,6 +69,7 @@ func (g *LinearDraftIdGenerator) Next() uint64 {
|
|||||||
// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines
|
// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines
|
||||||
// requires external synchronization.
|
// requires external synchronization.
|
||||||
type DraftProvider struct {
|
type DraftProvider struct {
|
||||||
|
mu sync.RWMutex
|
||||||
api *tgapi.API
|
api *tgapi.API
|
||||||
drafts map[uint64]*Draft
|
drafts map[uint64]*Draft
|
||||||
generator draftIdGenerator
|
generator draftIdGenerator
|
||||||
@@ -139,6 +141,8 @@ func (p *DraftProvider) SetEntities(entities []tgapi.MessageEntity) *DraftProvid
|
|||||||
//
|
//
|
||||||
// Returns the draft and true if found, or nil and false if not found.
|
// Returns the draft and true if found, or nil and false if not found.
|
||||||
func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) {
|
func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
draft, ok := p.drafts[id]
|
draft, ok := p.drafts[id]
|
||||||
return draft, ok
|
return draft, ok
|
||||||
}
|
}
|
||||||
@@ -150,8 +154,15 @@ func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) {
|
|||||||
//
|
//
|
||||||
// After successful flush, each draft is removed from the provider and cleared.
|
// After successful flush, each draft is removed from the provider and cleared.
|
||||||
func (p *DraftProvider) FlushAll() error {
|
func (p *DraftProvider) FlushAll() error {
|
||||||
var lastErr error
|
p.mu.RLock()
|
||||||
|
drafts := make([]*Draft, 0, len(p.drafts))
|
||||||
for _, draft := range p.drafts {
|
for _, draft := range p.drafts {
|
||||||
|
drafts = append(drafts, draft)
|
||||||
|
}
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, draft := range drafts {
|
||||||
if err := draft.Flush(); err != nil {
|
if err := draft.Flush(); err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
break // Stop on first error to avoid partial state
|
break // Stop on first error to avoid partial state
|
||||||
@@ -201,7 +212,9 @@ func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft {
|
|||||||
ID: id,
|
ID: id,
|
||||||
Message: "",
|
Message: "",
|
||||||
}
|
}
|
||||||
|
p.mu.Lock()
|
||||||
p.drafts[id] = draft
|
p.drafts[id] = draft
|
||||||
|
p.mu.Unlock()
|
||||||
return draft
|
return draft
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +266,9 @@ func (d *Draft) Clear() {
|
|||||||
// want to cancel a draft without sending it.
|
// want to cancel a draft without sending it.
|
||||||
func (d *Draft) Delete() {
|
func (d *Draft) Delete() {
|
||||||
if d.provider != nil {
|
if d.provider != nil {
|
||||||
|
d.provider.mu.Lock()
|
||||||
delete(d.provider.drafts, d.ID)
|
delete(d.provider.drafts, d.ID)
|
||||||
|
d.provider.mu.Unlock()
|
||||||
}
|
}
|
||||||
d.Clear()
|
d.Clear()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
TG_TOKEN=
|
|
||||||
PREFIXES=/;!
|
|
||||||
DEBUG=true
|
|
||||||
USE_REQ_LOG=true
|
|
||||||
WRITE_TO_FILE=false
|
|
||||||
USE_TEST_SERVER=true
|
|
||||||
API_URL=http://127.0.0.1:8081
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"git.nix13.pw/scuroneko/laniakea"
|
|
||||||
)
|
|
||||||
|
|
||||||
func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) {
|
|
||||||
ctx.Answer(ctx.Text) // User input WITHOUT command
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
opts := &laniakea.BotOpts{Token: "TOKEN"}
|
|
||||||
bot := laniakea.NewBot[laniakea.NoDB](opts)
|
|
||||||
defer bot.Close()
|
|
||||||
|
|
||||||
p := laniakea.NewPlugin[laniakea.NoDB]("ping")
|
|
||||||
p.AddCommand(p.NewCommand(echo, "echo"))
|
|
||||||
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) {
|
|
||||||
ctx.Answer("Pong")
|
|
||||||
}, "ping"))
|
|
||||||
|
|
||||||
bot = bot.ErrorTemplate("Error\n\n%s").AddPlugins(p)
|
|
||||||
|
|
||||||
if err := bot.AutoGenerateCommands(); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
bot.Run()
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
module example/basic
|
|
||||||
|
|
||||||
go 1.26.1
|
|
||||||
|
|
||||||
require git.nix13.pw/scuroneko/laniakea v1.0.0-beta.14
|
|
||||||
|
|
||||||
replace (
|
|
||||||
git.nix13.pw/scuroneko/laniakea v1.0.0-beta.14 => ../../
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
git.nix13.pw/scuroneko/extypes v1.2.1 // indirect
|
|
||||||
git.nix13.pw/scuroneko/slog v1.0.2 // indirect
|
|
||||||
github.com/alitto/pond/v2 v2.7.0 // indirect
|
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
|
||||||
golang.org/x/time v0.15.0 // indirect
|
|
||||||
)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
git.nix13.pw/scuroneko/extypes v1.2.1 h1:IYrOjnWKL2EAuJYtYNa+luB1vBe6paE8VY/YD+5/RpQ=
|
|
||||||
git.nix13.pw/scuroneko/extypes v1.2.1/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw=
|
|
||||||
git.nix13.pw/scuroneko/laniakea v1.0.0-beta.13 h1:mRVxYh7CNrm8ccob+u6XxLzZRbs1fLNRg/nXaXY78yw=
|
|
||||||
git.nix13.pw/scuroneko/laniakea v1.0.0-beta.13/go.mod h1:M8jwm195hzAl9bj9Bkl95WfHmWvuBX6micsdtOs/gmE=
|
|
||||||
git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g=
|
|
||||||
git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI=
|
|
||||||
github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg=
|
|
||||||
github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE=
|
|
||||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
|
||||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
|
||||||
25
handler.go
25
handler.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||||
@@ -12,6 +13,12 @@ import (
|
|||||||
var ErrInvalidPayloadType = errors.New("invalid payload type")
|
var ErrInvalidPayloadType = errors.New("invalid payload type")
|
||||||
|
|
||||||
func (bot *Bot[T]) handle(u *tgapi.Update) {
|
func (bot *Bot[T]) handle(u *tgapi.Update) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Update: *u, Api: bot.api,
|
Update: *u, Api: bot.api,
|
||||||
botLogger: bot.logger,
|
botLogger: bot.logger,
|
||||||
@@ -84,7 +91,7 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
|
|||||||
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go plugin.executeCmd(cmd, ctx, bot.dbContext)
|
plugin.executeCmd(cmd, ctx, bot.dbContext)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +120,7 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
|
|||||||
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go plugin.executePayload(data.Command, ctx, bot.dbContext)
|
plugin.executePayload(data.Command, ctx, bot.dbContext)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,13 +155,6 @@ func encodeBase64Payload(d CallbackData) (string, error) {
|
|||||||
base64.StdEncoding.Encode(dst, []byte(data))
|
base64.StdEncoding.Encode(dst, []byte(data))
|
||||||
return string(dst), nil
|
return string(dst), nil
|
||||||
}
|
}
|
||||||
func decodeBase64Payload(s string) (CallbackData, error) {
|
|
||||||
b, err := base64.StdEncoding.DecodeString(s)
|
|
||||||
if err != nil {
|
|
||||||
return CallbackData{}, err
|
|
||||||
}
|
|
||||||
return decodeJsonPayload(string(b))
|
|
||||||
}
|
|
||||||
|
|
||||||
// func encodePayload(payloadType BotPayloadType, d CallbackData) (string, error) {
|
// func encodePayload(payloadType BotPayloadType, d CallbackData) (string, error) {
|
||||||
// switch payloadType {
|
// switch payloadType {
|
||||||
@@ -165,6 +165,13 @@ func decodeBase64Payload(s string) (CallbackData, error) {
|
|||||||
// }
|
// }
|
||||||
// return "", ErrInvalidPayloadType
|
// return "", ErrInvalidPayloadType
|
||||||
// }
|
// }
|
||||||
|
func decodeBase64Payload(s string) (CallbackData, error) {
|
||||||
|
b, err := base64.StdEncoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return CallbackData{}, err
|
||||||
|
}
|
||||||
|
return decodeJsonPayload(string(b))
|
||||||
|
}
|
||||||
func decodePayload(payloadType BotPayloadType, s string) (CallbackData, error) {
|
func decodePayload(payloadType BotPayloadType, s string) (CallbackData, error) {
|
||||||
switch payloadType {
|
switch payloadType {
|
||||||
case BotPayloadBase64:
|
case BotPayloadBase64:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ package laniakea
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||||
"git.nix13.pw/scuroneko/slog"
|
"git.nix13.pw/scuroneko/slog"
|
||||||
@@ -31,10 +32,12 @@ import (
|
|||||||
// It provides methods to respond, edit, delete, and translate messages, as well as
|
// It provides methods to respond, edit, delete, and translate messages, as well as
|
||||||
// manage inline keyboards and message drafts.
|
// manage inline keyboards and message drafts.
|
||||||
type MsgContext struct {
|
type MsgContext struct {
|
||||||
Api *tgapi.API
|
Api *tgapi.API
|
||||||
Msg *tgapi.Message
|
Update tgapi.Update
|
||||||
Update tgapi.Update
|
|
||||||
From *tgapi.User
|
Msg *tgapi.Message
|
||||||
|
From *tgapi.User
|
||||||
|
|
||||||
CallbackMsgId int
|
CallbackMsgId int
|
||||||
CallbackQueryId string
|
CallbackQueryId string
|
||||||
FromID int
|
FromID int
|
||||||
@@ -385,7 +388,13 @@ func (ctx *MsgContext) error(err error) {
|
|||||||
func (ctx *MsgContext) Error(err error) { ctx.error(err) }
|
func (ctx *MsgContext) Error(err error) { ctx.error(err) }
|
||||||
|
|
||||||
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
||||||
c := context.Background()
|
if ctx.Msg == nil {
|
||||||
|
ctx.botLogger.Errorln("can't create draft: ctx.Msg is nil")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
|
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
|
||||||
ctx.botLogger.Errorln(err)
|
ctx.botLogger.Errorln(err)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
31
plugins.go
31
plugins.go
@@ -33,11 +33,14 @@ const (
|
|||||||
CommandValueAnyType CommandValueType = "any"
|
CommandValueAnyType CommandValueType = "any"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CommandRegexInt matches one or more digits.
|
var (
|
||||||
var CommandRegexInt = regexp.MustCompile(`\d+`)
|
// CommandRegexInt matches one or more digits.
|
||||||
|
CommandRegexInt = regexp.MustCompile(`\d+`)
|
||||||
// CommandRegexString matches any non-empty string.
|
// CommandRegexString matches any non-empty string.
|
||||||
var CommandRegexString = regexp.MustCompile(".+")
|
CommandRegexString = regexp.MustCompile(`.+`)
|
||||||
|
// CommandRegexBool matches true or false
|
||||||
|
CommandRegexBool = regexp.MustCompile(`true|false`)
|
||||||
|
)
|
||||||
|
|
||||||
// ErrCmdArgCountMismatch is returned when the number of provided arguments
|
// ErrCmdArgCountMismatch is returned when the number of provided arguments
|
||||||
// is less than the number of required arguments.
|
// is less than the number of required arguments.
|
||||||
@@ -58,15 +61,22 @@ type CommandArg struct {
|
|||||||
// NewCommandArg creates a new CommandArg with the given text and type.
|
// NewCommandArg creates a new CommandArg with the given text and type.
|
||||||
// Uses a default regex based on the type (string or int).
|
// Uses a default regex based on the type (string or int).
|
||||||
// For CommandValueAnyType, no validation is performed.
|
// For CommandValueAnyType, no validation is performed.
|
||||||
func NewCommandArg(text string, valueType CommandValueType) *CommandArg {
|
func NewCommandArg(text string) *CommandArg {
|
||||||
|
return &CommandArg{CommandValueAnyType, text, CommandRegexString, false}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg {
|
||||||
regex := CommandRegexString
|
regex := CommandRegexString
|
||||||
switch valueType {
|
switch t {
|
||||||
case CommandValueIntType:
|
case CommandValueIntType:
|
||||||
regex = CommandRegexInt
|
regex = CommandRegexInt
|
||||||
|
case CommandValueBoolType:
|
||||||
|
regex = CommandRegexBool
|
||||||
case CommandValueAnyType:
|
case CommandValueAnyType:
|
||||||
regex = nil // Skip validation
|
regex = nil // Skip validation
|
||||||
}
|
}
|
||||||
return &CommandArg{valueType, text, regex, false}
|
c.regex = regex
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRequired marks this argument as required.
|
// SetRequired marks this argument as required.
|
||||||
@@ -320,7 +330,10 @@ func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] {
|
|||||||
// Otherwise, returns the result of the executor.
|
// Otherwise, returns the result of the executor.
|
||||||
func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool {
|
func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool {
|
||||||
if m.async {
|
if m.async {
|
||||||
go m.executor(ctx, db)
|
ctx := *ctx // copy context to avoid race condition
|
||||||
|
go func(ctx MsgContext) {
|
||||||
|
m.executor(&ctx, db)
|
||||||
|
}(ctx)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return m.executor(ctx, db)
|
return m.executor(ctx, db)
|
||||||
|
|||||||
27
runners.go
27
runners.go
@@ -11,6 +11,7 @@
|
|||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecRunners executes all runners registered on the Bot.
|
// ExecRunners executes all runners registered on the Bot with context-based lifecycle management.
|
||||||
//
|
//
|
||||||
// It logs warnings for misconfigured runners:
|
// It logs warnings for misconfigured runners:
|
||||||
// - Sync, non-onetime runners are skipped (invalid configuration).
|
// - Sync, non-onetime runners are skipped (invalid configuration).
|
||||||
@@ -92,11 +93,13 @@ func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] {
|
|||||||
// Execution logic:
|
// Execution logic:
|
||||||
// - onetime + async: Runs once in a goroutine.
|
// - onetime + async: Runs once in a goroutine.
|
||||||
// - onetime + sync: Runs once synchronously; warns if slower than 2 seconds.
|
// - onetime + sync: Runs once synchronously; warns if slower than 2 seconds.
|
||||||
// - !onetime + async: Runs in an infinite loop with timeout between iterations.
|
// - !onetime + async: Runs in a loop with timeout between iterations until ctx.Done().
|
||||||
// - !onetime + sync: Skipped with warning.
|
// - !onetime + sync: Skipped with warning.
|
||||||
//
|
//
|
||||||
// This method is typically called once during bot startup.
|
// Background runners listen for ctx.Done() and gracefully shut down when the context is canceled.
|
||||||
func (bot *Bot[T]) ExecRunners() {
|
//
|
||||||
|
// This method is typically called once during bot startup in RunWithContext.
|
||||||
|
func (bot *Bot[T]) ExecRunners(ctx context.Context) {
|
||||||
bot.logger.Infoln("Executing runners...")
|
bot.logger.Infoln("Executing runners...")
|
||||||
for _, runner := range bot.runners {
|
for _, runner := range bot.runners {
|
||||||
// Validate configuration
|
// Validate configuration
|
||||||
@@ -128,14 +131,20 @@ func (bot *Bot[T]) ExecRunners() {
|
|||||||
bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed)
|
bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed)
|
||||||
}
|
}
|
||||||
} else if !runner.onetime && runner.async {
|
} else if !runner.onetime && runner.async {
|
||||||
// Background loop: periodic execution
|
// Background loop: periodic execution with graceful shutdown
|
||||||
go func(r Runner[T]) {
|
go func(r Runner[T]) {
|
||||||
|
ticker := time.NewTicker(r.timeout)
|
||||||
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
err := r.fn(bot)
|
select {
|
||||||
if err != nil {
|
case <-ctx.Done():
|
||||||
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
err := r.fn(bot)
|
||||||
|
if err != nil {
|
||||||
|
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
time.Sleep(r.timeout)
|
|
||||||
}
|
}
|
||||||
}(runner)
|
}(runner)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,8 +193,10 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro
|
|||||||
|
|
||||||
// getChatLimiter returns the rate limiter for the given chat, creating it if needed.
|
// getChatLimiter returns the rate limiter for the given chat, creating it if needed.
|
||||||
// Uses 1 request per second with burst of 1 — conservative for per-user limits.
|
// Uses 1 request per second with burst of 1 — conservative for per-user limits.
|
||||||
// Must be called with rl.chatMu held.
|
|
||||||
func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter {
|
func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter {
|
||||||
|
rl.chatMu.Lock()
|
||||||
|
defer rl.chatMu.Unlock()
|
||||||
|
|
||||||
if lim, ok := rl.chatLimiters[chatID]; ok {
|
if lim, ok := rl.chatLimiters[chatID]; ok {
|
||||||
return lim
|
return lim
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
const (
|
const (
|
||||||
VersionString = "1.0.0-beta.15"
|
VersionString = "1.0.0-beta.18"
|
||||||
VersionMajor = 1
|
VersionMajor = 1
|
||||||
VersionMinor = 0
|
VersionMinor = 0
|
||||||
VersionPatch = 0
|
VersionPatch = 0
|
||||||
VersionBeta = 15
|
VersionBeta = 18
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user