feat: sync official star gift lifecycle tooling

Sync telesrv 4c0e2d9 (feat: complete official star gift lifecycle and admin tooling).

Public adjustments: skipped private docs/deploy-nginx/README source changes, kept iamxvbaba/td public dependency, replaced local/private sample IP and orange seed label.
This commit is contained in:
A 2026-07-19 03:11:35 +08:00
parent f2c2fd0236
commit 14bf7d1e20
92 changed files with 14768 additions and 727 deletions

1042
cmd/giftfetch/main.go Normal file

File diff suppressed because it is too large Load diff

238
cmd/giftfetch/main_test.go Normal file
View file

@ -0,0 +1,238 @@
package main
import (
"bytes"
"errors"
"os"
"path/filepath"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/tg"
)
func TestHasRenderableStickerAttribute(t *testing.T) {
tests := []struct {
name string
attributes []tg.DocumentAttributeClass
want bool
}{
{name: "sticker", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeSticker{}}, want: true},
{name: "custom emoji", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeCustomEmoji{}}, want: true},
{name: "ordinary file", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeFilename{}}, want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := hasRenderableStickerAttribute(&tg.Document{Attributes: test.attributes}); got != test.want {
t.Fatalf("hasRenderableStickerAttribute() = %v, want %v", got, test.want)
}
})
}
}
func TestDocumentExtension(t *testing.T) {
tests := []struct {
name string
mime string
want string
}{
{name: "gift.tgs", mime: "application/octet-stream", want: ".tgs"},
{name: "", mime: "application/x-tgsticker", want: ".tgs"},
{name: "unsafe.exe", mime: "video/webm", want: ".webm"},
{name: "", mime: "application/octet-stream", want: ".bin"},
}
for _, test := range tests {
if got := documentExtension(test.name, test.mime); got != test.want {
t.Errorf("documentExtension(%q, %q) = %q, want %q", test.name, test.mime, got, test.want)
}
}
}
func TestBoundedBuffer(t *testing.T) {
buffer := &boundedBuffer{max: 4}
if _, err := buffer.Write([]byte("abc")); err != nil {
t.Fatal(err)
}
if written, err := buffer.Write([]byte("def")); err == nil || written != 1 {
t.Fatalf("overflow write = (%d, %v), want (1, error)", written, err)
}
if !bytes.Equal(buffer.Bytes(), []byte("abcd")) {
t.Fatalf("buffer = %q, want abcd", buffer.Bytes())
}
}
func TestDownloadPartSize(t *testing.T) {
tests := []struct {
size int64
want int
}{
{size: 1, want: 4 << 10},
{size: (4 << 10) - 1, want: 4 << 10},
{size: 4 << 10, want: 8 << 10},
{size: (512 << 10) - 1, want: 512 << 10},
{size: 512 << 10, want: 512 << 10},
{size: 1 << 20, want: 512 << 10},
}
for _, test := range tests {
if got := downloadPartSize(test.size); got != test.want {
t.Errorf("downloadPartSize(%d) = %d, want %d", test.size, got, test.want)
}
}
}
func TestParseAllowedMissingThumbs(t *testing.T) {
allowed, err := parseAllowedMissingThumbs("5417911440709285239:photo:m,42:video:v")
if err != nil {
t.Fatal(err)
}
if !missingThumbAllowed(allowed, 5417911440709285239, "photo", "m") || !missingThumbAllowed(allowed, 42, "video", "v") {
t.Fatalf("allowed = %v", allowed)
}
for _, invalid := range []string{"bad", "0:photo:m", "1:audio:m", "1:photo:?"} {
if _, err := parseAllowedMissingThumbs(invalid); err == nil {
t.Errorf("parseAllowedMissingThumbs(%q) succeeded", invalid)
}
}
}
func TestExistingArtifact(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "resource.bin"), []byte("gift"), 0o644); err != nil {
t.Fatal(err)
}
data, reused, err := existingArtifact(root, "resource.bin", 4, 16)
if err != nil || !reused || string(data) != "gift" {
t.Fatalf("existingArtifact(valid) = (%q, %v, %v)", data, reused, err)
}
if _, reused, err := existingArtifact(root, "resource.bin", 5, 16); err != nil || reused {
t.Fatalf("existingArtifact(size mismatch) = (reused=%v, err=%v)", reused, err)
}
if _, reused, err := existingArtifact(root, "missing.bin", -1, 16); err != nil || reused {
t.Fatalf("existingArtifact(missing) = (reused=%v, err=%v)", reused, err)
}
}
func TestReadTLArtifact(t *testing.T) {
root := t.TempDir()
var encoded bin.Buffer
if err := (&tg.PaymentsStarGiftUpgradeAttributes{}).Encode(&encoded); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "attributes.tl"), encoded.Buf, 0o644); err != nil {
t.Fatal(err)
}
var decoded tg.PaymentsStarGiftUpgradeAttributes
artifact, err := readTLArtifact(root, "attributes.tl", &decoded)
if err != nil {
t.Fatal(err)
}
if artifact.Kind != "tl" || artifact.Size != int64(len(encoded.Buf)) || artifact.SHA256 == "" {
t.Fatalf("artifact = %+v", artifact)
}
if err := os.WriteFile(filepath.Join(root, "trailing.tl"), append(append([]byte(nil), encoded.Buf...), 0xff), 0o644); err != nil {
t.Fatal(err)
}
if _, err := readTLArtifact(root, "trailing.tl", &tg.PaymentsStarGiftUpgradeAttributes{}); err == nil {
t.Fatal("expected trailing-byte error")
}
}
func TestCollectUpgradeableGiftIDs(t *testing.T) {
classes := []tg.StarGiftClass{
&tg.StarGift{ID: 1, UpgradeStars: 10},
&tg.StarGift{ID: 2, UpgradeVariants: 3},
&tg.StarGift{ID: 3},
&tg.StarGiftUnique{ID: 4, GiftID: 1},
}
got := collectUpgradeableGiftIDs(classes)
if len(got) != 2 || got[0] != 1 || got[1] != 2 {
t.Fatalf("collectUpgradeableGiftIDs() = %v, want [1 2]", got)
}
}
func TestCollectUpgradeAttributes(t *testing.T) {
modelDoc := testGiftDocument(101)
patternDoc := testGiftDocument(102)
model := &tg.StarGiftAttributeModel{
Name: "Crafted model",
Document: modelDoc,
Rarity: &tg.StarGiftAttributeRarityLegendary{},
}
model.SetCrafted(true)
result := &tg.PaymentsStarGiftUpgradeAttributes{Attributes: []tg.StarGiftAttributeClass{
model,
&tg.StarGiftAttributePattern{Name: "Pattern", Document: patternDoc, Rarity: &tg.StarGiftAttributeRarity{Permille: 125}},
&tg.StarGiftAttributeBackdrop{Name: "Backdrop", BackdropID: 7, CenterColor: 1, EdgeColor: 2, PatternColor: 3, TextColor: 4, Rarity: &tg.StarGiftAttributeRarityEpic{}},
}}
added := make(map[int64]string)
set, err := collectUpgradeAttributes(99, result, fileArtifact{Path: "upgrade-attributes/99.tl"}, func(class tg.DocumentClass, purpose string) (*tg.Document, error) {
doc, ok := class.(*tg.Document)
if !ok {
return nil, errors.New("not a document")
}
added[doc.ID] = purpose
return doc, nil
})
if err != nil {
t.Fatal(err)
}
if set.AttributeCount != 3 || len(set.Models) != 1 || len(set.Patterns) != 1 || len(set.Backdrops) != 1 {
t.Fatalf("unexpected attribute counts: %+v", set)
}
if !set.Models[0].Crafted || set.Models[0].Rarity.Kind != "legendary" {
t.Fatalf("model = %+v", set.Models[0])
}
if set.Patterns[0].Rarity.Permille == nil || *set.Patterns[0].Rarity.Permille != 125 {
t.Fatalf("pattern rarity = %+v", set.Patterns[0].Rarity)
}
if set.Backdrops[0].PatternColor != 3 || set.Backdrops[0].Rarity.Kind != "epic" {
t.Fatalf("backdrop = %+v", set.Backdrops[0])
}
if len(set.DocumentIDs) != 2 || len(added) != 2 {
t.Fatalf("document ids = %v, added = %v", set.DocumentIDs, added)
}
}
func TestCollectUpgradeAttributesRejectsInstanceOnlyAttribute(t *testing.T) {
_, err := collectUpgradeAttributes(99, &tg.PaymentsStarGiftUpgradeAttributes{Attributes: []tg.StarGiftAttributeClass{
&tg.StarGiftAttributeOriginalDetails{},
}}, fileArtifact{}, func(tg.DocumentClass, string) (*tg.Document, error) {
return nil, nil
})
if err == nil {
t.Fatal("expected unsupported-constructor error")
}
}
func TestCollectRarityKinds(t *testing.T) {
tests := []struct {
class tg.StarGiftAttributeRarityClass
kind string
}{
{class: &tg.StarGiftAttributeRarityUncommon{}, kind: "uncommon"},
{class: &tg.StarGiftAttributeRarityRare{}, kind: "rare"},
{class: &tg.StarGiftAttributeRarityEpic{}, kind: "epic"},
{class: &tg.StarGiftAttributeRarityLegendary{}, kind: "legendary"},
}
for _, test := range tests {
got, err := collectRarity(test.class)
if err != nil || got.Kind != test.kind || got.ConstructorID == "" {
t.Fatalf("collectRarity(%T) = (%+v, %v)", test.class, got, err)
}
}
if _, err := collectRarity(nil); err == nil {
t.Fatal("expected nil-rarity error")
}
}
func testGiftDocument(id int64) *tg.Document {
return &tg.Document{
ID: id,
Size: 1,
MimeType: "application/x-tgsticker",
Attributes: []tg.DocumentAttributeClass{
&tg.DocumentAttributeCustomEmoji{Alt: "gift"},
},
}
}

View file

@ -134,23 +134,23 @@ type ChannelDetail struct {
}
type StarGiftRow struct {
GiftID int64
RevisionID int64
GiftID int64 `json:"GiftID,string"`
RevisionID int64 `json:"RevisionID,string"`
Revision int
Title string
Stars int64
ConvertStars int64
Stars int64 `json:"Stars,string"`
ConvertStars int64 `json:"ConvertStars,string"`
Enabled bool
SortOrder int
DocumentID int64
DocumentID int64 `json:"DocumentID,string"`
SourceName string
SourceFormat string
AnimationSHA string
AnimationSize int64
AnimationSize int64 `json:"AnimationSize,string"`
Width int
Height int
FrameRate float64
ReceivedCount int64
ReceivedCount int64 `json:"ReceivedCount,string"`
CreatedBy string
UpdatedAt time.Time
}

View file

@ -58,6 +58,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/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)))
@ -70,6 +72,7 @@ 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/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)))
@ -228,6 +231,19 @@ 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) 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)
@ -696,10 +712,10 @@ type importStarGiftAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
GiftID int64 `json:"gift_id"`
GiftID int64 `json:"gift_id,string"`
Title string `json:"title"`
Stars int64 `json:"stars"`
ConvertStars int64 `json:"convert_stars"`
Stars int64 `json:"stars,string"`
ConvertStars int64 `json:"convert_stars,string"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
}
@ -746,11 +762,48 @@ 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"`
}
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 publishStarGiftCollectiblesAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UpgradeStars int64 `json:"upgrade_stars"`
UpgradeStars int64 `json:"upgrade_stars,string"`
SupplyTotal int `json:"supply_total"`
SlugPrefix string `json:"slug_prefix"`
Models []admin.StarGiftCollectibleAnimationUpload `json:"models"`
@ -832,7 +885,7 @@ type setStarGiftEnabledAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
GiftID int64 `json:"gift_id"`
GiftID int64 `json:"gift_id,string"`
Enabled bool `json:"enabled"`
}
@ -853,7 +906,7 @@ type setStarGiftSortOrderAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
GiftID int64 `json:"gift_id"`
GiftID int64 `json:"gift_id,string"`
SortOrder int `json:"sort_order"`
}

View file

@ -82,3 +82,76 @@ func TestSetAccountFrozenBFFForwardsClientVisibleState(t *testing.T) {
t.Fatalf("forwarded freeze request = %+v", got)
}
}
func TestStarGiftRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
raw, err := json.Marshal(StarGiftRow{
GiftID: maxInt64,
RevisionID: maxInt64,
Stars: maxInt64,
ConvertStars: maxInt64,
DocumentID: maxInt64,
AnimationSize: maxInt64,
ReceivedCount: maxInt64,
})
if err != nil {
t.Fatalf("marshal star gift row: %v", err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal star gift row: %v", err)
}
for _, field := range []string{"GiftID", "RevisionID", "Stars", "ConvertStars", "DocumentID", "AnimationSize", "ReceivedCount"} {
if got[field] != "9223372036854775807" {
t.Fatalf("%s = %#v, want exact decimal string", field, got[field])
}
}
}
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"
}`))
var got importOfficialStarGiftAPIRequest
if err := decodeJSON(req, &got); err != nil {
t.Fatalf("decode gift action: %v", err)
}
if got.GiftID != maxInt64 || got.Stars != maxInt64 || got.ConvertStars != maxInt64 || got.UpgradeStars != maxInt64 {
t.Fatalf("decoded gift action = %+v", got)
}
}
func TestSetStarGiftEnabledBFFForwardsExactInt64(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
var got admin.SetStarGiftEnabledRequest
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/gifts/set-enabled" || r.Header.Get("Authorization") != "Bearer secret" {
t.Fatalf("upstream request path=%q authorization=%q", r.URL.Path, r.Header.Get("Authorization"))
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed", DryRun: got.DryRun})
}))
defer upstream.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/actions/set-gift-enabled", strings.NewReader(`{
"reason":"precision regression","confirm":false,
"gift_id":"9223372036854775807","enabled":false
}`))
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
rec := httptest.NewRecorder()
srv.handleSetStarGiftEnabledAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got.GiftID != maxInt64 || got.Actor != "operator" || !got.DryRun {
t.Fatalf("forwarded gift request = %+v", got)
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,13 +1,13 @@
<!doctype html>
<html lang="en" translate="no">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title>
<script type="module" crossorigin src="/assets/index-BFkUM6v2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BaxMq_AT.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
<!doctype html>
<html lang="en" translate="no">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title>
<script type="module" crossorigin src="/assets/index-DKmJO2ZY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DHdrFM5j.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View file

@ -8,6 +8,7 @@ import type {
GroupMessageListResponse,
MessageDetail,
MessageListResponse,
OfficialStarGiftListResponse,
StarGiftCollectiblePreview,
StarGiftListResponse
} from "./types";
@ -66,11 +67,14 @@ export const api = {
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
},
gifts: () => request<StarGiftListResponse>("/api/gifts"),
giftAnimation: (id: number) => request<Record<string, unknown>>(`/api/gifts/${id}/animation`),
giftCollectibles: (id: number) => request<StarGiftCollectiblePreview>(`/api/gifts/${id}/collectibles`),
giftCollectibleAnimation: (giftID: number, kind: "model" | "pattern", attributeID: number) => request<Record<string, unknown>>(`/api/gifts/${giftID}/collectibles/${kind}/${attributeID}/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 }),
publishGiftCollectibles: (giftID: number, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${giftID}`, { method: "POST", body: form }),
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",
body: JSON.stringify(payload)

View file

@ -260,6 +260,26 @@ const translations: Record<Language, 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",
@ -307,7 +327,7 @@ const translations: Record<Language, Record<string, string>> = {
"collectibles.pattern": "Pattern",
"collectibles.backdrop": "Backdrop",
"collectibles.rarity": "Rarity ‰",
"collectibles.rarityHint": "Every section must total exactly 1000‰.",
"collectibles.rarityHint": "Permille values are relative regular-upgrade weights; their total does not need to equal 1000.",
"collectibles.colorHint": "Colors are stored as Telegram 24-bit RGB values.",
"collectibles.addAttribute": "Add",
"collectibles.remove": "Remove attribute",
@ -610,6 +630,26 @@ const translations: Record<Language, Record<string, string>> = {
"gifts.importEyebrow": "礼物目录操作",
"gifts.newRevision": "为礼物 #{id} 创建新版本",
"gifts.importHint": "支持 TGS 或纯 Lottie JSONLottie 会规范化并压缩成 TGS。",
"gifts.officialSource": "官方资源库",
"gifts.fileSource": "上传文件",
"gifts.officialHint": "从 data/official-gifts 的已校验快照中选择;完整 collectible 属性池会与礼物原子导入。",
"gifts.officialSearch": "搜索官方礼物 ID 或标题",
"gifts.officialSelect": "请选择官方礼物",
"gifts.officialRequired": "请先选择一个官方礼物",
"gifts.officialResults": "显示 {shown} / {total} 项",
"gifts.officialCategoryLabel": "官方礼物能力分类",
"gifts.officialCategory.all": "全部",
"gifts.officialCategory.upgrade": "可升级",
"gifts.officialCategory.craft": "可 Craft",
"gifts.officialCategory.basic": "不可升级",
"gifts.officialUnnamed": "未命名官方礼物 #{id}",
"gifts.officialAttributes": "{count} 个属性",
"gifts.canUpgrade": "可升级",
"gifts.cannotUpgrade": "不可升级",
"gifts.canCraft": "可 Craft",
"gifts.cannotCraft": "不可 Craft",
"gifts.officialEmpty": "当前分类和搜索条件下没有官方礼物。",
"gifts.includeCollectible": "完整导入 collectible 属性池(包含 crafted 模型)",
"gifts.animation": "动画文件",
"gifts.filePrompt": "拖放或选择 TGS / Lottie 文件",
"gifts.fileHint": "支持 TGS、JSON、Lottie导入前会先进行校验",
@ -657,7 +697,7 @@ const translations: Record<Language, Record<string, string>> = {
"collectibles.pattern": "图案",
"collectibles.backdrop": "背景",
"collectibles.rarity": "稀有度 ‰",
"collectibles.rarityHint": "每一类的稀有度总和必须正好为 1000‰。",
"collectibles.rarityHint": "Permille 是普通升级的相对权重,不要求每类合计正好为 1000。",
"collectibles.colorHint": "颜色会按 Telegram 24 位 RGB 数值保存。",
"collectibles.addAttribute": "添加",
"collectibles.remove": "删除属性",
@ -960,6 +1000,26 @@ const translations: Record<Language, Record<string, string>> = {
"gifts.importEyebrow": "Управление каталогом подарков",
"gifts.newRevision": "Создать версию для подарка #{id}",
"gifts.importHint": "Загрузите файл TGS или обычный Lottie JSON. Lottie нормализуется и сжимается в формат TGS.",
"gifts.officialSource": "Официальный снимок",
"gifts.fileSource": "Загрузить файл",
"gifts.officialHint": "Выберите проверенный подарок из data/official-gifts. Полные пулы коллекционных предметов импортируются атомарно.",
"gifts.officialSearch": "Поиск по ID или названию официального подарка",
"gifts.officialSelect": "Выберите официальный подарок",
"gifts.officialRequired": "Сначала выберите официальный подарок",
"gifts.officialResults": "Показано {shown} из {total}",
"gifts.officialCategoryLabel": "Категория возможностей официального подарка",
"gifts.officialCategory.all": "Все",
"gifts.officialCategory.upgrade": "Можно улучшить",
"gifts.officialCategory.craft": "Можно создать",
"gifts.officialCategory.basic": "Нельзя улучшить",
"gifts.officialUnnamed": "Официальный подарок без названия #{id}",
"gifts.officialAttributes": "Атрибутов: {count}",
"gifts.canUpgrade": "Можно улучшить",
"gifts.cannotUpgrade": "Нельзя улучшить",
"gifts.canCraft": "Можно создать",
"gifts.cannotCraft": "Нельзя создать",
"gifts.officialEmpty": "Нет подарков, соответствующих категории и поиску.",
"gifts.includeCollectible": "Импортировать полный пул коллекционных предметов, включая созданные модели",
"gifts.animation": "Файл анимации",
"gifts.filePrompt": "Перетащите или выберите файл TGS / Lottie",
"gifts.fileHint": "TGS, JSON или Lottie · файл проверяется перед импортом",
@ -1007,7 +1067,7 @@ const translations: Record<Language, Record<string, string>> = {
"collectibles.pattern": "Узор",
"collectibles.backdrop": "Фон",
"collectibles.rarity": "Редкость ‰",
"collectibles.rarityHint": "Сумма по каждому разделу должна составлять ровно 1000‰.",
"collectibles.rarityHint": "Значения permille — это относительные веса обычного улучшения; их сумма не обязана равняться 1000.",
"collectibles.colorHint": "Цвета сохраняются как 24-битные RGB-значения Telegram.",
"collectibles.addAttribute": "Добавить",
"collectibles.remove": "Удалить атрибут",

View file

@ -44,7 +44,7 @@ function AnimationPreview({ data, compact = false }: { data: AnimationData; comp
return <div className={`collectible-animation ${compact ? "compact" : ""}`} ref={host} />;
}
function RemoteAnimation({ giftID, attribute }: { giftID: number; attribute: StarGiftCollectibleAttributeRow }) {
function RemoteAnimation({ giftID, attribute }: { giftID: string; attribute: StarGiftCollectibleAttributeRow }) {
const [data, setData] = useState<AnimationData | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
@ -74,6 +74,7 @@ async function parseAnimationFile(file: File): Promise<AnimationData> {
}
const colorNumber = (value: string) => Number.parseInt(value.replace("#", ""), 16);
const rarityLabel = (attribute: StarGiftCollectibleAttributeRow) => attribute.rarity_kind === "permille" ? `${attribute.rarity_permille}` : attribute.rarity_kind;
export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) {
const { t } = useI18n();
@ -135,7 +136,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
const animatedMetadata = (rows: AnimatedDraft[]) => rows.map((row) => ({ name: row.name.trim(), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder), file_key: row.key }));
form.set("metadata", JSON.stringify({
command_id: commandID, reason: reason.trim(), confirm,
upgrade_stars: Number(upgradeStars), supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase(),
upgrade_stars: upgradeStars, supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase(),
models: animatedMetadata(models), patterns: animatedMetadata(patterns),
backdrops: backdrops.map((row) => ({
name: row.name.trim(), backdrop_id: Number(row.backdropID), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder),
@ -167,7 +168,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
<section className="collectible-section">
<div className="collectible-section-head">
<div><strong>{t(`collectibles.${kind}`)}</strong><span>{t("collectibles.rarityHint")}</span></div>
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] === 1000 ? "good" : "neutral"}>{rarityTotals[kind]} / 1000</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows([...rows, newAnimated(kind === "models" ? "model" : "pattern")]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div>
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] > 0 ? "good" : "neutral"}>{rarityTotals[kind]}</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows([...rows, newAnimated(kind === "models" ? "model" : "pattern")]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div>
</div>
<div className="collectible-rows">
{rows.map((row, index) => <div className="collectible-row animated" key={row.key}>
@ -194,8 +195,8 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
{loading ? <div className="collectible-loading"><Loader2 className="spin" />{t("common.loading")}</div> : active?.found ? <section className="collectible-active">
<div className="collectible-active-head"><div><Gem size={18} /><div><strong>{t("collectibles.activeRevision", { revision: active.revision ?? 0 })}</strong><span>{active.slug_prefix} · {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{t("collectibles.published")}</Badge></div>
<div className="collectible-active-grid">
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}</strong><span>{t(`collectibles.${attribute.kind}`)} · {attribute.rarity_permille}</span></div></article>)}
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {attribute.rarity_permille}</span></div></article>)}
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}{attribute.crafted && <Badge>crafted</Badge>}</strong><span>{t(`collectibles.${attribute.kind}`)} · {rarityLabel(attribute)}</span></div></article>)}
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {rarityLabel(attribute)}</span></div></article>)}
</div>
</section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{t("collectibles.noPool")}</strong><span>{t("collectibles.noPoolHint")}</span></div></div>}
@ -210,11 +211,11 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
{renderAnimatedRows("models", models, setModels)}
{renderAnimatedRows("patterns", patterns, setPatterns)}
<section className="collectible-section">
<div className="collectible-section-head"><div><strong>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops === 1000 ? "good" : "neutral"}>{rarityTotals.backdrops} / 1000</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops([...backdrops, newBackdrop()]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div>
<div className="collectible-section-head"><div><strong>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops > 0 ? "good" : "neutral"}>{rarityTotals.backdrops}</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops([...backdrops, newBackdrop()]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div>
<div className="collectible-rows">{backdrops.map((row, index) => <div className="collectible-row backdrop" key={row.key}>
<div className="collectible-row-index">{index + 1}</div>
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.backdropID")}</span><input type="number" min="1" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.backdropID")}</span><input type="number" min="0" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label>
{(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{t(`collectibles.color.${field}`)}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)}

View file

@ -7,16 +7,23 @@ 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, StarGiftRow } from "../types";
import type { CommandResult, OfficialStarGiftRow, StarGiftRow } from "../types";
import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
function officialGiftAttributeCount(gift: OfficialStarGiftRow) {
return gift.model_count + gift.pattern_count + gift.backdrop_count;
}
function LottiePreview({ giftID, revision, compact = false }: { giftID: number; revision: number; compact?: boolean }) {
function formatBytes(value: number | string) {
const bytes = Number(value);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function LottiePreview({ giftID, revision, compact = false }: { giftID: string; revision: number; compact?: boolean }) {
const host = useRef<HTMLDivElement>(null);
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
const [playing, setPlaying] = useState(true);
@ -59,6 +66,20 @@ function LottiePreview({ giftID, revision, compact = false }: { giftID: number;
);
}
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[]>([]);
@ -66,7 +87,16 @@ export function GiftsPage() {
const [importOpen, setImportOpen] = useState(false);
const [collectibleGift, setCollectibleGift] = useState<StarGiftRow | null>(null);
const [file, setFile] = useState<File | null>(null);
const [giftID, setGiftID] = useState(0);
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 [giftID, setGiftID] = useState("0");
const [title, setTitle] = useState("");
const [stars, setStars] = useState("50");
const [convertStars, setConvertStars] = useState("50");
@ -89,6 +119,29 @@ 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]);
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();
if (!normalized) return gifts;
@ -107,10 +160,10 @@ export function GiftsPage() {
command_id: commandID,
reason: reason.trim(),
confirm,
gift_id: giftID,
title: title.trim(),
stars: Number(stars),
convert_stars: Number(convertStars),
gift_id: giftID,
title: title.trim(),
stars,
convert_stars: convertStars,
enabled,
sort_order: Number(sortOrder)
}));
@ -118,10 +171,34 @@ export function GiftsPage() {
return form;
}
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(await api.importGift(uploadForm(false)));
setPreview(importSource === "official" ? await api.importOfficialGift(officialPayload(false)) : await api.importGift(uploadForm(false)));
} catch (err) {
setImportError(errorMessage(err));
} finally { setBusy(false); }
@ -131,8 +208,9 @@ export function GiftsPage() {
if (!preview) return;
setBusy(true); setImportError("");
try {
await api.importGift(uploadForm(true, preview.command_id));
setPreview(null); setFile(null); setGiftID(0); setTitle("");
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(""); setSourceGiftID("");
await load();
setImportOpen(false);
} catch (err) {
@ -141,14 +219,16 @@ export function GiftsPage() {
}
function startImport() {
setGiftID(0); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError(""); setImportOpen(true);
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);
}
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(""); setImportOpen(true);
setReason(""); setFile(null); setPreview(null); setImportError("");
setImportSource("official"); setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); setImportOpen(true);
}
return (
@ -160,7 +240,7 @@ export function GiftsPage() {
<div className="metric-row gift-metrics">
<Metric label={t("gifts.total")} value={String(gifts.length)} />
<Metric label={t("gifts.enabled")} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" />
<Metric label={t("gifts.received")} value={String(gifts.reduce((sum, gift) => sum + gift.ReceivedCount, 0))} />
<Metric label={t("gifts.received")} value={gifts.reduce((sum, gift) => sum + BigInt(gift.ReceivedCount), 0n).toString()} />
<Metric label={t("gifts.formats")} value="TGS / Lottie" />
</div>
<QueryPanel>
@ -193,24 +273,77 @@ export function GiftsPage() {
{importOpen && createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}>
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}>
<div className="modal-head">
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div>
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div>
<button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
</div>
<div className="command-body gift-import-modal-body">
<div className="command-steps">
<div className={`command-step ${file ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div>
<div className={`command-step ${preview ? "done" : file ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div>
<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 ${preview ? "active" : ""}`}><span>3</span><strong>{t("gifts.stepImport")}</strong></div>
</div>
<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" : ""}`}>
<input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} />
<span className="gift-file-icon"><FileJson2 size={22} /></span>
<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-source-tabs">
<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 === "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 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)}>
<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>}
</>}
</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" : ""}`}>
<input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} />
<span className="gift-file-icon"><FileJson2 size={22} /></span>
<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>

View file

@ -276,6 +276,60 @@
.gift-import-modal { width: min(860px, 100%); }
.gift-import-modal-body { gap: 14px; }
.gift-source-tabs { display: flex; gap: 8px; }
.official-gift-picker { display: grid; min-width: 0; gap: 12px; }
.official-gift-tools { display: flex; align-items: center; gap: 12px; }
.official-gift-tools .searchbox { width: 100%; }
.official-gift-tools > span { flex: 0 0 auto; color: var(--muted); font-size: 11px; font-weight: 750; }
.official-gift-categories { display: flex; flex-wrap: wrap; gap: 7px; }
.official-gift-categories button {
display: inline-flex; align-items: center; gap: 7px; min-height: 32px; padding: 5px 10px;
color: #49605c; background: #f7faf9; border: 1px solid #d7e2df; border-radius: 999px;
font: inherit; font-size: 11px; font-weight: 800; cursor: pointer;
transition: color .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease;
}
.official-gift-categories button:hover { color: var(--brand); border-color: #9fc9c0; }
.official-gift-categories button.active { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: 0 4px 12px rgba(23, 109, 97, .17); }
.official-gift-categories button span {
display: grid; min-width: 20px; height: 20px; padding: 0 5px; place-items: center;
color: inherit; background: rgba(255,255,255,.65); border-radius: 999px; font-size: 10px;
}
.official-gift-categories button.active span { color: var(--brand); }
.official-gift-list {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; max-height: 314px;
min-height: 126px; padding: 8px; overflow: auto; border: 1px solid var(--line); border-radius: 14px;
background: #f6f9f8; scrollbar-gutter: stable;
}
.official-gift-option {
display: grid; min-width: 0; gap: 8px; padding: 11px 12px; text-align: left; color: var(--text);
background: #ffffff; border: 1px solid #dce6e3; border-radius: 11px; cursor: pointer;
box-shadow: 0 1px 2px rgba(32, 54, 50, .03);
transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
}
.official-gift-option:hover { border-color: #9fc9c0; box-shadow: 0 5px 14px rgba(32, 76, 68, .08); transform: translateY(-1px); }
.official-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(23, 109, 97, .12), 0 5px 14px rgba(32, 76, 68, .08); }
.official-gift-option-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: baseline; }
.official-gift-option-head strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.official-gift-option-head .mono { color: var(--muted); font-size: 9px; }
.official-gift-option-meta { display: flex; flex-wrap: wrap; gap: 10px; color: #667773; font-size: 10px; font-weight: 700; }
.official-gift-capabilities { display: flex; flex-wrap: wrap; gap: 5px; }
.official-gift-capabilities > span {
padding: 3px 7px; border: 1px solid transparent; border-radius: 999px; font-size: 9px; font-weight: 850; letter-spacing: .01em;
}
.official-gift-capabilities > span.yes { color: #136b4d; background: #e9f8f0; border-color: #bde6cf; }
.official-gift-capabilities > span.craft { color: #6e3ca0; background: #f3ebfb; border-color: #d9c5ef; }
.official-gift-capabilities > span.no { color: #78837f; background: #f1f3f2; border-color: #dde2e0; }
.official-gift-empty {
display: grid; grid-column: 1 / -1; min-height: 108px; place-items: center; padding: 20px;
color: var(--muted); text-align: center; font-size: 12px;
}
.official-gift-selected {
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 > 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; }
.gift-file-picker {
@ -321,6 +375,7 @@
.gift-fields-grid input,
.gift-reason-field input {
width: 100%;
min-width: 0;
height: 38px;
padding: 0 10px;
@ -440,6 +495,10 @@
.gift-file-action { display: none; }
.gift-fields-grid { grid-template-columns: 1fr; }
.gift-list-summary { width: 100%; margin-left: 0; }
.official-gift-tools { align-items: stretch; flex-direction: column; }
.official-gift-list { grid-template-columns: 1fr; max-height: 340px; }
.official-gift-selected { grid-template-columns: 82px minmax(0, 1fr); }
.official-gift-selected .gift-animation-shell { width: 72px; height: 72px; }
.collectible-modal-body { padding: 10px; }
.collectible-definition-head,
.collectible-section-head { align-items: flex-start; flex-direction: column; }

View file

@ -161,34 +161,58 @@ export type OutboxRow = {
};
export type StarGiftRow = {
GiftID: number;
RevisionID: number;
GiftID: string;
RevisionID: string;
Revision: number;
Title: string;
Stars: number;
ConvertStars: number;
Stars: string;
ConvertStars: string;
Enabled: boolean;
SortOrder: number;
DocumentID: number;
DocumentID: string;
SourceName: string;
SourceFormat: "tgs" | "lottie";
AnimationSHA: string;
AnimationSize: number;
AnimationSize: string;
Width: number;
Height: number;
FrameRate: number;
ReceivedCount: number;
ReceivedCount: string;
CreatedBy: string;
UpdatedAt: string;
};
export type StarGiftListResponse = { Gifts: StarGiftRow[] };
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: number;
id: string;
kind: "model" | "pattern" | "backdrop";
name: string;
rarity_kind: "permille" | "uncommon" | "rare" | "epic" | "legendary";
rarity_permille: number;
crafted: boolean;
official_document_id: string;
sort_order: number;
source_name?: string;
source_format?: "tgs" | "lottie";
@ -201,9 +225,9 @@ export type StarGiftCollectibleAttributeRow = {
export type StarGiftCollectiblePreview = {
found: boolean;
gift_id: number;
gift_id: string;
revision?: number;
upgrade_stars?: number;
upgrade_stars?: string;
supply_total?: number;
issued?: number;
slug_prefix?: string;

View file

@ -1,4 +1,4 @@
// Command telesrv 是基于 github.com/iamxvbaba/td 的 Telegram-like server第一兼容目标Telegram Desktop
// Command telesrv 是基于 gotd/td 的 Telegram-like server第一兼容目标Telegram Desktop
package main
import (
@ -56,6 +56,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"
@ -467,8 +468,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,
@ -663,9 +665,26 @@ func run(logger *zap.Logger) error {
starsStore := postgres.NewStarsStore(pool)
starsService := stars.NewService(starsStore, stars.WithStartingGrant(cfg.StarsStartingGrant))
starGiftStore := postgres.NewStarGiftStore(pool)
starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore)
starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore, postgres.WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: cfg.StarGiftTransferStars, DropOriginalDetailsStars: cfg.StarGiftDropOriginalDetailsStars,
OfferMinStars: cfg.StarGiftOfferMinStars,
ExportDelaySeconds: int(cfg.StarGiftExportDelay / time.Second), TransferDelaySeconds: int(cfg.StarGiftTransferDelay / time.Second),
ResellDelaySeconds: int(cfg.StarGiftResellDelay / time.Second), CraftDelaySeconds: int(cfg.StarGiftCraftDelay / time.Second),
CraftChancePermille: cfg.StarGiftCraftChancePermille,
}))
starGiftLifecycleStore := postgres.NewStarGiftLifecycleStore(pool, messageStore, cfg.StarGiftTONStartingGrant,
postgres.WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
StarsProceedsPermille: cfg.StarGiftStarsProceedsPermille,
TONProceedsPermille: cfg.StarGiftTONProceedsPermille,
}))
starGiftWithdrawalProvider, err := stargifts.NewLocalWithdrawalProvider(cfg.PublicBaseURL)
if err != nil {
return fmt.Errorf("init local star gift withdrawal provider: %w", err)
}
giftsService := stargifts.NewService(starGiftStore, blobBackend, cfg.DC,
stargifts.WithUpgradeStore(starGiftUpgradeStore))
stargifts.WithUpgradeStore(starGiftUpgradeStore),
stargifts.WithLifecycleStore(starGiftLifecycleStore),
stargifts.WithWithdrawalProvider(starGiftWithdrawalProvider))
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
// 同属进程内一次性凭据,不跨实例)。
passkeyStore := postgres.NewPasskeyStore(pool)
@ -852,6 +871,32 @@ func run(logger *zap.Logger) error {
go router.RunPresenceSweeper(ctx, time.Minute)
go activeSessions.RunPendingSweeper(ctx, time.Minute)
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
go func() {
interval := cfg.StarGiftSweepInterval
if interval <= 0 {
interval = 15 * time.Second
}
batch := cfg.StarGiftSweepBatch
if batch <= 0 {
batch = 1000
}
run := func() {
if err := giftsService.SweepLifecycle(ctx, int(time.Now().Unix()), batch); err != nil && ctx.Err() == nil {
logger.Warn("star_gift_lifecycle_sweep_failed", zap.Error(err))
}
}
run()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
run()
}
}
}()
go router.RunInlineBotPushSubscriber(ctx)
if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil {
return fmt.Errorf("start bot api: %w", err)
@ -860,16 +905,18 @@ func run(logger *zap.Logger) error {
return fmt.Errorf("start admin api: %w", err)
}
if _, err := web.Start(ctx, web.Config{
Addr: cfg.PublicLinkWebAddr,
PublicBaseURL: cfg.PublicBaseURL,
AppScheme: cfg.PublicAppScheme,
WebBaseURL: cfg.PublicWebBaseURL,
AppName: cfg.PublicAppName,
StickerSets: filesService,
Users: userStore,
Channels: channelStore,
Privacy: privacyService,
Photos: filesService,
Addr: cfg.PublicLinkWebAddr,
PublicBaseURL: cfg.PublicBaseURL,
AppScheme: cfg.PublicAppScheme,
WebBaseURL: cfg.PublicWebBaseURL,
AppName: cfg.PublicAppName,
StickerSets: filesService,
Users: userStore,
Channels: channelStore,
Privacy: privacyService,
Photos: filesService,
UniqueGifts: giftsService,
GiftWithdrawals: giftsService,
}, logger.Named("public-web")); err != nil {
return fmt.Errorf("start public Web: %w", err)
}