Merge remote-tracking branch 'upstream/main' into dev
This commit is contained in:
commit
6b29556ef8
836 changed files with 1598388 additions and 64684 deletions
29
.env.example
29
.env.example
|
|
@ -27,6 +27,14 @@ TELESRV_MTPROTO_RPC_TIMEOUT=30s
|
|||
TELESRV_MTPROTO_RPC_GLOBAL_WORKERS=256
|
||||
TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=8192
|
||||
TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES=536870912
|
||||
# In-process 331s rpc_result ownership budgets: global >= auth >= session.
|
||||
TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_ENTRIES=262144
|
||||
TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_BYTES=67108864
|
||||
TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_ENTRIES=32768
|
||||
TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_BYTES=33554432
|
||||
TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_ENTRIES=16384
|
||||
TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_BYTES=16777216
|
||||
TELESRV_MTPROTO_RPC_RESULT_PENDING_PER_AUTH=2048
|
||||
# Process-wide in-flight transport wire + decrypted plaintext reservation.
|
||||
TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES=536870912
|
||||
# Per-connection outbound mailboxes (normal/control) and process-wide resend pending bodies.
|
||||
|
|
@ -36,12 +44,24 @@ TELESRV_MTPROTO_OUTBOUND_TRACKED_GLOBAL_MAX_BYTES=536870912
|
|||
# Concurrent encrypted wire/codec/obfuscation scratch (shared bounded pool, not per connection).
|
||||
TELESRV_MTPROTO_OUTBOUND_WRITE_GLOBAL_MAX_BYTES=536870912
|
||||
|
||||
# Optional login-email verification. When enabled, accounts with a confirmed
|
||||
# login email receive login codes by email; REQUIRE_SETUP also forces new/legacy
|
||||
# OTP delivery routing. "development" preserves the fixed phone code. "webhook"
|
||||
# generates a random SMS code and sends it with the versioned protocol documented
|
||||
# in docs/otp-delivery.md. For an existing account, both modes first create the
|
||||
# durable 777000 message; Webhook is an additional delivery channel for that code.
|
||||
TELESRV_PHONE_CODE_DELIVERY_PROVIDER=development
|
||||
TELESRV_PHONE_CODE_LENGTH=5
|
||||
TELESRV_OTP_WEBHOOK_URL=
|
||||
TELESRV_OTP_WEBHOOK_SECRET=
|
||||
TELESRV_OTP_WEBHOOK_TIMEOUT=5s
|
||||
|
||||
# Optional login-email verification. EMAIL_CODE_DELIVERY_PROVIDER may be smtp
|
||||
# or webhook. Existing-account login codes are also mirrored into 777000 before
|
||||
# provider delivery; setup/change codes are provider-only. REQUIRE_SETUP forces
|
||||
# accounts without a login email to set one during the phone login flow.
|
||||
TELESRV_LOGIN_EMAIL_ENABLE=false
|
||||
TELESRV_LOGIN_EMAIL_REQUIRE_SETUP=false
|
||||
TELESRV_LOGIN_EMAIL_CODE_LENGTH=6
|
||||
TELESRV_EMAIL_CODE_DELIVERY_PROVIDER=smtp
|
||||
TELESRV_SMTP_HOST=
|
||||
TELESRV_SMTP_PORT=587
|
||||
TELESRV_SMTP_USERNAME=
|
||||
|
|
@ -194,5 +214,8 @@ TELESRV_TRANSLATION_RATE_WINDOW=1m
|
|||
# TELESRV_AI_KIMI_TEMPERATURE=0.6
|
||||
|
||||
# Business automation reply provider:
|
||||
# echo (default), template/quick_reply, or ai/compose_ai/kimi to reuse AI providers.
|
||||
# echo (default/empty), template/quick_reply/quick-reply, or
|
||||
# ai/compose_ai/ai_compose/aicompose/kimi to reuse TELESRV_AI_PROVIDERS.
|
||||
# Custom provider names such as "ollama" are selected through TELESRV_AI_PROVIDERS;
|
||||
# use TELESRV_BUSINESS_AI_PROVIDER=ai for those.
|
||||
TELESRV_BUSINESS_AI_PROVIDER=echo
|
||||
|
|
|
|||
23
README.md
23
README.md
|
|
@ -6,14 +6,24 @@
|
|||
|
||||
**Your own private messaging server — self-hosted, protocol-compatible, fully yours.**
|
||||
|
||||
The protocol stack is built on the published
|
||||
[`github.com/iamxvbaba/td`](https://github.com/iamxvbaba/td) module
|
||||
(`v1.1.0`), using a canonical Layer 228 schema with sparse `tlprofile`
|
||||
exact Layer 225-228 compatibility profiles.
|
||||
|
||||
If you are looking for a **Telegram server**, **MTProto server**,
|
||||
**Telegram backend**, **Telegram clone server**, or **self-hosted
|
||||
Telegram-like chat server**, this repository is the server-side implementation
|
||||
to study, run, and improve.
|
||||
|
||||
OwpenGram Server is an open-source, Telegram-compatible MTProto backend written
|
||||
in Go. Run it on your own network for a private, closed setup, or on a VPS to
|
||||
be reachable anywhere in the world. Your data, your keys, your rules — no
|
||||
cloud, no lock-in, no censorship.
|
||||
|
||||
> 🔗 Implements **MTProto API layer 227**.
|
||||
> 🔗 Implements **MTProto API layer 228**.
|
||||
|
||||
`gramsrv` is independent and unofficial. It is not affiliated with, endorsed by,
|
||||
`OwpenGram Server` is independent and unofficial. It is not affiliated with, endorsed by,
|
||||
or sponsored by Telegram or the official Telegram team.
|
||||
|
||||
---
|
||||
|
|
@ -43,7 +53,7 @@ or sponsored by Telegram or the official Telegram team.
|
|||
|
||||
| Status | Feature | What works today |
|
||||
|---|---|---|
|
||||
| ✅ | MTProto server edge | TCP transport, RSA key exchange, auth keys, encrypted sessions, salts, ack/resend, bad messages, RPC dispatch, and layer compatibility helpers. |
|
||||
| ✅ | MTProto server edge | TCP transport, RSA key exchange, auth keys, encrypted sessions, salts, ack/resend, bad messages, RPC dispatch, canonical Layer 228, and sparse exact Layer 225-228 compatibility profiles. |
|
||||
| ✅ | Login and accounts | Development login code, sign-in, sign-up, log-out, authorizations, account settings, SRP/password state, email/passkey-oriented paths. |
|
||||
| ✅ | Users and contacts | User profiles, usernames, profile photos, contact import/search, blocked/privacy state, presence, and last-seen style status. |
|
||||
| ✅ | Dialogs and sync | Dialog list, pinned dialogs, manual unread, folders/filters, drafts, read boundaries, durable updates, online fan-out, and offline difference recovery. |
|
||||
|
|
@ -55,7 +65,7 @@ or sponsored by Telegram or the official Telegram team.
|
|||
| ✅ | Supergroups and channels | Create, join, leave, invite links, participants, admins, forum topics, linked discussion guests, history, send/edit/delete/read, reactions, public search, and previews. |
|
||||
| ✅ | Media and files | Upload, download, local blob storage, photos, documents, thumbnails, canonical GIFv conversion, external media fetch, web page previews, map tile cache hooks, profile/channel photos. |
|
||||
| ✅ | Stickers and reactions | Sticker/reaction catalog, seed support, saved GIFs, recent reactions, top reactions, default reactions, and moderation-oriented reaction paths. |
|
||||
| ✅ | Gifts and stars | Star gifts and local stars ledger foundations for compatibility and future feature work. |
|
||||
| ✅ | Gifts and stars | Dynamic star gift catalog, admin import tools, collectible/unique gift upgrade flows, prepaid upgrade tracking, and local stars ledger foundations. |
|
||||
| ✅ | Bots and mini apps | Bot service foundations, callbacks, inline helpers, webview/mini-app paths, a minimal Bot API gateway for libraries such as `python-telegram-bot`, persistent `getUpdates` delivery, and demo tools. |
|
||||
| ✅ | Calls and live streams | Private call signaling foundations, group call state, RTMP live streaming, scheduled video chats, channel `join_as`, SFU/TURN building blocks, liveness, and expiry workers. |
|
||||
| ✅ | Admin and operations | Admin API/UI backend, PostgreSQL migrations, Redis volatile state, retention workers, pprof/debug hooks, and load-test helpers. |
|
||||
|
|
@ -202,6 +212,11 @@ server-selection screen at login — no source patching or custom build needed:
|
|||
A stock Telegram client will not connect, since it only trusts Telegram's own
|
||||
DC list and RSA keys.
|
||||
|
||||
- Telegram Desktop commit: `9caf32dffc90ddd9bb08ad5777b865f729fa167b`
|
||||
- Canonical TL layer: 228
|
||||
- Exact compatibility profiles: Layer 225-228
|
||||
- Local DC: `127.0.0.1:2398`, DC id `2`
|
||||
|
||||
**1. Export your server's public key**
|
||||
|
||||
After the server generates `data/server_rsa.pem`, export the matching public
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@
|
|||
它是一个 Telegram-like backend,面向真实客户端兼容、自建聊天实验、协议研究,
|
||||
以及一条长期可演进的社区 server 路线。
|
||||
|
||||
协议栈基于已发布的
|
||||
[`github.com/iamxvbaba/td`](https://github.com/iamxvbaba/td) module
|
||||
(`v1.1.0`),使用 canonical Layer 228 schema,并通过 sparse `tlprofile`
|
||||
提供 exact Layer 225-228 compatibility profiles。
|
||||
|
||||
如果你正在搜索 **Telegram server 实现**、**MTProto server 实现**、
|
||||
**Telegram 后端**、**Telegram clone server**、**自建 Telegram-like 聊天服务器**,
|
||||
这个仓库就是可以运行、研究和共同优化的 server 侧实现。
|
||||
|
|
@ -37,7 +42,7 @@ https://github.com/user-attachments/assets/25e651dc-a022-4d60-8b9b-ca3e8bfe216c
|
|||
|
||||
| 状态 | 功能 | 当前已实现 |
|
||||
|---|---|---|
|
||||
| ✅ | MTProto server 接入层 | TCP transport、RSA key exchange、auth key、加密 session、salt、ack/resend、bad message、RPC dispatch、layer 兼容辅助。 |
|
||||
| ✅ | MTProto server 接入层 | TCP transport、RSA key exchange、auth key、加密 session、salt、ack/resend、bad message、RPC dispatch、canonical Layer 228,以及 sparse exact Layer 225-228 compatibility profiles。 |
|
||||
| ✅ | 登录与账号 | 开发验证码登录、sign-in、sign-up、log-out、授权设备、账号设置、SRP/password 状态、email/passkey 相关路径。 |
|
||||
| ✅ | 用户与联系人 | 用户资料、username、头像、联系人导入/搜索、block/privacy 状态、presence、last seen。 |
|
||||
| ✅ | 会话与同步 | dialog list、置顶、手动未读、folders/filters、草稿、read boundary、durable updates、在线 fan-out、离线 difference 恢复。 |
|
||||
|
|
@ -49,7 +54,7 @@ https://github.com/user-attachments/assets/25e651dc-a022-4d60-8b9b-ca3e8bfe216c
|
|||
| ✅ | 超级群与频道 | create、join、leave、邀请链接、成员、管理员、forum topics、关联讨论组 guest 访问、history、send/edit/delete/read、reactions、公开搜索和预览。 |
|
||||
| ✅ | 媒体与文件 | upload、download、本地 blob 存储、照片、文档、缩略图、规范 GIFv 转换、外链媒体抓取、网页预览、地图缩略图缓存、用户/频道头像。 |
|
||||
| ✅ | Stickers 与 Reactions | sticker/reaction catalog、seed 支持、saved GIFs、recent reactions、top reactions、default reactions、reaction moderation 相关路径。 |
|
||||
| ✅ | Gifts 与 Stars | star gifts、本地 stars ledger 基础,用于兼容和后续功能扩展。 |
|
||||
| ✅ | Gifts 与 Stars | 动态 star gift catalog、后台导入、收藏品/唯一礼物升级流程、预付升级跟踪,以及本地 stars ledger 基础。 |
|
||||
| ✅ | Bots 与 Mini Apps | bot 服务基础、callbacks、inline helpers、webview/mini-app 路径、适配 `python-telegram-bot` 等库的最小 Bot API gateway、持久化 `getUpdates` 投递队列和 demo 工具。 |
|
||||
| ✅ | 通话与直播 | 私聊通话信令基础、group call 状态、RTMP live stream、定时视频通话、频道 `join_as` 身份、SFU/TURN building blocks、liveness 与 expiry worker。 |
|
||||
| ✅ | 管理与运维 | Admin API/UI backend、PostgreSQL migrations、Redis 易失态、retention workers、pprof/debug hooks、load-test helpers。 |
|
||||
|
|
@ -199,7 +204,8 @@ TELESRV_PUBLIC_APP_NAME=YourApp
|
|||
当前 Telegram Desktop 基线:
|
||||
|
||||
- Telegram Desktop commit:`9caf32dffc90ddd9bb08ad5777b865f729fa167b`
|
||||
- TL layer:227
|
||||
- Canonical TL layer:228
|
||||
- Exact compatibility profiles:Layer 225-228
|
||||
- 本地 DC:`127.0.0.1:2398`,DC id `2`
|
||||
|
||||
等 `gramsrv` 生成 `data/server_rsa.pem` 后,导出匹配的公钥:
|
||||
|
|
|
|||
|
|
@ -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 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 {
|
||||
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,9 +533,40 @@ func run(logger *zap.Logger) error {
|
|||
account.WithEmailSignup(cfg.EmailSignupEnable),
|
||||
account.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
|
||||
}
|
||||
var loginEmailSender mailpkg.Sender
|
||||
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 {
|
||||
loginEmailSender = mailpkg.NewSMTP(mailpkg.Config{
|
||||
switch cfg.EmailCodeDeliveryProvider {
|
||||
case "webhook":
|
||||
loginEmailSender = webhookSender
|
||||
default:
|
||||
loginEmailSender = otpsmtp.New(otpsmtp.Config{
|
||||
Host: cfg.SMTPHost,
|
||||
Port: cfg.SMTPPort,
|
||||
Username: cfg.SMTPUsername,
|
||||
|
|
@ -546,6 +576,7 @@ func run(logger *zap.Logger) error {
|
|||
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,
|
||||
|
|
@ -735,9 +776,16 @@ func run(logger *zap.Logger) error {
|
|||
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
|
||||
}, rpc.Deps{
|
||||
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: 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,
|
||||
|
|
@ -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 创建后注入。
|
||||
|
|
@ -848,7 +898,7 @@ func run(logger *zap.Logger) error {
|
|||
Logger: logger.Named("mtprotoedge"),
|
||||
DC: cfg.DC,
|
||||
RSAKey: rsaKey,
|
||||
RPC: router,
|
||||
LayerRPC: router,
|
||||
AuthKeys: authKeyStore,
|
||||
ActiveSessions: activeSessions,
|
||||
ObfuscatedTCP: true,
|
||||
|
|
@ -863,12 +913,19 @@ func run(logger *zap.Logger) error {
|
|||
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)),
|
||||
|
|
@ -877,5 +934,9 @@ func run(logger *zap.Logger) error {
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
14222
data/langpack/android/android_ar_v59981214.strings
Normal file
14222
data/langpack/android/android_ar_v59981214.strings
Normal file
File diff suppressed because it is too large
Load diff
13227
data/langpack/android/android_be_v59969518.strings
Normal file
13227
data/langpack/android/android_be_v59969518.strings
Normal file
File diff suppressed because it is too large
Load diff
11881
data/langpack/android/android_ca_v59967427.strings
Normal file
11881
data/langpack/android/android_ca_v59967427.strings
Normal file
File diff suppressed because it is too large
Load diff
13305
data/langpack/android/android_cs_v59969803.strings
Normal file
13305
data/langpack/android/android_cs_v59969803.strings
Normal file
File diff suppressed because it is too large
Load diff
11883
data/langpack/android/android_de_v59926882.strings
Normal file
11883
data/langpack/android/android_de_v59926882.strings
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2040,7 +2040,7 @@
|
|||
"BusinessBotNoAddedText" = "You haven’t added a bot to manage your account. Leave anyway?";
|
||||
"BusinessBotNoAddedTitle" = "No Bot Added";
|
||||
"BusinessBotNotFound" = "Chatbot not found";
|
||||
"BusinessBotNotSupportedMessage" = "This bot doesn’t support **Telegram Business** yet.";
|
||||
"BusinessBotNotSupportedMessage" = "This bot doesn’t support **Secretary Mode** yet.";
|
||||
"BusinessBotNotSupportedTitle" = "Oops";
|
||||
"BusinessBotPermissions" = "Bot permissions";
|
||||
"BusinessBotPermissionsGiftsSection" = "Manage Gifts and Stars";
|
||||
|
|
@ -11646,10 +11646,3 @@
|
|||
"messages#one" = "%1$d message";
|
||||
"messages#other" = "%1$d messages";
|
||||
"telegram_passport" = "Telegram Passport";
|
||||
"FilterByUser" = "Filter by user";
|
||||
"Gift" = "Gift";
|
||||
"GiftDiscount" = "-%1$d%";
|
||||
"Giveaway" = "Giveaway";
|
||||
"Password" = "Password";
|
||||
"Previous" = "Previous";
|
||||
"Volume" = "Volume";
|
||||
11883
data/langpack/android/android_es_v59946392.strings
Normal file
11883
data/langpack/android/android_es_v59946392.strings
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
11531
data/langpack/android/android_fa_v59921144.strings
Normal file
11531
data/langpack/android/android_fa_v59921144.strings
Normal file
File diff suppressed because it is too large
Load diff
11864
data/langpack/android/android_fi_v59933749.strings
Normal file
11864
data/langpack/android/android_fi_v59933749.strings
Normal file
File diff suppressed because it is too large
Load diff
11861
data/langpack/android/android_fr_v59921990.strings
Normal file
11861
data/langpack/android/android_fr_v59921990.strings
Normal file
File diff suppressed because it is too large
Load diff
13310
data/langpack/android/android_he_v59966306.strings
Normal file
13310
data/langpack/android/android_he_v59966306.strings
Normal file
File diff suppressed because it is too large
Load diff
12610
data/langpack/android/android_hr_v59966087.strings
Normal file
12610
data/langpack/android/android_hr_v59966087.strings
Normal file
File diff suppressed because it is too large
Load diff
11861
data/langpack/android/android_hu_v59906304.strings
Normal file
11861
data/langpack/android/android_hu_v59906304.strings
Normal file
File diff suppressed because it is too large
Load diff
10976
data/langpack/android/android_id_v59866609.strings
Normal file
10976
data/langpack/android/android_id_v59866609.strings
Normal file
File diff suppressed because it is too large
Load diff
11891
data/langpack/android/android_it_v59967903.strings
Normal file
11891
data/langpack/android/android_it_v59967903.strings
Normal file
File diff suppressed because it is too large
Load diff
11867
data/langpack/android/android_kk_v59917391.strings
Normal file
11867
data/langpack/android/android_kk_v59917391.strings
Normal file
File diff suppressed because it is too large
Load diff
11221
data/langpack/android/android_ko_v59946838.strings
Normal file
11221
data/langpack/android/android_ko_v59946838.strings
Normal file
File diff suppressed because it is too large
Load diff
11201
data/langpack/android/android_ms_v59946976.strings
Normal file
11201
data/langpack/android/android_ms_v59946976.strings
Normal file
File diff suppressed because it is too large
Load diff
11870
data/langpack/android/android_nb_v59977190.strings
Normal file
11870
data/langpack/android/android_nb_v59977190.strings
Normal file
File diff suppressed because it is too large
Load diff
11885
data/langpack/android/android_nl_v59914009.strings
Normal file
11885
data/langpack/android/android_nl_v59914009.strings
Normal file
File diff suppressed because it is too large
Load diff
13055
data/langpack/android/android_pl_v59844900.strings
Normal file
13055
data/langpack/android/android_pl_v59844900.strings
Normal file
File diff suppressed because it is too large
Load diff
11919
data/langpack/android/android_pt-br_v59974228.strings
Normal file
11919
data/langpack/android/android_pt-br_v59974228.strings
Normal file
File diff suppressed because it is too large
Load diff
12553
data/langpack/android/android_ro_v59981171.strings
Normal file
12553
data/langpack/android/android_ro_v59981171.strings
Normal file
File diff suppressed because it is too large
Load diff
13343
data/langpack/android/android_ru_v59974213.strings
Normal file
13343
data/langpack/android/android_ru_v59974213.strings
Normal file
File diff suppressed because it is too large
Load diff
10928
data/langpack/android/android_sk_v59822247.strings
Normal file
10928
data/langpack/android/android_sk_v59822247.strings
Normal file
File diff suppressed because it is too large
Load diff
12613
data/langpack/android/android_sr_v59973181.strings
Normal file
12613
data/langpack/android/android_sr_v59973181.strings
Normal file
File diff suppressed because it is too large
Load diff
11844
data/langpack/android/android_sv_v59950998.strings
Normal file
11844
data/langpack/android/android_sv_v59950998.strings
Normal file
File diff suppressed because it is too large
Load diff
11862
data/langpack/android/android_tr_v59920319.strings
Normal file
11862
data/langpack/android/android_tr_v59920319.strings
Normal file
File diff suppressed because it is too large
Load diff
13316
data/langpack/android/android_uk_v59953439.strings
Normal file
13316
data/langpack/android/android_uk_v59953439.strings
Normal file
File diff suppressed because it is too large
Load diff
10876
data/langpack/android/android_uz_v59985112.strings
Normal file
10876
data/langpack/android/android_uz_v59985112.strings
Normal file
File diff suppressed because it is too large
Load diff
10827
data/langpack/android/android_vi_v59453700.strings
Normal file
10827
data/langpack/android/android_vi_v59453700.strings
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -743,7 +743,7 @@
|
|||
"ApproveNewMembersDescriptionFrozen" = "您无法对需要按月付费的链接启用管理员批准。";
|
||||
"ApproveNewMembersDisable" = "禁用";
|
||||
"ApproveNewMembersDisableForLinks#other" = "同时禁用此群组中 %1$d 个现有邀请链接的 **批准新成员** 功能?";
|
||||
"ApproveNewMembersDisableForLinksChannel#other" = "同时禁用此频道中 %1$d 个现有邀请链接的 **批准新成员** 功能?";
|
||||
"ApproveNewMembersDisableForLinksChannel#other" = "同时禁用此频道中 %1$d 个现有邀请链接的**批准新订阅**功能?";
|
||||
"ApproveNewMembersDisabledMessageChannel" = "新订阅者加入此频道前,将不再需要获得 %1$s 的批准。";
|
||||
"ApproveNewMembersDisabledMessageGroup" = "新成员在群内发消息无需再经 **%1$s** 批准。";
|
||||
"ApproveNewMembersEnable" = "启用";
|
||||
|
|
@ -873,7 +873,18 @@
|
|||
"AreYouSureUnsaveSingleMessage" = "您确定要从“我的收藏”中删除此消息吗?";
|
||||
"AreYouSureWebSessions" = "您确定要断开所有使用 Telegram 登录的网站吗?";
|
||||
"ArticleByAuthor" = "作者:%1$s";
|
||||
"ArticleCode" = "代码";
|
||||
"ArticleCommandAudio" = "音频";
|
||||
"ArticleCommandDivider" = "分隔线";
|
||||
"ArticleCommandImage" = "图片";
|
||||
"ArticleCommandMap" = "地图";
|
||||
"ArticleCommandToggle" = "切换";
|
||||
"ArticleCommandVideo" = "Video";
|
||||
"ArticleDateByAuthor" = "%2$s 作于 %1$s";
|
||||
"ArticleHintDetailsTitle" = "标题";
|
||||
"ArticleHintTitle" = "标题";
|
||||
"ArticleNone" = "无";
|
||||
"ArticleText" = "文字";
|
||||
"AskAQuestion" = "向我们提问";
|
||||
"AskAQuestionInfo" = "请注意:Telegram 的支持服务来自志愿者。我们会尽快回复,但仍可能需要等待一段时间。\n\n请前往<![CDATA[<a href=\"https://telegram.org/faq#general-questions\">Telegram 常见问题</a>]]>看看:那里有绝大多数问题的答案和重要的<![CDATA[<a href=\"https://telegram.org/faq#troubleshooting\">故障排除</a>]]>提示。";
|
||||
"AskButton" = "向志愿者提问";
|
||||
|
|
@ -1869,7 +1880,7 @@
|
|||
"BusinessAwayScheduleOutsideHours" = "不在营业时间";
|
||||
"BusinessAwaySend" = "发送离开消息";
|
||||
"BusinessAwayUnsavedChanges" = "您已更改企业离开留言设置,是否应用更改?";
|
||||
"BusinessBotChats" = "机器人可访问的聊天";
|
||||
"BusinessBotChats" = "聊天机器人可访问";
|
||||
"BusinessBotChats2" = "机器人可以访问的聊天";
|
||||
"BusinessBotChatsInfo" = "选择机器人将有权访问的聊天或整个聊天类型。";
|
||||
"BusinessBotChatsInfo2" = "选择机器人不会访问的聊天。";
|
||||
|
|
@ -2707,6 +2718,21 @@
|
|||
"CommentsNoNumber#other" = "评论";
|
||||
"CommentsTitle" = "评论";
|
||||
"CommonGroups#other" = "%1$d 个共同群组";
|
||||
"CommunityAddChatTitle" = "添加聊天";
|
||||
"CommunityAdministrators" = "管理员";
|
||||
"CommunityMenuRemoveBotFromCommunityConfirm" = "您确定要将从社群中移除此机器人吗?";
|
||||
"CommunityMenuRemoveChannelFromCommunityConfirm" = "您确定要将从社群中移除此群组吗?";
|
||||
"CommunityMenuRemoveGroupFromCommunityConfirm" = "您确定要将从社群中移除此群组吗?";
|
||||
"CommunityMenuSettings" = "设置";
|
||||
"CommunityPendingRequestDecline" = "拒绝";
|
||||
"CommunityServiceMessageChannelRemoved" = "**%1$s** 已从社群中移除此频道";
|
||||
"CommunityServiceMessageChannelRemovedUnknown" = "此频道已从社群中移除";
|
||||
"CommunityServiceMessageChannelYouRemoved" = "**You** 已从社群中移除此频道";
|
||||
"CommunityServiceMessageGroupRemoved" = "**%1$s** 已从社群中移除此群组";
|
||||
"CommunityWhoCanAddChatsAllMembers" = " 所有成员";
|
||||
"CommunityWhoCanAddChatsOnlyAdmins" = "仅限管理员";
|
||||
"CommunityYouAreAdminContinue" = "继续";
|
||||
"CommunityYouAreAdminNoThanks" = "不用了,谢谢";
|
||||
"CompatibilityChat" = "%1$s 正在使用旧版 Telegram,因此阅后即焚图片会以兼容模式显示。\n\n当 %2$s 更新 Telegram 后,若阅后即焚计时为 1 分钟或以内,对方可“长按以观看”;对方截屏时,您也会收到通知。";
|
||||
"ConferenceCallIncoming" = "呼入群组通话";
|
||||
"ConferenceCallMissed" = "未接群组通话";
|
||||
|
|
@ -2946,7 +2972,7 @@
|
|||
"DeleteAccountIfAwayFor3" = "保留";
|
||||
"DeleteAccountTitle" = "自动删除帐号";
|
||||
"DeleteAdditionalActions" = "更多选项";
|
||||
"DeleteAlertReaction" = "您确定要删除此用户的回应吗?";
|
||||
"DeleteAlertReaction" = "您确定要删除来自此用户的回应吗?";
|
||||
"DeleteAlertReactionAll" = "删除所有该用户的回应";
|
||||
"DeleteAll" = "删除所有";
|
||||
"DeleteAllCalls" = "删除所有通话";
|
||||
|
|
@ -4563,7 +4589,7 @@
|
|||
"GiftCraftSymbolChance#other" = "制作出的礼物将有 **%1$d%%** 的概率带有 **%2$s** 符号。";
|
||||
"GiftCraftText1" = "最多可添加 **4** 份礼物来制作全新的";
|
||||
"GiftCraftText2" = "**%1$s #%2$s**.\n\n如果制作失败,这些礼物 \n将会 **丟失**。";
|
||||
"GiftCraftTextEmpty1" = "最多可添加 **4** 份礼物来制作全新的";
|
||||
"GiftCraftTextEmpty1" = "至少添加 **4** 份礼物来制作一个新的";
|
||||
"GiftCraftTextEmpty2" = "**%s** 件礼物。\n\n如果制作失败,这些礼物 \n将会 **丟失**。";
|
||||
"GiftCraftTitle" = "制作礼物";
|
||||
"GiftCraftUnavailableText" = "这些礼物目前无法制作。";
|
||||
|
|
@ -6968,6 +6994,9 @@
|
|||
"PmReadTodayAt" = "%s 已读";
|
||||
"PmReadUnknown" = "未知已读时间";
|
||||
"PmReadYesterdayAt" = "昨天 %s 已读";
|
||||
"PmSentDateTimeAt" = "已发送于 %1$s %2$s";
|
||||
"PmSentTodayAt" = "已发送于 %s";
|
||||
"PmSentYesterdayAt" = "已发送于昨天 %s";
|
||||
"Points#other" = "%1$d";
|
||||
"Poll" = "投票";
|
||||
"PollAddAnOption" = "添加选项";
|
||||
|
|
@ -7173,6 +7202,7 @@
|
|||
"PremiumPreviewReactions2" = "无限表情回应";
|
||||
"PremiumPreviewReactions2Description" = "用数千个表情作出回应——可使用多个表情回应单条消息。";
|
||||
"PremiumPreviewReactionsDescription" = "使用更多动画表情回应消息,仅高级版订阅者可用。";
|
||||
"PremiumPreviewRichEditorDescription" = "在消息中添加标题、表格、内嵌媒体和 AI 内容。";
|
||||
"PremiumPreviewSharingDisable" = "禁用分享";
|
||||
"PremiumPreviewSharingDisableDescription" = "禁用私聊中的截屏、收藏和转发消息功能。";
|
||||
"PremiumPreviewStickers" = "高级版贴纸";
|
||||
|
|
@ -7907,7 +7937,7 @@
|
|||
"ResellGiftInfoMinTON" = "最低价格为 %s Gram";
|
||||
"ResellGiftInfoTON" = "您将收到 **%s** Gram。";
|
||||
"ResellGiftPriceHintOnlyTON" = "如果买家使用 Gram 付款,则不存在买家要求退款的风险,\n这是与星星付款的不同之处。";
|
||||
"ResellGiftPriceOnlyTON" = "只接受以 Gram 付款";
|
||||
"ResellGiftPriceOnlyTON" = "仅接受 Gram";
|
||||
"ResellGiftPriceTitle" = "以星星作为单位输入价格";
|
||||
"ResellGiftPriceTitleTON" = "输入 Gram 价格";
|
||||
"ResellGiftPriceTooMuch" = "您最高可以报价 %s";
|
||||
|
|
@ -10805,7 +10835,7 @@
|
|||
"WebBrowserExceptionsLimitMessage" = "\n网站例外数量已达上限。请先移除部分现有例外,再添加新的。";
|
||||
"WebBrowserExceptionsLimitTitle" = "已达上限";
|
||||
"WebBrowserShowCloseButton" = "显示关闭按钮";
|
||||
"WebBrowserShowCloseButtonInfo" = "浏览网页时添加关闭按钮";
|
||||
"WebBrowserShowCloseButtonInfo" = "在 Telegram 内打开外部浏览器。";
|
||||
"WebDownloadAlertInfo" = "开始下载 **%s**?";
|
||||
"WebDownloadAlertInfoWithSize" = "开始下载 **%1$s** (%2$s)?";
|
||||
"WebDownloadAlertTitle" = "下载文件";
|
||||
11201
data/langpack/android/android_zh-hant_v59979378.strings
Normal file
11201
data/langpack/android/android_zh-hant_v59979378.strings
Normal file
File diff suppressed because it is too large
Load diff
6483
data/langpack/android_x/android_x_ar_v1623895.strings
Normal file
6483
data/langpack/android_x/android_x_ar_v1623895.strings
Normal file
File diff suppressed because it is too large
Load diff
5067
data/langpack/android_x/android_x_be_v1623843.strings
Normal file
5067
data/langpack/android_x/android_x_be_v1623843.strings
Normal file
File diff suppressed because it is too large
Load diff
5168
data/langpack/android_x/android_x_ca_v1621108.strings
Normal file
5168
data/langpack/android_x/android_x_ca_v1621108.strings
Normal file
File diff suppressed because it is too large
Load diff
6150
data/langpack/android_x/android_x_cs_v1623534.strings
Normal file
6150
data/langpack/android_x/android_x_cs_v1623534.strings
Normal file
File diff suppressed because it is too large
Load diff
3894
data/langpack/android_x/android_x_de_v1617403.strings
Normal file
3894
data/langpack/android_x/android_x_de_v1617403.strings
Normal file
File diff suppressed because it is too large
Load diff
5128
data/langpack/android_x/android_x_en_v1619065.strings
Normal file
5128
data/langpack/android_x/android_x_en_v1619065.strings
Normal file
File diff suppressed because it is too large
Load diff
3600
data/langpack/android_x/android_x_eo_v1581671.strings
Normal file
3600
data/langpack/android_x/android_x_eo_v1581671.strings
Normal file
File diff suppressed because it is too large
Load diff
5168
data/langpack/android_x/android_x_es_v1621303.strings
Normal file
5168
data/langpack/android_x/android_x_es_v1621303.strings
Normal file
File diff suppressed because it is too large
Load diff
5119
data/langpack/android_x/android_x_fa_v1618464.strings
Normal file
5119
data/langpack/android_x/android_x_fa_v1618464.strings
Normal file
File diff suppressed because it is too large
Load diff
5168
data/langpack/android_x/android_x_fi_v1623250.strings
Normal file
5168
data/langpack/android_x/android_x_fi_v1623250.strings
Normal file
File diff suppressed because it is too large
Load diff
5168
data/langpack/android_x/android_x_fr_v1623248.strings
Normal file
5168
data/langpack/android_x/android_x_fr_v1623248.strings
Normal file
File diff suppressed because it is too large
Load diff
6152
data/langpack/android_x/android_x_he_v1623787.strings
Normal file
6152
data/langpack/android_x/android_x_he_v1623787.strings
Normal file
File diff suppressed because it is too large
Load diff
5451
data/langpack/android_x/android_x_hr_v1623294.strings
Normal file
5451
data/langpack/android_x/android_x_hr_v1623294.strings
Normal file
File diff suppressed because it is too large
Load diff
5120
data/langpack/android_x/android_x_hu_v1620856.strings
Normal file
5120
data/langpack/android_x/android_x_hu_v1620856.strings
Normal file
File diff suppressed because it is too large
Load diff
5168
data/langpack/android_x/android_x_it_v1621269.strings
Normal file
5168
data/langpack/android_x/android_x_it_v1621269.strings
Normal file
File diff suppressed because it is too large
Load diff
5168
data/langpack/android_x/android_x_kk_v1623033.strings
Normal file
5168
data/langpack/android_x/android_x_kk_v1623033.strings
Normal file
File diff suppressed because it is too large
Load diff
4676
data/langpack/android_x/android_x_ko_v1621784.strings
Normal file
4676
data/langpack/android_x/android_x_ko_v1621784.strings
Normal file
File diff suppressed because it is too large
Load diff
3190
data/langpack/android_x/android_x_ml_v1555695.strings
Normal file
3190
data/langpack/android_x/android_x_ml_v1555695.strings
Normal file
File diff suppressed because it is too large
Load diff
4676
data/langpack/android_x/android_x_ms_v1623219.strings
Normal file
4676
data/langpack/android_x/android_x_ms_v1623219.strings
Normal file
File diff suppressed because it is too large
Load diff
5168
data/langpack/android_x/android_x_nb_v1623870.strings
Normal file
5168
data/langpack/android_x/android_x_nb_v1623870.strings
Normal file
File diff suppressed because it is too large
Load diff
5757
data/langpack/android_x/android_x_pl_v1621110.strings
Normal file
5757
data/langpack/android_x/android_x_pl_v1621110.strings
Normal file
File diff suppressed because it is too large
Load diff
5168
data/langpack/android_x/android_x_pt-br_v1623582.strings
Normal file
5168
data/langpack/android_x/android_x_pt-br_v1623582.strings
Normal file
File diff suppressed because it is too large
Load diff
5653
data/langpack/android_x/android_x_ro_v1621247.strings
Normal file
5653
data/langpack/android_x/android_x_ro_v1621247.strings
Normal file
File diff suppressed because it is too large
Load diff
6152
data/langpack/android_x/android_x_ru_v1620897.strings
Normal file
6152
data/langpack/android_x/android_x_ru_v1620897.strings
Normal file
File diff suppressed because it is too large
Load diff
5396
data/langpack/android_x/android_x_sk_v1618398.strings
Normal file
5396
data/langpack/android_x/android_x_sk_v1618398.strings
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue