added default gifts import back
This commit is contained in:
parent
66e849d980
commit
c436c7c773
17 changed files with 765 additions and 40 deletions
|
|
@ -156,9 +156,12 @@ TELESRV_MAPBOX_TOKEN=
|
|||
TELESRV_MAPTILE_CACHE_DIR=data/maptiles
|
||||
|
||||
TELESRV_LANGPACK_SEED_DIR=data/langpack
|
||||
TELESRV_OFFICIAL_GIFTS_DIR=data/official-gifts
|
||||
# Built-in original demo Star Gifts (geometric Lottie we author ourselves — not
|
||||
# Telegram's copyrighted assets) are available to import from the admin console's
|
||||
# "Default gifts" tab; nothing is imported automatically and no config is needed.
|
||||
# An operator-supplied official Star Gift snapshot (see cmd/giftfetch) can also be
|
||||
# dropped into TELESRV_OFFICIAL_GIFTS_DIR for manual import — empty by default.
|
||||
# Star Gift expiry/auction worker. TON values are handled by the local ledger;
|
||||
# no wallet, Fragment or chain node endpoint is configured or contacted.
|
||||
TELESRV_STARGIFT_SWEEP_INTERVAL=15s
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("GET /api/gifts", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftsAPI)))
|
||||
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/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/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)))
|
||||
|
|
@ -75,6 +77,8 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/import-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportStarGiftAPI)))
|
||||
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/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/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)))
|
||||
|
|
@ -256,6 +260,19 @@ func (s *server) handleDefaultStarGiftAnimationAPI(w http.ResponseWriter, r *htt
|
|||
s.proxyAdminJSON(w, r, "/v1/default-gifts/"+id+"/animation", 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
s.proxyAdminJSON(w, r, "/v1/official-gifts", 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleOfficialStarGiftAnimationAPI(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")
|
||||
return
|
||||
}
|
||||
s.proxyAdminJSON(w, r, "/v1/official-gifts/"+id+"/animation", 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleStarGiftCollectibleAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
attributeID, attrErr := strconv.ParseInt(r.PathValue("attribute_id"), 10, 64)
|
||||
|
|
@ -857,6 +874,61 @@ func (s *server) handleImportAllDefaultStarGiftsAPI(w http.ResponseWriter, r *ht
|
|||
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"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportOfficialStarGiftAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body importOfficialStarGiftAPIRequest
|
||||
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")
|
||||
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,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type importAllOfficialStarGiftsAPIRequest 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
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.ImportAllOfficialStarGiftsRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-all-official-gifts"),
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import-all", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type publishStarGiftCollectiblesAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
|
|
|
|||
|
|
@ -121,6 +121,24 @@ func TestDefaultGiftImportActionDecodes(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestOfficialGiftActionDecimalStringDecodingPreservesInt64(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"
|
||||
}`))
|
||||
var got importOfficialStarGiftAPIRequest
|
||||
if err := decodeJSON(req, &got); err != nil {
|
||||
t.Fatalf("decode official gift action: %v", err)
|
||||
}
|
||||
if got.GiftID != maxInt64 || got.Stars != maxInt64 || got.ConvertStars != maxInt64 || got.UpgradeStars != maxInt64 {
|
||||
t.Fatalf("decoded official gift action = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetStarGiftEnabledBFFForwardsExactInt64(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
var got admin.SetStarGiftEnabledRequest
|
||||
|
|
|
|||
9
cmd/telesrv-admin/web/dist/assets/index-6aaLOsAb.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-6aaLOsAb.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
2
cmd/telesrv-admin/web/dist/index.html
vendored
2
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -5,7 +5,7 @@
|
|||
<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-CO25dkAS.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-6aaLOsAb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D2rrhA4q.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
MessageDetail,
|
||||
MessageListResponse,
|
||||
DefaultGiftListResponse,
|
||||
OfficialStarGiftListResponse,
|
||||
StarGiftCollectiblePreview,
|
||||
StarGiftListResponse,
|
||||
StickerSetListResponse
|
||||
|
|
@ -75,11 +76,14 @@ export const api = {
|
|||
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
|
||||
defaultGifts: () => request<DefaultGiftListResponse>("/api/default-gifts"),
|
||||
defaultGiftAnimation: (id: number) => request<Record<string, unknown>>(`/api/default-gifts/${id}/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`),
|
||||
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 }),
|
||||
importDefaultGift: (payload: Record<string, unknown>) => request<CommandResult>("/api/actions/import-default-gift", { method: "POST", body: JSON.stringify(payload) }),
|
||||
importOfficialGift: (payload: Record<string, unknown>) => request<CommandResult>("/api/actions/import-official-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",
|
||||
|
|
|
|||
|
|
@ -258,6 +258,21 @@ const translations: Record<string, string> = {
|
|||
"gifts.defaultSelect": "Choose a default gift",
|
||||
"gifts.defaultRequired": "Choose a default gift first",
|
||||
"gifts.defaultEmpty": "No default gifts are available.",
|
||||
"gifts.importAllOfficial": "Import all official gifts",
|
||||
"gifts.officialSource": "Official snapshot",
|
||||
"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.officialEmpty": "No official gifts match this category and search.",
|
||||
"gifts.includeCollectible": "Import the complete collectible pool, including crafted models",
|
||||
"gifts.limited": "Limited · {total}",
|
||||
"gifts.premium": "Premium only",
|
||||
"gifts.searchPlaceholder": "Search gift ID, title or format",
|
||||
|
|
|
|||
|
|
@ -7,15 +7,20 @@ 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, DefaultGiftRow, StarGiftRow } from "../types";
|
||||
import type { CommandResult, DefaultGiftRow, OfficialStarGiftRow, StarGiftRow } from "../types";
|
||||
import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
|
||||
|
||||
type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
|
||||
type GiftPageSize = 10 | 20 | 50 | 100 | "all";
|
||||
|
||||
function defaultGiftAttributeCount(gift: DefaultGiftRow) {
|
||||
return gift.model_count + gift.pattern_count + gift.backdrop_count;
|
||||
}
|
||||
|
||||
function officialGiftAttributeCount(gift: OfficialStarGiftRow) {
|
||||
return gift.model_count + gift.pattern_count + gift.backdrop_count;
|
||||
}
|
||||
|
||||
function formatBytes(value: number | string) {
|
||||
const bytes = Number(value);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
|
|
@ -80,6 +85,20 @@ function DefaultLottiePreview({ id }: { id: number }) {
|
|||
return <div className="gift-animation-shell"><div className="gift-animation" ref={host} /></div>;
|
||||
}
|
||||
|
||||
function OfficialLottiePreview({ sourceGiftID }: { sourceGiftID: string }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let player: ReturnType<typeof lottie.loadAnimation> | null = null;
|
||||
api.officialGiftAnimation(sourceGiftID).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]);
|
||||
return <div className="gift-animation-shell"><div className="gift-animation" ref={host} /></div>;
|
||||
}
|
||||
|
||||
export function GiftsPage() {
|
||||
const { t } = useI18n();
|
||||
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
|
||||
|
|
@ -87,9 +106,17 @@ 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<"default" | "file">("default");
|
||||
const [importSource, setImportSource] = useState<"default" | "official" | "file">("default");
|
||||
const [defaultGifts, setDefaultGifts] = useState<DefaultGiftRow[]>([]);
|
||||
const [selectedDefaultID, setSelectedDefaultID] = useState(0);
|
||||
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 [giftID, setGiftID] = useState("0");
|
||||
const [title, setTitle] = useState("");
|
||||
const [stars, setStars] = useState("50");
|
||||
|
|
@ -124,7 +151,29 @@ export function GiftsPage() {
|
|||
api.defaultGifts().then((value) => setDefaultGifts(value.gifts ?? [])).catch((err) => setImportError(errorMessage(err)));
|
||||
}, [importOpen, importSource, defaultGifts.length]);
|
||||
|
||||
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]);
|
||||
|
||||
const selectedDefault = useMemo(() => defaultGifts.find((gift) => gift.id === selectedDefaultID) ?? null, [defaultGifts, selectedDefaultID]);
|
||||
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 visibleGifts = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
|
|
@ -228,10 +277,37 @@ export function GiftsPage() {
|
|||
return { command_id: commandID, reason: reason.trim(), confirm, id: selectedDefaultID };
|
||||
}
|
||||
|
||||
function officialPayload(confirm: boolean, commandID = "") {
|
||||
if (!sourceGiftID) throw new Error(t("gifts.officialRequired"));
|
||||
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);
|
||||
}
|
||||
|
||||
async function validateImport() {
|
||||
setBusy(true); setImportError(""); setPreview(null);
|
||||
try {
|
||||
setPreview(importSource === "default" ? await api.importDefaultGift(defaultPayload(false)) : await api.importGift(uploadForm(false)));
|
||||
const result = importSource === "default" ? await api.importDefaultGift(defaultPayload(false))
|
||||
: importSource === "official" ? await api.importOfficialGift(officialPayload(false))
|
||||
: await api.importGift(uploadForm(false));
|
||||
setPreview(result);
|
||||
} catch (err) {
|
||||
setImportError(errorMessage(err));
|
||||
} finally { setBusy(false); }
|
||||
|
|
@ -242,8 +318,9 @@ export function GiftsPage() {
|
|||
setBusy(true); setImportError("");
|
||||
try {
|
||||
if (importSource === "default") await api.importDefaultGift(defaultPayload(true, preview.command_id));
|
||||
else if (importSource === "official") await api.importOfficialGift(officialPayload(true, preview.command_id));
|
||||
else await api.importGift(uploadForm(true, preview.command_id));
|
||||
setPreview(null); setFile(null); setGiftID("0"); setTitle(""); setSelectedDefaultID(0);
|
||||
setPreview(null); setFile(null); setGiftID("0"); setTitle(""); setSelectedDefaultID(0); setSourceGiftID("");
|
||||
await load();
|
||||
setImportOpen(false);
|
||||
} catch (err) {
|
||||
|
|
@ -254,17 +331,20 @@ export function GiftsPage() {
|
|||
function startImport() {
|
||||
setGiftID("0"); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
|
||||
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError("");
|
||||
setImportSource("default"); setSelectedDefaultID(0); setImportOpen(true);
|
||||
setImportSource("default"); setSelectedDefaultID(0);
|
||||
setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); 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("file"); setSelectedDefaultID(0); setImportOpen(true);
|
||||
setImportSource("file"); setSelectedDefaultID(0); setSourceGiftID(""); setImportOpen(true);
|
||||
}
|
||||
|
||||
const step1Done = importSource === "default" ? selectedDefaultID > 0 : Boolean(file);
|
||||
const step1Done = importSource === "default" ? selectedDefaultID > 0
|
||||
: importSource === "official" ? Boolean(sourceGiftID)
|
||||
: Boolean(file);
|
||||
|
||||
return (
|
||||
<PageFrame title={t("gifts.pageTitle")} eyebrow={t("gifts.eyebrow")} actions={<>
|
||||
|
|
@ -355,6 +435,7 @@ export function GiftsPage() {
|
|||
</div>
|
||||
{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 === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{t("gifts.officialSource")}</button>
|
||||
<button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{t("gifts.fileSource")}</button>
|
||||
</div>}
|
||||
{importSource === "default" && giftID === "0" ? <section className="official-gift-picker">
|
||||
|
|
@ -395,6 +476,70 @@ export function GiftsPage() {
|
|||
<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>}
|
||||
</section> : importSource === "official" && giftID === "0" ? <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 className="official-gift-bulk-import">
|
||||
<ActionButton
|
||||
label={t("gifts.importAllOfficial")}
|
||||
path="/api/actions/import-all-official-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 isSelected = gift.source_gift_id === sourceGiftID;
|
||||
return <button key={gift.source_gift_id} className={`official-gift-option ${isSelected ? "selected" : ""}`}
|
||||
type="button" role="option" aria-selected={isSelected} onClick={() => chooseOfficial(gift)}>
|
||||
<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>
|
||||
</span>
|
||||
<span className="official-gift-option-meta">
|
||||
<span>⭐ {gift.stars}</span>
|
||||
<span>{t("gifts.officialAttributes", { count: officialGiftAttributeCount(gift) })}</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>
|
||||
</button>;
|
||||
})}
|
||||
{visibleOfficial.length === 0 && <div className="official-gift-empty">{t("gifts.officialEmpty")}</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>
|
||||
</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>}
|
||||
</>}
|
||||
<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>
|
||||
</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" : ""}`}>
|
||||
|
|
|
|||
|
|
@ -206,6 +206,29 @@ export type DefaultGiftRow = {
|
|||
|
||||
export type DefaultGiftListResponse = { gifts: DefaultGiftRow[] };
|
||||
|
||||
// A verified official Star Gift snapshot entry (see cmd/giftfetch). Numeric
|
||||
// ids/counters that can approach int64 range are decimal strings.
|
||||
export type OfficialStarGiftRow = {
|
||||
source_gift_id: string;
|
||||
title: string;
|
||||
stars: string;
|
||||
convert_stars: string;
|
||||
upgrade_stars: string;
|
||||
availability_total: number;
|
||||
limited: boolean;
|
||||
sold_out: 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;
|
||||
};
|
||||
|
||||
export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] };
|
||||
|
||||
export type StarGiftCollectibleAttributeRow = {
|
||||
id: string;
|
||||
kind: "model" | "pattern" | "backdrop";
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ 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"
|
||||
|
|
@ -478,8 +479,9 @@ 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,
|
||||
Commands: adminStore,
|
||||
Restrictions: adminStore,
|
||||
OfficialGifts: officialgifts.New(cfg.OfficialGiftsDir),
|
||||
})
|
||||
go maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"),
|
||||
cfg.UpdateEventRetention,
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ This document describes every setting loaded by `internal/config`. Defaults and
|
|||
| `TELESRV_REDIS_PASSWORD` | secret string / empty | Redis password. |
|
||||
| `TELESRV_REDIS_DB` | int / `0` | Redis logical database number. |
|
||||
| `TELESRV_LANGPACK_SEED_DIR` | path / `data/langpack` | TDesktop `.strings` language-pack seed directory. |
|
||||
| `TELESRV_OFFICIAL_GIFTS_DIR` | path / `data/official-gifts` | Read-only snapshot generated by `cmd/giftfetch`, used for verified explicit imports in the admin UI. |
|
||||
| `TELESRV_BLOB_DIR` | path / `data/blobs` | Local development blob-backend root for media bytes. |
|
||||
| `TELESRV_STICKER_SEED_DIR` | path / `data/sticker-seed` | Sticker/reaction seed packages imported into documents, sticker sets, and blobs. |
|
||||
| `TELESRV_STICKER_SEED_MAX_SETS` | int / `300` | Maximum regular sticker sets imported at startup; `<=0` means unlimited. |
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@
|
|||
| `TELESRV_REDIS_PASSWORD` | secret string / 空 | Redis 密码。 |
|
||||
| `TELESRV_REDIS_DB` | int / `0` | Redis 逻辑库编号。 |
|
||||
| `TELESRV_LANGPACK_SEED_DIR` | path / `data/langpack` | TDesktop `.strings` 语言包 seed 目录。 |
|
||||
| `TELESRV_OFFICIAL_GIFTS_DIR` | path / `data/official-gifts` | `cmd/giftfetch` 生成的只读官方礼物快照;供管理后台选择、验哈希并显式导入。 |
|
||||
| `TELESRV_BLOB_DIR` | path / `data/blobs` | 本地开发 blob backend 的媒体字节根目录。 |
|
||||
| `TELESRV_STICKER_SEED_DIR` | path / `data/sticker-seed` | 导入 documents、sticker sets、blob 的贴纸/reaction seed 目录。 |
|
||||
| `TELESRV_STICKER_SEED_MAX_SETS` | int / `300` | 启动时导入的常规贴纸集上限;`<=0` 表示不限。 |
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
|
|
@ -18,31 +19,34 @@ import (
|
|||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/seed/giftdemo"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionSetAccountFrozen = "account.set_frozen"
|
||||
ActionGrantPremium = "account.grant_premium"
|
||||
ActionGrantStars = "account.grant_stars"
|
||||
ActionSetVerified = "account.set_verified"
|
||||
ActionSetChannelVerified = "channel.set_verified"
|
||||
ActionRevokeSessions = "account.revoke_sessions"
|
||||
ActionDeletePrivateMessages = "messages.delete_private_messages"
|
||||
ActionDeletePrivateHistory = "messages.delete_private_history"
|
||||
ActionImportStarGift = "gifts.import"
|
||||
ActionImportDefaultStarGift = "gifts.default.import"
|
||||
ActionImportAllDefaultStarGifts = "gifts.default.import_all"
|
||||
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"
|
||||
ActionCreateStickerSet = "stickers.create"
|
||||
ActionAddStickerToSet = "stickers.add_sticker"
|
||||
ActionRemoveStickerFromSet = "stickers.remove_sticker"
|
||||
ActionSetAccountFrozen = "account.set_frozen"
|
||||
ActionGrantPremium = "account.grant_premium"
|
||||
ActionGrantStars = "account.grant_stars"
|
||||
ActionSetVerified = "account.set_verified"
|
||||
ActionSetChannelVerified = "channel.set_verified"
|
||||
ActionRevokeSessions = "account.revoke_sessions"
|
||||
ActionDeletePrivateMessages = "messages.delete_private_messages"
|
||||
ActionDeletePrivateHistory = "messages.delete_private_history"
|
||||
ActionImportStarGift = "gifts.import"
|
||||
ActionImportOfficialStarGift = "gifts.official.import"
|
||||
ActionImportAllOfficialStarGifts = "gifts.official.import_all"
|
||||
ActionImportDefaultStarGift = "gifts.default.import"
|
||||
ActionImportAllDefaultStarGifts = "gifts.default.import_all"
|
||||
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"
|
||||
ActionCreateStickerSet = "stickers.create"
|
||||
ActionAddStickerToSet = "stickers.add_sticker"
|
||||
ActionRemoveStickerFromSet = "stickers.remove_sticker"
|
||||
|
||||
maxCommandIDLength = 128
|
||||
maxActorLength = 128
|
||||
|
|
@ -109,6 +113,7 @@ type MessagesService interface {
|
|||
|
||||
type GiftsService interface {
|
||||
PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error)
|
||||
PrepareOfficialAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error)
|
||||
Catalog(ctx context.Context) ([]domain.StarGift, error)
|
||||
CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error)
|
||||
CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error)
|
||||
|
|
@ -120,6 +125,14 @@ type GiftsService interface {
|
|||
CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
|
||||
}
|
||||
|
||||
// OfficialGiftsSource reads a verified official Star Gift snapshot from disk
|
||||
// (see cmd/giftfetch) for explicit, operator-triggered import. Nothing here
|
||||
// is imported automatically — the snapshot directory can simply be empty.
|
||||
type OfficialGiftsSource interface {
|
||||
List(ctx context.Context) ([]officialgifts.GiftSummary, error)
|
||||
Bundle(ctx context.Context, giftID int64, includeCollectible bool) (officialgifts.Bundle, error)
|
||||
}
|
||||
|
||||
// AvatarResolver is the same shape as internal/web's ProfilePhotoResolver, kept as its
|
||||
// own local interface (rather than importing internal/web) since only this narrow slice
|
||||
// is needed to serve an account's current profile photo in the admin console.
|
||||
|
|
@ -158,6 +171,7 @@ type Dependencies struct {
|
|||
ChannelNotifier ChannelNotifier
|
||||
Messages MessagesService
|
||||
Gifts GiftsService
|
||||
OfficialGifts OfficialGiftsSource
|
||||
Photos AvatarResolver
|
||||
StickerSets StickerSetsService
|
||||
Now func() time.Time
|
||||
|
|
@ -176,6 +190,7 @@ type Service struct {
|
|||
channelNotifier ChannelNotifier
|
||||
messages MessagesService
|
||||
gifts GiftsService
|
||||
officialGifts OfficialGiftsSource
|
||||
photos AvatarResolver
|
||||
stickerSets StickerSetsService
|
||||
now func() time.Time
|
||||
|
|
@ -223,6 +238,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
|||
if deps.Gifts != nil {
|
||||
s.gifts = deps.Gifts
|
||||
}
|
||||
if deps.OfficialGifts != nil {
|
||||
s.officialGifts = deps.OfficialGifts
|
||||
}
|
||||
if deps.Photos != nil {
|
||||
s.photos = deps.Photos
|
||||
}
|
||||
|
|
@ -280,6 +298,27 @@ type ImportAllDefaultStarGiftsRequest struct {
|
|||
CommandMeta
|
||||
}
|
||||
|
||||
type ImportOfficialStarGiftRequest struct {
|
||||
CommandMeta
|
||||
SourceGiftID string `json:"source_gift_id"`
|
||||
GiftID int64 `json:"gift_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Stars int64 `json:"stars"`
|
||||
ConvertStars int64 `json:"convert_stars"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IncludeCollectible bool `json:"include_collectible"`
|
||||
UpgradeStars int64 `json:"upgrade_stars,omitempty"`
|
||||
SupplyTotal int `json:"supply_total,omitempty"`
|
||||
SlugPrefix string `json:"slug_prefix,omitempty"`
|
||||
ManifestSHA256 string `json:"manifest_sha256,omitempty"`
|
||||
AssetSHA256 []string `json:"asset_sha256,omitempty"`
|
||||
}
|
||||
|
||||
type ImportAllOfficialStarGiftsRequest struct {
|
||||
CommandMeta
|
||||
}
|
||||
|
||||
type SetStarGiftEnabledRequest struct {
|
||||
CommandMeta
|
||||
GiftID int64 `json:"gift_id"`
|
||||
|
|
@ -936,6 +975,16 @@ func (s *Service) DefaultStarGifts() []giftdemo.GiftInfo {
|
|||
return giftdemo.List()
|
||||
}
|
||||
|
||||
// OfficialStarGifts lists the verified official snapshot on disk (see
|
||||
// cmd/giftfetch), if one has been placed there. Nothing here is imported
|
||||
// automatically — this is purely for the admin console's picker.
|
||||
func (s *Service) OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSummary, error) {
|
||||
if s == nil || s.officialGifts == nil {
|
||||
return nil, officialgifts.ErrUnavailable
|
||||
}
|
||||
return s.officialGifts.List(ctx)
|
||||
}
|
||||
|
||||
const maxAccountAvatarBytes = 4 << 20
|
||||
|
||||
// AccountAvatar returns an account's current profile photo bytes and detected
|
||||
|
|
@ -1045,6 +1094,28 @@ func (s *Service) DefaultStarGiftAnimation(_ context.Context, id int) ([]byte, b
|
|||
return giftdemo.BaseAnimationJSON(s.gifts, id)
|
||||
}
|
||||
|
||||
func (s *Service) OfficialStarGiftAnimation(ctx context.Context, sourceGiftID string) ([]byte, bool, error) {
|
||||
if s == nil || s.officialGifts == nil || s.gifts == nil {
|
||||
return nil, false, officialgifts.ErrUnavailable
|
||||
}
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(sourceGiftID), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return nil, false, officialgifts.ErrNotFound
|
||||
}
|
||||
bundle, err := s.officialGifts.Bundle(ctx, id, false)
|
||||
if errors.Is(err, officialgifts.ErrNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
animation, err := s.gifts.PrepareOfficialAnimation(bundle.BaseDocument.FileName, bundle.BaseDocument.Data)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return animation.JSON, true, nil
|
||||
}
|
||||
|
||||
// ImportDefaultStarGift imports one built-in original demo gift (complete with
|
||||
// its collectible pool when upgradeable). Idempotent: a gift whose title is
|
||||
// already in the catalog is skipped rather than duplicated.
|
||||
|
|
@ -1137,6 +1208,283 @@ func (s *Service) ImportAllDefaultStarGifts(ctx context.Context, req ImportAllDe
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Service) ImportOfficialStarGift(ctx context.Context, req ImportOfficialStarGiftRequest) (CommandResult, error) {
|
||||
if s == nil || s.gifts == nil || s.officialGifts == nil {
|
||||
return CommandResult{}, fmt.Errorf("official star gift importer is not configured")
|
||||
}
|
||||
sourceID, err := strconv.ParseInt(strings.TrimSpace(req.SourceGiftID), 10, 64)
|
||||
if err != nil || sourceID <= 0 || req.GiftID < 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
|
||||
return CommandResult{}, domain.ErrStarGiftInvalid
|
||||
}
|
||||
bundle, err := s.officialGifts.Bundle(ctx, sourceID, req.IncludeCollectible)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if req.Title = strings.TrimSpace(req.Title); req.Title == "" {
|
||||
req.Title = strings.TrimSpace(bundle.Gift.Title)
|
||||
if req.Title == "" {
|
||||
req.Title = "Official gift " + req.SourceGiftID
|
||||
}
|
||||
}
|
||||
if req.Stars <= 0 {
|
||||
req.Stars = bundle.Gift.Stars
|
||||
}
|
||||
if req.ConvertStars < 0 || req.ConvertStars > req.Stars || len([]rune(req.Title)) > domain.MaxStarGiftTitleRunes {
|
||||
return CommandResult{}, domain.ErrStarGiftInvalid
|
||||
}
|
||||
if req.UpgradeStars <= 0 {
|
||||
req.UpgradeStars = bundle.Gift.UpgradeStars
|
||||
}
|
||||
if req.SupplyTotal <= 0 {
|
||||
req.SupplyTotal = bundle.Gift.AvailabilityTotal
|
||||
}
|
||||
if req.SlugPrefix = strings.ToLower(strings.TrimSpace(req.SlugPrefix)); req.SlugPrefix == "" {
|
||||
req.SlugPrefix = "official-" + req.SourceGiftID
|
||||
}
|
||||
|
||||
baseAnimation, err := s.gifts.PrepareOfficialAnimation(bundle.BaseDocument.FileName, bundle.BaseDocument.Data)
|
||||
if err != nil {
|
||||
return CommandResult{}, fmt.Errorf("prepare official gift animation: %w", err)
|
||||
}
|
||||
assetHashes := []string{bundle.BaseDocument.SHA256}
|
||||
rarityCounts := map[string]int{}
|
||||
var background *domain.StarGiftBackground
|
||||
if bundle.Gift.Background != nil {
|
||||
background = &domain.StarGiftBackground{
|
||||
CenterColor: bundle.Gift.Background.CenterColor,
|
||||
EdgeColor: bundle.Gift.Background.EdgeColor,
|
||||
TextColor: bundle.Gift.Background.TextColor,
|
||||
}
|
||||
}
|
||||
var collectible *domain.StarGiftCollectibleWrite
|
||||
if req.IncludeCollectible {
|
||||
if bundle.Collectible == nil {
|
||||
return CommandResult{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
modelNames := map[string]int{}
|
||||
models := make([]domain.StarGiftCollectibleAttribute, 0, len(bundle.Collectible.Models))
|
||||
for index, value := range bundle.Collectible.Models {
|
||||
animation, err := s.gifts.PrepareOfficialAnimation(value.Document.FileName, value.Document.Data)
|
||||
if err != nil {
|
||||
return CommandResult{}, fmt.Errorf("prepare official model %q: %w", value.Name, err)
|
||||
}
|
||||
rarityKind, permille, err := officialRarity(value.Rarity)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
models = append(models, domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleModel,
|
||||
Name: dedupeCollectibleAttributeName(modelNames, strings.TrimSpace(value.Name)), RarityKind: rarityKind, RarityPermille: permille,
|
||||
Crafted: value.Crafted, OfficialDocumentID: value.DocumentID, SortOrder: index, Animation: &animation})
|
||||
assetHashes = append(assetHashes, value.Document.SHA256)
|
||||
rarityCounts[string(rarityKind)]++
|
||||
}
|
||||
patternNames := map[string]int{}
|
||||
patterns := make([]domain.StarGiftCollectibleAttribute, 0, len(bundle.Collectible.Patterns))
|
||||
for index, value := range bundle.Collectible.Patterns {
|
||||
animation, err := s.gifts.PrepareOfficialAnimation(value.Document.FileName, value.Document.Data)
|
||||
if err != nil {
|
||||
return CommandResult{}, fmt.Errorf("prepare official pattern %q: %w", value.Name, err)
|
||||
}
|
||||
rarityKind, permille, err := officialRarity(value.Rarity)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
patterns = append(patterns, domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectiblePattern,
|
||||
Name: dedupeCollectibleAttributeName(patternNames, strings.TrimSpace(value.Name)), RarityKind: rarityKind, RarityPermille: permille,
|
||||
OfficialDocumentID: value.DocumentID, SortOrder: index, Animation: &animation})
|
||||
assetHashes = append(assetHashes, value.Document.SHA256)
|
||||
rarityCounts[string(rarityKind)]++
|
||||
}
|
||||
backdropNames := map[string]int{}
|
||||
backdrops := make([]domain.StarGiftCollectibleAttribute, 0, len(bundle.Collectible.Backdrops))
|
||||
for index, value := range bundle.Collectible.Backdrops {
|
||||
rarityKind, permille, err := officialRarity(value.Rarity)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
backdrops = append(backdrops, domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleBackdrop,
|
||||
Name: dedupeCollectibleAttributeName(backdropNames, strings.TrimSpace(value.Name)), BackdropID: value.BackdropID, CenterColor: value.CenterColor,
|
||||
EdgeColor: value.EdgeColor, PatternColor: value.PatternColor, TextColor: value.TextColor,
|
||||
RarityKind: rarityKind, RarityPermille: permille, SortOrder: index})
|
||||
rarityCounts[string(rarityKind)]++
|
||||
}
|
||||
collectible = &domain.StarGiftCollectibleWrite{GiftID: req.GiftID, UpgradeStars: req.UpgradeStars,
|
||||
SupplyTotal: req.SupplyTotal, SlugPrefix: req.SlugPrefix, Models: models, Patterns: patterns, Backdrops: backdrops,
|
||||
Actor: req.Actor, CommandID: req.CommandID, OfficialGiftID: sourceID,
|
||||
SourceManifestSHA256: append([]byte(nil), bundle.ManifestSHA256...)}
|
||||
validation := *collectible
|
||||
if validation.GiftID == 0 {
|
||||
validation.GiftID = 1
|
||||
}
|
||||
if err := domain.ValidateStarGiftCollectibleDraft(validation); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
}
|
||||
req.ManifestSHA256 = hex.EncodeToString(bundle.ManifestSHA256)
|
||||
sort.Strings(assetHashes)
|
||||
req.AssetSHA256 = assetHashes
|
||||
write := domain.StarGiftCatalogBundleWrite{Catalog: domain.StarGiftCatalogWrite{
|
||||
GiftID: req.GiftID, Title: req.Title, Stars: req.Stars, ConvertStars: req.ConvertStars,
|
||||
Enabled: req.Enabled, SortOrder: req.SortOrder, Animation: baseAnimation, Actor: req.Actor, CommandID: req.CommandID,
|
||||
OfficialGiftID: sourceID, SourceManifestSHA256: append([]byte(nil), bundle.ManifestSHA256...),
|
||||
OfficialSourceJSON: append([]byte(nil), bundle.SourceJSON...),
|
||||
// The snapshot describes Telegram's global market, not this deployment's
|
||||
// inventory. Keep the complete source JSON as provenance, while publishing
|
||||
// regular official imports as a fresh, locally purchasable catalog entry.
|
||||
// Local resale counters and sale dates are derived by lifecycle writes.
|
||||
// Auction gifts are the one exception: star_gift_catalog_revision_auction_check
|
||||
// requires limited=true whenever auction=true, so it can't be forced false here.
|
||||
Limited: bundle.Gift.Auction, SoldOut: false, Birthday: bundle.Gift.Birthday,
|
||||
RequirePremium: bundle.Gift.RequirePremium, LimitedPerUser: bundle.Gift.LimitedPerUser,
|
||||
PeerColorAvailable: bundle.Gift.PeerColorAvailable, Auction: bundle.Gift.Auction,
|
||||
AvailabilityRemains: 0, AvailabilityTotal: 0,
|
||||
AvailabilityResale: 0, FirstSaleDate: 0,
|
||||
LastSaleDate: 0, ResellMinStars: 0,
|
||||
PerUserTotal: bundle.Gift.PerUserTotal, LockedUntilDate: bundle.Gift.LockedUntilDate,
|
||||
AuctionSlug: bundle.Gift.AuctionSlug, GiftsPerRound: bundle.Gift.GiftsPerRound,
|
||||
AuctionStartDate: bundle.Gift.AuctionStartDate, UpgradeVariants: bundle.Gift.UpgradeVariants,
|
||||
Background: background,
|
||||
}, Collectible: collectible}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionImportOfficialStarGift, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"source_gift_id": req.SourceGiftID, "gift_id": strconv.FormatInt(req.GiftID, 10),
|
||||
"manifest_sha256": req.ManifestSHA256, "title": req.Title, "stars": strconv.FormatInt(req.Stars, 10),
|
||||
"convert_stars": strconv.FormatInt(req.ConvertStars, 10), "include_collectible": req.IncludeCollectible,
|
||||
"verified_asset_count": len(assetHashes), "rarity_counts": rarityCounts,
|
||||
"official_limited": bundle.Gift.Limited, "official_sold_out": bundle.Gift.SoldOut,
|
||||
"official_auction": bundle.Gift.Auction, "official_birthday": bundle.Gift.Birthday,
|
||||
"official_require_premium": bundle.Gift.RequirePremium,
|
||||
"official_availability_remains": bundle.Gift.AvailabilityRemains,
|
||||
"official_availability_total": bundle.Gift.AvailabilityTotal,
|
||||
"official_availability_resale": bundle.Gift.AvailabilityResale,
|
||||
}
|
||||
if bundle.Collectible != nil {
|
||||
details["models"] = len(bundle.Collectible.Models)
|
||||
details["patterns"] = len(bundle.Collectible.Patterns)
|
||||
details["backdrops"] = len(bundle.Collectible.Backdrops)
|
||||
crafted := 0
|
||||
for _, model := range bundle.Collectible.Models {
|
||||
if model.Crafted {
|
||||
crafted++
|
||||
}
|
||||
}
|
||||
details["crafted_models"] = crafted
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "official star gift bundle validated", Details: details}, nil
|
||||
}
|
||||
result, err := s.gifts.CreateCatalogBundle(ctx, write)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["gift_id"] = strconv.FormatInt(result.Catalog.Gift.ID, 10)
|
||||
details["catalog_revision_id"] = strconv.FormatInt(result.Catalog.Gift.RevisionID, 10)
|
||||
if result.Collectible != nil {
|
||||
details["collectible_revision_id"] = strconv.FormatInt(result.Collectible.ID, 10)
|
||||
details["collectible_revision"] = result.Collectible.Revision
|
||||
}
|
||||
return CommandResult{Message: "official star gift bundle imported", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ImportAllOfficialStarGifts imports every gift in the official snapshot, one
|
||||
// ImportOfficialStarGift call each, all inside the single admin command this
|
||||
// request itself represents (so it gets the same dry-run/confirm handling
|
||||
// and audit trail as every other admin action; DryRun previews only report
|
||||
// the candidate count and do not touch the catalog). Each per-gift call gets
|
||||
// a CommandID stable across separate confirmed runs of this action
|
||||
// (bulk-official-gift-<source id>), so re-running the batch later is safe:
|
||||
// gifts already imported by a prior run replay their cached result
|
||||
// (CommandResult.AlreadyExecuted) instead of writing a duplicate catalog
|
||||
// entry — the catalog table has no unique constraint on official_gift_id, so
|
||||
// without this the same gift could otherwise be imported twice.
|
||||
func (s *Service) ImportAllOfficialStarGifts(ctx context.Context, req ImportAllOfficialStarGiftsRequest) (CommandResult, error) {
|
||||
if s == nil || s.gifts == nil || s.officialGifts == nil {
|
||||
return CommandResult{}, fmt.Errorf("official star gift importer is not configured")
|
||||
}
|
||||
items, err := s.officialGifts.List(ctx)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionImportAllOfficialStarGifts, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"total": len(items)}
|
||||
if req.DryRun {
|
||||
details["note"] = "dry run does not import; confirming imports all, skipping gifts already imported by a prior run"
|
||||
return CommandResult{
|
||||
Message: fmt.Sprintf("%d official gifts available to import", len(items)),
|
||||
Details: details,
|
||||
}, nil
|
||||
}
|
||||
imported, skipped, failed := 0, 0, 0
|
||||
perGift := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
perReq := ImportOfficialStarGiftRequest{
|
||||
CommandMeta: CommandMeta{
|
||||
CommandID: fmt.Sprintf("bulk-official-gift-%d", item.ID),
|
||||
Actor: req.Actor,
|
||||
Reason: req.Reason,
|
||||
},
|
||||
SourceGiftID: strconv.FormatInt(item.ID, 10),
|
||||
// Every attribute the snapshot has for an upgradeable gift is
|
||||
// worth importing; CanUpgrade() is exactly the precondition
|
||||
// ImportOfficialStarGift enforces for IncludeCollectible.
|
||||
IncludeCollectible: item.CanUpgrade(),
|
||||
}
|
||||
result, opErr := s.ImportOfficialStarGift(ctx, perReq)
|
||||
entry := map[string]any{"source_gift_id": perReq.SourceGiftID, "status": result.Status}
|
||||
if opErr != nil {
|
||||
failed++
|
||||
entry["error"] = opErr.Error()
|
||||
} else if result.AlreadyExecuted {
|
||||
skipped++
|
||||
} else {
|
||||
imported++
|
||||
entry["gift_id"] = result.Details["gift_id"]
|
||||
}
|
||||
perGift = append(perGift, entry)
|
||||
}
|
||||
details["imported"] = imported
|
||||
details["skipped"] = skipped
|
||||
details["failed"] = failed
|
||||
details["gifts"] = perGift
|
||||
return CommandResult{
|
||||
Message: fmt.Sprintf("imported %d, skipped %d, failed %d of %d official gifts", imported, skipped, failed, len(items)),
|
||||
Details: details,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// dedupeCollectibleAttributeName disambiguates attribute names within one kind (models,
|
||||
// patterns, or backdrops each need distinct names per collectible_revision — see the
|
||||
// star_gift_collectible_{model,pattern,backdrop}_name_uniq constraints). Official Telegram
|
||||
// data legitimately reuses a display name across two distinct attributes of the same kind
|
||||
// (seen in practice: two different "Strawberry" models on one gift), which the DB would
|
||||
// otherwise reject outright on insert.
|
||||
func dedupeCollectibleAttributeName(seen map[string]int, name string) string {
|
||||
key := strings.ToLower(name)
|
||||
seen[key]++
|
||||
if seen[key] == 1 {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("%s (%d)", name, seen[key])
|
||||
}
|
||||
|
||||
func officialRarity(value officialgifts.Rarity) (domain.StarGiftAttributeRarityKind, int, error) {
|
||||
kind := domain.StarGiftAttributeRarityKind(strings.ToLower(strings.TrimSpace(value.Kind)))
|
||||
if !kind.Valid() {
|
||||
return "", 0, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
if kind == domain.StarGiftRarityPermille {
|
||||
if value.Permille == nil || *value.Permille <= 0 || *value.Permille > 1000 {
|
||||
return "", 0, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return kind, *value.Permille, nil
|
||||
}
|
||||
if value.Permille != nil {
|
||||
return "", 0, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return kind, 0, nil
|
||||
}
|
||||
|
||||
func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishStarGiftCollectiblesRequest) (CommandResult, error) {
|
||||
if s == nil || s.gifts == nil {
|
||||
return CommandResult{}, fmt.Errorf("star gift service is not configured")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -15,6 +16,7 @@ import (
|
|||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/seed/giftdemo"
|
||||
)
|
||||
|
||||
|
|
@ -38,6 +40,10 @@ type Service interface {
|
|||
ImportAllDefaultStarGifts(ctx context.Context, req admin.ImportAllDefaultStarGiftsRequest) (admin.CommandResult, error)
|
||||
DefaultStarGifts() []giftdemo.GiftInfo
|
||||
DefaultStarGiftAnimation(ctx context.Context, id int) ([]byte, bool, error)
|
||||
ImportOfficialStarGift(ctx context.Context, req admin.ImportOfficialStarGiftRequest) (admin.CommandResult, error)
|
||||
ImportAllOfficialStarGifts(ctx context.Context, req admin.ImportAllOfficialStarGiftsRequest) (admin.CommandResult, error)
|
||||
OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSummary, error)
|
||||
OfficialStarGiftAnimation(ctx context.Context, sourceGiftID string) ([]byte, bool, error)
|
||||
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)
|
||||
|
|
@ -114,6 +120,10 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("GET /v1/default-gifts/{id}/animation", s.authenticated(s.handleDefaultStarGiftAnimation))
|
||||
mux.HandleFunc("POST /v1/default-gifts/import", s.authenticated(s.handleImportDefaultStarGift))
|
||||
mux.HandleFunc("POST /v1/default-gifts/import-all", s.authenticated(s.handleImportAllDefaultStarGifts))
|
||||
mux.HandleFunc("GET /v1/official-gifts", s.authenticated(s.handleOfficialStarGifts))
|
||||
mux.HandleFunc("GET /v1/official-gifts/{id}/animation", s.authenticated(s.handleOfficialStarGiftAnimation))
|
||||
mux.HandleFunc("POST /v1/official-gifts/import", s.authenticated(s.handleImportOfficialStarGift))
|
||||
mux.HandleFunc("POST /v1/official-gifts/import-all", s.authenticated(s.handleImportAllOfficialStarGifts))
|
||||
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))
|
||||
|
|
@ -314,6 +324,69 @@ func (s *Server) handleImportAllDefaultStarGifts(w http.ResponseWriter, r *http.
|
|||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleOfficialStarGifts(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.svc.OfficialStarGifts(r.Context())
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, officialgifts.ErrUnavailable) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
result := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, officialStarGiftListItem(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gifts": result})
|
||||
}
|
||||
|
||||
func officialStarGiftListItem(item officialgifts.GiftSummary) map[string]any {
|
||||
return map[string]any{
|
||||
"source_gift_id": strconv.FormatInt(item.ID, 10), "title": item.Title,
|
||||
"stars": strconv.FormatInt(item.Stars, 10), "convert_stars": strconv.FormatInt(item.ConvertStars, 10),
|
||||
"upgrade_stars": strconv.FormatInt(item.UpgradeStars, 10),
|
||||
"availability_total": item.AvailabilityTotal, "limited": item.Limited, "sold_out": item.SoldOut,
|
||||
"model_count": item.ModelCount, "pattern_count": item.PatternCount, "backdrop_count": item.BackdropCount,
|
||||
"crafted_model_count": item.CraftedModelCount, "can_upgrade": item.CanUpgrade(), "can_craft": item.CanCraft(),
|
||||
"document_id": strconv.FormatInt(item.DocumentID, 10), "animation_validated": item.AnimationValidated,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleOfficialStarGiftAnimation(w http.ResponseWriter, r *http.Request) {
|
||||
raw, found, err := s.svc.OfficialStarGiftAnimation(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "official gift animation not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "private, max-age=60")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleImportOfficialStarGift(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.ImportOfficialStarGiftRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.ImportOfficialStarGift(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleImportAllOfficialStarGifts(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.ImportAllOfficialStarGiftsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.ImportAllOfficialStarGifts(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishStarGiftCollectibles(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/seed/giftdemo"
|
||||
)
|
||||
|
||||
|
|
@ -298,6 +299,22 @@ func (fakeService) DefaultStarGiftAnimation(context.Context, int) ([]byte, bool,
|
|||
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) ImportOfficialStarGift(_ context.Context, req admin.ImportOfficialStarGiftRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ImportAllOfficialStarGifts(_ context.Context, req admin.ImportAllOfficialStarGiftsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) OfficialStarGifts(context.Context) ([]officialgifts.GiftSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) OfficialStarGiftAnimation(context.Context, string) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) PublishStarGiftCollectibles(_ context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,6 +196,8 @@ type Config struct {
|
|||
WebPagePreviewRatePerMin int
|
||||
// LangPackSeedDir 是 TDesktop 语言包 .strings 种子目录。
|
||||
LangPackSeedDir string
|
||||
// OfficialGiftsDir 是 cmd/giftfetch 生成的只读官方礼物快照目录。
|
||||
OfficialGiftsDir string
|
||||
// StarGiftTONStartingGrant 是 telesrv 内部 TON 账本首次访问时授予的 nanoton。
|
||||
// 该账本只用于自建服务端礼物链路,不连接任何外部区块链。
|
||||
StarGiftTONStartingGrant int64
|
||||
|
|
@ -552,6 +554,7 @@ func Load() (Config, error) {
|
|||
SMTPTLSMode: strings.ToLower(strings.TrimSpace(envOr("TELESRV_SMTP_TLS", "starttls"))),
|
||||
SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second),
|
||||
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"),
|
||||
OfficialGiftsDir: envOr("TELESRV_OFFICIAL_GIFTS_DIR", "data/official-gifts"),
|
||||
StarGiftTONStartingGrant: envInt64Or("TELESRV_STARGIFT_TON_STARTING_GRANT", 10_000_000_000),
|
||||
BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"),
|
||||
StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue