mirror of
https://mau.dev/mautrix/go.git
synced 2026-03-14 14:25:53 +01:00
795 lines
25 KiB
Go
795 lines
25 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 (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
"go.mau.fi/util/fallocate"
|
|
"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/crypto/canonicaljson"
|
|
"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)
|
|
var _ bridgev2.EphemeralSendingMatrixAPI = (*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{}
|
|
}
|
|
if eventType == event.EventRedaction && !as.Connector.SpecVersions.Supports(mautrix.FeatureRedactSendAsEvent) {
|
|
parsedContent := content.Parsed.(*event.RedactionEventContent)
|
|
as.Matrix.AddDoublePuppetValue(content)
|
|
return as.Matrix.RedactEvent(ctx, roomID, parsedContent.Redacts, mautrix.ReqRedact{
|
|
Reason: parsedContent.Reason,
|
|
Extra: content.Raw,
|
|
})
|
|
}
|
|
if (eventType != event.EventReaction || as.Connector.Config.Encryption.MSC4392) && eventType != event.EventRedaction {
|
|
msgContent, ok := content.Parsed.(*event.MessageEventContent)
|
|
if ok {
|
|
msgContent.AddPerMessageProfileFallback()
|
|
}
|
|
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
|
|
}
|
|
}
|
|
return as.Matrix.SendMessageEvent(ctx, roomID, eventType, content, mautrix.ReqSendEvent{Timestamp: extra.Timestamp.UnixMilli()})
|
|
}
|
|
|
|
func (as *ASIntent) BeeperSendEphemeralEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, txnID string) (*mautrix.RespSendEvent, error) {
|
|
if !as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureEphemeralEvents) {
|
|
return nil, mautrix.MUnrecognized.WithMessage("Homeserver does not advertise com.beeper.ephemeral support")
|
|
}
|
|
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 && as.Connector.Crypto != nil {
|
|
if err = as.Connector.Crypto.Encrypt(ctx, roomID, eventType, content); err != nil {
|
|
return nil, err
|
|
}
|
|
eventType = event.EventEncrypted
|
|
}
|
|
return as.Matrix.BeeperSendEphemeralEvent(ctx, roomID, eventType, content, mautrix.ReqSendEvent{TransactionID: txnID})
|
|
}
|
|
|
|
func (as *ASIntent) fillMemberEvent(ctx context.Context, roomID id.RoomID, userID id.UserID, content *event.Content) {
|
|
targetContent, ok := content.Parsed.(*event.MemberEventContent)
|
|
if !ok || 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)
|
|
}
|
|
resp, err = as.Matrix.SendStateEvent(ctx, roomID, eventType, stateKey, content, mautrix.ReqSendEvent{Timestamp: 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) DownloadMediaToFile(ctx context.Context, uri id.ContentURIString, file *event.EncryptedFileInfo, writable bool, callback func(*os.File) error) error {
|
|
if file != nil {
|
|
uri = file.URL
|
|
err := file.PrepareForDecryption()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
parsedURI, err := uri.Parse()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tempFile, err := os.CreateTemp("", "mautrix-download-*")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
defer func() {
|
|
_ = tempFile.Close()
|
|
_ = os.Remove(tempFile.Name())
|
|
}()
|
|
resp, err := as.Matrix.Download(ctx, parsedURI)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send download request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
reader := resp.Body
|
|
if file != nil {
|
|
reader = file.DecryptStream(reader)
|
|
}
|
|
if resp.ContentLength > 0 {
|
|
err = fallocate.Fallocate(tempFile, int(resp.ContentLength))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to preallocate file: %w", err)
|
|
}
|
|
}
|
|
_, err = io.Copy(tempFile, reader)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
err = reader.Close()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to close response body: %w", err)
|
|
}
|
|
_, err = tempFile.Seek(0, io.SeekStart)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to seek to start of temp file: %w", err)
|
|
}
|
|
err = callback(tempFile)
|
|
if err != nil {
|
|
return bridgev2.CallbackError{Type: "read", Wrapped: err}
|
|
}
|
|
return 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 int64(len(data)) > as.Connector.MediaConfig.UploadSize {
|
|
return "", nil, fmt.Errorf("file too large (%.2f MB > %.2f MB)", float64(len(data))/1000/1000, float64(as.Connector.MediaConfig.UploadSize)/1000/1000)
|
|
}
|
|
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 = ""
|
|
}
|
|
}
|
|
url, err = as.doUploadReq(ctx, file, mautrix.ReqUploadMedia{
|
|
ContentBytes: data,
|
|
ContentType: mimeType,
|
|
FileName: fileName,
|
|
})
|
|
return
|
|
}
|
|
|
|
func (as *ASIntent) UploadMediaStream(
|
|
ctx context.Context,
|
|
roomID id.RoomID,
|
|
size int64,
|
|
requireFile bool,
|
|
cb bridgev2.FileStreamCallback,
|
|
) (url id.ContentURIString, file *event.EncryptedFileInfo, err error) {
|
|
if size > as.Connector.MediaConfig.UploadSize {
|
|
return "", nil, fmt.Errorf("file too large (%.2f MB > %.2f MB)", float64(size)/1000/1000, float64(as.Connector.MediaConfig.UploadSize)/1000/1000)
|
|
}
|
|
if !requireFile && 0 < size && size < as.Connector.Config.Matrix.UploadFileThreshold {
|
|
var buf bytes.Buffer
|
|
res, err := cb(&buf)
|
|
if err != nil {
|
|
return "", nil, err
|
|
} else if res.ReplacementFile != "" {
|
|
panic(fmt.Errorf("logic error: replacement path must only be returned if requireFile is true"))
|
|
}
|
|
return as.UploadMedia(ctx, roomID, buf.Bytes(), res.FileName, res.MimeType)
|
|
}
|
|
var tempFile *os.File
|
|
tempFile, err = os.CreateTemp("", "mautrix-upload-*")
|
|
if err != nil {
|
|
err = fmt.Errorf("failed to create temp file: %w", err)
|
|
return
|
|
}
|
|
removeAndClose := func(f *os.File) {
|
|
_ = f.Close()
|
|
_ = os.Remove(f.Name())
|
|
}
|
|
startedAsyncUpload := false
|
|
defer func() {
|
|
if !startedAsyncUpload {
|
|
removeAndClose(tempFile)
|
|
}
|
|
}()
|
|
if size > 0 {
|
|
err = fallocate.Fallocate(tempFile, int(size))
|
|
if err != nil {
|
|
err = fmt.Errorf("failed to preallocate file: %w", err)
|
|
return
|
|
}
|
|
}
|
|
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(),
|
|
}
|
|
}
|
|
}
|
|
var res *bridgev2.FileStreamResult
|
|
res, err = cb(tempFile)
|
|
if err != nil {
|
|
err = bridgev2.CallbackError{Type: "write", Wrapped: err}
|
|
return
|
|
}
|
|
var replFile *os.File
|
|
if res.ReplacementFile != "" {
|
|
replFile, err = os.OpenFile(res.ReplacementFile, os.O_RDWR, 0)
|
|
if err != nil {
|
|
err = fmt.Errorf("failed to open replacement file: %w", err)
|
|
return
|
|
}
|
|
defer func() {
|
|
if !startedAsyncUpload {
|
|
removeAndClose(replFile)
|
|
}
|
|
}()
|
|
} else {
|
|
replFile = tempFile
|
|
_, err = replFile.Seek(0, io.SeekStart)
|
|
if err != nil {
|
|
err = fmt.Errorf("failed to seek to start of temp file: %w", err)
|
|
return
|
|
}
|
|
}
|
|
if file != nil {
|
|
res.FileName = ""
|
|
res.MimeType = "application/octet-stream"
|
|
err = file.EncryptFile(replFile)
|
|
if err != nil {
|
|
err = fmt.Errorf("failed to encrypt file: %w", err)
|
|
return
|
|
}
|
|
_, err = replFile.Seek(0, io.SeekStart)
|
|
if err != nil {
|
|
err = fmt.Errorf("failed to seek to start of temp file after encrypting: %w", err)
|
|
return
|
|
}
|
|
}
|
|
info, err := replFile.Stat()
|
|
if err != nil {
|
|
err = fmt.Errorf("failed to get temp file info: %w", err)
|
|
return
|
|
}
|
|
size = info.Size()
|
|
if size > as.Connector.MediaConfig.UploadSize {
|
|
return "", nil, fmt.Errorf("file too large (%.2f MB > %.2f MB)", float64(size)/1000/1000, float64(as.Connector.MediaConfig.UploadSize)/1000/1000)
|
|
}
|
|
req := mautrix.ReqUploadMedia{
|
|
Content: replFile,
|
|
ContentLength: size,
|
|
ContentType: res.MimeType,
|
|
FileName: res.FileName,
|
|
}
|
|
if as.Connector.Config.Homeserver.AsyncMedia {
|
|
req.DoneCallback = func() {
|
|
removeAndClose(replFile)
|
|
removeAndClose(tempFile)
|
|
}
|
|
req.AsyncContext = zerolog.Ctx(ctx).WithContext(as.Connector.Bridge.BackgroundCtx)
|
|
startedAsyncUpload = true
|
|
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) doUploadReq(ctx context.Context, file *event.EncryptedFileInfo, req mautrix.ReqUploadMedia) (url id.ContentURIString, err error) {
|
|
if as.Connector.Config.Homeserver.AsyncMedia {
|
|
if req.ContentBytes != nil {
|
|
// Prevent too many background uploads at once
|
|
err = as.Connector.uploadSema.Acquire(ctx, int64(len(req.ContentBytes)))
|
|
if err != nil {
|
|
return
|
|
}
|
|
req.DoneCallback = func() {
|
|
as.Connector.uploadSema.Release(int64(len(req.ContentBytes)))
|
|
}
|
|
}
|
|
req.AsyncContext = zerolog.Ctx(ctx).WithContext(as.Connector.Bridge.BackgroundCtx)
|
|
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 dataToFields(data any) (map[string]json.RawMessage, error) {
|
|
fields, ok := data.(map[string]json.RawMessage)
|
|
if ok {
|
|
return fields, nil
|
|
}
|
|
d, err := json.Marshal(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d = canonicaljson.CanonicalJSONAssumeValid(d)
|
|
err = json.Unmarshal(d, &fields)
|
|
return fields, err
|
|
}
|
|
|
|
func marshalField(val any) json.RawMessage {
|
|
data, _ := json.Marshal(val)
|
|
if len(data) > 0 && (data[0] == '{' || data[0] == '[') {
|
|
return canonicaljson.CanonicalJSONAssumeValid(data)
|
|
}
|
|
return data
|
|
}
|
|
|
|
var nullJSON = json.RawMessage("null")
|
|
|
|
func (as *ASIntent) SetExtraProfileMeta(ctx context.Context, data any) error {
|
|
if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) {
|
|
return as.Matrix.BeeperUpdateProfile(ctx, data)
|
|
} else if as.Connector.SpecVersions.Supports(mautrix.FeatureArbitraryProfileFields) && as.Connector.Config.Matrix.GhostExtraProfileInfo {
|
|
fields, err := dataToFields(data)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal fields: %w", err)
|
|
}
|
|
currentProfile, err := as.Matrix.GetProfile(ctx, as.Matrix.UserID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get current profile: %w", err)
|
|
}
|
|
for key, val := range fields {
|
|
existing, ok := currentProfile.Extra[key]
|
|
if !ok {
|
|
if bytes.Equal(val, nullJSON) {
|
|
continue
|
|
}
|
|
err = as.Matrix.SetProfileField(ctx, key, val)
|
|
} else if !bytes.Equal(marshalField(existing), val) {
|
|
if bytes.Equal(val, nullJSON) {
|
|
err = as.Matrix.DeleteProfileField(ctx, key)
|
|
} else {
|
|
err = as.Matrix.SetProfileField(ctx, key, val)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set profile field %q: %w", key, err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (as *ASIntent) GetMXID() id.UserID {
|
|
return as.Matrix.UserID
|
|
}
|
|
|
|
func (as *ASIntent) IsDoublePuppet() bool {
|
|
return as.Matrix.IsDoublePuppet()
|
|
}
|
|
|
|
func (as *ASIntent) EnsureJoined(ctx context.Context, roomID id.RoomID, extra ...bridgev2.EnsureJoinedParams) error {
|
|
var params bridgev2.EnsureJoinedParams
|
|
if len(extra) > 0 {
|
|
params = extra[0]
|
|
}
|
|
err := as.Matrix.EnsureJoined(ctx, roomID, appservice.EnsureJoinedParams{Via: params.Via})
|
|
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) filterCreateRequestForV12(ctx context.Context, req *mautrix.ReqCreateRoom) {
|
|
if as.Connector.Config.Homeserver.Software == bridgeconfig.SoftwareHungry {
|
|
// Hungryserv doesn't override the capabilities endpoint nor do room versions
|
|
return
|
|
}
|
|
caps := as.Connector.fetchCapabilities(ctx)
|
|
roomVer := req.RoomVersion
|
|
if roomVer == "" && caps != nil && caps.RoomVersions != nil {
|
|
roomVer = id.RoomVersion(caps.RoomVersions.Default)
|
|
}
|
|
if roomVer != "" && !roomVer.PrivilegedRoomCreators() {
|
|
return
|
|
}
|
|
creators, _ := req.CreationContent["additional_creators"].([]id.UserID)
|
|
creators = append(slices.Clone(creators), as.GetMXID())
|
|
if req.PowerLevelOverride != nil {
|
|
for _, creator := range creators {
|
|
delete(req.PowerLevelOverride.Users, creator)
|
|
}
|
|
}
|
|
for _, evt := range req.InitialState {
|
|
if evt.Type != event.StatePowerLevels {
|
|
continue
|
|
}
|
|
content, ok := evt.Content.Parsed.(*event.PowerLevelsEventContent)
|
|
if ok {
|
|
for _, creator := range creators {
|
|
delete(content.Users, creator)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
as.filterCreateRequestForV12(ctx, req)
|
|
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 roomID == "" {
|
|
return nil
|
|
}
|
|
if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) {
|
|
err := as.Matrix.BeeperDeleteRoom(ctx, roomID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = as.Matrix.StateStore.ClearCachedMembers(ctx, roomID)
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to clear cached members while cleaning up portal")
|
|
}
|
|
return nil
|
|
}
|
|
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")
|
|
}
|
|
err = as.Matrix.StateStore.ClearCachedMembers(ctx, roomID)
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to clear cached members 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},
|
|
})
|
|
}
|
|
}
|
|
|
|
func (as *ASIntent) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*event.Event, error) {
|
|
evt, err := as.Matrix.Client.GetEvent(ctx, roomID, eventID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = evt.Content.ParseRaw(evt.Type)
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).Stringer("room_id", roomID).Stringer("event_id", eventID).Msg("failed to parse event content")
|
|
}
|
|
|
|
if evt.Type == event.EventEncrypted {
|
|
if as.Connector.Crypto == nil || as.Connector.Config.Encryption.DeleteKeys.RatchetOnDecrypt {
|
|
return nil, errors.New("can't decrypt the event")
|
|
}
|
|
return as.Connector.Crypto.Decrypt(ctx, evt)
|
|
}
|
|
|
|
return evt, nil
|
|
}
|