Compare commits

...

1 Commits

Author SHA1 Message Date
5976fcd0b8 v1.0.0 beta 19 2026-03-13 12:25:53 +03:00
4 changed files with 106 additions and 68 deletions

View File

@@ -61,23 +61,22 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
usage := fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " ")) usage := fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " "))
if desc != "" { if desc != "" {
desc = fmt.Sprintf("%s. %s", desc, usage) desc = fmt.Sprintf("%s. %s", desc, usage)
}
return tgapi.BotCommand{Command: cmd.command, Description: desc} return tgapi.BotCommand{Command: cmd.command, Description: desc}
} }
return tgapi.BotCommand{Command: cmd.command, Description: usage}
}
// checkCmdRegex check if command satisfy regexp [a-zA-Z0-9]+ // checkCmdRegex check if command satisfy regexp [a-zA-Z0-9]+
// Return true if satisfy, else false. // Return true if satisfied, else false.
func checkCmdRegex(cmd string) bool { func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) }
return CmdRegexp.MatchString(cmd)
}
// generateBotCommandForPlugin collects all non-skipped commands from a Plugin[T] // gatherCommandsForPlugin collects all non-skipped commands from a Plugin[T]
// and converts them into tgapi.BotCommand objects. // and converts them into tgapi.BotCommand objects.
// //
// Commands marked with skipAutoCmd = true are excluded from auto-registration. // Commands marked with skipAutoCmd = true are excluded from auto-registration.
// This allows plugins to opt out of automatic command generation (e.g., for // This allows plugins to opt out of automatic command generation (e.g., for
// internal or hidden commands). // internal or hidden commands).
func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0) commands := make([]tgapi.BotCommand, 0)
for _, cmd := range pl.commands { for _, cmd := range pl.commands {
if cmd.skipAutoCmd { if cmd.skipAutoCmd {
@@ -91,6 +90,21 @@ func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
return commands return commands
} }
// gatherCommands collects all commands from all plugins
// and converts them into tgapi.BotCommand objects.
// See gatherCommandsForPlugin
func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0)
for _, pl := range bot.plugins {
if pl.skipAutoCmd {
continue
}
commands = append(commands, gatherCommandsForPlugin(pl)...)
bot.logger.Debugln(fmt.Sprintf("Registered %d commands from plugin %s", len(pl.commands), pl.name))
}
return commands
}
// AutoGenerateCommands registers all plugin-defined commands with Telegram's Bot API // AutoGenerateCommands registers all plugin-defined commands with Telegram's Bot API
// across three scopes: // across three scopes:
// - Private chats (users) // - Private chats (users)
@@ -120,17 +134,7 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
return fmt.Errorf("failed to delete existing commands: %w", err) return fmt.Errorf("failed to delete existing commands: %w", err)
} }
// Collect all non-skipped commands from all plugins commands := gatherCommands(bot)
commands := make([]tgapi.BotCommand, 0)
for _, pl := range bot.plugins {
if pl.skipAutoCmd {
continue
}
commands = append(commands, generateBotCommandForPlugin(pl)...)
bot.logger.Debugln(fmt.Sprintf("Registered %d commands from plugin %s", len(pl.commands), pl.name))
}
// Enforce Telegram's 100-command limit
if len(commands) > 100 { if len(commands) > 100 {
return ErrTooManyCommands return ErrTooManyCommands
} }
@@ -154,3 +158,39 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
return nil return nil
} }
// AutoGenerateCommandsForScope registers all plugin-defined commands with Telegram's Bot API
// for the specified command scope. It first deletes any existing commands in that scope
// to ensure a clean state, then sets the new set of commands.
//
// The scope parameter defines where the commands should be available (e.g., private chats,
// group chats, chat administrators). See tgapi.BotCommandScope and its predefined types.
//
// Returns ErrTooManyCommands if the total number of commands exceeds 100.
// Returns any API error from Telegram (e.g., network issues, invalid scope).
//
// Usage:
//
// privateScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopePrivateType}
// if err := bot.AutoGenerateCommandsForScope(privateScope); err != nil {
// log.Fatal(err)
// }
func (bot *Bot[T]) AutoGenerateCommandsForScope(scope *tgapi.BotCommandScope) error {
_, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{Scope: scope})
if err != nil {
return fmt.Errorf("failed to delete existing commands: %w", err)
}
commands := gatherCommands(bot)
if len(commands) > 100 {
return ErrTooManyCommands
}
_, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{
Commands: commands,
Scope: scope,
})
if err != nil {
return fmt.Errorf("failed to set commands for scope %q: %w", scope.Type, err)
}
return nil
}

View File

@@ -29,6 +29,7 @@
package laniakea package laniakea
import ( import (
"errors"
"math/rand/v2" "math/rand/v2"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -36,6 +37,8 @@ import (
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.nix13.pw/scuroneko/laniakea/tgapi"
) )
var ErrDraftChatIDZero = errors.New("zero draft chat ID")
// draftIdGenerator defines an interface for generating unique draft IDs. // draftIdGenerator defines an interface for generating unique draft IDs.
type draftIdGenerator interface { type draftIdGenerator interface {
// Next returns the next unique draft ID. // Next returns the next unique draft ID.
@@ -73,12 +76,6 @@ type DraftProvider struct {
api *tgapi.API api *tgapi.API
drafts map[uint64]*Draft drafts map[uint64]*Draft
generator draftIdGenerator generator draftIdGenerator
// Internal defaults — not exposed directly to users.
chatID int64
messageThreadID int
parseMode tgapi.ParseMode
entities []tgapi.MessageEntity
} }
// NewRandomDraftProvider creates a new DraftProvider using random draft IDs. // NewRandomDraftProvider creates a new DraftProvider using random draft IDs.
@@ -109,34 +106,6 @@ func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider {
} }
} }
// SetChat sets the target chat and optional message thread for all drafts created
// by this provider. Must be called before NewDraft().
//
// If not set, NewDraft() will create drafts with zero chatID, which will cause
// SendMessageDraft to fail. Use this method to avoid runtime errors.
func (p *DraftProvider) SetChat(chatID int64, messageThreadID int) *DraftProvider {
p.chatID = chatID
p.messageThreadID = messageThreadID
return p
}
// SetParseMode sets the default parse mode for all new drafts.
// Overrides the parse mode passed to NewDraft() only if not specified there.
func (p *DraftProvider) SetParseMode(mode tgapi.ParseMode) *DraftProvider {
p.parseMode = mode
return p
}
// SetEntities sets the default message entities (e.g., bold, links, mentions)
// to be copied into every new draft.
//
// Entities are shallow-copied — if you mutate the slice later, it will affect
// future drafts. For safety, pass a copy if needed.
func (p *DraftProvider) SetEntities(entities []tgapi.MessageEntity) *DraftProvider {
p.entities = entities
return p
}
// GetDraft retrieves a draft by its ID. // GetDraft retrieves a draft by its ID.
// //
// 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.
@@ -154,12 +123,13 @@ 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 {
p.mu.RLock() p.mu.Lock()
drafts := make([]*Draft, 0, len(p.drafts)) drafts := make([]*Draft, 0, len(p.drafts))
for _, draft := range p.drafts { for _, draft := range p.drafts {
drafts = append(drafts, draft) drafts = append(drafts, draft)
} }
p.mu.RUnlock() p.drafts = make(map[uint64]*Draft)
p.mu.Unlock()
var lastErr error var lastErr error
for _, draft := range drafts { for _, draft := range drafts {
@@ -197,18 +167,11 @@ type Draft struct {
// //
// Panics if chatID is zero — call SetChat() on the provider first. // Panics if chatID is zero — call SetChat() on the provider first.
func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft { func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft {
if p.chatID == 0 {
panic("laniakea: DraftProvider.SetChat() must be called before NewDraft()")
}
id := p.generator.Next() id := p.generator.Next()
draft := &Draft{ draft := &Draft{
api: p.api, api: p.api,
provider: p, provider: p,
chatID: p.chatID,
messageThreadID: p.messageThreadID,
parseMode: parseMode, parseMode: parseMode,
entities: p.entities, // Shallow copy — caller must ensure immutability
ID: id, ID: id,
Message: "", Message: "",
} }
@@ -310,6 +273,9 @@ func (d *Draft) Flush() error {
// push is the internal helper for Push(). It updates the server draft via SendMessageDraft. // push is the internal helper for Push(). It updates the server draft via SendMessageDraft.
func (d *Draft) push(text string) error { func (d *Draft) push(text string) error {
if d.chatID == 0 {
return ErrDraftChatIDZero
}
d.Message += text d.Message += text
params := tgapi.SendMessageDraftP{ params := tgapi.SendMessageDraftP{
ChatID: d.chatID, ChatID: d.chatID,

View File

@@ -6,6 +6,38 @@ import (
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.nix13.pw/scuroneko/laniakea/tgapi"
) )
// Updates fetches new updates from Telegram API using long polling.
// It respects the bot's current update offset and automatically advances it
// after successful retrieval. The method supports selective update types
// through AllowedUpdates and includes optional request logging.
//
// Parameters:
// - None (uses bot's internal state for offset and allowed updates)
//
// Returns:
// - []tgapi.Update: slice of received updates (empty if none available)
// - error: any error encountered during the API call
//
// Behavior:
// 1. Uses the bot's current update offset (via GetUpdateOffset)
// 2. Requests updates with 30-second timeout
// 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.
// For non-blocking behavior, consider using webhooks instead.
//
// Example:
//
// updates, err := bot.Updates()
// if err != nil {
// log.Fatal(err)
// }
// for _, update := range updates {
// // process update
// }
func (bot *Bot[T]) Updates() ([]tgapi.Update, error) { func (bot *Bot[T]) Updates() ([]tgapi.Update, error) {
offset := bot.GetUpdateOffset() offset := bot.GetUpdateOffset()
params := tgapi.UpdateParams{ params := tgapi.UpdateParams{

View File

@@ -1,9 +1,9 @@
package utils package utils
const ( const (
VersionString = "1.0.0-beta.18" VersionString = "1.0.0-beta.19"
VersionMajor = 1 VersionMajor = 1
VersionMinor = 0 VersionMinor = 0
VersionPatch = 0 VersionPatch = 0
VersionBeta = 18 VersionBeta = 19
) )