mirror of
https://mau.dev/mautrix/go.git
synced 2026-03-14 14:25:53 +01:00
commands: add MSC4391 support
This commit is contained in:
parent
5ac73563b0
commit
d63a008ec6
5 changed files with 183 additions and 13 deletions
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright (c) 2025 Tulir Asokan
|
||||
// Copyright (c) 2026 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
|
||||
|
|
@ -8,14 +8,20 @@ package commands
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go.mau.fi/util/exmaps"
|
||||
|
||||
"maunium.net/go/mautrix/event/cmdschema"
|
||||
)
|
||||
|
||||
type CommandContainer[MetaType any] struct {
|
||||
commands map[string]*Handler[MetaType]
|
||||
aliases map[string]string
|
||||
lock sync.RWMutex
|
||||
parent *Handler[MetaType]
|
||||
}
|
||||
|
||||
func NewCommandContainer[MetaType any]() *CommandContainer[MetaType] {
|
||||
|
|
@ -25,6 +31,29 @@ func NewCommandContainer[MetaType any]() *CommandContainer[MetaType] {
|
|||
}
|
||||
}
|
||||
|
||||
func (cont *CommandContainer[MetaType]) AllSpecs() []*cmdschema.EventContent {
|
||||
data := make(exmaps.Set[*Handler[MetaType]])
|
||||
cont.collectHandlers(data)
|
||||
specs := make([]*cmdschema.EventContent, 0, data.Size())
|
||||
for handler := range data.Iter() {
|
||||
if handler.Parameters != nil {
|
||||
specs = append(specs, handler.Spec())
|
||||
}
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
func (cont *CommandContainer[MetaType]) collectHandlers(into exmaps.Set[*Handler[MetaType]]) {
|
||||
cont.lock.RLock()
|
||||
defer cont.lock.RUnlock()
|
||||
for _, handler := range cont.commands {
|
||||
into.Add(handler)
|
||||
if handler.subcommandContainer != nil {
|
||||
handler.subcommandContainer.collectHandlers(into)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register registers the given command handlers.
|
||||
func (cont *CommandContainer[MetaType]) Register(handlers ...*Handler[MetaType]) {
|
||||
if cont == nil {
|
||||
|
|
@ -32,7 +61,10 @@ func (cont *CommandContainer[MetaType]) Register(handlers ...*Handler[MetaType])
|
|||
}
|
||||
cont.lock.Lock()
|
||||
defer cont.lock.Unlock()
|
||||
for _, handler := range handlers {
|
||||
for i, handler := range handlers {
|
||||
if handler == nil {
|
||||
panic(fmt.Errorf("handler #%d is nil", i+1))
|
||||
}
|
||||
cont.registerOne(handler)
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +77,10 @@ func (cont *CommandContainer[MetaType]) registerOne(handler *Handler[MetaType])
|
|||
} else if aliasTarget, alreadyExists := cont.aliases[handler.Name]; alreadyExists {
|
||||
panic(fmt.Errorf("tried to register command %q, but it's already registered as an alias for %q", handler.Name, aliasTarget))
|
||||
}
|
||||
if !slices.Contains(handler.parents, cont.parent) {
|
||||
handler.parents = append(handler.parents, cont.parent)
|
||||
handler.nestedNameCache = nil
|
||||
}
|
||||
cont.commands[handler.Name] = handler
|
||||
for _, alias := range handler.Aliases {
|
||||
if strings.ToLower(alias) != alias {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright (c) 2025 Tulir Asokan
|
||||
// Copyright (c) 2026 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
|
||||
|
|
@ -8,6 +8,7 @@ package commands
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
|
|
@ -35,6 +36,8 @@ type Event[MetaType any] struct {
|
|||
// RawArgs is the same as args, but without the splitting by whitespace.
|
||||
RawArgs string
|
||||
|
||||
StructuredArgs json.RawMessage
|
||||
|
||||
Ctx context.Context
|
||||
Log *zerolog.Logger
|
||||
Proc *Processor[MetaType]
|
||||
|
|
@ -61,7 +64,7 @@ var IDHTMLParser = &format.HTMLParser{
|
|||
}
|
||||
|
||||
// ParseEvent parses a message into a command event struct.
|
||||
func ParseEvent[MetaType any](ctx context.Context, evt *event.Event) *Event[MetaType] {
|
||||
func (proc *Processor[MetaType]) ParseEvent(ctx context.Context, evt *event.Event) *Event[MetaType] {
|
||||
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
|
||||
if !ok || content.MsgType == event.MsgNotice || content.RelatesTo.GetReplaceID() != "" {
|
||||
return nil
|
||||
|
|
@ -70,12 +73,34 @@ func ParseEvent[MetaType any](ctx context.Context, evt *event.Event) *Event[Meta
|
|||
if content.Format == event.FormatHTML {
|
||||
text = IDHTMLParser.Parse(content.FormattedBody, format.NewContext(ctx))
|
||||
}
|
||||
if content.MSC4391BotCommand != nil {
|
||||
if !content.Mentions.Has(proc.Client.UserID) || len(content.Mentions.UserIDs) != 1 {
|
||||
return nil
|
||||
}
|
||||
wrapped := StructuredCommandToEvent[MetaType](ctx, evt, content.MSC4391BotCommand)
|
||||
wrapped.RawInput = text
|
||||
return wrapped
|
||||
}
|
||||
if len(text) == 0 {
|
||||
return nil
|
||||
}
|
||||
return RawTextToEvent[MetaType](ctx, evt, text)
|
||||
}
|
||||
|
||||
func StructuredCommandToEvent[MetaType any](ctx context.Context, evt *event.Event, content *event.MSC4391BotCommandInput) *Event[MetaType] {
|
||||
commandParts := strings.Split(content.Command, " ")
|
||||
return &Event[MetaType]{
|
||||
Event: evt,
|
||||
// Fake a command and args to let the subcommand finder in Process work.
|
||||
Command: commandParts[0],
|
||||
Args: commandParts[1:],
|
||||
Ctx: ctx,
|
||||
Log: zerolog.Ctx(ctx),
|
||||
|
||||
StructuredArgs: content.Arguments,
|
||||
}
|
||||
}
|
||||
|
||||
func RawTextToEvent[MetaType any](ctx context.Context, evt *event.Event, text string) *Event[MetaType] {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) == 0 {
|
||||
|
|
@ -188,3 +213,25 @@ func (evt *Event[MetaType]) UnshiftArg(arg string) {
|
|||
evt.RawArgs = arg + " " + evt.RawArgs
|
||||
evt.Args = append([]string{arg}, evt.Args...)
|
||||
}
|
||||
|
||||
func (evt *Event[MetaType]) ParseArgs(into any) error {
|
||||
return json.Unmarshal(evt.StructuredArgs, into)
|
||||
}
|
||||
|
||||
func ParseArgs[T, MetaType any](evt *Event[MetaType]) (into T, err error) {
|
||||
err = evt.ParseArgs(&into)
|
||||
return
|
||||
}
|
||||
|
||||
func WithParsedArgs[T, MetaType any](fn func(*Event[MetaType], T)) func(*Event[MetaType]) {
|
||||
return func(evt *Event[MetaType]) {
|
||||
parsed, err := ParseArgs[T, MetaType](evt)
|
||||
if err != nil {
|
||||
evt.Log.Debug().Err(err).Msg("Failed to parse structured args into struct")
|
||||
// TODO better error, usage info? deduplicate with Process
|
||||
evt.Reply("Failed to parse arguments: %v", err)
|
||||
return
|
||||
}
|
||||
fn(evt, parsed)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright (c) 2025 Tulir Asokan
|
||||
// Copyright (c) 2026 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
|
||||
|
|
@ -8,6 +8,9 @@ package commands
|
|||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/event/cmdschema"
|
||||
)
|
||||
|
||||
type Handler[MetaType any] struct {
|
||||
|
|
@ -25,12 +28,53 @@ type Handler[MetaType any] struct {
|
|||
// Event.ShiftArg will likely be useful for implementing such parameters.
|
||||
PreFunc func(ce *Event[MetaType])
|
||||
|
||||
// Description is a short description of the command.
|
||||
Description *event.ExtensibleTextContainer
|
||||
// Parameters is a description of structured command parameters.
|
||||
// If set, the StructuredArgs field of Event will be populated.
|
||||
Parameters []*cmdschema.Parameter
|
||||
|
||||
parents []*Handler[MetaType]
|
||||
nestedNameCache []string
|
||||
subcommandContainer *CommandContainer[MetaType]
|
||||
}
|
||||
|
||||
func (h *Handler[MetaType]) NestedNames() []string {
|
||||
if h.nestedNameCache != nil {
|
||||
return h.nestedNameCache
|
||||
}
|
||||
nestedNames := make([]string, 0, (1+len(h.Aliases))*len(h.parents))
|
||||
for _, parent := range h.parents {
|
||||
if parent == nil {
|
||||
nestedNames = append(nestedNames, h.Name)
|
||||
nestedNames = append(nestedNames, h.Aliases...)
|
||||
} else {
|
||||
for _, parentName := range parent.NestedNames() {
|
||||
nestedNames = append(nestedNames, parentName+" "+h.Name)
|
||||
for _, alias := range h.Aliases {
|
||||
nestedNames = append(nestedNames, parentName+" "+alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
h.nestedNameCache = nestedNames
|
||||
return nestedNames
|
||||
}
|
||||
|
||||
func (h *Handler[MetaType]) Spec() *cmdschema.EventContent {
|
||||
names := h.NestedNames()
|
||||
return &cmdschema.EventContent{
|
||||
Command: names[0],
|
||||
Aliases: names[1:],
|
||||
Parameters: h.Parameters,
|
||||
Description: h.Description,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler[MetaType]) initSubcommandContainer() {
|
||||
if len(h.Subcommands) > 0 {
|
||||
h.subcommandContainer = NewCommandContainer[MetaType]()
|
||||
h.subcommandContainer.parent = h
|
||||
h.subcommandContainer.Register(h.Subcommands...)
|
||||
} else {
|
||||
h.subcommandContainer = nil
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright (c) 2025 Tulir Asokan
|
||||
// Copyright (c) 2026 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
|
||||
|
|
@ -72,9 +72,9 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event)
|
|||
case event.EventReaction:
|
||||
parsed = proc.ParseReaction(ctx, evt)
|
||||
case event.EventMessage:
|
||||
parsed = ParseEvent[MetaType](ctx, evt)
|
||||
parsed = proc.ParseEvent(ctx, evt)
|
||||
}
|
||||
if parsed == nil || !proc.PreValidator.Validate(parsed) {
|
||||
if parsed == nil || (!proc.PreValidator.Validate(parsed) && parsed.StructuredArgs == nil) {
|
||||
return
|
||||
}
|
||||
parsed.Proc = proc
|
||||
|
|
@ -107,6 +107,11 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event)
|
|||
break
|
||||
}
|
||||
}
|
||||
if parsed.StructuredArgs != nil && len(parsed.Args) > 0 {
|
||||
// The client sent MSC4391 data, but the target command wasn't found
|
||||
log.Debug().Msg("Didn't find handler for MSC4391 command")
|
||||
return
|
||||
}
|
||||
|
||||
logWith := log.With().
|
||||
Str("command", parsed.Command).
|
||||
|
|
@ -116,11 +121,31 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event)
|
|||
}
|
||||
if proc.LogArgs {
|
||||
logWith = logWith.Strs("args", parsed.Args)
|
||||
if parsed.StructuredArgs != nil {
|
||||
logWith = logWith.RawJSON("structured_args", parsed.StructuredArgs)
|
||||
}
|
||||
}
|
||||
log = logWith.Logger()
|
||||
parsed.Ctx = log.WithContext(ctx)
|
||||
parsed.Log = &log
|
||||
|
||||
if handler.Parameters != nil && parsed.StructuredArgs == nil {
|
||||
// The handler wants structured parameters, but the client didn't send MSC4391 data
|
||||
var err error
|
||||
parsed.StructuredArgs, err = handler.Spec().ParseArguments(parsed.RawArgs)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to parse structured arguments")
|
||||
// TODO better error, usage info? deduplicate with WithParsedArgs
|
||||
parsed.Reply("Failed to parse arguments: %v", err)
|
||||
return
|
||||
}
|
||||
if proc.LogArgs {
|
||||
log.UpdateContext(func(c zerolog.Context) zerolog.Context {
|
||||
return c.RawJSON("structured_args", parsed.StructuredArgs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug().Msg("Processing command")
|
||||
handler.Func(parsed)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright (c) 2025 Tulir Asokan
|
||||
// Copyright (c) 2026 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
|
||||
|
|
@ -8,6 +8,7 @@ package commands
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
|
@ -19,6 +20,11 @@ import (
|
|||
const ReactionCommandsKey = "fi.mau.reaction_commands"
|
||||
const ReactionMultiUseKey = "fi.mau.reaction_multi_use"
|
||||
|
||||
type ReactionCommandData struct {
|
||||
Command string `json:"command"`
|
||||
Args any `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
func (proc *Processor[MetaType]) ParseReaction(ctx context.Context, evt *event.Event) *Event[MetaType] {
|
||||
content, ok := evt.Content.Parsed.(*event.ReactionEventContent)
|
||||
if !ok {
|
||||
|
|
@ -67,21 +73,33 @@ func (proc *Processor[MetaType]) ParseReaction(ctx context.Context, evt *event.E
|
|||
Msg("Reaction command not found in target event")
|
||||
return nil
|
||||
}
|
||||
cmdString, ok := rawCmd.(string)
|
||||
if !ok {
|
||||
var wrappedEvt *Event[MetaType]
|
||||
switch typedCmd := rawCmd.(type) {
|
||||
case string:
|
||||
wrappedEvt = RawTextToEvent[MetaType](ctx, evt, typedCmd)
|
||||
case map[string]any:
|
||||
var input event.MSC4391BotCommandInput
|
||||
if marshaled, err := json.Marshal(typedCmd); err != nil {
|
||||
|
||||
} else if err = json.Unmarshal(marshaled, &input); err != nil {
|
||||
|
||||
} else {
|
||||
wrappedEvt = StructuredCommandToEvent[MetaType](ctx, evt, &input)
|
||||
}
|
||||
}
|
||||
if wrappedEvt == nil {
|
||||
zerolog.Ctx(ctx).Debug().
|
||||
Stringer("target_event_id", evtID).
|
||||
Str("reaction_key", content.RelatesTo.Key).
|
||||
Msg("Reaction command data is invalid")
|
||||
return nil
|
||||
}
|
||||
wrappedEvt := RawTextToEvent[MetaType](ctx, evt, cmdString)
|
||||
wrappedEvt.Proc = proc
|
||||
wrappedEvt.Redact()
|
||||
if !isMultiUse {
|
||||
DeleteAllReactions(ctx, proc.Client, evt)
|
||||
}
|
||||
if cmdString == "" {
|
||||
if wrappedEvt.Command == "" {
|
||||
return nil
|
||||
}
|
||||
return wrappedEvt
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue