fixed stickers and emojies creation packs
This commit is contained in:
parent
43894f2fc9
commit
139967399d
16 changed files with 771 additions and 22 deletions
|
|
@ -85,6 +85,9 @@ func (s *server) routes() http.Handler {
|
||||||
mux.Handle("POST /api/actions/set-sticker-set-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetSortOrderAPI)))
|
mux.Handle("POST /api/actions/set-sticker-set-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetSortOrderAPI)))
|
||||||
mux.Handle("POST /api/actions/rename-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleRenameStickerSetAPI)))
|
mux.Handle("POST /api/actions/rename-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleRenameStickerSetAPI)))
|
||||||
mux.Handle("POST /api/actions/delete-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteStickerSetAPI)))
|
mux.Handle("POST /api/actions/delete-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteStickerSetAPI)))
|
||||||
|
mux.Handle("POST /api/actions/create-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleCreateStickerSetAPI)))
|
||||||
|
mux.Handle("POST /api/actions/add-sticker-to-set", s.requireAuthAPI(http.HandlerFunc(s.handleAddStickerToSetAPI)))
|
||||||
|
mux.Handle("POST /api/actions/remove-sticker-from-set", s.requireAuthAPI(http.HandlerFunc(s.handleRemoveStickerFromSetAPI)))
|
||||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
writeAPIError(w, http.StatusNotFound, "api route not found")
|
writeAPIError(w, http.StatusNotFound, "api route not found")
|
||||||
})
|
})
|
||||||
|
|
@ -1032,6 +1035,120 @@ func (s *server) handleDeleteStickerSetAPI(w http.ResponseWriter, r *http.Reques
|
||||||
writeCommandResultAPI(w, result, err)
|
writeCommandResultAPI(w, result, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type createStickerSetAPIRequest struct {
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ShortName string `json:"short_name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Emoji string `json:"emoji"`
|
||||||
|
Keywords string `json:"keywords,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleCreateStickerSetAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 21<<20)
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.MultipartForm != nil {
|
||||||
|
defer r.MultipartForm.RemoveAll()
|
||||||
|
}
|
||||||
|
var body createStickerSetAPIRequest
|
||||||
|
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(&body); err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "sticker file is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, (20<<20)+1))
|
||||||
|
if err != nil || len(data) == 0 || len(data) > 20<<20 {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "sticker file is empty or too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := admin.CreateStickerSetRequest{
|
||||||
|
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "create-sticker-set"),
|
||||||
|
Title: body.Title, ShortName: body.ShortName, Kind: body.Kind,
|
||||||
|
Emoji: body.Emoji, Keywords: body.Keywords, FileName: header.Filename,
|
||||||
|
}
|
||||||
|
result, err := s.callAdminMultipart(r.Context(), "/v1/stickers/create", req, header.Filename, data)
|
||||||
|
writeCommandResultAPI(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type addStickerToSetAPIRequest struct {
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
SetID int64 `json:"set_id,string"`
|
||||||
|
Emoji string `json:"emoji"`
|
||||||
|
Keywords string `json:"keywords,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleAddStickerToSetAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 21<<20)
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.MultipartForm != nil {
|
||||||
|
defer r.MultipartForm.RemoveAll()
|
||||||
|
}
|
||||||
|
var body addStickerToSetAPIRequest
|
||||||
|
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(&body); err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "sticker file is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, (20<<20)+1))
|
||||||
|
if err != nil || len(data) == 0 || len(data) > 20<<20 {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "sticker file is empty or too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := admin.AddStickerToSetRequest{
|
||||||
|
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "add-sticker-to-set"),
|
||||||
|
SetID: body.SetID, Emoji: body.Emoji, Keywords: body.Keywords, FileName: header.Filename,
|
||||||
|
}
|
||||||
|
result, err := s.callAdminMultipart(r.Context(), "/v1/stickers/add", req, header.Filename, data)
|
||||||
|
writeCommandResultAPI(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type removeStickerFromSetAPIRequest struct {
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
SetID int64 `json:"set_id,string"`
|
||||||
|
DocumentID int64 `json:"document_id,string"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleRemoveStickerFromSetAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body removeStickerFromSetAPIRequest
|
||||||
|
if !decodeAction(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := admin.RemoveStickerFromSetRequest{
|
||||||
|
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "remove-sticker-from-set"),
|
||||||
|
SetID: body.SetID, DocumentID: body.DocumentID,
|
||||||
|
}
|
||||||
|
result, err := s.callAdminAPI(r.Context(), "/v1/stickers/remove", req)
|
||||||
|
writeCommandResultAPI(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) handleStickerSetsAPI(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleStickerSetsAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.read == nil {
|
if s.read == nil {
|
||||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-CT_6lfHd.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-CT_6lfHd.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -5,8 +5,8 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/png" href="/logo.png" />
|
<link rel="icon" type="image/png" href="/logo.png" />
|
||||||
<title>OwpenGram Admin</title>
|
<title>OwpenGram Admin</title>
|
||||||
<script type="module" crossorigin src="/assets/index-B4-Dmklu.js"></script>
|
<script type="module" crossorigin src="/assets/index-CT_6lfHd.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CnQEc5UV.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D7AAg19g.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ export const api = {
|
||||||
stickerSets: (kind: string) => request<StickerSetListResponse>(`/api/stickers?kind=${encodeURIComponent(kind)}`),
|
stickerSets: (kind: string) => request<StickerSetListResponse>(`/api/stickers?kind=${encodeURIComponent(kind)}`),
|
||||||
stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`),
|
stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`),
|
||||||
stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`,
|
stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`,
|
||||||
|
createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }),
|
||||||
|
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
|
||||||
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
|
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
|
||||||
officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),
|
officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),
|
||||||
giftAnimation: (id: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(id)}/animation`),
|
giftAnimation: (id: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(id)}/animation`),
|
||||||
|
|
|
||||||
|
|
@ -345,6 +345,19 @@ const translations: Record<string, string> = {
|
||||||
"stickers.view": "View",
|
"stickers.view": "View",
|
||||||
"stickers.previewEyebrow": "Set contents",
|
"stickers.previewEyebrow": "Set contents",
|
||||||
"stickers.previewEmpty": "This set has no documents.",
|
"stickers.previewEmpty": "This set has no documents.",
|
||||||
|
"stickers.create": "Create {noun} pack",
|
||||||
|
"stickers.createTitle": "Create a new {noun} pack",
|
||||||
|
"stickers.createEyebrow": "New set",
|
||||||
|
"stickers.createFieldsRequired": "Title, short name, emoji and a first {noun} file are required.",
|
||||||
|
"stickers.shortNamePlaceholder": "lowercase_short_name",
|
||||||
|
"stickers.firstSticker": "First {noun}",
|
||||||
|
"stickers.filePrompt": "Choose a TGS, Lottie JSON, or WebP file",
|
||||||
|
"stickers.emoji": "Emoji",
|
||||||
|
"stickers.emojiPlaceholder": "e.g. 😀",
|
||||||
|
"stickers.emojiRequired": "An emoji is required.",
|
||||||
|
"stickers.addSticker": "Add {noun}",
|
||||||
|
"stickers.fileRequired": "Choose a {noun} file first",
|
||||||
|
"stickers.removeSticker": "Remove {noun}",
|
||||||
"collectibles.manage": "Attribute pool",
|
"collectibles.manage": "Attribute pool",
|
||||||
"collectibles.title": "Collectible pool · Gift #{id}",
|
"collectibles.title": "Collectible pool · Gift #{id}",
|
||||||
"collectibles.eyebrow": "Unique gift attributes",
|
"collectibles.eyebrow": "Unique gift attributes",
|
||||||
|
|
|
||||||
86
cmd/telesrv-admin/web/src/pages/CreateStickerSetModal.tsx
Normal file
86
cmd/telesrv-admin/web/src/pages/CreateStickerSetModal.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { Loader2, Upload, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { api, errorMessage } from "../api";
|
||||||
|
import { Alert } from "../components/ui";
|
||||||
|
import { useI18n } from "../i18n";
|
||||||
|
|
||||||
|
// Real Telegram sticker/emoji packs are created with at least one item, so
|
||||||
|
// this form collects the title/short name plus a single starting file —
|
||||||
|
// exactly like CreateStickerSet's domain-level requirement. More stickers
|
||||||
|
// get added afterward from the pack's own preview modal.
|
||||||
|
export function CreateStickerSetModal({ kind, onClose, onCreated }: { kind: "stickers" | "emoji"; onClose: () => void; onCreated: () => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const noun = kind === "emoji" ? "emoji" : "sticker";
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [shortName, setShortName] = useState("");
|
||||||
|
const [emoji, setEmoji] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!title.trim() || !shortName.trim() || !emoji.trim() || !file) {
|
||||||
|
setError(t("stickers.createFieldsRequired", { noun }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!reason.trim()) {
|
||||||
|
setError(t("action.reasonRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("metadata", JSON.stringify({
|
||||||
|
command_id: "", reason: reason.trim(), confirm: true,
|
||||||
|
title: title.trim(), short_name: shortName.trim().toLowerCase(), kind, emoji: emoji.trim()
|
||||||
|
}));
|
||||||
|
form.set("file", file, file.name);
|
||||||
|
await api.createStickerSet(form);
|
||||||
|
onCreated();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="modal-backdrop" role="presentation">
|
||||||
|
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={t("stickers.createTitle", { noun })}>
|
||||||
|
<div className="modal-head">
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow">{t("stickers.createEyebrow")}</div>
|
||||||
|
<h2>{t("stickers.createTitle", { noun })}</h2>
|
||||||
|
</div>
|
||||||
|
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="command-body">
|
||||||
|
<div className="gift-fields-grid">
|
||||||
|
<label><span>{t("stickers.title")}</span><input value={title} maxLength={64} onChange={(event) => setTitle(event.target.value)} /></label>
|
||||||
|
<label><span>{t("stickers.shortName")}</span><input value={shortName} maxLength={32} onChange={(event) => setShortName(event.target.value)} placeholder={t("stickers.shortNamePlaceholder")} /></label>
|
||||||
|
<label><span>{t("stickers.emoji")}</span><input value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={t("stickers.emojiPlaceholder")} /></label>
|
||||||
|
</div>
|
||||||
|
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
|
||||||
|
<input type="file" accept=".tgs,.json,.webp,application/json,application/x-tgsticker,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||||
|
<span className="gift-file-copy"><span className="gift-field-label">{t("stickers.firstSticker", { noun })}</span><strong>{file ? file.name : t("stickers.filePrompt")}</strong></span>
|
||||||
|
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span>
|
||||||
|
</label>
|
||||||
|
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={reason} placeholder={t("gifts.reasonPlaceholder")} onChange={(event) => setReason(event.target.value)} /></label>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn" type="button" onClick={onClose} disabled={busy}>{t("common.close")}</button>
|
||||||
|
<button className="btn primary" type="button" onClick={submit} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
|
||||||
|
{t("stickers.create", { noun })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { ChevronLeft, ChevronRight, Loader2, X } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Loader2, Plus, Trash2, X } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
|
import { ActionButton } from "../components/ActionButton";
|
||||||
import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
|
import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
|
||||||
import { Alert } from "../components/ui";
|
import { Alert } from "../components/ui";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
|
|
@ -15,15 +16,14 @@ const PAGE_SIZE = 24;
|
||||||
|
|
||||||
export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; onClose: () => void }) {
|
export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; onClose: () => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const noun = set.Kind === "emoji" ? "emoji" : "sticker";
|
||||||
const [documentIDs, setDocumentIDs] = useState<string[] | null>(null);
|
const [documentIDs, setDocumentIDs] = useState<string[] | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
useEffect(() => {
|
const load = useCallback(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setDocumentIDs(null);
|
|
||||||
setError("");
|
setError("");
|
||||||
setPage(1);
|
|
||||||
api.stickerSetDocuments(set.ID).then((result) => {
|
api.stickerSetDocuments(set.ID).then((result) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setDocumentIDs(result.document_ids ?? []);
|
setDocumentIDs(result.document_ids ?? []);
|
||||||
|
|
@ -33,6 +33,12 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [set.ID]);
|
}, [set.ID]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDocumentIDs(null);
|
||||||
|
setPage(1);
|
||||||
|
return load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
const total = documentIDs?.length ?? 0;
|
const total = documentIDs?.length ?? 0;
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
const currentPage = Math.min(page, totalPages);
|
const currentPage = Math.min(page, totalPages);
|
||||||
|
|
@ -52,6 +58,7 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
|
||||||
<button className="icon-btn" type="button" onClick={onClose} aria-label={t("action.close")}><X size={15} /></button>
|
<button className="icon-btn" type="button" onClick={onClose} aria-label={t("action.close")}><X size={15} /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="command-body">
|
<div className="command-body">
|
||||||
|
<AddStickerForm setID={set.ID} noun={noun} onAdded={load} />
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
{!error && documentIDs === null && (
|
{!error && documentIDs === null && (
|
||||||
<div className="loading-line"><Loader2 className="spin" size={18} /> {t("common.loading")}</div>
|
<div className="loading-line"><Loader2 className="spin" size={18} /> {t("common.loading")}</div>
|
||||||
|
|
@ -61,7 +68,20 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
|
||||||
)}
|
)}
|
||||||
{pageItems.length > 0 && (
|
{pageItems.length > 0 && (
|
||||||
<div className="sticker-doc-grid" key={currentPage}>
|
<div className="sticker-doc-grid" key={currentPage}>
|
||||||
{pageItems.map((documentID) => <StickerDocumentPreview key={documentID} documentID={documentID} />)}
|
{pageItems.map((documentID) => (
|
||||||
|
<div className="sticker-doc-grid-cell" key={documentID}>
|
||||||
|
<StickerDocumentPreview documentID={documentID} />
|
||||||
|
<ActionButton
|
||||||
|
compact
|
||||||
|
tone="danger"
|
||||||
|
label={t("stickers.removeSticker", { noun })}
|
||||||
|
icon={<Trash2 size={12} />}
|
||||||
|
path="/api/actions/remove-sticker-from-set"
|
||||||
|
payload={() => ({ set_id: set.ID, document_id: documentID })}
|
||||||
|
onDone={load}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{total > PAGE_SIZE && (
|
{total > PAGE_SIZE && (
|
||||||
|
|
@ -84,3 +104,62 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inline upload form embedded in the preview modal — file + emoji + a
|
||||||
|
// mandatory audit reason, single confirmed call (no separate dry-run preview
|
||||||
|
// step): unlike destructive actions, materializing one sticker document is
|
||||||
|
// low-risk and reversible via the per-cell Remove button.
|
||||||
|
function AddStickerForm({ setID, noun, onAdded }: { setID: string; noun: string; onAdded: () => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [emoji, setEmoji] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!file) {
|
||||||
|
setError(t("stickers.fileRequired", { noun }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!emoji.trim()) {
|
||||||
|
setError(t("stickers.emojiRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!reason.trim()) {
|
||||||
|
setError(t("action.reasonRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, set_id: setID, emoji: emoji.trim() }));
|
||||||
|
form.set("file", file, file.name);
|
||||||
|
await api.addStickerToSet(form);
|
||||||
|
setFile(null);
|
||||||
|
setEmoji("");
|
||||||
|
setReason("");
|
||||||
|
onAdded();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticker-add-form">
|
||||||
|
<label className={`gift-file-picker compact ${file ? "has-file" : ""}`}>
|
||||||
|
<input type="file" accept=".tgs,.json,.webp,application/json,application/x-tgsticker,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||||
|
<span className="gift-file-copy"><strong>{file ? file.name : t("stickers.filePrompt")}</strong></span>
|
||||||
|
</label>
|
||||||
|
<input className="small-input" value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={t("stickers.emojiPlaceholder")} />
|
||||||
|
<input className="small-input" value={reason} onChange={(event) => setReason(event.target.value)} placeholder={t("action.reasonPlaceholder")} />
|
||||||
|
<button className="btn primary compact-btn" type="button" onClick={submit} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="spin" size={14} /> : <Plus size={14} />} {t("stickers.addSticker", { noun })}
|
||||||
|
</button>
|
||||||
|
{error && <span className="sticker-add-form-error">{error}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Eye, ChevronLeft, ChevronRight, ImageOff, RefreshCw, Search } from "lucide-react";
|
import { Eye, ChevronLeft, ChevronRight, ImageOff, Plus, RefreshCw, Search } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
import { ActionButton } from "../components/ActionButton";
|
import { ActionButton } from "../components/ActionButton";
|
||||||
|
|
@ -6,6 +6,7 @@ import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
|
||||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import type { StickerSetRow } from "../types";
|
import type { StickerSetRow } from "../types";
|
||||||
|
import { CreateStickerSetModal } from "./CreateStickerSetModal";
|
||||||
import { StickerSetPreviewModal } from "./StickerSetPreviewModal";
|
import { StickerSetPreviewModal } from "./StickerSetPreviewModal";
|
||||||
|
|
||||||
type StickerPageSize = 10 | 20 | 50 | 100 | "all";
|
type StickerPageSize = 10 | 20 | 50 | 100 | "all";
|
||||||
|
|
@ -25,9 +26,11 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
|
||||||
const [orderDrafts, setOrderDrafts] = useState<Record<string, string>>({});
|
const [orderDrafts, setOrderDrafts] = useState<Record<string, string>>({});
|
||||||
const [titleDrafts, setTitleDrafts] = useState<Record<string, string>>({});
|
const [titleDrafts, setTitleDrafts] = useState<Record<string, string>>({});
|
||||||
const [previewSet, setPreviewSet] = useState<StickerSetRow | null>(null);
|
const [previewSet, setPreviewSet] = useState<StickerSetRow | null>(null);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
const pageTitleKey = kind === "emoji" ? "stickers.emojiPageTitle" : "stickers.pageTitle";
|
const pageTitleKey = kind === "emoji" ? "stickers.emojiPageTitle" : "stickers.pageTitle";
|
||||||
const eyebrowKey = kind === "emoji" ? "stickers.emojiEyebrow" : "stickers.eyebrow";
|
const eyebrowKey = kind === "emoji" ? "stickers.emojiEyebrow" : "stickers.eyebrow";
|
||||||
|
const noun = kind === "emoji" ? "emoji" : "sticker";
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|
@ -76,9 +79,14 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
|
||||||
title={t(pageTitleKey)}
|
title={t(pageTitleKey)}
|
||||||
eyebrow={t(eyebrowKey)}
|
eyebrow={t(eyebrowKey)}
|
||||||
actions={
|
actions={
|
||||||
|
<>
|
||||||
<button className="btn" type="button" onClick={() => load()} disabled={busy}>
|
<button className="btn" type="button" onClick={() => load()} disabled={busy}>
|
||||||
<RefreshCw size={15} /> {t("common.refresh")}
|
<RefreshCw size={15} /> {t("common.refresh")}
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn primary" type="button" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus size={15} /> {t("stickers.create", { noun })}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
|
|
@ -218,6 +226,7 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{previewSet && <StickerSetPreviewModal set={previewSet} onClose={() => setPreviewSet(null)} />}
|
{previewSet && <StickerSetPreviewModal set={previewSet} onClose={() => setPreviewSet(null)} />}
|
||||||
|
{createOpen && <CreateStickerSetModal kind={kind} onClose={() => setCreateOpen(false)} onCreated={() => void load()} />}
|
||||||
</PageFrame>
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,7 @@
|
||||||
|
|
||||||
.gift-file-picker:hover,
|
.gift-file-picker:hover,
|
||||||
.gift-file-picker.has-file { background: #f5f9ff; border-color: var(--brand); box-shadow: 0 0 0 2px rgba(37, 99, 235, .05); }
|
.gift-file-picker.has-file { background: #f5f9ff; border-color: var(--brand); box-shadow: 0 0 0 2px rgba(37, 99, 235, .05); }
|
||||||
|
.gift-file-picker.compact { grid-template-columns: minmax(0, 1fr); min-height: 44px; padding: 8px 12px; }
|
||||||
.gift-file-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
.gift-file-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||||
.gift-file-icon { width: 40px; height: 40px; border-radius: 9px; }
|
.gift-file-icon { width: 40px; height: 40px; border-radius: 9px; }
|
||||||
.gift-file-copy { display: grid; min-width: 0; gap: 2px; }
|
.gift-file-copy { display: grid; min-width: 0; gap: 2px; }
|
||||||
|
|
@ -425,6 +426,12 @@
|
||||||
.sticker-doc-image { width: 100%; height: 100%; object-fit: contain; }
|
.sticker-doc-image { width: 100%; height: 100%; object-fit: contain; }
|
||||||
.sticker-doc-cell.list-thumb { width: 40px; flex: 0 0 40px; }
|
.sticker-doc-cell.list-thumb { width: 40px; flex: 0 0 40px; }
|
||||||
.sticker-list-thumb-empty { display: grid; place-items: center; width: 40px; height: 40px; background: var(--panel-strong); border: 1px solid var(--line); border-radius: 9px; color: var(--muted); }
|
.sticker-list-thumb-empty { display: grid; place-items: center; width: 40px; height: 40px; background: var(--panel-strong); border: 1px solid var(--line); border-radius: 9px; color: var(--muted); }
|
||||||
|
.sticker-doc-grid-cell { display: grid; gap: 4px; }
|
||||||
|
.sticker-doc-grid-cell .btn { width: 100%; justify-content: center; }
|
||||||
|
.sticker-add-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 14px; padding: 10px; background: var(--panel-strong); border: 1px solid var(--line); border-radius: 10px; }
|
||||||
|
.sticker-add-form .gift-file-picker.compact { flex: 1 1 220px; min-width: 180px; }
|
||||||
|
.sticker-add-form .small-input { flex: 0 1 140px; }
|
||||||
|
.sticker-add-form-error { flex-basis: 100%; color: var(--danger); font-size: 12px; }
|
||||||
.sticker-doc-error { position: absolute; inset: 0; display: grid; place-items: center; padding: 4px; color: var(--danger); font-size: 9px; text-align: center; }
|
.sticker-doc-error { position: absolute; inset: 0; display: grid; place-items: center; padding: 4px; color: var(--danger); font-size: 9px; text-align: center; }
|
||||||
|
|
||||||
.gift-animation-shell { position: relative; display: grid; min-height: 210px; place-items: center; background: radial-gradient(circle, #f9f3ff, #eaf2fe); }
|
.gift-animation-shell { position: relative; display: grid; min-height: 210px; place-items: center; background: radial-gradient(circle, #f9f3ff, #eaf2fe); }
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ const (
|
||||||
ActionSetStickerSetSortOrder = "stickers.set_sort_order"
|
ActionSetStickerSetSortOrder = "stickers.set_sort_order"
|
||||||
ActionRenameStickerSet = "stickers.rename"
|
ActionRenameStickerSet = "stickers.rename"
|
||||||
ActionDeleteStickerSet = "stickers.delete"
|
ActionDeleteStickerSet = "stickers.delete"
|
||||||
|
ActionCreateStickerSet = "stickers.create"
|
||||||
|
ActionAddStickerToSet = "stickers.add_sticker"
|
||||||
|
ActionRemoveStickerFromSet = "stickers.remove_sticker"
|
||||||
|
|
||||||
maxCommandIDLength = 128
|
maxCommandIDLength = 128
|
||||||
maxActorLength = 128
|
maxActorLength = 128
|
||||||
|
|
@ -139,6 +142,13 @@ type StickerSetsService interface {
|
||||||
AdminSetStickerSetSortOrder(ctx context.Context, setID int64, order int) (bool, error)
|
AdminSetStickerSetSortOrder(ctx context.Context, setID int64, order int) (bool, error)
|
||||||
AdminRenameStickerSet(ctx context.Context, setID int64, title string) (domain.StickerSet, error)
|
AdminRenameStickerSet(ctx context.Context, setID int64, title string) (domain.StickerSet, error)
|
||||||
AdminDeleteStickerSet(ctx context.Context, setID int64) (domain.StickerSetKind, 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 {
|
type Dependencies struct {
|
||||||
|
|
@ -324,6 +334,32 @@ type DeleteStickerSetRequest struct {
|
||||||
SetID int64 `json:"set_id"`
|
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 {
|
type StarGiftCollectibleAnimationUpload struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
RarityPermille int `json:"rarity_permille"`
|
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
|
const maxStickerDocumentBytes = 8 << 20
|
||||||
|
|
||||||
// StickerDocumentAnimation returns a sticker/custom-emoji document's preview
|
// StickerDocumentAnimation returns a sticker/custom-emoji document's preview
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ type Service interface {
|
||||||
SetStickerSetSortOrder(ctx context.Context, req admin.SetStickerSetSortOrderRequest) (admin.CommandResult, error)
|
SetStickerSetSortOrder(ctx context.Context, req admin.SetStickerSetSortOrderRequest) (admin.CommandResult, error)
|
||||||
RenameStickerSet(ctx context.Context, req admin.RenameStickerSetRequest) (admin.CommandResult, error)
|
RenameStickerSet(ctx context.Context, req admin.RenameStickerSetRequest) (admin.CommandResult, error)
|
||||||
DeleteStickerSet(ctx context.Context, req admin.DeleteStickerSetRequest) (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)
|
StickerDocumentAnimation(ctx context.Context, documentID int64) ([]byte, string, bool, error)
|
||||||
StarGiftAnimation(ctx context.Context, giftID int64) ([]byte, bool, error)
|
StarGiftAnimation(ctx context.Context, giftID int64) ([]byte, bool, error)
|
||||||
StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, 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/set-sort-order", s.authenticated(s.handleSetStickerSetSortOrder))
|
||||||
mux.HandleFunc("POST /v1/stickers/rename", s.authenticated(s.handleRenameStickerSet))
|
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/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/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}/animation", s.authenticated(s.handleStarGiftAnimation))
|
||||||
mux.HandleFunc("GET /v1/gifts/{id}/collectibles", s.authenticated(s.handleStarGiftCollectibles))
|
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)
|
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) {
|
func (s *Server) handleStickerDocumentAnimation(w http.ResponseWriter, r *http.Request) {
|
||||||
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil || documentID <= 0 {
|
if err != nil || documentID <= 0 {
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,18 @@ func (fakeService) DeleteStickerSet(_ context.Context, req admin.DeleteStickerSe
|
||||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
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) {
|
func (fakeService) StickerDocumentAnimation(context.Context, int64) ([]byte, string, bool, error) {
|
||||||
return nil, "", false, nil
|
return nil, "", false, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
185
internal/app/files/sticker_admin.go
Normal file
185
internal/app/files/sticker_admin.go
Normal 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})
|
||||||
|
}
|
||||||
|
|
@ -62,6 +62,23 @@ func (s *Service) CreateStickerSet(ctx context.Context, req domain.CreateSticker
|
||||||
if req.CreatorUserID <= 0 {
|
if req.CreatorUserID <= 0 {
|
||||||
return domain.StickerSet{}, nil, domain.ErrStickerSetCreatorInvalid
|
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)
|
title := strings.TrimSpace(req.Title)
|
||||||
if err := validateStickerSetTitle(title); err != nil {
|
if err := validateStickerSetTitle(title); err != nil {
|
||||||
return domain.StickerSet{}, nil, err
|
return domain.StickerSet{}, nil, err
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue