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:
parent
f2c2fd0236
commit
14bf7d1e20
92 changed files with 14768 additions and 727 deletions
1042
cmd/giftfetch/main.go
Normal file
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
238
cmd/giftfetch/main_test.go
Normal 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"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
1
cmd/telesrv-admin/web/dist/assets/index-DHdrFM5j.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-DHdrFM5j.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-DKmJO2ZY.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-DKmJO2ZY.js
vendored
Normal file
File diff suppressed because one or more lines are too long
26
cmd/telesrv-admin/web/dist/index.html
vendored
26
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 JSON;Lottie 会规范化并压缩成 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": "Удалить атрибут",
|
||||
|
|
|
|||
|
|
@ -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>)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue