From 597b758280bed341bb910cff4332a5d8dbaf4d85 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Thu, 29 Oct 2026 15:51:05 +0000 Subject: [PATCH 1/2] PLAT-39403 connector: bridge Reddit avatars from the chat web API Reddit's chat Matrix server returns no avatar in profiles, member events or the user directory, so every ghost was marked as having none. Reddit's own chat UI reads them from the ChatRedditors GraphQL operation. Co-Authored-By: Claude Opus 5.5 --- pkg/connector/avatars.go | 90 +++++++++++++++++++++++++++++++++++++ pkg/connector/chatinfo.go | 19 +++----- pkg/connector/chatsync.go | 4 +- pkg/connector/client.go | 3 ++ pkg/connector/requests.go | 4 +- pkg/redditchat/redditors.go | 27 +++++++++++ 6 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 pkg/connector/avatars.go create mode 100644 pkg/redditchat/redditors.go diff --git a/pkg/connector/avatars.go b/pkg/connector/avatars.go new file mode 100644 index 0000000..f2a09f9 --- /dev/null +++ b/pkg/connector/avatars.go @@ -0,0 +1,90 @@ +package connector + +import ( + "context" + "fmt" + "net/http" + "slices" + "time" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +const redditorBatchSize = 50 + +var avatarHTTPClient = &http.Client{Timeout: 30 * time.Second} + +// Reddit's chat Matrix server returns no avatars, so they come from the web +// session. URLs are cached for the client's lifetime; a missing icon is cached +// as empty so it isn't requested on every resync. +func (r *RedditClient) redditorAvatars(ctx context.Context, userIDs []networkid.UserID) map[networkid.UserID]*bridgev2.Avatar { + if r.session == nil { + return nil + } + r.avatarMu.Lock() + defer r.avatarMu.Unlock() + var missing []string + for _, userID := range userIDs { + if _, cached := r.avatarURLs[userID]; !cached && isRedditUserID(userID) { + missing = append(missing, string(userID)) + } + } + for chunk := range slices.Chunk(missing, redditorBatchSize) { + icons, err := r.session.GetRedditorIcons(ctx, chunk) + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to fetch Reddit avatars") + break + } + if r.avatarURLs == nil { + r.avatarURLs = make(map[networkid.UserID]string) + } + for _, userID := range chunk { + r.avatarURLs[networkid.UserID(userID)] = icons[userID] + } + } + avatars := make(map[networkid.UserID]*bridgev2.Avatar, len(userIDs)) + for _, userID := range userIDs { + if url := r.avatarURLs[userID]; url != "" { + avatars[userID] = &bridgev2.Avatar{ + ID: networkid.AvatarID(url), + Get: func(ctx context.Context) ([]byte, error) { return downloadRedditorAvatar(ctx, url) }, + } + } + } + return avatars +} + +func (r *RedditClient) addMemberAvatars(ctx context.Context, info *bridgev2.ChatInfo) *bridgev2.ChatInfo { + if info.Members == nil { + return info + } + userIDs := make([]networkid.UserID, 0, len(info.Members.MemberMap)) + for _, member := range info.Members.MemberMap { + userIDs = append(userIDs, member.Sender) + } + avatars := r.redditorAvatars(ctx, userIDs) + for _, member := range info.Members.MemberMap { + if avatar := avatars[member.Sender]; avatar != nil && member.UserInfo != nil { + member.UserInfo.Avatar = avatar + } + } + return info +} + +func downloadRedditorAvatar(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := avatarHTTPClient.Do(req) + if err != nil { + return nil, mediaError(ctx, "Reddit avatar download", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("reddit avatar is unavailable (HTTP %d)", resp.StatusCode) + } + return readMediaBounded(resp.Body, 2*1024*1024) +} diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index a351a79..2d60014 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -21,7 +21,7 @@ func (r *RedditClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) if key != portal.PortalKey { return nil, errors.New("chat ownership does not match Reddit room state") } - return r.chatInfoFromState(state), nil + return r.addMemberAvatars(ctx, r.chatInfoFromState(state)), nil } // GetUserInfo fetches profile info for a ghost (a Reddit user puppeted into @@ -40,19 +40,10 @@ func (r *RedditClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) ( Username: stripUserPrefix(name), UserID: string(ghost.ID), }) - info := &bridgev2.UserInfo{Name: ptr.Ptr(formatted)} - if !resp.AvatarURL.IsEmpty() { - mxc := resp.AvatarURL.CUString() - info.Avatar = &bridgev2.Avatar{ - ID: networkid.AvatarID(mxc), - Get: func(ctx context.Context) ([]byte, error) { return r.downloadRedditMedia(ctx, mxc, nil) }, - } - if r.main.directMedia { - // Retain the normal download fallback if the identity cannot fit. - info.Avatar.MXC, _ = r.directMediaURI(ctx, mxc) - } - } - return info, nil + return &bridgev2.UserInfo{ + Name: ptr.Ptr(formatted), + Avatar: r.redditorAvatars(ctx, []networkid.UserID{ghost.ID})[ghost.ID], + }, nil } func stripUserPrefix(name string) string { diff --git a/pkg/connector/chatsync.go b/pkg/connector/chatsync.go index 0e37f9e..a9b7ed2 100644 --- a/pkg/connector/chatsync.go +++ b/pkg/connector/chatsync.go @@ -329,9 +329,9 @@ func (r *RedditClient) joinedRoomEvent(state *RoomState, key networkid.PortalKey info := r.chatInfoFromState(state) return &simplevent.ChatResync{ EventMeta: simplevent.EventMeta{Type: bridgev2.RemoteEventChatResync, PortalKey: key, CreatePortal: true}, - GetChatInfoFunc: func(context.Context, *bridgev2.Portal) (*bridgev2.ChatInfo, error) { + GetChatInfoFunc: func(ctx context.Context, _ *bridgev2.Portal) (*bridgev2.ChatInfo, error) { // Account block state may change while this room snapshot is queued. - return r.chatInfoFromState(state), nil + return r.addMemberAvatars(ctx, r.chatInfoFromState(state)), nil }, CheckNeedsBackfillFunc: func(_ context.Context, latest *database.Message) (bool, error) { return info.CanBackfill && (limited || latest == nil), nil diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 06a03c9..c9e44ff 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -48,6 +48,9 @@ type RedditClient struct { typingMu sync.Mutex typing map[networkid.PortalKey][]id.UserID + + avatarMu sync.Mutex + avatarURLs map[networkid.UserID]string } var _ bridgev2.NetworkAPI = (*RedditClient)(nil) diff --git a/pkg/connector/requests.go b/pkg/connector/requests.go index 25c609b..4b6fc3d 100644 --- a/pkg/connector/requests.go +++ b/pkg/connector/requests.go @@ -143,8 +143,8 @@ func (r *RedditClient) pendingRequestEvent(state *RoomState, key networkid.Porta info := r.chatInfoFromState(state) return &simplevent.ChatResync{ EventMeta: simplevent.EventMeta{Type: bridgev2.RemoteEventChatResync, PortalKey: key, CreatePortal: true}, - GetChatInfoFunc: func(context.Context, *bridgev2.Portal) (*bridgev2.ChatInfo, error) { - return r.chatInfoFromState(state), nil + GetChatInfoFunc: func(ctx context.Context, _ *bridgev2.Portal) (*bridgev2.ChatInfo, error) { + return r.addMemberAvatars(ctx, r.chatInfoFromState(state)), nil }, CheckNeedsBackfillFunc: func(_ context.Context, latest *database.Message) (bool, error) { // Populate an existing empty request room too. Subsequent resyncs diff --git a/pkg/redditchat/redditors.go b/pkg/redditchat/redditors.go new file mode 100644 index 0000000..900c3c2 --- /dev/null +++ b/pkg/redditchat/redditors.go @@ -0,0 +1,27 @@ +package redditchat + +import "context" + +// GetRedditorIcons returns avatar URLs keyed by t2_ ID. Reddit's chat Matrix +// server has no avatars; the integrated chat UI reads them from here. +func (s *RedditSession) GetRedditorIcons(ctx context.Context, redditorIDs []string) (map[string]string, error) { + var data struct { + RedditorsInfoByIDs []struct { + ID string `json:"id"` + Icon *struct { + URL string `json:"url"` + } `json:"icon"` + } `json:"redditorsInfoByIds"` + } + err := s.chatGraphQL(ctx, "ChatRedditors", map[string]any{"ids": redditorIDs, "includeRedditorAttributes": true}, &data) + if err != nil { + return nil, err + } + icons := make(map[string]string, len(data.RedditorsInfoByIDs)) + for _, redditor := range data.RedditorsInfoByIDs { + if redditor.Icon != nil && redditor.Icon.URL != "" { + icons[redditor.ID] = redditor.Icon.URL + } + } + return icons, nil +} From a52376d1c9da803804efeb56d05ef7e835f9bf89 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Thu, 29 Oct 2026 16:01:32 +0000 Subject: [PATCH 2/2] PLAT-39403 connector: look avatars up outside the lock and back off after failures Co-Authored-By: Claude Opus 5.5 --- pkg/connector/avatars.go | 75 ++++++++++++++++++++++++++++------------ pkg/connector/client.go | 6 ++-- 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/pkg/connector/avatars.go b/pkg/connector/avatars.go index f2a09f9..f8d3ea7 100644 --- a/pkg/connector/avatars.go +++ b/pkg/connector/avatars.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "slices" "time" "github.com/rs/zerolog" @@ -12,38 +11,25 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" ) -const redditorBatchSize = 50 +const ( + redditorBatchSize = 50 + avatarRetryDelay = 5 * time.Minute +) var avatarHTTPClient = &http.Client{Timeout: 30 * time.Second} // Reddit's chat Matrix server returns no avatars, so they come from the web -// session. URLs are cached for the client's lifetime; a missing icon is cached -// as empty so it isn't requested on every resync. +// session. A missing icon is cached as empty so it isn't requested on every +// resync, and failed lookups back off rather than repeat on every sync batch. func (r *RedditClient) redditorAvatars(ctx context.Context, userIDs []networkid.UserID) map[networkid.UserID]*bridgev2.Avatar { if r.session == nil { return nil } + if missing := r.claimUncachedRedditors(userIDs); len(missing) > 0 { + r.fetchRedditorIcons(ctx, missing) + } r.avatarMu.Lock() defer r.avatarMu.Unlock() - var missing []string - for _, userID := range userIDs { - if _, cached := r.avatarURLs[userID]; !cached && isRedditUserID(userID) { - missing = append(missing, string(userID)) - } - } - for chunk := range slices.Chunk(missing, redditorBatchSize) { - icons, err := r.session.GetRedditorIcons(ctx, chunk) - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to fetch Reddit avatars") - break - } - if r.avatarURLs == nil { - r.avatarURLs = make(map[networkid.UserID]string) - } - for _, userID := range chunk { - r.avatarURLs[networkid.UserID(userID)] = icons[userID] - } - } avatars := make(map[networkid.UserID]*bridgev2.Avatar, len(userIDs)) for _, userID := range userIDs { if url := r.avatarURLs[userID]; url != "" { @@ -56,6 +42,49 @@ func (r *RedditClient) redditorAvatars(ctx context.Context, userIDs []networkid. return avatars } +// The lookup itself runs without avatarMu, which the sync path takes. Claimed +// IDs are skipped by concurrent callers until it finishes. +func (r *RedditClient) claimUncachedRedditors(userIDs []networkid.UserID) []string { + r.avatarMu.Lock() + defer r.avatarMu.Unlock() + if time.Now().Before(r.avatarRetryAt) { + return nil + } + if r.avatarURLs == nil { + r.avatarURLs = make(map[networkid.UserID]string) + r.avatarFetching = make(map[networkid.UserID]bool) + } + var missing []string + for _, userID := range userIDs { + if len(missing) == redditorBatchSize { + break + } + if _, cached := r.avatarURLs[userID]; !cached && !r.avatarFetching[userID] && isRedditUserID(userID) { + r.avatarFetching[userID] = true + missing = append(missing, string(userID)) + } + } + return missing +} + +func (r *RedditClient) fetchRedditorIcons(ctx context.Context, redditorIDs []string) { + icons, err := r.session.GetRedditorIcons(ctx, redditorIDs) + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to fetch Reddit avatars") + } + r.avatarMu.Lock() + defer r.avatarMu.Unlock() + if err != nil { + r.avatarRetryAt = time.Now().Add(avatarRetryDelay) + } + for _, redditorID := range redditorIDs { + delete(r.avatarFetching, networkid.UserID(redditorID)) + if err == nil { + r.avatarURLs[networkid.UserID(redditorID)] = icons[redditorID] + } + } +} + func (r *RedditClient) addMemberAvatars(ctx context.Context, info *bridgev2.ChatInfo) *bridgev2.ChatInfo { if info.Members == nil { return info diff --git a/pkg/connector/client.go b/pkg/connector/client.go index c9e44ff..9368cd5 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -49,8 +49,10 @@ type RedditClient struct { typingMu sync.Mutex typing map[networkid.PortalKey][]id.UserID - avatarMu sync.Mutex - avatarURLs map[networkid.UserID]string + avatarMu sync.Mutex + avatarURLs map[networkid.UserID]string + avatarFetching map[networkid.UserID]bool + avatarRetryAt time.Time } var _ bridgev2.NetworkAPI = (*RedditClient)(nil)