From ddcf2d31ca33fab3f06e75cf137503ea886836c3 Mon Sep 17 00:00:00 2001 From: A Date: Sun, 5 Jul 2026 15:15:32 +0800 Subject: [PATCH] fix: sync birthday updates and system sticker sets --- cmd/stickerfetch/main.go | 96 +++++++++----- cmd/stickerfetch/main_test.go | 68 ++++++++++ cmd/stickerseeddeploy/main.go | 1 + internal/app/files/seed.go | 39 +++++- internal/app/files/seed_test.go | 149 ++++++++++++++++++++++ internal/domain/media.go | 2 + internal/rpc/account.go | 16 ++- internal/rpc/account_birthday_rpc_test.go | 84 ++++++++++++ internal/rpc/convert_media.go | 10 ++ internal/rpc/stickers_test.go | 1 + 10 files changed, 431 insertions(+), 35 deletions(-) create mode 100644 cmd/stickerfetch/main_test.go create mode 100644 internal/rpc/account_birthday_rpc_test.go diff --git a/cmd/stickerfetch/main.go b/cmd/stickerfetch/main.go index 28304883..7cab7cfc 100644 --- a/cmd/stickerfetch/main.go +++ b/cmd/stickerfetch/main.go @@ -4,10 +4,13 @@ // SeedMedia + handler 识别下发,无需改服务端代码。 // // 支持的 set spec(命令行参数,可多个): -// short: 按 short_name 拉(普通/mask/emoji 集) -// emoji_default_statuses inputStickerSetEmojiDefaultStatuses -// emoji_channel_default_statuses inputStickerSetEmojiChannelDefaultStatuses -// emoji_default_topic_icons inputStickerSetEmojiDefaultTopicIcons +// +// short: 按 short_name 拉(普通/mask/emoji 集) +// emoji_default_statuses inputStickerSetEmojiDefaultStatuses +// emoji_channel_default_statuses inputStickerSetEmojiChannelDefaultStatuses +// emoji_default_topic_icons inputStickerSetEmojiDefaultTopicIcons +// premium_gifts inputStickerSetPremiumGifts +// ton_gifts inputStickerSetTonGifts // // 需登录会话(复用 appearancefetch 的 /tmp/appearance.session,SESSION env 可覆盖)。 // @@ -52,6 +55,10 @@ func specToInput(spec string) (tg.InputStickerSetClass, string, error) { return &tg.InputStickerSetEmojiChannelDefaultStatuses{}, "EmojiChannelDefaultStatuses", nil case spec == "emoji_default_topic_icons": return &tg.InputStickerSetEmojiDefaultTopicIcons{}, "EmojiDefaultTopicIcons", nil + case spec == "premium_gifts": + return &tg.InputStickerSetPremiumGifts{}, "PremiumGifts", nil + case spec == "ton_gifts": + return &tg.InputStickerSetTonGifts{}, "TonGifts", nil default: return nil, "", fmt.Errorf("unknown spec %q", spec) } @@ -60,20 +67,20 @@ func specToInput(spec string) (tg.InputStickerSetClass, string, error) { // ---- seed JSON 结构(字段对齐 internal/app/files/seed.go 的解析) ---- type attrJSON struct { - Type string `json:"_"` - W int `json:"w,omitempty"` - H int `json:"h,omitempty"` - Alt string `json:"alt,omitempty"` - Mask bool `json:"mask,omitempty"` - Free bool `json:"free,omitempty"` - TextColor bool `json:"text_color,omitempty"` - FileName string `json:"file_name,omitempty"` - Duration float64 `json:"duration,omitempty"` - RoundMessage bool `json:"round_message,omitempty"` - SupportsStreaming bool `json:"supports_streaming,omitempty"` - Voice bool `json:"voice,omitempty"` - Title string `json:"title,omitempty"` - Performer string `json:"performer,omitempty"` + Type string `json:"_"` + W int `json:"w,omitempty"` + H int `json:"h,omitempty"` + Alt string `json:"alt,omitempty"` + Mask bool `json:"mask,omitempty"` + Free bool `json:"free,omitempty"` + TextColor bool `json:"text_color,omitempty"` + FileName string `json:"file_name,omitempty"` + Duration float64 `json:"duration,omitempty"` + RoundMessage bool `json:"round_message,omitempty"` + SupportsStreaming bool `json:"supports_streaming,omitempty"` + Voice bool `json:"voice,omitempty"` + Title string `json:"title,omitempty"` + Performer string `json:"performer,omitempty"` Stickerset *inputSetRefJSON `json:"stickerset,omitempty"` } @@ -154,7 +161,7 @@ func mapAttrs(in []tg.DocumentAttributeClass) []attrJSON { func main() { if len(os.Args) < 3 { - fmt.Fprintln(os.Stderr, "usage: SESSION=/tmp/appearance.session stickerfetch [spec...]\n spec: short: | emoji_default_statuses | emoji_channel_default_statuses | emoji_default_topic_icons") + fmt.Fprintln(os.Stderr, "usage: SESSION=/tmp/appearance.session stickerfetch [spec...]\n spec: short: | emoji_default_statuses | emoji_channel_default_statuses | emoji_default_topic_icons | premium_gifts | ton_gifts | effects") os.Exit(2) } out := os.Args[1] @@ -213,8 +220,9 @@ func fetchSet(ctx context.Context, api *tg.Client, dl *downloader.Downloader, ou return err } + fmt.Printf("[%s] set=%q id=%d docs=%d downloading...\n", spec, setName, full.Set.ID, len(full.Documents)) docs := make([]documentJSON, 0, len(full.Documents)) - for _, d := range full.Documents { + for i, d := range full.Documents { doc, ok := d.(*tg.Document) if !ok { continue @@ -227,16 +235,12 @@ func fetchSet(ctx context.Context, api *tg.Client, dl *downloader.Downloader, ou ext = ".webp" } path := filepath.Join(stickersDir, fmt.Sprintf("%d%s", doc.ID, ext)) - f, err := os.Create(path) - if err != nil { - return err + if !completeExistingFile(path, doc.Size) { + loc := &tg.InputDocumentFileLocation{ID: doc.ID, AccessHash: doc.AccessHash, FileReference: doc.FileReference} + if err := downloadToFile(ctx, dl, api, loc, stickersDir, path); err != nil { + return fmt.Errorf("download doc %d (%d/%d): %w", doc.ID, i+1, len(full.Documents), err) + } } - loc := &tg.InputDocumentFileLocation{ID: doc.ID, AccessHash: doc.AccessHash, FileReference: doc.FileReference} - if _, err := dl.Download(api, loc).Stream(ctx, f); err != nil { - f.Close() - return fmt.Errorf("download doc %d: %w", doc.ID, err) - } - f.Close() docs = append(docs, documentJSON{ ID: doc.ID, AccessHash: doc.AccessHash, @@ -247,6 +251,9 @@ func fetchSet(ctx context.Context, api *tg.Client, dl *downloader.Downloader, ou DCID: doc.DCID, Attributes: mapAttrs(doc.Attributes), }) + if (i+1)%25 == 0 || i+1 == len(full.Documents) { + fmt.Printf("[%s] docs %d/%d\n", spec, i+1, len(full.Documents)) + } } packs := make([]packJSON, 0, len(full.Packs)) @@ -285,6 +292,37 @@ func fetchSet(ctx context.Context, api *tg.Client, dl *downloader.Downloader, ou return nil } +func completeExistingFile(path string, wantSize int64) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + if wantSize > 0 { + return info.Size() == wantSize + } + return info.Size() > 0 +} + +func downloadToFile(ctx context.Context, dl *downloader.Downloader, api *tg.Client, loc tg.InputFileLocationClass, dir, path string) error { + tmp, err := os.CreateTemp(dir, ".download-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + _ = os.Remove(tmpPath) + }() + if _, err := dl.Download(api, loc).Stream(ctx, tmp); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + _ = os.Remove(path) + return os.Rename(tmpPath, path) +} + // ---- 消息特效(messages.getAvailableEffects)---- type effectJSON struct { diff --git a/cmd/stickerfetch/main_test.go b/cmd/stickerfetch/main_test.go new file mode 100644 index 00000000..c1bfcebd --- /dev/null +++ b/cmd/stickerfetch/main_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/gotd/td/tg" +) + +func TestSpecToInputSystemSets(t *testing.T) { + tests := []struct { + spec string + label string + want any + }{ + {"emoji_default_statuses", "EmojiDefaultStatuses", &tg.InputStickerSetEmojiDefaultStatuses{}}, + {"emoji_channel_default_statuses", "EmojiChannelDefaultStatuses", &tg.InputStickerSetEmojiChannelDefaultStatuses{}}, + {"emoji_default_topic_icons", "EmojiDefaultTopicIcons", &tg.InputStickerSetEmojiDefaultTopicIcons{}}, + {"premium_gifts", "PremiumGifts", &tg.InputStickerSetPremiumGifts{}}, + {"ton_gifts", "TonGifts", &tg.InputStickerSetTonGifts{}}, + } + for _, tt := range tests { + t.Run(tt.spec, func(t *testing.T) { + input, label, err := specToInput(tt.spec) + if err != nil { + t.Fatalf("specToInput: %v", err) + } + if label != tt.label { + t.Fatalf("label = %q, want %q", label, tt.label) + } + if got, want := fmt.Sprintf("%T", input), fmt.Sprintf("%T", tt.want); got != want { + t.Fatalf("input = %s, want %s", got, want) + } + }) + } +} + +func TestSpecToInputShortName(t *testing.T) { + input, label, err := specToInput("short:FestiveFontEmoji") + if err != nil { + t.Fatalf("specToInput short: %v", err) + } + short, ok := input.(*tg.InputStickerSetShortName) + if !ok { + t.Fatalf("input = %T, want short name", input) + } + if short.ShortName != "FestiveFontEmoji" || label != "FestiveFontEmoji" { + t.Fatalf("short input = (%q, %q), want FestiveFontEmoji", short.ShortName, label) + } +} + +func TestCompleteExistingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "doc.tgs") + if completeExistingFile(path, 4) { + t.Fatal("missing file reported complete") + } + if err := os.WriteFile(path, []byte("tgs!"), 0o644); err != nil { + t.Fatalf("write temp doc: %v", err) + } + if !completeExistingFile(path, 4) { + t.Fatal("matching file reported incomplete") + } + if completeExistingFile(path, 5) { + t.Fatal("mismatched file reported complete") + } +} diff --git a/cmd/stickerseeddeploy/main.go b/cmd/stickerseeddeploy/main.go index 1900803d..cb9e1615 100644 --- a/cmd/stickerseeddeploy/main.go +++ b/cmd/stickerseeddeploy/main.go @@ -37,6 +37,7 @@ var specialSeedDirs = map[string]string{ "emoji_default_statuses": "DefaultSet_EmojiDefaultStatuses", "emoji_default_topic_icons": "DefaultSet_EmojiDefaultTopicIcons", "premium_gifts": "DefaultSet_PremiumGifts", + "ton_gifts": "DefaultSet_TonGifts", } func main() { diff --git a/internal/app/files/seed.go b/internal/app/files/seed.go index 429549b8..9e65f680 100644 --- a/internal/app/files/seed.go +++ b/internal/app/files/seed.go @@ -295,7 +295,8 @@ func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey str return nil // 该目录无 set_info.json → 跳过 } var info struct { - Result seedStickerSetResultJSON `json:"result"` + InputSetType string `json:"input_set_type"` + Result seedStickerSetResultJSON `json:"result"` } if err := json.Unmarshal(raw, &info); err != nil { return fmt.Errorf("parse %s: %w", infoPath, err) @@ -304,13 +305,18 @@ func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey str if sj.ID == 0 { return nil } + if systemKey == "" { + systemKey = systemKeyForInputSetType(info.InputSetType) + } + kind := stickerSetKind(sj, systemKey) // 增量 seed:集已存在且内容 hash 未变则跳过(不重读文档/重传 blob)。force 时强制重导 // (缩略图内联缓存修复路径)。这让 seedStickerSets 可在非空 store 上每次启动安全重扫, - // 仅导入新增/变更集。 + // 仅导入新增/变更集;但系统集分类由导出元数据决定,旧库中 hash 相同但 system_key/kind + // 错误的记录必须重写,避免 constructor 方式取不到同一个资源集。 if !force { if existing, found, err := s.media.GetStickerSetByID(ctx, sj.ID); err != nil { return err - } else if found && existing.Hash == sj.Hash { + } else if found && existing.Hash == sj.Hash && existing.SystemKey == systemKey && existing.Kind == kind { return nil } } @@ -333,7 +339,6 @@ func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey str } } - kind := stickerSetKind(sj, systemKey) set := domain.StickerSet{ ID: sj.ID, AccessHash: sj.AccessHash, @@ -558,6 +563,8 @@ func systemKeyForDefaultSet(dirName string) string { return domain.StickerSetSystemKeyEmojiDefaultTopicIcons case "DefaultSet_PremiumGifts": return domain.StickerSetSystemKeyPremiumGifts + case "DefaultSet_TonGifts": + return domain.StickerSetSystemKeyTonGifts case "DefaultSet_Dice_Normal": return "dice:\U0001f3b2" case "DefaultSet_Dice_Dart": @@ -575,6 +582,30 @@ func systemKeyForDefaultSet(dirName string) string { } } +func systemKeyForInputSetType(inputSetType string) string { + normalized := strings.TrimSpace(inputSetType) + normalized = strings.TrimPrefix(normalized, "*") + normalized = strings.TrimPrefix(normalized, "tg.") + switch normalized { + case "InputStickerSetAnimatedEmoji": + return "animated_emoji" + case "InputStickerSetAnimatedEmojiAnimations": + return "animated_emoji_animations" + case "InputStickerSetEmojiGenericAnimations": + return "emoji_generic_animations" + case "InputStickerSetEmojiDefaultStatuses", "InputStickerSetEmojiChannelDefaultStatuses": + return domain.StickerSetSystemKeyEmojiDefaultStatuses + case "InputStickerSetEmojiDefaultTopicIcons": + return domain.StickerSetSystemKeyEmojiDefaultTopicIcons + case "InputStickerSetPremiumGifts": + return domain.StickerSetSystemKeyPremiumGifts + case "InputStickerSetTonGifts": + return domain.StickerSetSystemKeyTonGifts + default: + return "" + } +} + func stickerSetKind(sj seedStickerSetJSON, systemKey string) domain.StickerSetKind { switch { case systemKey != "": diff --git a/internal/app/files/seed_test.go b/internal/app/files/seed_test.go index 1fe801fb..023bf6b3 100644 --- a/internal/app/files/seed_test.go +++ b/internal/app/files/seed_test.go @@ -757,6 +757,155 @@ func TestSeedDocumentIDsAreImportedVerbatim(t *testing.T) { } } +func TestSeedMediaUsesInputSetTypeForSystemSets(t *testing.T) { + ctx := context.Background() + seedDir := t.TempDir() + cases := []struct { + name string + category string + dirName string + inputSetType string + shortName string + id int64 + docID int64 + emojis bool + wantSystem string + }{ + { + name: "default topic icons from emoji export", + category: "telegram_emoji_export", + dirName: "Topics_7173162320003082", + inputSetType: "*tg.InputStickerSetEmojiDefaultTopicIcons", + shortName: "Topics", + id: 7173162320003082, + docID: 5312536423851630001, + emojis: true, + wantSystem: domain.StickerSetSystemKeyEmojiDefaultTopicIcons, + }, + { + name: "premium gifts from fetched sticker export", + category: "telegram_stickers_export", + dirName: "PremiumGifts_328917524764688479", + inputSetType: "InputStickerSetPremiumGifts", + shortName: "PremiumGifts", + id: 328917524764688479, + docID: 5710643921839718407, + wantSystem: domain.StickerSetSystemKeyPremiumGifts, + }, + { + name: "ton gifts from fetched sticker export", + category: "telegram_stickers_export", + dirName: "TonGifts_9000000000000001", + inputSetType: "*tg.InputStickerSetTonGifts", + shortName: "TonGifts", + id: 9000000000000001, + docID: 9000000000000101, + wantSystem: domain.StickerSetSystemKeyTonGifts, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := filepath.Join(seedDir, tc.category, tc.dirName) + writeInputSetTypeSeed(t, dir, tc.inputSetType, tc.shortName, tc.id, tc.docID, tc.emojis) + + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("local fs: %v", err) + } + svc := NewService(media, blobs, 2) + if _, err := svc.SeedMedia(ctx, seedDir, 0); err != nil { + t.Fatalf("seed media: %v", err) + } + set, found, err := media.GetStickerSetBySystemKey(ctx, tc.wantSystem) + if err != nil || !found { + t.Fatalf("GetStickerSetBySystemKey(%q) found=%v err=%v", tc.wantSystem, found, err) + } + if set.Kind != domain.StickerSetKindSystem || set.SystemKey != tc.wantSystem { + t.Fatalf("set meta = %+v, want system key %q", set, tc.wantSystem) + } + if set.ID != tc.id || set.ShortName != tc.shortName || len(set.DocumentIDs) != 1 || set.DocumentIDs[0] != tc.docID { + t.Fatalf("set = %+v, want id=%d short=%s doc=%d", set, tc.id, tc.shortName, tc.docID) + } + resolved, docs, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{ + Kind: domain.StickerSetRefBySystem, + SystemKey: tc.wantSystem, + }) + if err != nil || !found { + t.Fatalf("ResolveStickerSet(%q) found=%v err=%v", tc.wantSystem, found, err) + } + if resolved.ID != tc.id || len(docs) != 1 || docs[0].ID != tc.docID { + t.Fatalf("resolved = set %d docs %+v, want set %d doc %d", resolved.ID, docs, tc.id, tc.docID) + } + }) + } +} + +func TestSeedMediaReclassifiesExistingSystemSetWhenHashUnchanged(t *testing.T) { + ctx := context.Background() + seedDir := t.TempDir() + const ( + setID int64 = 7173162320003082 + docID int64 = 5312536423851630001 + shortName = "Topics" + ) + writeInputSetTypeSeed(t, + filepath.Join(seedDir, "telegram_emoji_export", "Topics_7173162320003082"), + "*tg.InputStickerSetEmojiDefaultTopicIcons", + shortName, + setID, + docID, + true, + ) + + media := newFakeMediaStore() + if err := media.PutStickerSet(ctx, domain.StickerSet{ + ID: setID, + ShortName: shortName, + Hash: 17, + Kind: domain.StickerSetKindEmoji, + }); err != nil { + t.Fatalf("preload existing set: %v", err) + } + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("local fs: %v", err) + } + svc := NewService(media, blobs, 2) + if _, err := svc.SeedMedia(ctx, seedDir, 0); err != nil { + t.Fatalf("seed media: %v", err) + } + set, found, err := media.GetStickerSetBySystemKey(ctx, domain.StickerSetSystemKeyEmojiDefaultTopicIcons) + if err != nil || !found { + t.Fatalf("GetStickerSetBySystemKey(topic_icons) found=%v err=%v", found, err) + } + if set.Kind != domain.StickerSetKindSystem || set.SystemKey != domain.StickerSetSystemKeyEmojiDefaultTopicIcons { + t.Fatalf("set meta = %+v, want reclassified system set", set) + } + if len(set.DocumentIDs) != 1 || set.DocumentIDs[0] != docID { + t.Fatalf("set document ids = %+v, want [%d]", set.DocumentIDs, docID) + } +} + +func writeInputSetTypeSeed(t *testing.T, setDir, inputSetType, shortName string, setID, docID int64, emojis bool) { + t.Helper() + stickersDir := filepath.Join(setDir, "stickers") + if err := os.MkdirAll(stickersDir, 0o755); err != nil { + t.Fatal(err) + } + attrType := "DocumentAttributeSticker" + if emojis { + attrType = "DocumentAttributeCustomEmoji" + } + raw := fmt.Sprintf(`{"api_call":"messages.getStickerSet","input_set_type":%q,"result":{"_":"StickerSet","set":{"id":%d,"access_hash":2,"title":%q,"short_name":%q,"count":1,"hash":17,"emojis":%v},"packs":[{"emoticon":"🎁","documents":[%d]}],"documents":[{"id":%d,"access_hash":3,"file_reference":"","date":"2026-07-05T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"dc_id":4,"attributes":[{"_":"DocumentAttributeImageSize","w":512,"h":512},{"_":%q,"alt":"🎁","stickerset":{"id":%d,"access_hash":2}},{"_":"DocumentAttributeFilename","file_name":"AnimatedSticker.tgs"}],"thumbs":[]}]}}`, inputSetType, setID, shortName, shortName, emojis, docID, docID, attrType, setID) + if err := os.WriteFile(filepath.Join(setDir, "set_info.json"), []byte(raw), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stickersDir, fmt.Sprintf("system_%d.tgs", docID)), []byte("tgs!"), 0o644); err != nil { + t.Fatal(err) + } +} + func TestSeedStickerSetInstalledFlagNeverMarksViewerState(t *testing.T) { cases := []struct { name string diff --git a/internal/domain/media.go b/internal/domain/media.go index 7705f401..a19a1d58 100644 --- a/internal/domain/media.go +++ b/internal/domain/media.go @@ -680,6 +680,8 @@ const ( StickerSetSystemKeyEmojiDefaultTopicIcons = "emoji_default_topic_icons" // StickerSetSystemKeyPremiumGifts 对应 premium gifts 系统集。 StickerSetSystemKeyPremiumGifts = "premium_gifts" + // StickerSetSystemKeyTonGifts 对应 TON gifts 系统集。 + StickerSetSystemKeyTonGifts = "ton_gifts" ) // StickerSetKind 区分贴纸集用途(影响 getAllStickers / getEmojiStickers 归类)。 diff --git a/internal/rpc/account.go b/internal/rpc/account.go index d9bfc6d8..b4ab5b24 100644 --- a/internal/rpc/account.go +++ b/internal/rpc/account.go @@ -1292,8 +1292,8 @@ func (r *Router) onAccountUpdateUsername(ctx context.Context, username string) ( } // onAccountUpdateBirthday 持久化资料页生日(account.updateBirthday)。birthday 缺省即清除; -// 月/日/年非法返回 BIRTHDAY_INVALID。生日落在 userFull(按隐私 PrivacyKeyBirthday 对外裁剪), -// 故只需失效本人 userFull 投影缓存,客户端重拉 getFullUser 即见最新值。 +// 月/日/年非法返回 BIRTHDAY_INVALID。生日落在 userFull(按隐私 PrivacyKeyBirthday 对外裁剪)。 +// 写入后推 updateUser 信号给本人其它在线 session,促使已加载 full profile 的客户端重拉。 func (r *Router) onAccountUpdateBirthday(ctx context.Context, req *tg.AccountUpdateBirthdayRequest) (bool, error) { userID, _, err := r.currentUserID(ctx) if err != nil { @@ -1318,6 +1318,7 @@ func (r *Router) onAccountUpdateBirthday(ctx context.Context, req *tg.AccountUpd return false, internalErr() } r.invalidateRPCProjectionForUser(u.ID) + r.pushSelfUserChangedUpdate(ctx, u) return true, nil } @@ -1509,6 +1510,17 @@ func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) { }) } +func (r *Router) pushSelfUserChangedUpdate(ctx context.Context, u domain.User) { + if u.ID == 0 { + return + } + r.pushUserUpdates(ctx, u.ID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: u.ID}}, + Users: []tg.UserClass{r.tgSelfUser(u)}, + Date: int(r.clock.Now().Unix()), + }) +} + func tgAuthorization(a domain.Authorization, currentAuthKeyID [8]byte, now int) tg.Authorization { created := int(a.CreatedAt.Unix()) if created == 0 { diff --git a/internal/rpc/account_birthday_rpc_test.go b/internal/rpc/account_birthday_rpc_test.go new file mode 100644 index 00000000..d398d039 --- /dev/null +++ b/internal/rpc/account_birthday_rpc_test.go @@ -0,0 +1,84 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestAccountUpdateBirthdayPersistsFullUserAndPushesRefresh(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550003301", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + sessions := &captureSessions{} + router := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + birthday := tg.Birthday{Day: 14, Month: 2} + birthday.SetYear(1990) + req := &tg.AccountUpdateBirthdayRequest{} + req.SetBirthday(birthday) + callCtx := WithSessionID(WithUserID(ctx, owner.ID), 4242) + ok, err := router.onAccountUpdateBirthday(callCtx, req) + if err != nil || !ok { + t.Fatalf("update birthday = ok %v err %v, want true/nil", ok, err) + } + + saved, found, err := userStore.ByID(ctx, owner.ID) + if err != nil || !found { + t.Fatalf("load saved user found=%v err=%v", found, err) + } + if saved.Birthday != (domain.Birthday{Day: 14, Month: 2, Year: 1990}) { + t.Fatalf("saved birthday = %+v, want 14/2/1990", saved.Birthday) + } + + full, err := router.onUsersGetFullUser(WithUserID(ctx, owner.ID), &tg.InputUserSelf{}) + if err != nil { + t.Fatalf("get full user: %v", err) + } + gotBirthday, ok := full.FullUser.GetBirthday() + if !ok { + t.Fatal("full user missing birthday") + } + gotYear, gotYearOK := gotBirthday.GetYear() + if gotBirthday.Day != 14 || gotBirthday.Month != 2 || !gotYearOK || gotYear != 1990 { + t.Fatalf("full user birthday = %+v yearOK=%v, want 14/2/1990", gotBirthday, gotYearOK) + } + + snap := sessions.snapshot() + if snap.userID != owner.ID || snap.sessionID != 4242 { + t.Fatalf("push target user/session = %d/%d, want %d/4242", snap.userID, snap.sessionID, owner.ID) + } + updates, ok := snap.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", snap.message) + } + hasUserUpdate := false + for _, update := range updates.Updates { + if u, ok := update.(*tg.UpdateUser); ok && u.UserID == owner.ID { + hasUserUpdate = true + } + } + if !hasUserUpdate { + t.Fatalf("updates = %+v, want UpdateUser for self", updates.Updates) + } + if len(updates.Users) != 1 { + t.Fatalf("pushed users = %d, want 1 self user", len(updates.Users)) + } + pushedUser, ok := updates.Users[0].(*tg.User) + if !ok || pushedUser.ID != owner.ID { + t.Fatalf("pushed user = %T %+v, want self user", updates.Users[0], updates.Users[0]) + } +} diff --git a/internal/rpc/convert_media.go b/internal/rpc/convert_media.go index f5aaf821..ea817a3f 100644 --- a/internal/rpc/convert_media.go +++ b/internal/rpc/convert_media.go @@ -411,6 +411,14 @@ func tgInputStickerSetFromSystemKey(systemKey string) (tg.InputStickerSetClass, return &tg.InputStickerSetAnimatedEmojiAnimations{}, true case "emoji_generic_animations": return &tg.InputStickerSetEmojiGenericAnimations{}, true + case domain.StickerSetSystemKeyEmojiDefaultStatuses: + return &tg.InputStickerSetEmojiDefaultStatuses{}, true + case domain.StickerSetSystemKeyEmojiDefaultTopicIcons: + return &tg.InputStickerSetEmojiDefaultTopicIcons{}, true + case domain.StickerSetSystemKeyPremiumGifts: + return &tg.InputStickerSetPremiumGifts{}, true + case domain.StickerSetSystemKeyTonGifts: + return &tg.InputStickerSetTonGifts{}, true default: if strings.HasPrefix(systemKey, "dice:") { return &tg.InputStickerSetDice{Emoticon: strings.TrimPrefix(systemKey, "dice:")}, true @@ -705,6 +713,8 @@ func stickerSetRefFromInput(input tg.InputStickerSetClass) (domain.StickerSetRef return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: domain.StickerSetSystemKeyEmojiDefaultTopicIcons}, true case *tg.InputStickerSetPremiumGifts: return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: domain.StickerSetSystemKeyPremiumGifts}, true + case *tg.InputStickerSetTonGifts: + return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: domain.StickerSetSystemKeyTonGifts}, true case *tg.InputStickerSetDice: return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "dice:" + in.Emoticon}, true default: diff --git a/internal/rpc/stickers_test.go b/internal/rpc/stickers_test.go index 28400575..632694a0 100644 --- a/internal/rpc/stickers_test.go +++ b/internal/rpc/stickers_test.go @@ -285,6 +285,7 @@ func TestStickerSetRefFromSystemInputs(t *testing.T) { {"emoji channel default statuses", &tg.InputStickerSetEmojiChannelDefaultStatuses{}, domain.StickerSetSystemKeyEmojiDefaultStatuses}, {"emoji default topic icons", &tg.InputStickerSetEmojiDefaultTopicIcons{}, domain.StickerSetSystemKeyEmojiDefaultTopicIcons}, {"premium gifts", &tg.InputStickerSetPremiumGifts{}, domain.StickerSetSystemKeyPremiumGifts}, + {"ton gifts", &tg.InputStickerSetTonGifts{}, domain.StickerSetSystemKeyTonGifts}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {