mautrix-go/bridgev2/matrix/intent.go
Tulir Asokan 2355d70426
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run
bridgev2/matrix: return error if trying to encrypt message without encryption enabled
2024-08-17 14:12:53 +03:00

450 lines
14 KiB
Go

// Copyright (c) 2024 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 matrix
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"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.
type ASIntent struct {
Matrix *appservice.IntentAPI
Connector *Connector
dmUpdateLock sync.Mutex
directChatsCache event.DirectChatsEventContent
}
var _ bridgev2.MatrixAPI = (*ASIntent)(nil)
var _ bridgev2.MarkAsDMMatrixAPI = (*ASIntent)(nil)
func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, extra *bridgev2.MatrixSendExtra) (*mautrix.RespSendEvent, error) {
if extra == nil {
extra = &bridgev2.MatrixSendExtra{}
}
// TODO remove this once hungryserv and synapse support sending m.room.redactions directly in all room versions
if eventType == event.EventRedaction {
parsedContent := content.Parsed.(*event.RedactionEventContent)
return as.Matrix.RedactEvent(ctx, roomID, parsedContent.Redacts, mautrix.ReqRedact{
Reason: parsedContent.Reason,
Extra: content.Raw,
})
}
if eventType != event.EventReaction && eventType != event.EventRedaction {
if encrypted, err := as.Matrix.StateStore.IsEncrypted(ctx, roomID); err != nil {
return nil, fmt.Errorf("failed to check if room is encrypted: %w", err)
} else if encrypted {
if as.Connector.Crypto == nil {
return nil, fmt.Errorf("room is encrypted, but bridge isn't configured to support encryption")
}
if as.Matrix.IsCustomPuppet {
if extra.Timestamp.IsZero() {
as.Matrix.AddDoublePuppetValue(content)
} else {
as.Matrix.AddDoublePuppetValueWithTS(content, extra.Timestamp.UnixMilli())
}
}
err = as.Connector.Crypto.Encrypt(ctx, roomID, eventType, content)
if err != nil {
return nil, err
}
eventType = event.EventEncrypted
}
}
if extra.Timestamp.IsZero() {
return as.Matrix.SendMessageEvent(ctx, roomID, eventType, content)
} else {
return as.Matrix.SendMassagedMessageEvent(ctx, roomID, eventType, content, extra.Timestamp.UnixMilli())
}
}
func (as *ASIntent) fillMemberEvent(ctx context.Context, roomID id.RoomID, userID id.UserID, content *event.Content) {
targetContent := content.Parsed.(*event.MemberEventContent)
if targetContent.Displayname != "" || targetContent.AvatarURL != "" {
return
}
memberContent, err := as.Matrix.StateStore.TryGetMember(ctx, roomID, userID)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Stringer("target_user_id", userID).
Str("membership", string(targetContent.Membership)).
Msg("Failed to get old member content from state store to fill new membership event")
} else if memberContent != nil {
targetContent.Displayname = memberContent.Displayname
targetContent.AvatarURL = memberContent.AvatarURL
} else if ghost, err := as.Connector.Bridge.GetGhostByMXID(ctx, userID); err != nil {
zerolog.Ctx(ctx).Err(err).
Stringer("target_user_id", userID).
Str("membership", string(targetContent.Membership)).
Msg("Failed to get ghost to fill new membership event")
} else if ghost != nil {
targetContent.Displayname = ghost.Name
targetContent.AvatarURL = ghost.AvatarMXC
} else if profile, err := as.Matrix.GetProfile(ctx, userID); err != nil {
zerolog.Ctx(ctx).Debug().Err(err).
Stringer("target_user_id", userID).
Str("membership", string(targetContent.Membership)).
Msg("Failed to get profile to fill new membership event")
} else if profile != nil {
targetContent.Displayname = profile.DisplayName
targetContent.AvatarURL = profile.AvatarURL.CUString()
}
}
func (as *ASIntent) SendState(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, content *event.Content, ts time.Time) (resp *mautrix.RespSendEvent, err error) {
if eventType == event.StateMember {
as.fillMemberEvent(ctx, roomID, id.UserID(stateKey), content)
}
if ts.IsZero() {
resp, err = as.Matrix.SendStateEvent(ctx, roomID, eventType, stateKey, content)
} else {
resp, err = as.Matrix.SendMassagedStateEvent(ctx, roomID, eventType, stateKey, content, ts.UnixMilli())
}
if err != nil && eventType == event.StateMember {
var httpErr mautrix.HTTPError
if errors.As(err, &httpErr) && httpErr.RespError != nil &&
(strings.Contains(httpErr.RespError.Err, "is already in the room") || strings.Contains(httpErr.RespError.Err, "is already joined to room")) {
err = as.Matrix.StateStore.SetMembership(ctx, roomID, id.UserID(stateKey), event.MembershipJoin)
}
}
return resp, err
}
func (as *ASIntent) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, ts time.Time) (err error) {
extraData := map[string]any{}
if !ts.IsZero() {
extraData["ts"] = ts.UnixMilli()
}
as.Matrix.AddDoublePuppetValue(extraData)
req := mautrix.ReqSetReadMarkers{
Read: eventID,
BeeperReadExtra: extraData,
}
if as.Matrix.IsCustomPuppet {
req.FullyRead = eventID
req.BeeperFullyReadExtra = extraData
}
if as.Matrix.IsCustomPuppet && as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureInboxState) && as.Connector.Config.Homeserver.Software != bridgeconfig.SoftwareHungry {
err = as.Matrix.SetBeeperInboxState(ctx, roomID, &mautrix.ReqSetBeeperInboxState{
//MarkedUnread: ptr.Ptr(false),
ReadMarkers: &req,
})
} else {
err = as.Matrix.SetReadMarkers(ctx, roomID, &req)
if err == nil && as.Matrix.IsCustomPuppet && as.Connector.Config.Homeserver.Software != bridgeconfig.SoftwareHungry {
err = as.Matrix.SetRoomAccountData(ctx, roomID, event.AccountDataMarkedUnread.Type, &event.MarkedUnreadEventContent{
Unread: false,
})
}
}
return
}
func (as *ASIntent) MarkUnread(ctx context.Context, roomID id.RoomID, unread bool) error {
if as.Connector.Config.Homeserver.Software == bridgeconfig.SoftwareHungry {
return nil
}
if as.Matrix.IsCustomPuppet && as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureInboxState) {
return as.Matrix.SetBeeperInboxState(ctx, roomID, &mautrix.ReqSetBeeperInboxState{
MarkedUnread: ptr.Ptr(unread),
})
} else {
return as.Matrix.SetRoomAccountData(ctx, roomID, event.AccountDataMarkedUnread.Type, &event.MarkedUnreadEventContent{
Unread: unread,
})
}
}
func (as *ASIntent) MarkTyping(ctx context.Context, roomID id.RoomID, typingType bridgev2.TypingType, timeout time.Duration) error {
if typingType != bridgev2.TypingTypeText {
return nil
} else if as.Matrix.IsCustomPuppet {
// Don't send double puppeted typing notifications, there's no good way to prevent echoing them
return nil
}
_, err := as.Matrix.UserTyping(ctx, roomID, timeout > 0, timeout)
return err
}
func (as *ASIntent) DownloadMedia(ctx context.Context, uri id.ContentURIString, file *event.EncryptedFileInfo) ([]byte, error) {
if file != nil {
uri = file.URL
}
parsedURI, err := uri.Parse()
if err != nil {
return nil, err
}
data, err := as.Matrix.DownloadBytes(ctx, parsedURI)
if err != nil {
return nil, err
}
if file != nil {
err = file.DecryptInPlace(data)
if err != nil {
return nil, err
}
}
return data, nil
}
func (as *ASIntent) UploadMedia(ctx context.Context, roomID id.RoomID, data []byte, fileName, mimeType string) (url id.ContentURIString, file *event.EncryptedFileInfo, err error) {
if roomID != "" {
var encrypted bool
if encrypted, err = as.Matrix.StateStore.IsEncrypted(ctx, roomID); err != nil {
err = fmt.Errorf("failed to check if room is encrypted: %w", err)
return
} else if encrypted {
file = &event.EncryptedFileInfo{
EncryptedFile: *attachment.NewEncryptedFile(),
}
file.EncryptInPlace(data)
mimeType = "application/octet-stream"
fileName = ""
}
}
req := mautrix.ReqUploadMedia{
ContentBytes: data,
ContentType: mimeType,
FileName: fileName,
}
if as.Connector.Config.Homeserver.AsyncMedia {
var resp *mautrix.RespCreateMXC
resp, err = as.Matrix.UploadAsync(ctx, req)
if resp != nil {
url = resp.ContentURI.CUString()
}
} else {
var resp *mautrix.RespMediaUpload
resp, err = as.Matrix.UploadMedia(ctx, req)
if resp != nil {
url = resp.ContentURI.CUString()
}
}
if file != nil {
file.URL = url
url = ""
}
return
}
func (as *ASIntent) SetDisplayName(ctx context.Context, name string) error {
return as.Matrix.SetDisplayName(ctx, name)
}
func (as *ASIntent) SetAvatarURL(ctx context.Context, avatarURL id.ContentURIString) error {
parsedAvatarURL, err := avatarURL.Parse()
if err != nil {
return err
}
return as.Matrix.SetAvatarURL(ctx, parsedAvatarURL)
}
func (as *ASIntent) SetExtraProfileMeta(ctx context.Context, data any) error {
if !as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) {
return nil
}
return as.Matrix.BeeperUpdateProfile(ctx, data)
}
func (as *ASIntent) GetMXID() id.UserID {
return as.Matrix.UserID
}
func (as *ASIntent) InviteUser(ctx context.Context, roomID id.RoomID, userID id.UserID) error {
_, err := as.Matrix.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{
Reason: "",
UserID: userID,
})
return err
}
func (as *ASIntent) EnsureJoined(ctx context.Context, roomID id.RoomID) error {
err := as.Matrix.EnsureJoined(ctx, roomID)
if err != nil {
return err
}
if as.Connector.Bot.UserID == as.Matrix.UserID {
_, err = as.Matrix.State(ctx, roomID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get state after joining room with bot")
}
}
return nil
}
func (as *ASIntent) EnsureInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) error {
return as.Matrix.EnsureInvited(ctx, roomID, userID)
}
func (br *Connector) getDefaultEncryptionEvent() *event.EncryptionEventContent {
content := &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1}
if rot := br.Config.Encryption.Rotation; rot.EnableCustom {
content.RotationPeriodMillis = rot.Milliseconds
content.RotationPeriodMessages = rot.Messages
}
return content
}
func (as *ASIntent) CreateRoom(ctx context.Context, req *mautrix.ReqCreateRoom) (id.RoomID, error) {
if as.Connector.Config.Encryption.Default {
req.InitialState = append(req.InitialState, &event.Event{
Type: event.StateEncryption,
Content: event.Content{
Parsed: as.Connector.getDefaultEncryptionEvent(),
},
})
}
if !as.Connector.Config.Matrix.FederateRooms {
if req.CreationContent == nil {
req.CreationContent = make(map[string]any)
}
req.CreationContent["m.federate"] = false
}
resp, err := as.Matrix.CreateRoom(ctx, req)
if err != nil {
return "", err
}
return resp.RoomID, nil
}
func (as *ASIntent) MarkAsDM(ctx context.Context, roomID id.RoomID, withUser id.UserID) error {
if !as.Connector.Config.Matrix.SyncDirectChatList {
return nil
}
as.dmUpdateLock.Lock()
defer as.dmUpdateLock.Unlock()
cached, ok := as.directChatsCache[withUser]
if ok && slices.Contains(cached, roomID) {
return nil
}
var directChats event.DirectChatsEventContent
err := as.Matrix.GetAccountData(ctx, event.AccountDataDirectChats.Type, &directChats)
if err != nil {
return err
}
as.directChatsCache = directChats
rooms := directChats[withUser]
if slices.Contains(rooms, roomID) {
return nil
}
directChats[withUser] = append(rooms, roomID)
err = as.Matrix.SetAccountData(ctx, event.AccountDataDirectChats.Type, &directChats)
if err != nil {
if rooms == nil {
delete(directChats, withUser)
} else {
directChats[withUser] = rooms
}
return fmt.Errorf("failed to set direct chats account data: %w", err)
}
return nil
}
func (as *ASIntent) DeleteRoom(ctx context.Context, roomID id.RoomID, puppetsOnly bool) error {
if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) {
return as.Matrix.BeeperDeleteRoom(ctx, roomID)
}
members, err := as.Matrix.JoinedMembers(ctx, roomID)
if err != nil {
return fmt.Errorf("failed to get portal members for cleanup: %w", err)
}
for member := range members.Joined {
if member == as.Matrix.UserID {
continue
}
if as.Connector.Bridge.IsGhostMXID(member) {
_, err = as.Connector.AS.Intent(member).LeaveRoom(ctx, roomID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("user_id", member).Msg("Failed to leave room while cleaning up portal")
}
} else if !puppetsOnly {
_, err = as.Matrix.KickUser(ctx, roomID, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"})
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("user_id", member).Msg("Failed to kick user while cleaning up portal")
}
}
}
_, err = as.Matrix.LeaveRoom(ctx, roomID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to leave room while cleaning up portal")
}
return nil
}
func (as *ASIntent) TagRoom(ctx context.Context, roomID id.RoomID, tag event.RoomTag, isTagged bool) error {
tags, err := as.Matrix.GetTags(ctx, roomID)
if err != nil {
return fmt.Errorf("failed to get room tags: %w", err)
}
if isTagged {
_, alreadyTagged := tags.Tags[tag]
if alreadyTagged {
return nil
}
err = as.Matrix.AddTagWithCustomData(ctx, roomID, tag, &event.TagMetadata{
MauDoublePuppetSource: as.Connector.AS.DoublePuppetValue,
})
if err != nil {
return err
}
}
for extraTag := range tags.Tags {
if extraTag == event.RoomTagFavourite || extraTag == event.RoomTagLowPriority {
err = as.Matrix.RemoveTag(ctx, roomID, extraTag)
if err != nil {
return fmt.Errorf("failed to remove extra tag %s: %w", extraTag, err)
}
}
}
return nil
}
func (as *ASIntent) MuteRoom(ctx context.Context, roomID id.RoomID, until time.Time) error {
var mutedUntil int64
if until.Before(time.Now()) {
mutedUntil = 0
} else if until == event.MutedForever {
mutedUntil = -1
} else {
mutedUntil = until.UnixMilli()
}
if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureAccountDataMute) {
return as.Matrix.SetRoomAccountData(ctx, roomID, event.AccountDataBeeperMute.Type, &event.BeeperMuteEventContent{
MutedUntil: mutedUntil,
})
}
if mutedUntil == 0 {
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},
})
}
}