removed default telegram gifts

This commit is contained in:
onysd 2026-07-22 20:20:16 +03:00
parent 139967399d
commit b1836c78e8
25109 changed files with 1040 additions and 757922 deletions

View file

@ -59,8 +59,8 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
mux.Handle("GET /api/gifts", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftsAPI)))
mux.Handle("GET /api/official-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftsAPI)))
mux.Handle("GET /api/official-gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftAnimationAPI)))
mux.Handle("GET /api/default-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleDefaultStarGiftsAPI)))
mux.Handle("GET /api/default-gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleDefaultStarGiftAnimationAPI)))
mux.Handle("GET /api/gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftAnimationAPI)))
mux.Handle("GET /api/gifts/{id}/collectibles", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectiblesAPI)))
mux.Handle("GET /api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectibleAnimationAPI)))
@ -73,8 +73,8 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
mux.Handle("POST /api/actions/delete-history", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteHistoryAPI)))
mux.Handle("POST /api/actions/import-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportStarGiftAPI)))
mux.Handle("POST /api/actions/import-official-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportOfficialStarGiftAPI)))
mux.Handle("POST /api/actions/import-all-official-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleImportAllOfficialStarGiftsAPI)))
mux.Handle("POST /api/actions/import-default-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportDefaultStarGiftAPI)))
mux.Handle("POST /api/actions/import-all-default-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleImportAllDefaultStarGiftsAPI)))
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)))
@ -243,17 +243,17 @@ func (s *server) handleStarGiftCollectiblesAPI(w http.ResponseWriter, r *http.Re
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/gifts/%d/collectibles", giftID), 4<<20)
}
func (s *server) handleOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
s.proxyAdminJSON(w, r, "/v1/official-gifts", 4<<20)
func (s *server) handleDefaultStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
s.proxyAdminJSON(w, r, "/v1/default-gifts", 4<<20)
}
func (s *server) handleOfficialStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
func (s *server) handleDefaultStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.PathValue("id"))
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
if _, err := strconv.Atoi(id); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid default gift id")
return
}
s.proxyAdminJSON(w, r, "/v1/official-gifts/"+id+"/animation", 4<<20)
s.proxyAdminJSON(w, r, "/v1/default-gifts/"+id+"/animation", 4<<20)
}
func (s *server) handleStarGiftCollectibleAnimationAPI(w http.ResponseWriter, r *http.Request) {
@ -815,58 +815,45 @@ func (s *server) handleImportStarGiftAPI(w http.ResponseWriter, r *http.Request)
writeCommandResultAPI(w, result, err)
}
type importOfficialStarGiftAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
SourceGiftID string `json:"source_gift_id"`
GiftID int64 `json:"gift_id,string"`
Title string `json:"title"`
Stars int64 `json:"stars,string"`
ConvertStars int64 `json:"convert_stars,string"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
IncludeCollectible bool `json:"include_collectible"`
UpgradeStars int64 `json:"upgrade_stars,string"`
SupplyTotal int `json:"supply_total"`
SlugPrefix string `json:"slug_prefix"`
type importDefaultStarGiftAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ID int `json:"id"`
}
func (s *server) handleImportOfficialStarGiftAPI(w http.ResponseWriter, r *http.Request) {
var body importOfficialStarGiftAPIRequest
func (s *server) handleImportDefaultStarGiftAPI(w http.ResponseWriter, r *http.Request) {
var body importDefaultStarGiftAPIRequest
if !decodeAction(w, r, &body) {
return
}
if _, err := strconv.ParseInt(strings.TrimSpace(body.SourceGiftID), 10, 64); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
if body.ID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid default gift id")
return
}
req := admin.ImportOfficialStarGiftRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-official-gift"),
SourceGiftID: body.SourceGiftID, GiftID: body.GiftID, Title: body.Title,
Stars: body.Stars, ConvertStars: body.ConvertStars, Enabled: body.Enabled, SortOrder: body.SortOrder,
IncludeCollectible: body.IncludeCollectible, UpgradeStars: body.UpgradeStars,
SupplyTotal: body.SupplyTotal, SlugPrefix: body.SlugPrefix,
req := admin.ImportDefaultStarGiftRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-default-gift"),
ID: body.ID,
}
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import", req)
result, err := s.callAdminAPI(r.Context(), "/v1/default-gifts/import", req)
writeCommandResultAPI(w, result, err)
}
type importAllOfficialStarGiftsAPIRequest struct {
type importAllDefaultStarGiftsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
}
func (s *server) handleImportAllOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
var body importAllOfficialStarGiftsAPIRequest
func (s *server) handleImportAllDefaultStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
var body importAllDefaultStarGiftsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.ImportAllOfficialStarGiftsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-all-official-gifts"),
req := admin.ImportAllDefaultStarGiftsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-all-default-gifts"),
}
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import-all", req)
result, err := s.callAdminAPI(r.Context(), "/v1/default-gifts/import-all", req)
writeCommandResultAPI(w, result, err)
}

View file

@ -108,21 +108,16 @@ func TestStarGiftRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
}
}
func TestStarGiftActionDecimalStringDecodingPreservesInt64(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
req := httptest.NewRequest(http.MethodPost, "/api/actions/import-official-gift", strings.NewReader(`{
"source_gift_id":"5895603153683874485",
"gift_id":"9223372036854775807",
"stars":"9223372036854775807",
"convert_stars":"9223372036854775807",
"upgrade_stars":"9223372036854775807"
func TestDefaultGiftImportActionDecodes(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/actions/import-default-gift", strings.NewReader(`{
"command_id":"c1","reason":"demo","confirm":true,"id":3
}`))
var got importOfficialStarGiftAPIRequest
var got importDefaultStarGiftAPIRequest
if err := decodeJSON(req, &got); err != nil {
t.Fatalf("decode gift action: %v", err)
t.Fatalf("decode default gift action: %v", err)
}
if got.GiftID != maxInt64 || got.Stars != maxInt64 || got.ConvertStars != maxInt64 || got.UpgradeStars != maxInt64 {
t.Fatalf("decoded gift action = %+v", got)
if got.ID != 3 || got.CommandID != "c1" || !got.Confirm {
t.Fatalf("decoded default gift action = %+v", got)
}
}

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-CT_6lfHd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7AAg19g.css">
<script type="module" crossorigin src="/assets/index-CO25dkAS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D2rrhA4q.css">
</head>
<body>
<div id="root"></div>

View file

@ -8,7 +8,7 @@ import type {
GroupMessageListResponse,
MessageDetail,
MessageListResponse,
OfficialStarGiftListResponse,
DefaultGiftListResponse,
StarGiftCollectiblePreview,
StarGiftListResponse,
StickerSetListResponse
@ -73,13 +73,13 @@ export const api = {
stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`,
createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }),
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),
defaultGifts: () => request<DefaultGiftListResponse>("/api/default-gifts"),
defaultGiftAnimation: (id: number) => request<Record<string, unknown>>(`/api/default-gifts/${id}/animation`),
giftAnimation: (id: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(id)}/animation`),
giftCollectibles: (id: string) => request<StarGiftCollectiblePreview>(`/api/gifts/${encodeURIComponent(id)}/collectibles`),
giftCollectibleAnimation: (giftID: string, kind: "model" | "pattern", attributeID: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(giftID)}/collectibles/${kind}/${encodeURIComponent(attributeID)}/animation`),
importGift: (form: FormData) => request<CommandResult>("/api/actions/import-gift", { method: "POST", body: form }),
importOfficialGift: (payload: Record<string, unknown>) => request<CommandResult>("/api/actions/import-official-gift", { method: "POST", body: JSON.stringify(payload) }),
importDefaultGift: (payload: Record<string, unknown>) => request<CommandResult>("/api/actions/import-default-gift", { method: "POST", body: JSON.stringify(payload) }),
publishGiftCollectibles: (giftID: string, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${encodeURIComponent(giftID)}`, { method: "POST", body: form }),
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
method: "POST",

View file

@ -252,7 +252,14 @@ const translations: Record<string, string> = {
"gifts.received": "Received gifts",
"gifts.formats": "Accepted formats",
"gifts.add": "Add gift",
"gifts.importAll": "Import all official gifts",
"gifts.importAllDefault": "Import all default gifts",
"gifts.defaultSource": "Default gifts",
"gifts.defaultHint": "Import our built-in original OwpenGram gifts. Complete collectible pools (upgrade + craft) are imported atomically.",
"gifts.defaultSelect": "Choose a default gift",
"gifts.defaultRequired": "Choose a default gift first",
"gifts.defaultEmpty": "No default gifts are available.",
"gifts.limited": "Limited · {total}",
"gifts.premium": "Premium only",
"gifts.searchPlaceholder": "Search gift ID, title or format",
"gifts.listSummary": "Showing {shown} of {total}",
"gifts.idRevision": "ID / Revision",
@ -261,26 +268,12 @@ const translations: Record<string, string> = {
"gifts.importEyebrow": "Gift catalog operation",
"gifts.newRevision": "Create revision for gift #{id}",
"gifts.importHint": "Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS.",
"gifts.officialSource": "Official snapshot",
"gifts.fileSource": "Upload file",
"gifts.officialHint": "Choose a verified gift from data/official-gifts. Complete collectible pools are imported atomically.",
"gifts.officialSearch": "Search official gift ID or title",
"gifts.officialSelect": "Choose an official gift",
"gifts.officialRequired": "Choose an official gift first",
"gifts.officialResults": "Showing {shown} of {total}",
"gifts.officialCategoryLabel": "Official gift capability category",
"gifts.officialCategory.all": "All",
"gifts.officialCategory.upgrade": "Upgradable",
"gifts.officialCategory.craft": "Craftable",
"gifts.officialCategory.basic": "Not upgradable",
"gifts.officialUnnamed": "Unnamed official gift #{id}",
"gifts.officialAttributes": "{count} attributes",
"gifts.canUpgrade": "Can upgrade",
"gifts.cannotUpgrade": "Cannot upgrade",
"gifts.canCraft": "Can Craft",
"gifts.cannotCraft": "Cannot Craft",
"gifts.officialEmpty": "No official gifts match this category and search.",
"gifts.includeCollectible": "Import the complete collectible pool, including crafted models",
"gifts.animation": "Animation file",
"gifts.filePrompt": "Drop or choose a TGS / Lottie file",
"gifts.fileHint": "TGS, JSON or Lottie · validated before import",
@ -357,7 +350,7 @@ const translations: Record<string, string> = {
"stickers.emojiRequired": "An emoji is required.",
"stickers.addSticker": "Add {noun}",
"stickers.fileRequired": "Choose a {noun} file first",
"stickers.removeSticker": "Remove {noun}",
"stickers.removeSticker": "Remove",
"collectibles.manage": "Attribute pool",
"collectibles.title": "Collectible pool · Gift #{id}",
"collectibles.eyebrow": "Unique gift attributes",

View file

@ -7,13 +7,12 @@ import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { formatDate } from "../lib/format";
import type { CommandResult, OfficialStarGiftRow, StarGiftRow } from "../types";
import type { CommandResult, DefaultGiftRow, StarGiftRow } from "../types";
import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
type GiftPageSize = 10 | 20 | 50 | 100 | "all";
function officialGiftAttributeCount(gift: OfficialStarGiftRow) {
function defaultGiftAttributeCount(gift: DefaultGiftRow) {
return gift.model_count + gift.pattern_count + gift.backdrop_count;
}
@ -67,17 +66,17 @@ function LottiePreview({ giftID, revision, compact = false }: { giftID: string;
);
}
function OfficialLottiePreview({ sourceGiftID }: { sourceGiftID: string }) {
function DefaultLottiePreview({ id }: { id: number }) {
const host = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
let player: ReturnType<typeof lottie.loadAnimation> | null = null;
api.officialGiftAnimation(sourceGiftID).then((data) => {
api.defaultGiftAnimation(id).then((data) => {
if (cancelled || !host.current) return;
player = lottie.loadAnimation({ container: host.current, renderer: "canvas", loop: true, autoplay: true, animationData: structuredClone(data) });
}).catch(() => undefined);
return () => { cancelled = true; player?.destroy(); };
}, [sourceGiftID]);
}, [id]);
return <div className="gift-animation-shell"><div className="gift-animation" ref={host} /></div>;
}
@ -88,15 +87,9 @@ export function GiftsPage() {
const [importOpen, setImportOpen] = useState(false);
const [collectibleGift, setCollectibleGift] = useState<StarGiftRow | null>(null);
const [file, setFile] = useState<File | null>(null);
const [importSource, setImportSource] = useState<"official" | "file">("official");
const [officialGifts, setOfficialGifts] = useState<OfficialStarGiftRow[]>([]);
const [officialQuery, setOfficialQuery] = useState("");
const [officialCategory, setOfficialCategory] = useState<OfficialGiftCategory>("all");
const [sourceGiftID, setSourceGiftID] = useState("");
const [includeCollectible, setIncludeCollectible] = useState(true);
const [upgradeStars, setUpgradeStars] = useState("0");
const [supplyTotal, setSupplyTotal] = useState("0");
const [slugPrefix, setSlugPrefix] = useState("");
const [importSource, setImportSource] = useState<"default" | "file">("default");
const [defaultGifts, setDefaultGifts] = useState<DefaultGiftRow[]>([]);
const [selectedDefaultID, setSelectedDefaultID] = useState(0);
const [giftID, setGiftID] = useState("0");
const [title, setTitle] = useState("");
const [stars, setStars] = useState("50");
@ -127,27 +120,11 @@ export function GiftsPage() {
useEffect(() => { void load(); }, []);
useEffect(() => {
if (!importOpen || importSource !== "official" || officialGifts.length > 0) return;
api.officialGifts().then((value) => setOfficialGifts(value.gifts ?? [])).catch((err) => setImportError(errorMessage(err)));
}, [importOpen, importSource, officialGifts.length]);
if (!importOpen || importSource !== "default" || defaultGifts.length > 0) return;
api.defaultGifts().then((value) => setDefaultGifts(value.gifts ?? [])).catch((err) => setImportError(errorMessage(err)));
}, [importOpen, importSource, defaultGifts.length]);
const selectedOfficial = useMemo(() => officialGifts.find((gift) => gift.source_gift_id === sourceGiftID) ?? null, [officialGifts, sourceGiftID]);
const officialCategoryCounts = useMemo(() => ({
all: officialGifts.length,
upgrade: officialGifts.filter((gift) => gift.can_upgrade).length,
craft: officialGifts.filter((gift) => gift.can_craft).length,
basic: officialGifts.filter((gift) => !gift.can_upgrade).length
}), [officialGifts]);
const visibleOfficial = useMemo(() => {
const normalized = officialQuery.trim().toLowerCase();
return officialGifts.filter((gift) => {
const categoryMatches = officialCategory === "all" ||
(officialCategory === "upgrade" && gift.can_upgrade) ||
(officialCategory === "craft" && gift.can_craft) ||
(officialCategory === "basic" && !gift.can_upgrade);
return categoryMatches && (!normalized || gift.source_gift_id.includes(normalized) || gift.title.toLowerCase().includes(normalized));
});
}, [officialGifts, officialQuery, officialCategory]);
const selectedDefault = useMemo(() => defaultGifts.find((gift) => gift.id === selectedDefaultID) ?? null, [defaultGifts, selectedDefaultID]);
const visibleGifts = useMemo(() => {
const normalized = query.trim().toLowerCase();
@ -245,34 +222,16 @@ export function GiftsPage() {
return form;
}
function officialPayload(confirm: boolean, commandID = "") {
if (!sourceGiftID) throw new Error(t("gifts.officialRequired"));
function defaultPayload(confirm: boolean, commandID = "") {
if (!selectedDefaultID) throw new Error(t("gifts.defaultRequired"));
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
return {
command_id: commandID, reason: reason.trim(), confirm,
source_gift_id: sourceGiftID, gift_id: giftID, title: title.trim(),
stars, convert_stars: convertStars, enabled, sort_order: Number(sortOrder),
include_collectible: includeCollectible, upgrade_stars: upgradeStars,
supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase()
};
}
function chooseOfficial(gift: OfficialStarGiftRow) {
setSourceGiftID(gift.source_gift_id);
setTitle(gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id }));
setStars(String(gift.stars));
setConvertStars(String(gift.convert_stars));
setIncludeCollectible(gift.can_upgrade);
setUpgradeStars(gift.upgrade_stars);
setSupplyTotal(String(gift.availability_total || 1));
setSlugPrefix(`official-${gift.source_gift_id}`);
setPreview(null);
return { command_id: commandID, reason: reason.trim(), confirm, id: selectedDefaultID };
}
async function validateImport() {
setBusy(true); setImportError(""); setPreview(null);
try {
setPreview(importSource === "official" ? await api.importOfficialGift(officialPayload(false)) : await api.importGift(uploadForm(false)));
setPreview(importSource === "default" ? await api.importDefaultGift(defaultPayload(false)) : await api.importGift(uploadForm(false)));
} catch (err) {
setImportError(errorMessage(err));
} finally { setBusy(false); }
@ -282,9 +241,9 @@ export function GiftsPage() {
if (!preview) return;
setBusy(true); setImportError("");
try {
if (importSource === "official") await api.importOfficialGift(officialPayload(true, preview.command_id));
if (importSource === "default") await api.importDefaultGift(defaultPayload(true, preview.command_id));
else await api.importGift(uploadForm(true, preview.command_id));
setPreview(null); setFile(null); setGiftID("0"); setTitle(""); setSourceGiftID("");
setPreview(null); setFile(null); setGiftID("0"); setTitle(""); setSelectedDefaultID(0);
await load();
setImportOpen(false);
} catch (err) {
@ -295,16 +254,18 @@ export function GiftsPage() {
function startImport() {
setGiftID("0"); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError("");
setImportSource("official"); setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); setImportOpen(true);
setImportSource("default"); setSelectedDefaultID(0); setImportOpen(true);
}
function startRevision(gift: StarGiftRow) {
setGiftID(gift.GiftID); setTitle(gift.Title); setStars(String(gift.Stars));
setConvertStars(String(gift.ConvertStars)); setSortOrder(String(gift.SortOrder)); setEnabled(gift.Enabled);
setReason(""); setFile(null); setPreview(null); setImportError("");
setImportSource("official"); setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); setImportOpen(true);
setImportSource("file"); setSelectedDefaultID(0); setImportOpen(true);
}
const step1Done = importSource === "default" ? selectedDefaultID > 0 : Boolean(file);
return (
<PageFrame title={t("gifts.pageTitle")} eyebrow={t("gifts.eyebrow")} actions={<>
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {t("common.refresh")}</button>
@ -388,71 +349,52 @@ export function GiftsPage() {
</div>
<div className="command-body gift-import-modal-body">
<div className="command-steps">
<div className={`command-step ${(importSource === "official" ? sourceGiftID : file) ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div>
<div className={`command-step ${preview ? "done" : (importSource === "official" ? sourceGiftID : file) ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div>
<div className={`command-step ${step1Done ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div>
<div className={`command-step ${preview ? "done" : step1Done ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div>
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{t("gifts.stepImport")}</strong></div>
</div>
<div className="gift-source-tabs">
<button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{t("gifts.officialSource")}</button>
{giftID === "0" && <div className="gift-source-tabs">
<button className={`btn ${importSource === "default" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("default"); setPreview(null); }}>{t("gifts.defaultSource")}</button>
<button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{t("gifts.fileSource")}</button>
</div>
{importSource === "official" ? <section className="official-gift-picker">
<div className="gift-import-note"><span>{t("gifts.officialHint")}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div>
</div>}
{importSource === "default" && giftID === "0" ? <section className="official-gift-picker">
<div className="gift-import-note"><span>{t("gifts.defaultHint")}</span><div className="gift-format-chips"><span>{defaultGifts.length}</span><span>OwpenGram</span></div></div>
<div className="official-gift-bulk-import">
<ActionButton
label={t("gifts.importAll")}
path="/api/actions/import-all-official-gifts"
label={t("gifts.importAllDefault")}
path="/api/actions/import-all-default-gifts"
payload={() => ({})}
tone="neutral"
icon={<Upload size={14} />}
onDone={() => void load()}
/>
</div>
<div className="official-gift-tools">
<label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={t("gifts.officialSearch")} /></label>
<span>{t("gifts.officialResults", { shown: visibleOfficial.length, total: officialGifts.length })}</span>
</div>
<div className="official-gift-categories" role="group" aria-label={t("gifts.officialCategoryLabel")}>
{(["all", "upgrade", "craft", "basic"] as const).map((category) => (
<button key={category} className={officialCategory === category ? "active" : ""} type="button"
aria-pressed={officialCategory === category} onClick={() => setOfficialCategory(category)}>
{t(`gifts.officialCategory.${category}`)}<span>{officialCategoryCounts[category]}</span>
</button>
))}
</div>
<div className="official-gift-list" role="listbox" aria-label={t("gifts.officialSelect")}>
{visibleOfficial.map((gift) => {
const selected = gift.source_gift_id === sourceGiftID;
return <button key={gift.source_gift_id} className={`official-gift-option ${selected ? "selected" : ""}`}
type="button" role="option" aria-selected={selected} onClick={() => chooseOfficial(gift)}>
<div className="official-gift-list" role="listbox" aria-label={t("gifts.defaultSelect")}>
{defaultGifts.map((gift) => {
const isSelected = gift.id === selectedDefaultID;
return <button key={gift.id} className={`official-gift-option ${isSelected ? "selected" : ""}`}
type="button" role="option" aria-selected={isSelected} onClick={() => { setSelectedDefaultID(gift.id); setPreview(null); }}>
<span className="official-gift-option-head">
<strong>{gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id })}</strong>
<span className="mono">#{gift.source_gift_id}</span>
<strong>{gift.title}</strong>
<span className="mono"> {gift.stars}</span>
</span>
<span className="official-gift-option-meta">
<span> {gift.stars}</span>
<span>{t("gifts.officialAttributes", { count: officialGiftAttributeCount(gift) })}</span>
<span>{t("gifts.officialAttributes", { count: defaultGiftAttributeCount(gift) })}</span>
{gift.limited && <span>{t("gifts.limited", { total: gift.availability })}</span>}
{gift.require_premium && <span>{t("gifts.premium")}</span>}
</span>
<span className="official-gift-capabilities">
<span className={gift.can_upgrade ? "yes" : "no"}>{gift.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span>
<span className={gift.can_craft ? "craft" : "no"}>{gift.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span>
<span className={gift.upgradeable ? "yes" : "no"}>{gift.upgradeable ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span>
<span className={gift.craftable ? "craft" : "no"}>{gift.craftable ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span>
</span>
</button>;
})}
{visibleOfficial.length === 0 && <div className="official-gift-empty">{t("gifts.officialEmpty")}</div>}
{defaultGifts.length === 0 && <div className="official-gift-empty">{t("gifts.defaultEmpty")}</div>}
</div>
{selectedOfficial && <div className="official-gift-selected">
<OfficialLottiePreview sourceGiftID={selectedOfficial.source_gift_id} />
<div><strong>{selectedOfficial.title || t("gifts.officialUnnamed", { id: selectedOfficial.source_gift_id })}</strong><span className="mono">{selectedOfficial.source_gift_id}</span><small>{selectedOfficial.model_count} {t("collectibles.models")} · {selectedOfficial.pattern_count} {t("collectibles.patterns")} · {selectedOfficial.backdrop_count} {t("collectibles.backdrops")}</small><span className="official-gift-capabilities"><span className={selectedOfficial.can_upgrade ? "yes" : "no"}>{selectedOfficial.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span><span className={selectedOfficial.can_craft ? "craft" : "no"}>{selectedOfficial.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span></span></div>
{selectedDefault && <div className="official-gift-selected">
<DefaultLottiePreview id={selectedDefault.id} />
<div><strong>{selectedDefault.title}</strong><span className="mono"> {selectedDefault.stars} {selectedDefault.convert_stars}</span><small>{selectedDefault.model_count} {t("collectibles.models")} · {selectedDefault.pattern_count} {t("collectibles.patterns")} · {selectedDefault.backdrop_count} {t("collectibles.backdrops")}</small><span className="official-gift-capabilities"><span className={selectedDefault.upgradeable ? "yes" : "no"}>{selectedDefault.upgradeable ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span><span className={selectedDefault.craftable ? "craft" : "no"}>{selectedDefault.craftable ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span></span></div>
</div>}
{selectedOfficial?.can_upgrade && <>
<label className="gift-switch"><input type="checkbox" checked={includeCollectible} onChange={(e) => { setIncludeCollectible(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.includeCollectible")}</span></label>
{includeCollectible && <div className="gift-fields-grid">
<label><span>{t("collectibles.upgradeStars")}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("collectibles.supply")}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); setPreview(null); }} /></label>
<label><span>{t("collectibles.slug")}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); setPreview(null); }} /></label>
</div>}
</>}
</section> : <>
<div className="gift-import-note"><span>{t("gifts.importHint")}</span><div className="gift-format-chips" aria-label={t("gifts.formats")}><span>TGS</span><span>Lottie JSON</span></div></div>
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
@ -461,15 +403,15 @@ export function GiftsPage() {
<span className="gift-file-copy"><span className="gift-field-label">{t("gifts.animation")}</span><strong>{file ? file.name : t("gifts.filePrompt")}</strong><small>{file ? formatBytes(file.size) : t("gifts.fileHint")}</small></span>
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span>
</label>
<div className="gift-fields-grid">
<label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.convertStars")}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
</div>
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
</>}
<div className="gift-fields-grid">
<label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.convertStars")}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
</div>
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={reason} placeholder={t("gifts.reasonPlaceholder")} onChange={(e) => setReason(e.target.value)} /></label>
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
{importError && <Alert>{importError}</Alert>}
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("gifts.validationReady")}</strong><span>{t("gifts.validationHint")}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
</div>

View file

@ -327,7 +327,8 @@
display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: 14px; align-items: center;
padding: 12px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-soft);
}
.official-gift-selected .gift-animation-shell { width: 96px; height: 96px; }
.official-gift-selected .gift-animation-shell { width: 96px; height: 96px; min-height: 96px; overflow: hidden; border-radius: 12px; }
.official-gift-selected .gift-animation { width: 96px; height: 96px; }
.official-gift-selected > div:last-child { display: grid; gap: 5px; min-width: 0; }
.official-gift-selected small { color: var(--muted); }
.gift-import-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); line-height: 1.45; }

View file

@ -185,26 +185,26 @@ export type StarGiftRow = {
export type StarGiftListResponse = { Gifts: StarGiftRow[] };
export type OfficialStarGiftRow = {
source_gift_id: string;
// A built-in original demo gift available to import. Ids are small integers
// (1..N), so plain numbers are safe here — no snowflake precision concern.
export type DefaultGiftRow = {
id: number;
title: string;
stars: string;
convert_stars: string;
upgrade_stars: string;
availability_total: number;
stars: number;
convert_stars: number;
upgrade_stars: number;
upgradeable: boolean;
craftable: boolean;
limited: boolean;
sold_out: boolean;
availability: number;
require_premium: boolean;
model_count: number;
pattern_count: number;
backdrop_count: number;
crafted_model_count: number;
can_upgrade: boolean;
can_craft: boolean;
document_id: string;
animation_validated: boolean;
crafted_count: number;
};
export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] };
export type DefaultGiftListResponse = { gifts: DefaultGiftRow[] };
export type StarGiftCollectibleAttributeRow = {
id: string;

View file

@ -58,7 +58,6 @@ import (
"telesrv/internal/config"
"telesrv/internal/domain"
"telesrv/internal/mtprotoedge"
"telesrv/internal/officialgifts"
"telesrv/internal/otpdelivery"
otpsmtp "telesrv/internal/otpdelivery/smtp"
otpwebhook "telesrv/internal/otpdelivery/webhook"
@ -479,9 +478,8 @@ func run(logger *zap.Logger) error {
rateLimiter := redisstore.NewRateLimiter(rdb)
activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions"))
adminService := adminapp.NewService(adminapp.Dependencies{
Commands: adminStore,
Restrictions: adminStore,
OfficialGifts: officialgifts.New(cfg.OfficialGiftsDir),
Commands: adminStore,
Restrictions: adminStore,
})
go maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"),
cfg.UpdateEventRetention,