fix: sync sticker material reuse handling

This commit is contained in:
A 2026-07-02 02:43:48 +08:00
parent bb07eb16d1
commit 20b388eeaa
5 changed files with 370 additions and 44 deletions

View file

@ -102,10 +102,6 @@ func (s *Service) CreateStickerSet(ctx context.Context, req domain.CreateSticker
return domain.StickerSet{}, nil, err
}
docByID := documentsByID(loaded)
documentIDs := make([]int64, 0, len(req.Items))
packs := make([]domain.StickerPack, 0, len(req.Items))
packIndex := map[string]int{}
keywords := []domain.StickerKeyword{}
items := make([]domain.StickerSetItemInput, 0, len(req.Items))
seenDocs := map[int64]struct{}{}
for _, item := range req.Items {
@ -122,40 +118,55 @@ func (s *Service) CreateStickerSet(ctx context.Context, req domain.CreateSticker
return domain.StickerSet{}, nil, err
}
item.Emoji = emoji
documentIDs = append(documentIDs, item.DocumentID)
if idx, ok := packIndex[emoji]; ok {
packs[idx].DocumentIDs = append(packs[idx].DocumentIDs, item.DocumentID)
} else {
packIndex[emoji] = len(packs)
packs = append(packs, domain.StickerPack{Emoticon: emoji, DocumentIDs: []int64{item.DocumentID}})
}
if kw := parseStickerKeywords(item.DocumentID, item.Keywords); len(kw.Keywords) > 0 {
keywords = append(keywords, kw)
}
items = append(items, item)
}
if thumbID != 0 {
thumb, ok := docByID[thumbID]
if !ok || thumb.AccessHash != thumbAccess {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
}
set := domain.StickerSet{
ID: randomID(),
AccessHash: randomID(),
ShortName: shortName,
Title: title,
Count: len(documentIDs),
Kind: kind,
Emojis: kind == domain.StickerSetKindEmoji,
Masks: kind == domain.StickerSetKindMasks,
TextColor: kind == domain.StickerSetKindEmoji && req.TextColor,
Creator: true,
CreatorUserID: req.CreatorUserID,
DocumentIDs: documentIDs,
Packs: packs,
Keywords: keywords,
Software: strings.TrimSpace(req.Software),
}
updatedDocs := make([]domain.Document, 0, len(items))
finalBySourceID := make(map[int64]domain.Document, len(items))
for _, item := range items {
doc := docByID[item.DocumentID]
doc, err = s.materialDocumentForStickerSet(ctx, doc, set.ID)
if err != nil {
return domain.StickerSet{}, nil, err
}
doc, err = s.prepareStickerSetDocument(ctx, doc, set, item.Emoji)
if err != nil {
return domain.StickerSet{}, nil, err
}
set.DocumentIDs = append(set.DocumentIDs, doc.ID)
set.Packs = addDocumentToStickerPacks(set.Packs, item.Emoji, doc.ID)
set.Keywords = upsertStickerKeywords(set.Keywords, parseStickerKeywords(doc.ID, item.Keywords))
finalBySourceID[item.DocumentID] = doc
updatedDocs = append(updatedDocs, doc)
}
set.Count = len(set.DocumentIDs)
if thumbID != 0 {
thumb, ok := docByID[thumbID]
if !ok || thumb.AccessHash != thumbAccess {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
thumb, ok := finalBySourceID[thumbID]
if !ok {
thumb, ok = docByID[thumbID]
if !ok || thumb.AccessHash != thumbAccess {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
}
set.ThumbDocumentID = thumb.ID
set.Thumbs = copyPhotoSizes(thumb.Thumbs)
@ -165,17 +176,6 @@ func (s *Service) CreateStickerSet(ctx context.Context, req domain.CreateSticker
}
}
set.Hash = stickerSetHash(set)
updatedDocs := make([]domain.Document, 0, len(items))
for _, item := range items {
doc := docByID[item.DocumentID]
doc, err = s.prepareStickerSetDocument(ctx, doc, set, item.Emoji)
if err != nil {
return domain.StickerSet{}, nil, err
}
docByID[item.DocumentID] = doc
updatedDocs = append(updatedDocs, doc)
}
if err := s.media.CreateStickerSet(ctx, set, updatedDocs); err != nil {
if errors.Is(err, domain.ErrStickerSetShortNameOccupied) {
return domain.StickerSet{}, nil, domain.ErrStickerSetShortNameOccupied
@ -286,10 +286,7 @@ func (s *Service) prepareStickerSetDocument(ctx context.Context, doc domain.Docu
}
func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Document) (domain.Document, error) {
if doc.IsStickerLike() {
return doc, nil
}
mimeType := doc.StickerSetMaterialMime()
mimeType := canonicalStickerMaterialMime(doc.StickerSetMaterialMime())
hasImageSize := false
hasVideo := false
for _, attr := range doc.Attributes {
@ -301,7 +298,7 @@ func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Doc
}
}
switch mimeType {
case "application/json":
case stickerMaterialMimeJSON:
data, ok := s.readStickerMaterialBlob(ctx, doc)
if !ok {
return domain.Document{}, domain.ErrStickerSetFileInvalid
@ -314,10 +311,10 @@ func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Doc
if err != nil || int64(len(tgsData)) > domain.MaxStickerMaterialDocumentSize {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
if err := s.rewriteStickerMaterialBlob(ctx, doc.ID, tgsData, "application/x-tgsticker"); err != nil {
if err := s.rewriteStickerMaterialBlob(ctx, doc.ID, tgsData, stickerMaterialMimeTGS); err != nil {
return domain.Document{}, err
}
doc.MimeType = "application/x-tgsticker"
doc.MimeType = stickerMaterialMimeTGS
doc.Size = int64(len(tgsData))
doc.Attributes = replaceStickerMaterialFilename(doc.Attributes, "sticker.tgs")
if !hasImageSize {
@ -327,10 +324,15 @@ func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Doc
H: 512,
})
}
case "application/x-tgsticker":
case stickerMaterialMimeTGS:
if data, ok := s.readStickerMaterialBlob(ctx, doc); ok && !validTGSStickerData(data) {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
var err error
doc, err = s.ensureStickerMaterialMIME(ctx, doc, stickerMaterialMimeTGS)
if err != nil {
return domain.Document{}, err
}
if !hasImageSize {
doc.Attributes = append(doc.Attributes, domain.DocumentAttribute{
Kind: domain.DocAttrImageSize,
@ -338,7 +340,12 @@ func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Doc
H: 512,
})
}
case "image/webp":
case stickerMaterialMimeWebP:
var err error
doc, err = s.ensureStickerMaterialMIME(ctx, doc, stickerMaterialMimeWebP)
if err != nil {
return domain.Document{}, err
}
if !hasImageSize {
data, ok := s.readStickerMaterialBlob(ctx, doc)
if !ok {
@ -354,11 +361,19 @@ func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Doc
H: h,
})
}
case "video/webm", "video/mp4":
case stickerMaterialMimeWebM, stickerMaterialMimeMP4:
if !hasVideo {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
var err error
doc, err = s.ensureStickerMaterialMIME(ctx, doc, mimeType)
if err != nil {
return domain.Document{}, err
}
default:
if doc.IsStickerLike() {
return doc, nil
}
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
return doc, nil

View file

@ -106,6 +106,106 @@ func TestCreateStickerSetAcceptsUploadedStickerMaterial(t *testing.T) {
}
}
func TestCreateStickerSetNormalizesUploadedTGSMime(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
media.docs[206] = domain.Document{
ID: 206,
AccessHash: 2006,
DCID: 2,
Size: 1024,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "local.tgs"}},
}
media.blobs["doc:206"] = domain.FileBlob{
LocationKey: "doc:206",
Size: 1024,
}
svc := NewService(media, nil, 2)
_, docs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: 1000000001,
Title: "Uploads",
ShortName: "tgs_uploads",
Items: []domain.StickerSetItemInput{{
DocumentID: 206,
DocumentAccessHash: 2006,
Emoji: "👋",
}},
})
if err != nil {
t.Fatalf("create with uploaded tgs material: %v", err)
}
if len(docs) != 1 || docs[0].MimeType != stickerMaterialMimeTGS {
t.Fatalf("created docs = %+v, want normalized tgs mime", docs)
}
stored := media.docs[206]
if stored.MimeType != stickerMaterialMimeTGS {
t.Fatalf("stored document mime = %q, want %q", stored.MimeType, stickerMaterialMimeTGS)
}
blob := media.blobs["doc:206"]
if blob.MimeType != stickerMaterialMimeTGS {
t.Fatalf("stored blob mime = %q, want %q", blob.MimeType, stickerMaterialMimeTGS)
}
}
func TestCreateStickerSetClonesDocumentWhenReusedAcrossSets(t *testing.T) {
ctx := context.Background()
media := &fakeMediaStore{
docs: map[int64]domain.Document{
301: {ID: 301, AccessHash: 3001, DCID: 2, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
},
sets: map[int64]domain.StickerSet{},
}
svc := NewService(media, nil, 2)
emojiSet, emojiDocs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: 1000000001,
Title: "Emoji Pack",
ShortName: "emoji_pack",
Kind: domain.StickerSetKindEmoji,
Items: []domain.StickerSetItemInput{{
DocumentID: 301,
DocumentAccessHash: 3001,
Emoji: "🙂",
}},
})
if err != nil {
t.Fatalf("create emoji set: %v", err)
}
stickerSet, stickerDocs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: 1000000001,
Title: "Sticker Pack",
ShortName: "sticker_pack",
Items: []domain.StickerSetItemInput{{
DocumentID: 301,
DocumentAccessHash: 3001,
Emoji: "😄",
}},
})
if err != nil {
t.Fatalf("create sticker set from existing emoji doc: %v", err)
}
if len(emojiDocs) != 1 || len(stickerDocs) != 1 {
t.Fatalf("created docs: emoji=%+v sticker=%+v, want one doc each", emojiDocs, stickerDocs)
}
if stickerDocs[0].ID == 301 || stickerSet.DocumentIDs[0] == 301 {
t.Fatalf("sticker set reused source doc id, set=%+v docs=%+v", stickerSet, stickerDocs)
}
source := media.docs[301]
if !source.IsCustomEmoji() {
t.Fatalf("source doc attrs = %+v, want custom emoji preserved", source.Attributes)
}
if id, _, ok := source.StickerSetRef(); !ok || id != emojiSet.ID {
t.Fatalf("source doc set ref = %d/%v, want emoji set %d", id, ok, emojiSet.ID)
}
if !stickerDocs[0].IsSticker() || stickerDocs[0].IsCustomEmoji() {
t.Fatalf("cloned doc attrs = %+v, want regular sticker", stickerDocs[0].Attributes)
}
if id, _, ok := stickerDocs[0].StickerSetRef(); !ok || id != stickerSet.ID {
t.Fatalf("cloned doc set ref = %d/%v, want sticker set %d", id, ok, stickerSet.ID)
}
}
func TestCreateStickerSetAcceptsWebPMaterialWithClientImageSize(t *testing.T) {
ctx := context.Background()
media := &fakeMediaStore{

View file

@ -19,8 +19,9 @@ func (s *Service) AddStickerToSet(ctx context.Context, actorUserID int64, ref do
if err != nil {
return domain.StickerSet{}, nil, err
}
if ownedSetID, _, ok := doc.StickerSetRef(); ok && ownedSetID != set.ID {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
doc, err = s.materialDocumentForStickerSet(ctx, doc, set.ID)
if err != nil {
return domain.StickerSet{}, nil, err
}
if containsInt64(set.DocumentIDs, doc.ID) {
return set, docs, nil

View file

@ -178,3 +178,71 @@ func TestAddStickerToSetAcceptsUploadedMaterial(t *testing.T) {
t.Fatalf("added doc = %+v, want sticker-tagged video material", added)
}
}
func TestAddStickerToSetClonesDocumentWhenReusedAcrossSets(t *testing.T) {
ctx := context.Background()
media := &fakeMediaStore{
docs: map[int64]domain.Document{
401: {ID: 401, AccessHash: 4001, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
402: {ID: 402, AccessHash: 4002, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
},
sets: map[int64]domain.StickerSet{},
}
svc := NewService(media, nil, 2)
emojiSet, _, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: 1000000001,
Title: "Emoji Pack",
ShortName: "emoji_pack",
Kind: domain.StickerSetKindEmoji,
Items: []domain.StickerSetItemInput{{
DocumentID: 401,
DocumentAccessHash: 4001,
Emoji: "🙂",
}},
})
if err != nil {
t.Fatalf("create emoji set: %v", err)
}
stickerSet, _, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: 1000000001,
Title: "Sticker Pack",
ShortName: "sticker_pack",
Items: []domain.StickerSetItemInput{{
DocumentID: 402,
DocumentAccessHash: 4002,
Emoji: "😄",
}},
})
if err != nil {
t.Fatalf("create sticker set: %v", err)
}
stickerSet, docs, err := svc.AddStickerToSet(ctx, 1000000001, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: stickerSet.ID, AccessHash: stickerSet.AccessHash}, domain.StickerSetItemInput{
DocumentID: 401,
DocumentAccessHash: 4001,
Emoji: "👋",
})
if err != nil {
t.Fatalf("add existing emoji doc to sticker set: %v", err)
}
if stickerSet.Count != 2 || len(docs) != 2 {
t.Fatalf("after add set=%+v docs=%d, want two docs", stickerSet, len(docs))
}
added := docs[1]
if added.ID == 401 || stickerSet.DocumentIDs[1] == 401 {
t.Fatalf("added doc reused source id, set=%+v docs=%+v", stickerSet, docs)
}
source := media.docs[401]
if !source.IsCustomEmoji() {
t.Fatalf("source doc attrs = %+v, want custom emoji preserved", source.Attributes)
}
if id, _, ok := source.StickerSetRef(); !ok || id != emojiSet.ID {
t.Fatalf("source doc set ref = %d/%v, want emoji set %d", id, ok, emojiSet.ID)
}
if !added.IsSticker() || added.IsCustomEmoji() {
t.Fatalf("added clone attrs = %+v, want regular sticker", added.Attributes)
}
if id, _, ok := added.StickerSetRef(); !ok || id != stickerSet.ID {
t.Fatalf("added clone set ref = %d/%v, want sticker set %d", id, ok, stickerSet.ID)
}
}

View file

@ -0,0 +1,142 @@
package files
import (
"context"
"fmt"
"strings"
"telesrv/internal/domain"
)
const (
stickerMaterialMimeTGS = "application/x-tgsticker"
stickerMaterialMimeWebP = "image/webp"
stickerMaterialMimeWebM = "video/webm"
stickerMaterialMimeMP4 = "video/mp4"
stickerMaterialMimeJSON = "application/json"
stickerMaterialMimeOctet = "application/octet-stream"
)
func normalizeStickerMaterialDocumentMIME(doc domain.Document) (domain.Document, bool) {
mimeType := canonicalStickerMaterialMime(doc.StickerSetMaterialMime())
if mimeType == "" || mimeType == stickerMaterialMimeJSON {
return doc, false
}
if !shouldReplaceStickerMaterialMime(doc.MimeType, mimeType) {
return doc, false
}
doc.MimeType = mimeType
return doc, true
}
func (s *Service) materialDocumentForStickerSet(ctx context.Context, doc domain.Document, targetSetID int64) (domain.Document, error) {
if ownedSetID, _, ok := doc.StickerSetRef(); ok && ownedSetID != 0 && ownedSetID != targetSetID {
return s.cloneStickerSetDocument(ctx, doc)
}
return doc, nil
}
func (s *Service) cloneStickerSetDocument(ctx context.Context, source domain.Document) (domain.Document, error) {
copied := copyDocuments([]domain.Document{source})
if len(copied) == 0 {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
clone := copied[0]
oldID := clone.ID
clone.ID = randomID()
clone.AccessHash = randomID()
clone.FileReference = randomFileReference()
if source.Size > 0 {
blob, found, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", oldID))
if err != nil {
return domain.Document{}, err
}
if !found {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
blob.LocationKey = fmt.Sprintf("doc:%d", clone.ID)
if normalized, changed := normalizeStickerMaterialDocumentMIME(clone); changed {
clone = normalized
}
if mimeType := canonicalStickerMaterialMime(clone.StickerSetMaterialMime()); mimeType != "" && shouldReplaceStickerMaterialMime(blob.MimeType, mimeType) {
blob.MimeType = mimeType
}
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return domain.Document{}, err
}
s.blobCache.put(blob.LocationKey, blob)
}
for _, thumb := range source.Thumbs {
if !thumb.Downloadable() || thumb.Type == "" {
continue
}
oldKey := fmt.Sprintf("doc:%d:%s", oldID, thumb.Type)
blob, found, err := s.media.GetFileBlob(ctx, oldKey)
if err != nil {
return domain.Document{}, err
}
if !found {
continue
}
blob.LocationKey = fmt.Sprintf("doc:%d:%s", clone.ID, thumb.Type)
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return domain.Document{}, err
}
s.blobCache.put(blob.LocationKey, blob)
}
return clone, nil
}
func (s *Service) ensureStickerMaterialMIME(ctx context.Context, doc domain.Document, mimeType string) (domain.Document, error) {
mimeType = canonicalStickerMaterialMime(mimeType)
if mimeType == "" || mimeType == stickerMaterialMimeJSON {
return doc, nil
}
if shouldReplaceStickerMaterialMime(doc.MimeType, mimeType) {
doc.MimeType = mimeType
}
if s == nil || s.media == nil || doc.ID == 0 {
return doc, nil
}
blob, found, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", doc.ID))
if err != nil {
return domain.Document{}, err
}
if found && shouldReplaceStickerMaterialMime(blob.MimeType, mimeType) {
blob.MimeType = mimeType
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return domain.Document{}, err
}
s.blobCache.put(blob.LocationKey, blob)
}
return doc, nil
}
func canonicalStickerMaterialMime(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(mimeType)) {
case stickerMaterialMimeTGS:
return stickerMaterialMimeTGS
case stickerMaterialMimeWebP:
return stickerMaterialMimeWebP
case stickerMaterialMimeWebM:
return stickerMaterialMimeWebM
case stickerMaterialMimeMP4:
return stickerMaterialMimeMP4
case stickerMaterialMimeJSON, "text/json", "application/lottie+json":
return stickerMaterialMimeJSON
default:
return ""
}
}
func shouldReplaceStickerMaterialMime(current, inferred string) bool {
inferred = canonicalStickerMaterialMime(inferred)
if inferred == "" {
return false
}
current = strings.ToLower(strings.TrimSpace(current))
return current == "" || current == stickerMaterialMimeOctet
}