diff --git a/CHANGELOG.md b/CHANGELOG.md index 035e2eb..b9d0774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added `MsgContext.IsCallback()` and `MsgContext.HasPhoto()` helpers for callback-aware handler code. - Added `MsgContext.UpsertKeyboard(...)` and `MsgContext.UpsertKeyboardMarkdown(...)` helpers that edit callback messages, replace photo callback messages with a fresh chat message, and send a new chat message outside callback flow. +- Added `CommandGroup`, `NewCommandGroup(...)`, `Plugin.CommandGroup(...)`, and `Plugin.AddCommandGroup(...)` helpers for registering prefixed command groups with shared middleware. ### Changed - Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`. @@ -15,6 +16,7 @@ - Added regression coverage proving bot-level middleware blocks still complete the observer update lifecycle. - Added webhook runtime regression coverage for request enqueue through worker execution of a command handler. - Added regression coverage for inline callback keyboard upserts and callback target detection. +- Added regression coverage for command group prefixing, middleware order, clone behavior, and plugin registration. ## v1.0.0-rc.16 diff --git a/bot_utils.go b/bot_utils.go index 90b2c86..521bacc 100644 --- a/bot_utils.go +++ b/bot_utils.go @@ -176,43 +176,15 @@ func clonePlugin[T AppData](p *Plugin[T]) Plugin[T] { } for name, command := range p.commands { - cloned.commands[name] = cloneCommand(command) + cloned.commands[name] = command.clone() } for name, command := range p.payloads { - cloned.payloads[name] = cloneCommand(command) + cloned.payloads[name] = command.clone() } for name, scene := range p.scenes { - cloned.scenes[name] = cloneScene(scene) + cloned.scenes[name] = scene.clone() } maps.Copy(cloned.handlers, p.handlers) return cloned } - -func cloneCommand[T AppData](command *Command[T]) *Command[T] { - if command == nil { - return nil - } - - cloned := *command - cloned.args = append(extypes.Slice[CommandArg](nil), command.args...) - cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), command.middlewares...) - return &cloned -} - -func cloneScene[T AppData](scene *Scene[T]) *Scene[T] { - if scene == nil { - return nil - } - - cloned := *scene - cloned.steps = make(map[string]SceneHandler[T], len(scene.steps)) - cloned.commands = make(map[string]SceneHandler[T], len(scene.commands)) - cloned.payloads = make(map[string]SceneHandler[T], len(scene.payloads)) - - maps.Copy(cloned.steps, scene.steps) - maps.Copy(cloned.commands, scene.commands) - maps.Copy(cloned.payloads, scene.payloads) - - return &cloned -} diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..d98fd79 --- /dev/null +++ b/commands.go @@ -0,0 +1,222 @@ +package laniakea + +import ( + "errors" + "fmt" + "regexp" + + "git.scuroneko.dev/scuroneko/extypes" +) + +// CommandValueType defines the expected type of command argument. +type CommandValueType string + +const ( + // CommandValueStringType expects any non-empty string. + CommandValueStringType CommandValueType = "string" + // CommandValueIntType expects a decimal integer (digits only). + CommandValueIntType CommandValueType = "int" + // CommandValueBoolType expects a exact "true" or "false". + CommandValueBoolType CommandValueType = "bool" + // CommandValueAnyType accepts any input without validation. + CommandValueAnyType CommandValueType = "any" +) + +var ( + // CommandRegexInt matches one or more digits. + CommandRegexInt = regexp.MustCompile(`^\d+$`) + // CommandRegexString matches any non-empty string. + CommandRegexString = regexp.MustCompile(`^.+$`) + // CommandRegexBool matches true or false. + CommandRegexBool = regexp.MustCompile(`^(true|false)$`) +) + +// ErrCmdArgCountMismatch is returned when the number of provided arguments +// is less than the number of required arguments. +var ErrCmdArgCountMismatch = errors.New("command arg count mismatch") + +// ErrCmdArgRegexpMismatch is returned when an argument fails regex validation. +var ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch") + +var ( + errCommandNotFound = errors.New("command not found") + errPayloadNotFound = errors.New("payload not found") +) + +// CommandArg defines a single argument for a command, including type, regex, +// and whether it is required. +type CommandArg struct { + valueType CommandValueType // Type of expected value + text string // Human-readable description (not used in validation) + regex *regexp.Regexp // Regex used to validate input + required bool // Whether this argument must be provided +} + +// NewCommandArg creates a new CommandArg with the given text and type. +// Uses a default regex based on the type (string or int). +// For CommandValueAnyType, no validation is performed. +func NewCommandArg(text string) CommandArg { + return CommandArg{CommandValueAnyType, text, CommandRegexString, false} +} + +// SetValueType sets expected value type and switches built-in validation regexp. +func (c CommandArg) SetValueType(t CommandValueType) CommandArg { + regex := CommandRegexString + switch t { + case CommandValueIntType: + regex = CommandRegexInt + case CommandValueBoolType: + regex = CommandRegexBool + case CommandValueAnyType: + regex = nil // Skip validation + } + c.valueType = t + c.regex = regex + return c +} + +// SetRequired marks this argument as required. +// Returns the receiver for method chaining. +func (c CommandArg) SetRequired() CommandArg { + c.required = true + return c +} + +// CommandExecutor is the function type that executes a command. +// It receives the message context and injected application data. +// Returning a non-nil error routes it through the bot's error handler. +type CommandExecutor[T AppData] func(ctx *MsgContext, dbContext T) error + +// Command represents a bot command with arguments, description, and executor. +// Can be registered in a Plugin and optionally skipped from auto-generation. +type Command[T AppData] struct { + command string // The command trigger (e.g., "/start") + description string // Human-readable description for help + exec CommandExecutor[T] // Function to execute when command is triggered + args extypes.Slice[CommandArg] // List of expected arguments + middlewares extypes.Slice[Middleware[T]] // Optional middleware chain + skipAutoCmd bool // If true, this command won't be auto-added to help menus +} + +// NewCommand creates a new Command with the given executor, command string, and arguments. +// The command string should not include the leading slash (e.g., "start", not "/start"). +func NewCommand[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { + return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} +} + +// NewPayload creates a new Command with the given executor, command payload string, and arguments. +// The command string can contain any symbols, but it is recommended to use only "_", "-", ".", a-z, A-Z, and 0-9. +func NewPayload[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { + return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} +} + +// Use adds a middleware to the command's execution chain. +// Middlewares are executed in the order they are added. +func (c *Command[T]) Use(m Middleware[T]) *Command[T] { + c.middlewares = c.middlewares.Push(m) + return c +} + +// SetDescription sets the human-readable description of the command. +func (c *Command[T]) SetDescription(desc string) *Command[T] { + c.description = desc + return c +} + +// SkipCommandAutoGen marks this command to be excluded from auto-generated help menus. +func (c *Command[T]) SkipCommandAutoGen() *Command[T] { + c.skipAutoCmd = true + 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 { + return ErrCmdArgCountMismatch + } + } + + // Validate each argument against its regex + for i, arg := range args { + if i >= c.args.Len() { + // Extra arguments beyond defined args are ignored + break + } + cmdArg := c.args.Get(i) + if cmdArg.regex == nil { + continue // Skip validation for CommandValueAnyType + } + if !cmdArg.regex.MatchString(arg) { + return ErrCmdArgRegexpMismatch + } + } + return nil +} + +func (c *Command[T]) clone() *Command[T] { + if c == nil { + return nil + } + + cloned := *c + cloned.args = append(extypes.Slice[CommandArg](nil), c.args...) + cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), c.middlewares...) + return &cloned +} + +// CommandGroup builds a set of commands with a shared name prefix and middleware. +type CommandGroup[T any] struct { + prefix string + separator string + + middlewares extypes.Slice[Middleware[T]] + commands extypes.Slice[*Command[T]] +} + +// NewCommandGroup creates a command group that prefixes every added command. +func NewCommandGroup[T any](prefix string) *CommandGroup[T] { + return &CommandGroup[T]{ + prefix: prefix, separator: "", + + middlewares: make([]Middleware[T], 0), + commands: make([]*Command[T], 0), + } +} + +// SetSeparator sets the text inserted between the group prefix and command name. +func (g *CommandGroup[T]) SetSeparator(separator string) *CommandGroup[T] { + g.separator = separator + return g +} + +// Use adds middleware that runs before each command's own middleware. +func (g *CommandGroup[T]) Use(m Middleware[T]) *CommandGroup[T] { + g.middlewares = append(g.middlewares, m) + return g +} + +// AddCommand adds a prefixed copy of cmd to the group. +func (g *CommandGroup[T]) AddCommand(cmd *Command[T]) *CommandGroup[T] { + if cmd == nil { + return g + } + newCmd := cmd.clone() + newCmd.command = fmt.Sprintf("%s%s%s", g.prefix, g.separator, cmd.command) + g.commands = g.commands.Push(newCmd) + return g +} + +// Build returns command copies with group middleware prepended. +func (g *CommandGroup[T]) Build() []*Command[T] { + commands := make([]*Command[T], 0) + for _, cmd := range g.commands { + cloned := cmd.clone() + cloned.middlewares = append( + append(extypes.Slice[Middleware[T]]{}, g.middlewares...), + cloned.middlewares..., + ) + commands = append(commands, cloned) + } + return commands +} diff --git a/plugins.go b/plugins.go index ac001c0..a51e4bc 100644 --- a/plugins.go +++ b/plugins.go @@ -2,7 +2,6 @@ package laniakea import ( "errors" - "regexp" "git.scuroneko.dev/scuroneko/extypes" "git.scuroneko.dev/scuroneko/laniakea/tgapi" @@ -10,152 +9,6 @@ import ( "git.scuroneko.dev/scuroneko/sneklog/v2" ) -// CommandValueType defines the expected type of command argument. -type CommandValueType string - -const ( - // CommandValueStringType expects any non-empty string. - CommandValueStringType CommandValueType = "string" - // CommandValueIntType expects a decimal integer (digits only). - CommandValueIntType CommandValueType = "int" - // CommandValueBoolType expects a exact "true" or "false". - CommandValueBoolType CommandValueType = "bool" - // CommandValueAnyType accepts any input without validation. - CommandValueAnyType CommandValueType = "any" -) - -var ( - // CommandRegexInt matches one or more digits. - CommandRegexInt = regexp.MustCompile(`^\d+$`) - // CommandRegexString matches any non-empty string. - CommandRegexString = regexp.MustCompile(`^.+$`) - // CommandRegexBool matches true or false. - CommandRegexBool = regexp.MustCompile(`^(true|false)$`) -) - -// ErrCmdArgCountMismatch is returned when the number of provided arguments -// is less than the number of required arguments. -var ErrCmdArgCountMismatch = errors.New("command arg count mismatch") - -// ErrCmdArgRegexpMismatch is returned when an argument fails regex validation. -var ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch") - -var ( - errCommandNotFound = errors.New("command not found") - errPayloadNotFound = errors.New("payload not found") -) - -// CommandArg defines a single argument for a command, including type, regex, -// and whether it is required. -type CommandArg struct { - valueType CommandValueType // Type of expected value - text string // Human-readable description (not used in validation) - regex *regexp.Regexp // Regex used to validate input - required bool // Whether this argument must be provided -} - -// NewCommandArg creates a new CommandArg with the given text and type. -// Uses a default regex based on the type (string or int). -// For CommandValueAnyType, no validation is performed. -func NewCommandArg(text string) CommandArg { - return CommandArg{CommandValueAnyType, text, CommandRegexString, false} -} - -// SetValueType sets expected value type and switches built-in validation regexp. -func (c CommandArg) SetValueType(t CommandValueType) CommandArg { - regex := CommandRegexString - switch t { - case CommandValueIntType: - regex = CommandRegexInt - case CommandValueBoolType: - regex = CommandRegexBool - case CommandValueAnyType: - regex = nil // Skip validation - } - c.valueType = t - c.regex = regex - return c -} - -// SetRequired marks this argument as required. -// Returns the receiver for method chaining. -func (c CommandArg) SetRequired() CommandArg { - c.required = true - return c -} - -// CommandExecutor is the function type that executes a command. -// It receives the message context and injected application data. -// Returning a non-nil error routes it through the bot's error handler. -type CommandExecutor[T AppData] func(ctx *MsgContext, dbContext T) error - -// Command represents a bot command with arguments, description, and executor. -// Can be registered in a Plugin and optionally skipped from auto-generation. -type Command[T AppData] struct { - command string // The command trigger (e.g., "/start") - description string // Human-readable description for help - exec CommandExecutor[T] // Function to execute when command is triggered - args extypes.Slice[CommandArg] // List of expected arguments - middlewares extypes.Slice[Middleware[T]] // Optional middleware chain - skipAutoCmd bool // If true, this command won't be auto-added to help menus -} - -// NewCommand creates a new Command with the given executor, command string, and arguments. -// The command string should not include the leading slash (e.g., "start", not "/start"). -func NewCommand[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { - return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} -} - -// NewPayload creates a new Command with the given executor, command payload string, and arguments. -// The command string can contain any symbols, but it is recommended to use only "_", "-", ".", a-z, A-Z, and 0-9. -func NewPayload[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { - return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} -} - -// Use adds a middleware to the command's execution chain. -// Middlewares are executed in the order they are added. -func (c *Command[T]) Use(m Middleware[T]) *Command[T] { - c.middlewares = c.middlewares.Push(m) - return c -} - -// SetDescription sets the human-readable description of the command. -func (c *Command[T]) SetDescription(desc string) *Command[T] { - c.description = desc - return c -} - -// SkipCommandAutoGen marks this command to be excluded from auto-generated help menus. -func (c *Command[T]) SkipCommandAutoGen() *Command[T] { - c.skipAutoCmd = true - 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 { - return ErrCmdArgCountMismatch - } - } - - // Validate each argument against its regex - for i, arg := range args { - if i >= c.args.Len() { - // Extra arguments beyond defined args are ignored - break - } - cmdArg := c.args.Get(i) - if cmdArg.regex == nil { - continue // Skip validation for CommandValueAnyType - } - if !cmdArg.regex.MatchString(arg) { - return ErrCmdArgRegexpMismatch - } - } - return nil -} - // Plugin represents a collection of commands and payloads (e.g., callback handlers), // with shared middleware and configuration. // @@ -225,6 +78,36 @@ func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] { return p } +// CommandGroup configures and registers a prefixed command group. +func (p *Plugin[T]) CommandGroup(prefix string, groupFunc func(group *CommandGroup[T])) *Plugin[T] { + if groupFunc == nil { + return p + } + group := NewCommandGroup[T](prefix) + groupFunc(group) + if len(group.commands) == 0 { + return p + } + for _, cmd := range group.Build() { + p.AddCommand(cmd) + } + return p +} + +// AddCommandGroup registers every command built by group. +func (p *Plugin[T]) AddCommandGroup(group *CommandGroup[T]) *Plugin[T] { + if group == nil { + return p + } + if len(group.commands) == 0 { + return p + } + for _, cmd := range group.Build() { + p.AddCommand(cmd) + } + return p +} + // NewPayload creates and immediately adds a new payload command to the plugin. // Returns the created payload command for further configuration. func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { diff --git a/plugins_test.go b/plugins_test.go index 1518cf3..a3e0699 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -38,3 +38,83 @@ func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { t.Fatalf("expected both args to validate, got %v", err) } } + +func TestCommandGroupBuildsPrefixedCommandsWithoutMutatingOriginal(t *testing.T) { + groupMiddleware := NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true }) + commandMiddleware := NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true }) + cmd := NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban"). + SetDescription("Ban user"). + Use(commandMiddleware) + + group := NewCommandGroup[NoData]("admin"). + SetSeparator("_"). + Use(groupMiddleware). + AddCommand(cmd) + + built := group.Build() + if len(built) != 1 { + t.Fatalf("expected one command, got %d", len(built)) + } + + grouped := built[0] + if grouped.command != "admin_ban" { + t.Fatalf("expected prefixed command name, got %q", grouped.command) + } + if grouped.description != "Ban user" { + t.Fatalf("expected description to be copied, got %q", grouped.description) + } + if cmd.command != "ban" { + t.Fatalf("expected original command name to stay unchanged, got %q", cmd.command) + } + if len(cmd.middlewares) != 1 || cmd.middlewares[0].name != "command" { + t.Fatalf("expected original command middleware to stay unchanged, got %#v", cmd.middlewares) + } + if len(grouped.middlewares) != 2 { + t.Fatalf("expected group and command middleware, got %d", len(grouped.middlewares)) + } + if grouped.middlewares[0].name != "group" || grouped.middlewares[1].name != "command" { + t.Fatalf("expected group middleware before command middleware, got %q then %q", grouped.middlewares[0].name, grouped.middlewares[1].name) + } +} + +func TestCommandGroupBuildIsRepeatable(t *testing.T) { + group := NewCommandGroup[NoData]("admin"). + Use(NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true })). + AddCommand(NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban"). + Use(NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true }))) + + first := group.Build() + second := group.Build() + + if len(first) != 1 || len(second) != 1 { + t.Fatalf("expected one command from each build, got %d and %d", len(first), len(second)) + } + if len(first[0].middlewares) != 2 { + t.Fatalf("expected first build to have two middlewares, got %d", len(first[0].middlewares)) + } + if len(second[0].middlewares) != 2 { + t.Fatalf("expected second build to have two middlewares, got %d", len(second[0].middlewares)) + } + if first[0] == second[0] { + t.Fatal("expected repeated Build calls to return distinct command copies") + } +} + +func TestPluginCommandGroupRegistersBuiltCommands(t *testing.T) { + plugin := NewPlugin[NoData]("admin") + + plugin.CommandGroup("admin", func(group *CommandGroup[NoData]) { + group.SetSeparator("_") + group.AddCommand(NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban")) + }) + + if _, ok := plugin.commands["admin_ban"]; !ok { + t.Fatal("expected plugin to register prefixed command") + } + if _, ok := plugin.commands["ban"]; ok { + t.Fatal("expected plugin not to register unprefixed command") + } + + plugin.CommandGroup("ignored", nil) + plugin.AddCommandGroup(nil) +} diff --git a/scene.go b/scene.go index c3ec067..a868d31 100644 --- a/scene.go +++ b/scene.go @@ -2,6 +2,7 @@ package laniakea import ( "encoding/json" + "maps" "sync" ) @@ -111,6 +112,23 @@ func (s *Scene[T]) executeMessage(ctx *SceneContext, db T) (SceneResult, bool, e return result, true, err } +func (s *Scene[T]) clone() *Scene[T] { + if s == nil { + return nil + } + + cloned := *s + cloned.steps = make(map[string]SceneHandler[T], len(s.steps)) + cloned.commands = make(map[string]SceneHandler[T], len(s.commands)) + cloned.payloads = make(map[string]SceneHandler[T], len(s.payloads)) + + maps.Copy(cloned.steps, s.steps) + maps.Copy(cloned.commands, s.commands) + maps.Copy(cloned.payloads, s.payloads) + + return &cloned +} + // SceneSession stores the active scene state for one session key. type SceneSession struct { // Scene is the registered scene name for the active session.