feat: sync built-in sticker bot

This commit is contained in:
A 2026-07-01 21:55:55 +08:00
parent 7096625e13
commit 6867d201ed
60 changed files with 7063 additions and 144 deletions

View file

@ -70,6 +70,19 @@ func (c *stickerSetNegativeCache) put(ref domain.StickerSetRef) {
c.entries[key] = time.Now().Add(c.ttl)
}
func (c *stickerSetNegativeCache) delete(refs ...domain.StickerSetRef) {
if c == nil || len(refs) == 0 {
return
}
c.mu.Lock()
defer c.mu.Unlock()
for _, ref := range refs {
if key := stickerSetRefKey(ref); key != "" {
delete(c.entries, key)
}
}
}
// blobMetaCache 是 location_key → FileBlob 元数据的进程内 LRU,用于消除 upload.getFile
// 每个 chunk 一次 GetFileBlob 的 PG 往返(一个文件按 ≤512KB/1MB 分多次 getFile,热门贴纸/
// reaction/头像更被大量用户重复拉)。
@ -266,12 +279,31 @@ func (c *stickerSetFullCache) put(set domain.StickerSet, docs []domain.Document)
}
}
func (c *stickerSetFullCache) delete(set domain.StickerSet) {
if c == nil || set.ID == 0 {
return
}
c.mu.Lock()
defer c.mu.Unlock()
delete(c.byID, set.ID)
if set.ShortName != "" {
delete(c.byShort, set.ShortName)
}
if set.SystemKey != "" {
delete(c.bySystem, set.SystemKey)
}
}
func copyStickerSet(set domain.StickerSet) domain.StickerSet {
set.DocumentIDs = append([]int64(nil), set.DocumentIDs...)
set.Packs = append([]domain.StickerPack(nil), set.Packs...)
for i := range set.Packs {
set.Packs[i].DocumentIDs = append([]int64(nil), set.Packs[i].DocumentIDs...)
}
set.Keywords = append([]domain.StickerKeyword(nil), set.Keywords...)
for i := range set.Keywords {
set.Keywords[i].Keywords = append([]string(nil), set.Keywords[i].Keywords...)
}
set.Thumbs = copyPhotoSizes(set.Thumbs)
return set
}

View file

@ -6,6 +6,9 @@ import (
"testing"
"telesrv/internal/domain"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestBlobMetaCacheGetPutEvict(t *testing.T) {
@ -107,6 +110,58 @@ func TestGetFileCachesMetadataAndSmallBlobBytes(t *testing.T) {
}
}
func TestGetFileLogsCacheHitMiss(t *testing.T) {
ctx := context.Background()
local, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("local fs: %v", err)
}
objectKey, err := local.Put(ctx, []byte("0123456789"))
if err != nil {
t.Fatalf("put: %v", err)
}
media := newFakeMediaStore()
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:log", ObjectKey: objectKey, Size: 10, MimeType: "application/octet-stream"}); err != nil {
t.Fatalf("put blob: %v", err)
}
blobs := &countingBlobBackend{BlobBackend: local}
core, logs := observer.New(zap.InfoLevel)
svc := NewService(media, blobs, 2, WithLogger(zap.New(core)))
if _, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:log", Offset: 0, Limit: 5}); err != nil || !ok {
t.Fatalf("first getfile ok=%v err=%v", ok, err)
}
if _, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:log", Offset: 5, Limit: 5}); err != nil || !ok {
t.Fatalf("second getfile ok=%v err=%v", ok, err)
}
entries := logs.FilterMessage("upload.getFile cache").All()
if len(entries) != 2 {
t.Fatalf("cache log entries = %d, want 2", len(entries))
}
first := entries[0].ContextMap()
if first["source"] != "backend_fill_byte_cache" ||
first["meta_cache_hit"] != false ||
first["meta_cache_filled"] != true ||
first["byte_cache_hit"] != false ||
first["byte_cache_filled"] != true ||
first["backend_read"] != true ||
first["returned_bytes"] != int64(5) {
t.Fatalf("first cache log = %#v, want backend fill miss", first)
}
second := entries[1].ContextMap()
if second["source"] != "byte_cache" ||
second["meta_cache_hit"] != true ||
second["byte_cache_hit"] != true ||
second["backend_read"] != false ||
second["returned_bytes"] != int64(5) {
t.Fatalf("second cache log = %#v, want byte cache hit", second)
}
if blobs.getRangeCalls != 1 {
t.Fatalf("GetRange calls = %d, want only first miss to read backend", blobs.getRangeCalls)
}
}
func TestGetFileDoesNotByteCacheLargeBlob(t *testing.T) {
ctx := context.Background()
local, err := NewLocalFS(t.TempDir())

View file

@ -602,7 +602,7 @@ func stickerSetKind(sj seedStickerSetJSON, systemKey string) domain.StickerSetKi
}
func seedStickerSetInstalled(kind domain.StickerSetKind) bool {
return kind != domain.StickerSetKindSystem
return false
}
// ---- JSON → domain 转换 ----

View file

@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"sync"
"testing"
"time"
@ -209,17 +210,63 @@ func (f *fakeMediaStore) PutStickerSet(_ context.Context, set domain.StickerSet)
f.sets[set.ID] = set
return nil
}
func (f *fakeMediaStore) CreateStickerSet(_ context.Context, set domain.StickerSet, docs []domain.Document) error {
f.mu.Lock()
defer f.mu.Unlock()
for _, existing := range f.sets {
if existing.ShortName != "" && strings.EqualFold(existing.ShortName, set.ShortName) {
return domain.ErrStickerSetShortNameOccupied
}
}
f.sets[set.ID] = set
if f.docs == nil {
f.docs = map[int64]domain.Document{}
}
for _, doc := range docs {
f.docs[doc.ID] = doc
}
return nil
}
func (f *fakeMediaStore) UpdateStickerSet(_ context.Context, set domain.StickerSet, docs []domain.Document) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.sets[set.ID]; !ok {
return domain.ErrStickerSetInvalid
}
f.sets[set.ID] = set
if f.docs == nil {
f.docs = map[int64]domain.Document{}
}
for _, doc := range docs {
f.docs[doc.ID] = doc
}
return nil
}
func (f *fakeMediaStore) DeleteStickerSet(_ context.Context, setID int64, creatorUserID int64) error {
f.mu.Lock()
defer f.mu.Unlock()
set, ok := f.sets[setID]
if !ok || set.Deleted || set.CreatorUserID != creatorUserID {
return domain.ErrStickerSetInvalid
}
set.Deleted = true
f.sets[setID] = set
return nil
}
func (f *fakeMediaStore) GetStickerSetByID(_ context.Context, id int64) (domain.StickerSet, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
s, ok := f.sets[id]
if ok && s.Deleted {
return domain.StickerSet{}, false, nil
}
return s, ok, nil
}
func (f *fakeMediaStore) GetStickerSetByShortName(_ context.Context, name string) (domain.StickerSet, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, s := range f.sets {
if s.ShortName == name {
if strings.EqualFold(s.ShortName, name) && !s.Deleted {
return s, true, nil
}
}
@ -240,12 +287,48 @@ func (f *fakeMediaStore) ListStickerSets(_ context.Context, kind domain.StickerS
defer f.mu.Unlock()
var out []domain.StickerSet
for _, s := range f.sets {
if s.Kind == kind {
if s.Kind == kind && !s.Deleted {
out = append(out, s)
}
}
return out, nil
}
func (f *fakeMediaStore) ListStickerSetsByCreator(_ context.Context, creatorUserID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error) {
f.mu.Lock()
defer f.mu.Unlock()
var all []domain.StickerSet
for _, s := range f.sets {
if s.CreatorUserID == creatorUserID && !s.Deleted {
s.Creator = true
all = append(all, s)
}
}
sort.Slice(all, func(i, j int) bool { return all[i].ID > all[j].ID })
total := len(all)
if offsetID != 0 {
filtered := all[:0]
for _, s := range all {
if s.ID < offsetID {
filtered = append(filtered, s)
}
}
all = filtered
}
if limit > 0 && len(all) > limit {
all = all[:limit]
}
return append([]domain.StickerSet(nil), all...), total, nil
}
func (f *fakeMediaStore) StickerSetShortNameAvailable(_ context.Context, shortName string) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, s := range f.sets {
if s.ShortName != "" && strings.EqualFold(s.ShortName, shortName) && !s.Deleted {
return false, nil
}
}
return true, nil
}
func (f *fakeMediaStore) CountStickerSets(_ context.Context) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
@ -648,15 +731,15 @@ func TestSeedDocumentStorageIDNormalizesExternalIDs(t *testing.T) {
}
}
func TestSeedStickerSetInstalledFlagExcludesSystemSets(t *testing.T) {
func TestSeedStickerSetInstalledFlagNeverMarksViewerState(t *testing.T) {
cases := []struct {
name string
kind domain.StickerSetKind
want bool
}{
{name: "regular stickers", kind: domain.StickerSetKindStickers, want: true},
{name: "custom emoji", kind: domain.StickerSetKindEmoji, want: true},
{name: "masks", kind: domain.StickerSetKindMasks, want: true},
{name: "regular stickers", kind: domain.StickerSetKindStickers, want: false},
{name: "custom emoji", kind: domain.StickerSetKindEmoji, want: false},
{name: "masks", kind: domain.StickerSetKindMasks, want: false},
{name: "system resources", kind: domain.StickerSetKindSystem, want: false},
}
for _, tc := range cases {

View file

@ -263,23 +263,48 @@ func (s *Service) DeleteExpiredUploadParts(ctx context.Context, before time.Time
// 元数据走进程内 LRU(消除每 chunk 一次 PG 查);小 blob 全量字节进 LRU,供 sticker /
// reaction / thumbnail 热路径直接内存切片;大 blob 仍按 offset/limit 段读。
type blobMetaResult struct {
blob domain.FileBlob
found bool
blob domain.FileBlob
found bool
cacheHit bool
cacheFilled bool
}
type blobBytesResult struct {
data []byte
total int64
cacheable bool
data []byte
total int64
cacheable bool
cacheHit bool
cacheFilled bool
}
func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) {
type getFileCacheLog struct {
start time.Time
metaCacheHit bool
metaCacheFilled bool
metaSingleflight bool
byteCacheEligible bool
byteCacheHit bool
byteCacheFilled bool
byteSingleflight bool
backendRead bool
source string
}
func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (chunk domain.FileChunk, found bool, err error) {
cacheLog := getFileCacheLog{start: time.Now(), source: "unknown"}
var blob domain.FileBlob
defer func() {
s.logGetFileCache(req, blob, found, chunk, cacheLog, err)
}()
blob, ok := s.blobCache.get(req.LocationKey)
if !ok {
if ok {
cacheLog.metaCacheHit = true
} else {
// 同一 location_key 的并发首访合并成一次 PG GetFileBlob。
v, err, _ := s.blobMetaSF.Do(req.LocationKey, func() (any, error) {
v, err, shared := s.blobMetaSF.Do(req.LocationKey, func() (any, error) {
if cached, ok := s.blobCache.get(req.LocationKey); ok {
return blobMetaResult{blob: cached, found: true}, nil
return blobMetaResult{blob: cached, found: true, cacheHit: true}, nil
}
b, found, err := s.media.GetFileBlob(ctx, req.LocationKey)
if err != nil {
@ -288,19 +313,26 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
if found {
s.blobCache.put(req.LocationKey, b)
}
return blobMetaResult{blob: b, found: found}, nil
return blobMetaResult{blob: b, found: found, cacheFilled: found}, nil
})
cacheLog.metaSingleflight = shared
if err != nil {
return domain.FileChunk{}, false, err
}
res := v.(blobMetaResult)
cacheLog.metaCacheHit = res.cacheHit
cacheLog.metaCacheFilled = res.cacheFilled
if !res.found {
cacheLog.source = "metadata_miss"
return domain.FileChunk{}, false, nil
}
blob = res.blob
}
if blob.Size > 0 && blob.Size <= blobBytesCacheMaxEntryBytes {
cacheLog.byteCacheEligible = true
if data, ok := s.byteCache.get(blob.ObjectKey); ok {
cacheLog.byteCacheHit = true
cacheLog.source = "byte_cache"
return domain.FileChunk{
Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)),
MimeType: blob.MimeType,
@ -308,9 +340,9 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
}, true, nil
}
// 同一 object_key 的小 blob 并发首访合并成一次 backend 全量读 + 一次 byteCache 填充。
v, err, _ := s.blobBytesSF.Do(blob.ObjectKey, func() (any, error) {
v, err, shared := s.blobBytesSF.Do(blob.ObjectKey, func() (any, error) {
if cached, ok := s.byteCache.get(blob.ObjectKey); ok {
return blobBytesResult{data: cached, total: int64(len(cached)), cacheable: true}, nil
return blobBytesResult{data: cached, total: int64(len(cached)), cacheable: true, cacheHit: true}, nil
}
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1)
if err != nil {
@ -318,15 +350,24 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
}
if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total {
s.byteCache.put(blob.ObjectKey, data)
return blobBytesResult{data: data, total: total, cacheable: true}, nil
return blobBytesResult{data: data, total: total, cacheable: true, cacheFilled: true}, nil
}
return blobBytesResult{cacheable: false}, nil
})
cacheLog.byteSingleflight = shared
if err != nil {
return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err)
}
// res.data 在并发 caller 间只读共享,sliceBlobBytes 各自拷贝出自己的分片,安全。
if res := v.(blobBytesResult); res.cacheable {
cacheLog.byteCacheHit = res.cacheHit
cacheLog.byteCacheFilled = res.cacheFilled
cacheLog.backendRead = res.cacheFilled
if res.cacheHit {
cacheLog.source = "byte_cache"
} else {
cacheLog.source = "backend_fill_byte_cache"
}
return domain.FileChunk{
Bytes: sliceBlobBytes(res.data, req.Offset, int64(req.Limit)),
MimeType: blob.MimeType,
@ -334,6 +375,11 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
}, true, nil
}
// 大小不符/超限:落到下面的按需 range 读(与原行为一致)。
cacheLog.source = "backend_range_uncacheable"
}
cacheLog.backendRead = true
if cacheLog.source == "unknown" {
cacheLog.source = "backend_range"
}
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, req.Offset, int64(req.Limit))
if err != nil {
@ -346,6 +392,38 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
}, true, nil
}
func (s *Service) logGetFileCache(req domain.FileDownloadRequest, blob domain.FileBlob, found bool, chunk domain.FileChunk, cacheLog getFileCacheLog, err error) {
fields := []zap.Field{
zap.String("location_key", req.LocationKey),
zap.Int64("offset", req.Offset),
zap.Int("limit", req.Limit),
zap.Bool("found", found),
zap.String("source", cacheLog.source),
zap.Bool("meta_cache_hit", cacheLog.metaCacheHit),
zap.Bool("meta_cache_filled", cacheLog.metaCacheFilled),
zap.Bool("meta_singleflight_shared", cacheLog.metaSingleflight),
zap.Bool("byte_cache_eligible", cacheLog.byteCacheEligible),
zap.Bool("byte_cache_hit", cacheLog.byteCacheHit),
zap.Bool("byte_cache_filled", cacheLog.byteCacheFilled),
zap.Bool("byte_singleflight_shared", cacheLog.byteSingleflight),
zap.Bool("backend_read", cacheLog.backendRead),
zap.Int("returned_bytes", len(chunk.Bytes)),
zap.Int64("total_bytes", chunk.Total),
zap.Duration("dur", time.Since(cacheLog.start)),
}
if blob.ObjectKey != "" {
fields = append(fields,
zap.String("object_key", blob.ObjectKey),
zap.Int64("blob_size", blob.Size),
zap.String("mime_type", blob.MimeType),
)
}
if err != nil {
fields = append(fields, zap.Error(err))
}
s.log.Info("upload.getFile cache", fields...)
}
func sliceBlobBytes(data []byte, offset, limit int64) []byte {
total := int64(len(data))
if offset < 0 {

View file

@ -0,0 +1,661 @@
package files
import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"io"
"path/filepath"
"strings"
"unicode"
"unicode/utf8"
"telesrv/internal/domain"
)
func (s *Service) CheckStickerSetShortName(ctx context.Context, shortName string) (bool, error) {
shortName = normalizeStickerSetShortName(shortName)
if err := validateStickerSetShortName(shortName); err != nil {
return false, err
}
return s.media.StickerSetShortNameAvailable(ctx, shortName)
}
func (s *Service) SuggestStickerSetShortName(ctx context.Context, title string, userID int64) (string, error) {
if userID <= 0 {
return "", domain.ErrStickerSetCreatorInvalid
}
title = strings.TrimSpace(title)
if err := validateStickerSetTitle(title); err != nil {
return "", err
}
base := stickerSetShortNameBase(title)
candidates := []string{base, base + "_pack"}
if suffix := userIDSuffix(userID); suffix != "" {
candidates = append(candidates, base+"_"+suffix)
}
for i := 2; i <= 99; i++ {
candidates = append(candidates, trimStickerSetShortNameBase(base, 3)+"_"+itoaSmall(i))
}
for _, candidate := range candidates {
if err := validateStickerSetShortName(candidate); err != nil {
continue
}
available, err := s.media.StickerSetShortNameAvailable(ctx, candidate)
if err != nil {
return "", err
}
if available {
return candidate, nil
}
}
return "", domain.ErrStickerSetShortNameOccupied
}
func (s *Service) CreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error) {
if req.CreatorUserID <= 0 {
return domain.StickerSet{}, nil, domain.ErrStickerSetCreatorInvalid
}
title := strings.TrimSpace(req.Title)
if err := validateStickerSetTitle(title); err != nil {
return domain.StickerSet{}, nil, err
}
kind := normalizeStickerSetKind(req.Kind)
if len(req.Items) == 0 {
return domain.StickerSet{}, nil, domain.ErrStickerSetEmpty
}
if len(req.Items) > domain.MaxStickerSetItems {
return domain.StickerSet{}, nil, domain.ErrStickerSetTooMuch
}
shortName := normalizeStickerSetShortName(req.ShortName)
var err error
if shortName == "" {
shortName, err = s.SuggestStickerSetShortName(ctx, title, req.CreatorUserID)
if err != nil {
return domain.StickerSet{}, nil, err
}
} else {
if err := validateStickerSetShortName(shortName); err != nil {
return domain.StickerSet{}, nil, err
}
available, err := s.media.StickerSetShortNameAvailable(ctx, shortName)
if err != nil {
return domain.StickerSet{}, nil, err
}
if !available {
return domain.StickerSet{}, nil, domain.ErrStickerSetShortNameOccupied
}
}
docIDs, docAccess, thumbID, thumbAccess, err := stickerSetInputDocumentRefs(req)
if err != nil {
return domain.StickerSet{}, nil, err
}
loaded, err := s.media.GetDocuments(ctx, docIDs)
if err != nil {
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 {
doc, ok := docByID[item.DocumentID]
if !ok || doc.AccessHash != docAccess[item.DocumentID] || !doc.IsStickerSetMaterial() {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
if _, dup := seenDocs[item.DocumentID]; dup {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
seenDocs[item.DocumentID] = struct{}{}
emoji := strings.TrimSpace(item.Emoji)
if err := validateStickerEmoji(emoji); err != nil {
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)
}
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),
}
if thumbID != 0 {
thumb, ok := docByID[thumbID]
if !ok || thumb.AccessHash != thumbAccess {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
set.ThumbDocumentID = thumb.ID
set.Thumbs = copyPhotoSizes(thumb.Thumbs)
set.ThumbDCID = thumb.DCID
if len(set.Thumbs) > 0 {
set.ThumbVersion = 1
}
}
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
}
return domain.StickerSet{}, nil, err
}
ordered := orderDocuments(updatedDocs, set.DocumentIDs)
s.cacheStickerSet(set, ordered)
return set, ordered, nil
}
func (s *Service) ListCreatedStickerSets(ctx context.Context, userID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error) {
if userID <= 0 {
return nil, 0, domain.ErrStickerSetCreatorInvalid
}
return s.media.ListStickerSetsByCreator(ctx, userID, offsetID, limit)
}
func (s *Service) cacheStickerSet(set domain.StickerSet, docs []domain.Document) {
if s.stickerSetNegCache != nil {
refs := []domain.StickerSetRef{{Kind: domain.StickerSetRefByID, ID: set.ID}}
if set.ShortName != "" {
refs = append(refs, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: set.ShortName})
}
if set.SystemKey != "" {
refs = append(refs, domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: set.SystemKey})
}
s.stickerSetNegCache.delete(refs...)
}
if s.stickerSetCache != nil {
s.stickerSetCache.put(set, docs)
}
}
func stickerSetInputDocumentRefs(req domain.CreateStickerSetRequest) ([]int64, map[int64]int64, int64, int64, error) {
ids := make([]int64, 0, len(req.Items)+1)
access := make(map[int64]int64, len(req.Items))
seen := map[int64]struct{}{}
for _, item := range req.Items {
if item.DocumentID == 0 || item.DocumentAccessHash == 0 {
return nil, nil, 0, 0, domain.ErrStickerSetFileInvalid
}
if _, ok := seen[item.DocumentID]; !ok {
ids = append(ids, item.DocumentID)
seen[item.DocumentID] = struct{}{}
}
access[item.DocumentID] = item.DocumentAccessHash
}
if req.ThumbDocumentID != 0 {
if req.ThumbAccessHash == 0 {
return nil, nil, 0, 0, domain.ErrStickerSetFileInvalid
}
if _, ok := seen[req.ThumbDocumentID]; !ok {
ids = append(ids, req.ThumbDocumentID)
}
}
return ids, access, req.ThumbDocumentID, req.ThumbAccessHash, nil
}
func documentsByID(docs []domain.Document) map[int64]domain.Document {
out := make(map[int64]domain.Document, len(docs))
for _, doc := range docs {
out[doc.ID] = doc
}
return out
}
func attachStickerSetToDocument(doc domain.Document, set domain.StickerSet, emoji string) domain.Document {
want := domain.DocAttrSticker
if set.Kind == domain.StickerSetKindEmoji || set.Emojis {
want = domain.DocAttrCustomEmoji
}
attrs := append([]domain.DocumentAttribute(nil), doc.Attributes...)
replaced := false
for i := range attrs {
if attrs[i].Kind != domain.DocAttrSticker && attrs[i].Kind != domain.DocAttrCustomEmoji {
continue
}
attrs[i].Kind = want
attrs[i].Alt = emoji
attrs[i].StickerSetID = set.ID
attrs[i].StickerSetAccessHash = set.AccessHash
attrs[i].Mask = set.Kind == domain.StickerSetKindMasks || set.Masks
attrs[i].TextColor = set.TextColor
replaced = true
break
}
if !replaced {
attrs = append(attrs, domain.DocumentAttribute{
Kind: want,
Alt: emoji,
Mask: set.Kind == domain.StickerSetKindMasks || set.Masks,
StickerSetID: set.ID,
StickerSetAccessHash: set.AccessHash,
TextColor: set.TextColor,
})
}
doc.Attributes = attrs
return doc
}
func (s *Service) prepareStickerSetDocument(ctx context.Context, doc domain.Document, set domain.StickerSet, emoji string) (domain.Document, error) {
doc, err := s.ensureStickerMaterialShape(ctx, doc)
if err != nil {
return domain.Document{}, err
}
return attachStickerSetToDocument(doc, set, emoji), nil
}
func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Document) (domain.Document, error) {
if doc.IsStickerLike() {
return doc, nil
}
mimeType := doc.StickerSetMaterialMime()
hasImageSize := false
hasVideo := false
for _, attr := range doc.Attributes {
switch attr.Kind {
case domain.DocAttrImageSize:
hasImageSize = true
case domain.DocAttrVideo:
hasVideo = true
}
}
switch mimeType {
case "application/json":
data, ok := s.readStickerMaterialBlob(ctx, doc)
if !ok {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
lottieJSON := normalizeLottieStickerJSON(data)
if _, _, ok := lottieStickerDimensions(lottieJSON); !ok {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
tgsData, err := gzipLottieStickerData(lottieJSON)
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 {
return domain.Document{}, err
}
doc.MimeType = "application/x-tgsticker"
doc.Size = int64(len(tgsData))
doc.Attributes = replaceStickerMaterialFilename(doc.Attributes, "sticker.tgs")
if !hasImageSize {
doc.Attributes = append(doc.Attributes, domain.DocumentAttribute{
Kind: domain.DocAttrImageSize,
W: 512,
H: 512,
})
}
case "application/x-tgsticker":
if data, ok := s.readStickerMaterialBlob(ctx, doc); ok && !validTGSStickerData(data) {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
if !hasImageSize {
doc.Attributes = append(doc.Attributes, domain.DocumentAttribute{
Kind: domain.DocAttrImageSize,
W: 512,
H: 512,
})
}
case "image/webp":
if !hasImageSize {
data, ok := s.readStickerMaterialBlob(ctx, doc)
if !ok {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
w, h := imageDimensions(data, 0, 0)
if w <= 0 || h <= 0 {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
doc.Attributes = append(doc.Attributes, domain.DocumentAttribute{
Kind: domain.DocAttrImageSize,
W: w,
H: h,
})
}
case "video/webm", "video/mp4":
if !hasVideo {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
default:
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
return doc, nil
}
func (s *Service) readStickerMaterialBlob(ctx context.Context, doc domain.Document) ([]byte, bool) {
if s == nil || s.media == nil || s.blobs == nil || doc.ID == 0 || doc.Size <= 0 || doc.Size > domain.MaxStickerMaterialDocumentSize {
return nil, false
}
blob, found, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", doc.ID))
if err != nil || !found || blob.Size <= 0 || blob.Size > domain.MaxStickerMaterialDocumentSize {
return nil, false
}
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
if err != nil || int64(len(data)) != total || total != blob.Size {
return nil, false
}
return data, true
}
func (s *Service) rewriteStickerMaterialBlob(ctx context.Context, docID int64, data []byte, mimeType string) error {
if s == nil || s.media == nil || s.blobs == nil || docID == 0 || len(data) == 0 || int64(len(data)) > domain.MaxStickerMaterialDocumentSize {
return domain.ErrStickerSetFileInvalid
}
objectKey, err := s.blobs.Put(ctx, data)
if err != nil {
return err
}
sum := sha256.Sum256(data)
blob := domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", docID),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(data)),
SHA256: append([]byte(nil), sum[:]...),
MimeType: mimeType,
}
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return err
}
if s.blobCache != nil {
s.blobCache.put(blob.LocationKey, blob)
}
if s.byteCache != nil {
s.byteCache.put(blob.ObjectKey, data)
}
return nil
}
func replaceStickerMaterialFilename(attrs []domain.DocumentAttribute, fallback string) []domain.DocumentAttribute {
out := append([]domain.DocumentAttribute(nil), attrs...)
for i := range out {
if out[i].Kind != domain.DocAttrFilename {
continue
}
out[i].FileName = tgsFileName(out[i].FileName, fallback)
return out
}
return append(out, domain.DocumentAttribute{
Kind: domain.DocAttrFilename,
FileName: fallback,
})
}
func tgsFileName(fileName, fallback string) string {
fileName = strings.TrimSpace(fileName)
if fileName == "" {
return fallback
}
ext := filepath.Ext(fileName)
if ext == "" {
return fileName + ".tgs"
}
return strings.TrimSuffix(fileName, ext) + ".tgs"
}
func validTGSStickerData(data []byte) bool {
if len(data) == 0 {
return false
}
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return false
}
defer gz.Close()
data, err = io.ReadAll(io.LimitReader(gz, domain.MaxStickerMaterialDocumentSize+1))
if err != nil || int64(len(data)) > domain.MaxStickerMaterialDocumentSize {
return false
}
_, _, ok := lottieStickerDimensions(normalizeLottieStickerJSON(data))
return ok
}
func normalizeLottieStickerJSON(data []byte) []byte {
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
return bytes.TrimSpace(data)
}
func lottieStickerDimensions(data []byte) (int, int, bool) {
if len(data) == 0 || int64(len(data)) > domain.MaxStickerMaterialDocumentSize {
return 0, 0, false
}
var root struct {
Version string `json:"v"`
W int `json:"w"`
H int `json:"h"`
}
if err := json.NewDecoder(bytes.NewReader(data)).Decode(&root); err != nil {
return 0, 0, false
}
return root.W, root.H, root.Version != "" && root.W > 0 && root.H > 0
}
func gzipLottieStickerData(data []byte) ([]byte, error) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(data); err != nil {
_ = gz.Close()
return nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func normalizeStickerSetKind(kind domain.StickerSetKind) domain.StickerSetKind {
switch kind {
case domain.StickerSetKindEmoji, domain.StickerSetKindMasks:
return kind
default:
return domain.StickerSetKindStickers
}
}
func validateStickerSetTitle(title string) error {
if title == "" || utf8.RuneCountInString(title) > domain.MaxStickerSetTitleLen {
return domain.ErrStickerSetTitleInvalid
}
return nil
}
func normalizeStickerSetShortName(shortName string) string {
return strings.ToLower(strings.TrimSpace(shortName))
}
func validateStickerSetShortName(shortName string) error {
if len(shortName) < domain.MinStickerSetShortNameLen || len(shortName) > domain.MaxStickerSetShortNameLen {
return domain.ErrStickerSetShortNameInvalid
}
prevUnderscore := false
for i := 0; i < len(shortName); i++ {
ch := shortName[i]
switch {
case ch >= 'a' && ch <= 'z':
case ch >= '0' && ch <= '9':
if i == 0 {
return domain.ErrStickerSetShortNameInvalid
}
case ch == '_':
if i == 0 || i == len(shortName)-1 || prevUnderscore {
return domain.ErrStickerSetShortNameInvalid
}
prevUnderscore = true
continue
default:
return domain.ErrStickerSetShortNameInvalid
}
prevUnderscore = false
}
return nil
}
func validateStickerEmoji(emoji string) error {
if emoji == "" || utf8.RuneCountInString(emoji) > 64 {
return domain.ErrStickerSetEmojiInvalid
}
return nil
}
func stickerSetShortNameBase(title string) string {
var b strings.Builder
prevUnderscore := false
for _, r := range strings.ToLower(title) {
var out rune
switch {
case r >= 'a' && r <= 'z':
out = r
case r >= '0' && r <= '9':
out = r
case unicode.IsSpace(r) || r == '-' || r == '_':
out = '_'
default:
continue
}
if out == '_' {
if b.Len() == 0 || prevUnderscore {
continue
}
prevUnderscore = true
} else {
prevUnderscore = false
}
b.WriteRune(out)
if b.Len() >= domain.MaxStickerSetShortNameLen {
break
}
}
base := strings.Trim(b.String(), "_")
if base == "" || base[0] < 'a' || base[0] > 'z' {
base = "stickers_" + base
}
base = strings.Trim(base, "_")
if len(base) < domain.MinStickerSetShortNameLen {
base += "_pack"
}
return trimStickerSetShortNameBase(base, 0)
}
func trimStickerSetShortNameBase(base string, suffixReserve int) string {
max := domain.MaxStickerSetShortNameLen - suffixReserve
if max < domain.MinStickerSetShortNameLen {
max = domain.MinStickerSetShortNameLen
}
if len(base) <= max {
return strings.Trim(base, "_")
}
return strings.Trim(base[:max], "_")
}
func userIDSuffix(userID int64) string {
if userID <= 0 {
return ""
}
return itoaSmall(int(userID % 10000))
}
func itoaSmall(v int) string {
if v == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
return string(buf[i:])
}
func parseStickerKeywords(documentID int64, raw string) domain.StickerKeyword {
parts := strings.Split(raw, ",")
seen := map[string]struct{}{}
keywords := make([]string, 0, len(parts))
for _, part := range parts {
kw := strings.ToLower(strings.TrimSpace(part))
if kw == "" || utf8.RuneCountInString(kw) > domain.MaxStickerSetKeywordLen {
continue
}
if _, ok := seen[kw]; ok {
continue
}
seen[kw] = struct{}{}
keywords = append(keywords, kw)
if len(keywords) >= domain.MaxStickerSetKeywords {
break
}
}
return domain.StickerKeyword{DocumentID: documentID, Keywords: keywords}
}
func stickerSetHash(set domain.StickerSet) int {
h := fnv.New32a()
writeHashString(h, set.ShortName)
writeHashString(h, set.Title)
writeHashString(h, string(set.Kind))
var buf [8]byte
for _, id := range set.DocumentIDs {
binary.LittleEndian.PutUint64(buf[:], uint64(id))
_, _ = h.Write(buf[:])
}
for _, pack := range set.Packs {
writeHashString(h, pack.Emoticon)
for _, id := range pack.DocumentIDs {
binary.LittleEndian.PutUint64(buf[:], uint64(id))
_, _ = h.Write(buf[:])
}
}
sum := int(h.Sum32() & 0x7fffffff)
if sum == 0 {
return 1
}
return sum
}
func writeHashString(h interface{ Write([]byte) (int, error) }, s string) {
_, _ = h.Write([]byte(s))
_, _ = h.Write([]byte{0})
}

View file

@ -0,0 +1,338 @@
package files
import (
"context"
"errors"
"strings"
"testing"
"telesrv/internal/domain"
)
func TestCreateStickerSetInvalidatesNegativeCacheAndLinksDocuments(t *testing.T) {
ctx := context.Background()
media := &fakeMediaStore{
docs: map[int64]domain.Document{
101: {ID: 101, AccessHash: 1001, DCID: 2, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
},
photos: map[int64]domain.Photo{},
sets: map[int64]domain.StickerSet{},
}
svc := NewService(media, nil, 2)
_, _, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: "fresh_pack"})
if err != nil {
t.Fatalf("resolve before create: %v", err)
}
if found {
t.Fatalf("resolve before create found set, want miss")
}
set, docs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: 1000000001,
Title: "Fresh Pack",
ShortName: "fresh_pack",
Items: []domain.StickerSetItemInput{{
DocumentID: 101,
DocumentAccessHash: 1001,
Emoji: "🙂",
Keywords: "fresh, happy, fresh",
}},
})
if err != nil {
t.Fatalf("create sticker set: %v", err)
}
if set.ShortName != "fresh_pack" || !set.Creator || set.CreatorUserID != 1000000001 || set.Count != 1 {
t.Fatalf("created set = %+v, want creator-owned fresh_pack with one item", set)
}
if len(set.Keywords) != 1 || len(set.Keywords[0].Keywords) != 2 {
t.Fatalf("keywords = %+v, want deduped keyword list", set.Keywords)
}
if len(docs) != 1 {
t.Fatalf("created docs = %d, want 1", len(docs))
}
id, hash, ok := docs[0].StickerSetRef()
if !ok || id != set.ID || hash != set.AccessHash {
t.Fatalf("document sticker set ref = %d/%d/%v, want %d/%d/true", id, hash, ok, set.ID, set.AccessHash)
}
resolved, resolvedDocs, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: "fresh_pack"})
if err != nil {
t.Fatalf("resolve after create: %v", err)
}
if !found || resolved.ID != set.ID || len(resolvedDocs) != 1 {
t.Fatalf("resolve after create = found %v set %+v docs %d, want created set", found, resolved, len(resolvedDocs))
}
}
func TestCreateStickerSetAcceptsUploadedStickerMaterial(t *testing.T) {
ctx := context.Background()
media := &fakeMediaStore{
docs: map[int64]domain.Document{
201: {
ID: 201,
AccessHash: 2001,
DCID: 2,
MimeType: "application/octet-stream",
Size: 4096,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "local.tgs"}},
},
},
sets: map[int64]domain.StickerSet{},
}
svc := NewService(media, nil, 2)
set, docs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: 1000000001,
Title: "Uploads",
ShortName: "uploads_pack",
Items: []domain.StickerSetItemInput{{
DocumentID: 201,
DocumentAccessHash: 2001,
Emoji: "👋",
}},
})
if err != nil {
t.Fatalf("create with uploaded material: %v", err)
}
if len(docs) != 1 || !docs[0].IsSticker() {
t.Fatalf("created docs = %+v, want sticker-tagged uploaded document", docs)
}
if id, hash, ok := docs[0].StickerSetRef(); !ok || id != set.ID || hash != set.AccessHash {
t.Fatalf("uploaded doc sticker ref = %d/%d/%v, want %d/%d/true", id, hash, ok, set.ID, set.AccessHash)
}
if !documentHasAttr(docs[0], domain.DocAttrImageSize) || !documentHasAttr(docs[0], domain.DocAttrFilename) {
t.Fatalf("uploaded doc attrs = %+v, want filename preserved and image size added", docs[0].Attributes)
}
}
func TestCreateStickerSetAcceptsWebPMaterialWithClientImageSize(t *testing.T) {
ctx := context.Background()
media := &fakeMediaStore{
docs: map[int64]domain.Document{
202: {
ID: 202,
AccessHash: 2002,
DCID: 2,
MimeType: "image/webp",
Size: 4096,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrImageSize, W: 512, H: 512}},
},
},
sets: map[int64]domain.StickerSet{},
}
svc := NewService(media, nil, 2)
_, docs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: 1000000001,
Title: "WebP Uploads",
ShortName: "webp_uploads",
Items: []domain.StickerSetItemInput{{
DocumentID: 202,
DocumentAccessHash: 2002,
Emoji: "🙂",
}},
})
if err != nil {
t.Fatalf("create with client-sized webp: %v", err)
}
if len(docs) != 1 || !docs[0].IsSticker() || !documentHasAttr(docs[0], domain.DocAttrImageSize) {
t.Fatalf("created docs = %+v, want sticker with image size preserved", docs)
}
}
func TestCreateStickerSetConvertsLottieJSONMaterialToTGS(t *testing.T) {
ctx := context.Background()
raw := testLottieJSON()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("new local fs: %v", err)
}
objectKey, err := blobs.Put(ctx, raw)
if err != nil {
t.Fatalf("put lottie json blob: %v", err)
}
media := newFakeMediaStore()
media.docs[204] = domain.Document{
ID: 204,
AccessHash: 2004,
DCID: 2,
MimeType: "application/json",
Size: int64(len(raw)),
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "wave.json"}},
}
if err := media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: "doc:204",
Backend: domain.MediaBackend(blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(raw)),
MimeType: "application/json",
}); err != nil {
t.Fatalf("put lottie file blob: %v", err)
}
svc := NewService(media, blobs, 2)
_, docs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: 1000000001,
Title: "Lottie Uploads",
ShortName: "lottie_uploads",
Items: []domain.StickerSetItemInput{{
DocumentID: 204,
DocumentAccessHash: 2004,
Emoji: "👋",
}},
})
if err != nil {
t.Fatalf("create with lottie json: %v", err)
}
if len(docs) != 1 || !docs[0].IsSticker() || docs[0].MimeType != "application/x-tgsticker" {
t.Fatalf("created docs = %+v, want sticker-tagged tgs document", docs)
}
if !documentHasAttr(docs[0], domain.DocAttrImageSize) {
t.Fatalf("converted doc attrs = %+v, want image size", docs[0].Attributes)
}
if got := documentFileName(docs[0]); got != "wave.tgs" {
t.Fatalf("converted filename = %q, want wave.tgs", got)
}
blob, found, err := media.GetFileBlob(ctx, "doc:204")
if err != nil || !found {
t.Fatalf("converted file blob found=%v err=%v", found, err)
}
if blob.MimeType != "application/x-tgsticker" || blob.Size != docs[0].Size {
t.Fatalf("converted file blob = %+v doc size %d, want tgs metadata", blob, docs[0].Size)
}
data, total, err := blobs.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
if err != nil {
t.Fatalf("read converted tgs blob: %v", err)
}
if int64(len(data)) != total || !validTGSStickerData(data) {
t.Fatalf("converted blob len=%d total=%d valid=%v, want valid tgs", len(data), total, validTGSStickerData(data))
}
}
func TestCreateStickerSetRejectsInvalidLottieJSONMaterial(t *testing.T) {
ctx := context.Background()
raw := []byte(`{"v":"5.7.4","layers":[]}`)
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("new local fs: %v", err)
}
objectKey, err := blobs.Put(ctx, raw)
if err != nil {
t.Fatalf("put invalid lottie json blob: %v", err)
}
media := newFakeMediaStore()
media.docs[205] = domain.Document{
ID: 205,
AccessHash: 2005,
DCID: 2,
MimeType: "application/json",
Size: int64(len(raw)),
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "bad.json"}},
}
if err := media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: "doc:205",
Backend: domain.MediaBackend(blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(raw)),
MimeType: "application/json",
}); err != nil {
t.Fatalf("put invalid lottie file blob: %v", err)
}
svc := NewService(media, blobs, 2)
_, _, err = svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: 1000000001,
Title: "Bad Lottie Uploads",
ShortName: "bad_lottie_uploads",
Items: []domain.StickerSetItemInput{{
DocumentID: 205,
DocumentAccessHash: 2005,
Emoji: "👋",
}},
})
if !errors.Is(err, domain.ErrStickerSetFileInvalid) {
t.Fatalf("create with invalid lottie json err = %v, want ErrStickerSetFileInvalid", err)
}
}
func TestCreateStickerSetRejectsWebPMaterialWithoutShape(t *testing.T) {
ctx := context.Background()
media := &fakeMediaStore{
docs: map[int64]domain.Document{
203: {ID: 203, AccessHash: 2003, MimeType: "image/webp", Size: 4096},
},
sets: map[int64]domain.StickerSet{},
}
svc := NewService(media, nil, 2)
_, _, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: 1000000001,
Title: "Bad WebP Uploads",
ShortName: "bad_webp_uploads",
Items: []domain.StickerSetItemInput{{
DocumentID: 203,
DocumentAccessHash: 2003,
Emoji: "🙂",
}},
})
if !errors.Is(err, domain.ErrStickerSetFileInvalid) {
t.Fatalf("create with unsized webp err = %v, want ErrStickerSetFileInvalid", err)
}
}
func TestCreateStickerSetRejectsDuplicateShortNameCaseInsensitive(t *testing.T) {
ctx := context.Background()
media := &fakeMediaStore{
docs: map[int64]domain.Document{
101: {ID: 101, AccessHash: 1001, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
},
sets: map[int64]domain.StickerSet{
10: {ID: 10, ShortName: "Fresh_Pack", Kind: domain.StickerSetKindStickers},
},
}
svc := NewService(media, nil, 2)
_, _, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: 1000000001,
Title: "Other",
ShortName: "fresh_pack",
Items: []domain.StickerSetItemInput{{
DocumentID: 101,
DocumentAccessHash: 1001,
Emoji: "🙂",
}},
})
if !errors.Is(err, domain.ErrStickerSetShortNameOccupied) {
t.Fatalf("duplicate create err = %v, want ErrStickerSetShortNameOccupied", err)
}
}
func documentHasAttr(doc domain.Document, kind domain.DocumentAttributeKind) bool {
for _, attr := range doc.Attributes {
if attr.Kind == kind {
return true
}
}
return false
}
func documentFileName(doc domain.Document) string {
for _, attr := range doc.Attributes {
if attr.Kind == domain.DocAttrFilename {
return attr.FileName
}
}
return ""
}
func testLottieJSON() []byte {
return []byte(strings.TrimSpace(`{
"v": "5.7.4",
"fr": 30,
"ip": 0,
"op": 30,
"w": 512,
"h": 512,
"layers": []
}`))
}

View file

@ -0,0 +1,343 @@
package files
import (
"context"
"strings"
"telesrv/internal/domain"
)
func (s *Service) AddStickerToSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error) {
set, docs, err := s.resolveOwnedStickerSet(ctx, actorUserID, ref)
if err != nil {
return domain.StickerSet{}, nil, err
}
if len(set.DocumentIDs) >= domain.MaxStickerSetItems {
return domain.StickerSet{}, nil, domain.ErrStickerSetTooMuch
}
doc, err := s.loadStickerMaterialDocument(ctx, item.DocumentID, item.DocumentAccessHash)
if err != nil {
return domain.StickerSet{}, nil, err
}
if ownedSetID, _, ok := doc.StickerSetRef(); ok && ownedSetID != set.ID {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
if containsInt64(set.DocumentIDs, doc.ID) {
return set, docs, nil
}
emoji := strings.TrimSpace(item.Emoji)
if err := validateStickerEmoji(emoji); err != nil {
return domain.StickerSet{}, nil, err
}
doc, err = s.prepareStickerSetDocument(ctx, doc, set, emoji)
if err != nil {
return domain.StickerSet{}, nil, err
}
set.DocumentIDs = append(set.DocumentIDs, doc.ID)
set.Count = len(set.DocumentIDs)
set.Packs = addDocumentToStickerPacks(set.Packs, emoji, doc.ID)
set.Keywords = upsertStickerKeywords(set.Keywords, parseStickerKeywords(doc.ID, item.Keywords))
if set.ThumbDocumentID == 0 {
setStickerSetThumbFromDocument(&set, doc)
}
set.Hash = stickerSetHash(set)
docs = append(docs, doc)
return s.persistStickerSetMutation(ctx, set, docs, []domain.Document{doc})
}
func (s *Service) RemoveStickerFromSet(ctx context.Context, actorUserID int64, documentID int64, accessHash int64) (domain.StickerSet, []domain.Document, error) {
doc, err := s.loadStickerInputDocument(ctx, documentID, accessHash)
if err != nil {
return domain.StickerSet{}, nil, err
}
setID, setAccessHash, ok := doc.StickerSetRef()
if !ok || setID == 0 {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
set, docs, err := s.resolveOwnedStickerSet(ctx, actorUserID, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: setID, AccessHash: setAccessHash})
if err != nil {
return domain.StickerSet{}, nil, err
}
if len(set.DocumentIDs) <= 1 {
return domain.StickerSet{}, nil, domain.ErrStickerSetEmpty
}
idx := indexInt64(set.DocumentIDs, documentID)
if idx < 0 {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
set.DocumentIDs = removeInt64At(set.DocumentIDs, idx)
set.Count = len(set.DocumentIDs)
set.Packs = removeDocumentFromStickerPacks(set.Packs, documentID)
set.Keywords = removeStickerKeywords(set.Keywords, documentID)
doc = detachStickerSetFromDocument(doc)
docs = removeDocumentByID(docs, documentID)
if set.ThumbDocumentID == documentID {
clearStickerSetThumb(&set)
if len(docs) > 0 {
setStickerSetThumbFromDocument(&set, docs[0])
}
}
set.Hash = stickerSetHash(set)
return s.persistStickerSetMutation(ctx, set, docs, []domain.Document{doc})
}
func (s *Service) ChangeStickerPosition(ctx context.Context, actorUserID int64, documentID int64, accessHash int64, position int) (domain.StickerSet, []domain.Document, error) {
doc, err := s.loadStickerInputDocument(ctx, documentID, accessHash)
if err != nil {
return domain.StickerSet{}, nil, err
}
setID, setAccessHash, ok := doc.StickerSetRef()
if !ok || setID == 0 {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
set, docs, err := s.resolveOwnedStickerSet(ctx, actorUserID, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: setID, AccessHash: setAccessHash})
if err != nil {
return domain.StickerSet{}, nil, err
}
if position < 0 || position >= len(set.DocumentIDs) {
return domain.StickerSet{}, nil, domain.ErrStickerSetPositionInvalid
}
from := indexInt64(set.DocumentIDs, documentID)
if from < 0 {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
set.DocumentIDs = moveInt64(set.DocumentIDs, from, position)
docs = orderDocuments(docs, set.DocumentIDs)
set.Hash = stickerSetHash(set)
return s.persistStickerSetMutation(ctx, set, docs, nil)
}
func (s *Service) RenameStickerSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef, title string) (domain.StickerSet, []domain.Document, error) {
set, docs, err := s.resolveOwnedStickerSet(ctx, actorUserID, ref)
if err != nil {
return domain.StickerSet{}, nil, err
}
title = strings.TrimSpace(title)
if err := validateStickerSetTitle(title); err != nil {
return domain.StickerSet{}, nil, err
}
set.Title = title
set.Hash = stickerSetHash(set)
return s.persistStickerSetMutation(ctx, set, docs, nil)
}
func (s *Service) DeleteStickerSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef) (domain.StickerSetKind, error) {
set, _, err := s.resolveOwnedStickerSet(ctx, actorUserID, ref)
if err != nil {
return "", err
}
if err := s.media.DeleteStickerSet(ctx, set.ID, actorUserID); err != nil {
return "", err
}
s.deleteCachedStickerSet(set)
return set.Kind, nil
}
func (s *Service) resolveOwnedStickerSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, error) {
if actorUserID <= 0 {
return domain.StickerSet{}, nil, domain.ErrStickerSetCreatorInvalid
}
if ref.Kind != domain.StickerSetRefByID && ref.Kind != domain.StickerSetRefByShortName {
return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid
}
set, docs, found, err := s.ResolveStickerSet(ctx, ref)
if err != nil {
return domain.StickerSet{}, nil, err
}
if !found || set.ID == 0 || set.Deleted {
return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid
}
if ref.Kind == domain.StickerSetRefByID && set.AccessHash != ref.AccessHash {
return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid
}
if set.CreatorUserID != actorUserID {
return domain.StickerSet{}, nil, domain.ErrStickerSetNotOwned
}
return set, docs, nil
}
func (s *Service) loadStickerInputDocument(ctx context.Context, documentID int64, accessHash int64) (domain.Document, error) {
if documentID == 0 || accessHash == 0 {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
docs, err := s.media.GetDocuments(ctx, []int64{documentID})
if err != nil {
return domain.Document{}, err
}
if len(docs) != 1 || docs[0].ID != documentID || docs[0].AccessHash != accessHash || !docs[0].IsStickerLike() {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
return docs[0], nil
}
func (s *Service) loadStickerMaterialDocument(ctx context.Context, documentID int64, accessHash int64) (domain.Document, error) {
if documentID == 0 || accessHash == 0 {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
docs, err := s.media.GetDocuments(ctx, []int64{documentID})
if err != nil {
return domain.Document{}, err
}
if len(docs) != 1 || docs[0].ID != documentID || docs[0].AccessHash != accessHash || !docs[0].IsStickerSetMaterial() {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
return docs[0], nil
}
func (s *Service) persistStickerSetMutation(ctx context.Context, set domain.StickerSet, docs []domain.Document, changedDocs []domain.Document) (domain.StickerSet, []domain.Document, error) {
if err := s.media.UpdateStickerSet(ctx, set, changedDocs); err != nil {
return domain.StickerSet{}, nil, err
}
ordered := orderDocuments(docs, set.DocumentIDs)
s.cacheStickerSet(set, ordered)
return set, ordered, nil
}
func (s *Service) deleteCachedStickerSet(set domain.StickerSet) {
if s.stickerSetNegCache != nil {
s.stickerSetNegCache.put(domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID})
if set.ShortName != "" {
s.stickerSetNegCache.put(domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: set.ShortName})
}
}
if s.stickerSetCache != nil {
s.stickerSetCache.delete(set)
}
}
func addDocumentToStickerPacks(packs []domain.StickerPack, emoji string, documentID int64) []domain.StickerPack {
out := copyStickerPacks(packs)
for i := range out {
if out[i].Emoticon == emoji {
if !containsInt64(out[i].DocumentIDs, documentID) {
out[i].DocumentIDs = append(out[i].DocumentIDs, documentID)
}
return out
}
}
return append(out, domain.StickerPack{Emoticon: emoji, DocumentIDs: []int64{documentID}})
}
func removeDocumentFromStickerPacks(packs []domain.StickerPack, documentID int64) []domain.StickerPack {
out := make([]domain.StickerPack, 0, len(packs))
for _, pack := range packs {
ids := removeInt64Value(pack.DocumentIDs, documentID)
if len(ids) == 0 {
continue
}
out = append(out, domain.StickerPack{Emoticon: pack.Emoticon, DocumentIDs: ids})
}
return out
}
func upsertStickerKeywords(in []domain.StickerKeyword, kw domain.StickerKeyword) []domain.StickerKeyword {
out := removeStickerKeywords(in, kw.DocumentID)
if len(kw.Keywords) == 0 {
return out
}
return append(out, kw)
}
func removeStickerKeywords(in []domain.StickerKeyword, documentID int64) []domain.StickerKeyword {
out := make([]domain.StickerKeyword, 0, len(in))
for _, kw := range in {
if kw.DocumentID == documentID {
continue
}
out = append(out, domain.StickerKeyword{DocumentID: kw.DocumentID, Keywords: append([]string(nil), kw.Keywords...)})
}
return out
}
func detachStickerSetFromDocument(doc domain.Document) domain.Document {
attrs := append([]domain.DocumentAttribute(nil), doc.Attributes...)
for i := range attrs {
if attrs[i].Kind != domain.DocAttrSticker && attrs[i].Kind != domain.DocAttrCustomEmoji {
continue
}
attrs[i].StickerSetID = 0
attrs[i].StickerSetAccessHash = 0
attrs[i].Mask = false
attrs[i].TextColor = false
break
}
doc.Attributes = attrs
return doc
}
func setStickerSetThumbFromDocument(set *domain.StickerSet, doc domain.Document) {
set.ThumbDocumentID = doc.ID
set.Thumbs = copyPhotoSizes(doc.Thumbs)
set.ThumbDCID = doc.DCID
set.ThumbVersion = 0
if len(set.Thumbs) > 0 {
set.ThumbVersion = 1
}
}
func clearStickerSetThumb(set *domain.StickerSet) {
set.ThumbDocumentID = 0
set.Thumbs = nil
set.ThumbDCID = 0
set.ThumbVersion = 0
}
func copyStickerPacks(packs []domain.StickerPack) []domain.StickerPack {
out := append([]domain.StickerPack(nil), packs...)
for i := range out {
out[i].DocumentIDs = append([]int64(nil), out[i].DocumentIDs...)
}
return out
}
func containsInt64(in []int64, value int64) bool {
return indexInt64(in, value) >= 0
}
func indexInt64(in []int64, value int64) int {
for i, v := range in {
if v == value {
return i
}
}
return -1
}
func removeInt64At(in []int64, idx int) []int64 {
out := append([]int64(nil), in[:idx]...)
return append(out, in[idx+1:]...)
}
func removeInt64Value(in []int64, value int64) []int64 {
out := make([]int64, 0, len(in))
for _, v := range in {
if v != value {
out = append(out, v)
}
}
return out
}
func moveInt64(in []int64, from, to int) []int64 {
out := append([]int64(nil), in...)
if from == to {
return out
}
value := out[from]
out = append(out[:from], out[from+1:]...)
if to >= len(out) {
return append(out, value)
}
out = append(out[:to], append([]int64{value}, out[to:]...)...)
return out
}
func removeDocumentByID(docs []domain.Document, documentID int64) []domain.Document {
out := make([]domain.Document, 0, len(docs))
for _, doc := range docs {
if doc.ID != documentID {
out = append(out, doc)
}
}
return out
}

View file

@ -0,0 +1,180 @@
package files
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
func TestManageStickerSetMutationsKeepSetAndDocumentsConsistent(t *testing.T) {
ctx := context.Background()
media := &fakeMediaStore{
docs: map[int64]domain.Document{
101: {ID: 101, AccessHash: 1001, DCID: 2, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
102: {ID: 102, AccessHash: 1002, DCID: 2, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
},
sets: map[int64]domain.StickerSet{},
}
svc := NewService(media, nil, 2)
set, docs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: 1000000001,
Title: "Fresh Pack",
ShortName: "fresh_pack",
Items: []domain.StickerSetItemInput{{
DocumentID: 101,
DocumentAccessHash: 1001,
Emoji: "🙂",
}},
})
if err != nil {
t.Fatalf("create sticker set: %v", err)
}
originalHash := set.Hash
if len(docs) != 1 {
t.Fatalf("created docs = %d, want 1", len(docs))
}
set, docs, err = svc.AddStickerToSet(ctx, 1000000001, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: "fresh_pack"}, domain.StickerSetItemInput{
DocumentID: 102,
DocumentAccessHash: 1002,
Emoji: "😄",
Keywords: "smile, fresh",
})
if err != nil {
t.Fatalf("add sticker: %v", err)
}
if set.Count != 2 || len(set.DocumentIDs) != 2 || len(docs) != 2 || set.Hash == originalHash {
t.Fatalf("after add set=%+v docs=%d originalHash=%d, want two docs and bumped hash", set, len(docs), originalHash)
}
if id, hash, ok := docs[1].StickerSetRef(); !ok || id != set.ID || hash != set.AccessHash {
t.Fatalf("added doc sticker ref = %d/%d/%v, want %d/%d/true", id, hash, ok, set.ID, set.AccessHash)
}
set, docs, err = svc.ChangeStickerPosition(ctx, 1000000001, 102, 1002, 0)
if err != nil {
t.Fatalf("change sticker position: %v", err)
}
if got := set.DocumentIDs; len(got) != 2 || got[0] != 102 || got[1] != 101 {
t.Fatalf("document order after move = %v, want [102 101]", got)
}
if len(docs) != 2 || docs[0].ID != 102 || docs[1].ID != 101 {
t.Fatalf("returned docs after move = %+v, want 102 then 101", docs)
}
set, docs, err = svc.RenameStickerSet(ctx, 1000000001, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID, AccessHash: set.AccessHash}, "Renamed Pack")
if err != nil {
t.Fatalf("rename sticker set: %v", err)
}
if set.Title != "Renamed Pack" || len(docs) != 2 {
t.Fatalf("renamed set=%+v docs=%d, want renamed with docs intact", set, len(docs))
}
set, docs, err = svc.RemoveStickerFromSet(ctx, 1000000001, 102, 1002)
if err != nil {
t.Fatalf("remove sticker: %v", err)
}
if set.Count != 1 || len(set.DocumentIDs) != 1 || set.DocumentIDs[0] != 101 || len(docs) != 1 || docs[0].ID != 101 {
t.Fatalf("after remove set=%+v docs=%+v, want only doc 101", set, docs)
}
detached, ok := media.docs[102]
if !ok {
t.Fatalf("detached doc missing from fake store")
}
if id, _, ok := detached.StickerSetRef(); ok || id != 0 {
t.Fatalf("removed doc sticker ref = %d/%v, want detached", id, ok)
}
_, _, err = svc.RemoveStickerFromSet(ctx, 1000000001, 101, 1001)
if !errors.Is(err, domain.ErrStickerSetEmpty) {
t.Fatalf("remove last sticker err = %v, want ErrStickerSetEmpty", err)
}
kind, err := svc.DeleteStickerSet(ctx, 1000000001, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID, AccessHash: set.AccessHash})
if err != nil {
t.Fatalf("delete sticker set: %v", err)
}
if kind != domain.StickerSetKindStickers {
t.Fatalf("delete kind = %q, want stickers", kind)
}
if _, _, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: "fresh_pack"}); err != nil || found {
t.Fatalf("resolve deleted set = found %v err %v, want miss", found, err)
}
}
func TestManageStickerSetRejectsNonCreator(t *testing.T) {
ctx := context.Background()
media := &fakeMediaStore{
docs: map[int64]domain.Document{
101: {ID: 101, AccessHash: 1001, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
102: {ID: 102, AccessHash: 1002, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
},
sets: map[int64]domain.StickerSet{},
}
svc := NewService(media, nil, 2)
set, _, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: 1000000001,
Title: "Fresh Pack",
ShortName: "fresh_pack",
Items: []domain.StickerSetItemInput{{
DocumentID: 101,
DocumentAccessHash: 1001,
Emoji: "🙂",
}},
})
if err != nil {
t.Fatalf("create sticker set: %v", err)
}
_, _, err = svc.AddStickerToSet(ctx, 1000000002, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID, AccessHash: set.AccessHash}, domain.StickerSetItemInput{
DocumentID: 102,
DocumentAccessHash: 1002,
Emoji: "😄",
})
if !errors.Is(err, domain.ErrStickerSetNotOwned) {
t.Fatalf("non-creator add err = %v, want ErrStickerSetNotOwned", err)
}
}
func TestAddStickerToSetAcceptsUploadedMaterial(t *testing.T) {
ctx := context.Background()
media := &fakeMediaStore{
docs: map[int64]domain.Document{
101: {ID: 101, AccessHash: 1001, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
202: {ID: 202, AccessHash: 2002, MimeType: "video/mp4", Size: 4096, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrVideo, W: 512, H: 512, Duration: 1}}},
},
sets: map[int64]domain.StickerSet{},
}
svc := NewService(media, nil, 2)
set, _, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: 1000000001,
Title: "Fresh Pack",
ShortName: "fresh_pack",
Items: []domain.StickerSetItemInput{{
DocumentID: 101,
DocumentAccessHash: 1001,
Emoji: "🙂",
}},
})
if err != nil {
t.Fatalf("create sticker set: %v", err)
}
set, docs, err := svc.AddStickerToSet(ctx, 1000000001, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID, AccessHash: set.AccessHash}, domain.StickerSetItemInput{
DocumentID: 202,
DocumentAccessHash: 2002,
Emoji: "🎬",
})
if err != nil {
t.Fatalf("add uploaded material: %v", err)
}
if set.Count != 2 || len(docs) != 2 {
t.Fatalf("after add set=%+v docs=%d, want two items", set, len(docs))
}
added := docs[1]
if added.ID != 202 || !added.IsSticker() || !documentHasAttr(added, domain.DocAttrVideo) {
t.Fatalf("added doc = %+v, want sticker-tagged video material", added)
}
}