FILE / ScuroNeko/Laniakea
cmd_generator.go
Исходный файл и его история в репозитории.
246 lines
7.8 KiB
Go
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
|
|
}
|