Repository navigation
PLAT-39403 connector: bridge Reddit avatars from the chat web API #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚪️ Nit · Only the first 50 uncached members get avatars per chat sync
Claim all uncached IDs and fetch them in chunks outside the lock, releasing the claims for unfetched batches on error. Or loop until nothing is left to claim. Found by Indent Review Agent |
||
| 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) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚪️ Nit · Missing redditorsInfoByIds caches every requested user as having no avatar If the response has data but no Found by Indent Review Agent |
||
| 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 | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.