fix: sync birthday updates and system sticker sets

This commit is contained in:
A 2026-07-05 15:15:32 +08:00
parent c38dd73fdd
commit ddcf2d31ca
10 changed files with 431 additions and 35 deletions

View file

@ -4,10 +4,13 @@
// SeedMedia + handler 识别下发,无需改服务端代码。 // SeedMedia + handler 识别下发,无需改服务端代码。
// //
// 支持的 set spec(命令行参数,可多个): // 支持的 set spec(命令行参数,可多个):
// short:<shortName> 按 short_name 拉(普通/mask/emoji 集) //
// emoji_default_statuses inputStickerSetEmojiDefaultStatuses // short:<shortName> 按 short_name 拉(普通/mask/emoji 集)
// emoji_channel_default_statuses inputStickerSetEmojiChannelDefaultStatuses // emoji_default_statuses inputStickerSetEmojiDefaultStatuses
// emoji_default_topic_icons inputStickerSetEmojiDefaultTopicIcons // emoji_channel_default_statuses inputStickerSetEmojiChannelDefaultStatuses
// emoji_default_topic_icons inputStickerSetEmojiDefaultTopicIcons
// premium_gifts inputStickerSetPremiumGifts
// ton_gifts inputStickerSetTonGifts
// //
// 需登录会话(复用 appearancefetch 的 /tmp/appearance.session,SESSION env 可覆盖)。 // 需登录会话(复用 appearancefetch 的 /tmp/appearance.session,SESSION env 可覆盖)。
// //
@ -52,6 +55,10 @@ func specToInput(spec string) (tg.InputStickerSetClass, string, error) {
return &tg.InputStickerSetEmojiChannelDefaultStatuses{}, "EmojiChannelDefaultStatuses", nil return &tg.InputStickerSetEmojiChannelDefaultStatuses{}, "EmojiChannelDefaultStatuses", nil
case spec == "emoji_default_topic_icons": case spec == "emoji_default_topic_icons":
return &tg.InputStickerSetEmojiDefaultTopicIcons{}, "EmojiDefaultTopicIcons", nil return &tg.InputStickerSetEmojiDefaultTopicIcons{}, "EmojiDefaultTopicIcons", nil
case spec == "premium_gifts":
return &tg.InputStickerSetPremiumGifts{}, "PremiumGifts", nil
case spec == "ton_gifts":
return &tg.InputStickerSetTonGifts{}, "TonGifts", nil
default: default:
return nil, "", fmt.Errorf("unknown spec %q", spec) 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 的解析) ---- // ---- seed JSON 结构(字段对齐 internal/app/files/seed.go 的解析) ----
type attrJSON struct { type attrJSON struct {
Type string `json:"_"` Type string `json:"_"`
W int `json:"w,omitempty"` W int `json:"w,omitempty"`
H int `json:"h,omitempty"` H int `json:"h,omitempty"`
Alt string `json:"alt,omitempty"` Alt string `json:"alt,omitempty"`
Mask bool `json:"mask,omitempty"` Mask bool `json:"mask,omitempty"`
Free bool `json:"free,omitempty"` Free bool `json:"free,omitempty"`
TextColor bool `json:"text_color,omitempty"` TextColor bool `json:"text_color,omitempty"`
FileName string `json:"file_name,omitempty"` FileName string `json:"file_name,omitempty"`
Duration float64 `json:"duration,omitempty"` Duration float64 `json:"duration,omitempty"`
RoundMessage bool `json:"round_message,omitempty"` RoundMessage bool `json:"round_message,omitempty"`
SupportsStreaming bool `json:"supports_streaming,omitempty"` SupportsStreaming bool `json:"supports_streaming,omitempty"`
Voice bool `json:"voice,omitempty"` Voice bool `json:"voice,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Performer string `json:"performer,omitempty"` Performer string `json:"performer,omitempty"`
Stickerset *inputSetRefJSON `json:"stickerset,omitempty"` Stickerset *inputSetRefJSON `json:"stickerset,omitempty"`
} }
@ -154,7 +161,7 @@ func mapAttrs(in []tg.DocumentAttributeClass) []attrJSON {
func main() { func main() {
if len(os.Args) < 3 { if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: SESSION=/tmp/appearance.session stickerfetch <out_dir> <spec> [spec...]\n spec: short:<name> | emoji_default_statuses | emoji_channel_default_statuses | emoji_default_topic_icons") fmt.Fprintln(os.Stderr, "usage: SESSION=/tmp/appearance.session stickerfetch <out_dir> <spec> [spec...]\n spec: short:<name> | emoji_default_statuses | emoji_channel_default_statuses | emoji_default_topic_icons | premium_gifts | ton_gifts | effects")
os.Exit(2) os.Exit(2)
} }
out := os.Args[1] out := os.Args[1]
@ -213,8 +220,9 @@ func fetchSet(ctx context.Context, api *tg.Client, dl *downloader.Downloader, ou
return err 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)) docs := make([]documentJSON, 0, len(full.Documents))
for _, d := range full.Documents { for i, d := range full.Documents {
doc, ok := d.(*tg.Document) doc, ok := d.(*tg.Document)
if !ok { if !ok {
continue continue
@ -227,16 +235,12 @@ func fetchSet(ctx context.Context, api *tg.Client, dl *downloader.Downloader, ou
ext = ".webp" ext = ".webp"
} }
path := filepath.Join(stickersDir, fmt.Sprintf("%d%s", doc.ID, ext)) path := filepath.Join(stickersDir, fmt.Sprintf("%d%s", doc.ID, ext))
f, err := os.Create(path) if !completeExistingFile(path, doc.Size) {
if err != nil { loc := &tg.InputDocumentFileLocation{ID: doc.ID, AccessHash: doc.AccessHash, FileReference: doc.FileReference}
return err 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{ docs = append(docs, documentJSON{
ID: doc.ID, ID: doc.ID,
AccessHash: doc.AccessHash, AccessHash: doc.AccessHash,
@ -247,6 +251,9 @@ func fetchSet(ctx context.Context, api *tg.Client, dl *downloader.Downloader, ou
DCID: doc.DCID, DCID: doc.DCID,
Attributes: mapAttrs(doc.Attributes), 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)) 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 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)---- // ---- 消息特效(messages.getAvailableEffects)----
type effectJSON struct { type effectJSON struct {

View file

@ -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")
}
}

View file

@ -37,6 +37,7 @@ var specialSeedDirs = map[string]string{
"emoji_default_statuses": "DefaultSet_EmojiDefaultStatuses", "emoji_default_statuses": "DefaultSet_EmojiDefaultStatuses",
"emoji_default_topic_icons": "DefaultSet_EmojiDefaultTopicIcons", "emoji_default_topic_icons": "DefaultSet_EmojiDefaultTopicIcons",
"premium_gifts": "DefaultSet_PremiumGifts", "premium_gifts": "DefaultSet_PremiumGifts",
"ton_gifts": "DefaultSet_TonGifts",
} }
func main() { func main() {

View file

@ -295,7 +295,8 @@ func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey str
return nil // 该目录无 set_info.json → 跳过 return nil // 该目录无 set_info.json → 跳过
} }
var info struct { 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 { if err := json.Unmarshal(raw, &info); err != nil {
return fmt.Errorf("parse %s: %w", infoPath, err) 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 { if sj.ID == 0 {
return nil return nil
} }
if systemKey == "" {
systemKey = systemKeyForInputSetType(info.InputSetType)
}
kind := stickerSetKind(sj, systemKey)
// 增量 seed集已存在且内容 hash 未变则跳过(不重读文档/重传 blob。force 时强制重导 // 增量 seed集已存在且内容 hash 未变则跳过(不重读文档/重传 blob。force 时强制重导
// (缩略图内联缓存修复路径)。这让 seedStickerSets 可在非空 store 上每次启动安全重扫, // (缩略图内联缓存修复路径)。这让 seedStickerSets 可在非空 store 上每次启动安全重扫,
// 仅导入新增/变更集。 // 仅导入新增/变更集;但系统集分类由导出元数据决定,旧库中 hash 相同但 system_key/kind
// 错误的记录必须重写,避免 constructor 方式取不到同一个资源集。
if !force { if !force {
if existing, found, err := s.media.GetStickerSetByID(ctx, sj.ID); err != nil { if existing, found, err := s.media.GetStickerSetByID(ctx, sj.ID); err != nil {
return err return err
} else if found && existing.Hash == sj.Hash { } else if found && existing.Hash == sj.Hash && existing.SystemKey == systemKey && existing.Kind == kind {
return nil return nil
} }
} }
@ -333,7 +339,6 @@ func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey str
} }
} }
kind := stickerSetKind(sj, systemKey)
set := domain.StickerSet{ set := domain.StickerSet{
ID: sj.ID, ID: sj.ID,
AccessHash: sj.AccessHash, AccessHash: sj.AccessHash,
@ -558,6 +563,8 @@ func systemKeyForDefaultSet(dirName string) string {
return domain.StickerSetSystemKeyEmojiDefaultTopicIcons return domain.StickerSetSystemKeyEmojiDefaultTopicIcons
case "DefaultSet_PremiumGifts": case "DefaultSet_PremiumGifts":
return domain.StickerSetSystemKeyPremiumGifts return domain.StickerSetSystemKeyPremiumGifts
case "DefaultSet_TonGifts":
return domain.StickerSetSystemKeyTonGifts
case "DefaultSet_Dice_Normal": case "DefaultSet_Dice_Normal":
return "dice:\U0001f3b2" return "dice:\U0001f3b2"
case "DefaultSet_Dice_Dart": 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 { func stickerSetKind(sj seedStickerSetJSON, systemKey string) domain.StickerSetKind {
switch { switch {
case systemKey != "": case systemKey != "":

View file

@ -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) { func TestSeedStickerSetInstalledFlagNeverMarksViewerState(t *testing.T) {
cases := []struct { cases := []struct {
name string name string

View file

@ -680,6 +680,8 @@ const (
StickerSetSystemKeyEmojiDefaultTopicIcons = "emoji_default_topic_icons" StickerSetSystemKeyEmojiDefaultTopicIcons = "emoji_default_topic_icons"
// StickerSetSystemKeyPremiumGifts 对应 premium gifts 系统集。 // StickerSetSystemKeyPremiumGifts 对应 premium gifts 系统集。
StickerSetSystemKeyPremiumGifts = "premium_gifts" StickerSetSystemKeyPremiumGifts = "premium_gifts"
// StickerSetSystemKeyTonGifts 对应 TON gifts 系统集。
StickerSetSystemKeyTonGifts = "ton_gifts"
) )
// StickerSetKind 区分贴纸集用途(影响 getAllStickers / getEmojiStickers 归类)。 // StickerSetKind 区分贴纸集用途(影响 getAllStickers / getEmojiStickers 归类)。

View file

@ -1292,8 +1292,8 @@ func (r *Router) onAccountUpdateUsername(ctx context.Context, username string) (
} }
// onAccountUpdateBirthday 持久化资料页生日account.updateBirthday。birthday 缺省即清除; // onAccountUpdateBirthday 持久化资料页生日account.updateBirthday。birthday 缺省即清除;
// 月/日/年非法返回 BIRTHDAY_INVALID。生日落在 userFull按隐私 PrivacyKeyBirthday 对外裁剪) // 月/日/年非法返回 BIRTHDAY_INVALID。生日落在 userFull按隐私 PrivacyKeyBirthday 对外裁剪)
// 故只需失效本人 userFull 投影缓存,客户端重拉 getFullUser 即见最新值 // 写入后推 updateUser 信号给本人其它在线 session促使已加载 full profile 的客户端重拉
func (r *Router) onAccountUpdateBirthday(ctx context.Context, req *tg.AccountUpdateBirthdayRequest) (bool, error) { func (r *Router) onAccountUpdateBirthday(ctx context.Context, req *tg.AccountUpdateBirthdayRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx) userID, _, err := r.currentUserID(ctx)
if err != nil { if err != nil {
@ -1318,6 +1318,7 @@ func (r *Router) onAccountUpdateBirthday(ctx context.Context, req *tg.AccountUpd
return false, internalErr() return false, internalErr()
} }
r.invalidateRPCProjectionForUser(u.ID) r.invalidateRPCProjectionForUser(u.ID)
r.pushSelfUserChangedUpdate(ctx, u)
return true, nil 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 { func tgAuthorization(a domain.Authorization, currentAuthKeyID [8]byte, now int) tg.Authorization {
created := int(a.CreatedAt.Unix()) created := int(a.CreatedAt.Unix())
if created == 0 { if created == 0 {

View file

@ -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])
}
}

View file

@ -411,6 +411,14 @@ func tgInputStickerSetFromSystemKey(systemKey string) (tg.InputStickerSetClass,
return &tg.InputStickerSetAnimatedEmojiAnimations{}, true return &tg.InputStickerSetAnimatedEmojiAnimations{}, true
case "emoji_generic_animations": case "emoji_generic_animations":
return &tg.InputStickerSetEmojiGenericAnimations{}, true 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: default:
if strings.HasPrefix(systemKey, "dice:") { if strings.HasPrefix(systemKey, "dice:") {
return &tg.InputStickerSetDice{Emoticon: strings.TrimPrefix(systemKey, "dice:")}, true 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 return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: domain.StickerSetSystemKeyEmojiDefaultTopicIcons}, true
case *tg.InputStickerSetPremiumGifts: case *tg.InputStickerSetPremiumGifts:
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: domain.StickerSetSystemKeyPremiumGifts}, true 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: case *tg.InputStickerSetDice:
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "dice:" + in.Emoticon}, true return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "dice:" + in.Emoticon}, true
default: default:

View file

@ -285,6 +285,7 @@ func TestStickerSetRefFromSystemInputs(t *testing.T) {
{"emoji channel default statuses", &tg.InputStickerSetEmojiChannelDefaultStatuses{}, domain.StickerSetSystemKeyEmojiDefaultStatuses}, {"emoji channel default statuses", &tg.InputStickerSetEmojiChannelDefaultStatuses{}, domain.StickerSetSystemKeyEmojiDefaultStatuses},
{"emoji default topic icons", &tg.InputStickerSetEmojiDefaultTopicIcons{}, domain.StickerSetSystemKeyEmojiDefaultTopicIcons}, {"emoji default topic icons", &tg.InputStickerSetEmojiDefaultTopicIcons{}, domain.StickerSetSystemKeyEmojiDefaultTopicIcons},
{"premium gifts", &tg.InputStickerSetPremiumGifts{}, domain.StickerSetSystemKeyPremiumGifts}, {"premium gifts", &tg.InputStickerSetPremiumGifts{}, domain.StickerSetSystemKeyPremiumGifts},
{"ton gifts", &tg.InputStickerSetTonGifts{}, domain.StickerSetSystemKeyTonGifts},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {