From 69a777c81fd3abc08fa67ea111c50030bc217e9f Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Thu, 17 Sep 2026 21:36:19 +0800 Subject: [PATCH] feat: apply configured default user avatars --- server/default_avatar.go | 77 +++++++++++++++++++++++++++++++++++ server/default_avatar_test.go | 60 +++++++++++++++++++++++++++ server/im_http.go | 12 +++++- 3 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 server/default_avatar.go create mode 100644 server/default_avatar_test.go diff --git a/server/default_avatar.go b/server/default_avatar.go new file mode 100644 index 0000000..aef045c --- /dev/null +++ b/server/default_avatar.go @@ -0,0 +1,77 @@ +package main + +import ( + "encoding/json" + "hash/fnv" + "strings" + + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +const ( + imAssetsConfigGroup = "im_assets" + imDefaultMaleAvatarURLsConfig = "default_user_avatar_male_urls" + imDefaultFemaleAvatarURLsConfig = "default_user_avatar_female_urls" +) + +func applyDefaultUserAvatar(data map[string]any, userID, gender string) { + if data == nil || strings.TrimSpace(stringFromAny(data["avatar_file_id"])) != "" { + return + } + if avatarURL := defaultUserAvatarURL(userID, gender); avatarURL != "" { + data["avatar_file_id"] = avatarURL + } +} + +func defaultUserAvatarURL(userID, gender string) string { + if store.ThirdPartyConfig == nil { + return "" + } + values, err := store.ThirdPartyConfig.Get(imAssetsConfigGroup, imDefaultMaleAvatarURLsConfig, imDefaultFemaleAvatarURLsConfig) + if err != nil { + return "" + } + key := imDefaultMaleAvatarURLsConfig + if strings.EqualFold(strings.TrimSpace(gender), "female") { + key = imDefaultFemaleAvatarURLsConfig + } + urls := parseDefaultAvatarURLs(values[key]) + if len(urls) == 0 && key == imDefaultFemaleAvatarURLsConfig { + urls = parseDefaultAvatarURLs(values[imDefaultMaleAvatarURLsConfig]) + } + if len(urls) == 0 { + return "" + } + return urls[defaultAvatarIndex(userID, len(urls))] +} + +func parseDefaultAvatarURLs(raw string) []string { + var urls []string + if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &urls); err != nil { + return nil + } + out := urls[:0] + for _, url := range urls { + if url = strings.TrimSpace(url); url != "" { + out = append(out, url) + } + } + return out +} + +func defaultAvatarIndex(userID string, count int) int { + if count <= 1 { + return 0 + } + uid := types.ParseUserId(strings.TrimSpace(userID)) + if uid.IsZero() { + uid = types.ParseUid(strings.TrimSpace(userID)) + } + if !uid.IsZero() { + return int(uint64(uid) % uint64(count)) + } + h := fnv.New32a() + _, _ = h.Write([]byte(userID)) + return int(h.Sum32() % uint32(count)) +} diff --git a/server/default_avatar_test.go b/server/default_avatar_test.go new file mode 100644 index 0000000..d1fbf6e --- /dev/null +++ b/server/default_avatar_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +type defaultAvatarConfigStore map[string]string + +func (s defaultAvatarConfigStore) Get(_ string, keys ...string) (map[string]string, error) { + out := make(map[string]string, len(keys)) + for _, key := range keys { + out[key] = s[key] + } + return out, nil +} + +func TestUserSummaryPayloadAppliesDefaultAvatarByGenderAndUserID(t *testing.T) { + oldConfig := store.ThirdPartyConfig + store.ThirdPartyConfig = defaultAvatarConfigStore{ + imDefaultMaleAvatarURLsConfig: `["m0","m1","m2","m3","m4"]`, + imDefaultFemaleAvatarURLsConfig: `["f0","f1","f2","f3","f4"]`, + } + defer func() { store.ThirdPartyConfig = oldConfig }() + + for i, gender := range []string{"male", "female"} { + uid := types.Uid(10 + i) + payload := userSummaryPayload(&types.User{ + ObjHeader: types.ObjHeader{Id: uid.String()}, + Public: map[string]any{"display_name": "Alice", "gender": gender}, + }, "") + wantPrefix := "m" + if gender == "female" { + wantPrefix = "f" + } + want := fmt.Sprintf("%s%d", wantPrefix, uint64(uid)%5) + if payload["avatar_file_id"] != want { + t.Fatalf("gender=%s avatar=%v want %s", gender, payload["avatar_file_id"], want) + } + } +} + +func TestUserSummaryPayloadKeepsUploadedAvatar(t *testing.T) { + oldConfig := store.ThirdPartyConfig + store.ThirdPartyConfig = defaultAvatarConfigStore{ + imDefaultMaleAvatarURLsConfig: `["m0","m1","m2","m3","m4"]`, + } + defer func() { store.ThirdPartyConfig = oldConfig }() + + payload := userSummaryPayload(&types.User{ + ObjHeader: types.ObjHeader{Id: types.Uid(10).String()}, + Public: map[string]any{"display_name": "Alice", "gender": "male", "avatar_file_id": "uploaded.png"}, + }, "") + if payload["avatar_file_id"] != "uploaded.png" { + t.Fatalf("avatar=%v", payload["avatar_file_id"]) + } +} diff --git a/server/im_http.go b/server/im_http.go index cead564..fe63ea2 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -3252,10 +3252,12 @@ func serveIMMe(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } + profilePayload := userProfilePayload10(profile) + applyDefaultUserAvatar(profilePayload, principal.Uid.UserId(), profile.Gender) writeIMSuccess(wrt, req, map[string]any{ "user_id": principal.Uid.UserId(), "username": account["username"], - "profile": userProfilePayload10(profile), + "profile": profilePayload, "profile_version": profile.Version, "realname": map[string]any{"status": "unknown"}, "youth_mode": map[string]any{"enabled": false}, @@ -10104,6 +10106,7 @@ func currentUserProfilePayload(tenantID types.TenantID, uid types.Uid, profile * return nil, err } data := userProfilePayload10(profile) + applyDefaultUserAvatar(data, uid.UserId(), profile.Gender) data["phone_mask"] = account["phone_mask"] data["username"] = account["username"] return data, nil @@ -10721,10 +10724,14 @@ func userSummaryPayload(user *types.User, remark string) map[string]any { userID = user.Id } data := map[string]any{"user_id": userID} + gender := "" if public, ok := user.Public.(map[string]any); ok { copyUserPublicSummary(data, public) + gender = stringFromAny(public["gender"]) } else if public, ok := user.Public.(types.KVMap); ok { - copyUserPublicSummary(data, map[string]any(public)) + publicMap := map[string]any(public) + copyUserPublicSummary(data, publicMap) + gender = stringFromAny(publicMap["gender"]) } if remark != "" { data["display_name"] = remark @@ -10732,6 +10739,7 @@ func userSummaryPayload(user *types.User, remark string) map[string]any { if _, ok := data["display_name"]; !ok { data["display_name"] = userID } + applyDefaultUserAvatar(data, userID, gender) return data }