REPOSITORY / ScuroNeko/Laniakea

Pull Requests

PULL REQUESTS REPOSITORY

v1.0.0 #9

Merged
ScuroNeko merged 101 commits from dev into main 2026-05-20 13:43:34 +03:00
6 changed files with 355 additions and 178 deletions
Showing only changes of commit 269ccec007 - Show all commits
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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] {
+80
View File
@@ -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)
}
+18
View File
@@ -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.