mirror of
https://mau.dev/mautrix/go.git
synced 2026-03-14 14:25:53 +01:00
450 lines
14 KiB
Go
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},
|
|
})
|
|
}
|
|
}
|