diff --git a/commands/event.go b/commands/event.go index 96d5e921..65ddd3da 100644 --- a/commands/event.go +++ b/commands/event.go @@ -40,6 +40,8 @@ type Event[MetaType any] struct { Proc *Processor[MetaType] Handler *Handler[MetaType] Meta MetaType + + redactedBy id.EventID } var IDHTMLParser = &format.HTMLParser{ @@ -71,7 +73,14 @@ func ParseEvent[MetaType any](ctx context.Context, evt *event.Event) *Event[Meta if len(text) == 0 { return nil } + return RawTextToEvent[MetaType](ctx, evt, text) +} + +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, @@ -91,6 +100,7 @@ type ReplyOpts struct { SendAsText bool Edit id.EventID OverrideMentions *event.Mentions + Extra map[string]any } func (evt *Event[MetaType]) Reply(msg string, args ...any) id.EventID { @@ -117,7 +127,14 @@ func (evt *Event[MetaType]) Respond(msg string, opts ReplyOpts) id.EventID { if opts.OverrideMentions != nil { content.Mentions = opts.OverrideMentions } - resp, err := evt.Proc.Client.SendMessageEvent(evt.Ctx, evt.RoomID, event.EventMessage, content) + 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 "" @@ -135,11 +152,15 @@ func (evt *Event[MetaType]) React(emoji string) id.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 } diff --git a/commands/processor.go b/commands/processor.go index c4940526..9341329b 100644 --- a/commands/processor.go +++ b/commands/processor.go @@ -26,6 +26,8 @@ type Processor[MetaType any] struct { LogArgs bool PreValidator PreValidator[MetaType] Meta MetaType + + ReactionCommandPrefix string } // UnknownCommandName is the name of the fallback handler which is used if no other handler is found. @@ -65,7 +67,13 @@ func (proc *Processor[MetaType]) Process(ctx context.Context, evt *event.Event) } } }() - parsed := ParseEvent[MetaType](ctx, evt) + var parsed *Event[MetaType] + switch evt.Type { + case event.EventReaction: + parsed = proc.ParseReaction(ctx, evt) + case event.EventMessage: + parsed = ParseEvent[MetaType](ctx, evt) + } if parsed == nil || !proc.PreValidator.Validate(parsed) { return } diff --git a/commands/reactions.go b/commands/reactions.go new file mode 100644 index 00000000..0df372e5 --- /dev/null +++ b/commands/reactions.go @@ -0,0 +1,125 @@ +// 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" + "strings" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" +) + +const ReactionCommandsKey = "fi.mau.reaction_commands" +const ReactionMultiUseKey = "fi.mau.reaction_multi_use" + +func (proc *Processor[MetaType]) ParseReaction(ctx context.Context, evt *event.Event) *Event[MetaType] { + content, ok := evt.Content.Parsed.(*event.ReactionEventContent) + if !ok { + return nil + } + evtID := content.RelatesTo.EventID + if evtID == "" || !strings.HasPrefix(content.RelatesTo.Key, proc.ReactionCommandPrefix) { + return nil + } + targetEvt, err := proc.Client.GetEvent(ctx, evt.RoomID, evtID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("target_event_id", evtID).Msg("Failed to get target event for reaction") + return nil + } else if targetEvt.Sender != proc.Client.UserID || targetEvt.Unsigned.RedactedBecause != nil { + return nil + } + if targetEvt.Type == event.EventEncrypted { + if proc.Client.Crypto == nil { + zerolog.Ctx(ctx).Warn(). + Stringer("target_event_id", evtID). + Msg("Received reaction to encrypted event, but don't have crypto helper in client") + return nil + } + _ = targetEvt.Content.ParseRaw(targetEvt.Type) + targetEvt, err = proc.Client.Crypto.Decrypt(ctx, targetEvt) + if err != nil { + zerolog.Ctx(ctx).Err(err). + Stringer("target_event_id", evtID). + Msg("Failed to decrypt target event for reaction") + return nil + } + } + reactionCommands, ok := targetEvt.Content.Raw[ReactionCommandsKey].(map[string]any) + if !ok { + zerolog.Ctx(ctx).Trace(). + Stringer("target_event_id", evtID). + Msg("Reaction target event doesn't have commands key") + return nil + } + isMultiUse, _ := targetEvt.Content.Raw[ReactionMultiUseKey].(bool) + rawCmd, ok := reactionCommands[content.RelatesTo.Key] + if !ok { + zerolog.Ctx(ctx).Debug(). + Stringer("target_event_id", evtID). + Str("reaction_key", content.RelatesTo.Key). + Msg("Reaction command not found in target event") + return nil + } + cmdString, ok := rawCmd.(string) + if !ok { + 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 == "" { + return nil + } + return wrappedEvt +} + +func DeleteAllReactionsCommandFunc[MetaType any](ce *Event[MetaType]) { + DeleteAllReactions(ce.Ctx, ce.Proc.Client, ce.Event) +} + +func DeleteAllReactions(ctx context.Context, client *mautrix.Client, evt *event.Event) { + rel, ok := evt.Content.Parsed.(event.Relatable) + if !ok { + return + } + relation := rel.OptionalGetRelatesTo() + if relation == nil { + return + } + targetEvt := relation.GetReplyTo() + if targetEvt == "" { + targetEvt = relation.GetAnnotationID() + } + if targetEvt == "" { + return + } + relations, err := client.GetRelations(ctx, evt.RoomID, targetEvt, &mautrix.ReqGetRelations{ + RelationType: event.RelAnnotation, + EventType: event.EventReaction, + Limit: 20, + }) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get reactions to delete") + return + } + for _, relEvt := range relations.Chunk { + _, err = client.RedactEvent(ctx, relEvt.RoomID, relEvt.ID) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("event_id", relEvt.ID).Msg("Failed to redact reaction event") + } + } +}