removed all "paid" features - no more stars, gifts, or grams
This commit is contained in:
parent
d4451d753c
commit
21d8e91756
165 changed files with 318 additions and 40948 deletions
|
|
@ -1,294 +0,0 @@
|
|||
// Command giftcheck 是 Star 礼物端到端验证工具(开发用,非生产组件)。
|
||||
//
|
||||
// 以用户身份登录本地 telesrv,验证 star gift 全链路对 live 服务端:
|
||||
// 1. payments.getStarGifts —— 打印礼物目录。
|
||||
// 2. 从 dialogs 找一个收礼用户(或 -to 指定)。
|
||||
// 3. payments.getPaymentForm(inputInvoiceStarGift) —— 必须返 paymentFormStarGift(XTR+非空 prices)。
|
||||
// 4. payments.sendStarsForm —— 必须返 paymentResult{updates}(含礼物服务消息 + updateStarsBalance)。
|
||||
// 5. payments.getStarsStatus 前后对比验证扣费。
|
||||
//
|
||||
// 用法: go run ./cmd/giftcheck -phone "+8618800000001"
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotd/log/logzap"
|
||||
"github.com/iamxvbaba/td/exchange"
|
||||
"github.com/iamxvbaba/td/mtproxy"
|
||||
"github.com/iamxvbaba/td/mtproxy/obfuscator"
|
||||
"github.com/iamxvbaba/td/proto/codec"
|
||||
"github.com/iamxvbaba/td/telegram"
|
||||
"github.com/iamxvbaba/td/telegram/dcs"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/transport"
|
||||
|
||||
"telesrv/internal/mtprotoedge"
|
||||
)
|
||||
|
||||
type obfuscatedResolver struct {
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func (r obfuscatedResolver) dial(ctx context.Context, dc int) (transport.Conn, error) {
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(r.host, strconv.Itoa(r.port)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obf := obfuscator.Obfuscated2(rand.Reader, conn)
|
||||
if err := obf.Handshake(codec.IntermediateClientStart, dc, mtproxy.Secret{}); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("obfuscated2 handshake: %w", err)
|
||||
}
|
||||
proto := transport.NewProtocol(func() transport.Codec { return codec.NoHeader{Codec: codec.Intermediate{}} })
|
||||
tc, err := proto.Handshake(obf)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("transport handshake: %w", err)
|
||||
}
|
||||
return tc, nil
|
||||
}
|
||||
|
||||
func (r obfuscatedResolver) Primary(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
|
||||
return r.dial(ctx, dc)
|
||||
}
|
||||
func (r obfuscatedResolver) MediaOnly(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
|
||||
return r.dial(ctx, dc)
|
||||
}
|
||||
func (r obfuscatedResolver) CDN(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
|
||||
return r.dial(ctx, dc)
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "127.0.0.1:2398", "telesrv MTProto 地址")
|
||||
dcID := flag.Int("dc", 2, "DC id")
|
||||
rsaPath := flag.String("rsa", "data/server_rsa.pem", "server RSA key 路径")
|
||||
apiID := flag.Int("api-id", 1, "api_id")
|
||||
apiHash := flag.String("api-hash", "hash", "api_hash")
|
||||
phone := flag.String("phone", "", "登录手机号")
|
||||
code := flag.String("code", "12345", "开发登录码")
|
||||
toID := flag.Int64("to", 0, "收礼用户 id(0=自动从 dialogs 找)")
|
||||
toHash := flag.Int64("to-hash", 0, "收礼用户 access_hash")
|
||||
flag.Parse()
|
||||
if *phone == "" {
|
||||
fmt.Fprintln(os.Stderr, "缺少 -phone")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
logger, _ := zap.NewDevelopment()
|
||||
defer func() { _ = logger.Sync() }()
|
||||
priv, err := mtprotoedge.LoadOrGenerateRSAKey(*rsaPath)
|
||||
if err != nil {
|
||||
logger.Fatal("load RSA key failed", zap.Error(err))
|
||||
}
|
||||
host, portStr, _ := net.SplitHostPort(*addr)
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
client := telegram.NewClient(*apiID, *apiHash, telegram.Options{
|
||||
PublicKeys: []exchange.PublicKey{{RSA: &priv.PublicKey}},
|
||||
Resolver: obfuscatedResolver{host: host, port: port},
|
||||
DCList: dcs.List{Options: []tg.DCOption{{ID: *dcID, IPAddress: host, Port: port, Static: true}}},
|
||||
Logger: logzap.New(logger.Named("client")),
|
||||
})
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := client.Run(ctx, func(ctx context.Context) error {
|
||||
raw := tg.NewClient(client)
|
||||
sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: *phone, APIID: *apiID, APIHash: *apiHash, Settings: tg.CodeSettings{}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("sendCode: %w", err)
|
||||
}
|
||||
sc, ok := sent.(*tg.AuthSentCode)
|
||||
if !ok {
|
||||
return fmt.Errorf("sentCode 类型 = %T", sent)
|
||||
}
|
||||
authz, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: *phone, PhoneCodeHash: sc.PhoneCodeHash, PhoneCode: *code})
|
||||
if err != nil {
|
||||
return fmt.Errorf("signIn: %w", err)
|
||||
}
|
||||
self := authz.(*tg.AuthAuthorization).User.(*tg.User)
|
||||
fmt.Printf("==== 登录: id=%d name=%q ====\n", self.ID, self.FirstName)
|
||||
|
||||
// 1. 目录。
|
||||
gifts := giftCatalog(ctx, raw)
|
||||
if len(gifts) == 0 {
|
||||
return fmt.Errorf("礼物目录为空(animated_emoji 未 seed?)")
|
||||
}
|
||||
gift := gifts[0]
|
||||
fmt.Printf(" 目录 %d 个礼物;选第一个: id=%d stars=%d title=%q sticker=%T\n", len(gifts), gift.ID, gift.Stars, giftTitle(gift), gift.Sticker)
|
||||
|
||||
balBefore := starsBalance(ctx, raw, "扣费前")
|
||||
|
||||
// 2. 收礼用户(-to 指定 id 时从 dialogs 解析其 access_hash;否则取首个 user)。
|
||||
var to *tg.InputPeerUser
|
||||
if *toID != 0 && *toHash != 0 {
|
||||
to = &tg.InputPeerUser{UserID: *toID, AccessHash: *toHash}
|
||||
} else {
|
||||
to = findRecipient(ctx, raw, self.ID, *toID)
|
||||
}
|
||||
if to == nil {
|
||||
return fmt.Errorf("找不到收礼用户(用 -to/-to-hash 指定)")
|
||||
}
|
||||
fmt.Printf(" 收礼用户: id=%d\n", to.UserID)
|
||||
|
||||
inv := &tg.InputInvoiceStarGift{Peer: to, GiftID: gift.ID}
|
||||
|
||||
// 3. getPaymentForm → paymentFormStarGift。
|
||||
formRes, err := raw.PaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
|
||||
if err != nil {
|
||||
return fmt.Errorf("getPaymentForm: %w", err)
|
||||
}
|
||||
form, ok := formRes.(*tg.PaymentsPaymentFormStarGift)
|
||||
if !ok {
|
||||
fmt.Printf(" [FAIL] getPaymentForm 返回 %T,want *PaymentsPaymentFormStarGift(TDesktop 单分支 match)\n", formRes)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf(" getPaymentForm: paymentFormStarGift form_id=%d currency=%s prices=%d\n", form.FormID, form.Invoice.Currency, len(form.Invoice.Prices))
|
||||
if form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) == 0 {
|
||||
fmt.Printf(" [FAIL] invoice 须 XTR + 非空 prices\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. sendStarsForm → paymentResult。
|
||||
payRes, err := raw.PaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv})
|
||||
if err != nil {
|
||||
return fmt.Errorf("sendStarsForm: %w", err)
|
||||
}
|
||||
pay, ok := payRes.(*tg.PaymentsPaymentResult)
|
||||
if !ok {
|
||||
fmt.Printf(" [FAIL] sendStarsForm 返回 %T,want *PaymentsPaymentResult(DrKLO 强转)\n", payRes)
|
||||
return nil
|
||||
}
|
||||
inspectGiftUpdates(pay.Updates)
|
||||
|
||||
balAfter := starsBalance(ctx, raw, "扣费后")
|
||||
fmt.Printf("==== 扣费: %d -> %d,差 %d(期望 -%d)====\n", balBefore, balAfter, balBefore-balAfter, gift.Stars)
|
||||
if balBefore-balAfter == gift.Stars {
|
||||
fmt.Println("==== ✅ PASS:star gift 扣费正确 ====")
|
||||
} else {
|
||||
fmt.Println("==== ❌ FAIL:扣费金额不符 ====")
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
logger.Fatal("run failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func giftCatalog(ctx context.Context, raw *tg.Client) []*tg.StarGift {
|
||||
res, err := raw.PaymentsGetStarGifts(ctx, 0)
|
||||
if err != nil {
|
||||
fmt.Printf(" getStarGifts 失败: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
full, ok := res.(*tg.PaymentsStarGifts)
|
||||
if !ok {
|
||||
fmt.Printf(" getStarGifts 返回 %T\n", res)
|
||||
return nil
|
||||
}
|
||||
out := make([]*tg.StarGift, 0, len(full.Gifts))
|
||||
for _, g := range full.Gifts {
|
||||
if sg, ok := g.(*tg.StarGift); ok {
|
||||
out = append(out, sg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func giftTitle(g *tg.StarGift) string {
|
||||
t, _ := g.GetTitle()
|
||||
return t
|
||||
}
|
||||
|
||||
func starsBalance(ctx context.Context, raw *tg.Client, label string) int64 {
|
||||
status, err := raw.PaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}})
|
||||
if err != nil {
|
||||
fmt.Printf(" [%s] getStarsStatus 失败: %v\n", label, err)
|
||||
return -1
|
||||
}
|
||||
var bal int64
|
||||
if amt, ok := status.Balance.(*tg.StarsAmount); ok {
|
||||
bal = amt.Amount
|
||||
}
|
||||
fmt.Printf(" [%s] 余额=%d stars\n", label, bal)
|
||||
return bal
|
||||
}
|
||||
|
||||
func findRecipient(ctx context.Context, raw *tg.Client, selfID, targetID int64) *tg.InputPeerUser {
|
||||
dlgs, err := raw.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 100})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var users []tg.UserClass
|
||||
switch v := dlgs.(type) {
|
||||
case *tg.MessagesDialogs:
|
||||
users = v.Users
|
||||
case *tg.MessagesDialogsSlice:
|
||||
users = v.Users
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
for _, uc := range users {
|
||||
u, ok := uc.(*tg.User)
|
||||
if !ok || u.ID == selfID || u.Bot || u.Self {
|
||||
continue
|
||||
}
|
||||
if targetID != 0 {
|
||||
if u.ID == targetID {
|
||||
return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash}
|
||||
}
|
||||
continue
|
||||
}
|
||||
return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inspectGiftUpdates(res tg.UpdatesClass) {
|
||||
var ups []tg.UpdateClass
|
||||
switch v := res.(type) {
|
||||
case *tg.Updates:
|
||||
ups = v.Updates
|
||||
case *tg.UpdatesCombined:
|
||||
ups = v.Updates
|
||||
case *tg.UpdateShort:
|
||||
ups = []tg.UpdateClass{v.Update}
|
||||
default:
|
||||
fmt.Printf(" [警告] paymentResult.updates 非 Updates 子类型: %T\n", res)
|
||||
return
|
||||
}
|
||||
hasGiftMsg, hasBalance := false, false
|
||||
for _, up := range ups {
|
||||
switch u := up.(type) {
|
||||
case *tg.UpdateNewMessage:
|
||||
if svc, ok := u.Message.(*tg.MessageService); ok {
|
||||
if a, ok := svc.Action.(*tg.MessageActionStarGift); ok {
|
||||
hasGiftMsg = true
|
||||
gid := int64(0)
|
||||
if sg, ok := a.Gift.(*tg.StarGift); ok {
|
||||
gid = sg.ID
|
||||
}
|
||||
fmt.Printf(" messageActionStarGift: msg_id=%d gift_id=%d\n", u.Message.(*tg.MessageService).ID, gid)
|
||||
}
|
||||
}
|
||||
case *tg.UpdateStarsBalance:
|
||||
hasBalance = true
|
||||
if amt, ok := u.Balance.(*tg.StarsAmount); ok {
|
||||
fmt.Printf(" updateStarsBalance: %d\n", amt.Amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf(" paymentResult: 含礼物服务消息=%v 含 updateStarsBalance=%v\n", hasGiftMsg, hasBalance)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,255 +0,0 @@
|
|||
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: 48_632, want: 64 << 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 TestDownloadPartSizeUsesNonPreciseChunkLadder(t *testing.T) {
|
||||
const oneMiB = 1 << 20
|
||||
for size := int64(1); size < 512<<10; size += 997 {
|
||||
partSize := downloadPartSize(size)
|
||||
if partSize < 4<<10 || partSize > 512<<10 || partSize%(4<<10) != 0 {
|
||||
t.Fatalf("downloadPartSize(%d) = %d is outside the valid 4 KiB-aligned range", size, partSize)
|
||||
}
|
||||
if oneMiB%partSize != 0 {
|
||||
t.Fatalf("downloadPartSize(%d) = %d does not divide a 1 MiB request window", size, partSize)
|
||||
}
|
||||
if int64(partSize) <= size {
|
||||
t.Fatalf("downloadPartSize(%d) = %d does not cover the known-size single chunk", size, partSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,304 +0,0 @@
|
|||
// Command starcheck 是 Stars 付费 reaction 端到端验证工具(开发用,非生产组件)。
|
||||
//
|
||||
// 它以用户身份(开发码 12345)登录本地 telesrv,验证 Stars 本地账本与付费 reaction:
|
||||
// 1. payments.getStarsStatus —— 打印当前余额(Phase 1:惰性首读授予后应 >0)。
|
||||
// 2. 自动从 dialogs 找一个广播频道 + 其顶部消息(或用 -channel-id/-access-hash/-msg-id 指定)。
|
||||
// 3. messages.sendPaidReaction —— 发付费 reaction,打印返回的 Updates 结构
|
||||
// (Phase 2 崩溃约束:必须是 *tg.Updates,应含 updateMessageReactions + updateStarsBalance)。
|
||||
// 4. payments.getStarsStatus —— 再次打印余额,验证已扣 -count。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go run ./cmd/starcheck -phone "+8618800000001" -count 50
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotd/log/logzap"
|
||||
"github.com/iamxvbaba/td/exchange"
|
||||
"github.com/iamxvbaba/td/mtproxy"
|
||||
"github.com/iamxvbaba/td/mtproxy/obfuscator"
|
||||
"github.com/iamxvbaba/td/proto/codec"
|
||||
"github.com/iamxvbaba/td/telegram"
|
||||
"github.com/iamxvbaba/td/telegram/dcs"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/transport"
|
||||
|
||||
"telesrv/internal/mtprotoedge"
|
||||
)
|
||||
|
||||
type obfuscatedResolver struct {
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func (r obfuscatedResolver) dial(ctx context.Context, dc int) (transport.Conn, error) {
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(r.host, strconv.Itoa(r.port)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obf := obfuscator.Obfuscated2(rand.Reader, conn)
|
||||
if err := obf.Handshake(codec.IntermediateClientStart, dc, mtproxy.Secret{}); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("obfuscated2 handshake: %w", err)
|
||||
}
|
||||
proto := transport.NewProtocol(func() transport.Codec {
|
||||
return codec.NoHeader{Codec: codec.Intermediate{}}
|
||||
})
|
||||
tc, err := proto.Handshake(obf)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("transport handshake: %w", err)
|
||||
}
|
||||
return tc, nil
|
||||
}
|
||||
|
||||
func (r obfuscatedResolver) Primary(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
|
||||
return r.dial(ctx, dc)
|
||||
}
|
||||
func (r obfuscatedResolver) MediaOnly(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
|
||||
return r.dial(ctx, dc)
|
||||
}
|
||||
func (r obfuscatedResolver) CDN(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
|
||||
return r.dial(ctx, dc)
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "127.0.0.1:2398", "telesrv MTProto 地址")
|
||||
dcID := flag.Int("dc", 2, "DC id")
|
||||
rsaPath := flag.String("rsa", "data/server_rsa.pem", "server RSA key 路径")
|
||||
apiID := flag.Int("api-id", 1, "api_id")
|
||||
apiHash := flag.String("api-hash", "hash", "api_hash")
|
||||
phone := flag.String("phone", "", "登录手机号,如 +8618800000001")
|
||||
code := flag.String("code", "12345", "开发登录码")
|
||||
count := flag.Int("count", 50, "付费 reaction 星数")
|
||||
channelID := flag.Int64("channel-id", 0, "指定频道 id(0=自动从 dialogs 找广播频道)")
|
||||
accessHash := flag.Int64("access-hash", 0, "指定频道 access_hash")
|
||||
msgID := flag.Int("msg-id", 0, "指定消息 id(0=用频道顶部消息)")
|
||||
flag.Parse()
|
||||
|
||||
if *phone == "" {
|
||||
fmt.Fprintln(os.Stderr, "缺少 -phone")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
logger, _ := zap.NewDevelopment()
|
||||
defer func() { _ = logger.Sync() }()
|
||||
|
||||
priv, err := mtprotoedge.LoadOrGenerateRSAKey(*rsaPath)
|
||||
if err != nil {
|
||||
logger.Fatal("load RSA key failed", zap.Error(err))
|
||||
}
|
||||
host, portStr, err := net.SplitHostPort(*addr)
|
||||
if err != nil {
|
||||
logger.Fatal("parse address failed", zap.Error(err))
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
client := telegram.NewClient(*apiID, *apiHash, telegram.Options{
|
||||
PublicKeys: []exchange.PublicKey{{RSA: &priv.PublicKey}},
|
||||
Resolver: obfuscatedResolver{host: host, port: port},
|
||||
DCList: dcs.List{Options: []tg.DCOption{
|
||||
{ID: *dcID, IPAddress: host, Port: port, Static: true},
|
||||
}},
|
||||
Logger: logzap.New(logger.Named("client")),
|
||||
})
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := client.Run(ctx, func(ctx context.Context) error {
|
||||
raw := tg.NewClient(client)
|
||||
|
||||
// 1. 用户登录(开发码)。
|
||||
sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{
|
||||
PhoneNumber: *phone, APIID: *apiID, APIHash: *apiHash,
|
||||
Settings: tg.CodeSettings{},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("sendCode: %w", err)
|
||||
}
|
||||
sentCode, ok := sent.(*tg.AuthSentCode)
|
||||
if !ok {
|
||||
return fmt.Errorf("sentCode 类型 = %T", sent)
|
||||
}
|
||||
authz, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{
|
||||
PhoneNumber: *phone, PhoneCodeHash: sentCode.PhoneCodeHash, PhoneCode: *code,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("signIn: %w", err)
|
||||
}
|
||||
a, ok := authz.(*tg.AuthAuthorization)
|
||||
if !ok {
|
||||
return fmt.Errorf("authorization 类型 = %T", authz)
|
||||
}
|
||||
self, _ := a.User.(*tg.User)
|
||||
fmt.Printf("==== 登录成功: id=%d name=%q phone=%s ====\n", self.ID, self.FirstName, *phone)
|
||||
|
||||
// 2. 余额(before)。
|
||||
balBefore := printStarsBalance(ctx, raw, "扣费前")
|
||||
|
||||
// 3. 解析目标频道 + 消息。
|
||||
var peer *tg.InputPeerChannel
|
||||
mid := *msgID
|
||||
if *channelID != 0 {
|
||||
peer = &tg.InputPeerChannel{ChannelID: *channelID, AccessHash: *accessHash}
|
||||
} else {
|
||||
ch, topMsg, err := findBroadcastChannel(ctx, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
peer = &tg.InputPeerChannel{ChannelID: ch.ID, AccessHash: ch.AccessHash}
|
||||
if mid == 0 {
|
||||
mid = topMsg
|
||||
}
|
||||
fmt.Printf(" 目标频道: id=%d title=%q access_hash=%d top_msg=%d\n", ch.ID, ch.Title, ch.AccessHash, mid)
|
||||
}
|
||||
if mid == 0 {
|
||||
return fmt.Errorf("无可用消息 id(频道无消息?用 -msg-id 指定)")
|
||||
}
|
||||
|
||||
// 4. 发付费 reaction。
|
||||
rid, _ := randInt64()
|
||||
fmt.Printf(">> sendPaidReaction: channel=%d msg=%d count=%d\n", peer.ChannelID, mid, *count)
|
||||
res, err := raw.MessagesSendPaidReaction(ctx, &tg.MessagesSendPaidReactionRequest{
|
||||
Peer: peer, MsgID: mid, Count: *count, RandomID: rid,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("sendPaidReaction: %w", err)
|
||||
}
|
||||
inspectPaidReactionUpdates(res)
|
||||
|
||||
// 5. 余额(after)。
|
||||
balAfter := printStarsBalance(ctx, raw, "扣费后")
|
||||
fmt.Printf("==== 扣费校验: %d -> %d,差 %d(期望 -%d)====\n", balBefore, balAfter, balBefore-balAfter, *count)
|
||||
if balBefore-balAfter == int64(*count) {
|
||||
fmt.Println("==== ✅ PASS:付费 reaction 扣费正确 ====")
|
||||
} else {
|
||||
fmt.Println("==== ❌ FAIL:扣费金额不符 ====")
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
logger.Fatal("run failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func printStarsBalance(ctx context.Context, raw *tg.Client, label string) int64 {
|
||||
status, err := raw.PaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}})
|
||||
if err != nil {
|
||||
fmt.Printf(" [%s] getStarsStatus 失败: %v\n", label, err)
|
||||
return -1
|
||||
}
|
||||
amount, _ := status.Balance.(*tg.StarsAmount)
|
||||
var bal int64
|
||||
if amount != nil {
|
||||
bal = amount.Amount
|
||||
}
|
||||
fmt.Printf(" [%s] 余额=%d stars (balance 类型=%T, chats=%d users=%d)\n", label, bal, status.Balance, len(status.Chats), len(status.Users))
|
||||
return bal
|
||||
}
|
||||
|
||||
// findBroadcastChannel 从 dialogs 找第一个广播频道 + 其顶部消息 id。
|
||||
func findBroadcastChannel(ctx context.Context, raw *tg.Client) (*tg.Channel, int, error) {
|
||||
dlgs, err := raw.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{
|
||||
OffsetPeer: &tg.InputPeerEmpty{}, Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("getDialogs: %w", err)
|
||||
}
|
||||
var chats []tg.ChatClass
|
||||
var dialogs []tg.DialogClass
|
||||
switch v := dlgs.(type) {
|
||||
case *tg.MessagesDialogs:
|
||||
chats, dialogs = v.Chats, v.Dialogs
|
||||
case *tg.MessagesDialogsSlice:
|
||||
chats, dialogs = v.Chats, v.Dialogs
|
||||
default:
|
||||
return nil, 0, fmt.Errorf("dialogs 类型 = %T", dlgs)
|
||||
}
|
||||
topByChannel := make(map[int64]int)
|
||||
for _, d := range dialogs {
|
||||
dd, ok := d.(*tg.Dialog)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if pc, ok := dd.Peer.(*tg.PeerChannel); ok {
|
||||
topByChannel[pc.ChannelID] = dd.TopMessage
|
||||
}
|
||||
}
|
||||
for _, c := range chats {
|
||||
ch, ok := c.(*tg.Channel)
|
||||
if !ok || !ch.Broadcast || ch.Megagroup {
|
||||
continue
|
||||
}
|
||||
return ch, topByChannel[ch.ID], nil
|
||||
}
|
||||
return nil, 0, fmt.Errorf("dialogs 中无广播频道")
|
||||
}
|
||||
|
||||
// inspectPaidReactionUpdates 检查返回的 Updates 是否合法且含 updateMessageReactions/updateStarsBalance。
|
||||
func inspectPaidReactionUpdates(res tg.UpdatesClass) {
|
||||
fmt.Printf(" 返回类型=%T\n", res)
|
||||
var ups []tg.UpdateClass
|
||||
switch v := res.(type) {
|
||||
case *tg.Updates:
|
||||
ups = v.Updates
|
||||
case *tg.UpdatesCombined:
|
||||
ups = v.Updates
|
||||
case *tg.UpdateShort:
|
||||
ups = []tg.UpdateClass{v.Update}
|
||||
default:
|
||||
fmt.Printf(" [警告] 返回非 Updates 子类型(DrKLO 会 ClassCastException 崩溃)\n")
|
||||
return
|
||||
}
|
||||
hasReactions, hasBalance := false, false
|
||||
for _, up := range ups {
|
||||
switch u := up.(type) {
|
||||
case *tg.UpdateMessageReactions:
|
||||
hasReactions = true
|
||||
paid := 0
|
||||
for _, rc := range u.Reactions.Results {
|
||||
if _, ok := rc.Reaction.(*tg.ReactionPaid); ok {
|
||||
paid = rc.Count
|
||||
}
|
||||
}
|
||||
reactors, _ := u.Reactions.GetTopReactors()
|
||||
fmt.Printf(" updateMessageReactions: msg=%d paid_count=%d top_reactors=%d\n", u.MsgID, paid, len(reactors))
|
||||
case *tg.UpdateStarsBalance:
|
||||
hasBalance = true
|
||||
amount, _ := u.Balance.(*tg.StarsAmount)
|
||||
var b int64
|
||||
if amount != nil {
|
||||
b = amount.Amount
|
||||
}
|
||||
fmt.Printf(" updateStarsBalance: balance=%d\n", b)
|
||||
}
|
||||
}
|
||||
fmt.Printf(" 合法 Updates=true, 含 updateMessageReactions=%v, 含 updateStarsBalance=%v\n", hasReactions, hasBalance)
|
||||
}
|
||||
|
||||
func randInt64() (int64, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return time.Now().UnixNano(), err
|
||||
}
|
||||
v := int64(binary.LittleEndian.Uint64(b[:]))
|
||||
if v == 0 {
|
||||
v = 1
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
|
@ -24,14 +24,11 @@ const (
|
|||
channelListDefaultLimit = 50
|
||||
channelListMaxLimit = 100
|
||||
messagePageLimit = 100
|
||||
// Collectible username and account rating pages. The bounds mirror the
|
||||
// use-case layer, so a table page costs the same whichever surface asks.
|
||||
// Collectible username pages. The bounds mirror the use-case layer, so a
|
||||
// table page costs the same whichever surface asks.
|
||||
collectibleListDefaultLimit = 50
|
||||
collectibleListMaxLimit = 200
|
||||
collectibleTransferLimit = 50
|
||||
ratingListDefaultLimit = 50
|
||||
ratingListMaxLimit = 200
|
||||
ratingEventLimit = 50
|
||||
// Verification review queue pages. The bounds mirror app/verification, so the
|
||||
// panel and the admin API page the queue identically.
|
||||
verificationListDefaultLimit = 50
|
||||
|
|
@ -123,8 +120,6 @@ type AccountDetail struct {
|
|||
Fake bool
|
||||
Support bool
|
||||
Bot bool
|
||||
StarsBalance int64
|
||||
StarsGranted bool
|
||||
Restriction RestrictionRow
|
||||
HasRestriction bool
|
||||
Authorizations []AuthorizationRow
|
||||
|
|
@ -237,65 +232,10 @@ type ChannelDetail struct {
|
|||
AuditLogs []AuditLogRow
|
||||
}
|
||||
|
||||
type StarGiftRow struct {
|
||||
GiftID int64 `json:"GiftID,string"`
|
||||
RevisionID int64 `json:"RevisionID,string"`
|
||||
Revision int
|
||||
Title string
|
||||
Stars int64 `json:"Stars,string"`
|
||||
ConvertStars int64 `json:"ConvertStars,string"`
|
||||
Enabled bool
|
||||
SortOrder int
|
||||
DocumentID int64 `json:"DocumentID,string"`
|
||||
SourceName string
|
||||
SourceFormat string
|
||||
AnimationSHA string
|
||||
AnimationSize int64 `json:"AnimationSize,string"`
|
||||
Width int
|
||||
Height int
|
||||
FrameRate float64
|
||||
ReceivedCount int64 `json:"ReceivedCount,string"`
|
||||
CreatedBy string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *readStore) ListStarGifts(ctx context.Context) ([]StarGiftRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT c.gift_id, r.id, r.revision, r.title, r.stars, r.convert_stars,
|
||||
c.enabled, c.sort_order, r.document_id, r.source_name, r.source_format,
|
||||
encode(r.animation_sha256, 'hex'), d.size, r.width, r.height, r.frame_rate,
|
||||
(SELECT COUNT(*) FROM peer_star_gifts p WHERE p.gift_id = c.gift_id),
|
||||
r.created_by, c.updated_at
|
||||
FROM star_gift_catalog c
|
||||
JOIN star_gift_catalog_revisions r ON r.id = c.active_revision_id
|
||||
JOIN documents d ON d.id = r.document_id
|
||||
ORDER BY c.sort_order, c.gift_id
|
||||
LIMIT $1`, domain.MaxStarGiftCatalogSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list star gifts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]StarGiftRow, 0)
|
||||
for rows.Next() {
|
||||
var row StarGiftRow
|
||||
if err := rows.Scan(
|
||||
&row.GiftID, &row.RevisionID, &row.Revision, &row.Title, &row.Stars, &row.ConvertStars,
|
||||
&row.Enabled, &row.SortOrder, &row.DocumentID, &row.SourceName, &row.SourceFormat,
|
||||
&row.AnimationSHA, &row.AnimationSize, &row.Width, &row.Height, &row.FrameRate,
|
||||
&row.ReceivedCount, &row.CreatedBy, &row.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type StickerSetRow struct {
|
||||
// ID must round-trip through JSON as a string: these are Telegram-style
|
||||
// snowflake ids (18-19 digits), well past JS's 2^53 safe-integer limit, so
|
||||
// a plain JSON number gets silently rounded by the browser (see StarGiftRow
|
||||
// for the same fix applied to gift ids).
|
||||
// a plain JSON number gets silently rounded by the browser.
|
||||
ID int64 `json:"ID,string"`
|
||||
ShortName string
|
||||
Title string
|
||||
|
|
@ -986,17 +926,15 @@ SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.upd
|
|||
u.about, u.last_seen_at, u.verified, u.scam, u.fake, u.support, u.is_bot,
|
||||
COALESCE(r.frozen, false), COALESCE(r.reason, ''),
|
||||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
|
||||
COALESCE(sb.balance, 0)::bigint, COALESCE(sb.granted, false),
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
|
||||
`+accountCollectibleUsernamesColumn+` AS collectibles
|
||||
FROM users u
|
||||
LEFT JOIN account_restrictions r ON r.user_id = u.id
|
||||
LEFT JOIN stars_balances sb ON sb.user_id = u.id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable
|
||||
WHERE u.id = $1`, userID).Scan(
|
||||
&out.Account.ID, &out.Account.Phone, &out.Account.Username, &out.Account.FirstName, &out.Account.LastName,
|
||||
&out.Account.CreatedAt, &out.Account.UpdatedAt, &out.About, &out.LastSeenAt, &out.Verified, &out.Scam, &out.Fake, &out.Support, &out.Bot,
|
||||
&out.Account.Frozen, &out.Account.Reason, &out.Account.PremiumUntil, &out.StarsBalance, &out.StarsGranted, &out.Account.Username,
|
||||
&out.Account.Frozen, &out.Account.Reason, &out.Account.PremiumUntil, &out.Account.Username,
|
||||
&out.Account.Collectibles,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -1674,246 +1612,6 @@ LIMIT $2`, collectibleID, collectibleTransferLimit)
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AccountRatingRow is one user's composite rating projection with the account
|
||||
// resolved for display. The score and every component are int64 decimal strings
|
||||
// for the same exactness reason as the collectible amounts.
|
||||
type AccountRatingRow struct {
|
||||
UserID int64 `json:"UserID,string"`
|
||||
Username string
|
||||
FirstName string
|
||||
Level int
|
||||
Stars int64 `json:"Stars,string"`
|
||||
CurrentLevelStars int64 `json:"CurrentLevelStars,string"`
|
||||
NextLevelStars int64 `json:"NextLevelStars,string"`
|
||||
HasNextLevel bool
|
||||
StarsComponent int64 `json:"StarsComponent,string"`
|
||||
ActivityComponent int64 `json:"ActivityComponent,string"`
|
||||
PenaltyComponent int64 `json:"PenaltyComponent,string"`
|
||||
ManualComponent int64 `json:"ManualComponent,string"`
|
||||
PendingStars int64 `json:"PendingStars,string"`
|
||||
PendingDate time.Time
|
||||
ComputedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Version int64 `json:"Version,string"`
|
||||
// Computed is false for an account that has no stored projection yet. The
|
||||
// detail view still renders it, so the operator can trigger the first
|
||||
// recompute instead of facing a dead end.
|
||||
Computed bool
|
||||
}
|
||||
|
||||
// AccountRatingEventRow is one contribution ledger entry.
|
||||
type AccountRatingEventRow struct {
|
||||
ID int64 `json:"ID,string"`
|
||||
UserID int64 `json:"UserID,string"`
|
||||
Kind string
|
||||
Amount int64 `json:"Amount,string"`
|
||||
Reason string
|
||||
Actor string
|
||||
CommandKey string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AccountRatingDetail is the projection plus the ledger that explains it.
|
||||
type AccountRatingDetail struct {
|
||||
Rating AccountRatingRow
|
||||
Events []AccountRatingEventRow
|
||||
}
|
||||
|
||||
const accountRatingSelectColumns = `r.user_id,
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
|
||||
COALESCE(u.first_name, ''),
|
||||
r.level, r.stars, r.current_level_stars, r.next_level_stars,
|
||||
r.stars_component, r.activity_component, r.penalty_component, r.manual_component,
|
||||
r.pending_stars, r.pending_date, r.computed_at, r.updated_at, r.version`
|
||||
|
||||
const accountRatingJoins = `
|
||||
FROM account_rating r
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = r.user_id AND p.editable`
|
||||
|
||||
func scanAccountRatingRow(scan func(dest ...any) error, item *AccountRatingRow) error {
|
||||
// next_level_stars and pending_date are nullable: the first is NULL at the top
|
||||
// level, the second whenever no score is parked.
|
||||
var nextLevelStars *int64
|
||||
var pendingDate *time.Time
|
||||
if err := scan(
|
||||
&item.UserID, &item.Username, &item.FirstName,
|
||||
&item.Level, &item.Stars, &item.CurrentLevelStars, &nextLevelStars,
|
||||
&item.StarsComponent, &item.ActivityComponent, &item.PenaltyComponent, &item.ManualComponent,
|
||||
&item.PendingStars, &pendingDate, &item.ComputedAt, &item.UpdatedAt, &item.Version,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
// A NULL next threshold is the maxed-out level: the TL flag is omitted, so the
|
||||
// panel must render "no next level" instead of a next level of zero.
|
||||
item.HasNextLevel = nextLevelStars != nil
|
||||
if nextLevelStars != nil {
|
||||
item.NextLevelStars = *nextLevelStars
|
||||
}
|
||||
if pendingDate != nil {
|
||||
item.PendingDate = pendingDate.UTC()
|
||||
}
|
||||
item.Computed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAccountRatings pages the leaderboard. Ordering and the keyset predicate
|
||||
// mirror the rating store exactly -- (level DESC, stars DESC, user_id) with the
|
||||
// cursor row resolved from beforeID -- so both surfaces page identically.
|
||||
// ListAccountRatings pages the leaderboard. query is a free-text operator search:
|
||||
// it matches a username prefix (editable or collectible), a first/last name
|
||||
// prefix, and -- when the term is numeric -- the user id, so an operator can find
|
||||
// an account the same way they do on the accounts tab.
|
||||
func (s *readStore) ListAccountRatings(ctx context.Context, minLevel int, userID, beforeID int64, limit int, query string) ([]AccountRatingRow, bool, error) {
|
||||
if limit <= 0 {
|
||||
limit = ratingListDefaultLimit
|
||||
}
|
||||
if limit > ratingListMaxLimit {
|
||||
limit = ratingListMaxLimit
|
||||
}
|
||||
if minLevel < 0 {
|
||||
minLevel = 0
|
||||
}
|
||||
if minLevel > domain.MaxAccountRatingLevel {
|
||||
minLevel = domain.MaxAccountRatingLevel
|
||||
}
|
||||
query = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(query), "@"))
|
||||
pattern := ""
|
||||
queryUserID := int64(0)
|
||||
if query != "" {
|
||||
pattern = strings.ToLower(escapeLikePattern(query)) + "%"
|
||||
if parsed, err := strconv.ParseInt(query, 10, 64); err == nil && parsed > 0 {
|
||||
queryUserID = parsed
|
||||
}
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH cursor_row AS (
|
||||
SELECT level AS c_level, stars AS c_stars, user_id AS c_user_id
|
||||
FROM account_rating WHERE $3::bigint <> 0 AND user_id = $3
|
||||
)
|
||||
SELECT `+accountRatingSelectColumns+accountRatingJoins+`
|
||||
LEFT JOIN cursor_row c ON true
|
||||
WHERE r.level >= $1
|
||||
AND ($2::bigint = 0 OR r.user_id = $2)
|
||||
AND ($5::text = '' OR (
|
||||
($6::bigint <> 0 AND r.user_id = $6)
|
||||
OR lower(COALESCE(u.username, '')) LIKE $5
|
||||
OR lower(COALESCE(u.first_name, '')) LIKE $5
|
||||
OR lower(COALESCE(u.last_name, '')) LIKE $5
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM peer_usernames pu
|
||||
WHERE pu.peer_type = 'user' AND pu.peer_id = r.user_id
|
||||
AND pu.username_lower LIKE $5
|
||||
)
|
||||
))
|
||||
AND (
|
||||
c.c_user_id IS NULL
|
||||
OR r.level < c.c_level
|
||||
OR (r.level = c.c_level AND r.stars < c.c_stars)
|
||||
OR (r.level = c.c_level AND r.stars = c.c_stars AND r.user_id > c.c_user_id)
|
||||
)
|
||||
ORDER BY r.level DESC, r.stars DESC, r.user_id
|
||||
LIMIT $4`, minLevel, userID, beforeID, limit+1, pattern, queryUserID)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list account ratings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]AccountRatingRow, 0, limit+1)
|
||||
for rows.Next() {
|
||||
var item AccountRatingRow
|
||||
if err := scanAccountRatingRow(rows.Scan, &item); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(out) > limit
|
||||
if hasMore {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
// AccountRatingDetail returns one user's projection with its contribution
|
||||
// ledger.
|
||||
//
|
||||
// An account that exists but was never computed is answered with a zero-valued
|
||||
// projection carrying Computed=false, because the recompute command lives on this
|
||||
// very page: reporting "not found" for a real account would leave the operator
|
||||
// with no way to create the first projection. Only an unknown account is a 404.
|
||||
func (s *readStore) AccountRatingDetail(ctx context.Context, userID int64) (AccountRatingDetail, error) {
|
||||
var out AccountRatingDetail
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT `+accountRatingSelectColumns+accountRatingJoins+`
|
||||
WHERE r.user_id = $1`, userID)
|
||||
err := scanAccountRatingRow(row.Scan, &out.Rating)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
placeholder, uncomputedErr := s.uncomputedAccountRating(ctx, userID)
|
||||
if uncomputedErr != nil {
|
||||
return out, uncomputedErr
|
||||
}
|
||||
out.Rating = placeholder
|
||||
default:
|
||||
return out, fmt.Errorf("get account rating: %w", err)
|
||||
}
|
||||
events, err := s.accountRatingEvents(ctx, userID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Events = events
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// uncomputedAccountRating renders the projection an account would start from,
|
||||
// derived through the same threshold policy the store persists, so the panel's
|
||||
// level maths does not have to special-case a missing row.
|
||||
func (s *readStore) uncomputedAccountRating(ctx context.Context, userID int64) (AccountRatingRow, error) {
|
||||
var row AccountRatingRow
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name
|
||||
FROM users u
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable
|
||||
WHERE u.id = $1`, userID).Scan(&row.UserID, &row.Username, &row.FirstName)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return row, errReadNotFound
|
||||
}
|
||||
return row, fmt.Errorf("get account for rating: %w", err)
|
||||
}
|
||||
level, current, next, hasNext := domain.AccountRatingLevelForStars(0)
|
||||
row.Level = level
|
||||
row.CurrentLevelStars = current
|
||||
row.NextLevelStars = next
|
||||
row.HasNextLevel = hasNext
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *readStore) accountRatingEvents(ctx context.Context, userID int64) ([]AccountRatingEventRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at
|
||||
FROM account_rating_events
|
||||
WHERE user_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT $2`, userID, ratingEventLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account rating events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]AccountRatingEventRow, 0)
|
||||
for rows.Next() {
|
||||
var item AccountRatingEventRow
|
||||
if err := rows.Scan(&item.ID, &item.UserID, &item.Kind, &item.Amount, &item.Reason, &item.Actor, &item.CommandKey, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Official platform verification review queue.
|
||||
//
|
||||
// The application record is the audit subject and is read here directly, with the
|
||||
|
|
|
|||
|
|
@ -69,18 +69,8 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("GET /api/messages/detail", s.requireAuthAPI(http.HandlerFunc(s.handleMessageDetailAPI)))
|
||||
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/default-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleDefaultStarGiftsAPI)))
|
||||
mux.Handle("GET /api/default-gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleDefaultStarGiftAnimationAPI)))
|
||||
mux.Handle("GET /api/official-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftsAPI)))
|
||||
mux.Handle("GET /api/official-gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftAnimationAPI)))
|
||||
mux.Handle("GET /api/gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftAnimationAPI)))
|
||||
mux.Handle("GET /api/gifts/{id}/collectibles", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectiblesAPI)))
|
||||
mux.Handle("GET /api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectibleAnimationAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
|
||||
mux.Handle("GET /api/account-ratings", s.requireAuthAPI(http.HandlerFunc(s.handleAccountRatingsAPI)))
|
||||
mux.Handle("GET /api/account-ratings/{user_id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountRatingDetailAPI)))
|
||||
mux.Handle("GET /api/storage/stats", s.requireAuthAPI(http.HandlerFunc(s.handleStorageStatsAPI)))
|
||||
mux.Handle("GET /api/storage/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleStorageAccountsAPI)))
|
||||
mux.Handle("GET /api/moderation/cases", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCasesAPI)))
|
||||
|
|
@ -91,7 +81,6 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/moderation/cases/{id}/appeals/{appeal_id}/review", s.requireAuthAPI(http.HandlerFunc(s.handleReviewModerationAppealAPI)))
|
||||
mux.Handle("POST /api/actions/set-frozen", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountFrozenAPI)))
|
||||
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
|
||||
mux.Handle("POST /api/actions/grant-stars", s.requireAuthAPI(http.HandlerFunc(s.handleGrantStarsAPI)))
|
||||
mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserFlagsAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelFlagsAPI)))
|
||||
|
|
@ -116,14 +105,6 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
|
||||
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-default-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportDefaultStarGiftAPI)))
|
||||
mux.Handle("POST /api/actions/import-all-default-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleImportAllDefaultStarGiftsAPI)))
|
||||
mux.Handle("POST /api/actions/import-official-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportOfficialStarGiftAPI)))
|
||||
mux.Handle("POST /api/actions/import-all-official-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleImportAllOfficialStarGiftsAPI)))
|
||||
mux.Handle("POST /api/actions/publish-gift-collectibles", s.requireAuthAPI(http.HandlerFunc(s.handlePublishStarGiftCollectiblesAPI)))
|
||||
mux.Handle("POST /api/actions/set-gift-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftEnabledAPI)))
|
||||
mux.Handle("POST /api/actions/set-gift-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftSortOrderAPI)))
|
||||
mux.Handle("GET /api/stickers", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetsAPI)))
|
||||
mux.Handle("GET /api/stickers/{id}/documents", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetDocumentsAPI)))
|
||||
mux.Handle("GET /api/stickers/documents/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStickerDocumentAnimationAPI)))
|
||||
|
|
@ -134,13 +115,10 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/create-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleCreateStickerSetAPI)))
|
||||
mux.Handle("POST /api/actions/add-sticker-to-set", s.requireAuthAPI(http.HandlerFunc(s.handleAddStickerToSetAPI)))
|
||||
mux.Handle("POST /api/actions/remove-sticker-from-set", s.requireAuthAPI(http.HandlerFunc(s.handleRemoveStickerFromSetAPI)))
|
||||
mux.Handle("POST /api/actions/give-gift", s.requireAuthAPI(http.HandlerFunc(s.handleGiveGiftAPI)))
|
||||
mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/delete-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/recompute-account-rating", s.requireAuthAPI(http.HandlerFunc(s.handleRecomputeAccountRatingAPI)))
|
||||
mux.Handle("POST /api/actions/adjust-account-rating", s.requireAuthAPI(http.HandlerFunc(s.handleAdjustAccountRatingAPI)))
|
||||
// Official platform verification. Every route needs verification.review;
|
||||
// clearing an existing badge needs verification.revoke on top of it.
|
||||
mux.Handle("GET /api/verification/applications", s.verificationRead(s.handleVerificationApplicationsAPI))
|
||||
|
|
@ -286,19 +264,6 @@ func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
}
|
||||
|
||||
func (s *server) handleStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
rows, err := s.read.ListStarGifts(r.Context())
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"Gifts": rows})
|
||||
}
|
||||
|
||||
func (s *server) handleEmojiAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
|
|
@ -366,86 +331,6 @@ func (s *server) handleEmojiAnimationAPI(w http.ResponseWriter, r *http.Request)
|
|||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *server) handleStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || giftID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid gift id")
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
|
||||
fmt.Sprintf("%s/v1/gifts/%d/animation", s.cfg.AdminAPIURL, giftID), nil)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, (4<<20)+1))
|
||||
if err != nil || len(raw) > 4<<20 {
|
||||
writeAPIError(w, http.StatusBadGateway, "invalid animation response")
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
writeAPIError(w, resp.StatusCode, string(raw))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "private, max-age=60")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *server) handleStarGiftCollectiblesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || giftID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid gift id")
|
||||
return
|
||||
}
|
||||
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/gifts/%d/collectibles", giftID), 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleDefaultStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
s.proxyAdminJSON(w, r, "/v1/default-gifts", 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleDefaultStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if _, err := strconv.Atoi(id); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid default gift id")
|
||||
return
|
||||
}
|
||||
s.proxyAdminJSON(w, r, "/v1/default-gifts/"+id+"/animation", 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
s.proxyAdminJSON(w, r, "/v1/official-gifts", 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleOfficialStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
|
||||
return
|
||||
}
|
||||
s.proxyAdminJSON(w, r, "/v1/official-gifts/"+id+"/animation", 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleStarGiftCollectibleAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
attributeID, attrErr := strconv.ParseInt(r.PathValue("attribute_id"), 10, 64)
|
||||
kind := r.PathValue("kind")
|
||||
if err != nil || giftID <= 0 || attrErr != nil || attributeID <= 0 || (kind != "model" && kind != "pattern") {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid collectible animation")
|
||||
return
|
||||
}
|
||||
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/gifts/%d/collectibles/%s/%d/animation", giftID, kind, attributeID), 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) proxyAdminJSON(w http.ResponseWriter, r *http.Request, apiPath string, maxBytes int64) {
|
||||
s.proxyAdminJSONWithCache(w, r, apiPath, maxBytes, "private, max-age=30")
|
||||
}
|
||||
|
|
@ -1254,28 +1139,6 @@ func (s *server) handleGrantPremiumAPI(w http.ResponseWriter, r *http.Request) {
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type grantStarsAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
}
|
||||
|
||||
func (s *server) handleGrantStarsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body grantStarsAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.GrantStarsRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "grant-stars"),
|
||||
UserID: body.UserID,
|
||||
Amount: body.Amount,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/grant-stars", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setVerifiedAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
|
|
@ -1766,287 +1629,6 @@ func (s *server) handleDeleteHistoryAPI(w http.ResponseWriter, r *http.Request)
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type importStarGiftAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportStarGiftAPI(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 5<<20)
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var body importStarGiftAPIRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "animation file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, (4<<20)+1))
|
||||
if err != nil || len(data) == 0 || len(data) > 4<<20 {
|
||||
writeAPIError(w, http.StatusBadRequest, "animation file is empty or too large")
|
||||
return
|
||||
}
|
||||
req := admin.ImportStarGiftRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-gift"),
|
||||
GiftID: body.GiftID,
|
||||
Title: body.Title,
|
||||
Stars: body.Stars,
|
||||
ConvertStars: body.ConvertStars,
|
||||
Enabled: body.Enabled,
|
||||
SortOrder: body.SortOrder,
|
||||
FileName: header.Filename,
|
||||
}
|
||||
result, err := s.callAdminMultipart(r.Context(), "/v1/gifts/import", req, header.Filename, data)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type importDefaultStarGiftAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
ID int `json:"id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportDefaultStarGiftAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body importDefaultStarGiftAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.ID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid default gift id")
|
||||
return
|
||||
}
|
||||
req := admin.ImportDefaultStarGiftRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-default-gift"),
|
||||
ID: body.ID,
|
||||
Enabled: body.Enabled,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/default-gifts/import", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type importAllDefaultStarGiftsAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportAllDefaultStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body importAllDefaultStarGiftsAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.ImportAllDefaultStarGiftsRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-all-default-gifts"),
|
||||
Enabled: body.Enabled,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/default-gifts/import-all", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type importOfficialStarGiftAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
SourceGiftID string `json:"source_gift_id"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
Title string `json:"title"`
|
||||
Stars int64 `json:"stars,string"`
|
||||
ConvertStars int64 `json:"convert_stars,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IncludeCollectible bool `json:"include_collectible"`
|
||||
UpgradeStars int64 `json:"upgrade_stars,string"`
|
||||
SupplyTotal int `json:"supply_total"`
|
||||
SlugPrefix string `json:"slug_prefix"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportOfficialStarGiftAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body importOfficialStarGiftAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if _, err := strconv.ParseInt(strings.TrimSpace(body.SourceGiftID), 10, 64); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
|
||||
return
|
||||
}
|
||||
req := admin.ImportOfficialStarGiftRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-official-gift"),
|
||||
SourceGiftID: body.SourceGiftID, GiftID: body.GiftID, Title: body.Title,
|
||||
Stars: body.Stars, ConvertStars: body.ConvertStars, Enabled: body.Enabled, SortOrder: body.SortOrder,
|
||||
IncludeCollectible: body.IncludeCollectible, UpgradeStars: body.UpgradeStars,
|
||||
SupplyTotal: body.SupplyTotal, SlugPrefix: body.SlugPrefix,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type importAllOfficialStarGiftsAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportAllOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body importAllOfficialStarGiftsAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.ImportAllOfficialStarGiftsRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-all-official-gifts"),
|
||||
Enabled: body.Enabled,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import-all", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type publishStarGiftCollectiblesAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UpgradeStars int64 `json:"upgrade_stars,string"`
|
||||
SupplyTotal int `json:"supply_total"`
|
||||
SlugPrefix string `json:"slug_prefix"`
|
||||
Models []admin.StarGiftCollectibleAnimationUpload `json:"models"`
|
||||
Patterns []admin.StarGiftCollectibleAnimationUpload `json:"patterns"`
|
||||
Backdrops []admin.StarGiftCollectibleBackdropInput `json:"backdrops"`
|
||||
}
|
||||
|
||||
func (s *server) handlePublishStarGiftCollectiblesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
giftID, err := strconv.ParseInt(r.URL.Query().Get("gift_id"), 10, 64)
|
||||
if err != nil || giftID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid gift id")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<20)
|
||||
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid collectible multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var body publishStarGiftCollectiblesAPIRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(body.Models)+len(body.Patterns) > 128 {
|
||||
writeAPIError(w, http.StatusBadRequest, "too many collectible animation files")
|
||||
return
|
||||
}
|
||||
seen := make(map[string]struct{}, len(body.Models)+len(body.Patterns))
|
||||
load := func(upload *admin.StarGiftCollectibleAnimationUpload) error {
|
||||
upload.FileKey = strings.TrimSpace(upload.FileKey)
|
||||
if upload.FileKey == "" {
|
||||
return errors.New("animation file key is required")
|
||||
}
|
||||
if _, ok := seen[upload.FileKey]; ok {
|
||||
return fmt.Errorf("duplicate animation file key %q", upload.FileKey)
|
||||
}
|
||||
seen[upload.FileKey] = struct{}{}
|
||||
file, header, err := r.FormFile(upload.FileKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("animation file %q is required", upload.FileKey)
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, (4<<20)+1))
|
||||
if err != nil || len(data) == 0 || len(data) > 4<<20 {
|
||||
return fmt.Errorf("animation file %q is empty or too large", upload.FileKey)
|
||||
}
|
||||
upload.FileName = header.Filename
|
||||
upload.Data = data
|
||||
return nil
|
||||
}
|
||||
for i := range body.Models {
|
||||
if err := load(&body.Models[i]); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
for i := range body.Patterns {
|
||||
if err := load(&body.Patterns[i]); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
req := admin.PublishStarGiftCollectiblesRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "publish-gift-collectibles"),
|
||||
GiftID: giftID, UpgradeStars: body.UpgradeStars, SupplyTotal: body.SupplyTotal,
|
||||
SlugPrefix: body.SlugPrefix, Models: body.Models, Patterns: body.Patterns, Backdrops: body.Backdrops,
|
||||
}
|
||||
result, err := s.callAdminCollectibleMultipart(r.Context(), fmt.Sprintf("/v1/gifts/%d/collectibles/publish", giftID), req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setStarGiftEnabledAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetStarGiftEnabledAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setStarGiftEnabledAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetStarGiftEnabledRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-gift-enabled"),
|
||||
GiftID: body.GiftID, Enabled: body.Enabled,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/set-enabled", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setStarGiftSortOrderAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetStarGiftSortOrderAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setStarGiftSortOrderAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetStarGiftSortOrderRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-gift-sort-order"),
|
||||
GiftID: body.GiftID, SortOrder: body.SortOrder,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/set-sort-order", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type renameStickerSetAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
|
|
@ -2234,7 +1816,7 @@ func (s *server) handleStickerSetDocumentsAPI(w http.ResponseWriter, r *http.Req
|
|||
}
|
||||
|
||||
// handleStickerDocumentAnimationAPI proxies one document's decompressed Lottie
|
||||
// JSON from the real telesrv admin API — mirrors handleStarGiftAnimationAPI.
|
||||
// JSON from the real telesrv admin API.
|
||||
func (s *server) handleStickerDocumentAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
documentID, err := parseInt64(r.PathValue("id"))
|
||||
if err != nil || documentID <= 0 {
|
||||
|
|
@ -2319,44 +1901,6 @@ func (s *server) handleSetStickerSetSortOrderAPI(w http.ResponseWriter, r *http.
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type giveGiftAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
SenderUserID int64 `json:"sender_user_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
HideName bool `json:"hide_name"`
|
||||
Message string `json:"message"`
|
||||
Upgrade bool `json:"upgrade"`
|
||||
ModelAttributeID int64 `json:"model_attribute_id,string"`
|
||||
PatternAttributeID int64 `json:"pattern_attribute_id,string"`
|
||||
BackdropAttributeID int64 `json:"backdrop_attribute_id,string"`
|
||||
}
|
||||
|
||||
func (s *server) handleGiveGiftAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body giveGiftAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.GiveGiftRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "give-gift"),
|
||||
SenderUserID: body.SenderUserID,
|
||||
UserID: body.UserID,
|
||||
ChannelID: body.ChannelID,
|
||||
GiftID: body.GiftID,
|
||||
HideName: body.HideName,
|
||||
Message: body.Message,
|
||||
Upgrade: body.Upgrade,
|
||||
ModelAttributeID: body.ModelAttributeID,
|
||||
PatternAttributeID: body.PatternAttributeID,
|
||||
BackdropAttributeID: body.BackdropAttributeID,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/give", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
// flexInt64 decodes an int64 the panel may send either as a JSON number or as a
|
||||
// decimal string. Ids and nanoton amounts are sent as strings to stay exact past
|
||||
// 2^53, while a picker-supplied peer id arrives as a plain number; an empty
|
||||
|
|
@ -2532,48 +2076,6 @@ func (s *server) handleDeleteCollectibleUsernameAPI(w http.ResponseWriter, r *ht
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type recomputeAccountRatingAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID flexInt64 `json:"user_id"`
|
||||
}
|
||||
|
||||
func (s *server) handleRecomputeAccountRatingAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body recomputeAccountRatingAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.RecomputeAccountRatingRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "recompute-account-rating"),
|
||||
UserID: body.UserID.Int64(),
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/account-ratings/recompute", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type adjustAccountRatingAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID flexInt64 `json:"user_id"`
|
||||
Amount flexInt64 `json:"amount"`
|
||||
}
|
||||
|
||||
func (s *server) handleAdjustAccountRatingAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body adjustAccountRatingAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.AdjustAccountRatingRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "adjust-account-rating"),
|
||||
UserID: body.UserID.Int64(),
|
||||
Amount: body.Amount.Int64(),
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/account-ratings/adjust", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
// handleCollectibleUsernamesAPI pages the collectible asset table straight from
|
||||
// PostgreSQL, like every other table view, and echoes the keyset cursor as a
|
||||
// decimal string so an int64 id survives the round trip through the browser.
|
||||
|
|
@ -2648,51 +2150,6 @@ func (s *server) handleCollectibleUsernameDetailAPI(w http.ResponseWriter, r *ht
|
|||
})
|
||||
}
|
||||
|
||||
// handleAccountRatingsAPI pages the leaderboard. next_before_id is the last
|
||||
// user id: the keyset predicate resolves the full (level, stars, user_id) cursor
|
||||
// from it, so one opaque-looking value is enough to continue the page.
|
||||
func (s *server) handleAccountRatingsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
minLevel, err := parseInt(query.Get("min_level"))
|
||||
if err != nil || minLevel < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid min_level")
|
||||
return
|
||||
}
|
||||
userID, err := parseInt64(query.Get("user_id"))
|
||||
if err != nil || userID < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid user_id")
|
||||
return
|
||||
}
|
||||
beforeID, err := parseInt64(query.Get("before_id"))
|
||||
if err != nil || beforeID < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid before_id")
|
||||
return
|
||||
}
|
||||
limit, err := parseInt(query.Get("limit"))
|
||||
if err != nil || limit < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
rows, hasMore, err := s.read.ListAccountRatings(r.Context(), minLevel, userID, beforeID, limit, query.Get("q"))
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
nextBeforeID := ""
|
||||
if hasMore && len(rows) > 0 {
|
||||
nextBeforeID = strconv.FormatInt(rows[len(rows)-1].UserID, 10)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleStorageStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
|
|
@ -2740,31 +2197,6 @@ func (s *server) handleStorageAccountsAPI(w http.ResponseWriter, r *http.Request
|
|||
})
|
||||
}
|
||||
|
||||
func (s *server) handleAccountRatingDetailAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
userID, err := parseInt64(r.PathValue("user_id"))
|
||||
if err != nil || userID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid user_id")
|
||||
return
|
||||
}
|
||||
detail, err := s.read.AccountRatingDetail(r.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errReadNotFound) {
|
||||
writeAPIError(w, http.StatusNotFound, "account rating not found")
|
||||
return
|
||||
}
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rating": detail.Rating,
|
||||
"events": detail.Events,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) commandMetaFromAPI(r *http.Request, commandID, reason string, confirm bool, prefix string) admin.CommandMeta {
|
||||
commandID = strings.TrimSpace(commandID)
|
||||
if confirm && strings.HasPrefix(commandID, "dry-") {
|
||||
|
|
@ -2897,62 +2329,6 @@ func (s *server) callAdminMultipart(ctx context.Context, apiPath string, metadat
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func (s *server) callAdminCollectibleMultipart(ctx context.Context, apiPath string, payload admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
meta, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
if err := writer.WriteField("metadata", string(meta)); err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
writeUploads := func(uploads []admin.StarGiftCollectibleAnimationUpload) error {
|
||||
for _, upload := range uploads {
|
||||
part, err := writer.CreateFormFile(upload.FileKey, upload.FileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := part.Write(upload.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := writeUploads(payload.Models); err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
if err := writeUploads(payload.Patterns); err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.AdminAPIURL+apiPath, &body)
|
||||
if err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var result admin.CommandResult
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return result, fmt.Errorf("admin api %s: status=%d body=%s", apiPath, resp.StatusCode, string(raw))
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if result.Error == "" {
|
||||
result.Error = resp.Status
|
||||
}
|
||||
return result, errors.New(result.Error)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeAction(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
if err := decodeJSON(r, dst); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
|
|
|
|||
|
|
@ -144,92 +144,6 @@ func TestModerationReadAPIDisablesBrowserCaching(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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 TestDefaultGiftImportActionDecodes(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/import-default-gift", strings.NewReader(`{
|
||||
"command_id":"c1","reason":"demo","confirm":true,"id":3,"enabled":true
|
||||
}`))
|
||||
var got importDefaultStarGiftAPIRequest
|
||||
if err := decodeJSON(req, &got); err != nil {
|
||||
t.Fatalf("decode default gift action: %v", err)
|
||||
}
|
||||
if got.ID != 3 || got.CommandID != "c1" || !got.Confirm || !got.Enabled {
|
||||
t.Fatalf("decoded default gift action = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialGiftActionDecimalStringDecodingPreservesInt64(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/import-official-gift", strings.NewReader(`{
|
||||
"source_gift_id":"5895603153683874485",
|
||||
"gift_id":"9223372036854775807",
|
||||
"stars":"9223372036854775807",
|
||||
"convert_stars":"9223372036854775807",
|
||||
"upgrade_stars":"9223372036854775807"
|
||||
}`))
|
||||
var got importOfficialStarGiftAPIRequest
|
||||
if err := decodeJSON(req, &got); err != nil {
|
||||
t.Fatalf("decode official gift action: %v", err)
|
||||
}
|
||||
if got.GiftID != maxInt64 || got.Stars != maxInt64 || got.ConvertStars != maxInt64 || got.UpgradeStars != maxInt64 {
|
||||
t.Fatalf("decoded official gift action = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetStarGiftEnabledBFFForwardsExactInt64(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
var got admin.SetStarGiftEnabledRequest
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintCollectibleUsernameBFFForwardsActorAndTolerantScalars(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
var got admin.MintCollectibleUsernameRequest
|
||||
|
|
@ -268,33 +182,6 @@ func TestMintCollectibleUsernameBFFForwardsActorAndTolerantScalars(t *testing.T)
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdjustAccountRatingBFFForwardsNumericPayload(t *testing.T) {
|
||||
var got admin.AdjustAccountRatingRequest
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/account-ratings/adjust" {
|
||||
t.Fatalf("upstream path=%q", r.URL.Path)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed"})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/adjust-account-rating", strings.NewReader(
|
||||
`{"reason":"manual penalty","confirm":true,"user_id":1001,"amount":-2500}`))
|
||||
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleAdjustAccountRatingAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.Actor != "operator" || got.UserID != 1001 || got.Amount != -2500 || got.DryRun {
|
||||
t.Fatalf("forwarded adjust request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeCollectibleUsernameBFFRejectsUnknownFields(t *testing.T) {
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/revoke-collectible-username", strings.NewReader(
|
||||
|
|
@ -307,7 +194,7 @@ func TestRevokeCollectibleUsernameBFFRejectsUnknownFields(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCollectibleUsernameAndRatingRowsJSONPreserveInt64AsDecimalStrings(t *testing.T) {
|
||||
func TestCollectibleUsernameRowsJSONPreserveInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
raw, err := json.Marshal(CollectibleUsernameRow{
|
||||
ID: maxInt64, OwnerPeerID: maxInt64, Amount: maxInt64, CryptoAmount: maxInt64,
|
||||
|
|
@ -326,30 +213,6 @@ func TestCollectibleUsernameAndRatingRowsJSONPreserveInt64AsDecimalStrings(t *te
|
|||
}
|
||||
}
|
||||
|
||||
raw, err = json.Marshal(AccountRatingRow{
|
||||
UserID: maxInt64, Stars: maxInt64, CurrentLevelStars: maxInt64, NextLevelStars: maxInt64,
|
||||
StarsComponent: maxInt64, ActivityComponent: maxInt64, PenaltyComponent: maxInt64,
|
||||
ManualComponent: -maxInt64, PendingStars: maxInt64, Version: maxInt64,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal account rating row: %v", err)
|
||||
}
|
||||
var rating map[string]any
|
||||
if err := json.Unmarshal(raw, &rating); err != nil {
|
||||
t.Fatalf("unmarshal account rating row: %v", err)
|
||||
}
|
||||
for _, field := range []string{
|
||||
"UserID", "Stars", "CurrentLevelStars", "NextLevelStars",
|
||||
"StarsComponent", "ActivityComponent", "PenaltyComponent", "PendingStars", "Version",
|
||||
} {
|
||||
if rating[field] != "9223372036854775807" {
|
||||
t.Fatalf("rating %s = %#v, want exact decimal string", field, rating[field])
|
||||
}
|
||||
}
|
||||
if rating["ManualComponent"] != "-9223372036854775807" {
|
||||
t.Fatalf("rating ManualComponent = %#v, want signed decimal string", rating["ManualComponent"])
|
||||
}
|
||||
|
||||
transfer, err := json.Marshal(CollectibleUsernameTransferRow{
|
||||
ID: maxInt64, CollectibleID: maxInt64, FromPeerID: maxInt64, ToPeerID: maxInt64, Amount: maxInt64,
|
||||
})
|
||||
|
|
@ -381,26 +244,9 @@ func TestFlexScalarsAcceptNumbersStringsAndBlanks(t *testing.T) {
|
|||
body.PurchaseDate.Unix() != time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC).Unix() {
|
||||
t.Fatalf("decoded mint action = %+v", body)
|
||||
}
|
||||
|
||||
var rating adjustAccountRatingAPIRequest
|
||||
numeric := httptest.NewRequest(http.MethodPost, "/api/actions/adjust-account-rating", strings.NewReader(
|
||||
`{"user_id":1001,"amount":-2500}`))
|
||||
if err := decodeJSON(numeric, &rating); err != nil {
|
||||
t.Fatalf("decode adjust action: %v", err)
|
||||
}
|
||||
if rating.UserID.Int64() != 1001 || rating.Amount.Int64() != -2500 {
|
||||
t.Fatalf("decoded adjust action = %+v", rating)
|
||||
}
|
||||
|
||||
var broken adjustAccountRatingAPIRequest
|
||||
invalid := httptest.NewRequest(http.MethodPost, "/api/actions/adjust-account-rating", strings.NewReader(
|
||||
`{"user_id":"not-a-number"}`))
|
||||
if err := decodeJSON(invalid, &broken); err == nil {
|
||||
t.Fatal("decoded a non-numeric user_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCollectibleAndRatingRoutesRequireSession(t *testing.T) {
|
||||
func TestNewCollectibleRoutesRequireSession(t *testing.T) {
|
||||
srv, err := newServer(uiConfig{SessionKey: []byte("01234567890123456789012345678901")}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newServer: %v", err)
|
||||
|
|
@ -411,13 +257,9 @@ func TestNewCollectibleAndRatingRoutesRequireSession(t *testing.T) {
|
|||
}{
|
||||
{http.MethodGet, "/api/collectible-usernames"},
|
||||
{http.MethodGet, "/api/collectible-usernames/7"},
|
||||
{http.MethodGet, "/api/account-ratings"},
|
||||
{http.MethodGet, "/api/account-ratings/7"},
|
||||
{http.MethodPost, "/api/actions/mint-collectible-username"},
|
||||
{http.MethodPost, "/api/actions/transfer-collectible-username"},
|
||||
{http.MethodPost, "/api/actions/revoke-collectible-username"},
|
||||
{http.MethodPost, "/api/actions/recompute-account-rating"},
|
||||
{http.MethodPost, "/api/actions/adjust-account-rating"},
|
||||
}
|
||||
for _, item := range cases {
|
||||
req := httptest.NewRequest(item.method, item.path, strings.NewReader(`{}`))
|
||||
|
|
|
|||
|
|
@ -179,10 +179,8 @@ func TestCSRFProtectionCoversEveryExistingMutatingRoute(t *testing.T) {
|
|||
for _, path := range []string{
|
||||
"/api/logout",
|
||||
"/api/actions/set-frozen",
|
||||
"/api/actions/grant-stars",
|
||||
"/api/actions/delete-bot",
|
||||
"/api/actions/revoke-collectible-username",
|
||||
"/api/actions/adjust-account-rating",
|
||||
"/api/moderation/cases/7/claim",
|
||||
"/api/verification/applications/7/approve",
|
||||
"/api/actions/revoke-verification",
|
||||
|
|
|
|||
1
cmd/telesrv-admin/web/dist/assets/index-98gjjW0y.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-98gjjW0y.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-B6-NxcJd.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-B6-NxcJd.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-CS-EAiSc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-sqNghGhC.css">
|
||||
<script type="module" crossorigin src="/assets/index-B6-NxcJd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-98gjjW0y.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import type {
|
||||
AccountDetail,
|
||||
AccountListResponse,
|
||||
AccountRatingDetail,
|
||||
AccountRatingListResponse,
|
||||
AccountStatsResponse,
|
||||
AccountStorageListResponse,
|
||||
SharedDeviceGroupListResponse,
|
||||
|
|
@ -28,13 +26,9 @@ import type {
|
|||
GroupMessageListResponse,
|
||||
MessageDetail,
|
||||
MessageListResponse,
|
||||
DefaultGiftListResponse,
|
||||
ModerationCaseDetail,
|
||||
ModerationCaseRow,
|
||||
ModerationReport,
|
||||
OfficialStarGiftListResponse,
|
||||
StarGiftCollectiblePreview,
|
||||
StarGiftListResponse,
|
||||
StickerSetListResponse,
|
||||
VerificationApplicationDetail,
|
||||
VerificationApplicationListResponse,
|
||||
|
|
@ -167,10 +161,6 @@ export const api = {
|
|||
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
|
||||
collectibleUsername: (id: string) =>
|
||||
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
|
||||
accountRatings: (params: URLSearchParams) =>
|
||||
request<AccountRatingListResponse>(`/api/account-ratings?${params.toString()}`),
|
||||
accountRating: (userID: string) =>
|
||||
request<AccountRatingDetail>(`/api/account-ratings/${encodeURIComponent(userID)}`),
|
||||
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
|
||||
storageAccounts: (params: URLSearchParams) =>
|
||||
request<AccountStorageListResponse>(`/api/storage/accounts?${params.toString()}`),
|
||||
|
|
@ -229,7 +219,6 @@ export const api = {
|
|||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
gifts: () => request<StarGiftListResponse>("/api/gifts"),
|
||||
stickerSets: (kind: string) => request<StickerSetListResponse>(`/api/stickers?kind=${encodeURIComponent(kind)}`),
|
||||
stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`),
|
||||
stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`,
|
||||
|
|
@ -237,17 +226,6 @@ export const api = {
|
|||
setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }),
|
||||
setChannelAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-channel-avatar", { method: "POST", body: form }),
|
||||
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
|
||||
defaultGifts: () => request<DefaultGiftListResponse>("/api/default-gifts"),
|
||||
defaultGiftAnimation: (id: number) => request<Record<string, unknown>>(`/api/default-gifts/${id}/animation`),
|
||||
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
|
||||
officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),
|
||||
giftAnimation: (id: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(id)}/animation`),
|
||||
giftCollectibles: (id: string) => request<StarGiftCollectiblePreview>(`/api/gifts/${encodeURIComponent(id)}/collectibles`),
|
||||
giftCollectibleAnimation: (giftID: string, kind: "model" | "pattern", attributeID: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(giftID)}/collectibles/${kind}/${encodeURIComponent(attributeID)}/animation`),
|
||||
importGift: (form: FormData) => request<CommandResult>("/api/actions/import-gift", { method: "POST", body: form }),
|
||||
importDefaultGift: (payload: Record<string, unknown>) => request<CommandResult>("/api/actions/import-default-gift", { method: "POST", body: JSON.stringify(payload) }),
|
||||
importOfficialGift: (payload: Record<string, unknown>) => request<CommandResult>("/api/actions/import-official-gift", { method: "POST", body: JSON.stringify(payload) }),
|
||||
publishGiftCollectibles: (giftID: string, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${encodeURIComponent(giftID)}`, { method: "POST", body: form }),
|
||||
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
|
|
|
|||
|
|
@ -12,11 +12,8 @@ import {
|
|||
ShieldCheck,
|
||||
Smile,
|
||||
Stamp,
|
||||
Trophy,
|
||||
Users,
|
||||
Gift,
|
||||
Sticker,
|
||||
Send
|
||||
Sticker
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api } from "../api";
|
||||
|
|
@ -101,10 +98,7 @@ export function Shell({
|
|||
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink>
|
||||
)}
|
||||
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
|
||||
<NavLink icon={<Trophy size={16} />} href="/account-ratings" route={route} navigate={navigate}>{"Account Rating"}</NavLink>
|
||||
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
|
||||
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{"Star Gifts"}</NavLink>
|
||||
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{"Give Gifts"}</NavLink>
|
||||
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
|
||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
|
||||
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ArrowLeft, BadgeCheck, CircleAlert, ImagePlus, MonitorSmartphone, ScrollText, Settings2, Sparkles, Star, UserRound } from "lucide-react";
|
||||
import { ArrowLeft, BadgeCheck, CircleAlert, ImagePlus, MonitorSmartphone, ScrollText, Settings2, Sparkles, UserRound } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { AvatarModal } from "../components/AvatarModal";
|
||||
|
|
@ -20,7 +20,6 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
const [busy, setBusy] = useState(false);
|
||||
const [tab, setTab] = useState<Tab>("profile");
|
||||
const [months, setMonths] = useState("1");
|
||||
const [starsAmount, setStarsAmount] = useState("1000");
|
||||
const [freezeUntil, setFreezeUntil] = useState(() => toDateTimeLocal(new Date(Date.now() + 7 * 86400_000)));
|
||||
const [freezeAppealURL, setFreezeAppealURL] = useState("");
|
||||
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
|
||||
|
|
@ -123,7 +122,6 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
<Summary label={"User ID"} value={String(account.ID)} mono />
|
||||
<Summary label={"Last active"} value={formatUnix(detail.LastSeenAt) || "-"} />
|
||||
<Summary label={"Premium expires"} value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : "None"} />
|
||||
<Summary label={"Stars balance"} value={`${detail.StarsBalance} / ${detail.StarsGranted ? "initial grant applied" : "initial grant pending"}`} />
|
||||
<Summary label={"Updated"} value={formatDate(account.UpdatedAt) || "-"} />
|
||||
<Summary label={"Authorized devices"} value={String(detail.Authorizations.length)} />
|
||||
<Summary label={"Account flags"} value={`support=${detail.Support} bot=${detail.Bot}`} />
|
||||
|
|
@ -197,7 +195,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Premium & Stars"} />
|
||||
<SectionHead title={"Premium"} />
|
||||
<div className="card-body">
|
||||
<div className="attr-block">
|
||||
<label className="duration-field">
|
||||
|
|
@ -230,27 +228,6 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="attr-block">
|
||||
<label className="duration-field">
|
||||
<span>{"Stars to grant"}</span>
|
||||
<input
|
||||
aria-label={"Set Stars amount to grant"}
|
||||
value={starsAmount}
|
||||
onChange={(event) => setStarsAmount(event.target.value)}
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000000000"
|
||||
/>
|
||||
</label>
|
||||
<ActionButton
|
||||
label={"Grant Stars"}
|
||||
icon={<Star size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/grant-stars"
|
||||
payload={() => ({ user_id: account.ID, amount: toInt(starsAmount) })}
|
||||
onDone={load}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,265 +0,0 @@
|
|||
import { ArrowLeft, Calculator, RefreshCw, SlidersHorizontal, User } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, LoadingSurface, Metric, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { displayUsername, formatDate, formatQuantity, formatSigned, toNumeric } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountRatingDetail, AccountRatingEventKind, AccountRatingRow } from "../types";
|
||||
import { LevelBadge, RatingProgress, levelProgress } from "./AccountRatingsPage";
|
||||
|
||||
export function AccountRatingDetailPage({ userID, navigate }: { userID: string; navigate: Navigate }) {
|
||||
const [detail, setDetail] = useState<AccountRatingDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [adjustment, setAdjustment] = useState("");
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.accountRating(userID));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [userID]);
|
||||
|
||||
if (error && !detail) {
|
||||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={busy ? "Loading account rating…" : "Waiting for data"} />;
|
||||
}
|
||||
|
||||
const rating = detail.rating;
|
||||
const events = detail.events ?? [];
|
||||
const pending = toNumeric(rating.PendingStars);
|
||||
const progress = levelProgress(rating);
|
||||
// user_id / amount are `,string` int64 fields on the backend, so they stay
|
||||
// decimal strings and never pass through a float.
|
||||
const payloadUserID = rating.UserID || userID;
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={`Rating of ${displayUsername(rating.Username) || rating.FirstName || rating.UserID}`}
|
||||
eyebrow={"Rating / Component breakdown"}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate("/account-ratings")}>
|
||||
<ArrowLeft size={15} /> {"Back to list"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{displayUsername(rating.Username) || rating.FirstName || "Unnamed bot"}</div>
|
||||
<div className="entity-subtitle">{"User ID"}: {rating.UserID}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<LevelBadge level={rating.Level} />
|
||||
{pending !== 0 && <Badge tone="warn">{`Pending ${formatSigned(rating.PendingStars)}`}</Badge>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="metric-row">
|
||||
<Metric label={"Points"} value={formatQuantity(rating.Stars)} mono />
|
||||
<Metric label={"Level"} value={String(rating.Level)} tone="good" />
|
||||
<Metric
|
||||
label={"Next level threshold"}
|
||||
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : "Max level reached"}
|
||||
mono={rating.HasNextLevel}
|
||||
/>
|
||||
<Metric
|
||||
label={"Points to next level"}
|
||||
value={rating.HasNextLevel ? formatQuantity(String(progress.remaining)) : "-"}
|
||||
mono
|
||||
tone={rating.HasNextLevel && progress.percent >= 80 ? "good" : "neutral"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"How the rating adds up"} text={"Contribution of every source: stars, activity, moderation penalties and manual corrections."} />
|
||||
<Breakdown rating={rating} />
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Current level threshold"} value={formatQuantity(rating.CurrentLevelStars)} mono />
|
||||
<Summary
|
||||
label={"Next level threshold"}
|
||||
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : "Max level reached"}
|
||||
mono={rating.HasNextLevel}
|
||||
/>
|
||||
<Summary label={"Computed"} value={formatDate(rating.ComputedAt) || "-"} />
|
||||
<Summary label={"Updated"} value={formatDate(rating.UpdatedAt) || "-"} />
|
||||
</div>
|
||||
<div className="progress-wide">
|
||||
<RatingProgress row={rating} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{pending !== 0 && (
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Pending points"} text={"Already earned, but counted towards the rating only on the date below."} />
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Pending"} value={formatSigned(rating.PendingStars)} mono />
|
||||
<Summary label={"Applied on"} value={formatDate(rating.PendingDate) || "-"} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Rating events"} text={"Every rating change with its source, actor and reason."} />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"ID"}</th>
|
||||
<th>{"Source"}</th>
|
||||
<th>{"Change"}</th>
|
||||
<th>{"Reason"}</th>
|
||||
<th>{"Actor"}</th>
|
||||
<th>{"Time"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td><EventKind kind={row.Kind} /></td>
|
||||
<td className="mono">{formatSigned(row.Amount)}</td>
|
||||
<td className="truncate">{row.Reason || "-"}</td>
|
||||
<td>{row.Actor || "-"}</td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{events.length === 0 && <EmptyRow colSpan={6} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{"Rating operations"}</div>
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate(`/accounts/${rating.UserID}`)}>
|
||||
<User size={15} /> {"Open account"}
|
||||
</button>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={"Recompute"}
|
||||
icon={<Calculator size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/recompute-account-rating"
|
||||
payload={() => ({ user_id: payloadUserID })}
|
||||
onDone={load}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{"Rebuilds the rating from stars, activity, penalties and manual corrections."}</p>
|
||||
<div className="dock-title">{"Manual correction"}</div>
|
||||
<label className="duration-field">
|
||||
<span>{"Value (negative allowed)"}</span>
|
||||
<input
|
||||
value={adjustment}
|
||||
onChange={(event) => setAdjustment(event.target.value)}
|
||||
type="number"
|
||||
step="1"
|
||||
placeholder="-500"
|
||||
/>
|
||||
</label>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={"Apply correction"}
|
||||
icon={<SlidersHorizontal size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/adjust-account-rating"
|
||||
payload={() => ({
|
||||
user_id: payloadUserID,
|
||||
amount: String(Number.parseInt(adjustment.trim() || "0", 10) || 0)
|
||||
})}
|
||||
onDone={() => {
|
||||
setAdjustment("");
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{"The value is added to the manual component; a negative number lowers the rating."}</p>
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function Breakdown({ rating }: { rating: AccountRatingRow }) {
|
||||
// PenaltyComponent is stored as a positive magnitude and subtracted by the
|
||||
// scorer, so it is shown (and summed) as a negative contribution.
|
||||
const components = [
|
||||
{ key: "stars", label: "Stars", hint: "Purchased and received stars", value: toNumeric(rating.StarsComponent) },
|
||||
{ key: "activity", label: "Activity", hint: "Messages, sessions and long-term engagement", value: toNumeric(rating.ActivityComponent) },
|
||||
{ key: "penalty", label: "Penalties", hint: "Moderation decisions and restrictions", value: -toNumeric(rating.PenaltyComponent) },
|
||||
{ key: "manual", label: "Manual corrections", hint: "Adjustments made by admins", value: toNumeric(rating.ManualComponent) }
|
||||
];
|
||||
const scale = Math.max(1, ...components.map((item) => Math.abs(item.value)));
|
||||
// The score is clamped at zero, and a delayed increase sits in PendingStars
|
||||
// instead of the score, so both cases are expected rather than drift.
|
||||
const sum = Math.max(0, components.reduce((total, item) => total + item.value, 0));
|
||||
const total = toNumeric(rating.Stars);
|
||||
const pending = toNumeric(rating.PendingStars);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="breakdown-list">
|
||||
{components.map((item) => {
|
||||
const percent = Math.min(100, (Math.abs(item.value) / scale) * 100);
|
||||
const tone = item.value < 0 ? "danger" : item.value > 0 ? "good" : "";
|
||||
return (
|
||||
<div className="breakdown-row" key={item.key}>
|
||||
<div className="breakdown-label">
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.hint}</small>
|
||||
</div>
|
||||
<div className={`progress-bar ${tone}`} role="img" aria-label={String(item.value)}>
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<div className={`breakdown-value mono ${tone}`}>{formatSigned(String(item.value))}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="breakdown-row total">
|
||||
<div className="breakdown-label"><strong>{"Total rating"}</strong></div>
|
||||
<div className="breakdown-value mono">{formatQuantity(rating.Stars)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{pending === 0 && sum !== total && (
|
||||
<Alert>{`Components add up to ${formatQuantity(String(sum))} while the stored rating is ${formatQuantity(rating.Stars)}. Recompute to resolve the drift.`}</Alert>
|
||||
)}
|
||||
{pending !== 0 && <p className="bot-create-note">{`Components already include ${formatSigned(rating.PendingStars)} that reaches the score only on the date below.`}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ratingKindLabels: Record<AccountRatingEventKind, string> = {
|
||||
stars: "Stars",
|
||||
activity: "Activity",
|
||||
moderation: "Moderation",
|
||||
manual: "Manual",
|
||||
recompute: "Recompute"
|
||||
};
|
||||
|
||||
function EventKind({ kind }: { kind: AccountRatingEventKind }) {
|
||||
const tone = kind === "moderation" ? "danger" : kind === "manual" ? "warn" : kind === "recompute" ? "neutral" : "good";
|
||||
return <Badge tone={tone}>{ratingKindLabels[kind]}</Badge>;
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import { ChevronDown, ChevronRight, Loader2, RefreshCw, Search, Trophy } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { displayUsername, formatDate, formatQuantity, toNumeric } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountRatingRow } from "../types";
|
||||
|
||||
export function AccountRatingsPage({ navigate }: { navigate: Navigate }) {
|
||||
const [minLevel, setMinLevel] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [rows, setRows] = useState<AccountRatingRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [cursor, setCursor] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load(next = false) {
|
||||
// One free-text field: the backend matches a username prefix (editable or
|
||||
// collectible), a first/last name prefix, and a bare number as the user id.
|
||||
const wanted = search.trim();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (minLevel.trim()) params.set("min_level", minLevel.trim());
|
||||
if (wanted) params.set("q", wanted);
|
||||
if (next && cursor) params.set("before_id", cursor);
|
||||
try {
|
||||
const result = await api.accountRatings(params);
|
||||
const page = result.rows ?? [];
|
||||
setRows((current) => (next ? [...current, ...page] : page));
|
||||
setCursor(result.next_before_id ?? "");
|
||||
setHasMore(Boolean(result.has_more));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
const topLevel = rows.reduce((max, row) => Math.max(max, row.Level), 0);
|
||||
const pendingCount = rows.filter((row) => toNumeric(row.PendingStars) !== 0).length;
|
||||
const avgLevel = rows.length > 0
|
||||
? (rows.reduce((sum, row) => sum + row.Level, 0) / rows.length).toFixed(1)
|
||||
: "0";
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={"Account rating leaderboard"}
|
||||
eyebrow={"Rating / Leaderboard"}
|
||||
actions={
|
||||
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={"Loaded rows"} value={String(rows.length)} />
|
||||
<Metric label={"Top level"} value={String(topLevel)} tone="good" />
|
||||
<Metric label={"Average level"} value={avgLevel} />
|
||||
<Metric label={"With pending points"} value={String(pendingCount)} tone={pendingCount ? "warn" : "neutral"} />
|
||||
</div>
|
||||
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={"Search by username, name or user ID"} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Min level"}</span>
|
||||
<input className="small-input" value={minLevel} onChange={(event) => setMinLevel(event.target.value)} type="number" min="0" placeholder="0" />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Limit"}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"User ID"}</th>
|
||||
<th>{"Username"}</th>
|
||||
<th>{"Level"}</th>
|
||||
<th>{"Points"}</th>
|
||||
<th>{"Progress to next level"}</th>
|
||||
<th>{"Pending"}</th>
|
||||
<th>{"Computed"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.UserID}>
|
||||
<td className="mono">{row.UserID}</td>
|
||||
<td>{displayUsername(row.Username) || row.FirstName || "-"}</td>
|
||||
<td><LevelBadge level={row.Level} /></td>
|
||||
<td className="mono">{formatQuantity(row.Stars)}</td>
|
||||
<td><RatingProgress row={row} /></td>
|
||||
<td className="mono">{toNumeric(row.PendingStars) !== 0 ? formatQuantity(row.PendingStars) : "-"}</td>
|
||||
<td>{formatDate(row.ComputedAt) || "-"}</td>
|
||||
<td>
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/account-ratings/${row.UserID}`)}>
|
||||
<Trophy size={14} /> {"Details"} <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="toolbar">
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function LevelBadge({ level }: { level: number }) {
|
||||
const tone = level >= 10 ? "good" : level >= 5 ? "warn" : "neutral";
|
||||
return <Badge tone={tone}>{`Level ${level}`}</Badge>;
|
||||
}
|
||||
|
||||
export function levelProgress(row: AccountRatingRow): { percent: number; remaining: number; target: number; stars: number } {
|
||||
const stars = toNumeric(row.Stars);
|
||||
const current = toNumeric(row.CurrentLevelStars);
|
||||
const target = toNumeric(row.NextLevelStars);
|
||||
const span = target - current;
|
||||
const percent = span > 0 ? Math.min(100, Math.max(0, ((stars - current) / span) * 100)) : 0;
|
||||
return { percent, remaining: Math.max(0, target - stars), target, stars };
|
||||
}
|
||||
|
||||
export function RatingProgress({ row }: { row: AccountRatingRow }) {
|
||||
if (!row.HasNextLevel) {
|
||||
return <span className="progress-note">{"Max level reached"}</span>;
|
||||
}
|
||||
const { percent, remaining, target } = levelProgress(row);
|
||||
return (
|
||||
<div className="progress-cell">
|
||||
<div className="progress-bar" role="img" aria-label={`${Math.round(percent)}%`}>
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<small>{`${formatQuantity(String(remaining))} left to reach ${formatQuantity(String(target))}`}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
import { CheckCircle2, FileJson2, Gem, Loader2, Plus, ShieldCheck, Sparkles, Trash2, Upload, X } from "lucide-react";
|
||||
import lottie from "lottie-web/build/player/lottie_light_canvas";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge } from "../components/ui";
|
||||
import type { CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types";
|
||||
|
||||
type AnimationData = Record<string, unknown>;
|
||||
type AnimatedDraft = {
|
||||
key: string;
|
||||
name: string;
|
||||
rarity: string;
|
||||
sortOrder: string;
|
||||
file: File | null;
|
||||
animation: AnimationData | null;
|
||||
fileError: string;
|
||||
};
|
||||
type BackdropDraft = {
|
||||
key: string;
|
||||
name: string;
|
||||
backdropID: string;
|
||||
rarity: string;
|
||||
sortOrder: string;
|
||||
center: string;
|
||||
edge: string;
|
||||
pattern: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
let draftSequence = 0;
|
||||
const nextKey = (kind: string) => `${kind}-${++draftSequence}`;
|
||||
const backdropPalettes = [
|
||||
{ center: "#6f5bea", edge: "#34278f", pattern: "#a89df5", text: "#ffffff" },
|
||||
{ center: "#32a86b", edge: "#17613e", pattern: "#8ee0b3", text: "#ffffff" },
|
||||
{ center: "#df8d2f", edge: "#8c421e", pattern: "#ffd08a", text: "#ffffff" },
|
||||
{ center: "#d95878", edge: "#7b2944", pattern: "#f5a1b6", text: "#ffffff" }
|
||||
];
|
||||
|
||||
function rebalanceRarity<T extends { rarity: string }>(rows: T[]): T[] {
|
||||
if (!rows.length) return rows;
|
||||
const base = Math.floor(1000 / rows.length);
|
||||
const remainder = 1000 % rows.length;
|
||||
return rows.map((row, index) => ({ ...row, rarity: String(base + (index < remainder ? 1 : 0)) }));
|
||||
}
|
||||
|
||||
const newAnimated = (kind: string, sortOrder: number): AnimatedDraft => ({
|
||||
key: nextKey(kind), name: "", rarity: "1", sortOrder: String(sortOrder), file: null, animation: null, fileError: ""
|
||||
});
|
||||
|
||||
function newBackdrop(rows: BackdropDraft[]): BackdropDraft {
|
||||
const backdropID = rows.reduce((maximum, row) => {
|
||||
const value = Number(row.backdropID);
|
||||
return Number.isInteger(value) ? Math.max(maximum, value) : maximum;
|
||||
}, 0) + 1;
|
||||
const colors = backdropPalettes[rows.length % backdropPalettes.length];
|
||||
return { key: nextKey("backdrop"), name: "", backdropID: String(backdropID), rarity: "1", sortOrder: String(rows.length), ...colors };
|
||||
}
|
||||
|
||||
const initialAnimated = (kind: string) => rebalanceRarity([newAnimated(kind, 0), newAnimated(kind, 1)]);
|
||||
const initialBackdrops = () => {
|
||||
const first = newBackdrop([]);
|
||||
return rebalanceRarity([first, newBackdrop([first])]);
|
||||
};
|
||||
|
||||
function AnimationPreview({ data, compact = false }: { data: AnimationData; compact?: boolean }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
const player = lottie.loadAnimation({ container: host.current, renderer: "canvas", loop: true, autoplay: true, animationData: structuredClone(data) });
|
||||
return () => player.destroy();
|
||||
}, [data]);
|
||||
return <div className={`collectible-animation ${compact ? "compact" : ""}`} ref={host} />;
|
||||
}
|
||||
|
||||
function RemoteAnimation({ giftID, attribute }: { giftID: string; attribute: StarGiftCollectibleAttributeRow }) {
|
||||
const [data, setData] = useState<AnimationData | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setFailed(false);
|
||||
api.giftCollectibleAnimation(giftID, attribute.kind as "model" | "pattern", attribute.id)
|
||||
.then((value) => { if (!cancelled) setData(value); })
|
||||
.catch(() => { if (!cancelled) setFailed(true); });
|
||||
return () => { cancelled = true; };
|
||||
}, [giftID, attribute.id, attribute.kind]);
|
||||
if (failed) return <div className="collectible-animation compact failed">!</div>;
|
||||
if (!data) return <div className="collectible-animation compact loading"><Loader2 className="spin" size={15} /></div>;
|
||||
return <AnimationPreview data={data} compact />;
|
||||
}
|
||||
|
||||
async function parseAnimationFile(file: File): Promise<AnimationData> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
let raw: Uint8Array = bytes;
|
||||
if (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) {
|
||||
if (!("DecompressionStream" in window)) throw new Error("This browser cannot preview TGS files");
|
||||
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
|
||||
raw = new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
}
|
||||
const parsed: unknown = JSON.parse(new TextDecoder().decode(raw));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Invalid Lottie JSON");
|
||||
return parsed as AnimationData;
|
||||
}
|
||||
|
||||
const colorNumber = (value: string) => Number.parseInt(value.replace("#", ""), 16);
|
||||
const rarityLabel = (attribute: StarGiftCollectibleAttributeRow) => attribute.rarity_kind === "permille" ? `${attribute.rarity_permille}‰` : attribute.rarity_kind;
|
||||
|
||||
const collectibleGroupLabels: Record<"models" | "patterns", string> = {
|
||||
models: "Models",
|
||||
patterns: "Patterns"
|
||||
};
|
||||
|
||||
const collectibleAttributeLabels: Record<"model" | "pattern" | "backdrop", string> = {
|
||||
model: "Model",
|
||||
pattern: "Pattern",
|
||||
backdrop: "Backdrop"
|
||||
};
|
||||
|
||||
const collectibleColorLabels: Record<"center" | "edge" | "pattern" | "text", string> = {
|
||||
center: "Center",
|
||||
edge: "Edge",
|
||||
pattern: "Pattern",
|
||||
text: "Text"
|
||||
};
|
||||
|
||||
export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) {
|
||||
const [active, setActive] = useState<StarGiftCollectiblePreview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [preview, setPreview] = useState<CommandResult | null>(null);
|
||||
const [upgradeStars, setUpgradeStars] = useState("100");
|
||||
const [supplyTotal, setSupplyTotal] = useState("1000");
|
||||
const [slugPrefix, setSlugPrefix] = useState(`gift-${gift.GiftID}`);
|
||||
const [reason, setReason] = useState("");
|
||||
const [models, setModels] = useState<AnimatedDraft[]>(() => initialAnimated("model"));
|
||||
const [patterns, setPatterns] = useState<AnimatedDraft[]>(() => initialAnimated("pattern"));
|
||||
const [backdrops, setBackdrops] = useState<BackdropDraft[]>(initialBackdrops);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.giftCollectibles(gift.GiftID).then((value) => {
|
||||
if (cancelled) return;
|
||||
setActive(value);
|
||||
if (value.found) {
|
||||
setUpgradeStars(String(value.upgrade_stars ?? 100));
|
||||
setSupplyTotal(String(value.supply_total ?? 1000));
|
||||
setSlugPrefix(value.slug_prefix ?? `gift-${gift.GiftID}`);
|
||||
}
|
||||
}).catch((err) => setError(errorMessage(err))).finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [gift.GiftID]);
|
||||
|
||||
const rarityTotals = useMemo(() => ({
|
||||
models: models.reduce((sum, value) => sum + Number(value.rarity || 0), 0),
|
||||
patterns: patterns.reduce((sum, value) => sum + Number(value.rarity || 0), 0),
|
||||
backdrops: backdrops.reduce((sum, value) => sum + Number(value.rarity || 0), 0)
|
||||
}), [models, patterns, backdrops]);
|
||||
|
||||
const invalidate = () => setPreview(null);
|
||||
const updateAnimated = (kind: "models" | "patterns", key: string, patch: Partial<AnimatedDraft>) => {
|
||||
const setter = kind === "models" ? setModels : setPatterns;
|
||||
setter((rows) => rows.map((row) => row.key === key ? { ...row, ...patch } : row));
|
||||
invalidate();
|
||||
};
|
||||
|
||||
async function chooseFile(kind: "models" | "patterns", row: AnimatedDraft, file: File | null) {
|
||||
updateAnimated(kind, row.key, { file, animation: null, fileError: "" });
|
||||
if (!file) return;
|
||||
try {
|
||||
const animation = await parseAnimationFile(file);
|
||||
updateAnimated(kind, row.key, { animation, fileError: "" });
|
||||
} catch (err) {
|
||||
updateAnimated(kind, row.key, { animation: null, fileError: errorMessage(err) });
|
||||
}
|
||||
}
|
||||
|
||||
function buildForm(confirm: boolean, commandID = "") {
|
||||
if (!reason.trim()) throw new Error("Please enter an operation reason");
|
||||
if (models.length < 2 || patterns.length < 2 || backdrops.length < 2) throw new Error("Models, patterns, and backdrops must each contain at least two attributes.");
|
||||
const backdropIDs = backdrops.map((row) => Number(row.backdropID));
|
||||
if (new Set(backdropIDs).size !== backdropIDs.length) throw new Error("Backdrop IDs must be unique within the pool.");
|
||||
for (const row of [...models, ...patterns]) if (!row.file) throw new Error("Every model and pattern needs a TGS or Lottie file.");
|
||||
const form = new FormData();
|
||||
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: 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),
|
||||
center_color: colorNumber(row.center), edge_color: colorNumber(row.edge), pattern_color: colorNumber(row.pattern), text_color: colorNumber(row.text)
|
||||
}))
|
||||
}));
|
||||
for (const row of [...models, ...patterns]) form.set(row.key, row.file as File, (row.file as File).name);
|
||||
return form;
|
||||
}
|
||||
|
||||
async function validate() {
|
||||
setBusy(true); setError(""); setPreview(null);
|
||||
try { setPreview(await api.publishGiftCollectibles(gift.GiftID, buildForm(false))); }
|
||||
catch (err) { setError(errorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (!preview) return;
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
await api.publishGiftCollectibles(gift.GiftID, buildForm(true, preview.command_id));
|
||||
onPublished(); onClose();
|
||||
} catch (err) { setError(errorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const renderAnimatedRows = (kind: "models" | "patterns", rows: AnimatedDraft[], setRows: (rows: AnimatedDraft[]) => void) => (
|
||||
<section className="collectible-section">
|
||||
<div className="collectible-section-head">
|
||||
<div><strong>{collectibleGroupLabels[kind]}</strong><span>{"Permille values are relative regular-upgrade weights; their total does not need to equal 1000."}</span></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(rebalanceRarity([...rows, newAnimated(kind === "models" ? "model" : "pattern", rows.length)])); invalidate(); }}><Plus size={13} />{"Add"}</button></div>
|
||||
</div>
|
||||
<div className="collectible-rows">
|
||||
{rows.map((row, index) => <div className="collectible-row animated" key={row.key}>
|
||||
<div className="collectible-row-index">{index + 1}</div>
|
||||
<label><span>{"Name"}</span><input value={row.name} maxLength={128} onChange={(e) => updateAnimated(kind, row.key, { name: e.target.value })} /></label>
|
||||
<label><span>{"Rarity ‰"}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => updateAnimated(kind, row.key, { rarity: e.target.value })} /></label>
|
||||
<label><span>{"Sort order"}</span><input type="number" value={row.sortOrder} onChange={(e) => updateAnimated(kind, row.key, { sortOrder: e.target.value })} /></label>
|
||||
<label className="collectible-file"><span>{"Animation file"}</span><input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => void chooseFile(kind, row, e.target.files?.[0] ?? null)} /><em><FileJson2 size={13} />{row.file?.name ?? "Choose file"}</em></label>
|
||||
<div className="collectible-inline-preview">{row.animation ? <AnimationPreview data={row.animation} compact /> : <Sparkles size={16} />}</div>
|
||||
<button className="icon-btn danger" type="button" disabled={rows.length <= 2} onClick={() => { setRows(rebalanceRarity(rows.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={"Remove attribute"}><Trash2 size={14} /></button>
|
||||
{row.fileError && <span className="collectible-file-error">{row.fileError}</span>}
|
||||
</div>)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
return createPortal(<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal collectible-modal" role="dialog" aria-modal="true" aria-label={`Collectible pool · Gift #${gift.GiftID}`}>
|
||||
<div className="modal-head">
|
||||
<div><div className="eyebrow">{"Unique gift attributes"}</div><h2>{`Collectible pool · Gift #${gift.GiftID}`}</h2><p>{gift.Title || `Gift #${gift.GiftID}`}</p></div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body collectible-modal-body">
|
||||
{loading ? <div className="collectible-loading"><Loader2 className="spin" />{"Loading"}</div> : active?.found ? <section className="collectible-active">
|
||||
<div className="collectible-active-head"><div><Gem size={18} /><div><strong>{`Published revision ${active.revision ?? 0}`}</strong><span>{active.slug_prefix} · ⭐ {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{"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}{attribute.crafted && <Badge>crafted</Badge>}</strong><span>{collectibleAttributeLabels[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>{"Backdrop"} · {rarityLabel(attribute)}</span></div></article>)}
|
||||
</div>
|
||||
</section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{"No collectible pool published"}</strong><span>{"Publish models, patterns and backdrops to enable upgrades."}</span></div></div>}
|
||||
|
||||
<section className="collectible-definition">
|
||||
<div className="collectible-definition-head"><div><strong>{"Publish a new immutable revision"}</strong><span>{"Dry-run checks every file and rarity total before the revision becomes active."}</span></div><div className="gift-format-chips"><span>TGS</span><span>Lottie JSON</span></div></div>
|
||||
<div className="gift-fields-grid collectible-main-fields">
|
||||
<label><span>{"Upgrade price in Stars"}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); invalidate(); }} /></label>
|
||||
<label><span>{"Unique supply"}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); invalidate(); }} /></label>
|
||||
<label><span>{"Public slug prefix"}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); invalidate(); }} /></label>
|
||||
<label><span>{"Audit reason"}</span><input value={reason} maxLength={1000} placeholder={"Briefly describe why this gift is being imported"} onChange={(e) => setReason(e.target.value)} /></label>
|
||||
</div>
|
||||
{renderAnimatedRows("models", models, setModels)}
|
||||
{renderAnimatedRows("patterns", patterns, setPatterns)}
|
||||
<section className="collectible-section">
|
||||
<div className="collectible-section-head"><div><strong>{"Backdrops"}</strong><span>{"Colors are stored as 24-bit RGB values."}</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(rebalanceRarity([...backdrops, newBackdrop(backdrops)])); invalidate(); }}><Plus size={13} />{"Add"}</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>{"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>{"Backdrop ID"}</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>{"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>{"Sort order"}</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>{collectibleColorLabels[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>)}
|
||||
<div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, ${row.center}, ${row.edge})`, color: row.text }}>Aa</div>
|
||||
<button className="icon-btn danger" type="button" disabled={backdrops.length <= 2} onClick={() => { setBackdrops(rebalanceRarity(backdrops.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={"Remove attribute"}><Trash2 size={14} /></button>
|
||||
</div>)}</div>
|
||||
</section>
|
||||
</section>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{"Attribute pool is valid"}</strong><span>{"Review the normalized assets, then publish this immutable revision."}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
|
||||
<button className="btn" type="button" onClick={validate} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{"Dry-run validation"}</button>
|
||||
<button className="btn primary" type="button" onClick={publish} disabled={busy || !preview}><Upload size={15} />{"Publish revision"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>, document.body);
|
||||
}
|
||||
|
|
@ -1,696 +0,0 @@
|
|||
import { CheckCircle2, ChevronLeft, ChevronRight, FileJson2, Gem, Loader2, Pause, Play, Plus, RefreshCw, Search, ShieldCheck, Upload, X } from "lucide-react";
|
||||
import lottie from "lottie-web/build/player/lottie_light_canvas";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, APIError, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { formatDate } from "../lib/format";
|
||||
import type { CommandResult, DefaultGiftRow, OfficialStarGiftRow, StarGiftRow } from "../types";
|
||||
import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
|
||||
|
||||
type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
|
||||
type GiftPageSize = 10 | 20 | 50 | 100 | "all";
|
||||
|
||||
const officialCategoryLabels: Record<OfficialGiftCategory, string> = {
|
||||
all: "All",
|
||||
upgrade: "Upgradable",
|
||||
craft: "Craftable",
|
||||
basic: "Not upgradable"
|
||||
};
|
||||
|
||||
// The demo pool only has 3 placeholder gifts left after pruning to one per
|
||||
// capability tier (Spark/Star/Coin); hide the tab until real custom designs
|
||||
// replace them. Flip back to true to re-enable.
|
||||
const SHOW_DEFAULT_GIFTS_TAB = false;
|
||||
|
||||
function defaultGiftAttributeCount(gift: DefaultGiftRow) {
|
||||
return gift.model_count + gift.pattern_count + gift.backdrop_count;
|
||||
}
|
||||
|
||||
function officialGiftAttributeCount(gift: OfficialStarGiftRow) {
|
||||
return gift.model_count + gift.pattern_count + gift.backdrop_count;
|
||||
}
|
||||
|
||||
function formatBytes(value: number | string) {
|
||||
const bytes = Number(value);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export 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);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.giftAnimation(giftID).then((data) => {
|
||||
if (cancelled || !host.current) return;
|
||||
animation.current?.destroy();
|
||||
animation.current = lottie.loadAnimation({
|
||||
container: host.current,
|
||||
renderer: "canvas",
|
||||
loop: true,
|
||||
autoplay: true,
|
||||
animationData: structuredClone(data)
|
||||
});
|
||||
}).catch((err) => setError(errorMessage(err)));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
animation.current?.destroy();
|
||||
animation.current = null;
|
||||
};
|
||||
}, [giftID, revision]);
|
||||
|
||||
function toggle() {
|
||||
if (!animation.current) return;
|
||||
if (playing) animation.current.pause();
|
||||
else animation.current.play();
|
||||
setPlaying(!playing);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`gift-animation-shell ${compact ? "compact" : ""}`}>
|
||||
<div className="gift-animation" ref={host}>{error && <span>{error}</span>}</div>
|
||||
<button className="gift-play" type="button" onClick={toggle} aria-label={playing ? "Pause" : "Play"}>
|
||||
{playing ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultLottiePreview({ id }: { id: number }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let player: ReturnType<typeof lottie.loadAnimation> | null = null;
|
||||
api.defaultGiftAnimation(id).then((data) => {
|
||||
if (cancelled || !host.current) return;
|
||||
player = lottie.loadAnimation({ container: host.current, renderer: "canvas", loop: true, autoplay: true, animationData: structuredClone(data) });
|
||||
}).catch(() => undefined);
|
||||
return () => { cancelled = true; player?.destroy(); };
|
||||
}, [id]);
|
||||
return <div className="gift-animation-shell"><div className="gift-animation" ref={host} /></div>;
|
||||
}
|
||||
|
||||
function OfficialLottiePreview({ sourceGiftID }: { sourceGiftID: string }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let player: ReturnType<typeof lottie.loadAnimation> | null = null;
|
||||
api.officialGiftAnimation(sourceGiftID).then((data) => {
|
||||
if (cancelled || !host.current) return;
|
||||
player = lottie.loadAnimation({ container: host.current, renderer: "canvas", loop: true, autoplay: true, animationData: structuredClone(data) });
|
||||
}).catch(() => undefined);
|
||||
return () => { cancelled = true; player?.destroy(); };
|
||||
}, [sourceGiftID]);
|
||||
return <div className="gift-animation-shell"><div className="gift-animation" ref={host} /></div>;
|
||||
}
|
||||
|
||||
export function GiftsPage() {
|
||||
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [collectibleGift, setCollectibleGift] = useState<StarGiftRow | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [importSource, setImportSource] = useState<"default" | "official" | "file">(SHOW_DEFAULT_GIFTS_TAB ? "default" : "official");
|
||||
const [defaultGifts, setDefaultGifts] = useState<DefaultGiftRow[]>([]);
|
||||
const [selectedDefaultID, setSelectedDefaultID] = useState(0);
|
||||
const [officialGifts, setOfficialGifts] = useState<OfficialStarGiftRow[]>([]);
|
||||
const [officialQuery, setOfficialQuery] = useState("");
|
||||
const [officialCategory, setOfficialCategory] = useState<OfficialGiftCategory>("all");
|
||||
const [sourceGiftID, setSourceGiftID] = useState("");
|
||||
const [includeCollectible, setIncludeCollectible] = useState(true);
|
||||
const [upgradeStars, setUpgradeStars] = useState("0");
|
||||
const [supplyTotal, setSupplyTotal] = useState("0");
|
||||
const [slugPrefix, setSlugPrefix] = useState("");
|
||||
const [giftID, setGiftID] = useState("0");
|
||||
const [title, setTitle] = useState("");
|
||||
const [stars, setStars] = useState("50");
|
||||
const [convertStars, setConvertStars] = useState("50");
|
||||
const [sortOrder, setSortOrder] = useState("0");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [reason, setReason] = useState("");
|
||||
const [preview, setPreview] = useState<CommandResult | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [importError, setImportError] = useState("");
|
||||
const [bulkImportOpen, setBulkImportOpen] = useState<"default" | "official" | null>(null);
|
||||
const [bulkImportItems, setBulkImportItems] = useState<Array<DefaultGiftRow | OfficialStarGiftRow>>([]);
|
||||
const [bulkImportEnabled, setBulkImportEnabled] = useState(true);
|
||||
const [bulkImportReason, setBulkImportReason] = useState("");
|
||||
const [bulkImportBusy, setBulkImportBusy] = useState(false);
|
||||
const [bulkImportProgress, setBulkImportProgress] = useState({ done: 0, total: 0 });
|
||||
const [bulkImportError, setBulkImportError] = useState("");
|
||||
const [bulkImportResult, setBulkImportResult] = useState<{ imported: number; skipped: number; failed: number; errors: string[] } | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkReason, setBulkReason] = useState("");
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [bulkError, setBulkError] = useState("");
|
||||
const [pageSize, setPageSize] = useState<GiftPageSize>(10);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
setGifts((await api.gifts()).Gifts ?? []);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!importOpen || importSource !== "default" || defaultGifts.length > 0) return;
|
||||
api.defaultGifts().then((value) => setDefaultGifts(value.gifts ?? [])).catch((err) => setImportError(errorMessage(err)));
|
||||
}, [importOpen, importSource, defaultGifts.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!importOpen || importSource !== "official" || officialGifts.length > 0) return;
|
||||
api.officialGifts().then((value) => setOfficialGifts(value.gifts ?? [])).catch((err) => setImportError(errorMessage(err)));
|
||||
}, [importOpen, importSource, officialGifts.length]);
|
||||
|
||||
const selectedDefault = useMemo(() => defaultGifts.find((gift) => gift.id === selectedDefaultID) ?? null, [defaultGifts, selectedDefaultID]);
|
||||
const selectedOfficial = useMemo(() => officialGifts.find((gift) => gift.source_gift_id === sourceGiftID) ?? null, [officialGifts, sourceGiftID]);
|
||||
const officialCategoryCounts = useMemo(() => ({
|
||||
all: officialGifts.length,
|
||||
upgrade: officialGifts.filter((gift) => gift.can_upgrade).length,
|
||||
craft: officialGifts.filter((gift) => gift.can_craft).length,
|
||||
basic: officialGifts.filter((gift) => !gift.can_upgrade).length
|
||||
}), [officialGifts]);
|
||||
const visibleOfficial = useMemo(() => {
|
||||
const normalized = officialQuery.trim().toLowerCase();
|
||||
return officialGifts.filter((gift) => {
|
||||
const categoryMatches = officialCategory === "all" ||
|
||||
(officialCategory === "upgrade" && gift.can_upgrade) ||
|
||||
(officialCategory === "craft" && gift.can_craft) ||
|
||||
(officialCategory === "basic" && !gift.can_upgrade);
|
||||
return categoryMatches && (!normalized || gift.source_gift_id.includes(normalized) || gift.title.toLowerCase().includes(normalized));
|
||||
});
|
||||
}, [officialGifts, officialQuery, officialCategory]);
|
||||
|
||||
const visibleGifts = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return gifts;
|
||||
return gifts.filter((gift) =>
|
||||
String(gift.GiftID).includes(normalized) ||
|
||||
gift.Title.toLowerCase().includes(normalized) ||
|
||||
gift.SourceFormat.toLowerCase().includes(normalized)
|
||||
);
|
||||
}, [gifts, query]);
|
||||
|
||||
useEffect(() => { setPage(1); }, [query, pageSize]);
|
||||
|
||||
const totalPages = pageSize === "all" ? 1 : Math.max(1, Math.ceil(visibleGifts.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pagedGifts = useMemo(() => {
|
||||
if (pageSize === "all") return visibleGifts;
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return visibleGifts.slice(start, start + pageSize);
|
||||
}, [visibleGifts, currentPage, pageSize]);
|
||||
const pageRangeStart = pagedGifts.length === 0 ? 0 : pageSize === "all" ? 1 : (currentPage - 1) * pageSize + 1;
|
||||
const pageRangeEnd = pageRangeStart === 0 ? 0 : pageRangeStart + pagedGifts.length - 1;
|
||||
|
||||
const allVisibleSelected = pagedGifts.length > 0 && pagedGifts.every((gift) => selected.has(gift.GiftID));
|
||||
|
||||
function toggleSelected(giftID: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(giftID)) next.delete(giftID);
|
||||
else next.add(giftID);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAllVisible() {
|
||||
setSelected((prev) => {
|
||||
if (allVisibleSelected) {
|
||||
const next = new Set(prev);
|
||||
for (const gift of pagedGifts) next.delete(gift.GiftID);
|
||||
return next;
|
||||
}
|
||||
const next = new Set(prev);
|
||||
for (const gift of pagedGifts) next.add(gift.GiftID);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function bulkSetEnabled(nextEnabled: boolean) {
|
||||
if (!bulkReason.trim()) {
|
||||
setBulkError("Please enter an operation reason");
|
||||
return;
|
||||
}
|
||||
setBulkBusy(true);
|
||||
setBulkError("");
|
||||
const ids = Array.from(selected);
|
||||
let failed = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await api.action("/api/actions/set-gift-enabled", {
|
||||
gift_id: id,
|
||||
enabled: nextEnabled,
|
||||
reason: bulkReason.trim(),
|
||||
confirm: true
|
||||
});
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
setBulkBusy(false);
|
||||
if (failed > 0) {
|
||||
setBulkError(`${failed} of ${ids.length} failed`);
|
||||
} else {
|
||||
setSelected(new Set());
|
||||
setBulkReason("");
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
function uploadForm(confirm: boolean, commandID = "") {
|
||||
if (!file) throw new Error("Choose a TGS or Lottie file first");
|
||||
if (!reason.trim()) throw new Error("Please enter an operation reason");
|
||||
const form = new FormData();
|
||||
form.set("metadata", JSON.stringify({
|
||||
command_id: commandID,
|
||||
reason: reason.trim(),
|
||||
confirm,
|
||||
gift_id: giftID,
|
||||
title: title.trim(),
|
||||
stars,
|
||||
convert_stars: convertStars,
|
||||
enabled,
|
||||
sort_order: Number(sortOrder)
|
||||
}));
|
||||
form.set("file", file, file.name);
|
||||
return form;
|
||||
}
|
||||
|
||||
function defaultPayload(confirm: boolean, commandID = "") {
|
||||
if (!selectedDefaultID) throw new Error("Choose a default gift first");
|
||||
if (!reason.trim()) throw new Error("Please enter an operation reason");
|
||||
return { command_id: commandID, reason: reason.trim(), confirm, id: selectedDefaultID };
|
||||
}
|
||||
|
||||
function officialPayload(confirm: boolean, commandID = "") {
|
||||
if (!sourceGiftID) throw new Error("Choose an official gift first");
|
||||
if (!reason.trim()) throw new Error("Please enter an operation reason");
|
||||
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 || `Unnamed official gift #${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 openBulkImport(source: "default" | "official") {
|
||||
setBulkImportOpen(source);
|
||||
setBulkImportItems([]);
|
||||
setBulkImportEnabled(true);
|
||||
setBulkImportReason("");
|
||||
setBulkImportBusy(false);
|
||||
setBulkImportProgress({ done: 0, total: 0 });
|
||||
setBulkImportError("");
|
||||
setBulkImportResult(null);
|
||||
try {
|
||||
if (source === "default") {
|
||||
const list = defaultGifts.length > 0 ? defaultGifts : (await api.defaultGifts()).gifts ?? [];
|
||||
setBulkImportItems(list);
|
||||
} else {
|
||||
const list = officialGifts.length > 0 ? officialGifts : (await api.officialGifts()).gifts ?? [];
|
||||
setBulkImportItems(list);
|
||||
}
|
||||
} catch (err) {
|
||||
setBulkImportError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
function closeBulkImport() {
|
||||
if (bulkImportBusy) return;
|
||||
setBulkImportOpen(null);
|
||||
}
|
||||
|
||||
async function runBulkImport() {
|
||||
if (!bulkImportOpen) return;
|
||||
if (!bulkImportReason.trim()) { setBulkImportError("Please enter an operation reason"); return; }
|
||||
const source = bulkImportOpen;
|
||||
setBulkImportBusy(true); setBulkImportError(""); setBulkImportResult(null);
|
||||
setBulkImportProgress({ done: 0, total: bulkImportItems.length });
|
||||
let imported = 0, skipped = 0, failed = 0;
|
||||
const errors: string[] = [];
|
||||
for (const item of bulkImportItems) {
|
||||
const label = source === "default" ? (item as DefaultGiftRow).title : ((item as OfficialStarGiftRow).title || `#${(item as OfficialStarGiftRow).source_gift_id}`);
|
||||
try {
|
||||
// Stable per-gift command_id (mirrors the old server-side bulk
|
||||
// endpoint) so a gift already imported by a prior run is recognized
|
||||
// as a replay instead of creating a duplicate catalog entry -
|
||||
// CreateCatalogBundle has no unique constraint on title/source id to
|
||||
// fall back on. If this run's Enabled value differs from the run
|
||||
// that first created it, the server reports COMMAND_ID_CONFLICT
|
||||
// instead of silently re-importing; treat that as "skipped" too.
|
||||
const result = source === "default"
|
||||
? await api.importDefaultGift({
|
||||
command_id: `bulk-default-gift-${(item as DefaultGiftRow).id}`,
|
||||
reason: bulkImportReason.trim(),
|
||||
confirm: true,
|
||||
id: (item as DefaultGiftRow).id,
|
||||
enabled: bulkImportEnabled
|
||||
})
|
||||
: await api.importOfficialGift({
|
||||
command_id: `bulk-official-gift-${(item as OfficialStarGiftRow).source_gift_id}`,
|
||||
reason: bulkImportReason.trim(),
|
||||
confirm: true,
|
||||
source_gift_id: (item as OfficialStarGiftRow).source_gift_id,
|
||||
include_collectible: (item as OfficialStarGiftRow).can_upgrade,
|
||||
enabled: bulkImportEnabled
|
||||
});
|
||||
if (result.already_executed || result.details?.skipped) skipped++;
|
||||
else imported++;
|
||||
} catch (err) {
|
||||
if (err instanceof APIError && err.message === "COMMAND_ID_CONFLICT") {
|
||||
skipped++;
|
||||
} else {
|
||||
failed++;
|
||||
errors.push(`${label}: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
setBulkImportProgress((prev) => ({ ...prev, done: prev.done + 1 }));
|
||||
}
|
||||
setBulkImportBusy(false);
|
||||
setBulkImportResult({ imported, skipped, failed, errors });
|
||||
await load();
|
||||
}
|
||||
|
||||
async function validateImport() {
|
||||
setBusy(true); setImportError(""); setPreview(null);
|
||||
try {
|
||||
const result = importSource === "default" ? await api.importDefaultGift(defaultPayload(false))
|
||||
: importSource === "official" ? await api.importOfficialGift(officialPayload(false))
|
||||
: await api.importGift(uploadForm(false));
|
||||
setPreview(result);
|
||||
} catch (err) {
|
||||
setImportError(errorMessage(err));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function confirmImport() {
|
||||
if (!preview) return;
|
||||
setBusy(true); setImportError("");
|
||||
try {
|
||||
if (importSource === "default") await api.importDefaultGift(defaultPayload(true, preview.command_id));
|
||||
else if (importSource === "official") await api.importOfficialGift(officialPayload(true, preview.command_id));
|
||||
else await api.importGift(uploadForm(true, preview.command_id));
|
||||
setPreview(null); setFile(null); setGiftID("0"); setTitle(""); setSelectedDefaultID(0); setSourceGiftID("");
|
||||
await load();
|
||||
setImportOpen(false);
|
||||
} catch (err) {
|
||||
setImportError(errorMessage(err));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function startImport() {
|
||||
setGiftID("0"); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
|
||||
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError("");
|
||||
setImportSource(SHOW_DEFAULT_GIFTS_TAB ? "default" : "official"); setSelectedDefaultID(0);
|
||||
setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all");
|
||||
setBulkImportBusy(false); setBulkImportProgress({ done: 0, total: 0 }); setBulkImportError("");
|
||||
setImportOpen(true);
|
||||
}
|
||||
|
||||
function startRevision(gift: StarGiftRow) {
|
||||
setGiftID(gift.GiftID); setTitle(gift.Title); setStars(String(gift.Stars));
|
||||
setConvertStars(String(gift.ConvertStars)); setSortOrder(String(gift.SortOrder)); setEnabled(gift.Enabled);
|
||||
setReason(""); setFile(null); setPreview(null); setImportError("");
|
||||
setImportSource("file"); setSelectedDefaultID(0); setSourceGiftID(""); setImportOpen(true);
|
||||
}
|
||||
|
||||
const step1Done = importSource === "default" ? selectedDefaultID > 0
|
||||
: importSource === "official" ? Boolean(sourceGiftID)
|
||||
: Boolean(file);
|
||||
|
||||
return (
|
||||
<PageFrame title={"Star Gift Catalog"} eyebrow={"Catalog, immutable revisions and animation assets"} actions={<>
|
||||
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {"Refresh"}</button>
|
||||
<button className="btn primary" type="button" onClick={startImport}><Plus size={15} /> {"Add gift"}</button>
|
||||
</>}>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row gift-metrics">
|
||||
<Metric label={"Catalog entries"} value={String(gifts.length)} />
|
||||
<Metric label={"Enabled"} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" />
|
||||
<Metric label={"Received gifts"} value={gifts.reduce((sum, gift) => sum + BigInt(gift.ReceivedCount), 0n).toString()} />
|
||||
<Metric label={"Accepted formats"} value="TGS / Lottie" />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
<div className="toolbar">
|
||||
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={"Search gift ID, title or format"} /></label>
|
||||
<label className="gift-page-size"><span>{"Per page"}</span>
|
||||
<select value={String(pageSize)} onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as GiftPageSize))}>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="all">{"All"}</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="gift-list-summary">{`Showing ${visibleGifts.length} of ${gifts.length}`}</span>
|
||||
</div>
|
||||
</QueryPanel>
|
||||
{selected.size > 0 && <div className="gift-bulk-toolbar">
|
||||
<span className="gift-bulk-count">{`${selected.size} selected`}</span>
|
||||
<label className="gift-reason-field gift-bulk-reason"><span>{"Audit reason"}</span><input value={bulkReason} placeholder={"Briefly describe why this gift is being imported"} onChange={(e) => setBulkReason(e.target.value)} /></label>
|
||||
<button className="btn" type="button" onClick={() => bulkSetEnabled(true)} disabled={bulkBusy}>
|
||||
{bulkBusy ? <Loader2 className="spin" size={14} /> : <CheckCircle2 size={14} />} {"Enable selected"}
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={() => bulkSetEnabled(false)} disabled={bulkBusy}>
|
||||
{bulkBusy ? <Loader2 className="spin" size={14} /> : <Pause size={14} />} {"Disable selected"}
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={() => { setSelected(new Set()); setBulkError(""); }} disabled={bulkBusy}>{"Close"}</button>
|
||||
{bulkError && <span className="gift-bulk-error">{bulkError}</span>}
|
||||
</div>}
|
||||
<div className="table-wrap gift-table-wrap">
|
||||
<table className="data-table gift-table">
|
||||
<thead><tr><th className="gift-select-col"><input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAllVisible} aria-label={"Select all visible gifts"} /></th><th>{"Animation file"}</th><th>{"ID / Revision"}</th><th>{"Display title"}</th><th>{"Price / Conversion"}</th><th>{"Source"}</th><th>{"Received gifts"}</th><th>{"Status"}</th><th>{"Updated"}</th><th>{"Actions"}</th></tr></thead>
|
||||
<tbody>
|
||||
{pagedGifts.map((gift) => (
|
||||
<tr className={gift.Enabled ? "" : "gift-row-disabled"} key={gift.GiftID}>
|
||||
<td className="gift-select-col"><input type="checkbox" checked={selected.has(gift.GiftID)} onChange={() => toggleSelected(gift.GiftID)} aria-label={`Select gift ${gift.GiftID}`} /></td>
|
||||
<td><LottiePreview giftID={gift.GiftID} revision={gift.Revision} compact /></td>
|
||||
<td className="mono">{gift.GiftID} / {gift.Revision}</td>
|
||||
<td><strong className="gift-table-title">{gift.Title || `Gift #${gift.GiftID}`}</strong><span className="gift-sort-order">{"Sort order"}: {gift.SortOrder}</span></td>
|
||||
<td><strong className="gift-table-price">⭐ {gift.Stars}</strong><span className="gift-convert-price">→ {gift.ConvertStars}</span></td>
|
||||
<td><Badge>{gift.SourceFormat}</Badge><span className="gift-source-size">{formatBytes(gift.AnimationSize)}</span></td>
|
||||
<td>{gift.ReceivedCount}</td>
|
||||
<td><Badge tone={gift.Enabled ? "good" : "neutral"}>{gift.Enabled ? "Enabled" : "Disabled"}</Badge></td>
|
||||
<td>{formatDate(gift.UpdatedAt)}</td>
|
||||
<td><div className="gift-table-actions"><button className="btn compact-btn collectible-button" type="button" onClick={() => setCollectibleGift(gift)}><Gem size={13} />{"Attribute pool"}</button><button className="btn compact-btn" type="button" onClick={() => startRevision(gift)}>{"New revision"}</button><ActionButton compact tone="neutral" label={gift.Enabled ? "Disable" : "Enable"} path="/api/actions/set-gift-enabled" payload={() => ({ gift_id: gift.GiftID, enabled: !gift.Enabled })} onDone={() => void load()} /></div></td>
|
||||
</tr>
|
||||
))}
|
||||
{pagedGifts.length === 0 && <EmptyRow colSpan={10} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pageSize !== "all" && visibleGifts.length > 0 && <div className="gift-pager">
|
||||
<span className="gift-pager-range">{`Showing ${pageRangeStart}-${pageRangeEnd} of ${visibleGifts.length}`}</span>
|
||||
<div className="gift-pager-controls">
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
|
||||
<ChevronLeft size={14} /> {"Previous"}
|
||||
</button>
|
||||
<span className="gift-pager-page">{`Page ${currentPage} of ${totalPages}`}</span>
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
|
||||
{"Next"} <ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{importOpen && createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID !== "0" ? `Create revision for gift #${giftID}` : "Import a Star Gift"}>
|
||||
<div className="modal-head">
|
||||
<div><div className="eyebrow">{"Gift catalog operation"}</div><h2>{giftID !== "0" ? `Create revision for gift #${giftID}` : "Import a Star Gift"}</h2></div>
|
||||
<button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body gift-import-modal-body">
|
||||
<div className="command-steps">
|
||||
<div className={`command-step ${step1Done ? "done" : "active"}`}><span>1</span><strong>{"File and details"}</strong></div>
|
||||
<div className={`command-step ${preview ? "done" : step1Done ? "active" : ""}`}><span>2</span><strong>{"Dry-run validation"}</strong></div>
|
||||
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{"Confirm import"}</strong></div>
|
||||
</div>
|
||||
{giftID === "0" && <div className="gift-source-tabs">
|
||||
{SHOW_DEFAULT_GIFTS_TAB && <button className={`btn ${importSource === "default" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("default"); setPreview(null); }}>{"Default gifts"}</button>}
|
||||
<button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{"Official snapshot"}</button>
|
||||
<button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{"Upload file"}</button>
|
||||
</div>}
|
||||
{importSource === "default" && giftID === "0" && SHOW_DEFAULT_GIFTS_TAB ? <section className="official-gift-picker">
|
||||
<div className="gift-import-note"><span>{"Import our built-in original OwpenGram gifts. Complete collectible pools (upgrade + craft) are imported atomically."}</span><div className="gift-format-chips"><span>{defaultGifts.length}</span><span>OwpenGram</span></div></div>
|
||||
<div className="official-gift-bulk-import">
|
||||
<button className="btn" type="button" onClick={() => openBulkImport("default")}>
|
||||
<Upload size={14} /> {"Import all default gifts"}
|
||||
</button>
|
||||
</div>
|
||||
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
|
||||
<div className="official-gift-list" role="listbox" aria-label={"Choose a default gift"}>
|
||||
{defaultGifts.map((gift) => {
|
||||
const isSelected = gift.id === selectedDefaultID;
|
||||
return <button key={gift.id} className={`official-gift-option ${isSelected ? "selected" : ""}`}
|
||||
type="button" role="option" aria-selected={isSelected} onClick={() => { setSelectedDefaultID(gift.id); setPreview(null); }}>
|
||||
<span className="official-gift-option-head">
|
||||
<strong>{gift.title}</strong>
|
||||
<span className="mono">⭐ {gift.stars}</span>
|
||||
</span>
|
||||
<span className="official-gift-option-meta">
|
||||
<span>{`${defaultGiftAttributeCount(gift)} attributes`}</span>
|
||||
{gift.limited && <span>{`Limited · ${gift.availability}`}</span>}
|
||||
{gift.require_premium && <span>{"Premium only"}</span>}
|
||||
</span>
|
||||
<span className="official-gift-capabilities">
|
||||
<span className={gift.upgradeable ? "yes" : "no"}>{gift.upgradeable ? "Can upgrade" : "Cannot upgrade"}</span>
|
||||
<span className={gift.craftable ? "craft" : "no"}>{gift.craftable ? "Can Craft" : "Cannot Craft"}</span>
|
||||
</span>
|
||||
</button>;
|
||||
})}
|
||||
{defaultGifts.length === 0 && <div className="official-gift-empty">{"No default gifts are available."}</div>}
|
||||
</div>
|
||||
{selectedDefault && <div className="official-gift-selected">
|
||||
<DefaultLottiePreview id={selectedDefault.id} />
|
||||
<div><strong>{selectedDefault.title}</strong><span className="mono">⭐ {selectedDefault.stars} → {selectedDefault.convert_stars}</span><small>{selectedDefault.model_count} {"Models"} · {selectedDefault.pattern_count} {"Patterns"} · {selectedDefault.backdrop_count} {"Backdrops"}</small><span className="official-gift-capabilities"><span className={selectedDefault.upgradeable ? "yes" : "no"}>{selectedDefault.upgradeable ? "Can upgrade" : "Cannot upgrade"}</span><span className={selectedDefault.craftable ? "craft" : "no"}>{selectedDefault.craftable ? "Can Craft" : "Cannot Craft"}</span></span></div>
|
||||
</div>}
|
||||
</section> : importSource === "official" && giftID === "0" ? <section className="official-gift-picker">
|
||||
<div className="gift-import-note"><span>{"Choose a verified gift from data/official-gifts. Complete collectible pools are imported atomically."}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div>
|
||||
<div className="official-gift-bulk-import">
|
||||
<button className="btn" type="button" onClick={() => openBulkImport("official")}>
|
||||
<Upload size={14} /> {"Import all official gifts"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="official-gift-tools">
|
||||
<label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={"Search official gift ID or title"} /></label>
|
||||
<span>{`Showing ${visibleOfficial.length} of ${officialGifts.length}`}</span>
|
||||
</div>
|
||||
<div className="official-gift-categories" role="group" aria-label={"Official gift capability category"}>
|
||||
{(["all", "upgrade", "craft", "basic"] as const).map((category) => (
|
||||
<button key={category} className={officialCategory === category ? "active" : ""} type="button"
|
||||
aria-pressed={officialCategory === category} onClick={() => setOfficialCategory(category)}>
|
||||
{officialCategoryLabels[category]}<span>{officialCategoryCounts[category]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="official-gift-list" role="listbox" aria-label={"Choose an official gift"}>
|
||||
{visibleOfficial.map((gift) => {
|
||||
const isSelected = gift.source_gift_id === sourceGiftID;
|
||||
return <button key={gift.source_gift_id} className={`official-gift-option ${isSelected ? "selected" : ""}`}
|
||||
type="button" role="option" aria-selected={isSelected} onClick={() => chooseOfficial(gift)}>
|
||||
<span className="official-gift-option-head">
|
||||
<strong>{gift.title || `Unnamed official gift #${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>{`${officialGiftAttributeCount(gift)} attributes`}</span>
|
||||
</span>
|
||||
<span className="official-gift-capabilities">
|
||||
<span className={gift.can_upgrade ? "yes" : "no"}>{gift.can_upgrade ? "Can upgrade" : "Cannot upgrade"}</span>
|
||||
<span className={gift.can_craft ? "craft" : "no"}>{gift.can_craft ? "Can Craft" : "Cannot Craft"}</span>
|
||||
</span>
|
||||
</button>;
|
||||
})}
|
||||
{visibleOfficial.length === 0 && <div className="official-gift-empty">{"No official gifts match this category and search."}</div>}
|
||||
</div>
|
||||
{selectedOfficial && <div className="official-gift-selected">
|
||||
<OfficialLottiePreview sourceGiftID={selectedOfficial.source_gift_id} />
|
||||
<div><strong>{selectedOfficial.title || `Unnamed official gift #${selectedOfficial.source_gift_id}`}</strong><span className="mono">{selectedOfficial.source_gift_id}</span><small>{selectedOfficial.model_count} {"Models"} · {selectedOfficial.pattern_count} {"Patterns"} · {selectedOfficial.backdrop_count} {"Backdrops"}</small><span className="official-gift-capabilities"><span className={selectedOfficial.can_upgrade ? "yes" : "no"}>{selectedOfficial.can_upgrade ? "Can upgrade" : "Cannot upgrade"}</span><span className={selectedOfficial.can_craft ? "craft" : "no"}>{selectedOfficial.can_craft ? "Can Craft" : "Cannot Craft"}</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>{"Import the complete collectible pool, including crafted models"}</span></label>
|
||||
{includeCollectible && <div className="gift-fields-grid">
|
||||
<label><span>{"Upgrade price in Stars"}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Unique supply"}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Public slug prefix"}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); setPreview(null); }} /></label>
|
||||
</div>}
|
||||
</>}
|
||||
<div className="gift-fields-grid">
|
||||
<label><span>{"Display title"}</span><input value={title} maxLength={128} placeholder={"e.g. Celebration Star"} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Price in Stars"}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Conversion Stars"}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Sort order"}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
|
||||
</div>
|
||||
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
|
||||
</section> : <>
|
||||
<div className="gift-import-note"><span>{"Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS."}</span><div className="gift-format-chips" aria-label={"Accepted 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">{"Animation file"}</span><strong>{file ? file.name : "Drop or choose a TGS / Lottie file"}</strong><small>{file ? formatBytes(file.size) : "TGS, JSON or Lottie · validated before import"}</small></span>
|
||||
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
|
||||
</label>
|
||||
<div className="gift-fields-grid">
|
||||
<label><span>{"Display title"}</span><input value={title} maxLength={128} placeholder={"e.g. Celebration Star"} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Price in Stars"}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Conversion Stars"}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Sort order"}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
|
||||
</div>
|
||||
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
|
||||
</>}
|
||||
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this gift is being imported"} onChange={(e) => setReason(e.target.value)} /></label>
|
||||
{importError && <Alert>{importError}</Alert>}
|
||||
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{"Validation passed"}</strong><span>{"Review the normalized metadata, then confirm the import."}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={() => setImportOpen(false)} disabled={busy}>{"Close"}</button>
|
||||
<button className="btn" type="button" onClick={validateImport} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{"Dry-run validation"}</button>
|
||||
<button className="btn primary" type="button" onClick={confirmImport} disabled={busy || !preview}><Upload size={15} />{"Confirm import"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{bulkImportOpen && createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal gift-bulk-import-modal" role="dialog" aria-modal="true"
|
||||
aria-label={bulkImportOpen === "default" ? "Import all default gifts" : "Import all official gifts"}>
|
||||
<div className="modal-head">
|
||||
<div><div className="eyebrow">{"Gift catalog operation"}</div><h2>{bulkImportOpen === "default" ? "Import all default gifts" : "Import all official gifts"}</h2></div>
|
||||
<button className="icon-btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<div className="gift-import-note"><span>{`${bulkImportItems.length} gifts available to import`}</span></div>
|
||||
<label className="gift-switch"><input type="checkbox" checked={bulkImportEnabled} disabled={bulkImportBusy} onChange={(e) => setBulkImportEnabled(e.target.checked)} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
|
||||
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={bulkImportReason} placeholder={"Briefly describe why this gift is being imported"} disabled={bulkImportBusy} onChange={(e) => setBulkImportReason(e.target.value)} /></label>
|
||||
{bulkImportBusy && <div className="gift-bulk-import-progress">
|
||||
<div className="gift-bulk-import-progress-bar"><div style={{ width: `${bulkImportProgress.total ? Math.round((bulkImportProgress.done / bulkImportProgress.total) * 100) : 0}%` }} /></div>
|
||||
<span>{`Importing ${bulkImportProgress.done} of ${bulkImportProgress.total}`}</span>
|
||||
</div>}
|
||||
{bulkImportError && <Alert>{bulkImportError}</Alert>}
|
||||
{bulkImportResult && <div className="gift-validation">
|
||||
<div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{"Import complete"}</strong><span>{`Imported ${bulkImportResult.imported}, skipped ${bulkImportResult.skipped}, failed ${bulkImportResult.failed}`}</span></div></div>
|
||||
{bulkImportResult.errors.length > 0 && <pre>{bulkImportResult.errors.join("\n")}</pre>}
|
||||
</div>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy}>{"Close"}</button>
|
||||
<button className="btn primary" type="button" onClick={runBulkImport} disabled={bulkImportBusy || bulkImportItems.length === 0}>
|
||||
{bulkImportBusy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />} {"Start import"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{collectibleGift && <GiftCollectiblesModal gift={collectibleGift} onClose={() => setCollectibleGift(null)} onPublished={() => void load()} />}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
import { CheckCircle2, CircleAlert, Gift, Loader2, Play, User, Users } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
|
||||
import { Alert, JsonBlock } from "../components/ui";
|
||||
import type { AccountRow, ChannelRow, CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types";
|
||||
|
||||
const SYSTEM_SENDER = "777000";
|
||||
|
||||
type RecipientKind = "user" | "channel";
|
||||
|
||||
function attrLabel(attr: StarGiftCollectibleAttributeRow): string {
|
||||
const rarity = attr.rarity_permille > 0 ? ` · ${(attr.rarity_permille / 10).toFixed(1)}%` : "";
|
||||
return `${attr.name || `#${attr.id}`}${rarity}`;
|
||||
}
|
||||
|
||||
export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: () => void }) {
|
||||
const [kind, setKind] = useState<RecipientKind>("user");
|
||||
const [user, setUser] = useState<AccountRow | null>(null);
|
||||
const [channel, setChannel] = useState<ChannelRow | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [hideName, setHideName] = useState(false);
|
||||
const [upgrade, setUpgrade] = useState(false);
|
||||
const [preview, setPreview] = useState<StarGiftCollectiblePreview | null>(null);
|
||||
const [previewError, setPreviewError] = useState("");
|
||||
const [modelID, setModelID] = useState("0");
|
||||
const [patternID, setPatternID] = useState("0");
|
||||
const [backdropID, setBackdropID] = useState("0");
|
||||
const [reason, setReason] = useState("");
|
||||
const [result, setResult] = useState<CommandResult | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const recipientID = kind === "user" ? user?.ID ?? 0 : channel?.ID ?? 0;
|
||||
const upgradable = kind === "user" && upgrade;
|
||||
|
||||
// Reset the collectible selection whenever the chosen gift changes; the
|
||||
// recipient/sender/message are intentionally preserved for fast re-issuing.
|
||||
useEffect(() => {
|
||||
setUpgrade(false);
|
||||
setPreview(null);
|
||||
setPreviewError("");
|
||||
setModelID("0");
|
||||
setPatternID("0");
|
||||
setBackdropID("0");
|
||||
setResult(null);
|
||||
setError("");
|
||||
}, [gift.GiftID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!upgradable || preview) return;
|
||||
let cancelled = false;
|
||||
setPreviewError("");
|
||||
api.giftCollectibles(gift.GiftID)
|
||||
.then((data) => { if (!cancelled) setPreview(data); })
|
||||
.catch((err) => { if (!cancelled) setPreviewError(errorMessage(err)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [upgradable, preview, gift.GiftID]);
|
||||
|
||||
function buildPayload(confirm: boolean): Record<string, unknown> {
|
||||
return {
|
||||
gift_id: gift.GiftID,
|
||||
// Gifts are always sent from the official system account (777000).
|
||||
sender_user_id: Number(SYSTEM_SENDER),
|
||||
user_id: kind === "user" ? recipientID : 0,
|
||||
channel_id: kind === "channel" ? recipientID : 0,
|
||||
hide_name: hideName,
|
||||
message: message.trim(),
|
||||
upgrade: upgradable,
|
||||
model_attribute_id: upgradable ? modelID : "0",
|
||||
pattern_attribute_id: upgradable ? patternID : "0",
|
||||
backdrop_attribute_id: upgradable ? backdropID : "0",
|
||||
reason: reason.trim(),
|
||||
confirm
|
||||
};
|
||||
}
|
||||
|
||||
const previewPayload = useMemo(() => buildPayload(false), [gift.GiftID, kind, recipientID, message, hideName, upgrade, modelID, patternID, backdropID, reason]);
|
||||
const canConfirm = result?.dry_run && !result.error;
|
||||
|
||||
async function run(confirm: boolean) {
|
||||
if (recipientID <= 0) {
|
||||
setError("Select a recipient first");
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
setError("Please enter an operation reason");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const commandResult = await api.action("/api/actions/give-gift", buildPayload(confirm));
|
||||
setResult(commandResult);
|
||||
if (confirm && !commandResult.error) {
|
||||
onDone?.();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="give-gift-form">
|
||||
<div className="give-gift-summary">
|
||||
<Gift size={16} />
|
||||
<div>
|
||||
<strong>{gift.Title || `Gift #${gift.GiftID}`}</strong>
|
||||
<span className="mono">#{gift.GiftID} · ⭐ {gift.Stars}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="give-gift-tabs" role="group" aria-label={"Recipient type"}>
|
||||
<button type="button" className={`btn ${kind === "user" ? "primary" : ""}`} onClick={() => { setKind("user"); setResult(null); }}>
|
||||
<User size={15} /> {"User"}
|
||||
</button>
|
||||
<button type="button" className={`btn ${kind === "channel" ? "primary" : ""}`} onClick={() => { setKind("channel"); setUpgrade(false); setResult(null); }}>
|
||||
<Users size={15} /> {"Channel"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{kind === "user"
|
||||
? <UserPicker label={"Recipient user"} value={user} onChange={(row) => { setUser(row); setResult(null); }} />
|
||||
: <ChannelPicker label={"Recipient channel"} value={channel} onChange={(row) => { setChannel(row); setResult(null); }} />}
|
||||
|
||||
<label className="form-field">
|
||||
<span>{"Sender account ID"}</span>
|
||||
<input value={SYSTEM_SENDER} disabled readOnly />
|
||||
<small className="field-hint">{"Gifts are always sent from the system account 777000 (Telesrv)."}</small>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>{"Attached message (optional)"}</span>
|
||||
<textarea value={message} rows={2} maxLength={128} onChange={(event) => { setMessage(event.target.value); setResult(null); }} placeholder={"Shown with the gift"} />
|
||||
</label>
|
||||
|
||||
<label className="gift-switch">
|
||||
<input type="checkbox" checked={hideName} onChange={(event) => { setHideName(event.target.checked); setResult(null); }} />
|
||||
<span className="gift-switch-track" aria-hidden="true"><span /></span>
|
||||
<span>{"Hide sender name from recipient"}</span>
|
||||
</label>
|
||||
|
||||
{kind === "user" && (
|
||||
<>
|
||||
<label className="gift-switch">
|
||||
<input type="checkbox" checked={upgrade} onChange={(event) => { setUpgrade(event.target.checked); if (!event.target.checked) { setModelID("0"); setPatternID("0"); setBackdropID("0"); } setResult(null); }} />
|
||||
<span className="gift-switch-track" aria-hidden="true"><span /></span>
|
||||
<span>{"Deliver as upgraded collectible"}</span>
|
||||
</label>
|
||||
{upgrade && <p className="give-gift-upgrade-note">{"The gift is minted as a unique collectible. Pick specific attributes below, or leave them on Random to draw from the published pool. The collectible number is assigned automatically. Requires a published collectible upgrade with remaining supply."}</p>}
|
||||
{upgrade && previewError && <Alert>{previewError}</Alert>}
|
||||
{upgrade && preview && (
|
||||
<div className="gift-fields-grid give-gift-attrs">
|
||||
<label>
|
||||
<span>{"Model"}</span>
|
||||
<select value={modelID} onChange={(event) => { setModelID(event.target.value); setResult(null); }}>
|
||||
<option value="0">{"Random"}</option>
|
||||
{(preview.models ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{"Pattern"}</span>
|
||||
<select value={patternID} onChange={(event) => { setPatternID(event.target.value); setResult(null); }}>
|
||||
<option value="0">{"Random"}</option>
|
||||
{(preview.patterns ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{"Backdrop"}</span>
|
||||
<select value={backdropID} onChange={(event) => { setBackdropID(event.target.value); setResult(null); }}>
|
||||
<option value="0">{"Random"}</option>
|
||||
{(preview.backdrops ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="form-field">
|
||||
<span>{"Operation reason"}</span>
|
||||
<textarea value={reason} rows={2} onChange={(event) => setReason(event.target.value)} placeholder={"Describe why this operation is being performed"} />
|
||||
</label>
|
||||
|
||||
<div className="command-preview">
|
||||
<div className="preview-head">{"Request preview"}</div>
|
||||
<JsonBlock value={JSON.stringify(previewPayload, null, 2)} />
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{result && (
|
||||
<div className="result-box">
|
||||
<div className="result-title">
|
||||
{result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />}
|
||||
<strong>{result.message || result.error || "Action result"}</strong>
|
||||
</div>
|
||||
<div className="result-line"><span>{"Command ID"}</span><strong>{result.command_id}</strong></div>
|
||||
<div className="result-line"><span>{"Status"}</span><strong>{result.status}</strong></div>
|
||||
<div className="result-line"><span>{"Dry-run"}</span><strong>{result.dry_run ? "Yes" : "No"}</strong></div>
|
||||
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="give-gift-form-actions">
|
||||
<button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
|
||||
{result ? "Run dry-run again" : "Run dry-run first"}
|
||||
</button>
|
||||
<button className="btn primary icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
|
||||
<Gift size={15} />
|
||||
{"Give gift"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import { Gift, RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { StaticLottie } from "../components/StaticLottie";
|
||||
import { Alert, Badge, PageFrame } from "../components/ui";
|
||||
import type { StarGiftRow } from "../types";
|
||||
import { GiveGiftForm } from "./GiveGiftForm";
|
||||
|
||||
export function GiveGiftsPage() {
|
||||
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<StarGiftRow | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const rows = (await api.gifts()).Gifts ?? [];
|
||||
setGifts(rows);
|
||||
setSelected((current) => current ?? rows[0] ?? null);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return gifts;
|
||||
return gifts.filter((gift) =>
|
||||
String(gift.GiftID).includes(normalized) || gift.Title.toLowerCase().includes(normalized)
|
||||
);
|
||||
}, [gifts, query]);
|
||||
|
||||
return (
|
||||
<PageFrame title={"Give Gifts"} eyebrow={"Grant catalog gifts to any user or channel"} actions={
|
||||
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {"Refresh"}</button>
|
||||
}>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<p className="give-gift-upgrade-note">{"Pick a gift to grant. Delivery is free of charge and sent from the system account 777000 (Telesrv) by default."}</p>
|
||||
<div className="give-gift-layout">
|
||||
<section className="give-gift-picker">
|
||||
<div className="give-gift-picker-head">
|
||||
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={"Search by title or gift ID"} /></label>
|
||||
<span className="gift-list-summary">{`Showing ${visible.length} of ${gifts.length}`}</span>
|
||||
</div>
|
||||
<div className="give-gift-picker-list" role="listbox" aria-label={"Select a gift"}>
|
||||
{visible.map((gift) => {
|
||||
const active = selected?.GiftID === gift.GiftID;
|
||||
return (
|
||||
<button key={gift.GiftID} type="button" role="option" aria-selected={active}
|
||||
className={`give-gift-option ${active ? "selected" : ""} ${gift.Enabled ? "" : "gift-row-disabled"}`}
|
||||
onClick={() => setSelected(gift)}>
|
||||
<StaticLottie className="give-gift-thumb" cacheKey={`${gift.GiftID}:${gift.Revision}`} loader={() => api.giftAnimation(gift.GiftID)} />
|
||||
<span className="give-gift-option-info">
|
||||
<strong>{gift.Title || `Gift #${gift.GiftID}`}</strong>
|
||||
<span className="mono">#{gift.GiftID}</span>
|
||||
</span>
|
||||
<span className="give-gift-option-price">
|
||||
{gift.Enabled ? <Badge>⭐ {gift.Stars}</Badge> : <Badge tone="neutral">{"Disabled"}</Badge>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{visible.length === 0 && !busy && <div className="official-gift-empty">{"No results"}</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="give-gift-panel">
|
||||
{selected
|
||||
? <GiveGiftForm key={selected.GiftID} gift={selected} onDone={() => void load()} />
|
||||
: <div className="give-gift-empty-panel"><Gift size={26} /><p>{"Select a gift from the list to start."}</p></div>}
|
||||
</section>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import { type Navigate, type RouteState } from "../routing";
|
||||
import { AccountDetailPage } from "./AccountDetailPage";
|
||||
import { AccountRatingDetailPage } from "./AccountRatingDetailPage";
|
||||
import { AccountRatingsPage } from "./AccountRatingsPage";
|
||||
import { AccountsPage } from "./AccountsPage";
|
||||
import { SharedDevicesPage } from "./SharedDevicesPage";
|
||||
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
|
||||
|
|
@ -16,9 +14,7 @@ import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
|
|||
import { GroupMessagesPage } from "./GroupMessagesPage";
|
||||
import { MessageDetailPage } from "./MessageDetailPage";
|
||||
import { MessagesPage } from "./MessagesPage";
|
||||
import { GiftsPage } from "./GiftsPage";
|
||||
import { StickerSetsPage } from "./StickerSetsPage";
|
||||
import { GiveGiftsPage } from "./GiveGiftsPage";
|
||||
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
|
||||
import { ModerationCasesPage } from "./ModerationCasesPage";
|
||||
import { StoragePage } from "./StoragePage";
|
||||
|
|
@ -40,7 +36,6 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
const moderationCaseID = route.path.match(/^\/moderation\/(\d+)$/)?.[1];
|
||||
// int64 ids stay strings so large values never lose precision.
|
||||
const collectibleUsernameID = route.path.match(/^\/collectible-usernames\/(\d+)$/)?.[1];
|
||||
const ratingUserID = route.path.match(/^\/account-ratings\/(\d+)$/)?.[1];
|
||||
const verificationID = route.path.match(/^\/verification\/(\d+)$/)?.[1];
|
||||
// Third-party verification: a separate section with its own rights, matched before
|
||||
// the official one so neither prefix can shadow the other.
|
||||
|
|
@ -83,15 +78,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (collectibleUsernameID) {
|
||||
return <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />;
|
||||
}
|
||||
if (ratingUserID) {
|
||||
return <AccountRatingDetailPage userID={ratingUserID} navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/collectible-usernames") {
|
||||
return <CollectibleUsernamesPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/account-ratings") {
|
||||
return <AccountRatingsPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/storage") {
|
||||
return <StoragePage navigate={navigate} />;
|
||||
}
|
||||
|
|
@ -128,15 +117,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (route.path === "/emoji") {
|
||||
return <StickerSetsPage kind="emoji" />;
|
||||
}
|
||||
if (route.path === "/gifts") {
|
||||
return <GiftsPage />;
|
||||
}
|
||||
if (route.path === "/stickers") {
|
||||
return <StickerSetsPage kind="stickers" />;
|
||||
}
|
||||
if (route.path === "/give-gifts") {
|
||||
return <GiveGiftsPage />;
|
||||
}
|
||||
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
|
||||
return (
|
||||
<MessageDetailPage
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ export function routeTitle(pathname: string): string {
|
|||
if (pathname.startsWith("/bot-verification")) return "Third-party verification";
|
||||
if (pathname.startsWith("/verification")) return "Official Verification";
|
||||
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
|
||||
if (pathname.startsWith("/account-ratings")) return "Account Rating";
|
||||
if (pathname.startsWith("/storage")) return "Storage";
|
||||
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
|
||||
if (pathname.startsWith("/accounts")) return "Accounts";
|
||||
|
|
@ -30,9 +29,6 @@ export function routeTitle(pathname: string): string {
|
|||
if (pathname.startsWith("/broadcasts")) return "Broadcasts";
|
||||
if (pathname.startsWith("/emoji")) return "Emoji";
|
||||
if (pathname.startsWith("/messages")) return "Message Audit";
|
||||
if (pathname.startsWith("/give-gifts")) return "Give Gifts";
|
||||
if (pathname.startsWith("/gifts")) return "Star Gifts";
|
||||
if (pathname.startsWith("/stickers")) return "Stickers";
|
||||
if (pathname.startsWith("/emoji")) return "Emoji";
|
||||
return "Operations Console";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -720,42 +720,3 @@ textarea:focus {
|
|||
}
|
||||
}
|
||||
|
||||
/* Level progress bars (account rating leaderboard and detail). */
|
||||
.progress-cell {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.progress-cell small,
|
||||
.progress-note {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.progress-bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--brand-2);
|
||||
}
|
||||
|
||||
.progress-bar.good > span {
|
||||
background: var(--good);
|
||||
}
|
||||
|
||||
.progress-bar.danger > span {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.progress-wide .progress-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -879,71 +879,6 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Account rating component breakdown. */
|
||||
.breakdown-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.breakdown-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 260px) 1fr minmax(80px, auto);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 9px 10px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.breakdown-row.total {
|
||||
grid-template-columns: 1fr minmax(80px, auto);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.breakdown-label {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.breakdown-label strong {
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.breakdown-label small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.breakdown-value {
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.breakdown-value.good {
|
||||
color: var(--good);
|
||||
}
|
||||
|
||||
.breakdown-value.danger {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.breakdown-row,
|
||||
.breakdown-row.total {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.breakdown-value {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* Collectible usernames branching off the peer's editable one. The guide is drawn
|
||||
with borders rather than a "↳" character so it lines up at any font size and is
|
||||
not read out by a screen reader as punctuation. */
|
||||
|
|
|
|||
|
|
@ -75,8 +75,6 @@ export type AccountDetail = {
|
|||
Fake: boolean;
|
||||
Support: boolean;
|
||||
Bot: boolean;
|
||||
StarsBalance: number;
|
||||
StarsGranted: boolean;
|
||||
Restriction: RestrictionRow;
|
||||
HasRestriction: boolean;
|
||||
Authorizations: AuthorizationRow[];
|
||||
|
|
@ -205,74 +203,6 @@ export type OutboxRow = {
|
|||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type StarGiftRow = {
|
||||
GiftID: string;
|
||||
RevisionID: string;
|
||||
Revision: number;
|
||||
Title: string;
|
||||
Stars: string;
|
||||
ConvertStars: string;
|
||||
Enabled: boolean;
|
||||
SortOrder: number;
|
||||
DocumentID: string;
|
||||
SourceName: string;
|
||||
SourceFormat: "tgs" | "lottie";
|
||||
AnimationSHA: string;
|
||||
AnimationSize: string;
|
||||
Width: number;
|
||||
Height: number;
|
||||
FrameRate: number;
|
||||
ReceivedCount: string;
|
||||
CreatedBy: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type StarGiftListResponse = { Gifts: StarGiftRow[] };
|
||||
|
||||
// A built-in original demo gift available to import. Ids are small integers
|
||||
// (1..N), so plain numbers are safe here — no snowflake precision concern.
|
||||
export type DefaultGiftRow = {
|
||||
id: number;
|
||||
title: string;
|
||||
stars: number;
|
||||
convert_stars: number;
|
||||
upgrade_stars: number;
|
||||
upgradeable: boolean;
|
||||
craftable: boolean;
|
||||
limited: boolean;
|
||||
availability: number;
|
||||
require_premium: boolean;
|
||||
model_count: number;
|
||||
pattern_count: number;
|
||||
backdrop_count: number;
|
||||
crafted_count: number;
|
||||
};
|
||||
|
||||
export type DefaultGiftListResponse = { gifts: DefaultGiftRow[] };
|
||||
|
||||
// A verified official Star Gift snapshot entry (see cmd/giftfetch). Numeric
|
||||
// ids/counters that can approach int64 range are decimal strings.
|
||||
export type OfficialStarGiftRow = {
|
||||
source_gift_id: string;
|
||||
title: string;
|
||||
stars: string;
|
||||
convert_stars: string;
|
||||
upgrade_stars: string;
|
||||
availability_total: number;
|
||||
limited: boolean;
|
||||
sold_out: boolean;
|
||||
model_count: number;
|
||||
pattern_count: number;
|
||||
backdrop_count: number;
|
||||
crafted_model_count: number;
|
||||
can_upgrade: boolean;
|
||||
can_craft: boolean;
|
||||
document_id: string;
|
||||
animation_validated: boolean;
|
||||
};
|
||||
|
||||
export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] };
|
||||
|
||||
export type ModerationPeer = {
|
||||
Type: "user" | "channel";
|
||||
ID: number;
|
||||
|
|
@ -352,37 +282,6 @@ export type ModerationReport = {
|
|||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type StarGiftCollectibleAttributeRow = {
|
||||
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";
|
||||
backdrop_id?: number;
|
||||
center_color?: number;
|
||||
edge_color?: number;
|
||||
pattern_color?: number;
|
||||
text_color?: number;
|
||||
};
|
||||
|
||||
export type StarGiftCollectiblePreview = {
|
||||
found: boolean;
|
||||
gift_id: string;
|
||||
revision?: number;
|
||||
upgrade_stars?: string;
|
||||
supply_total?: number;
|
||||
issued?: number;
|
||||
slug_prefix?: string;
|
||||
models?: StarGiftCollectibleAttributeRow[];
|
||||
patterns?: StarGiftCollectibleAttributeRow[];
|
||||
backdrops?: StarGiftCollectibleAttributeRow[];
|
||||
};
|
||||
|
||||
export type CollectibleUsernameStatus = "vault" | "owned" | "burned";
|
||||
|
||||
export type CollectiblePeerType = "" | "user" | "channel";
|
||||
|
|
@ -448,50 +347,6 @@ export type CollectibleUsernameDetail = {
|
|||
transfers: CollectibleUsernameTransferRow[] | null;
|
||||
};
|
||||
|
||||
export type AccountRatingRow = {
|
||||
UserID: string;
|
||||
Username: string;
|
||||
FirstName: string;
|
||||
Level: number;
|
||||
Stars: string;
|
||||
CurrentLevelStars: string;
|
||||
NextLevelStars: string;
|
||||
HasNextLevel: boolean;
|
||||
StarsComponent: string;
|
||||
ActivityComponent: string;
|
||||
PenaltyComponent: string;
|
||||
ManualComponent: string;
|
||||
PendingStars: string;
|
||||
PendingDate: string;
|
||||
ComputedAt: string;
|
||||
UpdatedAt: string;
|
||||
Version: string;
|
||||
};
|
||||
|
||||
export type AccountRatingEventKind = "stars" | "activity" | "moderation" | "manual" | "recompute";
|
||||
|
||||
export type AccountRatingEventRow = {
|
||||
ID: string;
|
||||
UserID: string;
|
||||
Kind: AccountRatingEventKind;
|
||||
Amount: string;
|
||||
Reason: string;
|
||||
Actor: string;
|
||||
CommandKey: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type AccountRatingListResponse = {
|
||||
rows: AccountRatingRow[] | null;
|
||||
has_more: boolean;
|
||||
next_before_id: string;
|
||||
};
|
||||
|
||||
export type AccountRatingDetail = {
|
||||
rating: AccountRatingRow;
|
||||
events: AccountRatingEventRow[] | null;
|
||||
};
|
||||
|
||||
// Official platform verification. Every int64 the backend tags `,string` stays a
|
||||
// decimal string here: application ids, peer ids and the optimistic-locking
|
||||
// version all outgrow the exact range of a JSON number, and a rounded version
|
||||
|
|
@ -762,7 +617,7 @@ export type CommandResult = {
|
|||
|
||||
export type StickerSetRow = {
|
||||
// String, not number: these are 18-19 digit snowflake ids, past JS's 2^53
|
||||
// safe-integer limit — see GiftID on StarGiftRow for the same convention.
|
||||
// safe-integer limit.
|
||||
ID: string;
|
||||
ShortName: string;
|
||||
Title: string;
|
||||
|
|
|
|||
|
|
@ -50,10 +50,7 @@ import (
|
|||
phoneapp "telesrv/internal/app/phone"
|
||||
pollsapp "telesrv/internal/app/polls"
|
||||
privacyapp "telesrv/internal/app/privacy"
|
||||
ratingapp "telesrv/internal/app/rating"
|
||||
secretchatapp "telesrv/internal/app/secretchat"
|
||||
"telesrv/internal/app/stargifts"
|
||||
"telesrv/internal/app/stars"
|
||||
storiesapp "telesrv/internal/app/stories"
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
themesapp "telesrv/internal/app/themes"
|
||||
|
|
@ -68,7 +65,6 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
"telesrv/internal/mtprotoedge"
|
||||
obsmetrics "telesrv/internal/observability/metrics"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/otpdelivery"
|
||||
otpsmtp "telesrv/internal/otpdelivery/smtp"
|
||||
otpwebhook "telesrv/internal/otpdelivery/webhook"
|
||||
|
|
@ -873,9 +869,8 @@ func run(logger *zap.Logger) error {
|
|||
rateLimiter := redisstore.NewRateLimiter(rdb)
|
||||
activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions"))
|
||||
adminService := adminapp.NewService(adminapp.Dependencies{
|
||||
Commands: adminStore,
|
||||
Restrictions: adminStore,
|
||||
OfficialGifts: officialgifts.New(cfg.OfficialGiftsDir),
|
||||
Commands: adminStore,
|
||||
Restrictions: adminStore,
|
||||
})
|
||||
storageRetentionMaxAge := cfg.StorageRetentionMaxAge
|
||||
if !cfg.StorageRetentionEnable {
|
||||
|
|
@ -1095,32 +1090,6 @@ func run(logger *zap.Logger) error {
|
|||
secretChatStore := postgres.NewSecretChatStore(pool)
|
||||
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
|
||||
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator)
|
||||
starsStore := postgres.NewStarsStore(pool)
|
||||
starsPurchaseStore := postgres.NewStarsPurchaseStore(pool, messageStore, channelStore)
|
||||
starsService := stars.NewService(starsStore,
|
||||
stars.WithStartingGrant(cfg.StarsStartingGrant),
|
||||
stars.WithPurchaseStore(starsPurchaseStore))
|
||||
starGiftStore := postgres.NewStarGiftStore(pool)
|
||||
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.WithLifecycleStore(starGiftLifecycleStore),
|
||||
stargifts.WithWithdrawalProvider(starGiftWithdrawalProvider))
|
||||
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
|
||||
// 同属进程内一次性凭据,不跨实例)。
|
||||
passkeyStore := postgres.NewPasskeyStore(pool)
|
||||
|
|
@ -1216,11 +1185,9 @@ func run(logger *zap.Logger) error {
|
|||
}),
|
||||
auth.WithEmailSignup(cfg.EmailSignupEnable),
|
||||
auth.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes))
|
||||
// Collectible (NFT) usernames and the gramsrv composite account rating are
|
||||
// optional read models projected at the protocol edge. The rating worker
|
||||
// computes and persists scores; profile reads never recompute them.
|
||||
// Collectible (NFT) usernames are an optional read model projected at the
|
||||
// protocol edge.
|
||||
collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool)
|
||||
accountRatingStore := postgres.NewAccountRatingStore(pool)
|
||||
usernamesService := usernamesapp.NewService(
|
||||
usernamesapp.WithRegistryStore(collectibleUsernameStore),
|
||||
usernamesapp.WithCollectibleStore(collectibleUsernameStore),
|
||||
|
|
@ -1228,14 +1195,6 @@ func run(logger *zap.Logger) error {
|
|||
usernamesapp.WithPublicBaseURL(cfg.PublicBaseURL),
|
||||
usernamesapp.WithLogger(logger.Named("app").Named("usernames")),
|
||||
)
|
||||
ratingService := ratingapp.NewService(
|
||||
ratingapp.WithStore(accountRatingStore),
|
||||
ratingapp.WithEnabled(cfg.RatingEnabled),
|
||||
ratingapp.WithWeights(cfg.AccountRatingWeights()),
|
||||
ratingapp.WithPendingDelay(cfg.RatingPendingDelay),
|
||||
ratingapp.WithStaleAfter(cfg.RatingStaleAfter),
|
||||
ratingapp.WithLogger(logger.Named("app").Named("rating")),
|
||||
)
|
||||
// Official platform verification: applications are filed through the built-in
|
||||
// @verifybot and decided in the admin panel. Every eligibility rule lives in
|
||||
// this service; the bot and the panel are only its two surfaces.
|
||||
|
|
@ -1339,7 +1298,6 @@ func run(logger *zap.Logger) error {
|
|||
Moderation: moderationService,
|
||||
Users: usersService,
|
||||
Usernames: usernamesService,
|
||||
AccountRatings: ratingService,
|
||||
BotVerifications: botVerificationService,
|
||||
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
|
||||
Updates: updatesService,
|
||||
|
|
@ -1361,8 +1319,6 @@ func run(logger *zap.Logger) error {
|
|||
Stories: storiesService,
|
||||
Phone: phoneService,
|
||||
SecretChats: secretChatService,
|
||||
Stars: starsService,
|
||||
Gifts: giftsService,
|
||||
Passkey: passkeyService,
|
||||
Themes: themeService,
|
||||
GroupCalls: groupCallsService,
|
||||
|
|
@ -1393,7 +1349,6 @@ func run(logger *zap.Logger) error {
|
|||
RPCProjections: router,
|
||||
BaseUsers: userCache,
|
||||
BotProfiles: botsService,
|
||||
StarGifts: giftsService,
|
||||
AccountSettings: router,
|
||||
}, logger.Named("store").Named("read-model-listener"))
|
||||
go readModelListener.Run(ctx)
|
||||
|
|
@ -1406,23 +1361,18 @@ func run(logger *zap.Logger) error {
|
|||
Auth: authService,
|
||||
Revoker: router,
|
||||
Users: usersService,
|
||||
Stars: starsService,
|
||||
StarsNotifier: router,
|
||||
UserNotifier: router,
|
||||
UserModerationNotifier: router,
|
||||
FreezeNotifier: router,
|
||||
Channels: channelsService,
|
||||
ChannelNotifier: router,
|
||||
Messages: messagesService,
|
||||
Gifts: giftsService,
|
||||
Photos: filesService,
|
||||
StickerSets: filesService,
|
||||
GiftGranter: router,
|
||||
Bots: botsService,
|
||||
Emoji: filesService,
|
||||
Moderation: moderationService,
|
||||
Usernames: usernamesService,
|
||||
Rating: ratingService,
|
||||
Verification: verificationService,
|
||||
BotVerification: botVerificationService,
|
||||
Account: accountService,
|
||||
|
|
@ -1479,8 +1429,6 @@ func run(logger *zap.Logger) error {
|
|||
logger.Warn("third-party verification push is not implemented by the RPC edge",
|
||||
zap.String("expected_hook", "rpc.Router.NotifyPeerBotVerification"))
|
||||
}
|
||||
go ratingapp.NewRecomputeWorker(ratingService, logger.Named("rating").Named("recompute"),
|
||||
cfg.RatingRecomputeInterval, cfg.RatingRecomputeBatch).Run(ctx)
|
||||
// Applicant notifications are delivered from a durable outbox, never inside the
|
||||
// decision transaction: @verifybot may be blocked and the panel must not wait on
|
||||
// a message send.
|
||||
|
|
@ -1536,32 +1484,6 @@ func run(logger *zap.Logger) error {
|
|||
if telegramLoginService != nil {
|
||||
go runTelegramLoginRetention(ctx, telegramLoginService, cfg.TelegramLoginRetention, cfg.TelegramLoginSweepInterval, cfg.TelegramLoginSweepBatch, logger.Named("telegram-login-retention"))
|
||||
}
|
||||
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)
|
||||
go router.RunBotCallbackAnswerSubscriber(ctx)
|
||||
go router.RunEphemeralPushSubscriber(ctx)
|
||||
|
|
@ -1598,8 +1520,6 @@ func run(logger *zap.Logger) error {
|
|||
Channels: channelStore,
|
||||
Privacy: privacyService,
|
||||
Photos: filesService,
|
||||
UniqueGifts: giftsService,
|
||||
GiftWithdrawals: giftsService,
|
||||
ModerationAppeals: moderationService,
|
||||
TelegramLogin: telegramLoginHTTPHandler,
|
||||
}, logger.Named("public-web")); err != nil {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue