Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions commands/container.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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] {
Expand All @@ -25,14 +31,40 @@ 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 {
return
}
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)
}
}
Expand All @@ -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 {
Expand Down
51 changes: 49 additions & 2 deletions commands/event.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -8,6 +8,7 @@ package commands

import (
"context"
"encoding/json"
"fmt"
"strings"

Expand Down Expand Up @@ -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]
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}
46 changes: 45 additions & 1 deletion commands/handler.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -8,6 +8,9 @@ package commands

import (
"strings"

"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/event/cmdschema"
)

type Handler[MetaType any] struct {
Expand All @@ -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
Expand Down
31 changes: 28 additions & 3 deletions commands/processor.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand All @@ -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)
}
28 changes: 23 additions & 5 deletions commands/reactions.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -8,6 +8,7 @@ package commands

import (
"context"
"encoding/json"
"strings"

"github.com/rs/zerolog"
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading