diff --git a/commands/event.go b/commands/event.go new file mode 100644 index 00000000..baf9ecda --- /dev/null +++ b/commands/event.go @@ -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") + } +} diff --git a/commands/prevalidate.go b/commands/prevalidate.go new file mode 100644 index 00000000..95bbcc97 --- /dev/null +++ b/commands/prevalidate.go @@ -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 + }) +} diff --git a/commands/processor.go b/commands/processor.go new file mode 100644 index 00000000..d4a29690 --- /dev/null +++ b/commands/processor.go @@ -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) +}