mirror of
https://mau.dev/mautrix/go.git
synced 2026-03-14 14:25:53 +01:00
commands: add generic command processing framework for bots
This commit is contained in:
parent
7c1b0c5968
commit
56e2adbf83
3 changed files with 366 additions and 0 deletions
124
commands/event.go
Normal file
124
commands/event.go
Normal 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
86
commands/prevalidate.go
Normal 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
156
commands/processor.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue