Merge remote-tracking branch 'upstream/main' into dev
This commit is contained in:
commit
6b29556ef8
836 changed files with 1598388 additions and 64684 deletions
|
|
@ -1,5 +1,5 @@
|
|||
// Command appearancefetch 从官方 Telegram 拉取墙纸 + 聊天主题,下载文档/缩略图,
|
||||
// 生成 telesrv 外观 seed(default_appearance_seed.json + default_wallpapers/{documents,thumbs/m}/*.dat)。
|
||||
// 生成 telesrv 外观 seed(Default_appearance_seed.json + Default_wallpapers/{documents,thumbs/m}/*.dat)。
|
||||
// 复用 internal/seed/appearance 的结构体保证 schema 完全一致。peer_colors 从现有 JSON 沿用。
|
||||
//
|
||||
// 需登录(墙纸/主题接口非免登)。api 凭据用 TDesktop 开源公开的 id/hash。
|
||||
|
|
@ -21,10 +21,10 @@ import (
|
|||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/telegram/auth"
|
||||
"github.com/gotd/td/telegram/downloader"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/iamxvbaba/td/telegram"
|
||||
"github.com/iamxvbaba/td/telegram/auth"
|
||||
"github.com/iamxvbaba/td/telegram/downloader"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/seed/appearance"
|
||||
)
|
||||
|
|
@ -114,8 +114,8 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
|
|||
}
|
||||
|
||||
api := client.API()
|
||||
docsDir := filepath.Join(outDir, "default_wallpapers", "documents")
|
||||
thumbsDir := filepath.Join(outDir, "default_wallpapers", "thumbs", "m")
|
||||
docsDir := filepath.Join(outDir, "Default_wallpapers", "documents")
|
||||
thumbsDir := filepath.Join(outDir, "Default_wallpapers", "thumbs", "m")
|
||||
if err := os.MkdirAll(docsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -169,7 +169,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
|
|||
return appearance.Document{}, err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
out.Path = "default_wallpapers/documents/" + name
|
||||
out.Path = "Default_wallpapers/documents/" + name
|
||||
out.SHA256 = hex.EncodeToString(sum[:])
|
||||
// "m" 缩略图
|
||||
for _, t := range doc.Thumbs {
|
||||
|
|
@ -187,7 +187,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
|
|||
tsum := sha256.Sum256(tdata)
|
||||
out.Thumbs = append(out.Thumbs, appearance.PhotoSize{
|
||||
Kind: "size", Type: "m", W: ps.W, H: ps.H, Size: ps.Size,
|
||||
Path: "default_wallpapers/thumbs/m/" + name, SHA256: hex.EncodeToString(tsum[:]),
|
||||
Path: "Default_wallpapers/thumbs/m/" + name, SHA256: hex.EncodeToString(tsum[:]),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
|
@ -390,7 +390,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jsonPath := filepath.Join(outDir, "default_appearance_seed.json")
|
||||
jsonPath := filepath.Join(outDir, "Default_appearance_seed.json")
|
||||
if err := os.WriteFile(jsonPath, out, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,14 +29,14 @@ import (
|
|||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotd/log/logzap"
|
||||
"github.com/gotd/td/exchange"
|
||||
"github.com/gotd/td/mtproxy"
|
||||
"github.com/gotd/td/mtproxy/obfuscator"
|
||||
"github.com/gotd/td/proto/codec"
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/telegram/dcs"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/transport"
|
||||
"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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -50,15 +50,15 @@ import (
|
|||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotd/log/logzap"
|
||||
"github.com/gotd/td/exchange"
|
||||
"github.com/gotd/td/mtproxy"
|
||||
"github.com/gotd/td/mtproxy/obfuscator"
|
||||
"github.com/gotd/td/proto/codec"
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/telegram/dcs"
|
||||
"github.com/gotd/td/telegram/uploader"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/transport"
|
||||
"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/telegram/uploader"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/transport"
|
||||
|
||||
"telesrv/internal/mtprotoedge"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ import (
|
|||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/iamxvbaba/td/telegram"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -24,14 +24,14 @@ import (
|
|||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotd/log/logzap"
|
||||
"github.com/gotd/td/exchange"
|
||||
"github.com/gotd/td/mtproxy"
|
||||
"github.com/gotd/td/mtproxy/obfuscator"
|
||||
"github.com/gotd/td/proto/codec"
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/telegram/dcs"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/transport"
|
||||
"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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,21 +3,28 @@
|
|||
// 开源公开的 id/hash。
|
||||
//
|
||||
// 用法:
|
||||
// langpackfetch languages [pack] 列出某 pack(默认 android)的可用语言
|
||||
// langpackfetch <out_dir> <langCode> [pack...] 拉取语言包(默认 packs = android ios macos)
|
||||
//
|
||||
// langpackfetch languages [pack] 列出某 pack(默认 android)的可用语言
|
||||
// langpackfetch all <out_dir> [pack...] 拉取所有官方语言及 manifest
|
||||
// langpackfetch <out_dir> <langCode> [pack...] 拉取语言包(默认 packs = android ios macos)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/iamxvbaba/td/telegram"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -25,13 +32,46 @@ const (
|
|||
tdesktopAPIHash = "344583e45741c457fe1862106095a5eb"
|
||||
)
|
||||
|
||||
var (
|
||||
officialPacks = []string{"android", "android_x", "ios", "macos", "tdesktop", "weba", "webk"}
|
||||
packNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)
|
||||
langCodeRE = regexp.MustCompile(`^[a-z0-9]{1,16}(?:[-_][a-z0-9]{1,16})*$`)
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Schema int `json:"schema"`
|
||||
Packs []packManifest `json:"packs"`
|
||||
}
|
||||
|
||||
type packManifest struct {
|
||||
Name string `json:"name"`
|
||||
Languages []languageManifest `json:"languages"`
|
||||
}
|
||||
|
||||
type languageManifest struct {
|
||||
LangCode string `json:"lang_code"`
|
||||
Name string `json:"name"`
|
||||
NativeName string `json:"native_name"`
|
||||
BaseLangCode string `json:"base_lang_code,omitempty"`
|
||||
PluralCode string `json:"plural_code"`
|
||||
Official bool `json:"official"`
|
||||
RTL bool `json:"rtl,omitempty"`
|
||||
Beta bool `json:"beta,omitempty"`
|
||||
StringsCount int `json:"strings_count"`
|
||||
TranslatedCount int `json:"translated_count"`
|
||||
TranslationsURL string `json:"translations_url,omitempty"`
|
||||
Version int `json:"version"`
|
||||
File string `json:"file"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage:\n langpackfetch languages [pack]\n langpackfetch <out_dir> <langCode> [pack...]")
|
||||
fmt.Fprintln(os.Stderr, "usage:\n langpackfetch languages [pack]\n langpackfetch all <out_dir> [pack...]\n langpackfetch <out_dir> <langCode> [pack...]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
client := telegram.NewClient(tdesktopAPIID, tdesktopAPIHash, telegram.Options{})
|
||||
|
|
@ -54,6 +94,16 @@ func main() {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
if os.Args[1] == "all" {
|
||||
if len(os.Args) < 3 {
|
||||
return fmt.Errorf("all needs <out_dir>")
|
||||
}
|
||||
packs := os.Args[3:]
|
||||
if len(packs) == 0 {
|
||||
packs = officialPacks
|
||||
}
|
||||
return fetchAll(ctx, api, os.Args[2], packs)
|
||||
}
|
||||
|
||||
outRoot := os.Args[1]
|
||||
langCode := "en"
|
||||
|
|
@ -73,7 +123,7 @@ func main() {
|
|||
fmt.Fprintf(os.Stderr, "skip %s/%s: %v\n", pack, langCode, err)
|
||||
continue
|
||||
}
|
||||
if err := writePack(outRoot, pack, diff); err != nil {
|
||||
if _, err := writePack(outRoot, pack, diff); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -84,10 +134,88 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
func writePack(root, pack string, diff *tg.LangPackDifference) error {
|
||||
func fetchAll(ctx context.Context, api *tg.Client, root string, packs []string) error {
|
||||
seen := make(map[string]struct{}, len(packs))
|
||||
result := manifest{Schema: 1}
|
||||
for _, pack := range packs {
|
||||
pack = strings.ToLower(strings.TrimSpace(pack))
|
||||
if !packNameRE.MatchString(pack) {
|
||||
return fmt.Errorf("invalid lang pack %q", pack)
|
||||
}
|
||||
if _, ok := seen[pack]; ok {
|
||||
continue
|
||||
}
|
||||
seen[pack] = struct{}{}
|
||||
|
||||
languages, err := api.LangpackGetLanguages(ctx, pack)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getLanguages %q: %w", pack, err)
|
||||
}
|
||||
sort.Slice(languages, func(i, j int) bool { return languages[i].LangCode < languages[j].LangCode })
|
||||
pm := packManifest{Name: pack}
|
||||
for _, language := range languages {
|
||||
if !language.Official {
|
||||
continue
|
||||
}
|
||||
code := strings.ToLower(strings.TrimSpace(language.LangCode))
|
||||
if !langCodeRE.MatchString(code) {
|
||||
return fmt.Errorf("invalid language code %q returned for %q", language.LangCode, pack)
|
||||
}
|
||||
diff, err := api.LangpackGetLangPack(ctx, &tg.LangpackGetLangPackRequest{
|
||||
LangPack: pack,
|
||||
LangCode: code,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("getLangPack %s/%s: %w", pack, code, err)
|
||||
}
|
||||
written, err := writePack(root, pack, diff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, written.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pm.Languages = append(pm.Languages, languageManifest{
|
||||
LangCode: code,
|
||||
Name: language.Name,
|
||||
NativeName: language.NativeName,
|
||||
BaseLangCode: language.BaseLangCode,
|
||||
PluralCode: language.PluralCode,
|
||||
Official: language.Official,
|
||||
RTL: language.Rtl,
|
||||
Beta: language.Beta,
|
||||
StringsCount: language.StringsCount,
|
||||
TranslatedCount: language.TranslatedCount,
|
||||
TranslationsURL: language.TranslationsURL,
|
||||
Version: diff.Version,
|
||||
File: filepath.ToSlash(rel),
|
||||
SHA256: written.SHA256,
|
||||
})
|
||||
}
|
||||
fmt.Printf("pack %s complete: %d official languages\n", pack, len(pm.Languages))
|
||||
result.Packs = append(result.Packs, pm)
|
||||
}
|
||||
encoded, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(filepath.Join(root, "official-language-packs.json"), append(encoded, '\n'))
|
||||
}
|
||||
|
||||
type writtenPack struct {
|
||||
Path string
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
func writePack(root, pack string, diff *tg.LangPackDifference) (writtenPack, error) {
|
||||
pack = strings.ToLower(strings.TrimSpace(pack))
|
||||
if !packNameRE.MatchString(pack) {
|
||||
return writtenPack{}, fmt.Errorf("invalid lang pack %q", pack)
|
||||
}
|
||||
dir := filepath.Join(root, pack)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
return writtenPack{}, err
|
||||
}
|
||||
var b strings.Builder
|
||||
count := 0
|
||||
|
|
@ -115,13 +243,51 @@ func writePack(root, pack string, diff *tg.LangPackDifference) error {
|
|||
}
|
||||
langCode := diff.LangCode
|
||||
if langCode == "" {
|
||||
langCode = "unknown"
|
||||
return writtenPack{}, fmt.Errorf("empty language code returned for %q", pack)
|
||||
}
|
||||
langCode = strings.ToLower(langCode)
|
||||
if !langCodeRE.MatchString(langCode) {
|
||||
return writtenPack{}, fmt.Errorf("invalid language code %q returned for %q", diff.LangCode, pack)
|
||||
}
|
||||
name := fmt.Sprintf("%s_%s_v%d.strings", pack, langCode, diff.Version)
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
|
||||
data := []byte(b.String())
|
||||
if err := writeFileAtomic(path, data); err != nil {
|
||||
return writtenPack{}, err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
fmt.Printf("wrote %s (%d strings, version %d)\n", path, count, diff.Version)
|
||||
return writtenPack{Path: path, SHA256: hex.EncodeToString(sum[:])}, nil
|
||||
}
|
||||
|
||||
func writeFileAtomic(path string, data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("wrote %s (%d strings, version %d)\n", path, count, diff.Version)
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".langpack-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err := tmp.Chmod(0o644); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) {
|
||||
return fmt.Errorf("replace %q: %w (remove existing: %v)", path, err, removeErr)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
78
cmd/langpackfetch/main_test.go
Normal file
78
cmd/langpackfetch/main_test.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
)
|
||||
|
||||
func TestWritePack(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
diff := &tg.LangPackDifference{
|
||||
LangCode: "pt-br",
|
||||
Version: 42,
|
||||
Strings: []tg.LangPackStringClass{
|
||||
&tg.LangPackString{Key: "plain", Value: "value"},
|
||||
&tg.LangPackStringPluralized{Key: "items", OneValue: "one", OtherValue: "many"},
|
||||
&tg.LangPackStringDeleted{Key: "removed"},
|
||||
},
|
||||
}
|
||||
written, err := writePack(root, "tdesktop", diff)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPath := filepath.Join(root, "tdesktop", "tdesktop_pt-br_v42.strings")
|
||||
if written.Path != wantPath {
|
||||
t.Fatalf("path = %q, want %q", written.Path, wantPath)
|
||||
}
|
||||
data, err := os.ReadFile(wantPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, want := range []string{`"plain" = "value";`, `"items#one" = "one";`, `"items#other" = "many";`} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("output does not contain %q: %s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "removed") {
|
||||
t.Errorf("deleted key was emitted: %s", text)
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
if written.SHA256 != hex.EncodeToString(sum[:]) {
|
||||
t.Fatalf("sha256 = %q, want %x", written.SHA256, sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePackRejectsPathComponents(t *testing.T) {
|
||||
_, err := writePack(t.TempDir(), "../android", &tg.LangPackDifference{LangCode: "en", Version: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid pack error")
|
||||
}
|
||||
_, err = writePack(t.TempDir(), "android", &tg.LangPackDifference{LangCode: "../en", Version: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid language code error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileAtomicReplacesExisting(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||
if err := writeFileAtomic(path, []byte("first")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeFileAtomic(path, []byte("second")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "second" {
|
||||
t.Fatalf("content = %q, want second", data)
|
||||
}
|
||||
}
|
||||
49
cmd/otpwebhook-example/README.md
Normal file
49
cmd/otpwebhook-example/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# OTP Webhook example
|
||||
|
||||
This command implements the `telesrv` OTP Webhook v1 receiving side with only
|
||||
the Go standard library. It validates the signed request, rejects expired or
|
||||
invalid payloads, and deduplicates concurrent or repeated delivery IDs.
|
||||
|
||||
The default `exampleDelivery` function deliberately does **not** send a real
|
||||
email/SMS and does not print the code or recipient. For local debugging only,
|
||||
set `TELESRV_OTP_EXAMPLE_LOG_CODE=true` to print the received code while still
|
||||
redacting the recipient. Replace that one function with the API call for your
|
||||
email or SMS provider before real use.
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
$env:TELESRV_OTP_EXAMPLE_SECRET = 'replace-with-a-random-secret'
|
||||
$env:TELESRV_OTP_EXAMPLE_LOG_CODE = 'true' # local testing only
|
||||
go run ./cmd/otpwebhook-example
|
||||
```
|
||||
|
||||
The default endpoints are:
|
||||
|
||||
- `POST http://127.0.0.1:2800/v1/otp/deliveries`
|
||||
- `GET http://127.0.0.1:2800/healthz`
|
||||
|
||||
Then configure `telesrv` with the same secret:
|
||||
|
||||
```dotenv
|
||||
TELESRV_EMAIL_CODE_DELIVERY_PROVIDER=webhook
|
||||
TELESRV_PHONE_CODE_DELIVERY_PROVIDER=webhook
|
||||
TELESRV_OTP_WEBHOOK_URL=http://127.0.0.1:2800/v1/otp/deliveries
|
||||
TELESRV_OTP_WEBHOOK_SECRET=replace-with-a-random-secret
|
||||
```
|
||||
|
||||
The example accepts these optional settings:
|
||||
|
||||
```dotenv
|
||||
TELESRV_OTP_EXAMPLE_ADDR=127.0.0.1:2800
|
||||
TELESRV_OTP_EXAMPLE_MAX_SKEW=5m
|
||||
TELESRV_OTP_EXAMPLE_LOG_CODE=false
|
||||
```
|
||||
|
||||
The idempotency registry is intentionally in memory. A production receiver
|
||||
must put delivery IDs and the downstream provider message ID in durable shared
|
||||
storage before running more than one instance or surviving restarts. Pass the
|
||||
same delivery ID to a downstream provider when it supports idempotency. The
|
||||
example remembers both successful and failed outcomes until the code expires,
|
||||
because an apparent downstream failure may have happened after it sent the
|
||||
message.
|
||||
397
cmd/otpwebhook-example/main.go
Normal file
397
cmd/otpwebhook-example/main.go
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxRequestBody = 64 << 10
|
||||
|
||||
var errIdempotencyConflict = errors.New("idempotency key was already used with a different payload")
|
||||
|
||||
type config struct {
|
||||
address string
|
||||
secret string
|
||||
maxSkew time.Duration
|
||||
logCode bool
|
||||
}
|
||||
|
||||
type deliveryRequest struct {
|
||||
Version string `json:"version"`
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
Purpose string `json:"purpose"`
|
||||
Channel string `json:"channel"`
|
||||
Recipient string `json:"recipient"`
|
||||
Code string `json:"code"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
}
|
||||
|
||||
type deliveryResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
MessageID string `json:"message_id,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
Retryable *bool `json:"retryable,omitempty"`
|
||||
}
|
||||
|
||||
type deliveryFunc func(context.Context, deliveryRequest) (string, error)
|
||||
|
||||
type receipt struct {
|
||||
fingerprint [sha256.Size]byte
|
||||
expiresAt time.Time
|
||||
done chan struct{}
|
||||
messageID string
|
||||
err error
|
||||
completed bool
|
||||
}
|
||||
|
||||
type application struct {
|
||||
secret []byte
|
||||
maxSkew time.Duration
|
||||
now func() time.Time
|
||||
deliver deliveryFunc
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
receipts map[string]*receipt
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("OTP webhook example stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
app := newApplication(cfg.secret, cfg.maxSkew, time.Now, exampleDelivery(logger, cfg.logCode), logger)
|
||||
server := &http.Server{
|
||||
Addr: cfg.address,
|
||||
Handler: app.routes(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if cfg.secret == "" {
|
||||
logger.Warn("signature verification is disabled; set TELESRV_OTP_EXAMPLE_SECRET outside local development")
|
||||
}
|
||||
if cfg.logCode {
|
||||
logger.Warn("OTP code logging is enabled for local testing")
|
||||
}
|
||||
logger.Info("OTP webhook example listening", "address", cfg.address)
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-serverErr:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("shutdown HTTP server: %w", err)
|
||||
}
|
||||
err := <-serverErr
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() (config, error) {
|
||||
cfg := config{
|
||||
address: envOrDefault("TELESRV_OTP_EXAMPLE_ADDR", "127.0.0.1:2800"),
|
||||
secret: os.Getenv("TELESRV_OTP_EXAMPLE_SECRET"),
|
||||
maxSkew: 5 * time.Minute,
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("TELESRV_OTP_EXAMPLE_MAX_SKEW")); raw != "" {
|
||||
parsed, err := time.ParseDuration(raw)
|
||||
if err != nil || parsed <= 0 {
|
||||
return config{}, fmt.Errorf("TELESRV_OTP_EXAMPLE_MAX_SKEW must be a positive duration")
|
||||
}
|
||||
cfg.maxSkew = parsed
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("TELESRV_OTP_EXAMPLE_LOG_CODE")); raw != "" {
|
||||
parsed, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return config{}, fmt.Errorf("TELESRV_OTP_EXAMPLE_LOG_CODE must be a boolean")
|
||||
}
|
||||
cfg.logCode = parsed
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func envOrDefault(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func newApplication(
|
||||
secret string,
|
||||
maxSkew time.Duration,
|
||||
now func() time.Time,
|
||||
deliver deliveryFunc,
|
||||
logger *slog.Logger,
|
||||
) *application {
|
||||
return &application{
|
||||
secret: []byte(secret),
|
||||
maxSkew: maxSkew,
|
||||
now: now,
|
||||
deliver: deliver,
|
||||
logger: logger,
|
||||
receipts: make(map[string]*receipt),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *application) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, "ok\n")
|
||||
})
|
||||
mux.HandleFunc("POST /v1/otp/deliveries", a.handleDelivery)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (a *application) handleDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || mediaType != "application/json" {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "CONTENT_TYPE_INVALID", false)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRequestBody))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "REQUEST_TOO_LARGE", false)
|
||||
return
|
||||
}
|
||||
if err := a.verifySignature(r.Header, body); err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "SIGNATURE_INVALID", false)
|
||||
return
|
||||
}
|
||||
|
||||
var request deliveryRequest
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "JSON_INVALID", false)
|
||||
return
|
||||
}
|
||||
if err := ensureJSONEOF(decoder); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "JSON_INVALID", false)
|
||||
return
|
||||
}
|
||||
if err := validateRequest(request, r.Header.Get("Idempotency-Key"), a.now()); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "REQUEST_INVALID", false)
|
||||
return
|
||||
}
|
||||
|
||||
messageID, err := a.deliverOnce(r.Context(), request, body)
|
||||
if errors.Is(err, errIdempotencyConflict) {
|
||||
writeError(w, http.StatusConflict, "IDEMPOTENCY_CONFLICT", false)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn("OTP delivery failed", "delivery_id", request.DeliveryID)
|
||||
writeError(w, http.StatusBadGateway, "DELIVERY_FAILED", true)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, deliveryResponse{Accepted: true, MessageID: messageID})
|
||||
}
|
||||
|
||||
func (a *application) verifySignature(header http.Header, body []byte) error {
|
||||
if len(a.secret) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
timestamp := header.Get("X-Telesrv-Timestamp")
|
||||
unixSeconds, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New("invalid timestamp")
|
||||
}
|
||||
delta := a.now().Sub(time.Unix(unixSeconds, 0))
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta > a.maxSkew {
|
||||
return errors.New("timestamp outside allowed skew")
|
||||
}
|
||||
|
||||
provided := header.Get("X-Telesrv-Signature")
|
||||
expected := signatureFor(a.secret, timestamp, body)
|
||||
if !hmac.Equal([]byte(provided), []byte(expected)) {
|
||||
return errors.New("signature mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func signatureFor(secret []byte, timestamp string, body []byte) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = io.WriteString(mac, timestamp)
|
||||
_, _ = mac.Write([]byte{'.'})
|
||||
_, _ = mac.Write(body)
|
||||
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func validateRequest(request deliveryRequest, idempotencyKey string, now time.Time) error {
|
||||
if request.Version != "1" {
|
||||
return errors.New("unsupported version")
|
||||
}
|
||||
if request.DeliveryID == "" || len(request.DeliveryID) > 128 || request.DeliveryID != idempotencyKey {
|
||||
return errors.New("invalid delivery ID")
|
||||
}
|
||||
if len(request.Recipient) == 0 || len(request.Recipient) > 512 {
|
||||
return errors.New("invalid recipient")
|
||||
}
|
||||
if len(request.Code) == 0 || len(request.Code) > 32 {
|
||||
return errors.New("invalid code")
|
||||
}
|
||||
if len(request.Locale) > 64 || request.ExpiresIn < 0 || request.ExpiresAt.IsZero() || !request.ExpiresAt.After(now) {
|
||||
return errors.New("invalid expiry or locale")
|
||||
}
|
||||
|
||||
expectedChannel, ok := map[string]string{
|
||||
"login_email": "email",
|
||||
"login_email_setup": "email",
|
||||
"login_email_change": "email",
|
||||
"login_sms": "sms",
|
||||
"change_phone": "sms",
|
||||
}[request.Purpose]
|
||||
if !ok || request.Channel != expectedChannel {
|
||||
return errors.New("invalid purpose or channel")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureJSONEOF(decoder *json.Decoder) error {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("multiple JSON values")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *application) deliverOnce(
|
||||
ctx context.Context,
|
||||
request deliveryRequest,
|
||||
body []byte,
|
||||
) (string, error) {
|
||||
fingerprint := sha256.Sum256(body)
|
||||
now := a.now()
|
||||
|
||||
a.mu.Lock()
|
||||
for id, existing := range a.receipts {
|
||||
if existing.completed && !existing.expiresAt.After(now) {
|
||||
delete(a.receipts, id)
|
||||
}
|
||||
}
|
||||
if existing, ok := a.receipts[request.DeliveryID]; ok {
|
||||
if existing.fingerprint != fingerprint {
|
||||
a.mu.Unlock()
|
||||
return "", errIdempotencyConflict
|
||||
}
|
||||
done := existing.done
|
||||
a.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
a.mu.Lock()
|
||||
messageID, err := existing.messageID, existing.err
|
||||
a.mu.Unlock()
|
||||
return messageID, err
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
current := &receipt{
|
||||
fingerprint: fingerprint,
|
||||
expiresAt: request.ExpiresAt,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
a.receipts[request.DeliveryID] = current
|
||||
a.mu.Unlock()
|
||||
|
||||
messageID, err := a.deliver(ctx, request)
|
||||
|
||||
a.mu.Lock()
|
||||
current.messageID = messageID
|
||||
current.err = err
|
||||
current.completed = true
|
||||
close(current.done)
|
||||
a.mu.Unlock()
|
||||
return messageID, err
|
||||
}
|
||||
|
||||
// exampleDelivery is the extension point for an email/SMS provider. It does
|
||||
// not send a real message. Replace this function with a provider call before
|
||||
// real use. Code logging is an explicit local-debug option.
|
||||
func exampleDelivery(logger *slog.Logger, logCode bool) deliveryFunc {
|
||||
return func(_ context.Context, request deliveryRequest) (string, error) {
|
||||
recipientHash := sha256.Sum256([]byte(request.Recipient))
|
||||
messageHash := sha256.Sum256([]byte(request.DeliveryID))
|
||||
attributes := []any{
|
||||
"delivery_id", request.DeliveryID,
|
||||
"purpose", request.Purpose,
|
||||
"channel", request.Channel,
|
||||
"recipient_sha256", hex.EncodeToString(recipientHash[:6]),
|
||||
}
|
||||
if logCode {
|
||||
attributes = append(attributes, "code", request.Code)
|
||||
}
|
||||
logger.Info("OTP delivery accepted by example adapter", attributes...)
|
||||
return "example_" + hex.EncodeToString(messageHash[:8]), nil
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code string, retryable bool) {
|
||||
writeJSON(w, status, deliveryResponse{Accepted: false, ErrorCode: code, Retryable: &retryable})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, response deliveryResponse) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
164
cmd/otpwebhook-example/main_test.go
Normal file
164
cmd/otpwebhook-example/main_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDeliveryAcceptsSignedRequestAndDeduplicatesReplay(t *testing.T) {
|
||||
now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
|
||||
var calls atomic.Int32
|
||||
app := testApplication(now, func(_ context.Context, _ deliveryRequest) (string, error) {
|
||||
calls.Add(1)
|
||||
return "provider-message-1", nil
|
||||
})
|
||||
body := marshalRequest(t, validRequest(now))
|
||||
|
||||
for range 2 {
|
||||
response := performDelivery(t, app.routes(), body, "otp_test_1", "test-secret", now)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
var result deliveryResponse
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Accepted || result.MessageID != "provider-message-1" {
|
||||
t.Fatalf("unexpected response: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("deliver calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryRejectsIdempotencyConflict(t *testing.T) {
|
||||
now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
|
||||
app := testApplication(now, func(_ context.Context, _ deliveryRequest) (string, error) {
|
||||
return "provider-message-1", nil
|
||||
})
|
||||
first := validRequest(now)
|
||||
response := performDelivery(t, app.routes(), marshalRequest(t, first), first.DeliveryID, "test-secret", now)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("first status = %d", response.Code)
|
||||
}
|
||||
|
||||
second := first
|
||||
second.Code = "654321"
|
||||
response = performDelivery(t, app.routes(), marshalRequest(t, second), second.DeliveryID, "test-secret", now)
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("conflict status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryRejectsInvalidSignature(t *testing.T) {
|
||||
now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
|
||||
app := testApplication(now, func(_ context.Context, _ deliveryRequest) (string, error) {
|
||||
t.Fatal("deliver must not be called")
|
||||
return "", nil
|
||||
})
|
||||
request := validRequest(now)
|
||||
response := performDelivery(t, app.routes(), marshalRequest(t, request), request.DeliveryID, "wrong-secret", now)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryRejectsExpiredCode(t *testing.T) {
|
||||
now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
|
||||
app := testApplication(now, func(_ context.Context, _ deliveryRequest) (string, error) {
|
||||
t.Fatal("deliver must not be called")
|
||||
return "", nil
|
||||
})
|
||||
request := validRequest(now)
|
||||
request.ExpiresAt = now.Add(-time.Second)
|
||||
response := performDelivery(t, app.routes(), marshalRequest(t, request), request.DeliveryID, "test-secret", now)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryFailureIsAlsoDeduplicated(t *testing.T) {
|
||||
now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
|
||||
var calls atomic.Int32
|
||||
app := testApplication(now, func(_ context.Context, _ deliveryRequest) (string, error) {
|
||||
calls.Add(1)
|
||||
return "", errors.New("downstream outcome unknown")
|
||||
})
|
||||
request := validRequest(now)
|
||||
body := marshalRequest(t, request)
|
||||
|
||||
for range 2 {
|
||||
response := performDelivery(t, app.routes(), body, request.DeliveryID, "test-secret", now)
|
||||
if response.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("deliver calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testApplication(now time.Time, deliver deliveryFunc) *application {
|
||||
return newApplication(
|
||||
"test-secret",
|
||||
5*time.Minute,
|
||||
func() time.Time { return now },
|
||||
deliver,
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
)
|
||||
}
|
||||
|
||||
func validRequest(now time.Time) deliveryRequest {
|
||||
return deliveryRequest{
|
||||
Version: "1",
|
||||
DeliveryID: "otp_test_1",
|
||||
Purpose: "login_email",
|
||||
Channel: "email",
|
||||
Recipient: "alice@example.test",
|
||||
Code: "123456",
|
||||
ExpiresAt: now.Add(5 * time.Minute),
|
||||
ExpiresIn: 300,
|
||||
Locale: "zh-CN",
|
||||
}
|
||||
}
|
||||
|
||||
func marshalRequest(t *testing.T, request deliveryRequest) []byte {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func performDelivery(
|
||||
t *testing.T,
|
||||
handler http.Handler,
|
||||
body []byte,
|
||||
deliveryID string,
|
||||
secret string,
|
||||
now time.Time,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
timestampText := strconv.FormatInt(now.Unix(), 10)
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/otp/deliveries", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Idempotency-Key", deliveryID)
|
||||
request.Header.Set("X-Telesrv-Timestamp", timestampText)
|
||||
request.Header.Set("X-Telesrv-Signature", signatureFor([]byte(secret), timestampText, body))
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
|
@ -28,14 +28,14 @@ import (
|
|||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotd/log/logzap"
|
||||
"github.com/gotd/td/exchange"
|
||||
"github.com/gotd/td/mtproxy"
|
||||
"github.com/gotd/td/mtproxy/obfuscator"
|
||||
"github.com/gotd/td/proto/codec"
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/telegram/dcs"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/transport"
|
||||
"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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/telegram/downloader"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/iamxvbaba/td/telegram"
|
||||
"github.com/iamxvbaba/td/telegram/downloader"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
)
|
||||
|
||||
func TestSpecToInputSystemSets(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import (
|
|||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -63,6 +65,9 @@ type AccountDetail struct {
|
|||
|
||||
type RestrictionRow struct {
|
||||
Frozen bool
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
AppealURL string
|
||||
Reason string
|
||||
Actor string
|
||||
CommandID string
|
||||
|
|
@ -128,6 +133,60 @@ type ChannelDetail struct {
|
|||
AuditLogs []AuditLogRow
|
||||
}
|
||||
|
||||
type StarGiftRow struct {
|
||||
GiftID int64
|
||||
RevisionID int64
|
||||
Revision int
|
||||
Title string
|
||||
Stars int64
|
||||
ConvertStars int64
|
||||
Enabled bool
|
||||
SortOrder int
|
||||
DocumentID int64
|
||||
SourceName string
|
||||
SourceFormat string
|
||||
AnimationSHA string
|
||||
AnimationSize int64
|
||||
Width int
|
||||
Height int
|
||||
FrameRate float64
|
||||
ReceivedCount int64
|
||||
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()
|
||||
}
|
||||
|
||||
func (s *readStore) SearchAccounts(ctx context.Context, q string) ([]AccountRow, error) {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
|
|
@ -152,7 +211,7 @@ SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.upd
|
|||
COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(a.device_count, 0)::int,
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
|
||||
FROM users u
|
||||
LEFT JOIN account_send_restrictions r ON r.user_id = u.id
|
||||
LEFT JOIN account_restrictions r ON r.user_id = u.id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||
LEFT JOIN auth a ON a.user_id = u.id
|
||||
WHERE u.id = $1 OR u.phone = $2 OR u.phone = $3 OR lower(u.username) = $4 OR p.username_lower = $4
|
||||
|
|
@ -326,7 +385,7 @@ SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.upd
|
|||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
|
||||
FROM users u
|
||||
JOIN auth ON auth.user_id = u.id
|
||||
LEFT JOIN account_send_restrictions r ON r.user_id = u.id
|
||||
LEFT JOIN account_restrictions r ON r.user_id = u.id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||
WHERE NOT u.is_bot
|
||||
AND ($1::bigint = 0 OR (auth.last_active_at, u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
|
||||
|
|
@ -364,7 +423,7 @@ SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.upd
|
|||
COALESCE(sb.balance, 0)::bigint, COALESCE(sb.granted, false),
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
|
||||
FROM users u
|
||||
LEFT JOIN account_send_restrictions r ON r.user_id = u.id
|
||||
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
|
||||
WHERE u.id = $1`, userID).Scan(
|
||||
|
|
@ -393,9 +452,12 @@ WHERE u.id = $1`, userID).Scan(
|
|||
func (s *readStore) restriction(ctx context.Context, userID int64) (RestrictionRow, bool, error) {
|
||||
var r RestrictionRow
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT frozen, reason, actor, command_id, updated_at
|
||||
FROM account_send_restrictions
|
||||
WHERE user_id = $1`, userID).Scan(&r.Frozen, &r.Reason, &r.Actor, &r.CommandID, &r.UpdatedAt)
|
||||
SELECT frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
|
||||
FROM account_restrictions
|
||||
WHERE user_id = $1`, userID).Scan(
|
||||
&r.Frozen, &r.Since, &r.Until, &r.AppealURL,
|
||||
&r.Reason, &r.Actor, &r.CommandID, &r.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return RestrictionRow{}, false, nil
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
|
|
@ -56,7 +57,11 @@ 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("POST /api/actions/freeze-send", s.requireAuthAPI(http.HandlerFunc(s.handleFreezeSendAPI)))
|
||||
mux.Handle("GET /api/gifts", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftsAPI)))
|
||||
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("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)))
|
||||
|
|
@ -64,6 +69,10 @@ 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/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.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeAPIError(w, http.StatusNotFound, "api route not found")
|
||||
})
|
||||
|
|
@ -163,6 +172,101 @@ func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, http.StatusOK, map[string]any{"actor": actorFromContext(r.Context())})
|
||||
}
|
||||
|
||||
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) 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) 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) {
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, 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, maxBytes+1))
|
||||
if err != nil || int64(len(raw)) > maxBytes {
|
||||
writeAPIError(w, http.StatusBadGateway, "invalid admin api response")
|
||||
return
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
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=30")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *server) handleAccountsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
|
|
@ -388,25 +492,29 @@ func (s *server) handleGroupMessageDetailAPI(w http.ResponseWriter, r *http.Requ
|
|||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
type freezeSendAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Frozen bool `json:"frozen"`
|
||||
type setAccountFrozenAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Frozen bool `json:"frozen"`
|
||||
Until time.Time `json:"freeze_until"`
|
||||
AppealURL string `json:"freeze_appeal_url"`
|
||||
}
|
||||
|
||||
func (s *server) handleFreezeSendAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body freezeSendAPIRequest
|
||||
func (s *server) handleSetAccountFrozenAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setAccountFrozenAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetSendFrozenRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "freeze-send"),
|
||||
req := admin.SetAccountFrozenRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-frozen"),
|
||||
UserID: body.UserID,
|
||||
Frozen: body.Frozen,
|
||||
Until: body.Until,
|
||||
AppealURL: body.AppealURL,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/freeze-send", req)
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-frozen", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
|
|
@ -584,6 +692,184 @@ 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"`
|
||||
Title string `json:"title"`
|
||||
Stars int64 `json:"stars"`
|
||||
ConvertStars int64 `json:"convert_stars"`
|
||||
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 publishStarGiftCollectiblesAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UpgradeStars int64 `json:"upgrade_stars"`
|
||||
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"`
|
||||
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"`
|
||||
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)
|
||||
}
|
||||
|
||||
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-") {
|
||||
|
|
@ -635,6 +921,107 @@ func (s *server) callAdminAPI(ctx context.Context, apiPath string, payload any)
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func (s *server) callAdminMultipart(ctx context.Context, apiPath string, metadata any, fileName string, data []byte) (admin.CommandResult, error) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
meta, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
if err := writer.WriteField("metadata", string(meta)); err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
part, err := writer.CreateFormFile("file", fileName)
|
||||
if err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
if _, err := part.Write(data); 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 (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())
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
)
|
||||
|
||||
func TestSignedSessionRoundTripAndTamper(t *testing.T) {
|
||||
|
|
@ -48,3 +52,33 @@ func TestAdminAPIURLDefaultUsesAdminAPIPort(t *testing.T) {
|
|||
t.Fatalf("adminAPIURL(empty) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAccountFrozenBFFForwardsClientVisibleState(t *testing.T) {
|
||||
var got admin.SetAccountFrozenRequest
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/accounts/set-frozen" || 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-frozen", strings.NewReader(`{
|
||||
"reason":"review","confirm":false,"user_id":1001,"frozen":true,
|
||||
"freeze_until":"2030-01-02T00:00:00Z","freeze_appeal_url":"https://appeals.example.test/1001"
|
||||
}`))
|
||||
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleSetAccountFrozenAPI(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.Frozen || !got.DryRun ||
|
||||
got.Until.IsZero() || got.AppealURL != "https://appeals.example.test/1001" {
|
||||
t.Fatalf("forwarded freeze request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
1
cmd/telesrv-admin/web/dist/assets/index-BaxMq_AT.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-BaxMq_AT.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-Q8RNNOYL.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-Q8RNNOYL.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
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>telesrv admin</title>
|
||||
<script type="module" crossorigin src="/assets/index-BHnkZ_za.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-W1-UPxwc.css">
|
||||
<script type="module" crossorigin src="/assets/index-Q8RNNOYL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BaxMq_AT.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
7
cmd/telesrv-admin/web/package-lock.json
generated
7
cmd/telesrv-admin/web/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "telesrv-admin-ui",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"lottie-web": "^5.13.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
|
@ -741,6 +742,12 @@
|
|||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lottie-web": {
|
||||
"version": "5.13.0",
|
||||
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
|
||||
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.468.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lottie-web": "^5.13.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import type {
|
|||
GroupMessageDetail,
|
||||
GroupMessageListResponse,
|
||||
MessageDetail,
|
||||
MessageListResponse
|
||||
MessageListResponse,
|
||||
StarGiftCollectiblePreview,
|
||||
StarGiftListResponse
|
||||
} from "./types";
|
||||
|
||||
export class APIError extends Error {
|
||||
|
|
@ -20,12 +22,10 @@ export class APIError extends Error {
|
|||
}
|
||||
|
||||
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const isForm = typeof FormData !== "undefined" && init.body instanceof FormData;
|
||||
const response = await fetch(url, {
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers ?? {})
|
||||
},
|
||||
headers: isForm ? init.headers : { "Content-Type": "application/json", ...(init.headers ?? {}) },
|
||||
...init
|
||||
});
|
||||
const text = await response.text();
|
||||
|
|
@ -65,6 +65,12 @@ export const api = {
|
|||
const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) });
|
||||
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
|
||||
},
|
||||
gifts: () => request<StarGiftListResponse>("/api/gifts"),
|
||||
giftAnimation: (id: number) => request<Record<string, unknown>>(`/api/gifts/${id}/animation`),
|
||||
giftCollectibles: (id: number) => request<StarGiftCollectiblePreview>(`/api/gifts/${id}/collectibles`),
|
||||
giftCollectibleAnimation: (giftID: number, kind: "model" | "pattern", attributeID: number) => request<Record<string, unknown>>(`/api/gifts/${giftID}/collectibles/${kind}/${attributeID}/animation`),
|
||||
importGift: (form: FormData) => request<CommandResult>("/api/actions/import-gift", { method: "POST", body: form }),
|
||||
publishGiftCollectibles: (giftID: number, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${giftID}`, { method: "POST", body: form }),
|
||||
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import {
|
|||
Server,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Users
|
||||
Users,
|
||||
Gift
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api } from "../api";
|
||||
|
|
@ -74,6 +75,7 @@ export function Shell({
|
|||
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{t("layout.dashboard")}</NavLink>
|
||||
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{t("layout.accounts")}</NavLink>
|
||||
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
|
||||
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink>
|
||||
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
|
||||
<button
|
||||
className="nav-section-toggle"
|
||||
|
|
|
|||
|
|
@ -61,12 +61,15 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"route.dashboardSubtitle": "Console / Overview",
|
||||
"route.messages": "Message Audit",
|
||||
"route.messagesSubtitle": "Console / Messages",
|
||||
"route.gifts": "Star Gifts",
|
||||
"route.giftsSubtitle": "Console / Star Gifts",
|
||||
"layout.navigation": "Navigation",
|
||||
"layout.primaryNav": "Primary navigation",
|
||||
"layout.dashboard": "Overview",
|
||||
"layout.accounts": "Accounts",
|
||||
"layout.channels": "Supergroups / Channels",
|
||||
"layout.messages": "Messages",
|
||||
"layout.gifts": "Star Gifts",
|
||||
"layout.privateMessages": "Private",
|
||||
"layout.groupMessages": "Groups",
|
||||
"layout.runtime": "Runtime",
|
||||
|
|
@ -127,15 +130,21 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"account.waitingData": "Waiting for data",
|
||||
"account.noUsername": "No username",
|
||||
"account.noPhone": "No phone",
|
||||
"account.sendFrozen": "Sending frozen",
|
||||
"account.sendNormal": "Sending allowed",
|
||||
"account.accountFrozen": "Account frozen",
|
||||
"account.accountActive": "Account active",
|
||||
"account.authorizationsTitle": "Authorized Devices",
|
||||
"account.authorizationsCount": "{count} authorizations",
|
||||
"account.recentAdminOps": "Recent Admin Actions",
|
||||
"account.recent30Audit": "Last 30 audit rows",
|
||||
"account.actionDock": "Account Actions",
|
||||
"account.freezeSend": "Freeze sending",
|
||||
"account.unfreezeSend": "Unfreeze sending",
|
||||
"account.freezeAccount": "Freeze account",
|
||||
"account.updateFreeze": "Update freeze",
|
||||
"account.unfreezeAccount": "Unfreeze account",
|
||||
"account.freezeSince": "Frozen since",
|
||||
"account.freezeUntil": "Appeal deadline",
|
||||
"account.freezeUntilAria": "Freeze appeal deadline",
|
||||
"account.freezeAppealURL": "Appeal URL",
|
||||
"account.freezeAppealURLAria": "Freeze appeal URL",
|
||||
"account.premiumMonths": "Premium duration (months)",
|
||||
"account.premiumMonthsAria": "Set premium duration in months",
|
||||
"account.setPremium": "Set premium",
|
||||
|
|
@ -235,6 +244,81 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"messages.channelGroup": "Channel / Group",
|
||||
"messages.pinned": "Pinned",
|
||||
"messages.channelPost": "Channel post",
|
||||
"gifts.pageTitle": "Star Gift Catalog",
|
||||
"gifts.eyebrow": "Catalog, immutable revisions and animation assets",
|
||||
"gifts.total": "Catalog entries",
|
||||
"gifts.enabled": "Enabled",
|
||||
"gifts.received": "Received gifts",
|
||||
"gifts.formats": "Accepted formats",
|
||||
"gifts.add": "Add gift",
|
||||
"gifts.searchPlaceholder": "Search gift ID, title or format",
|
||||
"gifts.listSummary": "Showing {shown} of {total}",
|
||||
"gifts.idRevision": "ID / Revision",
|
||||
"gifts.price": "Price / Conversion",
|
||||
"gifts.importTitle": "Import a Star Gift",
|
||||
"gifts.importEyebrow": "Gift catalog operation",
|
||||
"gifts.newRevision": "Create revision for gift #{id}",
|
||||
"gifts.importHint": "Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS.",
|
||||
"gifts.animation": "Animation file",
|
||||
"gifts.filePrompt": "Drop or choose a TGS / Lottie file",
|
||||
"gifts.fileHint": "TGS, JSON or Lottie · validated before import",
|
||||
"gifts.chooseFile": "Choose file",
|
||||
"gifts.changeFile": "Change file",
|
||||
"gifts.title": "Display title",
|
||||
"gifts.titlePlaceholder": "e.g. Celebration Star",
|
||||
"gifts.stars": "Price in Stars",
|
||||
"gifts.convertStars": "Conversion Stars",
|
||||
"gifts.sortOrder": "Sort order",
|
||||
"gifts.reason": "Audit reason",
|
||||
"gifts.reasonPlaceholder": "Briefly describe why this gift is being imported",
|
||||
"gifts.enableAfterImport": "Enable after import",
|
||||
"gifts.validate": "Dry-run validation",
|
||||
"gifts.confirmImport": "Confirm import",
|
||||
"gifts.stepDetails": "File and details",
|
||||
"gifts.stepValidate": "Dry-run validation",
|
||||
"gifts.stepImport": "Confirm import",
|
||||
"gifts.fileRequired": "Choose a TGS or Lottie file first",
|
||||
"gifts.source": "Source",
|
||||
"gifts.replace": "New revision",
|
||||
"gifts.disable": "Disable",
|
||||
"gifts.enable": "Enable",
|
||||
"gifts.empty": "No Star Gifts have been imported.",
|
||||
"gifts.emptyHint": "Import the first animation above to build the gift catalog.",
|
||||
"gifts.validationReady": "Validation passed",
|
||||
"gifts.validationHint": "Review the normalized metadata, then confirm the import.",
|
||||
"gifts.confirmState": "Apply the validated state change to gift #{id}?",
|
||||
"collectibles.manage": "Attribute pool",
|
||||
"collectibles.title": "Collectible pool · Gift #{id}",
|
||||
"collectibles.eyebrow": "Unique gift attributes",
|
||||
"collectibles.activeRevision": "Published revision {revision}",
|
||||
"collectibles.published": "Published",
|
||||
"collectibles.noPool": "No collectible pool published",
|
||||
"collectibles.noPoolHint": "Publish models, patterns and backdrops to enable upgrades.",
|
||||
"collectibles.publishNew": "Publish a new immutable revision",
|
||||
"collectibles.immutableHint": "Dry-run checks every file and rarity total before the revision becomes active.",
|
||||
"collectibles.upgradeStars": "Upgrade price in Stars",
|
||||
"collectibles.supply": "Unique supply",
|
||||
"collectibles.slug": "Public slug prefix",
|
||||
"collectibles.models": "Models",
|
||||
"collectibles.patterns": "Patterns",
|
||||
"collectibles.backdrops": "Backdrops",
|
||||
"collectibles.model": "Model",
|
||||
"collectibles.pattern": "Pattern",
|
||||
"collectibles.backdrop": "Backdrop",
|
||||
"collectibles.rarity": "Rarity ‰",
|
||||
"collectibles.rarityHint": "Every section must total exactly 1000‰.",
|
||||
"collectibles.colorHint": "Colors are stored as Telegram 24-bit RGB values.",
|
||||
"collectibles.addAttribute": "Add",
|
||||
"collectibles.remove": "Remove attribute",
|
||||
"collectibles.fileRequired": "Every model and pattern needs a TGS or Lottie file.",
|
||||
"collectibles.backdropID": "Backdrop ID",
|
||||
"collectibles.color.center": "Center",
|
||||
"collectibles.color.edge": "Edge",
|
||||
"collectibles.color.pattern": "Pattern",
|
||||
"collectibles.color.text": "Text",
|
||||
"collectibles.validationReady": "Attribute pool is valid",
|
||||
"collectibles.validationHint": "Review the normalized assets, then publish this immutable revision.",
|
||||
"collectibles.publish": "Publish revision",
|
||||
"messages.msgIDsInvalid": "Message IDs are invalid",
|
||||
"auth.device": "Device",
|
||||
"auth.platform": "Platform",
|
||||
|
|
@ -326,12 +410,15 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"route.dashboardSubtitle": "控制台 / 总览",
|
||||
"route.messages": "消息审计",
|
||||
"route.messagesSubtitle": "控制台 / 消息",
|
||||
"route.gifts": "星星礼物",
|
||||
"route.giftsSubtitle": "控制台 / 星星礼物",
|
||||
"layout.navigation": "导航",
|
||||
"layout.primaryNav": "主导航",
|
||||
"layout.dashboard": "总览",
|
||||
"layout.accounts": "账号",
|
||||
"layout.channels": "超级群/频道",
|
||||
"layout.messages": "消息",
|
||||
"layout.gifts": "礼物目录",
|
||||
"layout.privateMessages": "私聊",
|
||||
"layout.groupMessages": "群聊",
|
||||
"layout.runtime": "运行状态",
|
||||
|
|
@ -392,15 +479,21 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"account.waitingData": "等待数据",
|
||||
"account.noUsername": "无用户名",
|
||||
"account.noPhone": "无手机号",
|
||||
"account.sendFrozen": "发消息冻结",
|
||||
"account.sendNormal": "发送正常",
|
||||
"account.accountFrozen": "账号已冻结",
|
||||
"account.accountActive": "账号正常",
|
||||
"account.authorizationsTitle": "授权设备",
|
||||
"account.authorizationsCount": "共 {count} 个授权",
|
||||
"account.recentAdminOps": "最近后台操作",
|
||||
"account.recent30Audit": "最近 30 条审计",
|
||||
"account.actionDock": "账号操作",
|
||||
"account.freezeSend": "冻结发消息",
|
||||
"account.unfreezeSend": "解冻发消息",
|
||||
"account.freezeAccount": "冻结账号",
|
||||
"account.updateFreeze": "更新冻结信息",
|
||||
"account.unfreezeAccount": "解冻账号",
|
||||
"account.freezeSince": "冻结开始时间",
|
||||
"account.freezeUntil": "申诉截止时间",
|
||||
"account.freezeUntilAria": "账号冻结申诉截止时间",
|
||||
"account.freezeAppealURL": "申诉链接",
|
||||
"account.freezeAppealURLAria": "账号冻结申诉链接",
|
||||
"account.premiumMonths": "会员时长(月)",
|
||||
"account.premiumMonthsAria": "设置会员时长,单位月",
|
||||
"account.setPremium": "设置会员",
|
||||
|
|
@ -500,6 +593,81 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"messages.channelGroup": "频道 / 群",
|
||||
"messages.pinned": "置顶",
|
||||
"messages.channelPost": "频道帖子",
|
||||
"gifts.pageTitle": "星星礼物目录",
|
||||
"gifts.eyebrow": "目录、不可变版本与动画资源",
|
||||
"gifts.total": "目录条目",
|
||||
"gifts.enabled": "已启用",
|
||||
"gifts.received": "已领取礼物",
|
||||
"gifts.formats": "支持格式",
|
||||
"gifts.add": "添加礼物",
|
||||
"gifts.searchPlaceholder": "搜索礼物 ID、标题或格式",
|
||||
"gifts.listSummary": "显示 {shown} / {total} 项",
|
||||
"gifts.idRevision": "ID / 版本",
|
||||
"gifts.price": "售价 / 兑换",
|
||||
"gifts.importTitle": "导入星星礼物",
|
||||
"gifts.importEyebrow": "礼物目录操作",
|
||||
"gifts.newRevision": "为礼物 #{id} 创建新版本",
|
||||
"gifts.importHint": "支持 TGS 或纯 Lottie JSON;Lottie 会规范化并压缩成 TGS。",
|
||||
"gifts.animation": "动画文件",
|
||||
"gifts.filePrompt": "拖放或选择 TGS / Lottie 文件",
|
||||
"gifts.fileHint": "支持 TGS、JSON、Lottie,导入前会先进行校验",
|
||||
"gifts.chooseFile": "选择文件",
|
||||
"gifts.changeFile": "更换文件",
|
||||
"gifts.title": "显示标题",
|
||||
"gifts.titlePlaceholder": "例如:庆典星星",
|
||||
"gifts.stars": "售价 Stars",
|
||||
"gifts.convertStars": "可兑换 Stars",
|
||||
"gifts.sortOrder": "排序值",
|
||||
"gifts.reason": "审计原因",
|
||||
"gifts.reasonPlaceholder": "简要说明本次导入礼物的原因",
|
||||
"gifts.enableAfterImport": "导入后启用",
|
||||
"gifts.validate": "Dry-run 校验",
|
||||
"gifts.confirmImport": "确认导入",
|
||||
"gifts.stepDetails": "文件与信息",
|
||||
"gifts.stepValidate": "Dry-run 校验",
|
||||
"gifts.stepImport": "确认导入",
|
||||
"gifts.fileRequired": "请先选择 TGS 或 Lottie 文件",
|
||||
"gifts.source": "来源",
|
||||
"gifts.replace": "创建新版本",
|
||||
"gifts.disable": "停用",
|
||||
"gifts.enable": "启用",
|
||||
"gifts.empty": "尚未导入星星礼物。",
|
||||
"gifts.emptyHint": "从上方导入第一个动画,开始搭建礼物目录。",
|
||||
"gifts.validationReady": "校验已通过",
|
||||
"gifts.validationHint": "确认规范化后的元数据无误,再执行正式导入。",
|
||||
"gifts.confirmState": "确认执行礼物 #{id} 的状态变更吗?",
|
||||
"collectibles.manage": "属性池",
|
||||
"collectibles.title": "Collectibles 属性池 · 礼物 #{id}",
|
||||
"collectibles.eyebrow": "唯一礼物属性管理",
|
||||
"collectibles.activeRevision": "已发布版本 {revision}",
|
||||
"collectibles.published": "已发布",
|
||||
"collectibles.noPool": "尚未发布 Collectibles 属性池",
|
||||
"collectibles.noPoolHint": "发布模型、图案与背景后,客户端即可升级为唯一礼物。",
|
||||
"collectibles.publishNew": "发布新的不可变版本",
|
||||
"collectibles.immutableHint": "Dry-run 会校验全部文件和稀有度总和,通过后才切换为当前版本。",
|
||||
"collectibles.upgradeStars": "升级价格 Stars",
|
||||
"collectibles.supply": "唯一礼物总量",
|
||||
"collectibles.slug": "公开 Slug 前缀",
|
||||
"collectibles.models": "模型",
|
||||
"collectibles.patterns": "图案",
|
||||
"collectibles.backdrops": "背景",
|
||||
"collectibles.model": "模型",
|
||||
"collectibles.pattern": "图案",
|
||||
"collectibles.backdrop": "背景",
|
||||
"collectibles.rarity": "稀有度 ‰",
|
||||
"collectibles.rarityHint": "每一类的稀有度总和必须正好为 1000‰。",
|
||||
"collectibles.colorHint": "颜色会按 Telegram 24 位 RGB 数值保存。",
|
||||
"collectibles.addAttribute": "添加",
|
||||
"collectibles.remove": "删除属性",
|
||||
"collectibles.fileRequired": "每个模型和图案都必须选择 TGS 或 Lottie 文件。",
|
||||
"collectibles.backdropID": "背景 ID",
|
||||
"collectibles.color.center": "中心色",
|
||||
"collectibles.color.edge": "边缘色",
|
||||
"collectibles.color.pattern": "图案色",
|
||||
"collectibles.color.text": "文字色",
|
||||
"collectibles.validationReady": "属性池校验通过",
|
||||
"collectibles.validationHint": "确认规范化资源无误后,即可发布这个不可变版本。",
|
||||
"collectibles.publish": "发布版本",
|
||||
"messages.msgIDsInvalid": "消息 ID 无效",
|
||||
"auth.device": "设备",
|
||||
"auth.platform": "平台",
|
||||
|
|
|
|||
4
cmd/telesrv-admin/web/src/lottie-web.d.ts
vendored
Normal file
4
cmd/telesrv-admin/web/src/lottie-web.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module "lottie-web/build/player/lottie_light_canvas" {
|
||||
import lottie from "lottie-web";
|
||||
export default lottie;
|
||||
}
|
||||
|
|
@ -16,12 +16,21 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
const [busy, setBusy] = useState(false);
|
||||
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("");
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.account(id));
|
||||
const next = await api.account(id);
|
||||
setDetail(next);
|
||||
if (next.Restriction.Frozen) {
|
||||
if (next.Restriction.Until) {
|
||||
setFreezeUntil(toDateTimeLocal(new Date(next.Restriction.Until)));
|
||||
}
|
||||
setFreezeAppealURL(next.Restriction.AppealURL || "");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
|
|
@ -58,7 +67,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
<div className="entity-badges">
|
||||
{account.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")}</Badge> : <Badge>{t("account.notPremium")}</Badge>}
|
||||
{detail.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
|
||||
{account.Frozen ? <Badge tone="danger">{t("account.sendFrozen")}</Badge> : <Badge>{t("account.sendNormal")}</Badge>}
|
||||
{account.Frozen ? <Badge tone="danger">{t("account.accountFrozen")}</Badge> : <Badge>{t("account.accountActive")}</Badge>}
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
|
|
@ -70,6 +79,9 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
<Summary label={t("account.activeSessions")} value={String(detail.Authorizations.length)} />
|
||||
<Summary label={t("account.accountFlags")} value={`support=${detail.Support} bot=${detail.Bot}`} />
|
||||
<Summary label={t("account.restriction")} value={detail.HasRestriction ? detail.Restriction.Reason || t("account.restricted") : t("common.none")} />
|
||||
<Summary label={t("account.freezeSince")} value={detail.Restriction.Since ? formatDate(detail.Restriction.Since) : t("common.none")} />
|
||||
<Summary label={t("account.freezeUntil")} value={detail.Restriction.Until ? formatDate(detail.Restriction.Until) : t("common.none")} />
|
||||
<Summary label={t("account.freezeAppealURL")} value={detail.Restriction.AppealURL || t("common.none")} />
|
||||
<Summary label={t("account.createdAt")} value={formatDate(account.CreatedAt) || "-"} />
|
||||
</div>
|
||||
{detail.About && <p className="about-text">{detail.About}</p>}
|
||||
|
|
@ -86,13 +98,46 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{t("account.actionDock")}</div>
|
||||
<label className="duration-field">
|
||||
<span>{t("account.freezeUntil")}</span>
|
||||
<input
|
||||
aria-label={t("account.freezeUntilAria")}
|
||||
value={freezeUntil}
|
||||
onChange={(event) => setFreezeUntil(event.target.value)}
|
||||
type="datetime-local"
|
||||
/>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("account.freezeAppealURL")}</span>
|
||||
<input
|
||||
aria-label={t("account.freezeAppealURLAria")}
|
||||
value={freezeAppealURL}
|
||||
onChange={(event) => setFreezeAppealURL(event.target.value)}
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</label>
|
||||
<ActionButton
|
||||
label={account.Frozen ? t("account.unfreezeSend") : t("account.freezeSend")}
|
||||
label={account.Frozen ? t("account.updateFreeze") : t("account.freezeAccount")}
|
||||
icon={<CircleAlert size={15} />}
|
||||
path="/api/actions/freeze-send"
|
||||
payload={() => ({ user_id: account.ID, frozen: !account.Frozen })}
|
||||
path="/api/actions/set-frozen"
|
||||
payload={() => ({
|
||||
user_id: account.ID,
|
||||
frozen: true,
|
||||
freeze_until: new Date(freezeUntil).toISOString(),
|
||||
freeze_appeal_url: freezeAppealURL.trim()
|
||||
})}
|
||||
onDone={load}
|
||||
/>
|
||||
{account.Frozen && (
|
||||
<ActionButton
|
||||
label={t("account.unfreezeAccount")}
|
||||
icon={<CircleAlert size={15} />}
|
||||
path="/api/actions/set-frozen"
|
||||
payload={() => ({ user_id: account.ID, frozen: false })}
|
||||
onDone={load}
|
||||
/>
|
||||
)}
|
||||
<label className="duration-field">
|
||||
<span>{t("account.premiumMonths")}</span>
|
||||
<input
|
||||
|
|
@ -155,3 +200,8 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function toDateTimeLocal(date: Date): string {
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
|
|
|||
236
cmd/telesrv-admin/web/src/pages/GiftCollectiblesModal.tsx
Normal file
236
cmd/telesrv-admin/web/src/pages/GiftCollectiblesModal.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
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 { useI18n } from "../i18n";
|
||||
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 newAnimated = (kind: string): AnimatedDraft => ({ key: nextKey(kind), name: "", rarity: "1000", sortOrder: "0", file: null, animation: null, fileError: "" });
|
||||
const newBackdrop = (): BackdropDraft => ({ key: nextKey("backdrop"), name: "", backdropID: "1", rarity: "1000", sortOrder: "0", center: "#6f5bea", edge: "#34278f", pattern: "#a89df5", text: "#ffffff" });
|
||||
|
||||
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: number; 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);
|
||||
|
||||
export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) {
|
||||
const { t } = useI18n();
|
||||
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[]>([newAnimated("model")]);
|
||||
const [patterns, setPatterns] = useState<AnimatedDraft[]>([newAnimated("pattern")]);
|
||||
const [backdrops, setBackdrops] = useState<BackdropDraft[]>([newBackdrop()]);
|
||||
|
||||
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(t("action.reasonRequired"));
|
||||
for (const row of [...models, ...patterns]) if (!row.file) throw new Error(t("collectibles.fileRequired"));
|
||||
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: Number(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>{t(`collectibles.${kind}`)}</strong><span>{t("collectibles.rarityHint")}</span></div>
|
||||
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] === 1000 ? "good" : "neutral"}>{rarityTotals[kind]} / 1000</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows([...rows, newAnimated(kind === "models" ? "model" : "pattern")]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div>
|
||||
</div>
|
||||
<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>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => updateAnimated(kind, row.key, { name: e.target.value })} /></label>
|
||||
<label><span>{t("collectibles.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>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => updateAnimated(kind, row.key, { sortOrder: e.target.value })} /></label>
|
||||
<label className="collectible-file"><span>{t("gifts.animation")}</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 ?? t("gifts.chooseFile")}</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 === 1} onClick={() => { setRows(rows.filter((value) => value.key !== row.key)); invalidate(); }} aria-label={t("collectibles.remove")}><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={t("collectibles.title", { id: gift.GiftID })}>
|
||||
<div className="modal-head">
|
||||
<div><div className="eyebrow">{t("collectibles.eyebrow")}</div><h2>{t("collectibles.title", { id: gift.GiftID })}</h2><p>{gift.Title || `Gift #${gift.GiftID}`}</p></div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body collectible-modal-body">
|
||||
{loading ? <div className="collectible-loading"><Loader2 className="spin" />{t("common.loading")}</div> : active?.found ? <section className="collectible-active">
|
||||
<div className="collectible-active-head"><div><Gem size={18} /><div><strong>{t("collectibles.activeRevision", { revision: active.revision ?? 0 })}</strong><span>{active.slug_prefix} · ⭐ {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{t("collectibles.published")}</Badge></div>
|
||||
<div className="collectible-active-grid">
|
||||
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}</strong><span>{t(`collectibles.${attribute.kind}`)} · {attribute.rarity_permille}‰</span></div></article>)}
|
||||
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {attribute.rarity_permille}‰</span></div></article>)}
|
||||
</div>
|
||||
</section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{t("collectibles.noPool")}</strong><span>{t("collectibles.noPoolHint")}</span></div></div>}
|
||||
|
||||
<section className="collectible-definition">
|
||||
<div className="collectible-definition-head"><div><strong>{t("collectibles.publishNew")}</strong><span>{t("collectibles.immutableHint")}</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>{t("collectibles.upgradeStars")}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); invalidate(); }} /></label>
|
||||
<label><span>{t("collectibles.supply")}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); invalidate(); }} /></label>
|
||||
<label><span>{t("collectibles.slug")}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); invalidate(); }} /></label>
|
||||
<label><span>{t("gifts.reason")}</span><input value={reason} maxLength={1000} placeholder={t("gifts.reasonPlaceholder")} 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>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops === 1000 ? "good" : "neutral"}>{rarityTotals.backdrops} / 1000</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops([...backdrops, newBackdrop()]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div>
|
||||
<div className="collectible-rows">{backdrops.map((row, index) => <div className="collectible-row backdrop" key={row.key}>
|
||||
<div className="collectible-row-index">{index + 1}</div>
|
||||
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{t("collectibles.backdropID")}</span><input type="number" min="1" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label>
|
||||
{(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{t(`collectibles.color.${field}`)}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)}
|
||||
<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 === 1} onClick={() => { setBackdrops(backdrops.filter((value) => value.key !== row.key)); invalidate(); }} aria-label={t("collectibles.remove")}><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>{t("collectibles.validationReady")}</strong><span>{t("collectibles.validationHint")}</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}>{t("common.close")}</button>
|
||||
<button className="btn" type="button" onClick={validate} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{t("gifts.validate")}</button>
|
||||
<button className="btn primary" type="button" onClick={publish} disabled={busy || !preview}><Upload size={15} />{t("collectibles.publish")}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>, document.body);
|
||||
}
|
||||
237
cmd/telesrv-admin/web/src/pages/GiftsPage.tsx
Normal file
237
cmd/telesrv-admin/web/src/pages/GiftsPage.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { CheckCircle2, 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, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { formatDate } from "../lib/format";
|
||||
import type { CommandResult, StarGiftRow } from "../types";
|
||||
import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function LottiePreview({ giftID, revision, compact = false }: { giftID: number; 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function GiftsPage() {
|
||||
const { t } = useI18n();
|
||||
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 [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("");
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
setGifts((await api.gifts()).Gifts ?? []);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
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]);
|
||||
|
||||
function uploadForm(confirm: boolean, commandID = "") {
|
||||
if (!file) throw new Error(t("gifts.fileRequired"));
|
||||
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
|
||||
const form = new FormData();
|
||||
form.set("metadata", JSON.stringify({
|
||||
command_id: commandID,
|
||||
reason: reason.trim(),
|
||||
confirm,
|
||||
gift_id: giftID,
|
||||
title: title.trim(),
|
||||
stars: Number(stars),
|
||||
convert_stars: Number(convertStars),
|
||||
enabled,
|
||||
sort_order: Number(sortOrder)
|
||||
}));
|
||||
form.set("file", file, file.name);
|
||||
return form;
|
||||
}
|
||||
|
||||
async function validateImport() {
|
||||
setBusy(true); setImportError(""); setPreview(null);
|
||||
try {
|
||||
setPreview(await api.importGift(uploadForm(false)));
|
||||
} catch (err) {
|
||||
setImportError(errorMessage(err));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function confirmImport() {
|
||||
if (!preview) return;
|
||||
setBusy(true); setImportError("");
|
||||
try {
|
||||
await api.importGift(uploadForm(true, preview.command_id));
|
||||
setPreview(null); setFile(null); setGiftID(0); setTitle("");
|
||||
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(""); setImportOpen(true);
|
||||
}
|
||||
|
||||
function startRevision(gift: StarGiftRow) {
|
||||
setGiftID(gift.GiftID); setTitle(gift.Title); setStars(String(gift.Stars));
|
||||
setConvertStars(String(gift.ConvertStars)); setSortOrder(String(gift.SortOrder)); setEnabled(gift.Enabled);
|
||||
setReason(""); setFile(null); setPreview(null); setImportError(""); setImportOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageFrame title={t("gifts.pageTitle")} eyebrow={t("gifts.eyebrow")} actions={<>
|
||||
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {t("common.refresh")}</button>
|
||||
<button className="btn primary" type="button" onClick={startImport}><Plus size={15} /> {t("gifts.add")}</button>
|
||||
</>}>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row gift-metrics">
|
||||
<Metric label={t("gifts.total")} value={String(gifts.length)} />
|
||||
<Metric label={t("gifts.enabled")} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" />
|
||||
<Metric label={t("gifts.received")} value={String(gifts.reduce((sum, gift) => sum + gift.ReceivedCount, 0))} />
|
||||
<Metric label={t("gifts.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={t("gifts.searchPlaceholder")} /></label>
|
||||
<span className="gift-list-summary">{t("gifts.listSummary", { shown: visibleGifts.length, total: gifts.length })}</span>
|
||||
</div>
|
||||
</QueryPanel>
|
||||
<div className="table-wrap gift-table-wrap">
|
||||
<table className="data-table gift-table">
|
||||
<thead><tr><th>{t("gifts.animation")}</th><th>{t("gifts.idRevision")}</th><th>{t("gifts.title")}</th><th>{t("gifts.price")}</th><th>{t("gifts.source")}</th><th>{t("gifts.received")}</th><th>{t("common.status")}</th><th>{t("common.updatedAt")}</th><th>{t("common.actions")}</th></tr></thead>
|
||||
<tbody>
|
||||
{visibleGifts.map((gift) => (
|
||||
<tr className={gift.Enabled ? "" : "gift-row-disabled"} key={gift.GiftID}>
|
||||
<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">{t("gifts.sortOrder")}: {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 ? t("common.enabled") : t("common.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} />{t("collectibles.manage")}</button><button className="btn compact-btn" type="button" onClick={() => startRevision(gift)}>{t("gifts.replace")}</button><ActionButton compact tone="neutral" label={gift.Enabled ? t("gifts.disable") : t("gifts.enable")} path="/api/actions/set-gift-enabled" payload={() => ({ gift_id: gift.GiftID, enabled: !gift.Enabled })} onDone={() => void load()} /></div></td>
|
||||
</tr>
|
||||
))}
|
||||
{visibleGifts.length === 0 && <EmptyRow colSpan={9} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</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 ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}>
|
||||
<div className="modal-head">
|
||||
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div>
|
||||
<button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body gift-import-modal-body">
|
||||
<div className="command-steps">
|
||||
<div className={`command-step ${file ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div>
|
||||
<div className={`command-step ${preview ? "done" : file ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div>
|
||||
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{t("gifts.stepImport")}</strong></div>
|
||||
</div>
|
||||
<div className="gift-import-note"><span>{t("gifts.importHint")}</span><div className="gift-format-chips" aria-label={t("gifts.formats")}><span>TGS</span><span>Lottie JSON</span></div></div>
|
||||
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
|
||||
<input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} />
|
||||
<span className="gift-file-icon"><FileJson2 size={22} /></span>
|
||||
<span className="gift-file-copy"><span className="gift-field-label">{t("gifts.animation")}</span><strong>{file ? file.name : t("gifts.filePrompt")}</strong><small>{file ? formatBytes(file.size) : t("gifts.fileHint")}</small></span>
|
||||
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span>
|
||||
</label>
|
||||
<div className="gift-fields-grid">
|
||||
<label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("gifts.convertStars")}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
|
||||
</div>
|
||||
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={reason} placeholder={t("gifts.reasonPlaceholder")} onChange={(e) => setReason(e.target.value)} /></label>
|
||||
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
|
||||
{importError && <Alert>{importError}</Alert>}
|
||||
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("gifts.validationReady")}</strong><span>{t("gifts.validationHint")}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={() => setImportOpen(false)} disabled={busy}>{t("common.close")}</button>
|
||||
<button className="btn" type="button" onClick={validateImport} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{t("gifts.validate")}</button>
|
||||
<button className="btn primary" type="button" onClick={confirmImport} disabled={busy || !preview}><Upload size={15} />{t("gifts.confirmImport")}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{collectibleGift && <GiftCollectiblesModal gift={collectibleGift} onClose={() => setCollectibleGift(null)} onPublished={() => void load()} />}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
|
|||
import { GroupMessagesPage } from "./GroupMessagesPage";
|
||||
import { MessageDetailPage } from "./MessageDetailPage";
|
||||
import { MessagesPage } from "./MessagesPage";
|
||||
import { GiftsPage } from "./GiftsPage";
|
||||
|
||||
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
|
||||
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
|
||||
|
|
@ -24,6 +25,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (route.path === "/channels") {
|
||||
return <ChannelsPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/gifts") {
|
||||
return <GiftsPage />;
|
||||
}
|
||||
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
|
||||
return (
|
||||
<MessageDetailPage
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export function routeTitle(pathname: string, t: TFunction): string {
|
|||
if (pathname.startsWith("/accounts")) return t("route.accounts");
|
||||
if (pathname.startsWith("/channels")) return t("route.channels");
|
||||
if (pathname.startsWith("/messages")) return t("route.messages");
|
||||
if (pathname.startsWith("/gifts")) return t("route.gifts");
|
||||
return t("route.dashboard");
|
||||
}
|
||||
|
||||
|
|
@ -27,5 +28,6 @@ export function routeSubtitle(pathname: string, t: TFunction): string {
|
|||
if (pathname.startsWith("/accounts")) return t("route.accountsSubtitle");
|
||||
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
|
||||
if (pathname.startsWith("/messages")) return t("route.messagesSubtitle");
|
||||
if (pathname.startsWith("/gifts")) return t("route.giftsSubtitle");
|
||||
return t("route.dashboardSubtitle");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ a {
|
|||
height: 100vh;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
padding: 18px 12px;
|
||||
color: #eef2f6;
|
||||
background: var(--sidebar);
|
||||
|
|
|
|||
|
|
@ -252,3 +252,198 @@
|
|||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.gift-metrics .metric {
|
||||
min-height: 68px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(145deg, #ffffff, #f6f9f9);
|
||||
}
|
||||
|
||||
.gift-metrics .metric strong { font-size: 17px; }
|
||||
|
||||
.gift-file-icon {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
color: var(--brand);
|
||||
background: #eaf6f3;
|
||||
border: 1px solid #c7e3dc;
|
||||
}
|
||||
|
||||
.gift-format-chips { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
|
||||
.gift-format-chips span { padding: 4px 8px; color: #33645d; background: #eef8f5; border: 1px solid #cfe5df; border-radius: 999px; font-size: 10px; font-weight: 800; letter-spacing: .02em; }
|
||||
|
||||
.gift-list-summary { margin-left: auto; color: var(--muted); font-size: 11px; font-weight: 700; }
|
||||
|
||||
.gift-import-modal { width: min(860px, 100%); }
|
||||
.gift-import-modal-body { gap: 14px; }
|
||||
.gift-import-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); line-height: 1.45; }
|
||||
|
||||
.gift-file-picker {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||
min-height: 78px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
color: var(--text);
|
||||
background: #ffffff;
|
||||
border: 1px dashed #b7ccc8;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color .16s ease, background .16s ease, box-shadow .16s ease;
|
||||
}
|
||||
|
||||
.gift-file-picker:hover,
|
||||
.gift-file-picker.has-file { background: #f8fcfb; border-color: var(--brand); box-shadow: 0 0 0 2px rgba(23, 109, 97, .05); }
|
||||
.gift-file-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.gift-file-icon { width: 40px; height: 40px; border-radius: 9px; }
|
||||
.gift-file-copy { display: grid; min-width: 0; gap: 2px; }
|
||||
.gift-field-label { color: var(--muted); font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.gift-file-copy strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gift-file-copy small { color: var(--muted); font-size: 11px; font-weight: 500; }
|
||||
.gift-file-action { padding: 7px 10px; color: var(--brand); background: #f0f8f6; border: 1px solid #c7e3dc; border-radius: 7px; font-size: 11px; font-weight: 800; }
|
||||
|
||||
.gift-fields-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1.5fr) repeat(3, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gift-fields-grid label,
|
||||
.gift-reason-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gift-fields-grid input,
|
||||
.gift-reason-field input {
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.gift-fields-grid input:focus,
|
||||
.gift-reason-field input:focus { border-color: #77b6aa; box-shadow: 0 0 0 3px rgba(23, 109, 97, .08); outline: none; }
|
||||
|
||||
.gift-switch { display: inline-flex; align-items: center; gap: 9px; color: #344054; font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.gift-switch input { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
||||
.gift-switch-track { display: flex; width: 34px; height: 19px; align-items: center; padding: 2px; background: #c8d0d5; border-radius: 999px; transition: background .16s ease; }
|
||||
.gift-switch-track span { width: 15px; height: 15px; background: #ffffff; border-radius: 50%; box-shadow: 0 1px 3px rgba(16, 24, 40, .22); transition: transform .16s ease; }
|
||||
.gift-switch input:checked + .gift-switch-track { background: var(--brand); }
|
||||
.gift-switch input:checked + .gift-switch-track span { transform: translateX(15px); }
|
||||
.gift-switch input:focus-visible + .gift-switch-track { outline: 3px solid rgba(23, 109, 97, .16); outline-offset: 2px; }
|
||||
.gift-validation { overflow: hidden; color: #d5fff5; background: #173631; border: 1px solid #24564e; border-radius: 9px; }
|
||||
.gift-validation-head { display: flex; align-items: center; gap: 9px; padding: 10px 12px; color: #e3fff9; background: rgba(255, 255, 255, .035); border-bottom: 1px solid rgba(255, 255, 255, .09); }
|
||||
.gift-validation-head div { display: grid; gap: 2px; }
|
||||
.gift-validation-head span { color: #99cfc4; font-size: 10px; }
|
||||
.gift-validation pre { max-height: 180px; overflow: auto; margin: 0; padding: 11px 12px; color: #d5fff5; font-size: 11px; }
|
||||
|
||||
.gift-animation-shell { position: relative; display: grid; min-height: 210px; place-items: center; background: radial-gradient(circle, #f9f3ff, #eef8f5); }
|
||||
.gift-animation { width: 200px; height: 200px; }
|
||||
.gift-animation canvas { width: 100% !important; height: 100% !important; }
|
||||
.gift-play { position: absolute; right: 8px; bottom: 8px; display: grid; width: 30px; height: 30px; place-items: center; color: var(--text); background: rgba(255,255,255,.9); border: 1px solid var(--line); border-radius: 50%; }
|
||||
|
||||
.gift-table-wrap { background: #ffffff; }
|
||||
.gift-table { min-width: 1080px; }
|
||||
.gift-table th:first-child { width: 74px; }
|
||||
.gift-table td { vertical-align: middle; }
|
||||
.gift-animation-shell.compact { width: 56px; min-height: 56px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; }
|
||||
.gift-animation-shell.compact .gift-animation { width: 54px; height: 54px; }
|
||||
.gift-animation-shell.compact .gift-play { right: 3px; bottom: 3px; width: 20px; height: 20px; }
|
||||
.gift-row-disabled { opacity: .68; }
|
||||
.gift-table-title,
|
||||
.gift-sort-order,
|
||||
.gift-source-size,
|
||||
.gift-convert-price { display: block; }
|
||||
.gift-table-title { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gift-sort-order,
|
||||
.gift-source-size,
|
||||
.gift-convert-price { margin-top: 3px; color: var(--muted); font-size: 10px; }
|
||||
.gift-table-price { color: #755b00; }
|
||||
.gift-table-actions { display: flex; align-items: center; gap: 6px; }
|
||||
.collectible-button { color: #6548a8; background: #f7f3ff; border-color: #ddd2f5; }
|
||||
.collectible-button:hover { background: #efe8ff; border-color: #cbbaf0; }
|
||||
|
||||
.collectible-modal { width: min(1180px, 100%); max-height: min(92vh, 980px); }
|
||||
.collectible-modal .modal-head p { margin: 4px 0 0; color: var(--muted); font-size: 11px; }
|
||||
.collectible-modal-body { gap: 16px; overflow: auto; padding: 16px 18px 22px; background: #f5f7fa; }
|
||||
.collectible-loading { display: flex; min-height: 90px; align-items: center; justify-content: center; gap: 8px; color: var(--muted); }
|
||||
.collectible-empty { display: flex; align-items: center; gap: 12px; padding: 16px; color: #66568c; background: linear-gradient(135deg, #fbf9ff, #f2f7ff); border: 1px dashed #cfc3e9; border-radius: 12px; }
|
||||
.collectible-empty div,
|
||||
.collectible-definition-head > div:first-child,
|
||||
.collectible-section-head > div:first-child { display: grid; gap: 3px; }
|
||||
.collectible-empty span,
|
||||
.collectible-definition-head span,
|
||||
.collectible-section-head span { color: var(--muted); font-size: 10px; font-weight: 500; }
|
||||
.collectible-active { overflow: hidden; background: #ffffff; border: 1px solid #ddd6ee; border-radius: 12px; box-shadow: 0 5px 16px rgba(66, 46, 110, .05); }
|
||||
.collectible-active-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: linear-gradient(100deg, #fbf9ff, #f4f9ff); border-bottom: 1px solid #e9e4f3; }
|
||||
.collectible-active-head > div { display: flex; align-items: center; gap: 9px; color: #60458f; }
|
||||
.collectible-active-head > div > div { display: grid; gap: 2px; }
|
||||
.collectible-active-head span { color: var(--muted); font-size: 10px; }
|
||||
.collectible-active-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); gap: 1px; background: var(--line); }
|
||||
.collectible-active-grid article { display: flex; min-width: 0; align-items: center; gap: 9px; padding: 9px 11px; background: #ffffff; }
|
||||
.collectible-active-grid article > div:last-child { display: grid; min-width: 0; gap: 2px; }
|
||||
.collectible-active-grid article strong { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.collectible-active-grid article span { color: var(--muted); font-size: 9px; }
|
||||
.collectible-definition { overflow: hidden; background: #ffffff; border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 8px 24px rgba(16, 24, 40, .04); }
|
||||
.collectible-definition-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; background: linear-gradient(110deg, #f8fbfa, #fbf9ff); border-bottom: 1px solid var(--line); }
|
||||
.collectible-main-fields { padding: 14px 16px; background: #fbfcfd; border-bottom: 1px solid var(--line); }
|
||||
.collectible-section { padding: 14px 16px; border-bottom: 1px solid var(--line); }
|
||||
.collectible-section:last-child { border-bottom: 0; }
|
||||
.collectible-section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
||||
.collectible-section-tools { display: flex; align-items: center; gap: 7px; }
|
||||
.collectible-rows { display: grid; gap: 7px; }
|
||||
.collectible-row { position: relative; display: grid; align-items: end; gap: 7px; padding: 9px 9px 9px 36px; background: #fafbfc; border: 1px solid #e1e6eb; border-radius: 9px; }
|
||||
.collectible-row:hover { background: #ffffff; border-color: #cbd7dd; box-shadow: 0 3px 10px rgba(16, 24, 40, .035); }
|
||||
.collectible-row.animated { grid-template-columns: minmax(120px, 1.2fr) 90px 78px minmax(160px, 1.4fr) 48px 30px; }
|
||||
.collectible-row.backdrop { grid-template-columns: minmax(110px, 1.2fr) 70px 80px 70px repeat(4, 52px) 48px 30px; }
|
||||
.collectible-row-index { position: absolute; top: 0; bottom: 0; left: 0; display: grid; width: 27px; place-items: center; color: #71668c; background: #f0edf7; border-right: 1px solid #e0d9ed; border-radius: 8px 0 0 8px; font-size: 10px; font-weight: 800; }
|
||||
.collectible-row label { display: grid; min-width: 0; gap: 4px; }
|
||||
.collectible-row label > span { color: var(--muted); font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: .025em; }
|
||||
.collectible-row input:not([type="file"]) { width: 100%; min-width: 0; height: 32px; padding: 0 8px; color: var(--text); background: #ffffff; border: 1px solid #d5dde3; border-radius: 7px; font: inherit; font-size: 11px; }
|
||||
.collectible-row input:focus { border-color: #8d7aba; box-shadow: 0 0 0 3px rgba(111, 91, 174, .08); outline: none; }
|
||||
.collectible-file input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.collectible-file em { display: flex; min-width: 0; height: 32px; align-items: center; gap: 5px; overflow: hidden; padding: 0 8px; color: #625080; background: #f7f4fd; border: 1px dashed #cfc4e1; border-radius: 7px; font-size: 10px; font-style: normal; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
||||
.collectible-inline-preview { display: grid; width: 42px; height: 42px; place-items: center; overflow: hidden; color: #8c7cae; background: radial-gradient(circle, #ffffff, #eee8f8); border: 1px solid #ded5ed; border-radius: 8px; }
|
||||
.collectible-animation { width: 100%; height: 100%; overflow: hidden; }
|
||||
.collectible-animation.compact { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; background: radial-gradient(circle, #ffffff, #f0ebfa); border: 1px solid #e0d9ec; border-radius: 8px; }
|
||||
.collectible-animation canvas { width: 100% !important; height: 100% !important; }
|
||||
.collectible-animation.failed { color: #b42318; background: #fff4f2; }
|
||||
.collectible-animation.loading { color: #807397; }
|
||||
.collectible-file-error { grid-column: 1 / -1; color: #b42318; font-size: 10px; }
|
||||
.collectible-color input { height: 32px !important; padding: 3px !important; cursor: pointer; }
|
||||
.collectible-backdrop-preview { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; border: 1px solid rgba(42, 31, 71, .18); border-radius: 8px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.2); font-size: 11px; font-weight: 900; }
|
||||
.collectible-row .icon-btn { align-self: center; }
|
||||
.collectible-row .icon-btn:disabled { opacity: .28; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.gift-fields-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.collectible-row.animated,
|
||||
.collectible-row.backdrop { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.collectible-inline-preview,
|
||||
.collectible-backdrop-preview,
|
||||
.collectible-row .icon-btn { align-self: center; justify-self: start; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.gift-import-note { align-items: flex-start; flex-direction: column; }
|
||||
.gift-format-chips { justify-content: flex-start; }
|
||||
.gift-file-picker { grid-template-columns: 40px minmax(0, 1fr); }
|
||||
.gift-file-action { display: none; }
|
||||
.gift-fields-grid { grid-template-columns: 1fr; }
|
||||
.gift-list-summary { width: 100%; margin-left: 0; }
|
||||
.collectible-modal-body { padding: 10px; }
|
||||
.collectible-definition-head,
|
||||
.collectible-section-head { align-items: flex-start; flex-direction: column; }
|
||||
.collectible-row.animated,
|
||||
.collectible-row.backdrop { grid-template-columns: 1fr; }
|
||||
.collectible-active-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
.command-modal > .modal-head,
|
||||
.command-modal > .modal-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -52,6 +57,8 @@
|
|||
|
||||
.command-body {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-auto-rows: max-content;
|
||||
gap: 12px;
|
||||
overflow: auto;
|
||||
padding: 14px 18px;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ export type AccountRow = {
|
|||
|
||||
export type RestrictionRow = {
|
||||
Frozen: boolean;
|
||||
Since: string | null;
|
||||
Until: string | null;
|
||||
AppealURL: string;
|
||||
Reason: string;
|
||||
Actor: string;
|
||||
CommandID: string;
|
||||
|
|
@ -157,6 +160,58 @@ export type OutboxRow = {
|
|||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type StarGiftRow = {
|
||||
GiftID: number;
|
||||
RevisionID: number;
|
||||
Revision: number;
|
||||
Title: string;
|
||||
Stars: number;
|
||||
ConvertStars: number;
|
||||
Enabled: boolean;
|
||||
SortOrder: number;
|
||||
DocumentID: number;
|
||||
SourceName: string;
|
||||
SourceFormat: "tgs" | "lottie";
|
||||
AnimationSHA: string;
|
||||
AnimationSize: number;
|
||||
Width: number;
|
||||
Height: number;
|
||||
FrameRate: number;
|
||||
ReceivedCount: number;
|
||||
CreatedBy: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type StarGiftListResponse = { Gifts: StarGiftRow[] };
|
||||
|
||||
export type StarGiftCollectibleAttributeRow = {
|
||||
id: number;
|
||||
kind: "model" | "pattern" | "backdrop";
|
||||
name: string;
|
||||
rarity_permille: number;
|
||||
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: number;
|
||||
revision?: number;
|
||||
upgrade_stars?: number;
|
||||
supply_total?: number;
|
||||
issued?: number;
|
||||
slug_prefix?: string;
|
||||
models?: StarGiftCollectibleAttributeRow[];
|
||||
patterns?: StarGiftCollectibleAttributeRow[];
|
||||
backdrops?: StarGiftCollectibleAttributeRow[];
|
||||
};
|
||||
|
||||
export type MessageDetail = {
|
||||
Message: MessageRow;
|
||||
MessageJSON: string;
|
||||
|
|
|
|||
|
|
@ -14,16 +14,16 @@ import (
|
|||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"github.com/gotd/log/logzap"
|
||||
tdcrypto "github.com/gotd/td/crypto"
|
||||
"github.com/gotd/td/exchange"
|
||||
"github.com/gotd/td/mtproxy"
|
||||
"github.com/gotd/td/mtproxy/obfuscator"
|
||||
"github.com/gotd/td/proto/codec"
|
||||
"github.com/gotd/td/session"
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/telegram/dcs"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/transport"
|
||||
tdcrypto "github.com/iamxvbaba/td/crypto"
|
||||
"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/session"
|
||||
"github.com/iamxvbaba/td/telegram"
|
||||
"github.com/iamxvbaba/td/telegram/dcs"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/transport"
|
||||
|
||||
"telesrv/internal/mtprotoedge"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Command telesrv 是基于 gotd/td 的 Telegram-like server(第一兼容目标:Telegram Desktop)。
|
||||
// Command telesrv 是基于 github.com/iamxvbaba/td 的 Telegram-like server(第一兼容目标:Telegram Desktop)。
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -18,9 +18,9 @@ import (
|
|||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"github.com/gotd/td/clock"
|
||||
"github.com/gotd/td/exchange"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/exchange"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
adminapp "telesrv/internal/admin"
|
||||
"telesrv/internal/adminapi"
|
||||
|
|
@ -55,8 +55,10 @@ import (
|
|||
"telesrv/internal/botapi"
|
||||
"telesrv/internal/config"
|
||||
"telesrv/internal/domain"
|
||||
mailpkg "telesrv/internal/mail"
|
||||
"telesrv/internal/mtprotoedge"
|
||||
"telesrv/internal/otpdelivery"
|
||||
otpsmtp "telesrv/internal/otpdelivery/smtp"
|
||||
otpwebhook "telesrv/internal/otpdelivery/webhook"
|
||||
"telesrv/internal/rpc"
|
||||
"telesrv/internal/seed/catalog"
|
||||
"telesrv/internal/sfu"
|
||||
|
|
@ -297,7 +299,8 @@ func run(logger *zap.Logger) error {
|
|||
return fmt.Errorf("parse listen port %q: %w", portStr, err)
|
||||
}
|
||||
|
||||
// tg.Layer 来自 gotd/td v0.158.0(Layer 227),与目标 TDesktop 基线对齐。
|
||||
// tg.Layer 由当前导入的 canonical schema 生成;纳入未来 Layer 后无需
|
||||
// 在 telesrv 另维护一份常量。
|
||||
logger.Info("telesrv 启动",
|
||||
zap.String("listen", cfg.ListenAddr),
|
||||
zap.Int("dc", cfg.DC),
|
||||
|
|
@ -347,11 +350,6 @@ func run(logger *zap.Logger) error {
|
|||
defer func() { _ = rdb.Close() }()
|
||||
logger.Info("持久化依赖就绪", zap.String("redis", cfg.RedisAddr))
|
||||
|
||||
ln, err := net.Listen("tcp", cfg.ListenAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %q: %w", cfg.ListenAddr, err)
|
||||
}
|
||||
|
||||
authKeyStore := postgres.NewAuthKeyStore(pool)
|
||||
userStore := postgres.NewUserStore(pool)
|
||||
authzStore := postgres.NewAuthorizationStore(pool)
|
||||
|
|
@ -483,6 +481,7 @@ func run(logger *zap.Logger) error {
|
|||
cfg.RetentionBatch,
|
||||
).WithDispatchOutboxPoisonPolicy(cfg.OutboxPoisonRetention, cfg.OutboxPoisonCleanupInterval).
|
||||
WithBotAPIUpdateRetention(botAPIUpdateStore, cfg.BotAPIUpdateRetention).
|
||||
WithAuthKeySessionLayerRetention(authKeyStore).
|
||||
WithLoginCodeDeliveryRetention(messageStore).
|
||||
WithUserUpdateRetention(updateEventStore).
|
||||
WithChannelUpdateRetention(channelStore).
|
||||
|
|
@ -534,18 +533,50 @@ func run(logger *zap.Logger) error {
|
|||
account.WithEmailSignup(cfg.EmailSignupEnable),
|
||||
account.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
|
||||
}
|
||||
var loginEmailSender mailpkg.Sender
|
||||
if cfg.LoginEmailEnable || cfg.EmailSignupEnable {
|
||||
loginEmailSender = mailpkg.NewSMTP(mailpkg.Config{
|
||||
Host: cfg.SMTPHost,
|
||||
Port: cfg.SMTPPort,
|
||||
Username: cfg.SMTPUsername,
|
||||
Password: cfg.SMTPPassword,
|
||||
From: cfg.SMTPFrom,
|
||||
FromName: cfg.SMTPFromName,
|
||||
TLSMode: cfg.SMTPTLSMode,
|
||||
Timeout: cfg.SMTPTimeout,
|
||||
var webhookSender otpdelivery.Sender
|
||||
// EmailSignupEnable also needs a sender: "email as identity" sign-up/login
|
||||
// codes go out on the same loginEmailSender channel as LoginEmailEnable
|
||||
// (see auth.Service.emailSignupEnabled / account.sendChangePhoneCodeByEmail),
|
||||
// so it must gate provider construction identically or those flows silently
|
||||
// get a nil sender when LoginEmailEnable itself is off.
|
||||
if cfg.PhoneCodeDeliveryProvider == "webhook" ||
|
||||
((cfg.LoginEmailEnable || cfg.EmailSignupEnable) && cfg.EmailCodeDeliveryProvider == "webhook") {
|
||||
configured, err := otpwebhook.New(otpwebhook.Config{
|
||||
URL: cfg.OTPWebhookURL,
|
||||
Secret: cfg.OTPWebhookSecret,
|
||||
Timeout: cfg.OTPWebhookTimeout,
|
||||
Logger: logger.Named("otp").Named("webhook"),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure OTP webhook: %w", err)
|
||||
}
|
||||
webhookSender = configured
|
||||
logger.Info("OTP Webhook 投递已启用",
|
||||
zap.Bool("phone", cfg.PhoneCodeDeliveryProvider == "webhook"),
|
||||
zap.Bool("email", (cfg.LoginEmailEnable || cfg.EmailSignupEnable) && cfg.EmailCodeDeliveryProvider == "webhook"))
|
||||
}
|
||||
var phoneCodeSender otpdelivery.Sender
|
||||
if cfg.PhoneCodeDeliveryProvider == "webhook" {
|
||||
phoneCodeSender = webhookSender
|
||||
accountOptions = append(accountOptions, account.WithPhoneCodeDelivery(phoneCodeSender, cfg.PhoneCodeLength))
|
||||
}
|
||||
var loginEmailSender otpdelivery.Sender
|
||||
if cfg.LoginEmailEnable || cfg.EmailSignupEnable {
|
||||
switch cfg.EmailCodeDeliveryProvider {
|
||||
case "webhook":
|
||||
loginEmailSender = webhookSender
|
||||
default:
|
||||
loginEmailSender = otpsmtp.New(otpsmtp.Config{
|
||||
Host: cfg.SMTPHost,
|
||||
Port: cfg.SMTPPort,
|
||||
Username: cfg.SMTPUsername,
|
||||
Password: cfg.SMTPPassword,
|
||||
From: cfg.SMTPFrom,
|
||||
FromName: cfg.SMTPFromName,
|
||||
TLSMode: cfg.SMTPTLSMode,
|
||||
Timeout: cfg.SMTPTimeout,
|
||||
})
|
||||
}
|
||||
accountOptions = append(accountOptions,
|
||||
account.WithLoginEmailVerification(codeStore, loginEmailSender, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength))
|
||||
}
|
||||
|
|
@ -644,7 +675,9 @@ func run(logger *zap.Logger) error {
|
|||
starsStore := postgres.NewStarsStore(pool)
|
||||
starsService := stars.NewService(starsStore, stars.WithStartingGrant(cfg.StarsStartingGrant))
|
||||
starGiftStore := postgres.NewStarGiftStore(pool)
|
||||
giftsService := stargifts.NewService(starGiftStore, filesService)
|
||||
starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore)
|
||||
giftsService := stargifts.NewService(starGiftStore, blobBackend, cfg.DC,
|
||||
stargifts.WithUpgradeStore(starGiftUpgradeStore))
|
||||
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
|
||||
// 同属进程内一次性凭据,不跨实例)。
|
||||
passkeyStore := postgres.NewPasskeyStore(pool)
|
||||
|
|
@ -701,6 +734,14 @@ func run(logger *zap.Logger) error {
|
|||
auth.WithPremiumGrant(cfg.PremiumGrantMonths),
|
||||
auth.WithCodeTTL(cfg.AuthCodeTTL),
|
||||
auth.WithCodeMaxAttempts(cfg.AuthCodeMaxAttempts),
|
||||
auth.WithPhoneCodeDelivery(phoneCodeSender, cfg.PhoneCodeLength),
|
||||
auth.WithOTPDeliveryFailureObserver(func(_ context.Context, request otpdelivery.Request, err error) {
|
||||
logger.Named("otp").Warn("附加 OTP provider 投递失败,777000 App-code 保持有效",
|
||||
zap.String("delivery_id", request.DeliveryID),
|
||||
zap.String("purpose", string(request.Purpose)),
|
||||
zap.String("channel", string(request.Channel)),
|
||||
zap.Error(err))
|
||||
}),
|
||||
auth.WithLoginEmail(auth.LoginEmailOptions{
|
||||
Enabled: cfg.LoginEmailEnable,
|
||||
RequireSetup: cfg.LoginEmailRequireSetup,
|
||||
|
|
@ -734,39 +775,46 @@ func run(logger *zap.Logger) error {
|
|||
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
|
||||
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
|
||||
}, rpc.Deps{
|
||||
Auth: authService,
|
||||
Account: accountService,
|
||||
Privacy: privacyService,
|
||||
Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken), help.WithEmailSignupEnable(cfg.EmailSignupEnable), help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes)),
|
||||
AICompose: aiComposeService,
|
||||
Users: usersService,
|
||||
Updates: updatesService,
|
||||
BootstrapUpdates: bootstrapUpdateStore,
|
||||
BotAPIUpdates: botAPIUpdateStore,
|
||||
Contacts: contactsService,
|
||||
Dialogs: dialogsService,
|
||||
Chatlists: chatlistsService,
|
||||
Messages: messagesService,
|
||||
Translation: translationService,
|
||||
Channels: channelsService,
|
||||
Files: filesService,
|
||||
Bots: botsService,
|
||||
Polls: pollsapp.NewService(pollStore),
|
||||
Stories: storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService)),
|
||||
Phone: phoneService,
|
||||
SecretChats: secretChatService,
|
||||
Stars: starsService,
|
||||
Gifts: giftsService,
|
||||
Passkey: passkeyService,
|
||||
Themes: themeService,
|
||||
GroupCalls: groupCallsService,
|
||||
LiveStreams: liveStreamDep(liveStreamService),
|
||||
SFU: sfuService,
|
||||
TURN: turnService,
|
||||
LangPack: langPackService,
|
||||
Sessions: activeSessions,
|
||||
Inline: inlineRegistryStore,
|
||||
Limiter: rateLimiter,
|
||||
Auth: authService,
|
||||
AuthKeySessionLayers: authKeyStore,
|
||||
Account: accountService,
|
||||
Privacy: privacyService,
|
||||
Help: help.NewService(helpStore, helpStore,
|
||||
help.WithMapboxToken(cfg.MapboxToken),
|
||||
help.WithEmailSignupEnable(cfg.EmailSignupEnable),
|
||||
help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
|
||||
help.WithAccountFreezeProvider(adminService),
|
||||
),
|
||||
AccountFreeze: adminService,
|
||||
AICompose: aiComposeService,
|
||||
Users: usersService,
|
||||
Updates: updatesService,
|
||||
BootstrapUpdates: bootstrapUpdateStore,
|
||||
BotAPIUpdates: botAPIUpdateStore,
|
||||
Contacts: contactsService,
|
||||
Dialogs: dialogsService,
|
||||
Chatlists: chatlistsService,
|
||||
Messages: messagesService,
|
||||
Translation: translationService,
|
||||
Channels: channelsService,
|
||||
Files: filesService,
|
||||
Bots: botsService,
|
||||
Polls: pollsapp.NewService(pollStore),
|
||||
Stories: storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService)),
|
||||
Phone: phoneService,
|
||||
SecretChats: secretChatService,
|
||||
Stars: starsService,
|
||||
Gifts: giftsService,
|
||||
Passkey: passkeyService,
|
||||
Themes: themeService,
|
||||
GroupCalls: groupCallsService,
|
||||
LiveStreams: liveStreamDep(liveStreamService),
|
||||
SFU: sfuService,
|
||||
TURN: turnService,
|
||||
LangPack: langPackService,
|
||||
Sessions: activeSessions,
|
||||
Inline: inlineRegistryStore,
|
||||
Limiter: rateLimiter,
|
||||
}, logger.Named("rpc"), clock.System)
|
||||
readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{
|
||||
ReadModelVersions: readModelVersionStore,
|
||||
|
|
@ -786,6 +834,7 @@ func run(logger *zap.Logger) error {
|
|||
RPCProjections: router,
|
||||
BaseUsers: userCache,
|
||||
BotProfiles: botsService,
|
||||
StarGifts: giftsService,
|
||||
}, logger.Named("store").Named("read-model-listener"))
|
||||
go readModelListener.Run(ctx)
|
||||
activeSessions.SetLifecycleObserver(router)
|
||||
|
|
@ -799,6 +848,7 @@ func run(logger *zap.Logger) error {
|
|||
Channels: channelsService,
|
||||
ChannelNotifier: router,
|
||||
Messages: messagesService,
|
||||
Gifts: giftsService,
|
||||
})
|
||||
// bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界),
|
||||
// router 创建后注入。
|
||||
|
|
@ -845,37 +895,48 @@ func run(logger *zap.Logger) error {
|
|||
}
|
||||
|
||||
srv := mtprotoedge.New(mtprotoedge.Options{
|
||||
Logger: logger.Named("mtprotoedge"),
|
||||
DC: cfg.DC,
|
||||
RSAKey: rsaKey,
|
||||
RPC: router,
|
||||
AuthKeys: authKeyStore,
|
||||
ActiveSessions: activeSessions,
|
||||
ObfuscatedTCP: true,
|
||||
WebSocket: cfg.WebSocketEnable,
|
||||
WebSocketAllowedOrigins: cfg.WebSocketAllowedOrigins,
|
||||
MaxConnections: cfg.MTProtoMaxConnections,
|
||||
MaxConnectionsPerIP: cfg.MTProtoMaxConnectionsPerIP,
|
||||
MaxConcurrentHandshakes: cfg.MTProtoMaxConcurrentHandshakes,
|
||||
RPCMaxInflight: cfg.MTProtoRPCMaxInflight,
|
||||
RPCQueueSize: cfg.MTProtoRPCQueueSize,
|
||||
RPCTimeout: cfg.MTProtoRPCTimeout,
|
||||
RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers,
|
||||
RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks,
|
||||
RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes,
|
||||
InboundFrameGlobalMaxBytes: cfg.MTProtoInboundFrameGlobalMaxBytes,
|
||||
OutboundQueueSize: cfg.MTProtoOutboundQueueSize,
|
||||
OutboundControlQueueSize: cfg.MTProtoOutboundControlQueueSize,
|
||||
OutboundTrackedGlobalMaxBytes: cfg.MTProtoOutboundTrackedGlobalMaxBytes,
|
||||
OutboundWriteGlobalMaxBytes: cfg.MTProtoOutboundWriteGlobalMaxBytes,
|
||||
Logger: logger.Named("mtprotoedge"),
|
||||
DC: cfg.DC,
|
||||
RSAKey: rsaKey,
|
||||
LayerRPC: router,
|
||||
AuthKeys: authKeyStore,
|
||||
ActiveSessions: activeSessions,
|
||||
ObfuscatedTCP: true,
|
||||
WebSocket: cfg.WebSocketEnable,
|
||||
WebSocketAllowedOrigins: cfg.WebSocketAllowedOrigins,
|
||||
MaxConnections: cfg.MTProtoMaxConnections,
|
||||
MaxConnectionsPerIP: cfg.MTProtoMaxConnectionsPerIP,
|
||||
MaxConcurrentHandshakes: cfg.MTProtoMaxConcurrentHandshakes,
|
||||
RPCMaxInflight: cfg.MTProtoRPCMaxInflight,
|
||||
RPCQueueSize: cfg.MTProtoRPCQueueSize,
|
||||
RPCTimeout: cfg.MTProtoRPCTimeout,
|
||||
RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers,
|
||||
RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks,
|
||||
RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes,
|
||||
RPCResultCacheMaxEntries: cfg.MTProtoRPCResultCacheMaxEntries,
|
||||
RPCResultCacheMaxBytes: cfg.MTProtoRPCResultCacheMaxBytes,
|
||||
RPCResultCacheAuthMaxEntries: cfg.MTProtoRPCResultCacheAuthMaxEntries,
|
||||
RPCResultCacheAuthMaxBytes: cfg.MTProtoRPCResultCacheAuthMaxBytes,
|
||||
RPCResultCacheSessionMaxEntries: cfg.MTProtoRPCResultCacheSessionMaxEntries,
|
||||
RPCResultCacheSessionMaxBytes: cfg.MTProtoRPCResultCacheSessionMaxBytes,
|
||||
RPCResultPendingPerAuth: cfg.MTProtoRPCResultPendingPerAuth,
|
||||
InboundFrameGlobalMaxBytes: cfg.MTProtoInboundFrameGlobalMaxBytes,
|
||||
OutboundQueueSize: cfg.MTProtoOutboundQueueSize,
|
||||
OutboundControlQueueSize: cfg.MTProtoOutboundControlQueueSize,
|
||||
OutboundTrackedGlobalMaxBytes: cfg.MTProtoOutboundTrackedGlobalMaxBytes,
|
||||
OutboundWriteGlobalMaxBytes: cfg.MTProtoOutboundWriteGlobalMaxBytes,
|
||||
OnServing: func(_ net.Addr) {
|
||||
logger.Info("telesrv 服务就绪",
|
||||
zap.String("listen", cfg.ListenAddr),
|
||||
zap.String("advertise", net.JoinHostPort(cfg.AdvertiseIP, portStr)),
|
||||
zap.Int("pid", os.Getpid()),
|
||||
zap.String("git_commit", buildMeta.Commit),
|
||||
zap.Uint("schema_version", migrationStatus.Version),
|
||||
zap.String("blob_backend", "localfs"),
|
||||
)
|
||||
},
|
||||
})
|
||||
logger.Info("telesrv 服务就绪",
|
||||
zap.String("listen", cfg.ListenAddr),
|
||||
zap.String("advertise", net.JoinHostPort(cfg.AdvertiseIP, portStr)),
|
||||
zap.Int("pid", os.Getpid()),
|
||||
zap.String("git_commit", buildMeta.Commit),
|
||||
zap.Uint("schema_version", migrationStatus.Version),
|
||||
zap.String("blob_backend", "localfs"),
|
||||
)
|
||||
return srv.Serve(ctx, ln)
|
||||
// This is intentionally the final startup operation. ListenAndServe owns the
|
||||
// public listener so no seed/prewarm work can run after port 2398 is exposed.
|
||||
return srv.ListenAndServe(ctx, cfg.ListenAddr)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue