feat: sync monoforum and collectible emoji status
Sync telesrv bd15657 (feat(account): implement collectible emoji status). Skipped telesrv docs changes per public sync rules.
This commit is contained in:
parent
edb7057757
commit
0c99ae0a9d
91 changed files with 4061 additions and 693 deletions
34
internal/app/stargifts/collectible_emoji_status_test.go
Normal file
34
internal/app/stargifts/collectible_emoji_status_test.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package stargifts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestCollectiblePatternUsesTextColorCustomEmojiAttribute(t *testing.T) {
|
||||
pattern := collectibleDocumentAttributes(domain.StarGiftCollectiblePattern)
|
||||
if len(pattern) != 3 || pattern[1].Kind != domain.DocAttrCustomEmoji || !pattern[1].TextColor {
|
||||
t.Fatalf("pattern attributes = %+v, want text-color custom emoji", pattern)
|
||||
}
|
||||
model := collectibleDocumentAttributes(domain.StarGiftCollectibleModel)
|
||||
if len(model) != 3 || model[1].Kind != domain.DocAttrSticker || model[1].TextColor {
|
||||
t.Fatalf("model attributes = %+v, want ordinary sticker", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectiblePatternHasInlinePathThumbForAndroidStaticPreview(t *testing.T) {
|
||||
pattern := collectibleDocumentThumbs(domain.StarGiftCollectiblePattern)
|
||||
if len(pattern) != 1 || pattern[0].Kind != domain.PhotoSizeKindPath ||
|
||||
pattern[0].Type != "j" || !bytes.Equal(pattern[0].Bytes, collectiblePatternPathThumb) {
|
||||
t.Fatalf("pattern thumbs = %+v, want inline path placeholder", pattern)
|
||||
}
|
||||
pattern[0].Bytes[0] ^= 0xff
|
||||
if bytes.Equal(pattern[0].Bytes, collectiblePatternPathThumb) {
|
||||
t.Fatal("collectibleDocumentThumbs returned shared mutable bytes")
|
||||
}
|
||||
if model := collectibleDocumentThumbs(domain.StarGiftCollectibleModel); len(model) != 0 {
|
||||
t.Fatalf("model thumbs = %+v, want no synthetic pattern placeholder", model)
|
||||
}
|
||||
}
|
||||
|
|
@ -368,11 +368,8 @@ func (s *Service) materializeCollectibleAttributes(ctx context.Context, attribut
|
|||
ID: documentID, AccessHash: accessHash, FileReference: fileReference,
|
||||
Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker",
|
||||
Size: int64(len(animation.TGS)), DCID: s.dc,
|
||||
Attributes: []domain.DocumentAttribute{
|
||||
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
|
||||
{Kind: domain.DocAttrSticker, Alt: "🎁"},
|
||||
{Kind: domain.DocAttrFilename, FileName: string(attributes[i].Kind) + ".tgs"},
|
||||
},
|
||||
Attributes: collectibleDocumentAttributes(attributes[i].Kind),
|
||||
Thumbs: collectibleDocumentThumbs(attributes[i].Kind),
|
||||
}
|
||||
attributes[i].Blob = &domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
|
|
@ -383,6 +380,50 @@ func (s *Service) materializeCollectibleAttributes(ctx context.Context, attribut
|
|||
return nil
|
||||
}
|
||||
|
||||
// collectiblePatternPathThumb is a valid, inline PhotoPathSize placeholder.
|
||||
// DrKLO's CACHE_TYPE_ALERT_PREVIEW_STATIC classifies a TGS document as an
|
||||
// animated sticker only when document.thumbs is non-empty. The placeholder is
|
||||
// not used as the rendered collectible pattern: after classification Android
|
||||
// downloads and decodes the document's full TGS first frame. Keeping the
|
||||
// placeholder inline avoids introducing a second downloadable blob and matches
|
||||
// the shape used by official animated-sticker documents.
|
||||
var collectiblePatternPathThumb = []byte{
|
||||
0x19, 0x06, 0xa5, 0x05, 0xdc, 0x61, 0x4d, 0x7e,
|
||||
0x78, 0x48, 0x04, 0x48, 0x04, 0x63, 0x6c, 0x7c,
|
||||
0x4e, 0x08, 0x9a, 0x4e, 0x07, 0xa2, 0x80, 0xa3,
|
||||
0x94, 0xba, 0xa1, 0x85, 0x83, 0x87, 0x48, 0x8c,
|
||||
0x4c, 0x8c, 0x4c, 0x9b, 0x55, 0xad, 0x55, 0x90,
|
||||
0x80, 0x9f, 0x86, 0xaa, 0x91, 0xaa, 0xab, 0x86,
|
||||
0x8a, 0x04, 0x58, 0x8e, 0x01, 0x4d, 0x91, 0x79,
|
||||
0x87, 0x03, 0x47, 0x06, 0x87, 0x03,
|
||||
}
|
||||
|
||||
func collectibleDocumentThumbs(kind domain.StarGiftCollectibleAttributeKind) []domain.PhotoSize {
|
||||
if kind != domain.StarGiftCollectiblePattern {
|
||||
return nil
|
||||
}
|
||||
return []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindPath,
|
||||
Type: "j",
|
||||
Bytes: append([]byte(nil), collectiblePatternPathThumb...),
|
||||
}}
|
||||
}
|
||||
|
||||
func collectibleDocumentAttributes(kind domain.StarGiftCollectibleAttributeKind) []domain.DocumentAttribute {
|
||||
renderAttribute := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: "🎁"}
|
||||
if kind == domain.StarGiftCollectiblePattern {
|
||||
// DrKLO only applies StarGiftAttributeBackdrop.pattern_color when the
|
||||
// pattern is a text-color custom emoji. Without this the gradient is
|
||||
// visible but the collectible pattern is rendered with its raw fill.
|
||||
renderAttribute = domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: "🎁", TextColor: true}
|
||||
}
|
||||
return []domain.DocumentAttribute{
|
||||
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
|
||||
renderAttribute,
|
||||
{Kind: domain.DocAttrFilename, FileName: string(kind) + ".tgs"},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
if s == nil || s.store == nil || giftID <= 0 {
|
||||
return domain.StarGiftUpgradePreview{}, false, nil
|
||||
|
|
@ -433,6 +474,14 @@ func (s *Service) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[i
|
|||
return s.store.UniqueByIDs(ctx, uniqueGiftIDs)
|
||||
}
|
||||
|
||||
func (s *Service) ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
|
||||
if s == nil || s.store == nil || owner.ID <= 0 ||
|
||||
(owner.Type != domain.PeerTypeUser && owner.Type != domain.PeerTypeChannel) || limit <= 0 {
|
||||
return []domain.UniqueStarGift{}, nil
|
||||
}
|
||||
return s.store.ListUniqueByOwner(ctx, owner, min(limit, domain.MaxSavedStarGiftsLimit))
|
||||
}
|
||||
|
||||
func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
|
||||
if s == nil || s.upgrades == nil {
|
||||
return domain.StarGiftUpgradeResult{}, fmt.Errorf("star gift upgrade store is not configured")
|
||||
|
|
|
|||
|
|
@ -593,6 +593,20 @@ func (s *Service) RecordContactsReset(ctx context.Context, stateAuthKeyID [8]byt
|
|||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordUserEmojiStatus durably synchronizes an absolute emoji-status snapshot
|
||||
// to the account's other sessions and offline difference stream.
|
||||
func (s *Service) RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]byte, userID int64, status domain.UserEmojiStatus, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if !status.Valid() {
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return s.recordEvent(ctx, stateAuthKeyID, excludeAuthKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventUserEmojiStatus,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||||
EmojiStatus: status,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordDraftMessage 记录某会话云草稿变化(保存/清空都是同一事件——草稿是绝对
|
||||
// 状态,重放时按 peer 重载当前值)。updateDraftMessage 无 pts 字段,走 LacksWirePts
|
||||
// aux 簿记;topMsgID 是 forum 话题草稿键(复用 MaxID 列持久化)。
|
||||
|
|
|
|||
|
|
@ -219,6 +219,35 @@ func TestRecordSettingsEventsFeedGetDifference(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRecordCollectibleEmojiStatusFeedsDifference(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
authKeyID := [8]byte{3, 1}
|
||||
events := memory.NewUpdateEventStore()
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
ownerUserID := int64(1000000001)
|
||||
status := domain.UserEmojiStatus{
|
||||
DocumentID: 71,
|
||||
Collectible: domain.EmojiStatusCollectible{
|
||||
CollectibleID: 91, DocumentID: 71, Title: "Gift", Slug: "Gift-1",
|
||||
PatternDocumentID: 72, CenterColor: 1, EdgeColor: 2, PatternColor: 3, TextColor: 4,
|
||||
},
|
||||
}
|
||||
event, state, err := svc.RecordUserEmojiStatus(ctx, authKeyID, ownerUserID, status, authKeyID, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordUserEmojiStatus: %v", err)
|
||||
}
|
||||
if event.Type != domain.UpdateEventUserEmojiStatus || event.Pts != 1 || state.Pts != 1 || !event.LacksWirePts() {
|
||||
t.Fatalf("event/state = %+v / %+v", event, state)
|
||||
}
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if len(diff.Events) != 1 || diff.Events[0].EmojiStatus != status || diff.Events[0].Peer.ID != ownerUserID {
|
||||
t.Fatalf("difference = %+v, want exact collectible snapshot", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
authKeyID := [8]byte{4}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ func TestUpdateEmojiStatusPremiumGate(t *testing.T) {
|
|||
svc := NewService(store)
|
||||
|
||||
// 非会员设置被拒(PREMIUM_ACCOUNT_REQUIRED)。
|
||||
if _, err := svc.UpdateEmojiStatus(ctx, u.ID, 42, 0); !errors.Is(err, domain.ErrPremiumRequired) {
|
||||
if _, err := svc.UpdateEmojiStatus(ctx, u.ID, domain.UserEmojiStatus{DocumentID: 42}); !errors.Is(err, domain.ErrPremiumRequired) {
|
||||
t.Fatalf("non-premium set err = %v, want ErrPremiumRequired", err)
|
||||
}
|
||||
|
||||
|
|
@ -115,14 +115,14 @@ func TestUpdateEmojiStatusPremiumGate(t *testing.T) {
|
|||
if _, err := store.SetPremiumUntil(ctx, u.ID, int(time.Now().Add(time.Hour).Unix())); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
set, err := svc.UpdateEmojiStatus(ctx, u.ID, 42, 0)
|
||||
set, err := svc.UpdateEmojiStatus(ctx, u.ID, domain.UserEmojiStatus{DocumentID: 42})
|
||||
if err != nil || set.EmojiStatusDocumentID != 42 {
|
||||
t.Fatalf("premium set = %+v err %v, want document 42", set, err)
|
||||
}
|
||||
if _, err := store.SetPremiumUntil(ctx, u.ID, 0); err != nil {
|
||||
t.Fatalf("downgrade: %v", err)
|
||||
}
|
||||
cleared, err := svc.UpdateEmojiStatus(ctx, u.ID, 0, 0)
|
||||
cleared, err := svc.UpdateEmojiStatus(ctx, u.ID, domain.UserEmojiStatus{})
|
||||
if err != nil || cleared.EmojiStatusDocumentID != 0 {
|
||||
t.Fatalf("clear after downgrade = %+v err %v, want cleared", cleared, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -375,17 +375,14 @@ func (s *Service) SweepExpiredPremium(ctx context.Context, now int64, limit int)
|
|||
return users, nil
|
||||
}
|
||||
|
||||
// UpdateEmojiStatus 更新当前用户 emoji status(premium 专属;documentID=0 清除)。
|
||||
// UpdateEmojiStatus 更新当前用户 emoji status(premium 专属;零值清除)。
|
||||
// 清除不要求会员(到期降级后客户端仍可显式清掉残留状态)。
|
||||
func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
|
||||
self, err := s.validateEmojiStatusUpdate(ctx, userID, status)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if documentID != 0 && !self.PremiumActiveAt(time.Now().Unix()) {
|
||||
return domain.User{}, domain.ErrPremiumRequired
|
||||
}
|
||||
u, err := s.users.UpdateEmojiStatus(ctx, self.ID, documentID, until)
|
||||
u, err := s.users.UpdateEmojiStatus(ctx, self.ID, status)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
|
|
@ -393,6 +390,52 @@ func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, documentI
|
|||
return u, nil
|
||||
}
|
||||
|
||||
// UpdateEmojiStatusWithEvent uses the store's aggregate transaction when it
|
||||
// is available. The bool reports whether the returned event was durably
|
||||
// appended with dispatch; lightweight memory/test wiring falls back to the
|
||||
// ordinary state write and lets the RPC's Updates service append the event.
|
||||
func (s *Service) UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, date int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, bool, error) {
|
||||
self, err := s.validateEmojiStatusUpdate(ctx, userID, status)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.UpdateEvent{}, false, err
|
||||
}
|
||||
writer, ok := s.users.(store.UserEmojiStatusEventStore)
|
||||
if !ok {
|
||||
u, err := s.users.UpdateEmojiStatus(ctx, self.ID, status)
|
||||
if err == nil {
|
||||
s.refreshCachedUsers(ctx, u)
|
||||
}
|
||||
return u, domain.UpdateEvent{}, false, err
|
||||
}
|
||||
event := domain.UpdateEvent{
|
||||
Type: domain.UpdateEventUserEmojiStatus,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: self.ID},
|
||||
EmojiStatus: status,
|
||||
Date: date,
|
||||
PtsCount: 1,
|
||||
}
|
||||
u, event, err := writer.UpdateEmojiStatusWithEvent(ctx, self.ID, status, event, excludeAuthKeyID, excludeSessionID)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.UpdateEvent{}, false, err
|
||||
}
|
||||
s.refreshCachedUsers(ctx, u)
|
||||
return u, event, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateEmojiStatusUpdate(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !status.Valid() {
|
||||
return domain.User{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
if !status.Empty() && !self.PremiumActiveAt(time.Now().Unix()) {
|
||||
return domain.User{}, domain.ErrPremiumRequired
|
||||
}
|
||||
return self, nil
|
||||
}
|
||||
|
||||
// UpdateBirthday 设置/清除用户生日(account.updateBirthday)。零值 Birthday 表示清除。
|
||||
func (s *Service) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
|
|
@ -513,7 +556,7 @@ func (s *Service) loadBaseUsersByIDs(ctx context.Context, userIDs []int64) ([]do
|
|||
if s.cache != nil {
|
||||
if cached, err := s.cache.GetByIDs(ctx, ids); err == nil && len(cached) > 0 {
|
||||
for id, u := range cached {
|
||||
if u.ID != 0 {
|
||||
if u.ID != 0 && u.EmojiStatusCollectible.Empty() {
|
||||
loaded[id] = u
|
||||
}
|
||||
}
|
||||
|
|
@ -561,7 +604,18 @@ func (s *Service) putCachedUsers(ctx context.Context, users ...domain.User) {
|
|||
if s.cache == nil || len(users) == 0 {
|
||||
return
|
||||
}
|
||||
_ = s.cache.PutMany(ctx, users)
|
||||
cacheable := make([]domain.User, 0, len(users))
|
||||
for _, user := range users {
|
||||
// Collectible ownership may change inside the star-gift aggregate. Keep
|
||||
// these uncommon users on the authoritative store path so the database
|
||||
// lifecycle trigger can never be masked by a stale base-user cache entry.
|
||||
if user.ID != 0 && user.EmojiStatusCollectible.Empty() {
|
||||
cacheable = append(cacheable, user)
|
||||
}
|
||||
}
|
||||
if len(cacheable) > 0 {
|
||||
_ = s.cache.PutMany(ctx, cacheable)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) dropCachedUsers(ctx context.Context, userIDs ...int64) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue