added stickers and emojies list
This commit is contained in:
parent
24276d1379
commit
43894f2fc9
26 changed files with 1118 additions and 14 deletions
|
|
@ -3,6 +3,7 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -188,6 +189,85 @@ LIMIT $1`, domain.MaxStarGiftCatalogSize)
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type StickerSetRow struct {
|
||||
// ID must round-trip through JSON as a string: these are Telegram-style
|
||||
// snowflake ids (18-19 digits), well past JS's 2^53 safe-integer limit, so
|
||||
// a plain JSON number gets silently rounded by the browser (see StarGiftRow
|
||||
// for the same fix applied to gift ids).
|
||||
ID int64 `json:"ID,string"`
|
||||
ShortName string
|
||||
Title string
|
||||
Count int
|
||||
Kind string
|
||||
SystemKey string
|
||||
Official bool
|
||||
Archived bool
|
||||
Installed bool
|
||||
SortOrder int
|
||||
CreatedAt time.Time
|
||||
// CoverDocumentID is the first document in the set, used as a small
|
||||
// thumbnail in the admin list — empty when the set has no documents yet.
|
||||
// Extracted straight from the jsonb array as text (document_ids->>0), so
|
||||
// it never round-trips through a JSON number and risks the same 2^53
|
||||
// precision loss as ID above.
|
||||
CoverDocumentID string
|
||||
}
|
||||
|
||||
// ListStickerSets lists non-system sticker/emoji sets. kind filters to exactly
|
||||
// that set_kind ("stickers", "emoji", "masks"); an empty kind lists all of
|
||||
// them (still excluding "system" — dice, animated emoji, premium/TON gifts
|
||||
// and similar built-in packs are never admin-editable).
|
||||
func (s *readStore) ListStickerSets(ctx context.Context, kind string) ([]StickerSetRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, short_name, title, count, set_kind, system_key, official, archived, installed, sort_order, created_at,
|
||||
COALESCE(document_ids->>0, '')
|
||||
FROM sticker_sets
|
||||
WHERE deleted = false AND set_kind <> 'system'
|
||||
AND ($1 = '' OR set_kind = $1)
|
||||
ORDER BY set_kind, sort_order, id`, kind)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sticker sets: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]StickerSetRow, 0)
|
||||
for rows.Next() {
|
||||
var item StickerSetRow
|
||||
if err := rows.Scan(&item.ID, &item.ShortName, &item.Title, &item.Count, &item.Kind, &item.SystemKey, &item.Official, &item.Archived, &item.Installed, &item.SortOrder, &item.CreatedAt, &item.CoverDocumentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// StickerSetDocumentIDs returns document ids as strings, not int64 — a plain
|
||||
// JSON number array would let the browser silently round these snowflake ids
|
||||
// past 2^53 (see StickerSetRow.ID for the same issue on the set id itself).
|
||||
func (s *readStore) StickerSetDocumentIDs(ctx context.Context, setID int64) ([]string, error) {
|
||||
// jsonb must be cast to text and scanned as a string — see the identical
|
||||
// ::text-cast pattern in internal/store/postgres/media.go's sticker set
|
||||
// select; scanning jsonb straight into []byte does not reliably decode here.
|
||||
var raw string
|
||||
err := s.pool.QueryRow(ctx, `SELECT document_ids::text FROM sticker_sets WHERE id = $1 AND deleted = false`, setID).Scan(&raw)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("sticker set documents: %w", err)
|
||||
}
|
||||
var ids []int64
|
||||
if raw != "" && raw != "[]" {
|
||||
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
|
||||
return nil, fmt.Errorf("sticker set documents: %w", err)
|
||||
}
|
||||
}
|
||||
out := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
out[i] = strconv.FormatInt(id, 10)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *readStore) SearchAccounts(ctx context.Context, q string) ([]AccountRow, error) {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
|
|
|
|||
|
|
@ -78,6 +78,13 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/publish-gift-collectibles", s.requireAuthAPI(http.HandlerFunc(s.handlePublishStarGiftCollectiblesAPI)))
|
||||
mux.Handle("POST /api/actions/set-gift-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftEnabledAPI)))
|
||||
mux.Handle("POST /api/actions/set-gift-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftSortOrderAPI)))
|
||||
mux.Handle("GET /api/stickers", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetsAPI)))
|
||||
mux.Handle("GET /api/stickers/{id}/documents", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetDocumentsAPI)))
|
||||
mux.Handle("GET /api/stickers/documents/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStickerDocumentAnimationAPI)))
|
||||
mux.Handle("POST /api/actions/set-sticker-set-archived", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetArchivedAPI)))
|
||||
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/delete-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteStickerSetAPI)))
|
||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeAPIError(w, http.StatusNotFound, "api route not found")
|
||||
})
|
||||
|
|
@ -984,6 +991,164 @@ func (s *server) handleSetStarGiftSortOrderAPI(w http.ResponseWriter, r *http.Re
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type renameStickerSetAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
SetID int64 `json:"set_id,string"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (s *server) handleRenameStickerSetAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body renameStickerSetAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.RenameStickerSetRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "rename-sticker-set"),
|
||||
SetID: body.SetID, Title: body.Title,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/stickers/rename", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type deleteStickerSetAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
SetID int64 `json:"set_id,string"`
|
||||
}
|
||||
|
||||
func (s *server) handleDeleteStickerSetAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body deleteStickerSetAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.DeleteStickerSetRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-sticker-set"),
|
||||
SetID: body.SetID,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/stickers/delete", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
func (s *server) handleStickerSetsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
rows, err := s.read.ListStickerSets(r.Context(), r.URL.Query().Get("kind"))
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
||||
}
|
||||
|
||||
func (s *server) handleStickerSetDocumentsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
setID, err := parseInt64(r.PathValue("id"))
|
||||
if err != nil || setID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
ids, err := s.read.StickerSetDocumentIDs(r.Context(), setID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"document_ids": ids})
|
||||
}
|
||||
|
||||
// handleStickerDocumentAnimationAPI proxies one document's decompressed Lottie
|
||||
// JSON from the real telesrv admin API — mirrors handleStarGiftAnimationAPI.
|
||||
func (s *server) handleStickerDocumentAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
documentID, err := parseInt64(r.PathValue("id"))
|
||||
if err != nil || documentID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
|
||||
fmt.Sprintf("%s/v1/stickers/documents/%d/animation", s.cfg.AdminAPIURL, documentID), nil)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1))
|
||||
if err != nil || len(raw) > 8<<20 {
|
||||
writeAPIError(w, http.StatusBadGateway, "invalid animation response")
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
writeAPIError(w, resp.StatusCode, string(raw))
|
||||
return
|
||||
}
|
||||
// Content-Type is passed through as-is: the underlying document may be a
|
||||
// decompressed Lottie animation (application/json) or a plain static
|
||||
// raster sticker (image/webp, image/png, ...) — see
|
||||
// admin.Service.StickerDocumentAnimation.
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
type setStickerSetArchivedAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
SetID int64 `json:"set_id,string"`
|
||||
Archived bool `json:"archived"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetStickerSetArchivedAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setStickerSetArchivedAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetStickerSetArchivedRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-sticker-set-archived"),
|
||||
SetID: body.SetID, Archived: body.Archived,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/stickers/set-archived", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setStickerSetSortOrderAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
SetID int64 `json:"set_id,string"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetStickerSetSortOrderAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setStickerSetSortOrderAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetStickerSetSortOrderRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-sticker-set-sort-order"),
|
||||
SetID: body.SetID, SortOrder: body.SortOrder,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/stickers/set-sort-order", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
func (s *server) commandMetaFromAPI(r *http.Request, commandID, reason string, confirm bool, prefix string) admin.CommandMeta {
|
||||
commandID = strings.TrimSpace(commandID)
|
||||
if confirm && strings.HasPrefix(commandID, "dry-") {
|
||||
|
|
|
|||
9
cmd/telesrv-admin/web/dist/assets/index-B4-Dmklu.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-B4-Dmklu.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
File diff suppressed because one or more lines are too long
1
cmd/telesrv-admin/web/dist/assets/index-CnQEc5UV.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-CnQEc5UV.css
vendored
Normal file
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" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<title>OwpenGram Admin</title>
|
||||
<script type="module" crossorigin src="/assets/index-CGbIqVNE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BAJpg0mp.css">
|
||||
<script type="module" crossorigin src="/assets/index-B4-Dmklu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CnQEc5UV.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import type {
|
|||
MessageListResponse,
|
||||
OfficialStarGiftListResponse,
|
||||
StarGiftCollectiblePreview,
|
||||
StarGiftListResponse
|
||||
StarGiftListResponse,
|
||||
StickerSetListResponse
|
||||
} from "./types";
|
||||
|
||||
export class APIError extends Error {
|
||||
|
|
@ -67,6 +68,9 @@ export const api = {
|
|||
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
|
||||
},
|
||||
gifts: () => request<StarGiftListResponse>("/api/gifts"),
|
||||
stickerSets: (kind: string) => request<StickerSetListResponse>(`/api/stickers?kind=${encodeURIComponent(kind)}`),
|
||||
stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`),
|
||||
stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`,
|
||||
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
|
||||
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`),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import {
|
|||
Shield,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
Gift
|
||||
Gift,
|
||||
Sticker,
|
||||
Smile
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api } from "../api";
|
||||
|
|
@ -76,6 +78,8 @@ export function Shell({
|
|||
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{t("layout.accounts")}</NavLink>
|
||||
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
|
||||
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink>
|
||||
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{t("layout.stickers")}</NavLink>
|
||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{t("layout.emoji")}</NavLink>
|
||||
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
|
||||
<button
|
||||
className="nav-section-toggle"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import lottie from "lottie-web/build/player/lottie_light_canvas";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
|
||||
// One preview cell in a sticker/emoji set's preview grid. Mounted only while
|
||||
// its modal is open, and only for the one set being viewed — not on the list
|
||||
// page — so this never repeats the "100 animations on one page" lag.
|
||||
//
|
||||
// Documents come back either as Lottie JSON (TGS-animated) or as a raw raster
|
||||
// image (static webp/png/etc, e.g. the "GestosLol" pack) — branch on the
|
||||
// response's real Content-Type rather than assuming every document animates.
|
||||
export function StickerDocumentPreview({ documentID, className = "", showError = true }: { documentID: string; className?: string; showError?: boolean }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [imageURL, setImageURL] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let objectURL: string | null = null;
|
||||
setError("");
|
||||
setImageURL(null);
|
||||
|
||||
fetch(api.stickerDocumentAnimationURL(documentID), { credentials: "same-origin" })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
throw new Error(body?.error || response.statusText);
|
||||
}
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("json")) {
|
||||
const data = await response.json();
|
||||
if (cancelled || !host.current) return;
|
||||
animation.current?.destroy();
|
||||
animation.current = lottie.loadAnimation({
|
||||
container: host.current,
|
||||
renderer: "canvas",
|
||||
loop: true,
|
||||
autoplay: true,
|
||||
animationData: data
|
||||
});
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (cancelled) return;
|
||||
objectURL = URL.createObjectURL(blob);
|
||||
setImageURL(objectURL);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
animation.current?.destroy();
|
||||
animation.current = null;
|
||||
if (objectURL) URL.revokeObjectURL(objectURL);
|
||||
};
|
||||
}, [documentID]);
|
||||
|
||||
return (
|
||||
<div className={`sticker-doc-cell ${className}`.trim()}>
|
||||
{imageURL ? (
|
||||
<img className="sticker-doc-image" src={imageURL} alt="" />
|
||||
) : (
|
||||
<div className="sticker-doc-canvas" ref={host} />
|
||||
)}
|
||||
{error && showError && <span className="sticker-doc-error">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -59,6 +59,10 @@ const translations: Record<string, string> = {
|
|||
"route.messagesSubtitle": "Console / Messages",
|
||||
"route.gifts": "Star Gifts",
|
||||
"route.giftsSubtitle": "Console / Star Gifts",
|
||||
"route.stickers": "Stickers",
|
||||
"route.stickersSubtitle": "Console / Stickers",
|
||||
"route.emoji": "Emoji",
|
||||
"route.emojiSubtitle": "Console / Emoji",
|
||||
"layout.navigation": "Navigation",
|
||||
"layout.primaryNav": "Primary navigation",
|
||||
"layout.dashboard": "Overview",
|
||||
|
|
@ -66,6 +70,8 @@ const translations: Record<string, string> = {
|
|||
"layout.channels": "Supergroups / Channels",
|
||||
"layout.messages": "Messages",
|
||||
"layout.gifts": "Star Gifts",
|
||||
"layout.stickers": "Stickers",
|
||||
"layout.emoji": "Emoji",
|
||||
"layout.privateMessages": "Private",
|
||||
"layout.groupMessages": "Groups",
|
||||
"layout.runtime": "Runtime",
|
||||
|
|
@ -315,6 +321,30 @@ const translations: Record<string, string> = {
|
|||
"gifts.validationReady": "Validation passed",
|
||||
"gifts.validationHint": "Review the normalized metadata, then confirm the import.",
|
||||
"gifts.confirmState": "Apply the validated state change to gift #{id}?",
|
||||
"stickers.pageTitle": "Stickers",
|
||||
"stickers.eyebrow": "Sticker packs — system packs (dice, animated emoji, gifts) aren't shown here, they're not hand-edited",
|
||||
"stickers.emojiPageTitle": "Emoji",
|
||||
"stickers.emojiEyebrow": "Custom-emoji packs — system packs aren't shown here, they're not hand-edited",
|
||||
"stickers.total": "Total sets",
|
||||
"stickers.searchPlaceholder": "Search set ID, short name or title",
|
||||
"stickers.listSummary": "Showing {shown} of {total}",
|
||||
"stickers.logo": "Logo",
|
||||
"stickers.id": "ID",
|
||||
"stickers.shortName": "Short name",
|
||||
"stickers.title": "Title",
|
||||
"stickers.count": "Documents",
|
||||
"stickers.official": "Official",
|
||||
"stickers.archived": "Archived",
|
||||
"stickers.sortOrder": "Sort order",
|
||||
"stickers.createdAt": "Created",
|
||||
"stickers.archive": "Archive",
|
||||
"stickers.unarchive": "Unarchive",
|
||||
"stickers.saveOrder": "Save",
|
||||
"stickers.saveTitle": "Save",
|
||||
"stickers.delete": "Delete",
|
||||
"stickers.view": "View",
|
||||
"stickers.previewEyebrow": "Set contents",
|
||||
"stickers.previewEmpty": "This set has no documents.",
|
||||
"collectibles.manage": "Attribute pool",
|
||||
"collectibles.title": "Collectible pool · Gift #{id}",
|
||||
"collectibles.eyebrow": "Unique gift attributes",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { GroupMessagesPage } from "./GroupMessagesPage";
|
|||
import { MessageDetailPage } from "./MessageDetailPage";
|
||||
import { MessagesPage } from "./MessagesPage";
|
||||
import { GiftsPage } from "./GiftsPage";
|
||||
import { StickerSetsPage } from "./StickerSetsPage";
|
||||
|
||||
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
|
||||
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
|
||||
|
|
@ -28,6 +29,12 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (route.path === "/gifts") {
|
||||
return <GiftsPage />;
|
||||
}
|
||||
if (route.path === "/stickers") {
|
||||
return <StickerSetsPage kind="stickers" />;
|
||||
}
|
||||
if (route.path === "/emoji") {
|
||||
return <StickerSetsPage kind="emoji" />;
|
||||
}
|
||||
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
|
||||
return (
|
||||
<MessageDetailPage
|
||||
|
|
|
|||
86
cmd/telesrv-admin/web/src/pages/StickerSetPreviewModal.tsx
Normal file
86
cmd/telesrv-admin/web/src/pages/StickerSetPreviewModal.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { ChevronLeft, ChevronRight, Loader2, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
|
||||
import { Alert } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import type { StickerSetRow } from "../types";
|
||||
|
||||
// Cells per modal page. Each page fully replaces the previous one (rather
|
||||
// than appending to a growing "load more" list) — accumulating batches made
|
||||
// the Lottie canvases overlap visually, and a fresh page also means each
|
||||
// batch's animations are properly unmounted before the next one mounts.
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [documentIDs, setDocumentIDs] = useState<string[] | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setDocumentIDs(null);
|
||||
setError("");
|
||||
setPage(1);
|
||||
api.stickerSetDocuments(set.ID).then((result) => {
|
||||
if (cancelled) return;
|
||||
setDocumentIDs(result.document_ids ?? []);
|
||||
}).catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [set.ID]);
|
||||
|
||||
const total = documentIDs?.length ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageStart = (currentPage - 1) * PAGE_SIZE;
|
||||
const pageItems = documentIDs?.slice(pageStart, pageStart + PAGE_SIZE) ?? [];
|
||||
const rangeStart = pageItems.length === 0 ? 0 : pageStart + 1;
|
||||
const rangeEnd = rangeStart === 0 ? 0 : rangeStart + pageItems.length - 1;
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal sticker-preview-modal" role="dialog" aria-modal="true" aria-label={set.Title || `#${set.ID}`}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{t("stickers.previewEyebrow")}</div>
|
||||
<h2>{set.Title || `#${set.ID}`}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} aria-label={t("action.close")}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{!error && documentIDs === null && (
|
||||
<div className="loading-line"><Loader2 className="spin" size={18} /> {t("common.loading")}</div>
|
||||
)}
|
||||
{documentIDs !== null && total === 0 && !error && (
|
||||
<div className="empty-panel">{t("stickers.previewEmpty")}</div>
|
||||
)}
|
||||
{pageItems.length > 0 && (
|
||||
<div className="sticker-doc-grid" key={currentPage}>
|
||||
{pageItems.map((documentID) => <StickerDocumentPreview key={documentID} documentID={documentID} />)}
|
||||
</div>
|
||||
)}
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="gift-pager">
|
||||
<span className="gift-pager-range">{t("gifts.pageRange", { start: rangeStart, end: rangeEnd, total })}</span>
|
||||
<div className="gift-pager-controls">
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
|
||||
<ChevronLeft size={14} /> {t("gifts.pagePrev")}
|
||||
</button>
|
||||
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span>
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
|
||||
{t("gifts.pageNext")} <ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
223
cmd/telesrv-admin/web/src/pages/StickerSetsPage.tsx
Normal file
223
cmd/telesrv-admin/web/src/pages/StickerSetsPage.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { Eye, ChevronLeft, ChevronRight, ImageOff, RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import type { StickerSetRow } from "../types";
|
||||
import { StickerSetPreviewModal } from "./StickerSetPreviewModal";
|
||||
|
||||
type StickerPageSize = 10 | 20 | 50 | 100 | "all";
|
||||
|
||||
// Shared list/manage view for one non-system sticker-set kind ("stickers" or
|
||||
// "emoji") — system packs (dice, animated emoji, premium/TON gifts, etc.) are
|
||||
// filtered out server-side and never reach this page; they aren't meant to be
|
||||
// hand-edited.
|
||||
export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
|
||||
const { t } = useI18n();
|
||||
const [sets, setSets] = useState<StickerSetRow[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [pageSize, setPageSize] = useState<StickerPageSize>(10);
|
||||
const [page, setPage] = useState(1);
|
||||
const [orderDrafts, setOrderDrafts] = useState<Record<string, string>>({});
|
||||
const [titleDrafts, setTitleDrafts] = useState<Record<string, string>>({});
|
||||
const [previewSet, setPreviewSet] = useState<StickerSetRow | null>(null);
|
||||
|
||||
const pageTitleKey = kind === "emoji" ? "stickers.emojiPageTitle" : "stickers.pageTitle";
|
||||
const eyebrowKey = kind === "emoji" ? "stickers.emojiEyebrow" : "stickers.eyebrow";
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setSets((await api.stickerSets(kind)).rows ?? []);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [kind]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return sets;
|
||||
return sets.filter((set) =>
|
||||
String(set.ID).includes(normalized) ||
|
||||
set.ShortName.toLowerCase().includes(normalized) ||
|
||||
set.Title.toLowerCase().includes(normalized)
|
||||
);
|
||||
}, [sets, query]);
|
||||
|
||||
useEffect(() => { setPage(1); }, [query, pageSize, kind]);
|
||||
|
||||
const totalPages = pageSize === "all" ? 1 : Math.max(1, Math.ceil(visible.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const paged = useMemo(() => {
|
||||
if (pageSize === "all") return visible;
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return visible.slice(start, start + pageSize);
|
||||
}, [visible, currentPage, pageSize]);
|
||||
const rangeStart = paged.length === 0 ? 0 : pageSize === "all" ? 1 : (currentPage - 1) * pageSize + 1;
|
||||
const rangeEnd = rangeStart === 0 ? 0 : rangeStart + paged.length - 1;
|
||||
|
||||
const counts = useMemo(() => ({
|
||||
total: sets.length,
|
||||
official: sets.filter((set) => set.Official).length,
|
||||
archived: sets.filter((set) => set.Archived).length
|
||||
}), [sets]);
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t(pageTitleKey)}
|
||||
eyebrow={t(eyebrowKey)}
|
||||
actions={
|
||||
<button className="btn" type="button" onClick={() => load()} disabled={busy}>
|
||||
<RefreshCw size={15} /> {t("common.refresh")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={t("stickers.total")} value={String(counts.total)} />
|
||||
<Metric label={t("stickers.official")} value={String(counts.official)} tone="good" />
|
||||
<Metric label={t("stickers.archived")} value={String(counts.archived)} tone={counts.archived > 0 ? "warn" : "neutral"} />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
<div className="toolbar">
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("stickers.searchPlaceholder")} />
|
||||
</label>
|
||||
<label className="gift-page-size">
|
||||
<span>{t("gifts.perPage")}</span>
|
||||
<select
|
||||
value={String(pageSize)}
|
||||
onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as StickerPageSize))}
|
||||
>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="all">{t("gifts.perPageAll")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="gift-list-summary">{t("stickers.listSummary", { shown: visible.length, total: sets.length })}</span>
|
||||
</div>
|
||||
</QueryPanel>
|
||||
<div className="table-wrap gift-table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("stickers.logo")}</th>
|
||||
<th>{t("stickers.id")}</th>
|
||||
<th>{t("stickers.shortName")}</th>
|
||||
<th>{t("stickers.title")}</th>
|
||||
<th>{t("stickers.count")}</th>
|
||||
<th>{t("stickers.official")}</th>
|
||||
<th>{t("common.status")}</th>
|
||||
<th>{t("stickers.sortOrder")}</th>
|
||||
<th>{t("common.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((set) => (
|
||||
<tr className={set.Archived ? "gift-row-disabled" : ""} key={set.ID}>
|
||||
<td>
|
||||
{set.CoverDocumentID ? (
|
||||
<StickerDocumentPreview documentID={set.CoverDocumentID} className="list-thumb" showError={false} />
|
||||
) : (
|
||||
<div className="sticker-list-thumb-empty"><ImageOff size={14} /></div>
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">{set.ID}</td>
|
||||
<td className="mono">{set.ShortName || <span className="muted-cell">{t("common.none")}</span>}</td>
|
||||
<td>
|
||||
<div className="sort-order-editor">
|
||||
<input
|
||||
className="small-input title-input"
|
||||
value={titleDrafts[set.ID] ?? set.Title}
|
||||
onChange={(event) => setTitleDrafts((prev) => ({ ...prev, [set.ID]: event.target.value }))}
|
||||
/>
|
||||
<ActionButton
|
||||
compact
|
||||
tone="neutral"
|
||||
label={t("stickers.saveTitle")}
|
||||
path="/api/actions/rename-sticker-set"
|
||||
payload={() => ({ set_id: set.ID, title: (titleDrafts[set.ID] ?? set.Title).trim() })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>{set.Count}</td>
|
||||
<td>{set.Official ? <Badge tone="good">{t("common.yes")}</Badge> : <Badge>{t("common.no")}</Badge>}</td>
|
||||
<td>{set.Archived ? <Badge tone="danger">{t("stickers.archived")}</Badge> : <Badge tone="good">{t("common.enabled")}</Badge>}</td>
|
||||
<td>
|
||||
<div className="sort-order-editor">
|
||||
<input
|
||||
type="number"
|
||||
className="small-input"
|
||||
value={orderDrafts[set.ID] ?? String(set.SortOrder)}
|
||||
onChange={(event) => setOrderDrafts((prev) => ({ ...prev, [set.ID]: event.target.value }))}
|
||||
/>
|
||||
<ActionButton
|
||||
compact
|
||||
tone="neutral"
|
||||
label={t("stickers.saveOrder")}
|
||||
path="/api/actions/set-sticker-set-sort-order"
|
||||
payload={() => ({ set_id: set.ID, sort_order: Number(orderDrafts[set.ID] ?? set.SortOrder) })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="gift-table-actions">
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPreviewSet(set)}>
|
||||
<Eye size={13} /> {t("stickers.view")}
|
||||
</button>
|
||||
<ActionButton
|
||||
compact
|
||||
tone="neutral"
|
||||
label={set.Archived ? t("stickers.unarchive") : t("stickers.archive")}
|
||||
path="/api/actions/set-sticker-set-archived"
|
||||
payload={() => ({ set_id: set.ID, archived: !set.Archived })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
<ActionButton
|
||||
compact
|
||||
tone="danger"
|
||||
label={t("stickers.delete")}
|
||||
path="/api/actions/delete-sticker-set"
|
||||
payload={() => ({ set_id: set.ID })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{paged.length === 0 && <EmptyRow colSpan={9} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pageSize !== "all" && visible.length > 0 && (
|
||||
<div className="gift-pager">
|
||||
<span className="gift-pager-range">{t("gifts.pageRange", { start: rangeStart, end: rangeEnd, total: visible.length })}</span>
|
||||
<div className="gift-pager-controls">
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
|
||||
<ChevronLeft size={14} /> {t("gifts.pagePrev")}
|
||||
</button>
|
||||
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span>
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
|
||||
{t("gifts.pageNext")} <ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{previewSet && <StickerSetPreviewModal set={previewSet} onClose={() => setPreviewSet(null)} />}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ export function routeTitle(pathname: string, t: TFunction): string {
|
|||
if (pathname.startsWith("/channels")) return t("route.channels");
|
||||
if (pathname.startsWith("/messages")) return t("route.messages");
|
||||
if (pathname.startsWith("/gifts")) return t("route.gifts");
|
||||
if (pathname.startsWith("/stickers")) return t("route.stickers");
|
||||
if (pathname.startsWith("/emoji")) return t("route.emoji");
|
||||
return t("route.dashboard");
|
||||
}
|
||||
|
||||
|
|
@ -29,5 +31,7 @@ export function routeSubtitle(pathname: string, t: TFunction): string {
|
|||
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
|
||||
if (pathname.startsWith("/messages")) return t("route.messagesSubtitle");
|
||||
if (pathname.startsWith("/gifts")) return t("route.giftsSubtitle");
|
||||
if (pathname.startsWith("/stickers")) return t("route.stickersSubtitle");
|
||||
if (pathname.startsWith("/emoji")) return t("route.emojiSubtitle");
|
||||
return t("route.dashboardSubtitle");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -347,6 +347,19 @@ textarea:focus {
|
|||
width: 88px;
|
||||
}
|
||||
|
||||
.sort-order-editor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.sort-order-editor .small-input {
|
||||
width: 64px;
|
||||
height: 32px;
|
||||
}
|
||||
.sort-order-editor .title-input {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.field-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -401,6 +401,32 @@
|
|||
.gift-validation-head span { color: #99cfc4; font-size: 10px; }
|
||||
.gift-validation pre { max-height: 180px; overflow: auto; margin: 0; padding: 11px 12px; color: #d5fff5; font-size: 11px; }
|
||||
|
||||
.sticker-preview-modal { width: min(760px, 100%); }
|
||||
.sticker-doc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
.sticker-doc-cell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.sticker-doc-canvas { width: 100%; height: 100%; }
|
||||
.sticker-doc-canvas canvas { width: 100% !important; height: 100% !important; }
|
||||
.sticker-doc-image { width: 100%; height: 100%; object-fit: contain; }
|
||||
.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-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 { width: 200px; height: 200px; }
|
||||
.gift-animation canvas { width: 100% !important; height: 100% !important; }
|
||||
|
|
|
|||
|
|
@ -266,6 +266,27 @@ export type CommandResult = {
|
|||
error?: string;
|
||||
};
|
||||
|
||||
export type StickerSetRow = {
|
||||
// String, not number: these are 18-19 digit snowflake ids, past JS's 2^53
|
||||
// safe-integer limit — see GiftID on StarGiftRow for the same convention.
|
||||
ID: string;
|
||||
ShortName: string;
|
||||
Title: string;
|
||||
Count: number;
|
||||
Kind: string;
|
||||
SystemKey: string;
|
||||
Official: boolean;
|
||||
Archived: boolean;
|
||||
Installed: boolean;
|
||||
SortOrder: number;
|
||||
CreatedAt: string;
|
||||
// Id of the set's first document, for a small list thumbnail — empty when
|
||||
// the set has no documents. Same string-not-number reasoning as ID.
|
||||
CoverDocumentID: string;
|
||||
};
|
||||
|
||||
export type StickerSetListResponse = { rows: StickerSetRow[] };
|
||||
|
||||
export type AccountListResponse = {
|
||||
query: string;
|
||||
limit: number;
|
||||
|
|
|
|||
|
|
@ -885,6 +885,7 @@ func run(logger *zap.Logger) error {
|
|||
Messages: messagesService,
|
||||
Gifts: giftsService,
|
||||
Photos: filesService,
|
||||
StickerSets: filesService,
|
||||
})
|
||||
// bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界),
|
||||
// router 创建后注入。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue