added stickers and emojies list

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

View file

@ -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 == "" {

View file

@ -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-") {

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

File diff suppressed because one or more lines are too long

View file

@ -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>

View file

@ -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`),

View file

@ -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"

View file

@ -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>
);
}

View file

@ -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",

View file

@ -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

View 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
);
}

View 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>
);
}

View file

@ -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");
}

View file

@ -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;

View file

@ -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; }

View file

@ -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;

View file

@ -885,6 +885,7 @@ func run(logger *zap.Logger) error {
Messages: messagesService,
Gifts: giftsService,
Photos: filesService,
StickerSets: filesService,
})
// bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界),
// router 创建后注入。

View file

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

View file

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

View file

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

View file

@ -282,6 +282,17 @@ func (f *fakeMediaStore) DeleteStickerSet(_ context.Context, setID int64, creato
f.sets[setID] = set
return nil
}
func (f *fakeMediaStore) AdminDeleteStickerSet(_ context.Context, setID int64) error {
f.mu.Lock()
defer f.mu.Unlock()
set, ok := f.sets[setID]
if !ok || set.Deleted {
return domain.ErrStickerSetInvalid
}
set.Deleted = true
f.sets[setID] = set
return nil
}
func (f *fakeMediaStore) GetStickerSetByID(_ context.Context, id int64) (domain.StickerSet, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()

View file

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

View file

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

View file

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