diff --git a/bridgev2/bridgeconfig/appservice.go b/bridgev2/bridgeconfig/appservice.go index 5e482499..1c935c00 100644 --- a/bridgev2/bridgeconfig/appservice.go +++ b/bridgev2/bridgeconfig/appservice.go @@ -123,7 +123,7 @@ func (buc *BotUserConfig) UnmarshalYAML(node *yaml.Node) error { return err } *buc = (BotUserConfig)(sbuc) - if buc.Avatar != "" && buc.Avatar != "remove" { + if buc.Avatar != "" { buc.ParsedAvatar, err = id.ParseContentURI(buc.Avatar) if err != nil { return fmt.Errorf("%w in bot avatar", err) diff --git a/bridgev2/ghost.go b/bridgev2/ghost.go index e4e007cd..54197da4 100644 --- a/bridgev2/ghost.go +++ b/bridgev2/ghost.go @@ -136,22 +136,16 @@ type UserInfo struct { ExtraUpdates ExtraUpdater[*Ghost] } -func (ghost *Ghost) UpdateName(ctx context.Context, name string) bool { +func (ghost *Ghost) updateName(name string) bool { if ghost.Name == name && ghost.NameSet { return false } ghost.Name = name ghost.NameSet = false - err := ghost.Intent.SetDisplayName(ctx, name) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to set display name") - } else { - ghost.NameSet = true - } return true } -func (ghost *Ghost) UpdateAvatar(ctx context.Context, avatar *Avatar) bool { +func (ghost *Ghost) updateAvatar(ctx context.Context, avatar *Avatar) bool { if ghost.AvatarID == avatar.ID && ghost.AvatarSet { return false } @@ -171,15 +165,10 @@ func (ghost *Ghost) UpdateAvatar(ctx context.Context, avatar *Avatar) bool { ghost.AvatarMXC = "" } ghost.AvatarSet = false - if err := ghost.Intent.SetAvatarURL(ctx, ghost.AvatarMXC); err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to set avatar URL") - } else { - ghost.AvatarSet = true - } return true } -func (ghost *Ghost) UpdateContactInfo(ctx context.Context, identifiers []string, isBot *bool) bool { +func (ghost *Ghost) updateContactInfo(identifiers []string, isBot *bool) bool { if identifiers != nil { slices.Sort(identifiers) } @@ -194,21 +183,6 @@ func (ghost *Ghost) UpdateContactInfo(ctx context.Context, identifiers []string, if isBot != nil { ghost.IsBot = *isBot } - bridgeName := ghost.Bridge.Network.GetName() - meta := &event.BeeperProfileExtra{ - RemoteID: string(ghost.ID), - Identifiers: ghost.Identifiers, - Service: bridgeName.BeeperBridgeType, - Network: bridgeName.NetworkID, - IsBridgeBot: false, - IsNetworkBot: ghost.IsBot, - } - err := ghost.Intent.SetExtraProfileMeta(ctx, meta) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to set extra profile metadata") - } else { - ghost.ContactInfoSet = true - } return true } @@ -264,17 +238,41 @@ func (ghost *Ghost) UpdateInfo(ctx context.Context, info *UserInfo) { oldName := ghost.Name oldAvatar := ghost.AvatarMXC if info.Name != nil { - update = ghost.UpdateName(ctx, *info.Name) || update + update = ghost.updateName(*info.Name) || update } if info.Avatar != nil { - update = ghost.UpdateAvatar(ctx, info.Avatar) || update + update = ghost.updateAvatar(ctx, info.Avatar) || update } if info.Identifiers != nil || info.IsBot != nil { - update = ghost.UpdateContactInfo(ctx, info.Identifiers, info.IsBot) || update + update = ghost.updateContactInfo(info.Identifiers, info.IsBot) || update } if info.ExtraUpdates != nil { update = info.ExtraUpdates(ctx, ghost) || update } + if update { + bridgeName := ghost.Bridge.Network.GetName() + err := ghost.Intent.SetProfile(ctx, &event.ExtendedProfile[event.BeeperProfileExtra]{ + StandardProfile: event.StandardProfile{ + Displayname: ghost.Name, + AvatarURL: ghost.AvatarMXC, + }, + Extra: event.BeeperProfileExtra{ + RemoteID: string(ghost.ID), + Identifiers: ghost.Identifiers, + Service: bridgeName.BeeperBridgeType, + Network: bridgeName.NetworkID, + IsBridgeBot: false, + IsNetworkBot: ghost.IsBot, + }, + }) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to set profile") + } else { + ghost.NameSet = true + ghost.AvatarSet = true + ghost.ContactInfoSet = true + } + } if oldName != ghost.Name || oldAvatar != ghost.AvatarMXC { ghost.updateDMPortals(ctx) } diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 4297cba7..b4cf36ce 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -375,39 +375,20 @@ func (br *Connector) fetchMediaConfig(ctx context.Context) { func (br *Connector) UpdateBotProfile(ctx context.Context) { br.Log.Debug().Msg("Updating bot profile") - botConfig := &br.Config.AppService.Bot - - var err error - var mxc id.ContentURI - if botConfig.Avatar == "remove" { - err = br.Bot.SetAvatarURL(ctx, mxc) - } else if !botConfig.ParsedAvatar.IsEmpty() { - err = br.Bot.SetAvatarURL(ctx, botConfig.ParsedAvatar) - } - if err != nil { - br.Log.Warn().Err(err).Msg("Failed to update bot avatar") - } - - if botConfig.Displayname == "remove" { - err = br.Bot.SetDisplayName(ctx, "") - } else if len(botConfig.Displayname) > 0 { - err = br.Bot.SetDisplayName(ctx, botConfig.Displayname) - } - if err != nil { - br.Log.Warn().Err(err).Msg("Failed to update bot displayname") - } - - if br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) { - br.Log.Debug().Msg("Setting contact info on the appservice bot") - netName := br.Bridge.Network.GetName() - err = br.Bot.BeeperUpdateProfile(ctx, event.BeeperProfileExtra{ + netName := br.Bridge.Network.GetName() + err := br.Bot.SetProfile(ctx, event.ExtendedProfile[event.BeeperProfileExtra]{ + StandardProfile: event.StandardProfile{ + Displayname: br.Config.AppService.Bot.Displayname, + AvatarURL: br.Config.AppService.Bot.ParsedAvatar.CUString(), + }, + Extra: event.BeeperProfileExtra{ Service: netName.BeeperBridgeType, Network: netName.NetworkID, IsBridgeBot: true, - }) - if err != nil { - br.Log.Warn().Err(err).Msg("Failed to update bot contact info") - } + }, + }) + if err != nil { + br.Log.Warn().Err(err).Msg("Failed to update bot profile") } } diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index 7f6ebbb9..8df4ae22 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -412,6 +412,10 @@ func (as *ASIntent) SetExtraProfileMeta(ctx context.Context, data any) error { return as.Matrix.BeeperUpdateProfile(ctx, data) } +func (as *ASIntent) SetProfile(ctx context.Context, profile any) error { + return as.Matrix.SetProfile(ctx, profile) +} + func (as *ASIntent) GetMXID() id.UserID { return as.Matrix.UserID } diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index 31490bb3..c738e235 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -167,8 +167,7 @@ appservice: bot: # Username of the appservice bot. username: $<<.NetworkID>>bot - # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty - # to leave display name/avatar as-is. + # Display name and avatar for bot. If empty, profile info won't be changed. displayname: $<<.DisplayName>> bridge bot avatar: $<<.NetworkIcon>> diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index 9fb0c82d..58e3005a 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -113,9 +113,13 @@ type MatrixAPI interface { UploadMedia(ctx context.Context, roomID id.RoomID, data []byte, fileName, mimeType string) (url id.ContentURIString, file *event.EncryptedFileInfo, err error) UploadMediaStream(ctx context.Context, roomID id.RoomID, size int64, requireFile bool, cb FileStreamCallback) (url id.ContentURIString, file *event.EncryptedFileInfo, err error) + // Deprecated: use SetProfile instead SetDisplayName(ctx context.Context, name string) error + // Deprecated: use SetProfile instead SetAvatarURL(ctx context.Context, avatarURL id.ContentURIString) error + // Deprecated: use SetProfile instead SetExtraProfileMeta(ctx context.Context, data any) error + SetProfile(ctx context.Context, profile any) error CreateRoom(ctx context.Context, req *mautrix.ReqCreateRoom) (id.RoomID, error) DeleteRoom(ctx context.Context, roomID id.RoomID, puppetsOnly bool) error diff --git a/client.go b/client.go index edbeedfe..4dfbc6b8 100644 --- a/client.go +++ b/client.go @@ -20,6 +20,7 @@ import ( "time" "github.com/rs/zerolog" + "github.com/tidwall/gjson" "go.mau.fi/util/ptr" "go.mau.fi/util/retryafter" "golang.org/x/exp/maps" @@ -1030,7 +1031,64 @@ func (cli *Client) SetAvatarURL(ctx context.Context, url id.ContentURI) (err err return nil } +// SetProfile replaces the user's entire profile. +// +// If MSC4133 (https://github.com/matrix-org/matrix-spec-proposals/pull/4133) is supported, this is a single PUT call. +// Otherwise, the provided data will be parsed and the displayname and avatar are sent in separate requests. +func (cli *Client) SetProfile(ctx context.Context, data any) (err error) { + return cli.setOrUpdateProfile(ctx, data, http.MethodPut) +} + +// UpdateProfile updates the provided fields in the user's entire profile. +// +// If MSC4133 (https://github.com/matrix-org/matrix-spec-proposals/pull/4133) is supported, this is a single PATCH call. +// Otherwise, the provided data will be parsed and the displayname and avatar are sent in separate requests. +func (cli *Client) UpdateProfile(ctx context.Context, data any) (err error) { + return cli.setOrUpdateProfile(ctx, data, http.MethodPatch) +} + +func (cli *Client) setOrUpdateProfile(ctx context.Context, data any, method string) (err error) { + if cli.SpecVersions.Supports(FeatureExtendedProfiles) || cli.SpecVersions.Supports(BeeperFeatureArbitraryProfileMeta) { + urlPath := cli.BuildClientURL("v3", "profile", cli.UserID) + _, err = cli.MakeRequest(ctx, method, urlPath, data, nil) + } else if cli.SpecVersions.Supports(UnstableFeatureExtendedProfiles) { + urlPath := cli.BuildClientURL("unstable", "uk.tcpip.msc4133", "profile", cli.UserID) + _, err = cli.MakeRequest(ctx, method, urlPath, data, nil) + } else { + var dataJSON []byte + dataJSON, err = json.Marshal(data) + if err != nil { + return err + } + vals := gjson.GetManyBytes(dataJSON, "displayname", "avatar_url") + if vals[0].Exists() || method == http.MethodPut { + err = cli.SetDisplayName(ctx, vals[0].Str) + if err != nil { + return fmt.Errorf("failed to set display name: %w", err) + } + } + if vals[1].Exists() { + parsed, err := id.ParseContentURI(vals[1].Str) + if err != nil { + return fmt.Errorf("failed to parse avatar URL: %w", err) + } + err = cli.SetAvatarURL(ctx, parsed) + if err != nil { + return fmt.Errorf("failed to set avatar URL: %w", err) + } + } else if method == http.MethodPut { + err = cli.SetAvatarURL(ctx, id.ContentURI{}) + if err != nil { + return fmt.Errorf("failed to set avatar URL: %w", err) + } + } + } + return +} + // BeeperUpdateProfile sets custom fields in the user's profile. +// +// Deprecated: Updating profiles is being added to the Matrix spec in MSC4133. Use UpdateProfile instead. func (cli *Client) BeeperUpdateProfile(ctx context.Context, data any) (err error) { urlPath := cli.BuildClientURL("v3", "profile", cli.UserID) _, err = cli.MakeRequest(ctx, http.MethodPatch, urlPath, data, nil) diff --git a/event/member.go b/event/member.go index ebafdcb7..1a731a3e 100644 --- a/event/member.go +++ b/event/member.go @@ -7,7 +7,9 @@ package event import ( + "bytes" "encoding/json" + "errors" "maunium.net/go/mautrix/id" ) @@ -43,6 +45,43 @@ type MemberEventContent struct { Reason string `json:"reason,omitempty"` } +type StandardProfile struct { + Displayname string `json:"displayname,omitempty"` + AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` +} + +type ExtendedProfile[T any] struct { + StandardProfile + Extra T +} + +func (ep *ExtendedProfile[T]) MarshalJSON() ([]byte, error) { + data, err := json.Marshal(ep.StandardProfile) + if err != nil { + return nil, err + } + extraData, err := json.Marshal(ep.Extra) + if err != nil { + return nil, err + } + if len(extraData) == 0 || bytes.Equal(extraData, []byte("{}")) || bytes.Equal(extraData, []byte("null")) { + return data, nil + } else if extraData[0] != '{' || extraData[len(extraData)-1] != '}' { + return nil, errors.New("unexpected type marshaling profile extra data: not an object") + } + data[len(data)-1] = ',' + data = append(data, extraData[1:]...) + return data, nil +} + +func (ep *ExtendedProfile[T]) UnmarshalJSON(data []byte) error { + err := json.Unmarshal(data, &ep.StandardProfile) + if err != nil { + return err + } + return json.Unmarshal(data, &ep.Extra) +} + type ThirdPartyInvite struct { DisplayName string `json:"display_name"` Signed struct { diff --git a/versions.go b/versions.go index 672018ff..d468e689 100644 --- a/versions.go +++ b/versions.go @@ -64,6 +64,9 @@ var ( FeatureAppservicePing = UnstableFeature{UnstableFlag: "fi.mau.msc2659.stable", SpecVersion: SpecV17} FeatureAuthenticatedMedia = UnstableFeature{UnstableFlag: "org.matrix.msc3916.stable", SpecVersion: SpecV111} + UnstableFeatureExtendedProfiles = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133"} + FeatureExtendedProfiles = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133.stable"} + BeeperFeatureHungry = UnstableFeature{UnstableFlag: "com.beeper.hungry"} BeeperFeatureBatchSending = UnstableFeature{UnstableFlag: "com.beeper.batch_sending"} BeeperFeatureRoomYeeting = UnstableFeature{UnstableFlag: "com.beeper.room_yeeting"}