(refactor): command cloning (tests): command groups (doc): changelog
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
### Added
|
### Added
|
||||||
- Added `MsgContext.IsCallback()` and `MsgContext.HasPhoto()` helpers for callback-aware handler code.
|
- 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 `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
|
### Changed
|
||||||
- Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`.
|
- 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 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 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 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
|
## v1.0.0-rc.16
|
||||||
|
|
||||||
|
|||||||
+3
-31
@@ -176,43 +176,15 @@ func clonePlugin[T AppData](p *Plugin[T]) Plugin[T] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for name, command := range p.commands {
|
for name, command := range p.commands {
|
||||||
cloned.commands[name] = cloneCommand(command)
|
cloned.commands[name] = command.clone()
|
||||||
}
|
}
|
||||||
for name, command := range p.payloads {
|
for name, command := range p.payloads {
|
||||||
cloned.payloads[name] = cloneCommand(command)
|
cloned.payloads[name] = command.clone()
|
||||||
}
|
}
|
||||||
for name, scene := range p.scenes {
|
for name, scene := range p.scenes {
|
||||||
cloned.scenes[name] = cloneScene(scene)
|
cloned.scenes[name] = scene.clone()
|
||||||
}
|
}
|
||||||
maps.Copy(cloned.handlers, p.handlers)
|
maps.Copy(cloned.handlers, p.handlers)
|
||||||
|
|
||||||
return cloned
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
+222
@@ -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
|
||||||
|
}
|
||||||
+30
-147
@@ -2,7 +2,6 @@ package laniakea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"regexp"
|
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/extypes"
|
"git.scuroneko.dev/scuroneko/extypes"
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
@@ -10,152 +9,6 @@ import (
|
|||||||
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
"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),
|
// Plugin represents a collection of commands and payloads (e.g., callback handlers),
|
||||||
// with shared middleware and configuration.
|
// with shared middleware and configuration.
|
||||||
//
|
//
|
||||||
@@ -225,6 +78,36 @@ func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] {
|
|||||||
return p
|
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.
|
// NewPayload creates and immediately adds a new payload command to the plugin.
|
||||||
// Returns the created payload command for further configuration.
|
// Returns the created payload command for further configuration.
|
||||||
func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] {
|
func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] {
|
||||||
|
|||||||
@@ -38,3 +38,83 @@ func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
|
|||||||
t.Fatalf("expected both args to validate, got %v", err)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package laniakea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"maps"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -111,6 +112,23 @@ func (s *Scene[T]) executeMessage(ctx *SceneContext, db T) (SceneResult, bool, e
|
|||||||
return result, true, err
|
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.
|
// SceneSession stores the active scene state for one session key.
|
||||||
type SceneSession struct {
|
type SceneSession struct {
|
||||||
// Scene is the registered scene name for the active session.
|
// Scene is the registered scene name for the active session.
|
||||||
|
|||||||
Reference in New Issue
Block a user