v1.0.0 beta 12
This commit is contained in:
228
plugins.go
228
plugins.go
@@ -1,3 +1,15 @@
|
||||
// Package laniakea provides a structured system for defining and executing
|
||||
// bot commands and payloads with middleware support, argument validation,
|
||||
// and plugin-based organization.
|
||||
//
|
||||
// The core concepts are:
|
||||
// - Command: A named bot command with arguments, description, and executor.
|
||||
// - Plugin: A collection of commands and payloads, with shared middlewares.
|
||||
// - Middleware: Interceptors that can validate, modify, or block execution.
|
||||
// - CommandArg: Type-safe argument definitions with regex validation.
|
||||
//
|
||||
// This system is designed to be used with MsgContext from the laniakea package
|
||||
// to handle Telegram bot interactions in a modular, type-safe way.
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
@@ -7,83 +19,122 @@ import (
|
||||
"git.nix13.pw/scuroneko/extypes"
|
||||
)
|
||||
|
||||
const (
|
||||
CommandValueStringType CommandValueType = "string"
|
||||
CommandValueIntType CommandValueType = "int"
|
||||
CommandValueBoolType CommandValueType = "bool"
|
||||
CommandValueAnyType CommandValueType = "any"
|
||||
)
|
||||
|
||||
var (
|
||||
CommandRegexInt = regexp.MustCompile(`\d+`)
|
||||
CommandRegexString = regexp.MustCompile(".+")
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCmdArgCountMismatch = errors.New("command arg count mismatch")
|
||||
ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch")
|
||||
)
|
||||
|
||||
// CommandValueType defines the expected type of a 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 is reserved for future use (not implemented).
|
||||
CommandValueBoolType CommandValueType = "bool"
|
||||
// CommandValueAnyType accepts any input without validation.
|
||||
CommandValueAnyType CommandValueType = "any"
|
||||
)
|
||||
|
||||
// CommandRegexInt matches one or more digits.
|
||||
var CommandRegexInt = regexp.MustCompile(`\d+`)
|
||||
|
||||
// CommandRegexString matches any non-empty string.
|
||||
var CommandRegexString = regexp.MustCompile(".+")
|
||||
|
||||
// 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")
|
||||
|
||||
// CommandArg defines a single argument for a command, including type, regex,
|
||||
// and whether it is required.
|
||||
type CommandArg struct {
|
||||
valueType CommandValueType
|
||||
text string
|
||||
regex *regexp.Regexp
|
||||
required bool
|
||||
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, valueType CommandValueType) *CommandArg {
|
||||
regex := CommandRegexString
|
||||
switch valueType {
|
||||
case CommandValueIntType:
|
||||
regex = CommandRegexInt
|
||||
case CommandValueAnyType:
|
||||
regex = nil // Skip validation
|
||||
}
|
||||
return &CommandArg{valueType, text, regex, false}
|
||||
}
|
||||
|
||||
// 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 a database context (generic).
|
||||
type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext *T)
|
||||
|
||||
// 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 DbContext] struct {
|
||||
command string
|
||||
description string
|
||||
exec CommandExecutor[T]
|
||||
args extypes.Slice[CommandArg]
|
||||
middlewares extypes.Slice[Middleware[T]]
|
||||
skipAutoCmd bool
|
||||
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}
|
||||
return &Command[T]{command, "", exec, extypes.Slice[CommandArg](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
|
||||
}
|
||||
|
||||
// validateArgs checks if the provided arguments match the command's requirements.
|
||||
// Returns ErrCmdArgCountMismatch if too few arguments are provided.
|
||||
// Returns ErrCmdArgRegexpMismatch if any argument fails regex validation.
|
||||
func (c *Command[T]) validateArgs(args []string) error {
|
||||
cmdArgs := c.args.Filter(func(e CommandArg) bool { return !e.required })
|
||||
if len(args) < cmdArgs.Len() {
|
||||
// Count required args
|
||||
requiredCount := c.args.Filter(func(a CommandArg) bool { return a.required }).Len()
|
||||
if len(args) < requiredCount {
|
||||
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
|
||||
continue // Skip validation for CommandValueAnyType
|
||||
}
|
||||
if !cmdArg.regex.MatchString(arg) {
|
||||
return ErrCmdArgRegexpMismatch
|
||||
@@ -92,57 +143,123 @@ func (c *Command[T]) validateArgs(args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Plugin represents a collection of commands and payloads (e.g., callback handlers),
|
||||
// with shared middleware and configuration.
|
||||
type Plugin[T DbContext] struct {
|
||||
name string
|
||||
commands map[string]Command[T]
|
||||
payloads map[string]Command[T]
|
||||
middlewares extypes.Slice[Middleware[T]]
|
||||
skipAutoCmd bool
|
||||
name string // Name of the plugin (e.g., "admin", "user")
|
||||
commands map[string]Command[T] // Registered commands (triggered by message)
|
||||
payloads map[string]Command[T] // Registered payloads (triggered by callback data)
|
||||
middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads
|
||||
skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help
|
||||
}
|
||||
|
||||
// NewPlugin creates a new Plugin with the given name.
|
||||
func NewPlugin[T DbContext](name string) *Plugin[T] {
|
||||
return &Plugin[T]{
|
||||
name, map[string]Command[T]{},
|
||||
map[string]Command[T]{}, extypes.Slice[Middleware[T]]{}, false,
|
||||
name, make(map[string]Command[T]),
|
||||
make(map[string]Command[T]), extypes.Slice[Middleware[T]]{}, false,
|
||||
}
|
||||
}
|
||||
|
||||
// AddCommand registers a command in the plugin.
|
||||
// The command's .command field is used as the key.
|
||||
func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] {
|
||||
p.commands[command.command] = *command
|
||||
return p
|
||||
}
|
||||
|
||||
// NewCommand creates and immediately adds a new command to the plugin.
|
||||
// Returns the created command for further configuration.
|
||||
func (p *Plugin[T]) NewCommand(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] {
|
||||
return NewCommand(exec, command, args...)
|
||||
cmd := NewCommand(exec, command, args...)
|
||||
p.AddCommand(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// AddPayload registers a payload (e.g., callback query data) in the plugin.
|
||||
// Payloads are triggered by inline button callback_data, not by message text.
|
||||
func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] {
|
||||
p.payloads[command.command] = *command
|
||||
return p
|
||||
}
|
||||
|
||||
// AddMiddleware adds a middleware to the plugin's global middleware chain.
|
||||
// Middlewares are executed before any command or payload.
|
||||
func (p *Plugin[T]) AddMiddleware(middleware Middleware[T]) *Plugin[T] {
|
||||
p.middlewares = p.middlewares.Push(middleware)
|
||||
return p
|
||||
}
|
||||
|
||||
// SkipCommandAutoGen marks the entire plugin to be excluded from auto-generated help menus.
|
||||
func (p *Plugin[T]) SkipCommandAutoGen() *Plugin[T] {
|
||||
p.skipAutoCmd = true
|
||||
return p
|
||||
}
|
||||
|
||||
// executeCmd finds and executes a command by its trigger string.
|
||||
// Validates arguments and runs middlewares before executor.
|
||||
// On error, sends an error message to the user via ctx.error().
|
||||
func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) {
|
||||
command := p.commands[cmd]
|
||||
command, exists := p.commands[cmd]
|
||||
if !exists {
|
||||
ctx.error(errors.New("command not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := command.validateArgs(ctx.Args); err != nil {
|
||||
ctx.error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Run plugin middlewares
|
||||
if !p.executeMiddlewares(ctx, dbContext) {
|
||||
return
|
||||
}
|
||||
|
||||
// Run command-specific middlewares
|
||||
for _, m := range command.middlewares {
|
||||
if !m.Execute(ctx, dbContext) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Execute command
|
||||
command.exec(ctx, dbContext)
|
||||
}
|
||||
|
||||
// executePayload finds and executes a payload by its callback_data string.
|
||||
// Validates arguments and runs middlewares before executor.
|
||||
// On error, sends an error message to the user via ctx.error().
|
||||
func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T) {
|
||||
pl := p.payloads[payload]
|
||||
if err := pl.validateArgs(ctx.Args); err != nil {
|
||||
command, exists := p.payloads[payload]
|
||||
if !exists {
|
||||
ctx.error(errors.New("payload not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := command.validateArgs(ctx.Args); err != nil {
|
||||
ctx.error(err)
|
||||
return
|
||||
}
|
||||
pl.exec(ctx, dbContext)
|
||||
|
||||
// Run plugin middlewares
|
||||
if !p.executeMiddlewares(ctx, dbContext) {
|
||||
return
|
||||
}
|
||||
|
||||
// Run command-specific middlewares
|
||||
for _, m := range command.middlewares {
|
||||
if !m.Execute(ctx, dbContext) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Execute payload
|
||||
command.exec(ctx, dbContext)
|
||||
}
|
||||
|
||||
// executeMiddlewares runs all plugin middlewares in order.
|
||||
// Returns false if any middleware returns false (blocks execution).
|
||||
func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool {
|
||||
for _, m := range p.middlewares {
|
||||
if !m.Execute(ctx, db) {
|
||||
@@ -152,28 +269,41 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// MiddlewareExecutor is the function type for middleware logic.
|
||||
// Returns true to continue execution, false to block it.
|
||||
// If async, return value is ignored.
|
||||
type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db *T) bool
|
||||
|
||||
// Middleware
|
||||
// When async, returned value ignored
|
||||
// Middleware represents a reusable execution interceptor.
|
||||
// Can be synchronous (blocking) or asynchronous (non-blocking).
|
||||
type Middleware[T DbContext] struct {
|
||||
name string
|
||||
executor MiddlewareExecutor[T]
|
||||
order int
|
||||
async bool
|
||||
name string // Human-readable name for logging/debugging
|
||||
executor MiddlewareExecutor[T] // Function to execute
|
||||
order int // Optional sort order (not used yet)
|
||||
async bool // If true, runs in goroutine and doesn't block
|
||||
}
|
||||
|
||||
// NewMiddleware creates a new synchronous middleware.
|
||||
func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) *Middleware[T] {
|
||||
return &Middleware[T]{name, executor, 0, false}
|
||||
}
|
||||
|
||||
// SetOrder sets the execution order (currently ignored).
|
||||
func (m *Middleware[T]) SetOrder(order int) *Middleware[T] {
|
||||
m.order = order
|
||||
return m
|
||||
}
|
||||
|
||||
// SetAsync marks the middleware to run asynchronously.
|
||||
// Execution continues regardless of its return value.
|
||||
func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] {
|
||||
m.async = async
|
||||
return m
|
||||
}
|
||||
|
||||
// Execute runs the middleware.
|
||||
// If async, runs in a goroutine and returns true immediately.
|
||||
// Otherwise, returns the result of the executor.
|
||||
func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool {
|
||||
if m.async {
|
||||
go m.executor(ctx, db)
|
||||
|
||||
Reference in New Issue
Block a user