diff --git a/pkg/connector/avatars.go b/pkg/connector/avatars.go new file mode 100644 index 0000000..f8d3ea7 --- /dev/null +++ b/pkg/connector/avatars.go @@ -0,0 +1,119 @@ +package connector + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +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. 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() + 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 +} + +// 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 + } + 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..9368cd5 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -48,6 +48,11 @@ type RedditClient struct { typingMu sync.Mutex typing map[networkid.PortalKey][]id.UserID + + avatarMu sync.Mutex + avatarURLs map[networkid.UserID]string + avatarFetching map[networkid.UserID]bool + avatarRetryAt time.Time } 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 +}