mirror of
https://mau.dev/mautrix/go.git
synced 2026-03-14 14:25:53 +01:00
237 lines
6.6 KiB
Go
237 lines
6.6 KiB
Go
// 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
|
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
|
|
package commands
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/rs/zerolog"
|
|
|
|
"maunium.net/go/mautrix/event"
|
|
"maunium.net/go/mautrix/format"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// 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
|
|
// ParentCommands is the chain of commands leading up to this command.
|
|
// This is only set if the command is a subcommand.
|
|
ParentCommands []string
|
|
ParentHandlers []*Handler[MetaType]
|
|
// 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
|
|
|
|
StructuredArgs json.RawMessage
|
|
|
|
Ctx context.Context
|
|
Log *zerolog.Logger
|
|
Proc *Processor[MetaType]
|
|
Handler *Handler[MetaType]
|
|
Meta MetaType
|
|
|
|
redactedBy id.EventID
|
|
}
|
|
|
|
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 (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
|
|
}
|
|
text := content.Body
|
|
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 {
|
|
parts = []string{""}
|
|
}
|
|
return &Event[MetaType]{
|
|
Event: evt,
|
|
RawInput: text,
|
|
Command: strings.ToLower(parts[0]),
|
|
Args: parts[1:],
|
|
RawArgs: strings.TrimLeft(strings.TrimPrefix(text, parts[0]), " "),
|
|
Log: zerolog.Ctx(ctx),
|
|
Ctx: ctx,
|
|
}
|
|
}
|
|
|
|
type ReplyOpts struct {
|
|
AllowHTML bool
|
|
AllowMarkdown bool
|
|
Reply bool
|
|
Thread bool
|
|
SendAsText bool
|
|
Edit id.EventID
|
|
OverrideMentions *event.Mentions
|
|
Extra map[string]any
|
|
}
|
|
|
|
func (evt *Event[MetaType]) Reply(msg string, args ...any) id.EventID {
|
|
if len(args) > 0 {
|
|
msg = fmt.Sprintf(msg, args...)
|
|
}
|
|
return evt.Respond(msg, ReplyOpts{AllowMarkdown: true, Reply: true})
|
|
}
|
|
|
|
func (evt *Event[MetaType]) Respond(msg string, opts ReplyOpts) id.EventID {
|
|
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
|
|
}
|
|
if opts.Edit != "" {
|
|
content.SetEdit(opts.Edit)
|
|
}
|
|
if opts.OverrideMentions != nil {
|
|
content.Mentions = opts.OverrideMentions
|
|
}
|
|
var wrapped any = &content
|
|
if opts.Extra != nil {
|
|
wrapped = &event.Content{
|
|
Parsed: &content,
|
|
Raw: opts.Extra,
|
|
}
|
|
}
|
|
resp, err := evt.Proc.Client.SendMessageEvent(evt.Ctx, evt.RoomID, event.EventMessage, wrapped)
|
|
if err != nil {
|
|
zerolog.Ctx(evt.Ctx).Err(err).Msg("Failed to send reply")
|
|
return ""
|
|
}
|
|
return resp.EventID
|
|
}
|
|
|
|
func (evt *Event[MetaType]) React(emoji string) id.EventID {
|
|
resp, 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")
|
|
return ""
|
|
}
|
|
return resp.EventID
|
|
}
|
|
|
|
func (evt *Event[MetaType]) Redact() id.EventID {
|
|
if evt.redactedBy != "" {
|
|
return evt.redactedBy
|
|
}
|
|
resp, 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")
|
|
return ""
|
|
}
|
|
evt.redactedBy = resp.EventID
|
|
return resp.EventID
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// ShiftArg removes the first argument from the Args list and RawArgs data and returns it.
|
|
// RawInput will not be modified.
|
|
func (evt *Event[MetaType]) ShiftArg() string {
|
|
if len(evt.Args) == 0 {
|
|
return ""
|
|
}
|
|
firstArg := evt.Args[0]
|
|
evt.RawArgs = strings.TrimLeft(strings.TrimPrefix(evt.RawArgs, evt.Args[0]), " ")
|
|
evt.Args = evt.Args[1:]
|
|
return firstArg
|
|
}
|
|
|
|
// UnshiftArg reverses ShiftArg by adding the given value to the beginning of the Args list and RawArgs data.
|
|
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)
|
|
}
|
|
}
|