(refactor): command cloning (tests): command groups (doc): changelog
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+3
-31
@@ -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
|
||||
}
|
||||
|
||||
+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 (
|
||||
"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] {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user