FILE / ScuroNeko/Laniakea

cmd_generator.go

Исходный файл и его история в репозитории.
FILE 29b208eeec6d77c16e9b5c5a4c4d280b36c10dea
Files
Laniakea/cmd_generator.go
T
ScuroNeko 29b208eeec
Golang lint / lint (push) Successful in 11m32s
(new): v1.2 release
2026-08-19 14:58:25 +03:00

246 lines
7.8 KiB
Go

package laniakea
import (
"context"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"unicode/utf8"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
var cmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$")
// ErrTooManyCommands is returned when the total number of registered commands
// exceeds Telegram's limit of 100 bot commands per bot.
//
// Telegram Bot API enforces this limit strictly. If exceeded, SetMyCommands
// will fail with a 400 error. This error helps catch the issue early during
// bot initialization.
var ErrTooManyCommands = errors.New("too many commands. max 100")
var (
// ErrInvalidBotCommand reports a command name that Telegram would reject.
ErrInvalidBotCommand = errors.New("invalid bot command")
// ErrInvalidBotCommandDescription reports an empty or overlong generated description.
ErrInvalidBotCommandDescription = errors.New("invalid bot command description")
// ErrDuplicateBotCommand reports the same command generated by multiple plugins.
ErrDuplicateBotCommand = errors.New("duplicate bot command")
// ErrPartialCommandScopeUpdate reports that an earlier command scope was
// updated before a later scope failed.
ErrPartialCommandScopeUpdate = errors.New("partial command scope update")
)
// CommandScopeUpdateError describes a failed multi-scope command update.
// UpdatedScopes lists scopes successfully changed before FailedScope failed.
type CommandScopeUpdateError struct {
// UpdatedScopes contains scopes changed before the failure.
UpdatedScopes []tgapi.BotCommandScopeType
// FailedScope identifies the scope whose update failed.
FailedScope tgapi.BotCommandScopeType
// Err is the Telegram API error for FailedScope.
Err error
}
// Error returns a human-readable partial-update description.
func (e *CommandScopeUpdateError) Error() string {
return fmt.Sprintf("%v: updated %v; failed scope %q: %v", ErrPartialCommandScopeUpdate, e.UpdatedScopes, e.FailedScope, e.Err)
}
// Unwrap exposes both the partial-update sentinel and the underlying API error.
func (e *CommandScopeUpdateError) Unwrap() []error {
return []error{ErrPartialCommandScopeUpdate, e.Err}
}
func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
desc := ""
if len(cmd.description) > 0 {
desc = cmd.description
}
var descArgs []string
for _, a := range cmd.args {
if a.required {
descArgs = append(descArgs, fmt.Sprintf("<%s>", a.text))
} else {
descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text))
}
}
usage := fmt.Sprintf("Usage: /%s", cmd.command)
if len(descArgs) > 0 {
usage += " " + strings.Join(descArgs, " ")
}
if desc != "" {
desc = fmt.Sprintf("%s. %s", desc, usage)
return tgapi.BotCommand{Command: cmd.command, Description: desc, IsEphemeral: cmd.isEphemeral}
}
return tgapi.BotCommand{Command: cmd.command, Description: usage, IsEphemeral: cmd.isEphemeral}
}
func checkCmdRegex(cmd string) bool { return cmdRegexp.MatchString(cmd) }
func gatherCommandsForPlugin[T any](pl Plugin[T]) ([]tgapi.BotCommand, error) {
commands := make([]tgapi.BotCommand, 0)
names := make([]string, 0, len(pl.commands))
for name := range pl.commands {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
cmd := pl.commands[name]
if cmd.skipAutoCmd {
continue
}
if !checkCmdRegex(cmd.command) {
return nil, fmt.Errorf("%w %q in plugin %q", ErrInvalidBotCommand, cmd.command, pl.name)
}
generated := generateBotCommand(cmd)
descriptionLength := utf8.RuneCountInString(generated.Description)
if descriptionLength < 1 || descriptionLength > 256 {
return nil, fmt.Errorf(
"%w for %q in plugin %q: got %d characters, want 1..256",
ErrInvalidBotCommandDescription,
cmd.command,
pl.name,
descriptionLength,
)
}
commands = append(commands, generated)
}
return commands, nil
}
func gatherCommands[T any](bot *Bot[T]) ([]tgapi.BotCommand, error) {
commands := make([]tgapi.BotCommand, 0)
owners := make(map[string]string)
for _, pl := range bot.plugins {
if pl.skipAutoCmd {
continue
}
pluginCommands, err := gatherCommandsForPlugin(pl)
if err != nil {
return nil, err
}
for _, command := range pluginCommands {
if owner, exists := owners[command.Command]; exists {
return nil, fmt.Errorf(
"%w %q in plugins %q and %q",
ErrDuplicateBotCommand,
command.Command,
owner,
pl.name,
)
}
owners[command.Command] = pl.name
commands = append(commands, command)
}
bot.logger.Debugln(fmt.Sprintf("Registered %d commands from plugin %s", len(pl.commands), pl.name))
}
return commands, nil
}
// AutoGenerateCommands replaces plugin-defined commands in the private-chat,
// group-chat, and all-chat-administrators scopes.
//
// Returns ErrTooManyCommands if the total number of commands exceeds 100.
// Returns any API error from Telegram (e.g., network issues, invalid scope).
//
// Important: This method assumes the bot has been properly initialized and
// the API client is authenticated and ready.
//
// Usage:
//
// err := bot.AutoGenerateCommands()
// if err != nil {
// log.Fatal(err)
// }
func (bot *Bot[T]) AutoGenerateCommands() error {
return bot.AutoGenerateCommandsWithContext(context.Background())
}
// AutoGenerateCommandsWithContext is the context-aware variant of AutoGenerateCommands.
func (bot *Bot[T]) AutoGenerateCommandsWithContext(ctx context.Context) error {
commands, err := gatherCommands(bot)
if err != nil {
return err
}
if len(commands) > 100 {
return ErrTooManyCommands
}
// Register commands for each scope
scopes := []tgapi.BotCommandScope{
{Type: tgapi.BotCommandScopePrivateType},
{Type: tgapi.BotCommandScopeGroupType},
{Type: tgapi.BotCommandScopeAllChatAdministratorsType},
}
updatedScopes := make([]tgapi.BotCommandScopeType, 0, len(scopes))
for i := range scopes {
if err := bot.setCommandsForScope(ctx, &scopes[i], commands); err != nil {
if len(updatedScopes) > 0 {
return &CommandScopeUpdateError{
UpdatedScopes: updatedScopes,
FailedScope: scopes[i].Type,
Err: err,
}
}
return err
}
updatedScopes = append(updatedScopes, scopes[i].Type)
}
return nil
}
// AutoGenerateCommandsForScope registers all plugin-defined commands with Telegram's Bot API
// for the specified command scope. A nil scope selects Telegram's default scope.
//
// 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 {
return bot.AutoGenerateCommandsForScopeWithContext(context.Background(), scope)
}
// AutoGenerateCommandsForScopeWithContext is the context-aware variant of
// AutoGenerateCommandsForScope.
func (bot *Bot[T]) AutoGenerateCommandsForScopeWithContext(ctx context.Context, scope *tgapi.BotCommandScope) error {
commands, err := gatherCommands(bot)
if err != nil {
return err
}
if len(commands) > 100 {
return ErrTooManyCommands
}
return bot.setCommandsForScope(ctx, scope, commands)
}
func (bot *Bot[T]) setCommandsForScope(ctx context.Context, scope *tgapi.BotCommandScope, commands []tgapi.BotCommand) error {
if len(commands) > 100 {
return ErrTooManyCommands
}
_, err := bot.api.SetMyCommandsWithContext(ctx, tgapi.SetMyCommands{Scope: scope, Commands: commands})
if err != nil {
scopeType := tgapi.BotCommandScopeDefaultType
if scope != nil {
scopeType = scope.Type
}
return fmt.Errorf("failed to set commands for scope %q: %w", scopeType, err)
}
return nil
}