commands: add generic command processing framework for bots
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run

This commit is contained in:
Tulir Asokan 2025-04-13 02:59:49 +03:00
commit 56e2adbf83
3 changed files with 366 additions and 0 deletions

124
commands/event.go Normal file
View file

@ -0,0 +1,124 @@
// Copyright (c) 2025 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package commands
import (
"context"
"fmt"
"strings"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
)
// Event contains the data of a single command event.
// It also provides some helper methods for responding to the command.
type Event[MetaType any] struct {
*event.Event
// RawInput is the entire message before splitting into command and arguments.
RawInput string
// Command is the lowercased first word of the message.
Command string
// Args are the rest of the message split by whitespace ([strings.Fields]).
Args []string
// RawArgs is the same as args, but without the splitting by whitespace.
RawArgs string
Ctx context.Context
Proc *Processor[MetaType]
Handler *Handler[MetaType]
Meta MetaType
}
var IDHTMLParser = &format.HTMLParser{
PillConverter: func(displayname, mxid, eventID string, ctx format.Context) string {
if len(mxid) == 0 {
return displayname
}
if eventID != "" {
return fmt.Sprintf("https://matrix.to/#/%s/%s", mxid, eventID)
}
return mxid
},
ItalicConverter: func(s string, c format.Context) string {
return fmt.Sprintf("*%s*", s)
},
Newline: "\n",
}
// ParseEvent parses a message into a command event struct.
func ParseEvent[MetaType any](ctx context.Context, evt *event.Event) *Event[MetaType] {
content := evt.Content.Parsed.(*event.MessageEventContent)
text := content.Body
if content.Format == event.FormatHTML {
text = IDHTMLParser.Parse(content.FormattedBody, format.NewContext(ctx))
}
parts := strings.Fields(text)
return &Event[MetaType]{
Event: evt,
RawInput: text,
Command: strings.ToLower(parts[0]),
Args: parts[1:],
RawArgs: strings.TrimLeft(strings.TrimPrefix(text, parts[0]), " "),
Ctx: ctx,
}
}
type ReplyOpts struct {
AllowHTML bool
AllowMarkdown bool
Reply bool
Thread bool
SendAsText bool
}
func (evt *Event[MetaType]) Reply(msg string, args ...any) {
if len(args) > 0 {
msg = fmt.Sprintf(msg, args...)
}
evt.Respond(msg, ReplyOpts{AllowMarkdown: true, Reply: true})
}
func (evt *Event[MetaType]) Respond(msg string, opts ReplyOpts) {
content := format.RenderMarkdown(msg, opts.AllowMarkdown, opts.AllowHTML)
if opts.Thread {
content.SetThread(evt.Event)
}
if opts.Reply {
content.SetReply(evt.Event)
}
if !opts.SendAsText {
content.MsgType = event.MsgNotice
}
_, err := evt.Proc.Client.SendMessageEvent(evt.Ctx, evt.RoomID, event.EventMessage, content)
if err != nil {
zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send reply")
}
}
func (evt *Event[MetaType]) React(emoji string) {
_, err := evt.Proc.Client.SendReaction(evt.Ctx, evt.RoomID, evt.ID, emoji)
if err != nil {
zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send reaction")
}
}
func (evt *Event[MetaType]) Redact() {
_, err := evt.Proc.Client.RedactEvent(evt.Ctx, evt.RoomID, evt.ID)
if err != nil {
zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to redact command")
}
}
func (evt *Event[MetaType]) MarkRead() {
err := evt.Proc.Client.MarkRead(evt.Ctx, evt.RoomID, evt.ID)
if err != nil {
zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send read receipt")
}
}

86
commands/prevalidate.go Normal file
View file

@ -0,0 +1,86 @@
// Copyright (c) 2025 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package commands
import (
"strings"
)
// A PreValidator contains a function that takes an Event and returns true if the event should be processed further.
//
// The [PreValidator] field in [Processor] is called before the handler of the command is checked.
// It can be used to modify the command or arguments, or to skip the command entirely.
//
// The primary use case is removing a static command prefix, such as requiring all commands start with `!`.
type PreValidator[MetaType any] interface {
Validate(*Event[MetaType]) bool
}
// FuncPreValidator is a simple function that implements the PreValidator interface.
type FuncPreValidator[MetaType any] func(*Event[MetaType]) bool
func (f FuncPreValidator[MetaType]) Validate(ce *Event[MetaType]) bool {
return f(ce)
}
// AllPreValidator can be used to combine multiple PreValidators, such that
// all of them must return true for the command to be processed further.
type AllPreValidator[MetaType any] []PreValidator[MetaType]
func (f AllPreValidator[MetaType]) Validate(ce *Event[MetaType]) bool {
for _, validator := range f {
if !validator.Validate(ce) {
return false
}
}
return true
}
// AnyPreValidator can be used to combine multiple PreValidators, such that
// at least one of them must return true for the command to be processed further.
type AnyPreValidator[MetaType any] []PreValidator[MetaType]
func (f AnyPreValidator[MetaType]) Validate(ce *Event[MetaType]) bool {
for _, validator := range f {
if validator.Validate(ce) {
return true
}
}
return false
}
// ValidatePrefixCommand checks that the first word in the input is exactly the given string,
// and if so, removes it from the command and sets the command to the next word.
//
// For example, `ValidateCommandPrefix("!mybot")` would only allow commands in the form `!mybot foo`,
// where `foo` would be used to look up the command handler.
func ValidatePrefixCommand[MetaType any](prefix string) PreValidator[MetaType] {
return FuncPreValidator[MetaType](func(ce *Event[MetaType]) bool {
if ce.Command == prefix && len(ce.Args) > 0 {
ce.Command = strings.ToLower(ce.Args[0])
ce.RawArgs = strings.TrimLeft(strings.TrimPrefix(ce.RawArgs, ce.Args[0]), " ")
ce.Args = ce.Args[1:]
return true
}
return false
})
}
// ValidatePrefixSubstring checks that the command starts with the given prefix,
// and if so, removes it from the command.
//
// For example, `ValidatePrefixSubstring("!")` would only allow commands in the form `!foo`,
// where `foo` would be used to look up the command handler.
func ValidatePrefixSubstring[MetaType any](prefix string) PreValidator[MetaType] {
return FuncPreValidator[MetaType](func(ce *Event[MetaType]) bool {
if strings.HasPrefix(ce.Command, prefix) {
ce.Command = ce.Command[len(prefix):]
return true
}
return false
})
}

156
commands/processor.go Normal file
View file

@ -0,0 +1,156 @@
// Copyright (c) 2025 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package commands
import (
"context"
"fmt"
"runtime/debug"
"strings"
"sync"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
)
// Processor implements boilerplate code for splitting messages into a command and arguments,
// and finding the appropriate handler for the command.
type Processor[MetaType any] struct {
Client *mautrix.Client
LogArgs bool
PreValidator PreValidator[MetaType]
Meta MetaType
commands map[string]*Handler[MetaType]
aliases map[string]string
lock sync.RWMutex
}
type Handler[MetaType any] struct {
Func func(ce *Event[MetaType])
// Name is the primary name of the command. It must be lowercase.
Name string
// Aliases are alternative names for the command. They must be lowercase.
Aliases []string
}
// UnknownCommandName is the name of the fallback handler which is used if no other handler is found.
// If even the unknown command handler is not found, the command is ignored.
const UnknownCommandName = "unknown-command"
func NewProcessor[MetaType any](cli *mautrix.Client) *Processor[MetaType] {
proc := &Processor[MetaType]{
Client: cli,
PreValidator: ValidatePrefixSubstring[MetaType]("!"),
commands: make(map[string]*Handler[MetaType]),
aliases: make(map[string]string),
}
proc.Register(&Handler[MetaType]{
Name: UnknownCommandName,
Func: func(ce *Event[MetaType]) {
ce.Reply("Unknown command")
},
})
return proc
}
// Register registers the given command handlers.
func (proc *Processor[MetaType]) Register(handlers ...*Handler[MetaType]) {
proc.lock.Lock()
defer proc.lock.Unlock()
for _, handler := range handlers {
proc.registerOne(handler)
}
}
func (proc *Processor[MetaType]) registerOne(handler *Handler[MetaType]) {
if strings.ToLower(handler.Name) != handler.Name {
panic(fmt.Errorf("command %q is not lowercase", handler.Name))
}
proc.commands[handler.Name] = handler
for _, alias := range handler.Aliases {
if strings.ToLower(alias) != alias {
panic(fmt.Errorf("alias %q is not lowercase", alias))
}
proc.aliases[alias] = handler.Name
}
}
func (proc *Processor[MetaType]) Unregister(handlers ...*Handler[MetaType]) {
proc.lock.Lock()
defer proc.lock.Unlock()
for _, handler := range handlers {
proc.unregisterOne(handler)
}
}
func (proc *Processor[MetaType]) unregisterOne(handler *Handler[MetaType]) {
delete(proc.commands, handler.Name)
for _, alias := range handler.Aliases {
if proc.aliases[alias] == handler.Name {
delete(proc.aliases, alias)
}
}
}
func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) {
log := *zerolog.Ctx(ctx)
defer func() {
panicErr := recover()
if panicErr != nil {
logEvt := log.Error().
Bytes(zerolog.ErrorStackFieldName, debug.Stack())
if realErr, ok := panicErr.(error); ok {
logEvt = logEvt.Err(realErr)
} else {
logEvt = logEvt.Any(zerolog.ErrorFieldName, panicErr)
}
logEvt.Msg("Panic in command handler")
_, err := proc.Client.SendReaction(ctx, evt.RoomID, evt.ID, "💥")
if err != nil {
log.Err(err).Msg("Failed to send reaction after panic")
}
}
}()
parsed := ParseEvent[MetaType](ctx, evt)
if !proc.PreValidator.Validate(parsed) {
return
}
realCommand := parsed.Command
proc.lock.RLock()
alias, ok := proc.aliases[realCommand]
if ok {
realCommand = alias
}
handler, ok := proc.commands[realCommand]
if !ok {
handler, ok = proc.commands[UnknownCommandName]
}
proc.lock.RUnlock()
if !ok {
return
}
logWith := log.With().
Str("command", realCommand).
Stringer("sender", evt.Sender).
Stringer("room_id", evt.RoomID)
if proc.LogArgs {
logWith = logWith.Strs("args", parsed.Args)
}
log = logWith.Logger()
parsed.Ctx = log.WithContext(ctx)
parsed.Handler = handler
parsed.Proc = proc
parsed.Meta = proc.Meta
log.Debug().Msg("Processing command")
handler.Func(parsed)
}