fixed stickers and emojies creation packs

This commit is contained in:
onysd 2026-07-22 14:01:02 +03:00
parent 43894f2fc9
commit 139967399d
16 changed files with 771 additions and 22 deletions

View file

@ -41,6 +41,9 @@ const (
ActionSetStickerSetSortOrder = "stickers.set_sort_order"
ActionRenameStickerSet = "stickers.rename"
ActionDeleteStickerSet = "stickers.delete"
ActionCreateStickerSet = "stickers.create"
ActionAddStickerToSet = "stickers.add_sticker"
ActionRemoveStickerFromSet = "stickers.remove_sticker"
maxCommandIDLength = 128
maxActorLength = 128
@ -139,6 +142,13 @@ type StickerSetsService interface {
AdminSetStickerSetSortOrder(ctx context.Context, setID int64, order int) (bool, error)
AdminRenameStickerSet(ctx context.Context, setID int64, title string) (domain.StickerSet, error)
AdminDeleteStickerSet(ctx context.Context, setID int64) (domain.StickerSetKind, error)
// ValidateStickerMaterialUpload is a pure check (no store writes) so a dry-run
// preview can validate an uploaded file's shape without materializing it.
ValidateStickerMaterialUpload(fileName string, data []byte) (mimeType string, ok bool)
AdminUploadStickerMaterial(ctx context.Context, fileName string, data []byte) (domain.Document, error)
AdminCreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error)
AdminAddStickerToSet(ctx context.Context, setID int64, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error)
AdminRemoveStickerFromSet(ctx context.Context, setID int64, documentID int64) (domain.StickerSet, []domain.Document, error)
}
type Dependencies struct {
@ -324,6 +334,32 @@ type DeleteStickerSetRequest struct {
SetID int64 `json:"set_id"`
}
type CreateStickerSetRequest struct {
CommandMeta
Title string `json:"title"`
ShortName string `json:"short_name"`
Kind string `json:"kind"`
Emoji string `json:"emoji"`
Keywords string `json:"keywords,omitempty"`
FileName string `json:"file_name"`
Data []byte `json:"-"`
}
type AddStickerToSetRequest struct {
CommandMeta
SetID int64 `json:"set_id"`
Emoji string `json:"emoji"`
Keywords string `json:"keywords,omitempty"`
FileName string `json:"file_name"`
Data []byte `json:"-"`
}
type RemoveStickerFromSetRequest struct {
CommandMeta
SetID int64 `json:"set_id"`
DocumentID int64 `json:"document_id"`
}
type StarGiftCollectibleAnimationUpload struct {
Name string `json:"name"`
RarityPermille int `json:"rarity_permille"`
@ -1505,6 +1541,109 @@ func (s *Service) DeleteStickerSet(ctx context.Context, req DeleteStickerSetRequ
})
}
func (s *Service) CreateStickerSet(ctx context.Context, req CreateStickerSetRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil {
return CommandResult{}, fmt.Errorf("sticker sets service is not configured")
}
if strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.ShortName) == "" || strings.TrimSpace(req.Emoji) == "" {
return CommandResult{}, domain.ErrStickerSetFileInvalid
}
mimeType, ok := s.stickerSets.ValidateStickerMaterialUpload(req.FileName, req.Data)
if !ok {
return CommandResult{}, domain.ErrStickerSetFileInvalid
}
kind := domain.StickerSetKindStickers
if req.Kind == string(domain.StickerSetKindEmoji) {
kind = domain.StickerSetKindEmoji
}
return s.runCommand(ctx, req.CommandMeta, ActionCreateStickerSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"title": req.Title, "short_name": req.ShortName, "kind": string(kind),
"file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
}
if req.DryRun {
return CommandResult{Message: "sticker pack validated", Details: details}, nil
}
doc, err := s.stickerSets.AdminUploadStickerMaterial(ctx, req.FileName, req.Data)
if err != nil {
return CommandResult{Details: details}, err
}
set, _, err := s.stickerSets.AdminCreateStickerSet(ctx, domain.CreateStickerSetRequest{
Title: req.Title,
ShortName: req.ShortName,
Kind: kind,
Items: []domain.StickerSetItemInput{{
DocumentID: doc.ID,
DocumentAccessHash: doc.AccessHash,
Emoji: req.Emoji,
Keywords: req.Keywords,
}},
})
if err != nil {
return CommandResult{Details: details}, err
}
details["set_id"] = strconv.FormatInt(set.ID, 10)
details["short_name"] = set.ShortName
return CommandResult{Message: "sticker pack created", Details: details}, nil
})
}
func (s *Service) AddStickerToSet(ctx context.Context, req AddStickerToSetRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 {
return CommandResult{}, fmt.Errorf("valid sticker set and service are required")
}
if strings.TrimSpace(req.Emoji) == "" {
return CommandResult{}, domain.ErrStickerSetEmojiInvalid
}
mimeType, ok := s.stickerSets.ValidateStickerMaterialUpload(req.FileName, req.Data)
if !ok {
return CommandResult{}, domain.ErrStickerSetFileInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionAddStickerToSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"set_id": strconv.FormatInt(req.SetID, 10), "emoji": req.Emoji,
"file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
}
if req.DryRun {
return CommandResult{Message: "sticker upload validated", Details: details}, nil
}
doc, err := s.stickerSets.AdminUploadStickerMaterial(ctx, req.FileName, req.Data)
if err != nil {
return CommandResult{Details: details}, err
}
set, _, err := s.stickerSets.AdminAddStickerToSet(ctx, req.SetID, domain.StickerSetItemInput{
DocumentID: doc.ID,
DocumentAccessHash: doc.AccessHash,
Emoji: req.Emoji,
Keywords: req.Keywords,
})
if err != nil {
return CommandResult{Details: details}, err
}
details["document_id"] = strconv.FormatInt(doc.ID, 10)
details["count"] = set.Count
return CommandResult{Message: "sticker added", Details: details}, nil
})
}
func (s *Service) RemoveStickerFromSet(ctx context.Context, req RemoveStickerFromSetRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 || req.DocumentID <= 0 {
return CommandResult{}, fmt.Errorf("valid sticker set, document and service are required")
}
return s.runCommand(ctx, req.CommandMeta, ActionRemoveStickerFromSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "document_id": strconv.FormatInt(req.DocumentID, 10)}
if req.DryRun {
return CommandResult{Message: "sticker removal validated", Details: details}, nil
}
set, _, err := s.stickerSets.AdminRemoveStickerFromSet(ctx, req.SetID, req.DocumentID)
if err != nil {
return CommandResult{Details: details}, err
}
details["count"] = set.Count
return CommandResult{Message: "sticker removed", Details: details}, nil
})
}
const maxStickerDocumentBytes = 8 << 20
// StickerDocumentAnimation returns a sticker/custom-emoji document's preview

View file

@ -46,6 +46,9 @@ type Service interface {
SetStickerSetSortOrder(ctx context.Context, req admin.SetStickerSetSortOrderRequest) (admin.CommandResult, error)
RenameStickerSet(ctx context.Context, req admin.RenameStickerSetRequest) (admin.CommandResult, error)
DeleteStickerSet(ctx context.Context, req admin.DeleteStickerSetRequest) (admin.CommandResult, error)
CreateStickerSet(ctx context.Context, req admin.CreateStickerSetRequest) (admin.CommandResult, error)
AddStickerToSet(ctx context.Context, req admin.AddStickerToSetRequest) (admin.CommandResult, error)
RemoveStickerFromSet(ctx context.Context, req admin.RemoveStickerFromSetRequest) (admin.CommandResult, error)
StickerDocumentAnimation(ctx context.Context, documentID int64) ([]byte, string, bool, error)
StarGiftAnimation(ctx context.Context, giftID int64) ([]byte, bool, error)
StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
@ -119,6 +122,9 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/stickers/set-sort-order", s.authenticated(s.handleSetStickerSetSortOrder))
mux.HandleFunc("POST /v1/stickers/rename", s.authenticated(s.handleRenameStickerSet))
mux.HandleFunc("POST /v1/stickers/delete", s.authenticated(s.handleDeleteStickerSet))
mux.HandleFunc("POST /v1/stickers/create", s.authenticated(s.handleCreateStickerSet))
mux.HandleFunc("POST /v1/stickers/add", s.authenticated(s.handleAddStickerToSet))
mux.HandleFunc("POST /v1/stickers/remove", s.authenticated(s.handleRemoveStickerFromSet))
mux.HandleFunc("GET /v1/stickers/documents/{id}/animation", s.authenticated(s.handleStickerDocumentAnimation))
mux.HandleFunc("GET /v1/gifts/{id}/animation", s.authenticated(s.handleStarGiftAnimation))
mux.HandleFunc("GET /v1/gifts/{id}/collectibles", s.authenticated(s.handleStarGiftCollectibles))
@ -448,6 +454,83 @@ func (s *Server) handleDeleteStickerSet(w http.ResponseWriter, r *http.Request)
writeCommandResult(w, result, err)
}
func (s *Server) handleCreateStickerSet(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, domain.MaxStickerMaterialDocumentSize+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var req admin.CreateStickerSetRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "sticker file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, domain.MaxStickerMaterialDocumentSize+1))
if err != nil || len(data) == 0 || int64(len(data)) > domain.MaxStickerMaterialDocumentSize {
writeError(w, http.StatusBadRequest, "sticker file is empty or too large")
return
}
req.FileName = header.Filename
req.Data = data
result, err := s.svc.CreateStickerSet(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleAddStickerToSet(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, domain.MaxStickerMaterialDocumentSize+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var req admin.AddStickerToSetRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "sticker file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, domain.MaxStickerMaterialDocumentSize+1))
if err != nil || len(data) == 0 || int64(len(data)) > domain.MaxStickerMaterialDocumentSize {
writeError(w, http.StatusBadRequest, "sticker file is empty or too large")
return
}
req.FileName = header.Filename
req.Data = data
result, err := s.svc.AddStickerToSet(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleRemoveStickerFromSet(w http.ResponseWriter, r *http.Request) {
var req admin.RemoveStickerFromSetRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.RemoveStickerFromSet(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleStickerDocumentAnimation(w http.ResponseWriter, r *http.Request) {
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || documentID <= 0 {

View file

@ -291,6 +291,18 @@ func (fakeService) DeleteStickerSet(_ context.Context, req admin.DeleteStickerSe
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) CreateStickerSet(_ context.Context, req admin.CreateStickerSetRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) AddStickerToSet(_ context.Context, req admin.AddStickerToSetRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RemoveStickerFromSet(_ context.Context, req admin.RemoveStickerFromSetRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) StickerDocumentAnimation(context.Context, int64) ([]byte, string, bool, error) {
return nil, "", false, nil
}

View file

@ -0,0 +1,185 @@
package files
import (
"context"
"crypto/sha256"
"fmt"
"path/filepath"
"strings"
"time"
"telesrv/internal/domain"
)
// ValidateStickerMaterialUpload is a pure check (no store writes), used by a
// dry-run preview before AdminUploadStickerMaterial actually materializes the
// document into a blob + row.
func (s *Service) ValidateStickerMaterialUpload(fileName string, data []byte) (string, bool) {
if len(data) == 0 || int64(len(data)) > domain.MaxStickerMaterialDocumentSize {
return "", false
}
return detectStickerMaterialUploadMime(fileName, data)
}
// AdminUploadStickerMaterial turns a raw uploaded file (TGS, plain Lottie
// JSON, or static WebP) into a loose Document not yet attached to any set,
// the same shape stickers.createStickerSet/addStickerToSet expect as input.
// Regular users get this document via messages.uploadMedia before referencing
// it; the admin console has no such upload step, so this does the equivalent
// materialization directly from the uploaded bytes.
func (s *Service) AdminUploadStickerMaterial(ctx context.Context, fileName string, data []byte) (domain.Document, error) {
if len(data) == 0 || int64(len(data)) > domain.MaxStickerMaterialDocumentSize {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
mimeType, ok := detectStickerMaterialUploadMime(fileName, data)
if !ok {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
objectKey, err := s.blobs.Put(ctx, data)
if err != nil {
return domain.Document{}, err
}
sum := sha256.Sum256(data)
docID := randomID()
if err := s.media.PutFileBlob(ctx, 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,
}); err != nil {
return domain.Document{}, err
}
doc := domain.Document{
ID: docID,
AccessHash: randomID(),
FileReference: randomFileReference(),
Date: int(time.Now().Unix()),
MimeType: mimeType,
Size: int64(len(data)),
DCID: s.dc,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrFilename, FileName: strings.TrimSpace(fileName)},
},
}
if err := s.media.PutDocument(ctx, doc); err != nil {
return domain.Document{}, err
}
return doc, nil
}
// detectStickerMaterialUploadMime accepts exactly the raw upload shapes
// ensureStickerMaterialShape (sticker_creator.go) already knows how to turn
// into a real sticker document: gzip'd TGS, plain Lottie JSON (gzip'd on
// attach), or a static WebP image. Anything else (raster PNG, raw video,
// unrecognized) is rejected rather than guessed at.
func detectStickerMaterialUploadMime(fileName string, data []byte) (string, bool) {
ext := strings.ToLower(filepath.Ext(strings.TrimSpace(fileName)))
webp := isWebPData(data)
switch {
case ext == ".tgs" || (len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b):
return stickerMaterialMimeTGS, validTGSStickerData(data)
case ext == ".webp" || webp:
return stickerMaterialMimeWebP, webp
case ext == ".json":
_, _, ok := lottieStickerDimensions(normalizeLottieStickerJSON(data))
return stickerMaterialMimeJSON, ok
default:
return "", false
}
}
func isWebPData(data []byte) bool {
return len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP"
}
// AdminAddStickerToSet appends an already-materialized document (from
// AdminUploadStickerMaterial) to an existing pack with no ownership check —
// same convention as AdminSetStickerSetArchived and friends.
func (s *Service) AdminAddStickerToSet(ctx context.Context, setID int64, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error) {
set, docs, found, err := s.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: setID})
if err != nil {
return domain.StickerSet{}, nil, err
}
if !found || set.ID == 0 || set.Deleted {
return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid
}
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
}
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
}
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})
}
// AdminRemoveStickerFromSet detaches one document from a pack with no
// ownership check; see AdminAddStickerToSet for why that's needed here.
func (s *Service) AdminRemoveStickerFromSet(ctx context.Context, setID int64, documentID int64) (domain.StickerSet, []domain.Document, error) {
set, docs, found, err := s.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: setID})
if err != nil {
return domain.StickerSet{}, nil, err
}
if !found || set.ID == 0 || set.Deleted {
return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid
}
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
}
var removedDoc domain.Document
removedFound := false
for _, d := range docs {
if d.ID == documentID {
removedDoc = d
removedFound = true
break
}
}
if !removedFound {
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)
removedDoc = detachStickerSetFromDocument(removedDoc)
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{removedDoc})
}

View file

@ -62,6 +62,23 @@ func (s *Service) CreateStickerSet(ctx context.Context, req domain.CreateSticker
if req.CreatorUserID <= 0 {
return domain.StickerSet{}, nil, domain.ErrStickerSetCreatorInvalid
}
return s.createStickerSet(ctx, req)
}
// AdminCreateStickerSet creates a pack with no owning user (CreatorUserID
// forced to 0) so it's editable only through the Admin* bypass methods —
// the same convention seed-imported packs already use. Unlike CreateStickerSet,
// the caller must supply a non-empty ShortName: short-name suggestion needs a
// real user id to build a stable suffix, which an unowned pack doesn't have.
func (s *Service) AdminCreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error) {
req.CreatorUserID = 0
if strings.TrimSpace(req.ShortName) == "" {
return domain.StickerSet{}, nil, domain.ErrStickerSetShortNameInvalid
}
return s.createStickerSet(ctx, req)
}
func (s *Service) createStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error) {
title := strings.TrimSpace(req.Title)
if err := validateStickerSetTitle(title); err != nil {
return domain.StickerSet{}, nil, err