added stickers and emojies list

This commit is contained in:
onysd 2026-07-22 12:18:27 +03:00
parent 24276d1379
commit 43894f2fc9
26 changed files with 1118 additions and 14 deletions

View file

@ -1,11 +1,14 @@
package admin
import (
"bytes"
"compress/gzip"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
@ -34,6 +37,10 @@ const (
ActionPublishGiftCollectibles = "gifts.collectibles.publish"
ActionSetStarGiftEnabled = "gifts.set_enabled"
ActionSetStarGiftSortOrder = "gifts.set_sort_order"
ActionSetStickerSetArchived = "stickers.set_archived"
ActionSetStickerSetSortOrder = "stickers.set_sort_order"
ActionRenameStickerSet = "stickers.rename"
ActionDeleteStickerSet = "stickers.delete"
maxCommandIDLength = 128
maxActorLength = 128
@ -124,6 +131,16 @@ type AvatarResolver interface {
GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error)
}
// StickerSetsService is the admin-console management surface over sticker/custom-emoji
// sets: no ownership check (see internal/app/files.Service.AdminSetStickerSetArchived),
// so it works for seed-imported system/regular packs as well as user-created ones.
type StickerSetsService interface {
AdminSetStickerSetArchived(ctx context.Context, setID int64, archived bool) (bool, error)
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)
}
type Dependencies struct {
Commands CommandRepository
Restrictions RestrictionStore
@ -139,6 +156,7 @@ type Dependencies struct {
Gifts GiftsService
OfficialGifts OfficialGiftsSource
Photos AvatarResolver
StickerSets StickerSetsService
Now func() time.Time
}
@ -157,6 +175,7 @@ type Service struct {
gifts GiftsService
officialGifts OfficialGiftsSource
photos AvatarResolver
stickerSets StickerSetsService
now func() time.Time
}
@ -208,6 +227,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.Photos != nil {
s.photos = deps.Photos
}
if deps.StickerSets != nil {
s.stickerSets = deps.StickerSets
}
if deps.Now != nil {
s.now = deps.Now
}
@ -279,6 +301,29 @@ type SetStarGiftSortOrderRequest struct {
SortOrder int `json:"sort_order"`
}
type SetStickerSetArchivedRequest struct {
CommandMeta
SetID int64 `json:"set_id"`
Archived bool `json:"archived"`
}
type SetStickerSetSortOrderRequest struct {
CommandMeta
SetID int64 `json:"set_id"`
SortOrder int `json:"sort_order"`
}
type RenameStickerSetRequest struct {
CommandMeta
SetID int64 `json:"set_id"`
Title string `json:"title"`
}
type DeleteStickerSetRequest struct {
CommandMeta
SetID int64 `json:"set_id"`
}
type StarGiftCollectibleAnimationUpload struct {
Name string `json:"name"`
RarityPermille int `json:"rarity_permille"`
@ -1394,6 +1439,123 @@ func (s *Service) SetStarGiftSortOrder(ctx context.Context, req SetStarGiftSortO
})
}
func (s *Service) SetStickerSetArchived(ctx context.Context, req SetStickerSetArchivedRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 {
return CommandResult{}, fmt.Errorf("valid sticker set and service are required")
}
return s.runCommand(ctx, req.CommandMeta, ActionSetStickerSetArchived, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "archived": req.Archived}
if req.DryRun {
return CommandResult{Message: "sticker set state change validated", Details: details}, nil
}
changed, err := s.stickerSets.AdminSetStickerSetArchived(ctx, req.SetID, req.Archived)
details["changed"] = changed
return CommandResult{Message: "sticker set state updated", Details: details}, err
})
}
func (s *Service) SetStickerSetSortOrder(ctx context.Context, req SetStickerSetSortOrderRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
return CommandResult{}, fmt.Errorf("valid sticker set and service are required")
}
return s.runCommand(ctx, req.CommandMeta, ActionSetStickerSetSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "sort_order": req.SortOrder}
if req.DryRun {
return CommandResult{Message: "sticker set order change validated", Details: details}, nil
}
changed, err := s.stickerSets.AdminSetStickerSetSortOrder(ctx, req.SetID, req.SortOrder)
details["changed"] = changed
return CommandResult{Message: "sticker set order updated", Details: details}, err
})
}
func (s *Service) RenameStickerSet(ctx context.Context, req RenameStickerSetRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 || strings.TrimSpace(req.Title) == "" {
return CommandResult{}, fmt.Errorf("valid sticker set, title and service are required")
}
return s.runCommand(ctx, req.CommandMeta, ActionRenameStickerSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "title": req.Title}
if req.DryRun {
return CommandResult{Message: "sticker set rename validated", Details: details}, nil
}
set, err := s.stickerSets.AdminRenameStickerSet(ctx, req.SetID, req.Title)
if err != nil {
return CommandResult{Details: details}, err
}
details["title"] = set.Title
return CommandResult{Message: "sticker set renamed", Details: details}, nil
})
}
func (s *Service) DeleteStickerSet(ctx context.Context, req DeleteStickerSetRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 {
return CommandResult{}, fmt.Errorf("valid sticker set and service are required")
}
return s.runCommand(ctx, req.CommandMeta, ActionDeleteStickerSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10)}
if req.DryRun {
return CommandResult{Message: "sticker set deletion validated", Details: details}, nil
}
kind, err := s.stickerSets.AdminDeleteStickerSet(ctx, req.SetID)
if err != nil {
return CommandResult{Details: details}, err
}
details["kind"] = string(kind)
return CommandResult{Message: "sticker set deleted", Details: details}, nil
})
}
const maxStickerDocumentBytes = 8 << 20
// StickerDocumentAnimation returns a sticker/custom-emoji document's preview
// for the admin console's set-preview grid, plus the content type the caller
// should serve it as. Most packs are gzip-compressed TGS (decompressed here to
// Lottie JSON), but some (e.g. hand-uploaded packs) contain plain static
// raster stickers instead — those are returned as-is with their real image
// content type so the frontend can render an <img> instead of a Lottie player.
func (s *Service) StickerDocumentAnimation(ctx context.Context, documentID int64) ([]byte, string, bool, error) {
if s == nil || s.photos == nil || documentID <= 0 {
return nil, "", false, nil
}
chunk, found, err := s.photos.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("doc:%d", documentID),
Limit: maxStickerDocumentBytes + 1,
})
if err != nil {
return nil, "", false, err
}
if !found || chunk.Total <= 0 || chunk.Total > maxStickerDocumentBytes || int64(len(chunk.Bytes)) != chunk.Total {
return nil, "", false, nil
}
data := chunk.Bytes
if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b {
reader, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, "", false, nil
}
defer reader.Close()
decompressed, err := io.ReadAll(io.LimitReader(reader, maxStickerDocumentBytes))
if err != nil {
return nil, "", false, nil
}
return decompressed, "application/json; charset=utf-8", true, nil
}
detected := http.DetectContentType(data)
if !isSafeStickerPreviewImageType(detected) {
return nil, "", false, nil
}
return data, detected, true, nil
}
func isSafeStickerPreviewImageType(value string) bool {
switch value {
case "image/webp", "image/png", "image/jpeg", "image/gif":
return true
default:
return false
}
}
func (s *Service) StarGiftAnimation(ctx context.Context, giftID int64) ([]byte, bool, error) {
if s == nil || s.gifts == nil || giftID <= 0 {
return nil, false, nil

View file

@ -42,6 +42,11 @@ type Service interface {
PublishStarGiftCollectibles(ctx context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error)
SetStarGiftEnabled(ctx context.Context, req admin.SetStarGiftEnabledRequest) (admin.CommandResult, error)
SetStarGiftSortOrder(ctx context.Context, req admin.SetStarGiftSortOrderRequest) (admin.CommandResult, error)
SetStickerSetArchived(ctx context.Context, req admin.SetStickerSetArchivedRequest) (admin.CommandResult, error)
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)
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)
StarGiftCollectibleAnimation(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
@ -110,6 +115,11 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/gifts/{id}/collectibles/publish", s.authenticated(s.handlePublishStarGiftCollectibles))
mux.HandleFunc("POST /v1/gifts/set-enabled", s.authenticated(s.handleSetStarGiftEnabled))
mux.HandleFunc("POST /v1/gifts/set-sort-order", s.authenticated(s.handleSetStarGiftSortOrder))
mux.HandleFunc("POST /v1/stickers/set-archived", s.authenticated(s.handleSetStickerSetArchived))
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("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))
mux.HandleFunc("GET /v1/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.authenticated(s.handleStarGiftCollectibleAnimation))
@ -402,6 +412,63 @@ func (s *Server) handleSetStarGiftSortOrder(w http.ResponseWriter, r *http.Reque
writeCommandResult(w, result, err)
}
func (s *Server) handleSetStickerSetArchived(w http.ResponseWriter, r *http.Request) {
var req admin.SetStickerSetArchivedRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetStickerSetArchived(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetStickerSetSortOrder(w http.ResponseWriter, r *http.Request) {
var req admin.SetStickerSetSortOrderRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetStickerSetSortOrder(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleRenameStickerSet(w http.ResponseWriter, r *http.Request) {
var req admin.RenameStickerSetRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.RenameStickerSet(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleDeleteStickerSet(w http.ResponseWriter, r *http.Request) {
var req admin.DeleteStickerSetRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.DeleteStickerSet(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 {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
raw, contentType, found, err := s.svc.StickerDocumentAnimation(r.Context(), documentID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !found {
writeError(w, http.StatusNotFound, "document animation not found")
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "private, max-age=300")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
func (s *Server) handleStarGiftAnimation(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || giftID <= 0 {

View file

@ -275,6 +275,26 @@ func (fakeService) AccountAvatar(context.Context, int64) ([]byte, string, bool,
return nil, "", false, nil
}
func (fakeService) SetStickerSetArchived(_ context.Context, req admin.SetStickerSetArchivedRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetStickerSetSortOrder(_ context.Context, req admin.SetStickerSetSortOrderRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RenameStickerSet(_ context.Context, req admin.RenameStickerSetRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) DeleteStickerSet(_ context.Context, req admin.DeleteStickerSetRequest) (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
}
func (fakeService) OfficialStarGifts(context.Context) ([]officialgifts.GiftSummary, error) {
return nil, nil
}

View file

@ -282,6 +282,17 @@ func (f *fakeMediaStore) DeleteStickerSet(_ context.Context, setID int64, creato
f.sets[setID] = set
return nil
}
func (f *fakeMediaStore) AdminDeleteStickerSet(_ context.Context, setID int64) error {
f.mu.Lock()
defer f.mu.Unlock()
set, ok := f.sets[setID]
if !ok || set.Deleted {
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()

View file

@ -134,6 +134,93 @@ func (s *Service) DeleteStickerSet(ctx context.Context, actorUserID int64, ref d
return set.Kind, nil
}
// AdminSetStickerSetArchived toggles a set's archived flag with no ownership
// check, so admins can hide/show any pack — including seed-imported system
// and regular packs, which have no creator_user_id to match against.
func (s *Service) AdminSetStickerSetArchived(ctx context.Context, setID int64, archived bool) (bool, error) {
set, found, err := s.media.GetStickerSetByID(ctx, setID)
if err != nil {
return false, err
}
if !found {
return false, domain.ErrStickerSetInvalid
}
if set.Archived == archived {
return false, nil
}
set.Archived = archived
if err := s.media.UpdateStickerSet(ctx, set, nil); err != nil {
return false, err
}
s.deleteCachedStickerSet(set)
return true, nil
}
// AdminSetStickerSetSortOrder sets a set's display sort order with no
// ownership check; see AdminSetStickerSetArchived for why that's needed here.
func (s *Service) AdminSetStickerSetSortOrder(ctx context.Context, setID int64, order int) (bool, error) {
set, found, err := s.media.GetStickerSetByID(ctx, setID)
if err != nil {
return false, err
}
if !found {
return false, domain.ErrStickerSetInvalid
}
if set.SortOrder == order {
return false, nil
}
set.SortOrder = order
if err := s.media.UpdateStickerSet(ctx, set, nil); err != nil {
return false, err
}
s.deleteCachedStickerSet(set)
return true, nil
}
// AdminRenameStickerSet renames a set with no ownership check; see
// AdminSetStickerSetArchived for why that's needed here.
func (s *Service) AdminRenameStickerSet(ctx context.Context, setID int64, title string) (domain.StickerSet, error) {
title = strings.TrimSpace(title)
if err := validateStickerSetTitle(title); err != nil {
return domain.StickerSet{}, err
}
set, found, err := s.media.GetStickerSetByID(ctx, setID)
if err != nil {
return domain.StickerSet{}, err
}
if !found {
return domain.StickerSet{}, domain.ErrStickerSetInvalid
}
set.Title = title
set.Hash = stickerSetHash(set)
if err := s.media.UpdateStickerSet(ctx, set, nil); err != nil {
return domain.StickerSet{}, err
}
s.deleteCachedStickerSet(set)
return set, nil
}
// AdminDeleteStickerSet deletes (soft-delete) a set with no ownership check;
// see AdminSetStickerSetArchived for why that's needed here. Safe to bypass
// ownership for: sticker_sets has no incoming foreign keys, so there's no
// cascade to worry about (unlike star gifts, which have ~15 dependent
// tables). Seed-imported sets will reappear on next restart if their source
// files are still under data/sticker-seed — this only removes the DB row.
func (s *Service) AdminDeleteStickerSet(ctx context.Context, setID int64) (domain.StickerSetKind, error) {
set, found, err := s.media.GetStickerSetByID(ctx, setID)
if err != nil {
return "", err
}
if !found {
return "", domain.ErrStickerSetInvalid
}
if err := s.media.AdminDeleteStickerSet(ctx, setID); 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

View file

@ -52,6 +52,10 @@ type MediaStore interface {
CreateStickerSet(ctx context.Context, set domain.StickerSet, docs []domain.Document) error
UpdateStickerSet(ctx context.Context, set domain.StickerSet, docs []domain.Document) error
DeleteStickerSet(ctx context.Context, setID int64, creatorUserID int64) error
// AdminDeleteStickerSet is DeleteStickerSet without the creator_user_id match —
// for admin-console management of any set, including seed-imported system/regular
// packs that have no creator (creator_user_id=0).
AdminDeleteStickerSet(ctx context.Context, setID int64) error
GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error)
GetStickerSetByShortName(ctx context.Context, shortName string) (domain.StickerSet, bool, error)
GetStickerSetBySystemKey(ctx context.Context, systemKey string) (domain.StickerSet, bool, error)

View file

@ -756,6 +756,24 @@ WHERE id = $1
})
}
func (s *MediaStore) AdminDeleteStickerSet(ctx context.Context, setID int64) error {
return withTx(ctx, s.db, "admin delete sticker set", func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE sticker_sets
SET deleted = true, updated_at = now()
WHERE id = $1
AND deleted = false`, setID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return domain.ErrStickerSetInvalid
}
_, err = tx.Exec(ctx, `DELETE FROM user_sticker_sets WHERE sticker_set_id = $1`, setID)
return err
})
}
func insertStickerSet(ctx context.Context, db sqlcgen.DBTX, set domain.StickerSet) error {
thumbs, err := jsonArrayOrEmpty(set.Thumbs)
if err != nil {