Merge remote-tracking branch 'upstream/main' into dev

This commit is contained in:
onysd 2026-07-18 09:19:27 +03:00
commit 6b29556ef8
836 changed files with 1598388 additions and 64684 deletions

View file

@ -27,6 +27,14 @@ TELESRV_MTPROTO_RPC_TIMEOUT=30s
TELESRV_MTPROTO_RPC_GLOBAL_WORKERS=256 TELESRV_MTPROTO_RPC_GLOBAL_WORKERS=256
TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=8192 TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=8192
TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES=536870912 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. # Process-wide in-flight transport wire + decrypted plaintext reservation.
TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES=536870912 TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES=536870912
# Per-connection outbound mailboxes (normal/control) and process-wide resend pending bodies. # 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). # Concurrent encrypted wire/codec/obfuscation scratch (shared bounded pool, not per connection).
TELESRV_MTPROTO_OUTBOUND_WRITE_GLOBAL_MAX_BYTES=536870912 TELESRV_MTPROTO_OUTBOUND_WRITE_GLOBAL_MAX_BYTES=536870912
# Optional login-email verification. When enabled, accounts with a confirmed # OTP delivery routing. "development" preserves the fixed phone code. "webhook"
# login email receive login codes by email; REQUIRE_SETUP also forces new/legacy # 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. # accounts without a login email to set one during the phone login flow.
TELESRV_LOGIN_EMAIL_ENABLE=false TELESRV_LOGIN_EMAIL_ENABLE=false
TELESRV_LOGIN_EMAIL_REQUIRE_SETUP=false TELESRV_LOGIN_EMAIL_REQUIRE_SETUP=false
TELESRV_LOGIN_EMAIL_CODE_LENGTH=6 TELESRV_LOGIN_EMAIL_CODE_LENGTH=6
TELESRV_EMAIL_CODE_DELIVERY_PROVIDER=smtp
TELESRV_SMTP_HOST= TELESRV_SMTP_HOST=
TELESRV_SMTP_PORT=587 TELESRV_SMTP_PORT=587
TELESRV_SMTP_USERNAME= TELESRV_SMTP_USERNAME=
@ -194,5 +214,8 @@ TELESRV_TRANSLATION_RATE_WINDOW=1m
# TELESRV_AI_KIMI_TEMPERATURE=0.6 # TELESRV_AI_KIMI_TEMPERATURE=0.6
# Business automation reply provider: # 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 TELESRV_BUSINESS_AI_PROVIDER=echo

View file

@ -6,14 +6,24 @@
**Your own private messaging server — self-hosted, protocol-compatible, fully yours.** **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 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 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 be reachable anywhere in the world. Your data, your keys, your rules — no
cloud, no lock-in, no censorship. 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. 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 | | 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. | | ✅ | 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. | | ✅ | 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. | | ✅ | 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. | | ✅ | 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. | | ✅ | 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. | | ✅ | 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. | | ✅ | 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. | | ✅ | 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. | | ✅ | 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 A stock Telegram client will not connect, since it only trusts Telegram's own
DC list and RSA keys. 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** **1. Export your server's public key**
After the server generates `data/server_rsa.pem`, export the matching public After the server generates `data/server_rsa.pem`, export the matching public

View file

@ -4,6 +4,11 @@
它是一个 Telegram-like backend面向真实客户端兼容、自建聊天实验、协议研究 它是一个 Telegram-like backend面向真实客户端兼容、自建聊天实验、协议研究
以及一条长期可演进的社区 server 路线。 以及一条长期可演进的社区 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 server 实现**、**MTProto server 实现**、
**Telegram 后端**、**Telegram clone server**、**自建 Telegram-like 聊天服务器** **Telegram 后端**、**Telegram clone server**、**自建 Telegram-like 聊天服务器**
这个仓库就是可以运行、研究和共同优化的 server 侧实现。 这个仓库就是可以运行、研究和共同优化的 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 相关路径。 | | ✅ | 登录与账号 | 开发验证码登录、sign-in、sign-up、log-out、授权设备、账号设置、SRP/password 状态、email/passkey 相关路径。 |
| ✅ | 用户与联系人 | 用户资料、username、头像、联系人导入/搜索、block/privacy 状态、presence、last seen。 | | ✅ | 用户与联系人 | 用户资料、username、头像、联系人导入/搜索、block/privacy 状态、presence、last seen。 |
| ✅ | 会话与同步 | dialog list、置顶、手动未读、folders/filters、草稿、read boundary、durable updates、在线 fan-out、离线 difference 恢复。 | | ✅ | 会话与同步 | 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、公开搜索和预览。 | | ✅ | 超级群与频道 | create、join、leave、邀请链接、成员、管理员、forum topics、关联讨论组 guest 访问、history、send/edit/delete/read、reactions、公开搜索和预览。 |
| ✅ | 媒体与文件 | upload、download、本地 blob 存储、照片、文档、缩略图、规范 GIFv 转换、外链媒体抓取、网页预览、地图缩略图缓存、用户/频道头像。 | | ✅ | 媒体与文件 | upload、download、本地 blob 存储、照片、文档、缩略图、规范 GIFv 转换、外链媒体抓取、网页预览、地图缩略图缓存、用户/频道头像。 |
| ✅ | Stickers 与 Reactions | sticker/reaction catalog、seed 支持、saved GIFs、recent reactions、top reactions、default reactions、reaction moderation 相关路径。 | | ✅ | 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 工具。 | | ✅ | 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。 | | ✅ | 通话与直播 | 私聊通话信令基础、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。 | | ✅ | 管理与运维 | 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 基线:
- Telegram Desktop commit`9caf32dffc90ddd9bb08ad5777b865f729fa167b` - Telegram Desktop commit`9caf32dffc90ddd9bb08ad5777b865f729fa167b`
- TL layer227 - Canonical TL layer228
- Exact compatibility profilesLayer 225-228
- 本地 DC`127.0.0.1:2398`DC id `2` - 本地 DC`127.0.0.1:2398`DC id `2`
`gramsrv` 生成 `data/server_rsa.pem` 后,导出匹配的公钥: `gramsrv` 生成 `data/server_rsa.pem` 后,导出匹配的公钥:

View file

@ -1,5 +1,5 @@
// Command appearancefetch 从官方 Telegram 拉取墙纸 + 聊天主题,下载文档/缩略图, // 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 沿用。 // 复用 internal/seed/appearance 的结构体保证 schema 完全一致。peer_colors 从现有 JSON 沿用。
// //
// 需登录(墙纸/主题接口非免登)。api 凭据用 TDesktop 开源公开的 id/hash。 // 需登录(墙纸/主题接口非免登)。api 凭据用 TDesktop 开源公开的 id/hash。
@ -21,10 +21,10 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/auth" "github.com/iamxvbaba/td/telegram/auth"
"github.com/gotd/td/telegram/downloader" "github.com/iamxvbaba/td/telegram/downloader"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"telesrv/internal/seed/appearance" "telesrv/internal/seed/appearance"
) )
@ -114,8 +114,8 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
} }
api := client.API() api := client.API()
docsDir := filepath.Join(outDir, "default_wallpapers", "documents") docsDir := filepath.Join(outDir, "Default_wallpapers", "documents")
thumbsDir := filepath.Join(outDir, "default_wallpapers", "thumbs", "m") thumbsDir := filepath.Join(outDir, "Default_wallpapers", "thumbs", "m")
if err := os.MkdirAll(docsDir, 0o755); err != nil { if err := os.MkdirAll(docsDir, 0o755); err != nil {
return err return err
} }
@ -169,7 +169,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
return appearance.Document{}, err return appearance.Document{}, err
} }
sum := sha256.Sum256(data) sum := sha256.Sum256(data)
out.Path = "default_wallpapers/documents/" + name out.Path = "Default_wallpapers/documents/" + name
out.SHA256 = hex.EncodeToString(sum[:]) out.SHA256 = hex.EncodeToString(sum[:])
// "m" 缩略图 // "m" 缩略图
for _, t := range doc.Thumbs { for _, t := range doc.Thumbs {
@ -187,7 +187,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
tsum := sha256.Sum256(tdata) tsum := sha256.Sum256(tdata)
out.Thumbs = append(out.Thumbs, appearance.PhotoSize{ out.Thumbs = append(out.Thumbs, appearance.PhotoSize{
Kind: "size", Type: "m", W: ps.W, H: ps.H, Size: ps.Size, 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 break
} }
@ -390,7 +390,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
if err != nil { if err != nil {
return err 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 { if err := os.WriteFile(jsonPath, out, 0o644); err != nil {
return err return err
} }

View file

@ -29,14 +29,14 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/mtproxy" "github.com/iamxvbaba/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator" "github.com/iamxvbaba/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/dcs" "github.com/iamxvbaba/td/telegram/dcs"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/mtprotoedge" "telesrv/internal/mtprotoedge"
) )

View file

@ -50,15 +50,15 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/mtproxy" "github.com/iamxvbaba/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator" "github.com/iamxvbaba/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/dcs" "github.com/iamxvbaba/td/telegram/dcs"
"github.com/gotd/td/telegram/uploader" "github.com/iamxvbaba/td/telegram/uploader"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/mtprotoedge" "telesrv/internal/mtprotoedge"
) )

View file

@ -19,8 +19,8 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
) )
const ( const (

View file

@ -24,14 +24,14 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/mtproxy" "github.com/iamxvbaba/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator" "github.com/iamxvbaba/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/dcs" "github.com/iamxvbaba/td/telegram/dcs"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/mtprotoedge" "telesrv/internal/mtprotoedge"
) )

View file

@ -3,21 +3,28 @@
// 开源公开的 id/hash。 // 开源公开的 id/hash。
// //
// 用法: // 用法:
// langpackfetch languages [pack] 列出某 pack(默认 android)的可用语言 //
// langpackfetch <out_dir> <langCode> [pack...] 拉取语言包(默认 packs = android ios macos) // langpackfetch languages [pack] 列出某 pack(默认 android)的可用语言
// langpackfetch all <out_dir> [pack...] 拉取所有官方语言及 manifest
// langpackfetch <out_dir> <langCode> [pack...] 拉取语言包(默认 packs = android ios macos)
package main package main
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
) )
const ( const (
@ -25,13 +32,46 @@ const (
tdesktopAPIHash = "344583e45741c457fe1862106095a5eb" 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() { func main() {
if len(os.Args) < 2 { 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) os.Exit(2)
} }
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel() defer cancel()
client := telegram.NewClient(tdesktopAPIID, tdesktopAPIHash, telegram.Options{}) client := telegram.NewClient(tdesktopAPIID, tdesktopAPIHash, telegram.Options{})
@ -54,6 +94,16 @@ func main() {
} }
return nil 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] outRoot := os.Args[1]
langCode := "en" langCode := "en"
@ -73,7 +123,7 @@ func main() {
fmt.Fprintf(os.Stderr, "skip %s/%s: %v\n", pack, langCode, err) fmt.Fprintf(os.Stderr, "skip %s/%s: %v\n", pack, langCode, err)
continue continue
} }
if err := writePack(outRoot, pack, diff); err != nil { if _, err := writePack(outRoot, pack, diff); err != nil {
return err 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) dir := filepath.Join(root, pack)
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return err return writtenPack{}, err
} }
var b strings.Builder var b strings.Builder
count := 0 count := 0
@ -115,13 +243,51 @@ func writePack(root, pack string, diff *tg.LangPackDifference) error {
} }
langCode := diff.LangCode langCode := diff.LangCode
if 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) name := fmt.Sprintf("%s_%s_v%d.strings", pack, langCode, diff.Version)
path := filepath.Join(dir, name) 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 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 return nil
} }

View 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)
}
}

View 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.

View 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)
}

View 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
}

View file

@ -28,14 +28,14 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/mtproxy" "github.com/iamxvbaba/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator" "github.com/iamxvbaba/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/dcs" "github.com/iamxvbaba/td/telegram/dcs"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/mtprotoedge" "telesrv/internal/mtprotoedge"
) )

View file

@ -27,9 +27,9 @@ import (
"strings" "strings"
"time" "time"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/downloader" "github.com/iamxvbaba/td/telegram/downloader"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
) )
const ( const (

View file

@ -6,7 +6,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
) )
func TestSpecToInputSystemSets(t *testing.T) { func TestSpecToInputSystemSets(t *testing.T) {

View file

@ -10,6 +10,8 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
) )
const ( const (
@ -63,6 +65,9 @@ type AccountDetail struct {
type RestrictionRow struct { type RestrictionRow struct {
Frozen bool Frozen bool
Since *time.Time
Until *time.Time
AppealURL string
Reason string Reason string
Actor string Actor string
CommandID string CommandID string
@ -128,6 +133,60 @@ type ChannelDetail struct {
AuditLogs []AuditLogRow 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) { func (s *readStore) SearchAccounts(ctx context.Context, q string) ([]AccountRow, error) {
q = strings.TrimSpace(q) q = strings.TrimSpace(q)
if 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(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 COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
FROM users u 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 peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
LEFT JOIN auth a ON a.user_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 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 COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
FROM users u FROM users u
JOIN auth ON auth.user_id = u.id 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 LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
WHERE NOT u.is_bot 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)) 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(sb.balance, 0)::bigint, COALESCE(sb.granted, false),
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
FROM users u 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 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 LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
WHERE u.id = $1`, userID).Scan( 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) { func (s *readStore) restriction(ctx context.Context, userID int64) (RestrictionRow, bool, error) {
var r RestrictionRow var r RestrictionRow
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
SELECT frozen, reason, actor, command_id, updated_at SELECT frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
FROM account_send_restrictions FROM account_restrictions
WHERE user_id = $1`, userID).Scan(&r.Frozen, &r.Reason, &r.Actor, &r.CommandID, &r.UpdatedAt) 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 != nil {
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
return RestrictionRow{}, false, nil return RestrictionRow{}, false, nil

View file

@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"mime/multipart"
"net/http" "net/http"
"path" "path"
"strconv" "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/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", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI))) 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-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/grant-stars", s.requireAuthAPI(http.HandlerFunc(s.handleGrantStarsAPI)))
mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI))) mux.Handle("POST /api/actions/set-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/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-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/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) { mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
writeAPIError(w, http.StatusNotFound, "api route not found") 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())}) 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) { func (s *server) handleAccountsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil { if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured") 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) writeJSON(w, http.StatusOK, detail)
} }
type freezeSendAPIRequest struct { type setAccountFrozenAPIRequest struct {
CommandID string `json:"command_id"` CommandID string `json:"command_id"`
Reason string `json:"reason"` Reason string `json:"reason"`
Confirm bool `json:"confirm"` Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Frozen bool `json:"frozen"` 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) { func (s *server) handleSetAccountFrozenAPI(w http.ResponseWriter, r *http.Request) {
var body freezeSendAPIRequest var body setAccountFrozenAPIRequest
if !decodeAction(w, r, &body) { if !decodeAction(w, r, &body) {
return return
} }
req := admin.SetSendFrozenRequest{ req := admin.SetAccountFrozenRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "freeze-send"), CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-frozen"),
UserID: body.UserID, UserID: body.UserID,
Frozen: body.Frozen, 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) writeCommandResultAPI(w, result, err)
} }
@ -584,6 +692,184 @@ func (s *server) handleDeleteHistoryAPI(w http.ResponseWriter, r *http.Request)
writeCommandResultAPI(w, result, err) 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 { func (s *server) commandMetaFromAPI(r *http.Request, commandID, reason string, confirm bool, prefix string) admin.CommandMeta {
commandID = strings.TrimSpace(commandID) commandID = strings.TrimSpace(commandID)
if confirm && strings.HasPrefix(commandID, "dry-") { if confirm && strings.HasPrefix(commandID, "dry-") {
@ -635,6 +921,107 @@ func (s *server) callAdminAPI(ctx context.Context, apiPath string, payload any)
return result, nil 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 { func decodeAction(w http.ResponseWriter, r *http.Request, dst any) bool {
if err := decodeJSON(r, dst); err != nil { if err := decodeJSON(r, dst); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error()) writeAPIError(w, http.StatusBadRequest, err.Error())

View file

@ -1,11 +1,15 @@
package main package main
import ( import (
"context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time" "time"
"telesrv/internal/admin"
) )
func TestSignedSessionRoundTripAndTamper(t *testing.T) { func TestSignedSessionRoundTripAndTamper(t *testing.T) {
@ -48,3 +52,33 @@ func TestAdminAPIURLDefaultUsesAdminAPIPort(t *testing.T) {
t.Fatalf("adminAPIURL(empty) = %q, want %q", got, want) 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title> <title>telesrv admin</title>
<script type="module" crossorigin src="/assets/index-BHnkZ_za.js"></script> <script type="module" crossorigin src="/assets/index-Q8RNNOYL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-W1-UPxwc.css"> <link rel="stylesheet" crossorigin href="/assets/index-BaxMq_AT.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -8,6 +8,7 @@
"name": "telesrv-admin-ui", "name": "telesrv-admin-ui",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"lottie-web": "^5.13.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
@ -741,6 +742,12 @@
"loose-envify": "cli.js" "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": { "node_modules/lucide-react": {
"version": "0.468.0", "version": "0.468.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",

View file

@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"lottie-web": "^5.13.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"

View file

@ -7,7 +7,9 @@ import type {
GroupMessageDetail, GroupMessageDetail,
GroupMessageListResponse, GroupMessageListResponse,
MessageDetail, MessageDetail,
MessageListResponse MessageListResponse,
StarGiftCollectiblePreview,
StarGiftListResponse
} from "./types"; } from "./types";
export class APIError extends Error { export class APIError extends Error {
@ -20,12 +22,10 @@ export class APIError extends Error {
} }
async function request<T>(url: string, init: RequestInit = {}): Promise<T> { async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
const isForm = typeof FormData !== "undefined" && init.body instanceof FormData;
const response = await fetch(url, { const response = await fetch(url, {
credentials: "same-origin", credentials: "same-origin",
headers: { headers: isForm ? init.headers : { "Content-Type": "application/json", ...(init.headers ?? {}) },
"Content-Type": "application/json",
...(init.headers ?? {})
},
...init ...init
}); });
const text = await response.text(); const text = await response.text();
@ -65,6 +65,12 @@ export const api = {
const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) }); const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) });
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`); 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, { action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)

View file

@ -7,7 +7,8 @@ import {
Server, Server,
Shield, Shield,
ShieldCheck, ShieldCheck,
Users Users,
Gift
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api"; 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={<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={<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={<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" : ""}`}> <div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button <button
className="nav-section-toggle" className="nav-section-toggle"

View file

@ -61,12 +61,15 @@ const translations: Record<Language, Record<string, string>> = {
"route.dashboardSubtitle": "Console / Overview", "route.dashboardSubtitle": "Console / Overview",
"route.messages": "Message Audit", "route.messages": "Message Audit",
"route.messagesSubtitle": "Console / Messages", "route.messagesSubtitle": "Console / Messages",
"route.gifts": "Star Gifts",
"route.giftsSubtitle": "Console / Star Gifts",
"layout.navigation": "Navigation", "layout.navigation": "Navigation",
"layout.primaryNav": "Primary navigation", "layout.primaryNav": "Primary navigation",
"layout.dashboard": "Overview", "layout.dashboard": "Overview",
"layout.accounts": "Accounts", "layout.accounts": "Accounts",
"layout.channels": "Supergroups / Channels", "layout.channels": "Supergroups / Channels",
"layout.messages": "Messages", "layout.messages": "Messages",
"layout.gifts": "Star Gifts",
"layout.privateMessages": "Private", "layout.privateMessages": "Private",
"layout.groupMessages": "Groups", "layout.groupMessages": "Groups",
"layout.runtime": "Runtime", "layout.runtime": "Runtime",
@ -127,15 +130,21 @@ const translations: Record<Language, Record<string, string>> = {
"account.waitingData": "Waiting for data", "account.waitingData": "Waiting for data",
"account.noUsername": "No username", "account.noUsername": "No username",
"account.noPhone": "No phone", "account.noPhone": "No phone",
"account.sendFrozen": "Sending frozen", "account.accountFrozen": "Account frozen",
"account.sendNormal": "Sending allowed", "account.accountActive": "Account active",
"account.authorizationsTitle": "Authorized Devices", "account.authorizationsTitle": "Authorized Devices",
"account.authorizationsCount": "{count} authorizations", "account.authorizationsCount": "{count} authorizations",
"account.recentAdminOps": "Recent Admin Actions", "account.recentAdminOps": "Recent Admin Actions",
"account.recent30Audit": "Last 30 audit rows", "account.recent30Audit": "Last 30 audit rows",
"account.actionDock": "Account Actions", "account.actionDock": "Account Actions",
"account.freezeSend": "Freeze sending", "account.freezeAccount": "Freeze account",
"account.unfreezeSend": "Unfreeze sending", "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.premiumMonths": "Premium duration (months)",
"account.premiumMonthsAria": "Set premium duration in months", "account.premiumMonthsAria": "Set premium duration in months",
"account.setPremium": "Set premium", "account.setPremium": "Set premium",
@ -235,6 +244,81 @@ const translations: Record<Language, Record<string, string>> = {
"messages.channelGroup": "Channel / Group", "messages.channelGroup": "Channel / Group",
"messages.pinned": "Pinned", "messages.pinned": "Pinned",
"messages.channelPost": "Channel post", "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", "messages.msgIDsInvalid": "Message IDs are invalid",
"auth.device": "Device", "auth.device": "Device",
"auth.platform": "Platform", "auth.platform": "Platform",
@ -326,12 +410,15 @@ const translations: Record<Language, Record<string, string>> = {
"route.dashboardSubtitle": "控制台 / 总览", "route.dashboardSubtitle": "控制台 / 总览",
"route.messages": "消息审计", "route.messages": "消息审计",
"route.messagesSubtitle": "控制台 / 消息", "route.messagesSubtitle": "控制台 / 消息",
"route.gifts": "星星礼物",
"route.giftsSubtitle": "控制台 / 星星礼物",
"layout.navigation": "导航", "layout.navigation": "导航",
"layout.primaryNav": "主导航", "layout.primaryNav": "主导航",
"layout.dashboard": "总览", "layout.dashboard": "总览",
"layout.accounts": "账号", "layout.accounts": "账号",
"layout.channels": "超级群/频道", "layout.channels": "超级群/频道",
"layout.messages": "消息", "layout.messages": "消息",
"layout.gifts": "礼物目录",
"layout.privateMessages": "私聊", "layout.privateMessages": "私聊",
"layout.groupMessages": "群聊", "layout.groupMessages": "群聊",
"layout.runtime": "运行状态", "layout.runtime": "运行状态",
@ -392,15 +479,21 @@ const translations: Record<Language, Record<string, string>> = {
"account.waitingData": "等待数据", "account.waitingData": "等待数据",
"account.noUsername": "无用户名", "account.noUsername": "无用户名",
"account.noPhone": "无手机号", "account.noPhone": "无手机号",
"account.sendFrozen": "发消息冻结", "account.accountFrozen": "账号已冻结",
"account.sendNormal": "发送正常", "account.accountActive": "账号正常",
"account.authorizationsTitle": "授权设备", "account.authorizationsTitle": "授权设备",
"account.authorizationsCount": "共 {count} 个授权", "account.authorizationsCount": "共 {count} 个授权",
"account.recentAdminOps": "最近后台操作", "account.recentAdminOps": "最近后台操作",
"account.recent30Audit": "最近 30 条审计", "account.recent30Audit": "最近 30 条审计",
"account.actionDock": "账号操作", "account.actionDock": "账号操作",
"account.freezeSend": "冻结发消息", "account.freezeAccount": "冻结账号",
"account.unfreezeSend": "解冻发消息", "account.updateFreeze": "更新冻结信息",
"account.unfreezeAccount": "解冻账号",
"account.freezeSince": "冻结开始时间",
"account.freezeUntil": "申诉截止时间",
"account.freezeUntilAria": "账号冻结申诉截止时间",
"account.freezeAppealURL": "申诉链接",
"account.freezeAppealURLAria": "账号冻结申诉链接",
"account.premiumMonths": "会员时长(月)", "account.premiumMonths": "会员时长(月)",
"account.premiumMonthsAria": "设置会员时长,单位月", "account.premiumMonthsAria": "设置会员时长,单位月",
"account.setPremium": "设置会员", "account.setPremium": "设置会员",
@ -500,6 +593,81 @@ const translations: Record<Language, Record<string, string>> = {
"messages.channelGroup": "频道 / 群", "messages.channelGroup": "频道 / 群",
"messages.pinned": "置顶", "messages.pinned": "置顶",
"messages.channelPost": "频道帖子", "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 JSONLottie 会规范化并压缩成 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 无效", "messages.msgIDsInvalid": "消息 ID 无效",
"auth.device": "设备", "auth.device": "设备",
"auth.platform": "平台", "auth.platform": "平台",

View file

@ -0,0 +1,4 @@
declare module "lottie-web/build/player/lottie_light_canvas" {
import lottie from "lottie-web";
export default lottie;
}

View file

@ -16,12 +16,21 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [months, setMonths] = useState("1"); const [months, setMonths] = useState("1");
const [starsAmount, setStarsAmount] = useState("1000"); const [starsAmount, setStarsAmount] = useState("1000");
const [freezeUntil, setFreezeUntil] = useState(() => toDateTimeLocal(new Date(Date.now() + 7 * 86400_000)));
const [freezeAppealURL, setFreezeAppealURL] = useState("");
async function load() { async function load() {
setBusy(true); setBusy(true);
setError(""); setError("");
try { 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) { } catch (err) {
setError(errorMessage(err)); setError(errorMessage(err));
} finally { } finally {
@ -58,7 +67,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<div className="entity-badges"> <div className="entity-badges">
{account.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")}</Badge> : <Badge>{t("account.notPremium")}</Badge>} {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>} {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> </div>
</section> </section>
<div className="summary-grid"> <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.activeSessions")} value={String(detail.Authorizations.length)} />
<Summary label={t("account.accountFlags")} value={`support=${detail.Support} bot=${detail.Bot}`} /> <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.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) || "-"} /> <Summary label={t("account.createdAt")} value={formatDate(account.CreatedAt) || "-"} />
</div> </div>
{detail.About && <p className="about-text">{detail.About}</p>} {detail.About && <p className="about-text">{detail.About}</p>}
@ -86,13 +98,46 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
side={ side={
<section className="action-dock"> <section className="action-dock">
<div className="dock-title">{t("account.actionDock")}</div> <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 <ActionButton
label={account.Frozen ? t("account.unfreezeSend") : t("account.freezeSend")} label={account.Frozen ? t("account.updateFreeze") : t("account.freezeAccount")}
icon={<CircleAlert size={15} />} icon={<CircleAlert size={15} />}
path="/api/actions/freeze-send" path="/api/actions/set-frozen"
payload={() => ({ user_id: account.ID, frozen: !account.Frozen })} payload={() => ({
user_id: account.ID,
frozen: true,
freeze_until: new Date(freezeUntil).toISOString(),
freeze_appeal_url: freezeAppealURL.trim()
})}
onDone={load} 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"> <label className="duration-field">
<span>{t("account.premiumMonths")}</span> <span>{t("account.premiumMonths")}</span>
<input <input
@ -155,3 +200,8 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
</PageFrame> </PageFrame>
); );
} }
function toDateTimeLocal(date: Date): string {
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}

View 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);
}

View 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>
);
}

View file

@ -8,6 +8,7 @@ import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
import { GroupMessagesPage } from "./GroupMessagesPage"; import { GroupMessagesPage } from "./GroupMessagesPage";
import { MessageDetailPage } from "./MessageDetailPage"; import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage"; import { MessagesPage } from "./MessagesPage";
import { GiftsPage } from "./GiftsPage";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) { export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1]; 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") { if (route.path === "/channels") {
return <ChannelsPage navigate={navigate} />; return <ChannelsPage navigate={navigate} />;
} }
if (route.path === "/gifts") {
return <GiftsPage />;
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") { if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return ( return (
<MessageDetailPage <MessageDetailPage

View file

@ -20,6 +20,7 @@ export function routeTitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/accounts")) return t("route.accounts"); if (pathname.startsWith("/accounts")) return t("route.accounts");
if (pathname.startsWith("/channels")) return t("route.channels"); if (pathname.startsWith("/channels")) return t("route.channels");
if (pathname.startsWith("/messages")) return t("route.messages"); if (pathname.startsWith("/messages")) return t("route.messages");
if (pathname.startsWith("/gifts")) return t("route.gifts");
return t("route.dashboard"); 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("/accounts")) return t("route.accountsSubtitle");
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle"); if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
if (pathname.startsWith("/messages")) return t("route.messagesSubtitle"); if (pathname.startsWith("/messages")) return t("route.messagesSubtitle");
if (pathname.startsWith("/gifts")) return t("route.giftsSubtitle");
return t("route.dashboardSubtitle"); return t("route.dashboardSubtitle");
} }

View file

@ -62,6 +62,7 @@ a {
height: 100vh; height: 100vh;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
overflow-y: auto;
padding: 18px 12px; padding: 18px 12px;
color: #eef2f6; color: #eef2f6;
background: var(--sidebar); background: var(--sidebar);

View file

@ -252,3 +252,198 @@
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; 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; }
}

View file

@ -24,6 +24,11 @@
flex-direction: column; flex-direction: column;
} }
.command-modal > .modal-head,
.command-modal > .modal-actions {
flex: 0 0 auto;
}
.modal-head { .modal-head {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@ -52,6 +57,8 @@
.command-body { .command-body {
display: grid; display: grid;
min-height: 0;
grid-auto-rows: max-content;
gap: 12px; gap: 12px;
overflow: auto; overflow: auto;
padding: 14px 18px; padding: 14px 18px;

View file

@ -16,6 +16,9 @@ export type AccountRow = {
export type RestrictionRow = { export type RestrictionRow = {
Frozen: boolean; Frozen: boolean;
Since: string | null;
Until: string | null;
AppealURL: string;
Reason: string; Reason: string;
Actor: string; Actor: string;
CommandID: string; CommandID: string;
@ -157,6 +160,58 @@ export type OutboxRow = {
UpdatedAt: string; 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 = { export type MessageDetail = {
Message: MessageRow; Message: MessageRow;
MessageJSON: string; MessageJSON: string;

View file

@ -14,16 +14,16 @@ import (
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
tdcrypto "github.com/gotd/td/crypto" tdcrypto "github.com/iamxvbaba/td/crypto"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/mtproxy" "github.com/iamxvbaba/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator" "github.com/iamxvbaba/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/session" "github.com/iamxvbaba/td/session"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/dcs" "github.com/iamxvbaba/td/telegram/dcs"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/mtprotoedge" "telesrv/internal/mtprotoedge"
) )

View file

@ -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 package main
import ( import (
@ -18,9 +18,9 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
"github.com/gotd/td/clock" "github.com/iamxvbaba/td/clock"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
adminapp "telesrv/internal/admin" adminapp "telesrv/internal/admin"
"telesrv/internal/adminapi" "telesrv/internal/adminapi"
@ -55,8 +55,10 @@ import (
"telesrv/internal/botapi" "telesrv/internal/botapi"
"telesrv/internal/config" "telesrv/internal/config"
"telesrv/internal/domain" "telesrv/internal/domain"
mailpkg "telesrv/internal/mail"
"telesrv/internal/mtprotoedge" "telesrv/internal/mtprotoedge"
"telesrv/internal/otpdelivery"
otpsmtp "telesrv/internal/otpdelivery/smtp"
otpwebhook "telesrv/internal/otpdelivery/webhook"
"telesrv/internal/rpc" "telesrv/internal/rpc"
"telesrv/internal/seed/catalog" "telesrv/internal/seed/catalog"
"telesrv/internal/sfu" "telesrv/internal/sfu"
@ -297,7 +299,8 @@ func run(logger *zap.Logger) error {
return fmt.Errorf("parse listen port %q: %w", portStr, err) return fmt.Errorf("parse listen port %q: %w", portStr, err)
} }
// tg.Layer 来自 gotd/td v0.158.0Layer 227与目标 TDesktop 基线对齐。 // tg.Layer 由当前导入的 canonical schema 生成;纳入未来 Layer 后无需
// 在 telesrv 另维护一份常量。
logger.Info("telesrv 启动", logger.Info("telesrv 启动",
zap.String("listen", cfg.ListenAddr), zap.String("listen", cfg.ListenAddr),
zap.Int("dc", cfg.DC), zap.Int("dc", cfg.DC),
@ -347,11 +350,6 @@ func run(logger *zap.Logger) error {
defer func() { _ = rdb.Close() }() defer func() { _ = rdb.Close() }()
logger.Info("持久化依赖就绪", zap.String("redis", cfg.RedisAddr)) 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) authKeyStore := postgres.NewAuthKeyStore(pool)
userStore := postgres.NewUserStore(pool) userStore := postgres.NewUserStore(pool)
authzStore := postgres.NewAuthorizationStore(pool) authzStore := postgres.NewAuthorizationStore(pool)
@ -483,6 +481,7 @@ func run(logger *zap.Logger) error {
cfg.RetentionBatch, cfg.RetentionBatch,
).WithDispatchOutboxPoisonPolicy(cfg.OutboxPoisonRetention, cfg.OutboxPoisonCleanupInterval). ).WithDispatchOutboxPoisonPolicy(cfg.OutboxPoisonRetention, cfg.OutboxPoisonCleanupInterval).
WithBotAPIUpdateRetention(botAPIUpdateStore, cfg.BotAPIUpdateRetention). WithBotAPIUpdateRetention(botAPIUpdateStore, cfg.BotAPIUpdateRetention).
WithAuthKeySessionLayerRetention(authKeyStore).
WithLoginCodeDeliveryRetention(messageStore). WithLoginCodeDeliveryRetention(messageStore).
WithUserUpdateRetention(updateEventStore). WithUserUpdateRetention(updateEventStore).
WithChannelUpdateRetention(channelStore). WithChannelUpdateRetention(channelStore).
@ -534,18 +533,50 @@ func run(logger *zap.Logger) error {
account.WithEmailSignup(cfg.EmailSignupEnable), account.WithEmailSignup(cfg.EmailSignupEnable),
account.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes), account.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
} }
var loginEmailSender mailpkg.Sender var webhookSender otpdelivery.Sender
if cfg.LoginEmailEnable || cfg.EmailSignupEnable { // EmailSignupEnable also needs a sender: "email as identity" sign-up/login
loginEmailSender = mailpkg.NewSMTP(mailpkg.Config{ // codes go out on the same loginEmailSender channel as LoginEmailEnable
Host: cfg.SMTPHost, // (see auth.Service.emailSignupEnabled / account.sendChangePhoneCodeByEmail),
Port: cfg.SMTPPort, // so it must gate provider construction identically or those flows silently
Username: cfg.SMTPUsername, // get a nil sender when LoginEmailEnable itself is off.
Password: cfg.SMTPPassword, if cfg.PhoneCodeDeliveryProvider == "webhook" ||
From: cfg.SMTPFrom, ((cfg.LoginEmailEnable || cfg.EmailSignupEnable) && cfg.EmailCodeDeliveryProvider == "webhook") {
FromName: cfg.SMTPFromName, configured, err := otpwebhook.New(otpwebhook.Config{
TLSMode: cfg.SMTPTLSMode, URL: cfg.OTPWebhookURL,
Timeout: cfg.SMTPTimeout, Secret: cfg.OTPWebhookSecret,
Timeout: cfg.OTPWebhookTimeout,
Logger: logger.Named("otp").Named("webhook"),
}) })
if err != nil {
return fmt.Errorf("configure OTP webhook: %w", err)
}
webhookSender = configured
logger.Info("OTP Webhook 投递已启用",
zap.Bool("phone", cfg.PhoneCodeDeliveryProvider == "webhook"),
zap.Bool("email", (cfg.LoginEmailEnable || cfg.EmailSignupEnable) && cfg.EmailCodeDeliveryProvider == "webhook"))
}
var phoneCodeSender otpdelivery.Sender
if cfg.PhoneCodeDeliveryProvider == "webhook" {
phoneCodeSender = webhookSender
accountOptions = append(accountOptions, account.WithPhoneCodeDelivery(phoneCodeSender, cfg.PhoneCodeLength))
}
var loginEmailSender otpdelivery.Sender
if cfg.LoginEmailEnable || cfg.EmailSignupEnable {
switch cfg.EmailCodeDeliveryProvider {
case "webhook":
loginEmailSender = webhookSender
default:
loginEmailSender = otpsmtp.New(otpsmtp.Config{
Host: cfg.SMTPHost,
Port: cfg.SMTPPort,
Username: cfg.SMTPUsername,
Password: cfg.SMTPPassword,
From: cfg.SMTPFrom,
FromName: cfg.SMTPFromName,
TLSMode: cfg.SMTPTLSMode,
Timeout: cfg.SMTPTimeout,
})
}
accountOptions = append(accountOptions, accountOptions = append(accountOptions,
account.WithLoginEmailVerification(codeStore, loginEmailSender, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength)) account.WithLoginEmailVerification(codeStore, loginEmailSender, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength))
} }
@ -644,7 +675,9 @@ func run(logger *zap.Logger) error {
starsStore := postgres.NewStarsStore(pool) starsStore := postgres.NewStarsStore(pool)
starsService := stars.NewService(starsStore, stars.WithStartingGrant(cfg.StarsStartingGrant)) starsService := stars.NewService(starsStore, stars.WithStartingGrant(cfg.StarsStartingGrant))
starGiftStore := postgres.NewStarGiftStore(pool) 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 // Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
// 同属进程内一次性凭据,不跨实例)。 // 同属进程内一次性凭据,不跨实例)。
passkeyStore := postgres.NewPasskeyStore(pool) passkeyStore := postgres.NewPasskeyStore(pool)
@ -701,6 +734,14 @@ func run(logger *zap.Logger) error {
auth.WithPremiumGrant(cfg.PremiumGrantMonths), auth.WithPremiumGrant(cfg.PremiumGrantMonths),
auth.WithCodeTTL(cfg.AuthCodeTTL), auth.WithCodeTTL(cfg.AuthCodeTTL),
auth.WithCodeMaxAttempts(cfg.AuthCodeMaxAttempts), 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{ auth.WithLoginEmail(auth.LoginEmailOptions{
Enabled: cfg.LoginEmailEnable, Enabled: cfg.LoginEmailEnable,
RequireSetup: cfg.LoginEmailRequireSetup, RequireSetup: cfg.LoginEmailRequireSetup,
@ -734,39 +775,46 @@ func run(logger *zap.Logger) error {
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL, TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries, TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
}, rpc.Deps{ }, rpc.Deps{
Auth: authService, Auth: authService,
Account: accountService, AuthKeySessionLayers: authKeyStore,
Privacy: privacyService, Account: accountService,
Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken), help.WithEmailSignupEnable(cfg.EmailSignupEnable), help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes)), Privacy: privacyService,
AICompose: aiComposeService, Help: help.NewService(helpStore, helpStore,
Users: usersService, help.WithMapboxToken(cfg.MapboxToken),
Updates: updatesService, help.WithEmailSignupEnable(cfg.EmailSignupEnable),
BootstrapUpdates: bootstrapUpdateStore, help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
BotAPIUpdates: botAPIUpdateStore, help.WithAccountFreezeProvider(adminService),
Contacts: contactsService, ),
Dialogs: dialogsService, AccountFreeze: adminService,
Chatlists: chatlistsService, AICompose: aiComposeService,
Messages: messagesService, Users: usersService,
Translation: translationService, Updates: updatesService,
Channels: channelsService, BootstrapUpdates: bootstrapUpdateStore,
Files: filesService, BotAPIUpdates: botAPIUpdateStore,
Bots: botsService, Contacts: contactsService,
Polls: pollsapp.NewService(pollStore), Dialogs: dialogsService,
Stories: storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService)), Chatlists: chatlistsService,
Phone: phoneService, Messages: messagesService,
SecretChats: secretChatService, Translation: translationService,
Stars: starsService, Channels: channelsService,
Gifts: giftsService, Files: filesService,
Passkey: passkeyService, Bots: botsService,
Themes: themeService, Polls: pollsapp.NewService(pollStore),
GroupCalls: groupCallsService, Stories: storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService)),
LiveStreams: liveStreamDep(liveStreamService), Phone: phoneService,
SFU: sfuService, SecretChats: secretChatService,
TURN: turnService, Stars: starsService,
LangPack: langPackService, Gifts: giftsService,
Sessions: activeSessions, Passkey: passkeyService,
Inline: inlineRegistryStore, Themes: themeService,
Limiter: rateLimiter, GroupCalls: groupCallsService,
LiveStreams: liveStreamDep(liveStreamService),
SFU: sfuService,
TURN: turnService,
LangPack: langPackService,
Sessions: activeSessions,
Inline: inlineRegistryStore,
Limiter: rateLimiter,
}, logger.Named("rpc"), clock.System) }, logger.Named("rpc"), clock.System)
readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{ readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{
ReadModelVersions: readModelVersionStore, ReadModelVersions: readModelVersionStore,
@ -786,6 +834,7 @@ func run(logger *zap.Logger) error {
RPCProjections: router, RPCProjections: router,
BaseUsers: userCache, BaseUsers: userCache,
BotProfiles: botsService, BotProfiles: botsService,
StarGifts: giftsService,
}, logger.Named("store").Named("read-model-listener")) }, logger.Named("store").Named("read-model-listener"))
go readModelListener.Run(ctx) go readModelListener.Run(ctx)
activeSessions.SetLifecycleObserver(router) activeSessions.SetLifecycleObserver(router)
@ -799,6 +848,7 @@ func run(logger *zap.Logger) error {
Channels: channelsService, Channels: channelsService,
ChannelNotifier: router, ChannelNotifier: router,
Messages: messagesService, Messages: messagesService,
Gifts: giftsService,
}) })
// bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界), // bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界),
// router 创建后注入。 // router 创建后注入。
@ -845,37 +895,48 @@ func run(logger *zap.Logger) error {
} }
srv := mtprotoedge.New(mtprotoedge.Options{ srv := mtprotoedge.New(mtprotoedge.Options{
Logger: logger.Named("mtprotoedge"), Logger: logger.Named("mtprotoedge"),
DC: cfg.DC, DC: cfg.DC,
RSAKey: rsaKey, RSAKey: rsaKey,
RPC: router, LayerRPC: router,
AuthKeys: authKeyStore, AuthKeys: authKeyStore,
ActiveSessions: activeSessions, ActiveSessions: activeSessions,
ObfuscatedTCP: true, ObfuscatedTCP: true,
WebSocket: cfg.WebSocketEnable, WebSocket: cfg.WebSocketEnable,
WebSocketAllowedOrigins: cfg.WebSocketAllowedOrigins, WebSocketAllowedOrigins: cfg.WebSocketAllowedOrigins,
MaxConnections: cfg.MTProtoMaxConnections, MaxConnections: cfg.MTProtoMaxConnections,
MaxConnectionsPerIP: cfg.MTProtoMaxConnectionsPerIP, MaxConnectionsPerIP: cfg.MTProtoMaxConnectionsPerIP,
MaxConcurrentHandshakes: cfg.MTProtoMaxConcurrentHandshakes, MaxConcurrentHandshakes: cfg.MTProtoMaxConcurrentHandshakes,
RPCMaxInflight: cfg.MTProtoRPCMaxInflight, RPCMaxInflight: cfg.MTProtoRPCMaxInflight,
RPCQueueSize: cfg.MTProtoRPCQueueSize, RPCQueueSize: cfg.MTProtoRPCQueueSize,
RPCTimeout: cfg.MTProtoRPCTimeout, RPCTimeout: cfg.MTProtoRPCTimeout,
RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers, RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers,
RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks, RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks,
RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes, RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes,
InboundFrameGlobalMaxBytes: cfg.MTProtoInboundFrameGlobalMaxBytes, RPCResultCacheMaxEntries: cfg.MTProtoRPCResultCacheMaxEntries,
OutboundQueueSize: cfg.MTProtoOutboundQueueSize, RPCResultCacheMaxBytes: cfg.MTProtoRPCResultCacheMaxBytes,
OutboundControlQueueSize: cfg.MTProtoOutboundControlQueueSize, RPCResultCacheAuthMaxEntries: cfg.MTProtoRPCResultCacheAuthMaxEntries,
OutboundTrackedGlobalMaxBytes: cfg.MTProtoOutboundTrackedGlobalMaxBytes, RPCResultCacheAuthMaxBytes: cfg.MTProtoRPCResultCacheAuthMaxBytes,
OutboundWriteGlobalMaxBytes: cfg.MTProtoOutboundWriteGlobalMaxBytes, RPCResultCacheSessionMaxEntries: cfg.MTProtoRPCResultCacheSessionMaxEntries,
RPCResultCacheSessionMaxBytes: cfg.MTProtoRPCResultCacheSessionMaxBytes,
RPCResultPendingPerAuth: cfg.MTProtoRPCResultPendingPerAuth,
InboundFrameGlobalMaxBytes: cfg.MTProtoInboundFrameGlobalMaxBytes,
OutboundQueueSize: cfg.MTProtoOutboundQueueSize,
OutboundControlQueueSize: cfg.MTProtoOutboundControlQueueSize,
OutboundTrackedGlobalMaxBytes: cfg.MTProtoOutboundTrackedGlobalMaxBytes,
OutboundWriteGlobalMaxBytes: cfg.MTProtoOutboundWriteGlobalMaxBytes,
OnServing: func(_ net.Addr) {
logger.Info("telesrv 服务就绪",
zap.String("listen", cfg.ListenAddr),
zap.String("advertise", net.JoinHostPort(cfg.AdvertiseIP, portStr)),
zap.Int("pid", os.Getpid()),
zap.String("git_commit", buildMeta.Commit),
zap.Uint("schema_version", migrationStatus.Version),
zap.String("blob_backend", "localfs"),
)
},
}) })
logger.Info("telesrv 服务就绪", // This is intentionally the final startup operation. ListenAndServe owns the
zap.String("listen", cfg.ListenAddr), // public listener so no seed/prewarm work can run after port 2398 is exposed.
zap.String("advertise", net.JoinHostPort(cfg.AdvertiseIP, portStr)), return srv.ListenAndServe(ctx, cfg.ListenAddr)
zap.Int("pid", os.Getpid()),
zap.String("git_commit", buildMeta.Commit),
zap.Uint("schema_version", migrationStatus.Version),
zap.String("blob_backend", "localfs"),
)
return srv.Serve(ctx, ln)
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2040,7 +2040,7 @@
"BusinessBotNoAddedText" = "You havent added a bot to manage your account. Leave anyway?"; "BusinessBotNoAddedText" = "You havent added a bot to manage your account. Leave anyway?";
"BusinessBotNoAddedTitle" = "No Bot Added"; "BusinessBotNoAddedTitle" = "No Bot Added";
"BusinessBotNotFound" = "Chatbot not found"; "BusinessBotNotFound" = "Chatbot not found";
"BusinessBotNotSupportedMessage" = "This bot doesnt support **Telegram Business** yet."; "BusinessBotNotSupportedMessage" = "This bot doesnt support **Secretary Mode** yet.";
"BusinessBotNotSupportedTitle" = "Oops"; "BusinessBotNotSupportedTitle" = "Oops";
"BusinessBotPermissions" = "Bot permissions"; "BusinessBotPermissions" = "Bot permissions";
"BusinessBotPermissionsGiftsSection" = "Manage Gifts and Stars"; "BusinessBotPermissionsGiftsSection" = "Manage Gifts and Stars";
@ -11646,10 +11646,3 @@
"messages#one" = "%1$d message"; "messages#one" = "%1$d message";
"messages#other" = "%1$d messages"; "messages#other" = "%1$d messages";
"telegram_passport" = "Telegram Passport"; "telegram_passport" = "Telegram Passport";
"FilterByUser" = "Filter by user";
"Gift" = "Gift";
"GiftDiscount" = "-%1$d%";
"Giveaway" = "Giveaway";
"Password" = "Password";
"Previous" = "Previous";
"Volume" = "Volume";

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -743,7 +743,7 @@
"ApproveNewMembersDescriptionFrozen" = "您无法对需要按月付费的链接启用管理员批准。"; "ApproveNewMembersDescriptionFrozen" = "您无法对需要按月付费的链接启用管理员批准。";
"ApproveNewMembersDisable" = "禁用"; "ApproveNewMembersDisable" = "禁用";
"ApproveNewMembersDisableForLinks#other" = "同时禁用此群组中 %1$d 个现有邀请链接的 **批准新成员** 功能?"; "ApproveNewMembersDisableForLinks#other" = "同时禁用此群组中 %1$d 个现有邀请链接的 **批准新成员** 功能?";
"ApproveNewMembersDisableForLinksChannel#other" = "同时禁用此频道中 %1$d 个现有邀请链接的 **批准新成员** 功能?"; "ApproveNewMembersDisableForLinksChannel#other" = "同时禁用此频道中 %1$d 个现有邀请链接的**批准新订阅**功能?";
"ApproveNewMembersDisabledMessageChannel" = "新订阅者加入此频道前,将不再需要获得 %1$s 的批准。"; "ApproveNewMembersDisabledMessageChannel" = "新订阅者加入此频道前,将不再需要获得 %1$s 的批准。";
"ApproveNewMembersDisabledMessageGroup" = "新成员在群内发消息无需再经 **%1$s** 批准。"; "ApproveNewMembersDisabledMessageGroup" = "新成员在群内发消息无需再经 **%1$s** 批准。";
"ApproveNewMembersEnable" = "启用"; "ApproveNewMembersEnable" = "启用";
@ -873,7 +873,18 @@
"AreYouSureUnsaveSingleMessage" = "您确定要从“我的收藏”中删除此消息吗?"; "AreYouSureUnsaveSingleMessage" = "您确定要从“我的收藏”中删除此消息吗?";
"AreYouSureWebSessions" = "您确定要断开所有使用 Telegram 登录的网站吗?"; "AreYouSureWebSessions" = "您确定要断开所有使用 Telegram 登录的网站吗?";
"ArticleByAuthor" = "作者:%1$s"; "ArticleByAuthor" = "作者:%1$s";
"ArticleCode" = "代码";
"ArticleCommandAudio" = "音频";
"ArticleCommandDivider" = "分隔线";
"ArticleCommandImage" = "图片";
"ArticleCommandMap" = "地图";
"ArticleCommandToggle" = "切换";
"ArticleCommandVideo" = "Video";
"ArticleDateByAuthor" = "%2$s 作于 %1$s"; "ArticleDateByAuthor" = "%2$s 作于 %1$s";
"ArticleHintDetailsTitle" = "标题";
"ArticleHintTitle" = "标题";
"ArticleNone" = "无";
"ArticleText" = "文字";
"AskAQuestion" = "向我们提问"; "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>]]>提示。"; "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" = "向志愿者提问"; "AskButton" = "向志愿者提问";
@ -1869,7 +1880,7 @@
"BusinessAwayScheduleOutsideHours" = "不在营业时间"; "BusinessAwayScheduleOutsideHours" = "不在营业时间";
"BusinessAwaySend" = "发送离开消息"; "BusinessAwaySend" = "发送离开消息";
"BusinessAwayUnsavedChanges" = "您已更改企业离开留言设置,是否应用更改?"; "BusinessAwayUnsavedChanges" = "您已更改企业离开留言设置,是否应用更改?";
"BusinessBotChats" = "机器人可访问的聊天"; "BusinessBotChats" = "聊天机器人可访问";
"BusinessBotChats2" = "机器人可以访问的聊天"; "BusinessBotChats2" = "机器人可以访问的聊天";
"BusinessBotChatsInfo" = "选择机器人将有权访问的聊天或整个聊天类型。"; "BusinessBotChatsInfo" = "选择机器人将有权访问的聊天或整个聊天类型。";
"BusinessBotChatsInfo2" = "选择机器人不会访问的聊天。"; "BusinessBotChatsInfo2" = "选择机器人不会访问的聊天。";
@ -2707,6 +2718,21 @@
"CommentsNoNumber#other" = "评论"; "CommentsNoNumber#other" = "评论";
"CommentsTitle" = "评论"; "CommentsTitle" = "评论";
"CommonGroups#other" = "%1$d 个共同群组"; "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 分钟或以内,对方可“长按以观看”;对方截屏时,您也会收到通知。"; "CompatibilityChat" = "%1$s 正在使用旧版 Telegram因此阅后即焚图片会以兼容模式显示。\n\n当 %2$s 更新 Telegram 后,若阅后即焚计时为 1 分钟或以内,对方可“长按以观看”;对方截屏时,您也会收到通知。";
"ConferenceCallIncoming" = "呼入群组通话"; "ConferenceCallIncoming" = "呼入群组通话";
"ConferenceCallMissed" = "未接群组通话"; "ConferenceCallMissed" = "未接群组通话";
@ -2946,7 +2972,7 @@
"DeleteAccountIfAwayFor3" = "保留"; "DeleteAccountIfAwayFor3" = "保留";
"DeleteAccountTitle" = "自动删除帐号"; "DeleteAccountTitle" = "自动删除帐号";
"DeleteAdditionalActions" = "更多选项"; "DeleteAdditionalActions" = "更多选项";
"DeleteAlertReaction" = "您确定要删除此用户的回应吗?"; "DeleteAlertReaction" = "您确定要删除来自此用户的回应吗?";
"DeleteAlertReactionAll" = "删除所有该用户的回应"; "DeleteAlertReactionAll" = "删除所有该用户的回应";
"DeleteAll" = "删除所有"; "DeleteAll" = "删除所有";
"DeleteAllCalls" = "删除所有通话"; "DeleteAllCalls" = "删除所有通话";
@ -4563,7 +4589,7 @@
"GiftCraftSymbolChance#other" = "制作出的礼物将有 **%1$d%%** 的概率带有 **%2$s** 符号。"; "GiftCraftSymbolChance#other" = "制作出的礼物将有 **%1$d%%** 的概率带有 **%2$s** 符号。";
"GiftCraftText1" = "最多可添加 **4** 份礼物来制作全新的"; "GiftCraftText1" = "最多可添加 **4** 份礼物来制作全新的";
"GiftCraftText2" = "**%1$s #%2$s**.\n\n如果制作失败这些礼物 \n将会 **丟失**。"; "GiftCraftText2" = "**%1$s #%2$s**.\n\n如果制作失败这些礼物 \n将会 **丟失**。";
"GiftCraftTextEmpty1" = "最多可添加 **4** 份礼物来制作全新的"; "GiftCraftTextEmpty1" = "至少添加 **4** 份礼物来制作一个新的";
"GiftCraftTextEmpty2" = "**%s** 件礼物。\n\n如果制作失败这些礼物 \n将会 **丟失**。"; "GiftCraftTextEmpty2" = "**%s** 件礼物。\n\n如果制作失败这些礼物 \n将会 **丟失**。";
"GiftCraftTitle" = "制作礼物"; "GiftCraftTitle" = "制作礼物";
"GiftCraftUnavailableText" = "这些礼物目前无法制作。"; "GiftCraftUnavailableText" = "这些礼物目前无法制作。";
@ -6968,6 +6994,9 @@
"PmReadTodayAt" = "%s 已读"; "PmReadTodayAt" = "%s 已读";
"PmReadUnknown" = "未知已读时间"; "PmReadUnknown" = "未知已读时间";
"PmReadYesterdayAt" = "昨天 %s 已读"; "PmReadYesterdayAt" = "昨天 %s 已读";
"PmSentDateTimeAt" = "已发送于 %1$s %2$s";
"PmSentTodayAt" = "已发送于 %s";
"PmSentYesterdayAt" = "已发送于昨天 %s";
"Points#other" = "%1$d"; "Points#other" = "%1$d";
"Poll" = "投票"; "Poll" = "投票";
"PollAddAnOption" = "添加选项"; "PollAddAnOption" = "添加选项";
@ -7173,6 +7202,7 @@
"PremiumPreviewReactions2" = "无限表情回应"; "PremiumPreviewReactions2" = "无限表情回应";
"PremiumPreviewReactions2Description" = "用数千个表情作出回应——可使用多个表情回应单条消息。"; "PremiumPreviewReactions2Description" = "用数千个表情作出回应——可使用多个表情回应单条消息。";
"PremiumPreviewReactionsDescription" = "使用更多动画表情回应消息,仅高级版订阅者可用。"; "PremiumPreviewReactionsDescription" = "使用更多动画表情回应消息,仅高级版订阅者可用。";
"PremiumPreviewRichEditorDescription" = "在消息中添加标题、表格、内嵌媒体和 AI 内容。";
"PremiumPreviewSharingDisable" = "禁用分享"; "PremiumPreviewSharingDisable" = "禁用分享";
"PremiumPreviewSharingDisableDescription" = "禁用私聊中的截屏、收藏和转发消息功能。"; "PremiumPreviewSharingDisableDescription" = "禁用私聊中的截屏、收藏和转发消息功能。";
"PremiumPreviewStickers" = "高级版贴纸"; "PremiumPreviewStickers" = "高级版贴纸";
@ -7907,7 +7937,7 @@
"ResellGiftInfoMinTON" = "最低价格为 %s Gram"; "ResellGiftInfoMinTON" = "最低价格为 %s Gram";
"ResellGiftInfoTON" = "您将收到 **%s** Gram。"; "ResellGiftInfoTON" = "您将收到 **%s** Gram。";
"ResellGiftPriceHintOnlyTON" = "如果买家使用 Gram 付款,则不存在买家要求退款的风险,\n这是与星星付款的不同之处。"; "ResellGiftPriceHintOnlyTON" = "如果买家使用 Gram 付款,则不存在买家要求退款的风险,\n这是与星星付款的不同之处。";
"ResellGiftPriceOnlyTON" = "只接受以 Gram 付款"; "ResellGiftPriceOnlyTON" = "仅接受 Gram";
"ResellGiftPriceTitle" = "以星星作为单位输入价格"; "ResellGiftPriceTitle" = "以星星作为单位输入价格";
"ResellGiftPriceTitleTON" = "输入 Gram 价格"; "ResellGiftPriceTitleTON" = "输入 Gram 价格";
"ResellGiftPriceTooMuch" = "您最高可以报价 %s"; "ResellGiftPriceTooMuch" = "您最高可以报价 %s";
@ -10805,7 +10835,7 @@
"WebBrowserExceptionsLimitMessage" = "\n网站例外数量已达上限。请先移除部分现有例外再添加新的。"; "WebBrowserExceptionsLimitMessage" = "\n网站例外数量已达上限。请先移除部分现有例外再添加新的。";
"WebBrowserExceptionsLimitTitle" = "已达上限"; "WebBrowserExceptionsLimitTitle" = "已达上限";
"WebBrowserShowCloseButton" = "显示关闭按钮"; "WebBrowserShowCloseButton" = "显示关闭按钮";
"WebBrowserShowCloseButtonInfo" = "浏览网页时添加关闭按钮"; "WebBrowserShowCloseButtonInfo" = "在 Telegram 内打开外部浏览器。";
"WebDownloadAlertInfo" = "开始下载 **%s**?"; "WebDownloadAlertInfo" = "开始下载 **%s**?";
"WebDownloadAlertInfoWithSize" = "开始下载 **%1$s** (%2$s)?"; "WebDownloadAlertInfoWithSize" = "开始下载 **%1$s** (%2$s)?";
"WebDownloadAlertTitle" = "下载文件"; "WebDownloadAlertTitle" = "下载文件";

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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