diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index 73c11658..42d33fdb 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -8,6 +8,7 @@ package matrix import ( "context" + "errors" "fmt" "time" @@ -19,6 +20,7 @@ import ( "maunium.net/go/mautrix/crypto/attachment" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules" ) // ASIntent implements the bridge ghost API interface using a real Matrix homeserver as the backend. @@ -249,3 +251,31 @@ func (as *ASIntent) DeleteRoom(ctx context.Context, roomID id.RoomID, puppetsOnl } return nil } + +func (as *ASIntent) TagRoom(ctx context.Context, roomID id.RoomID, tag event.RoomTag, isTagged bool) error { + if isTagged { + return as.Matrix.AddTagWithCustomData(ctx, roomID, tag, &event.TagMetadata{ + MauDoublePuppetSource: as.Connector.AS.DoublePuppetValue, + }) + } else { + if tag == "" { + // TODO clear all tags? + } + return as.Matrix.RemoveTag(ctx, roomID, tag) + } +} + +func (as *ASIntent) MuteRoom(ctx context.Context, roomID id.RoomID, until time.Time) error { + if !until.IsZero() && until.Before(time.Now()) { + err := as.Matrix.DeletePushRule(ctx, "global", pushrules.RoomRule, string(roomID)) + // If the push rule doesn't exist, everything is fine + if errors.Is(err, mautrix.MNotFound) { + err = nil + } + return err + } else { + return as.Matrix.PutPushRule(ctx, "global", pushrules.RoomRule, string(roomID), &mautrix.ReqPutPushRule{ + Actions: []pushrules.PushActionType{pushrules.ActionDontNotify}, + }) + } +} diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index c98404cf..5e78cc67 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -58,4 +58,7 @@ type MatrixAPI interface { DeleteRoom(ctx context.Context, roomID id.RoomID, puppetsOnly bool) error InviteUser(ctx context.Context, roomID id.RoomID, userID id.UserID) error EnsureJoined(ctx context.Context, roomID id.RoomID) error + + TagRoom(ctx context.Context, roomID id.RoomID, tag event.RoomTag, isTagged bool) error + MuteRoom(ctx context.Context, roomID id.RoomID, until time.Time) error } diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 06c8196e..c9aae04b 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -287,6 +287,8 @@ const ( RemoteEventReadReceipt RemoteEventDeliveryReceipt RemoteEventTyping + RemoteEventChatTag + RemoteEventChatMute ) // RemoteEvent represents a single event from the remote network, such as a message or a reaction. @@ -374,6 +376,18 @@ type RemoteTypingWithType interface { GetTypingType() TypingType } +type RemoteChatTag interface { + RemoteEvent + GetTag() (tag event.RoomTag, remove bool) +} + +var Unmuted = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + +type RemoteChatMute interface { + RemoteEvent + GetMutedUntil() time.Time +} + // SimpleRemoteEvent is a simple implementation of RemoteEvent that can be used with struct fields and some callbacks. type SimpleRemoteEvent[T any] struct { Type RemoteEventType diff --git a/bridgev2/portal.go b/bridgev2/portal.go index b259095a..b771a801 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -804,6 +804,10 @@ func (portal *Portal) handleRemoteEvent(source *UserLogin, evt RemoteEvent) { portal.handleRemoteDeliveryReceipt(ctx, source, evt.(RemoteReceipt)) case RemoteEventTyping: portal.handleRemoteTyping(ctx, source, evt.(RemoteTyping)) + case RemoteEventChatTag: + portal.handleRemoteChatTag(ctx, source, evt.(RemoteChatTag)) + case RemoteEventChatMute: + portal.handleRemoteChatMute(ctx, source, evt.(RemoteChatMute)) default: log.Warn().Int("type", int(evt.GetType())).Msg("Got remote event with unknown type") } @@ -1224,6 +1228,37 @@ func (portal *Portal) handleRemoteTyping(ctx context.Context, source *UserLogin, } } +func (portal *Portal) handleRemoteChatTag(ctx context.Context, source *UserLogin, evt RemoteChatTag) { + if !evt.GetSender().IsFromMe { + zerolog.Ctx(ctx).Warn().Msg("Ignoring chat tag event from non-self user") + return + } + dp := source.User.DoublePuppet(ctx) + if dp == nil { + return + } + tag, isTagged := evt.GetTag() + err := dp.TagRoom(ctx, portal.MXID, tag, isTagged) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to bridge chat tag event") + } +} + +func (portal *Portal) handleRemoteChatMute(ctx context.Context, source *UserLogin, evt RemoteChatMute) { + if !evt.GetSender().IsFromMe { + zerolog.Ctx(ctx).Warn().Msg("Ignoring chat mute event from non-self user") + return + } + dp := source.User.DoublePuppet(ctx) + if dp == nil { + return + } + err := dp.MuteRoom(ctx, portal.MXID, evt.GetMutedUntil()) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to bridge chat mute event") + } +} + type PortalInfo struct { Name *string Topic *string @@ -1233,6 +1268,13 @@ type PortalInfo struct { IsDirectChat *bool IsSpace *bool + + UserLocal *UserLocalPortalInfo +} + +type UserLocalPortalInfo struct { + MutedUntil *time.Time + Tag *event.RoomTag } func (portal *Portal) UpdateName(ctx context.Context, name string, sender *Ghost, ts time.Time) bool { @@ -1445,6 +1487,28 @@ func (portal *Portal) SyncParticipants(ctx context.Context, members []networkid. return expectedUserIDs, extraFunctionalMembers, nil } +func (portal *Portal) updateUserLocalInfo(ctx context.Context, info *UserLocalPortalInfo, source *UserLogin) { + if portal.MXID == "" || info == nil { + return + } + dp := source.User.DoublePuppet(ctx) + if dp == nil { + return + } + if info.MutedUntil != nil { + err := dp.MuteRoom(ctx, portal.MXID, *info.MutedUntil) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to mute room") + } + } + if info.Tag != nil { + err := dp.TagRoom(ctx, portal.MXID, *info.Tag, *info.Tag != "") + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to tag room") + } + } +} + func (portal *Portal) UpdateInfo(ctx context.Context, info *PortalInfo, source *UserLogin, sender *Ghost, ts time.Time) { changed := false if info.Name != nil { @@ -1469,6 +1533,7 @@ func (portal *Portal) UpdateInfo(ctx context.Context, info *PortalInfo, source * if err != nil { zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to ensure user portal row exists") } + portal.updateUserLocalInfo(ctx, info.UserLocal, source) } if changed { portal.UpdateBridgeInfo(ctx) @@ -1599,6 +1664,7 @@ func (portal *Portal) CreateMatrixRoom(ctx context.Context, source *UserLogin, i if portal.Parent != nil { // TODO add m.space.child event } + portal.updateUserLocalInfo(ctx, info.UserLocal, source) if !isBeeper { _, _, err = portal.SyncParticipants(ctx, info.Members, source) if err != nil { diff --git a/client.go b/client.go index 3fb9919e..5b4236c2 100644 --- a/client.go +++ b/client.go @@ -1925,15 +1925,13 @@ func (cli *Client) SetReadMarkers(ctx context.Context, roomID id.RoomID, content return } -func (cli *Client) AddTag(ctx context.Context, roomID id.RoomID, tag string, order float64) error { - var tagData event.Tag - if order == order { - tagData.Order = json.Number(strconv.FormatFloat(order, 'e', -1, 64)) - } - return cli.AddTagWithCustomData(ctx, roomID, tag, tagData) +func (cli *Client) AddTag(ctx context.Context, roomID id.RoomID, tag event.RoomTag, order float64) error { + return cli.AddTagWithCustomData(ctx, roomID, tag, &event.TagMetadata{ + Order: json.Number(strconv.FormatFloat(order, 'e', -1, 64)), + }) } -func (cli *Client) AddTagWithCustomData(ctx context.Context, roomID id.RoomID, tag string, data interface{}) (err error) { +func (cli *Client) AddTagWithCustomData(ctx context.Context, roomID id.RoomID, tag event.RoomTag, data any) (err error) { urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "tags", tag) _, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, data, nil) return @@ -1944,13 +1942,13 @@ func (cli *Client) GetTags(ctx context.Context, roomID id.RoomID) (tags event.Ta return } -func (cli *Client) GetTagsWithCustomData(ctx context.Context, roomID id.RoomID, resp interface{}) (err error) { +func (cli *Client) GetTagsWithCustomData(ctx context.Context, roomID id.RoomID, resp any) (err error) { urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "tags") _, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp) return } -func (cli *Client) RemoveTag(ctx context.Context, roomID id.RoomID, tag string) (err error) { +func (cli *Client) RemoveTag(ctx context.Context, roomID id.RoomID, tag event.RoomTag) (err error) { urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "tags", tag) _, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, nil) return diff --git a/event/accountdata.go b/event/accountdata.go index 6637fcfe..f4b05802 100644 --- a/event/accountdata.go +++ b/event/accountdata.go @@ -8,6 +8,7 @@ package event import ( "encoding/json" + "strings" "maunium.net/go/mautrix/id" ) @@ -18,10 +19,47 @@ type TagEventContent struct { Tags Tags `json:"tags"` } -type Tags map[string]Tag +type Tags map[RoomTag]TagMetadata -type Tag struct { +type RoomTag string + +const ( + RoomTagFavourite RoomTag = "m.favourite" + RoomTagLowPriority RoomTag = "m.lowpriority" + RoomTagServerNotice RoomTag = "m.server_notice" +) + +func (rt RoomTag) IsUserDefined() bool { + return strings.HasPrefix(string(rt), "u.") +} + +func (rt RoomTag) String() string { + return string(rt) +} + +func (rt RoomTag) Name() string { + if rt.IsUserDefined() { + return string(rt[2:]) + } + switch rt { + case RoomTagFavourite: + return "Favourite" + case RoomTagLowPriority: + return "Low priority" + case RoomTagServerNotice: + return "Server notice" + default: + return "" + } +} + +// Deprecated: type alias +type Tag = TagMetadata + +type TagMetadata struct { Order json.Number `json:"order,omitempty"` + + MauDoublePuppetSource string `json:"fi.mau.double_puppet_source,omitempty"` } // DirectChatsEventContent represents the content of a m.direct account data event.