REPOSITORY / ScuroNeko/Laniakea

Pull Requests

PULL REQUESTS REPOSITORY

v1.0.0 #9

Merged
ScuroNeko merged 101 commits from dev into main 2026-05-20 13:43:34 +03:00
29 changed files with 191 additions and 138 deletions
Showing only changes of commit 61d0b1ebb8 - Show all commits
+49
View File
@@ -1,5 +1,54 @@
# TODO
## v1.0.0 pre-release review
Findings from the full-repo review against `AGENTS.md` priorities. Build, vet, tests, and lint are clean; items below are public-API and godoc hygiene before the stable tag.
### Major — close before 1.0.0 tag
- [X] **M1. `BotPayloadType*` are `var`, must be `const`**`bot.go:50-59`. Public sentinels are user-mutable globals. `KeyboardButtonStyle*` in `keyboard.go:10-17` already uses `const`; match the pattern.
- [X] **M2. `Observer` method naming asymmetry**`observer.go:147-157`. `OnReceiveUpdate``OnUpdateReceived`; `OnHandledUpdate``OnUpdateHandled` to match `UpdateReceivedEvent` / `UpdateHandledEvent` and the rest of the `OnX` pattern. Breaking after 1.0.
- [X] **M3. Uploader returns ad-hoc error string instead of `*ResponseError`**`tgapi/uploader_api.go:183`. `tgapi/api.go:258-292` returns `*ResponseError`; uploader must do the same so `errors.As(err, &tgapi.ResponseError{})` works for upload paths too.
- [X] **M4. `BotOptsFileJSON` is missing `PollTimeout`**`bot_opts_loader.go:35-46`, plus `FromBytes`/`ToBytes` mapping. File round-trip silently drops `PollTimeout`.
- [X] **M5. Stale `Bot.Updates` godoc**`methods.go:11-44`. Claims "30-second timeout" and "empty slice if none"; in reality timeout is `bot.pollTimeout` and the function returns `nil` on error.
- [X] **M6. Self-contradicting `NewRandomDraftProvider` godoc**`drafts.go:50-59`. Says "cryptographically secure random numbers" but uses `math/rand/v2` (the underlying generator type correctly notes it is not crypto-secure).
- [X] **M7. `Draft.Delete` godoc says "internal method"**`drafts.go:190-201`. Method is exported; either rewrite the godoc with a public-intent description or unexport.
- [X] **M8. Russian comments in production code**
- `msg_handler.go:28` — "Ищем команду по точному совпадению"
- `tgapi/uploader_api.go:181` — "Повторяем запрос"
- [X] **M9. `MessageContext.Error` godoc references unexported helper**`msg_context.go:540`. "Error is an alias for error()" — rewrite to describe the centralized handler error path and `IsUserError` gating.
- [X] **M10. `Scene` and `SceneSession` mix exported fields with setters**
- `Scene` exports `Name/Scope/Entry/PluginName` and also has `SetScope/SetEntry`; `PluginName` is framework-assigned but publicly mutable.
- `SceneSession` exports `Data []byte` and also has `Set/Get/HasData/ClearData/BindData/SaveData`.
- Pick one model per type before 1.0.0.
- [X] **M11. Constant-time compare for webhook secret**`bot_webhook.go:296` (update handler) and `bot_webhook.go:341` (`/status`). Use `subtle.ConstantTimeCompare`.
### Minor — can slip to 1.0.x
- [X] Strip `// Internal helper …` godoc from unexported funcs (~23 occurrences in repo); `AGENTS.md` explicitly forbids godoc-style comments on unexported declarations without a strong reason.
- [X] `Plugin.AddCommand` godoc references unexported field `.command``plugins.go:48-49`.
- [X] `Runner` builder naming: `runner.Once(true)`, `runner.Async(true)` read awkwardly; consider `SetOnce`/`SetAsync` to match `Set*` on other types, or zero-arg `Once()` + paired `Repeat(every)`.
- [X] Typo in webhook error string: `bot_webhook.go:143` — "MaxConnections must between 1 and 100" (missing `be`).
- [X] `RunWebhookWithContext` uses inline `errors.New(...)` instead of `Err*` sentinels (`bot_webhook.go:131-156`); rest of the package uses sentinels from `errors.go`.
- [X] `tgapi.UpdateTypeManagedBot` (`tgapi/types.go:61`) has no godoc.
- [X] `Bot.GetAPI`, `Bot.GetUploader`, `InlineKeyboard.GetMaxRow` have no godoc.
- [X] `Bot.L10n` godoc says "Returns empty string if translation not found"; actually returns the key (`l10n.go:48-59`).
- [X] `Bot.handle` panic recovery only logs — emit `ErrorEvent` so observers see panics (`handler.go:18-23`).
- [X] `handleCallback` vs `handleMessage` differ in plugin-logger assignment: callback assigns unconditionally then falls back to bot logger (`msg_handler.go:209-212`); message only assigns if non-nil (`msg_handler.go:35-37`). Align.
- [X] `SetCallbackData` godoc says "default payload type is JSON" — actually the zero `BotPayloadType` falls through to the `default` branch (which happens to be JSON). Either document the zero-value behavior explicitly or initialize the builder with the bot's default (`keyboard.go:106-122`).
- [X] `commands.go:62-66` — empty `case CommandValueAny:` next to `default: regex = nil` looks like an incomplete switch. Merge or add a one-line comment.
- [X] `Bot.SetDebug` does not call `configMutable` unlike sibling setters; if intentional, note it in godoc.
### Tests to add after the fixes
- `BotOptsFileJSON` round-trip for `PollTimeout` (after M4).
- Uploader 4xx/429 surfaces `*tgapi.ResponseError` (after M3).
- `Bot.handle` panic → observer receives `ErrorEvent` (after panic-recovery fix).
- Webhook `/status` with wrong `SecretToken` returns 403 / `403`-equivalent (after M11), incl. a constant-time-compare smoke.
- Table-driven `parseCommand` cases for `/cmd@botname` and stripping behavior.
---
The framework backlog has moved to the wiki.
Primary page:
+4 -2
View File
@@ -47,7 +47,7 @@ type AppDataLogger[T AppData] func(data T) sneklog.LoggerWriter
// BotPayloadType defines the serialization format for callback data payloads.
type BotPayloadType string
var (
const (
// BotPayloadBase64 encodes callback data as a Base64 string.
BotPayloadBase64 BotPayloadType = "base64"
// BotPayloadJSON encodes callback data as a JSON string.
@@ -275,8 +275,10 @@ func (bot *Bot[T]) SetWebhookLogger(l *sneklog.Logger) *Bot[T] {
return bot
}
// GetAPI returns the underlying Telegram Bot API client.
func (bot *Bot[T]) GetAPI() *tgapi.API { return bot.api }
// GetUploader returns the underlying file uploader client.
func (bot *Bot[T]) GetUploader() *tgapi.Uploader { return bot.uploader }
// Close gracefully shuts down bot-owned resources.
@@ -385,7 +387,7 @@ func (bot *Bot[T]) GetLoggerLevel() sneklog.LogLevel {
}
// L10n translates a key in the given language.
// Returns empty string if translation not found.
// Returns key if translation not found.
func (bot *Bot[T]) L10n(lang, key string) string {
return bot.l10n.Translate(lang, key)
}
+5 -2
View File
@@ -125,7 +125,7 @@ func (bot *Bot[T]) GetAppData() T { return bot.appData }
// SetUpdateTypes sets the list of update types the bot will request from Telegram.
// Overwrites any previously set types.
func (bot *Bot[T]) SetUpdateTypes(t ...tgapi.UpdateType) *Bot[T] {
if !bot.configMutable("UpdateTypes") {
if !bot.configMutable("SetUpdateTypes") {
return bot
}
bot.updateTypes = make([]tgapi.UpdateType, 0)
@@ -177,7 +177,7 @@ func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] {
// Use "%s" to insert the error message.
// Example: "❌ Error: %s" → "❌ Error: Command not found".
func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] {
if !bot.configMutable("ErrorTemplate") {
if !bot.configMutable("SetErrorTemplate") {
return bot
}
bot.errorTemplate = s
@@ -186,6 +186,9 @@ func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] {
// SetDebug enables or disables debug logging.
func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] {
if !bot.configMutable("SetDebug") {
return bot
}
bot.debug = debug
level := sneklog.FATAL
if debug {
+3
View File
@@ -28,6 +28,7 @@ type botOptsFileJSONAPI struct {
UseTestServer bool `json:"use_test_server"`
APIURL string `json:"url"`
RateLimit int `json:"rate_limit"`
PollTimeout int `json:"poll_timeout"`
DropRLOverflow bool `json:"drop_overflow"`
}
@@ -73,6 +74,7 @@ func (codec BotOptsFileJSONCodec) FromBytes(data []byte) (*BotOpts, error) {
UseTestServer: fileOpts.API.UseTestServer,
APIURL: fileOpts.API.APIURL,
RateLimit: fileOpts.API.RateLimit,
PollTimeout: fileOpts.API.PollTimeout,
DropRateLimitOverflow: fileOpts.API.DropRLOverflow,
StrictPayloadType: fileOpts.StrictPayloadType,
@@ -102,6 +104,7 @@ func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) {
UseTestServer: opts.UseTestServer,
APIURL: opts.APIURL,
RateLimit: opts.RateLimit,
PollTimeout: opts.PollTimeout,
DropRLOverflow: opts.DropRateLimitOverflow,
},
StrictPayloadType: opts.StrictPayloadType,
+3 -3
View File
@@ -24,9 +24,9 @@ func (bot *Bot[T]) findScene(name string) (*sceneMeta, bool) {
}
return &sceneMeta{
Name: scene.Name,
Scope: scene.Scope,
Entry: scene.Entry,
Name: scene.name,
Scope: scene.scope,
Entry: scene.entry,
Steps: steps,
}, true
}
+2 -2
View File
@@ -37,8 +37,8 @@ func (o *pollingRetryObserver) OnPollingRetry(ctx context.Context, ev PollingRet
type testObserver struct{}
func (testObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {}
func (testObserver) OnHandledUpdate(context.Context, UpdateHandledEvent) {}
func (testObserver) OnUpdateReceived(context.Context, UpdateReceivedEvent) {}
func (testObserver) OnUpdateHandled(context.Context, UpdateHandledEvent) {}
func (testObserver) OnHandlerStarted(context.Context, HandlerStartedEvent) {}
func (testObserver) OnHandlerFinished(context.Context, HandlerFinishedEvent) {
}
+16 -12
View File
@@ -2,6 +2,7 @@ package laniakea
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
@@ -128,7 +129,7 @@ func (opts *BotWebhookOpts) SetSecretToken(secretToken string) *BotWebhookOpts {
// argument order.
func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOpts, tlsFiles ...string) error {
if opts == nil {
return errors.New("nil BotWebhookOpts")
return ErrNilBotWebhookOpts
}
if len(bot.prefixes) == 0 {
return ErrNoPrefixes
@@ -137,28 +138,28 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp
return ErrNoPlugins
}
if opts.URL == "" {
return errors.New("empty BotWebhookOpts.URL")
return ErrNoBotWebhookOptsURL
}
if opts.MaxConnections > 100 || opts.MaxConnections <= 0 {
return errors.New("BotWebhookOpts.MaxConnections must between 1 and 100")
return ErrBotWebhookOptsMaxConnectionsRange
}
if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil {
return err
}
if opts.UseStatusPath && opts.SecretToken == "" {
return errors.New("BotWebhookOpts.SecretToken required when status path is enabled")
return ErrStatusPathSecretRequired
}
if err := validateWebhookTLSFiles(tlsFiles); err != nil {
return err
}
if opts.Certificate != nil && bot.uploader == nil {
return errors.New("bot uploader nil, but certificate set")
return ErrBotUploaderWhenCertificate
}
return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error {
if opts.SecretToken == "" {
bot.webhookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.")
bot.webhookLogger.Warnln("Using webhook without secret is very dangerous. Anyone can simulate Telegram requests.")
}
i, err := bot.api.GetWebhookInfoWithContext(runCtx)
@@ -284,7 +285,7 @@ func (bot *Bot[T]) runWebhookRuntime(ctx context.Context, run func(context.Conte
return runErr
}
func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http.HandlerFunc {
func updateHandler[T any](ctx context.Context, bot *Bot[T], secret []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
_ = r.Body.Close()
@@ -293,7 +294,9 @@ func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http.
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if secret != "" && r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != secret {
provided := []byte(r.Header.Get("X-Telegram-Bot-Api-Secret-Token"))
if len(secret) > 0 && subtle.ConstantTimeCompare(secret, provided) != 1 {
w.WriteHeader(http.StatusForbidden)
return
}
@@ -330,7 +333,7 @@ func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http.
}
}
func statusHandler[T any](bot *Bot[T], opts *BotWebhookOpts) http.HandlerFunc {
func statusHandler[T any](bot *Bot[T], secret []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
auth := ""
if r.Header.Get("Authorization") != "" {
@@ -338,7 +341,7 @@ func statusHandler[T any](bot *Bot[T], opts *BotWebhookOpts) http.HandlerFunc {
} else if r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != "" {
auth = r.Header.Get("X-Telegram-Bot-Api-Secret-Token")
}
if auth != opts.SecretToken {
if len(secret) > 0 && subtle.ConstantTimeCompare(secret, []byte(auth)) != 1 {
w.WriteHeader(http.StatusNotFound)
return
}
@@ -363,11 +366,12 @@ func statusHandler[T any](bot *Bot[T], opts *BotWebhookOpts) http.HandlerFunc {
}
func (bot *Bot[T]) newWebhookMux(ctx context.Context, opts *BotWebhookOpts) *http.ServeMux {
token := []byte(opts.SecretToken)
r := http.NewServeMux()
if opts.UseStatusPath {
r.HandleFunc("/status", statusHandler(bot, opts))
r.HandleFunc("/status", statusHandler(bot, token))
}
r.HandleFunc(opts.Path, updateHandler(ctx, bot, opts.SecretToken))
r.HandleFunc(opts.Path, updateHandler(ctx, bot, token))
return r
}
func (bot *Bot[T]) baseRunWebhook(ctx context.Context, opts *BotWebhookOpts, runFunc func(*http.Server, chan error)) error {
+5 -5
View File
@@ -46,7 +46,7 @@ func TestUpdateHandlerEnqueuesUpdate(t *testing.T) {
req.Header.Set("X-Telegram-Bot-Api-Secret-Token", "secret")
rec := httptest.NewRecorder()
updateHandler(context.Background(), bot, "secret").ServeHTTP(rec, req)
updateHandler(context.Background(), bot, []byte("secret")).ServeHTTP(rec, req)
if rec.Result().StatusCode != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusOK)
@@ -94,7 +94,7 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) {
NewRunner("runner", func(bot *Bot[NoData]) error {
calls.Add(1)
return nil
}).Once(true).Async(false),
}).Async(false),
},
}
t.Cleanup(func() {
@@ -157,7 +157,7 @@ func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"update_id":9,"message":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"},"from":{"id":2,"is_bot":false,"first_name":"Test"},"text":"/start"}}`))
rec := httptest.NewRecorder()
updateHandler(ctx, bot, "").ServeHTTP(rec, req)
updateHandler(ctx, bot, []byte("")).ServeHTTP(rec, req)
if rec.Result().StatusCode != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusOK)
}
@@ -267,7 +267,7 @@ func TestUpdateHandlerRejectsOversizedBody(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("a", (256<<10)+1)))
rec := httptest.NewRecorder()
updateHandler(context.Background(), bot, "").ServeHTTP(rec, req)
updateHandler(context.Background(), bot, []byte("")).ServeHTTP(rec, req)
if rec.Result().StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusRequestEntityTooLarge)
@@ -301,7 +301,7 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) {
_ = bot.webhookLogger.Close()
})
handler := statusHandler(bot, &BotWebhookOpts{SecretToken: "secret"})
handler := statusHandler(bot, []byte("secret"))
tests := []struct {
name string
-4
View File
@@ -21,7 +21,6 @@ var cmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$")
// bot initialization.
var ErrTooManyCommands = errors.New("too many commands. max 100")
// Internal helper to build a BotCommand description with generated usage text.
func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
desc := ""
if len(cmd.description) > 0 {
@@ -45,10 +44,8 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
return tgapi.BotCommand{Command: cmd.command, Description: usage}
}
// Internal helper to validate Telegram command names.
func checkCmdRegex(cmd string) bool { return cmdRegexp.MatchString(cmd) }
// Internal helper to collect non-skipped, valid commands from one plugin.
func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0)
names := make([]string, 0, len(pl.commands))
@@ -70,7 +67,6 @@ func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
return commands
}
// Internal helper to collect all auto-generated commands from registered plugins.
func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0)
for _, pl := range bot.plugins {
-4
View File
@@ -66,9 +66,6 @@ func (c CommandArg) SetValueType(t CommandValueType) CommandArg {
regex = CommandRegexBool
case CommandValueString:
regex = CommandRegexString
case CommandValueAny:
default:
regex = nil // Skip validation
}
c.valueType = t
c.regex = regex
@@ -133,7 +130,6 @@ func (c *Command[T]) SkipCommandAutoGen() *Command[T] {
return c
}
// Internal helper that validates provided command arguments.
func (c *Command[T]) validateArgs(args []string) error {
for i := range c.args.Len() {
if i >= len(args) && c.args.Get(i).required {
+2 -5
View File
@@ -49,7 +49,7 @@ type DraftProvider struct {
// NewRandomDraftProvider creates a new DraftProvider using random draft IDs.
//
// The provider will use cryptographically secure random numbers for draft IDs.
// The provider will use random numbers for draft IDs.
// All drafts created via this provider will have unpredictable, unique IDs.
func NewRandomDraftProvider(api *tgapi.API) *DraftProvider {
return &DraftProvider{
@@ -189,8 +189,7 @@ func (d *Draft) Clear() {
// Delete removes the draft from its provider and clears its content.
//
// This is an internal method used by Flush(). You may call it manually if you
// want to cancel a draft without sending it.
// You may call it manually if you want to cancel a draft without sending it.
func (d *Draft) Delete() {
if d.provider != nil {
d.provider.mu.Lock()
@@ -241,8 +240,6 @@ func (d *Draft) Flush() error {
return err
}
// Internal helper for Push that updates the server-side draft.
//
// The candidate Message (current content + new text) is validated before any
// mutation, so a validation failure leaves the draft unchanged. After the
// validation passes, Message is committed locally regardless of whether the
+15
View File
@@ -38,6 +38,8 @@ var (
ErrAPIIsNil = errors.New("api is nil")
// ErrMessageIDZero reports that an operation requires a non-zero message ID.
ErrMessageIDZero = errors.New("message ID is zero")
)
var (
// ErrBindArgsTargetNotPointer reports that BindArgs received a nil or non-pointer destination.
ErrBindArgsTargetNotPointer = errors.New("bind args: dst must be a non-nil pointer")
// ErrBindArgsTargetNotStruct reports that BindArgs received a pointer to a non-struct value.
@@ -60,6 +62,19 @@ var (
ErrSceneRuntimeNil = errors.New("scene runtime is nil")
)
var (
// ErrNilBotWebhookOpts reports that a nil BotWebhookOpts was passed.
ErrNilBotWebhookOpts = errors.New("nil BotWebhookOpts")
// ErrNoBotWebhookOptsURL reports that BotWebhookOpts.URL is empty.
ErrNoBotWebhookOptsURL = errors.New("empty BotWebhookOpts.URL")
// ErrBotWebhookOptsMaxConnectionsRange reports that BotWebhookOpts.MaxConnections is out of range.
ErrBotWebhookOptsMaxConnectionsRange = errors.New("BotWebhookOpts.MaxConnections must be between 1 and 100")
// ErrBotUploaderWhenCertificate reports that a certificate was set without an uploader.
ErrBotUploaderWhenCertificate = errors.New("bot uploader nil, but certificate set")
// ErrStatusPathSecretRequired reports that UseStatusPath requires SecretToken to be set.
ErrStatusPathSecretRequired = errors.New("SecretToken required when UseStatusPath is enabled")
)
func validateMessageText(text string) error {
length := utf8.RuneCountInString(text)
switch {
+12
View File
@@ -19,6 +19,18 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) {
defer func() {
if r := recover(); r != nil {
bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r))
var err error
var ok bool
if err, ok = r.(error); !ok {
err = fmt.Errorf("%v", r)
}
bot.safeEmitEvent(parentCtx, ErrorEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
Err: err,
UserFacing: false,
})
}
}()
startTime := time.Now()
+2 -2
View File
@@ -20,10 +20,10 @@ type recordingObserver struct {
retries []PollingRetryEvent
}
func (o *recordingObserver) OnReceiveUpdate(_ context.Context, ev UpdateReceivedEvent) {
func (o *recordingObserver) OnUpdateReceived(_ context.Context, ev UpdateReceivedEvent) {
o.received = append(o.received, ev)
}
func (o *recordingObserver) OnHandledUpdate(_ context.Context, ev UpdateHandledEvent) {
func (o *recordingObserver) OnUpdateHandled(_ context.Context, ev UpdateHandledEvent) {
o.handled = append(o.handled, ev)
}
func (o *recordingObserver) OnHandlerStarted(_ context.Context, ev HandlerStartedEvent) {
+1 -2
View File
@@ -121,7 +121,6 @@ func (b InlineKeyboardButtonBuilder) SetCallbackData(cmd string, args ...any) In
return b
}
// Internal helper that converts the builder state into a Telegram button.
func (b InlineKeyboardButtonBuilder) build() tgapi.InlineKeyboardButton {
return tgapi.InlineKeyboardButton{
Text: b.text,
@@ -203,9 +202,9 @@ func (in *InlineKeyboard) SetMaxRow(maxRow int) *InlineKeyboard {
return in
}
// GetMaxRow returns the maximum number of buttons per row.
func (in *InlineKeyboard) GetMaxRow() int { return in.maxRow }
// Internal helper that appends a button and auto-flushes a full row.
func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard {
if in.CurrentLine.Len() == in.maxRow {
in.AddLine()
+10 -6
View File
@@ -22,15 +22,15 @@ import (
//
// Behavior:
// 1. Uses the bot's current update offset (via GetUpdateOffset)
// 2. Requests updates with 30-second timeout
// 2. Requests updates with the timeout configured via PollTimeout
// 3. Filters updates by types specified in bot.GetUpdateTypes()
// 4. Logs raw update JSON if RequestLogger is configured
// 5. Automatically updates the offset to the last received update ID + 1
// 6. Returns all received updates (empty slice if none)
//
// Note: This is a blocking call that waits up to 30 seconds for new updates,
// unless ctx is canceled earlier. For non-blocking behavior, consider using
// webhooks instead.
// Note: This is a blocking call that waits up to the configured PollTimeout
// for new updates, unless ctx is canceled earlier. For non-blocking behavior,
// consider using webhooks instead.
//
// Example:
//
@@ -50,9 +50,10 @@ func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) {
AllowedUpdates: bot.GetUpdateTypes(),
}
zero := make([]tgapi.Update, 0)
updates, err := bot.api.GetUpdatesWithContext(ctx, params)
if err != nil {
return nil, err
return zero, err
}
if bot.requestLogger != nil {
@@ -67,7 +68,10 @@ func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) {
if len(updates) > 0 {
bot.SetUpdateOffset(updates[len(updates)-1].UpdateID + 1)
}
return updates, err
if updates == nil {
return zero, nil
}
return updates, nil
}
// UpdatesIter fetches updates once and yields each update in order.
+1 -9
View File
@@ -95,7 +95,6 @@ type AnswerMessage struct {
ctx *MessageContext // internal back-reference
}
// Internal helper for text edits with optional keyboard and parse mode.
func (ctx *MessageContext) edit(messageID int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if err := validateMessageText(text); err != nil {
ctx.Logger.Errorln(err)
@@ -146,7 +145,6 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMarkdownV2)
}
// Internal helper for editing callback-linked messages.
func (ctx *MessageContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.CallbackMsgID == 0 && ctx.InlineMsgID == "" {
ctx.Logger.Errorln(ErrCallbackMessageMissing)
@@ -179,7 +177,6 @@ func (ctx *MessageContext) EditCallbackfMarkdown(format string, keyboard *Inline
return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMarkdownV2)
}
// Internal helper for media-caption edits.
func (ctx *MessageContext) editPhotoText(messageID int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if err := validateCaptionText(text); err != nil {
ctx.Logger.Errorln(err)
@@ -241,7 +238,6 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo
return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMarkdownV2)
}
// Internal helper for message replies with optional keyboard and parse mode.
func (ctx *MessageContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.Msg == nil {
ctx.Logger.Errorln(ErrMessageContextNil)
@@ -371,7 +367,6 @@ func (ctx *MessageContext) answerLong(text string, keyboard *InlineKeyboard, par
return messages
}
// Internal helper for photo replies with optional caption and keyboard.
func (ctx *MessageContext) answerPhoto(photoID, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.Msg == nil {
ctx.Logger.Errorln(ErrMessageContextNil)
@@ -443,7 +438,6 @@ func (ctx *MessageContext) AnswerPhotofMarkdown(photoID, template string, args .
return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2)
}
// Internal helper that deletes a message by ID.
func (ctx *MessageContext) delete(messageID int) {
if messageID == 0 {
ctx.Logger.Errorln(ErrMessageIDZero)
@@ -474,7 +468,6 @@ func (ctx *MessageContext) CallbackDelete() {
ctx.delete(ctx.CallbackMsgID)
}
// Internal helper that answers a callback query with optional text, alert, or URL.
func (ctx *MessageContext) answerCallbackQuery(url, text string, showAlert bool) {
if len(ctx.CallbackQueryID) == 0 {
return
@@ -518,7 +511,6 @@ func (ctx *MessageContext) SendAction(action tgapi.ChatActionType) {
}
}
// Internal helper that formats, sends, and logs an error.
func (ctx *MessageContext) error(err error) {
if err == nil {
return
@@ -536,7 +528,7 @@ func (ctx *MessageContext) error(err error) {
}
}
// Error is an alias for error().
// Error routes err through the centralized handler error path…
func (ctx *MessageContext) Error(err error) { ctx.error(err) }
func (ctx *MessageContext) newDraft(parseMode tgapi.ParseMode) *Draft {
+4 -3
View File
@@ -25,15 +25,16 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MessageContext) bool
cmd = cmd[:len(cmd)-len("@"+botUsername)] // remove @botname
}
}
// Ищем команду по точному совпадению
for _, plugin := range bot.plugins {
if _, exists := plugin.commands[cmd]; exists {
ctx.Text = args
ctx.Args = strings.Fields(args)
ctx.Logger = plugin.logger
if plugin.logger != nil {
ctx.Logger = plugin.logger
if ctx.Logger == nil {
ctx.Logger = bot.logger
}
if !plugin.executeMiddlewares(ctx, bot.appData) {
return false
+4 -4
View File
@@ -145,8 +145,8 @@ func (ErrorEvent) isEvent() {}
// Observer receives best-effort runtime instrumentation events.
type Observer interface {
OnReceiveUpdate(ctx context.Context, event UpdateReceivedEvent)
OnHandledUpdate(ctx context.Context, event UpdateHandledEvent)
OnUpdateReceived(ctx context.Context, event UpdateReceivedEvent)
OnUpdateHandled(ctx context.Context, event UpdateHandledEvent)
OnHandlerStarted(ctx context.Context, event HandlerStartedEvent)
OnHandlerFinished(ctx context.Context, event HandlerFinishedEvent)
OnSceneTransition(ctx context.Context, event SceneTransitionEvent)
@@ -167,9 +167,9 @@ func (bot *Bot[T]) safeEmitEvent(ctx context.Context, event Event) {
}()
switch e := event.(type) {
case UpdateReceivedEvent:
bot.observer.OnReceiveUpdate(ctx, e)
bot.observer.OnUpdateReceived(ctx, e)
case UpdateHandledEvent:
bot.observer.OnHandledUpdate(ctx, e)
bot.observer.OnUpdateHandled(ctx, e)
case HandlerStartedEvent:
bot.observer.OnHandlerStarted(ctx, e)
case HandlerFinishedEvent:
+5 -9
View File
@@ -46,7 +46,6 @@ func NewPlugin[T AppData](name string) *Plugin[T] {
}
// AddCommand registers a command in the plugin.
// The command's .command field is used as the key.
func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] {
if command == nil {
if p.logger != nil {
@@ -55,7 +54,7 @@ func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] {
return p
}
if _, exists := p.commands[command.command]; exists && p.logger != nil {
p.logger.Warnf("command '%s' is already registered in plugin '%s'; overwriting", command.command, p.name)
p.logger.Warnf("command '%s' already registered in plugin '%s'; overwriting", command.command, p.name)
}
p.commands[command.command] = command
return p
@@ -106,12 +105,12 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
if scene == nil {
return p
}
scene.PluginName = p.name
scene.pluginName = p.name
scene.setPluginName(p.name)
if _, exists := p.scenes[scene.Name]; exists && p.logger != nil {
p.logger.Warnf("scene '%s' is already registered in plugin '%s'; overwriting", scene.Name, p.name)
if _, exists := p.scenes[scene.name]; exists && p.logger != nil {
p.logger.Warnf("scene '%s' is already registered in plugin '%s'; overwriting", scene.name, p.name)
}
p.scenes[scene.Name] = scene
p.scenes[scene.name] = scene
return p
}
@@ -238,7 +237,6 @@ func (p *Plugin[T]) Close() error {
return errors.Join(e...)
}
// Internal helper that validates and executes a command handler.
func (p *Plugin[T]) executeCmd(cmd string, ctx *MessageContext, db T) error {
command, exists := p.commands[cmd]
if !exists {
@@ -260,7 +258,6 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MessageContext, db T) error {
return command.exec(ctx, db)
}
// Internal helper that validates and executes a payload handler.
func (p *Plugin[T]) executePayload(payload string, ctx *MessageContext, db T) error {
command, exists := p.payloads[payload]
if !exists {
@@ -282,7 +279,6 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MessageContext, db T) er
return command.exec(ctx, db)
}
// Internal helper that runs plugin middlewares in order.
func (p *Plugin[T]) executeMiddlewares(ctx *MessageContext, db T) bool {
for _, m := range p.middlewares {
if !m.Execute(ctx, db) {
+11 -20
View File
@@ -16,20 +16,19 @@ type RunnerFn[T AppData] func(*Bot[T]) error
// Once Execute() is called, the Runner should not be modified.
//
// Execution semantics:
// - once=true, async=false: Run once synchronously (blocks).
// - once=true, async=true: Run once in a goroutine (non-blocking).
// - once=false, async=true: Run repeatedly in a goroutine with timeout.
// - once=false, async=false: Invalid configuration — ignored with warning.
// - every=0, async=false: Run once synchronously (blocks).
// - every=0, async=true: Run once in a goroutine (non-blocking).
// - every>0, async=true: Run repeatedly in a goroutine with timeout.
// - every>0, async=false: Invalid configuration — ignored with warning.
type Runner[T AppData] struct {
name string // Human-readable name for logging
once bool // If true, runs once; if false, runs periodically
async bool // If true, runs in a goroutine; else, runs synchronously
every time.Duration // Duration to wait between periodic executions (ignored if once=true)
fn RunnerFn[T] // The function to execute
}
// NewRunner creates a new Runner with the given name and function.
// By default, the Runner is configured as async=true (non-blocking).
// By default, the Runner is configured as async=true (non-blocking), once=true/mo
//
// Builder methods (Once, Async, Every) can be chained to customize behavior.
// DO NOT call builder methods concurrently or after Execute().
@@ -38,18 +37,10 @@ func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] {
name: name,
fn: fn,
async: true, // Default: run asynchronously
every: 0, // Default: no timeout (ignored if once=true)
every: 0, // Default: 0 - one time
}
}
// Once sets whether the runner executes once or repeatedly.
// If true, the runner runs only once.
// If false, the runner runs in a loop with the configured timeout.
func (r Runner[T]) Once(once bool) Runner[T] {
r.once = once
return r
}
// Async sets whether the runner executes synchronously or asynchronously.
// If true, the runner runs in a goroutine (non-blocking).
// If false, the runner blocks the caller during execution.
@@ -94,16 +85,16 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
bot.logger.Infoln("Executing runners...")
for _, runner := range bot.runners {
// Validate configuration
if !runner.once && !runner.async {
if runner.every > 0 && !runner.async {
bot.logger.Warnf("Runner %s not once, but sync — skipping\n", runner.name)
continue
}
if !runner.once && runner.async && runner.every == 0 {
if runner.every > 0 && runner.async && runner.every == 0 {
bot.logger.Warnf("Background runner \"%s\" has no timeout — skipping\n", runner.name)
continue
}
if runner.once && runner.async {
if runner.every == 0 && runner.async {
// One-time async: fire and forget
bot.runnerOnceWG.Add(1)
go func(r Runner[T]) {
@@ -126,7 +117,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
}
}(runner)
} else if runner.once && !runner.async {
} else if runner.every == 0 && !runner.async {
// One-time sync: block until done
t := time.Now()
err := runner.fn(bot)
@@ -149,7 +140,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
if elapsed > time.Second*2 {
bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed)
}
} else if !runner.once && runner.async {
} else if runner.every > 0 && runner.async {
// Background loop: periodic execution with graceful shutdown
bot.runnerBgWG.Add(1)
go func(r Runner[T]) {
+2 -2
View File
@@ -22,7 +22,7 @@ func TestExecRunnersRunsOnceSyncRunner(t *testing.T) {
NewRunner("sync-once", func(*Bot[NoData]) error {
calls.Add(1)
return nil
}).Once(true).Async(false),
}).Async(false),
},
}
@@ -76,7 +76,7 @@ func TestExecRunnersEmitObserverEvents(t *testing.T) {
runners: []Runner[NoData]{
NewRunner("sync-once", func(*Bot[NoData]) error {
return wantErr
}).Once(true).Async(false),
}).Async(false),
},
}
+19 -23
View File
@@ -11,14 +11,10 @@ type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error)
// Scene defines a multi-step conversational flow.
type Scene[T any] struct {
// Name identifies the scene in plugin registration and session state.
Name string
// Scope controls how active scene sessions are keyed and shared.
Scope SceneScope
// Entry names the first step used by MessageContext.EnterScene.
Entry string
// PluginName stores the owning plugin name for scene resolution.
PluginName string
name string
scope SceneScope
entry string
pluginName string
steps map[string]SceneHandler[T]
commands map[string]SceneHandler[T]
@@ -29,9 +25,9 @@ type Scene[T any] struct {
// NewScene creates a new scene with user-chat scope by default.
func NewScene[T any](name string) *Scene[T] {
return &Scene[T]{
Name: name,
Scope: SceneScopeUserChat,
Entry: "",
name: name,
scope: SceneScopeUserChat,
entry: "",
steps: make(map[string]SceneHandler[T]),
commands: make(map[string]SceneHandler[T]),
payloads: make(map[string]SceneHandler[T]),
@@ -41,18 +37,18 @@ func NewScene[T any](name string) *Scene[T] {
// SetScope changes how scene sessions are keyed and shared.
func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] {
s.Scope = scope
s.scope = scope
return s
}
// SetEntry sets the initial step entered by MessageContext.EnterScene.
func (s *Scene[T]) SetEntry(step string) *Scene[T] {
s.Entry = step
s.entry = step
return s
}
func (s *Scene[T]) setPluginName(name string) *Scene[T] {
s.PluginName = name
s.pluginName = name
return s
}
@@ -135,36 +131,36 @@ type SceneSession struct {
Scene string
// Step is the current step name inside the active scene.
Step string
// Data stores opaque session payload bytes, typically JSON.
Data []byte
// data stores opaque session payload bytes, typically JSON.
data []byte
}
// SetData stores arbitrary opaque session data.
func (s *SceneSession) SetData(data []byte) {
s.Data = data
s.data = data
}
// GetData returns the raw session data payload.
func (s *SceneSession) GetData() []byte {
return s.Data
return s.data
}
// HasData reports whether the session has a non-empty data payload.
func (s *SceneSession) HasData() bool {
return len(s.Data) > 0
return len(s.data) > 0
}
// ClearData removes any stored session data.
func (s *SceneSession) ClearData() {
s.Data = nil
s.data = nil
}
// BindData unmarshals the stored JSON payload into v.
func (s *SceneSession) BindData(v any) error {
if len(s.Data) == 0 {
if len(s.data) == 0 {
return nil
}
return json.Unmarshal(s.Data, v)
return json.Unmarshal(s.data, v)
}
// SaveData marshals v as JSON and stores it in the session.
@@ -173,7 +169,7 @@ func (s *SceneSession) SaveData(v any) error {
if err != nil {
return err
}
s.Data = data
s.data = data
return nil
}
+6 -6
View File
@@ -24,7 +24,7 @@ func (bot *Bot[T]) tryHandleScene(ctx *MessageContext) (bool, error) {
if !ok {
continue
}
if scene.PluginName != "" && scene.PluginName != plugin.name {
if scene.pluginName != "" && scene.pluginName != plugin.name {
continue
}
if !plugin.executeMiddlewares(ctx, bot.appData) {
@@ -176,7 +176,7 @@ func (bot *Bot[T]) emitSceneStarted(ctx *SceneContext, scene *Scene[T], kind Han
bot.safeEmitEvent(ctx.Context(), HandlerStartedEvent{
UpdateID: ctx.Update.UpdateID,
UpdateType: ctx.Update.Type,
Plugin: scene.PluginName,
Plugin: scene.pluginName,
HandlerKind: kind,
HandlerName: name,
FromID: ctx.FromID,
@@ -188,7 +188,7 @@ func (bot *Bot[T]) emitSceneFinished(ctx *SceneContext, scene *Scene[T], kind Ha
bot.safeEmitEvent(ctx.Context(), HandlerFinishedEvent{
UpdateID: ctx.Update.UpdateID,
UpdateType: ctx.Update.Type,
Plugin: scene.PluginName,
Plugin: scene.pluginName,
HandlerKind: kind,
HandlerName: name,
FromID: ctx.FromID,
@@ -203,7 +203,7 @@ func (bot *Bot[T]) emitSceneError(ctx *SceneContext, scene *Scene[T], kind Handl
bot.safeEmitEvent(ctx.Context(), ErrorEvent{
UpdateID: ctx.Update.UpdateID,
UpdateType: ctx.Update.Type,
Plugin: scene.PluginName,
Plugin: scene.pluginName,
HandlerKind: kind,
HandlerName: name,
FromID: ctx.FromID,
@@ -229,8 +229,8 @@ func (bot *Bot[T]) emitSceneTransition(ctx *SceneContext, scene *Scene[T], from
}
bot.safeEmitEvent(ctx.Context(), SceneTransitionEvent{
Plugin: scene.PluginName,
Scene: scene.Name,
Plugin: scene.pluginName,
Scene: scene.name,
From: from,
To: to,
Action: result.Action,
+3 -3
View File
@@ -36,8 +36,8 @@ func TestPluginAddSceneRegistersScene(t *testing.T) {
if got, ok := plugin.scenes["signup"]; !ok || got != scene {
t.Fatalf("scene was not registered in plugin: ok=%v got=%p want=%p", ok, got, scene)
}
if scene.PluginName != "wizard" {
t.Fatalf("unexpected plugin name on scene: got %q want %q", scene.PluginName, "wizard")
if scene.pluginName != "wizard" {
t.Fatalf("unexpected plugin name on scene: got %q want %q", scene.pluginName, "wizard")
}
}
@@ -401,7 +401,7 @@ func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) {
if !ok {
t.Fatal("expected scene key to be built")
}
if err := bot.sessionStore.Set(key, SceneSession{Scene: scene.Name}); err != nil {
if err := bot.sessionStore.Set(key, SceneSession{Scene: scene.name}); err != nil {
t.Fatalf("failed to seed scene session: %v", err)
}
-2
View File
@@ -328,13 +328,11 @@ func (r TelegramRequest[R, P]) Do(api *API) (R, error) {
return r.DoWithContext(context.Background(), api)
}
// Internal helper that reads and caps a Telegram response body.
func readBody(body io.ReadCloser) ([]byte, error) {
reader := io.LimitReader(body, 10<<20) // 10 MB
return io.ReadAll(reader)
}
// Internal helper that parses a typed Telegram API response body.
func parseBody[R any](data []byte) (TelegramResponse[R], error) {
var resp TelegramResponse[R]
err := json.Unmarshal(data, &resp)
+1
View File
@@ -58,6 +58,7 @@ const (
// UpdateTypeRemovedChatBoost is a removed chat boost update.
UpdateTypeRemovedChatBoost UpdateType = "removed_chat_boost"
// UpdateTypeManagedBot is a managed bot update.
UpdateTypeManagedBot UpdateType = "managed_bot"
// UpdateTypeGuestMessage is a guest message update.
+6 -4
View File
@@ -177,10 +177,14 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
case <-ctx.Done():
return zero, ctx.Err()
case <-time.After(time.Duration(after) * time.Second):
continue // Повторяем запрос
continue
}
}
return zero, fmt.Errorf("[%d] %s", response.ErrorCode, response.Description)
return zero, &ResponseError{
Code: response.ErrorCode,
Description: response.Description,
Parameters: response.Parameters,
}
}
return response.Result, nil
}
@@ -218,7 +222,6 @@ func (r UploaderRequest[R, P]) Do(up *Uploader) (R, error) {
return r.DoWithContext(context.Background(), up)
}
// Internal helper that builds a finalized multipart body from files and params.
func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) {
buf := bytes.NewBuffer(nil)
w := multipart.NewWriter(buf)
@@ -251,7 +254,6 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str
return buf, w.FormDataContentType(), nil
}
// Internal helper that infers an upload field name from a file extension.
func uploaderTypeByExt(filename string) UploaderFileType {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
-4
View File
@@ -134,7 +134,6 @@ func (rl *RateLimiter) Wait(ctx context.Context, chatID int64) error {
return chatLimiter.Wait(ctx)
}
// Internal helper that returns the global limiter under read lock.
func (rl *RateLimiter) getGlobalLimiter() *rate.Limiter {
rl.globalMu.RLock()
defer rl.globalMu.RUnlock()
@@ -222,7 +221,6 @@ func (rl *RateLimiter) Check(ctx context.Context, dropOverflow bool, chatID int6
return nil
}
// Internal helper that waits for the global cooldown to expire.
func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error {
rl.globalMu.RLock()
until := rl.globalLockUntil
@@ -240,7 +238,6 @@ func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error {
}
}
// Internal helper that waits for a chat-specific cooldown to expire.
func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error {
rl.chatMu.RLock()
until, ok := rl.chatLocks[chatID]
@@ -258,7 +255,6 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro
}
}
// Internal helper that returns or creates a per-chat limiter.
// Updates chatLastSeen so Cleanup can evict idle entries.
func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter {
now := time.Now()