chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

400
cmd/appearancefetch/main.go Normal file
View file

@ -0,0 +1,400 @@
// Command appearancefetch 从官方 Telegram 拉取墙纸 + 聊天主题,下载文档/缩略图,
// 生成 telesrv 外观 seed(default_appearance_seed.json + default_wallpapers/{documents,thumbs/m}/*.dat)。
// 复用 internal/seed/appearance 的结构体保证 schema 完全一致。peer_colors 从现有 JSON 沿用。
//
// 需登录(墙纸/主题接口非免登)。api 凭据用 TDesktop 开源公开的 id/hash。
// 子命令:
//
// appearancefetch sendcode env: PHONE 发送登录码,打印 CODE_HASH
// appearancefetch fetch <out> <carry> env: PHONE CODE CODE_HASH PASSWORD 登录+拉取+生成
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/auth"
"github.com/gotd/td/telegram/downloader"
"github.com/gotd/td/tg"
"telesrv/internal/seed/appearance"
)
const (
apiID = 17349
apiHash = "344583e45741c457fe1862106095a5eb"
)
func sessionPath() string {
if p := os.Getenv("SESSION"); p != "" {
return p
}
return "/tmp/appearance.session"
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: appearancefetch sendcode | appearancefetch fetch <out_dir> <carry_json>")
os.Exit(2)
}
cmd := os.Args[1]
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
client := telegram.NewClient(apiID, apiHash, telegram.Options{
SessionStorage: &telegram.FileSessionStorage{Path: sessionPath()},
})
err := client.Run(ctx, func(ctx context.Context) error {
switch cmd {
case "sendcode":
return doSendCode(ctx, client)
case "fetch":
if len(os.Args) < 3 {
return errors.New("fetch needs <out_dir>")
}
return doFetch(ctx, client, os.Args[2])
default:
return fmt.Errorf("unknown cmd %q", cmd)
}
})
if err != nil {
fmt.Fprintln(os.Stderr, "ERROR:", err)
os.Exit(1)
}
}
func doSendCode(ctx context.Context, client *telegram.Client) error {
phone := os.Getenv("PHONE")
if phone == "" {
return errors.New("PHONE env required")
}
sent, err := client.Auth().SendCode(ctx, phone, auth.SendCodeOptions{})
if err != nil {
return fmt.Errorf("sendCode: %w", err)
}
code, ok := sent.(*tg.AuthSentCode)
if !ok {
return fmt.Errorf("sentCode = %T", sent)
}
fmt.Println("CODE_HASH=" + code.PhoneCodeHash)
return nil
}
func doFetch(ctx context.Context, client *telegram.Client, outDir string) error {
phone := os.Getenv("PHONE")
code := os.Getenv("CODE")
codeHash := os.Getenv("CODE_HASH")
password := os.Getenv("PASSWORD")
status, err := client.Auth().Status(ctx)
if err != nil {
return fmt.Errorf("auth status: %w", err)
}
if !status.Authorized {
_, err := client.Auth().SignIn(ctx, phone, code, codeHash)
if errors.Is(err, auth.ErrPasswordAuthNeeded) {
if _, err := client.Auth().Password(ctx, password); err != nil {
return fmt.Errorf("2FA: %w", err)
}
} else if err != nil {
return fmt.Errorf("signIn: %w", err)
}
fmt.Println("[authorized]")
} else {
fmt.Println("[already authorized]")
}
api := client.API()
docsDir := filepath.Join(outDir, "default_wallpapers", "documents")
thumbsDir := filepath.Join(outDir, "default_wallpapers", "thumbs", "m")
if err := os.MkdirAll(docsDir, 0o755); err != nil {
return err
}
if err := os.MkdirAll(thumbsDir, 0o755); err != nil {
return err
}
dl := downloader.NewDownloader()
download := func(doc *tg.Document, thumb string) ([]byte, error) {
loc := &tg.InputDocumentFileLocation{
ID: doc.ID,
AccessHash: doc.AccessHash,
FileReference: doc.FileReference,
ThumbSize: thumb,
}
var buf bytes.Buffer
if _, err := dl.Download(api, loc).Stream(ctx, &buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// 转一个 tg.Document → appearance.Document 并下载正文 + "m" 缩略图。
convDoc := func(dc tg.DocumentClass) (appearance.Document, error) {
doc, ok := dc.(*tg.Document)
if !ok || doc.ID == 0 {
return appearance.Document{}, nil
}
out := appearance.Document{
ID: doc.ID,
AccessHash: doc.AccessHash,
Date: doc.Date,
MimeType: doc.MimeType,
Size: doc.Size,
DCID: doc.DCID,
}
for _, a := range doc.Attributes {
switch attr := a.(type) {
case *tg.DocumentAttributeImageSize:
out.Attributes = append(out.Attributes, appearance.DocumentAttribute{Kind: "image_size", W: attr.W, H: attr.H})
case *tg.DocumentAttributeFilename:
out.Attributes = append(out.Attributes, appearance.DocumentAttribute{Kind: "filename", FileName: attr.FileName})
}
}
// 正文
data, err := download(doc, "")
if err != nil {
return appearance.Document{}, fmt.Errorf("download doc %d: %w", doc.ID, err)
}
name := fmt.Sprintf("%d.dat", doc.ID)
if err := os.WriteFile(filepath.Join(docsDir, name), data, 0o644); err != nil {
return appearance.Document{}, err
}
sum := sha256.Sum256(data)
out.Path = "default_wallpapers/documents/" + name
out.SHA256 = hex.EncodeToString(sum[:])
// "m" 缩略图
for _, t := range doc.Thumbs {
ps, ok := t.(*tg.PhotoSize)
if !ok || ps.Type != "m" {
continue
}
tdata, err := download(doc, "m")
if err != nil {
return appearance.Document{}, fmt.Errorf("download thumb %d: %w", doc.ID, err)
}
if err := os.WriteFile(filepath.Join(thumbsDir, name), tdata, 0o644); err != nil {
return appearance.Document{}, err
}
tsum := sha256.Sum256(tdata)
out.Thumbs = append(out.Thumbs, appearance.PhotoSize{
Kind: "size", Type: "m", W: ps.W, H: ps.H, Size: ps.Size,
Path: "default_wallpapers/thumbs/m/" + name, SHA256: hex.EncodeToString(tsum[:]),
})
break
}
return out, nil
}
convWallpaperSettings := func(s tg.WallPaperSettings) appearance.WallpaperSettings {
out := appearance.WallpaperSettings{}
if v, ok := s.GetBackgroundColor(); ok {
out.BackgroundColor = v
}
if v, ok := s.GetSecondBackgroundColor(); ok {
out.SecondBackgroundColor = v
}
if v, ok := s.GetThirdBackgroundColor(); ok {
out.ThirdBackgroundColor = v
}
if v, ok := s.GetFourthBackgroundColor(); ok {
out.FourthBackgroundColor = v
}
if v, ok := s.GetIntensity(); ok {
out.Intensity = v
}
if v, ok := s.GetRotation(); ok {
out.Rotation = v
}
out.Blur = s.Blur
out.Motion = s.Motion
return out
}
convWallpaper := func(wc tg.WallPaperClass) (appearance.Wallpaper, error) {
switch wp := wc.(type) {
case *tg.WallPaper:
doc, err := convDoc(wp.Document)
if err != nil {
return appearance.Wallpaper{}, err
}
out := appearance.Wallpaper{
ID: wp.ID, AccessHash: wp.AccessHash, Type: 0,
Default: wp.Default, Pattern: wp.Pattern, Dark: wp.Dark,
Slug: wp.Slug, Document: doc,
}
if s, ok := wp.GetSettings(); ok {
out.Settings = convWallpaperSettings(s)
}
return out, nil
case *tg.WallPaperNoFile:
out := appearance.Wallpaper{ID: wp.ID, Type: 1, Default: wp.Default, Dark: wp.Dark}
if s, ok := wp.GetSettings(); ok {
out.Settings = convWallpaperSettings(s)
}
return out, nil
}
return appearance.Wallpaper{}, nil
}
// ---- 墙纸 ----
wpRes, err := api.AccountGetWallPapers(ctx, 0)
if err != nil {
return fmt.Errorf("getWallPapers: %w", err)
}
var wallpapers []appearance.Wallpaper
if w, ok := wpRes.(*tg.AccountWallPapers); ok {
for _, wc := range w.Wallpapers {
conv, err := convWallpaper(wc)
if err != nil {
return err
}
if conv.ID != 0 {
wallpapers = append(wallpapers, conv)
}
}
}
fmt.Printf("[wallpapers] %d\n", len(wallpapers))
// ---- 聊天主题 ----
baseThemeName := func(b tg.BaseThemeClass) string {
switch b.(type) {
case *tg.BaseThemeClassic:
return "classic"
case *tg.BaseThemeDay:
return "day"
case *tg.BaseThemeNight:
return "night"
case *tg.BaseThemeTinted:
return "tinted"
case *tg.BaseThemeArctic:
return "arctic"
}
return ""
}
var chatThemes []appearance.ChatTheme
thRes, err := api.AccountGetChatThemes(ctx, 0)
if err != nil {
return fmt.Errorf("getChatThemes: %w", err)
}
if t, ok := thRes.(*tg.AccountThemes); ok {
for _, theme := range t.Themes {
ct := appearance.ChatTheme{
ID: theme.ID, AccessHash: theme.AccessHash, Slug: theme.Slug,
Title: theme.Title, Emoticon: theme.Emoticon, Default: theme.Default,
}
for _, s := range theme.Settings {
ts := appearance.ThemeSettings{
BaseTheme: baseThemeName(s.BaseTheme),
AccentColor: s.AccentColor,
}
if v, ok := s.GetOutboxAccentColor(); ok {
ts.OutboxAccentColor = v
}
if v, ok := s.GetMessageColors(); ok {
ts.MessageColors = v
}
if w, ok := s.GetWallpaper(); ok {
wp, err := convWallpaper(w)
if err != nil {
return err
}
ts.Wallpaper = wp
}
ct.Settings = append(ct.Settings, ts)
}
chatThemes = append(chatThemes, ct)
}
}
fmt.Printf("[chat_themes] %d\n", len(chatThemes))
// ---- peer colors / profile colors 从官方拉 ----
mapColorSet := func(c tg.HelpPeerColorSetClass) *appearance.ColorSet {
switch s := c.(type) {
case *tg.HelpPeerColorSet:
return &appearance.ColorSet{PredicateName: "help.peerColorSet", Colors: append([]int(nil), s.Colors...)}
case *tg.HelpPeerColorProfileSet:
return &appearance.ColorSet{
PredicateName: "help.peerColorProfileSet",
PaletteColors: append([]int(nil), s.PaletteColors...),
BgColors: append([]int(nil), s.BgColors...),
StoryColors: append([]int(nil), s.StoryColors...),
}
}
return nil
}
fetchColors := func(profile bool) ([]appearance.ColorOption, error) {
var res tg.HelpPeerColorsClass
var err error
if profile {
res, err = api.HelpGetPeerProfileColors(ctx, 0)
} else {
res, err = api.HelpGetPeerColors(ctx, 0)
}
if err != nil {
return nil, err
}
pc, ok := res.(*tg.HelpPeerColors)
if !ok {
return nil, nil
}
out := make([]appearance.ColorOption, 0, len(pc.Colors))
for _, o := range pc.Colors {
co := appearance.ColorOption{ID: o.ColorID, Hidden: o.GetHidden()}
if v, ok := o.GetChannelMinLevel(); ok {
co.ChannelMinLevel = v
}
if c, ok := o.GetColors(); ok {
co.Colors = mapColorSet(c)
}
if c, ok := o.GetDarkColors(); ok {
co.DarkColors = mapColorSet(c)
}
out = append(out, co)
}
return out, nil
}
peerColors, err := fetchColors(false)
if err != nil {
return fmt.Errorf("getPeerColors: %w", err)
}
peerProfileColors, err := fetchColors(true)
if err != nil {
return fmt.Errorf("getPeerProfileColors: %w", err)
}
fmt.Printf("[peer_colors] %d / [peer_profile_colors] %d\n", len(peerColors), len(peerProfileColors))
catalog := appearance.Catalog{
Source: "official telegram (appearancefetch)",
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Notes: appearance.Notes{
Server: "official",
ChatThemesTotal: len(chatThemes),
WallpaperDocumentsBucket: "documents",
WallpaperThumbsBucket: "thumbs",
},
ChatThemes: chatThemes,
Wallpapers: wallpapers,
PeerColors: peerColors,
PeerProfileColors: peerProfileColors,
}
out, err := json.MarshalIndent(catalog, "", " ")
if err != nil {
return err
}
jsonPath := filepath.Join(outDir, "default_appearance_seed.json")
if err := os.WriteFile(jsonPath, out, 0o644); err != nil {
return err
}
fmt.Printf("wrote %s (wallpapers=%d chat_themes=%d peer_colors=%d profile=%d)\n",
jsonPath, len(wallpapers), len(chatThemes), len(catalog.PeerColors), len(catalog.PeerProfileColors))
return nil
}

307
cmd/botcheck/main.go Normal file
View file

@ -0,0 +1,307 @@
// Command botcheck 是 bot 客户端验证工具(开发用,非生产组件)。
//
// 它用 BotFather 发的 token 经 auth.importBotAuthorization 登录本地 telesrv
// 自检 bot 身份 / updates 状态 / userFull.bot_info并可选以 echo 模式持续回显
// 收到的私聊消息,验证 bot 收发闭环(在线推送 → bot 处理 → bot 回复)。
//
// 用法:
//
// go run ./cmd/botcheck -token "<bot_id>:<secret>" # 仅登录自检
// go run ./cmd/botcheck -token "<bot_id>:<secret>" -echo # 自检后持续 echo
//
// 连接生产 telesrvobfuscated TCP靠 DCOption.TCPObfuscatedOnly=true
// gotd dcs.Plain 据此自动走 MTProto TCP obfuscation。
package main
import (
"context"
"crypto/rand"
"encoding/binary"
"flag"
"fmt"
"net"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"go.uber.org/zap"
"github.com/gotd/log/logzap"
"github.com/gotd/td/exchange"
"github.com/gotd/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/gotd/td/transport"
"telesrv/internal/mtprotoedge"
)
// obfuscatedResolver 用标准无-secret MTProto TCP obfuscationobfuscated2连接
// 匹配 telesrv 生产 server 的 transport.ObfuscatedListenerobfuscated2.Accept(conn, nil))。
// gotd 内置 dcs.Plain 的 obfuscated 路径走 MTProxy强制 secret不适用这里。
type obfuscatedResolver struct {
host string
port int
}
func (r obfuscatedResolver) dial(ctx context.Context, dc int) (transport.Conn, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(r.host, strconv.Itoa(r.port)))
if err != nil {
return nil, err
}
// obfuscated2 握手携带 intermediate codec 的 init tag空 secret = 标准 obfuscation。
obf := obfuscator.Obfuscated2(rand.Reader, conn)
if err := obf.Handshake(codec.IntermediateClientStart, dc, mtproxy.Secret{}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("obfuscated2 handshake: %w", err)
}
// NoHeadertag 已由 obf 握手发出codec 不再重发。
proto := transport.NewProtocol(func() transport.Codec {
return codec.NoHeader{Codec: codec.Intermediate{}}
})
tc, err := proto.Handshake(obf)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("transport handshake: %w", err)
}
return tc, nil
}
func (r obfuscatedResolver) Primary(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) MediaOnly(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) CDN(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func main() {
addr := flag.String("addr", "127.0.0.1:2398", "telesrv MTProto 地址")
dcID := flag.Int("dc", 2, "DC id")
token := flag.String("token", os.Getenv("TELESRV_BOT_TOKEN"), "bot token <bot_id>:<secret>,默认取 env TELESRV_BOT_TOKEN")
rsaPath := flag.String("rsa", "data/server_rsa.pem", "server RSA key 路径(仅读 public key")
apiID := flag.Int("api-id", 1, "api_id")
apiHash := flag.String("api-hash", "hash", "api_hash")
echo := flag.Bool("echo", false, "登录后持续 echo 收到的私聊消息")
sendTo := flag.Int64("send-to", 0, "主动发送目标 user_id验证 bot 主动发起消息)")
sendText := flag.String("send-text", "", "主动发送的文本")
runFor := flag.Duration("for", 0, "echo 模式运行时长0=直到 Ctrl+C")
flag.Parse()
if *token == "" {
fmt.Fprintln(os.Stderr, "缺少 -token或设环境变量 TELESRV_BOT_TOKEN")
os.Exit(2)
}
logger, _ := zap.NewDevelopment()
defer func() { _ = logger.Sync() }()
priv, err := mtprotoedge.LoadOrGenerateRSAKey(*rsaPath)
if err != nil {
logger.Fatal("加载 RSA key 失败", zap.Error(err))
}
host, portStr, err := net.SplitHostPort(*addr)
if err != nil {
logger.Fatal("解析地址失败", zap.Error(err))
}
port, err := strconv.Atoi(portStr)
if err != nil {
logger.Fatal("解析端口失败", zap.Error(err))
}
var client *telegram.Client
handler := telegram.UpdateHandlerFunc(func(ctx context.Context, u tg.UpdatesClass) error {
if !*echo {
return nil
}
return echoUpdates(ctx, tg.NewClient(client), u, logger)
})
client = telegram.NewClient(*apiID, *apiHash, telegram.Options{
PublicKeys: []exchange.PublicKey{{RSA: &priv.PublicKey}},
Resolver: obfuscatedResolver{host: host, port: port},
DCList: dcs.List{Options: []tg.DCOption{
{ID: *dcID, IPAddress: host, Port: port, Static: true},
}},
Logger: logzap.New(logger.Named("client")),
UpdateHandler: handler,
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := client.Run(ctx, func(ctx context.Context) error {
raw := tg.NewClient(client)
authz, err := raw.AuthImportBotAuthorization(ctx, &tg.AuthImportBotAuthorizationRequest{
APIID: *apiID, APIHash: *apiHash, BotAuthToken: *token,
})
if err != nil {
return fmt.Errorf("importBotAuthorization: %w", err)
}
a, ok := authz.(*tg.AuthAuthorization)
if !ok {
return fmt.Errorf("authorization 类型 = %Twant *tg.AuthAuthorization", authz)
}
self, ok := a.User.(*tg.User)
if !ok {
return fmt.Errorf("self 类型 = %Twant *tg.User", a.User)
}
ver, hasVer := self.GetBotInfoVersion()
fmt.Println("==== 登录成功 ====")
fmt.Printf(" id=%d username=@%s name=%q bot=%v self=%v bot_info_version=%d(present=%v)\n",
self.ID, self.Username, self.FirstName, self.Bot, self.Self, ver, hasVer)
if !self.Bot || !hasVer {
fmt.Println(" [警告] self 缺 bot flag 或 bot_info_version —— TDesktop 不会当 bot 处理")
}
if _, hasStatus := self.GetStatus(); hasStatus {
fmt.Println(" [警告] bot 携带 status —— 官方 bot 不应有 presence")
}
if self.Phone != "" {
fmt.Printf(" [警告] bot 携带 phone=%q —— 官方 bot 无手机号\n", self.Phone)
}
state, err := raw.UpdatesGetState(ctx)
if err != nil {
return fmt.Errorf("getState: %w", err)
}
fmt.Printf(" getState: pts=%d qts=%d date=%d seq=%d\n", state.Pts, state.Qts, state.Date, state.Seq)
full, err := raw.UsersGetFullUser(ctx, &tg.InputUserSelf{})
if err != nil {
return fmt.Errorf("getFullUser: %w", err)
}
if bi, ok := full.FullUser.GetBotInfo(); ok {
uid, _ := bi.GetUserID()
desc, _ := bi.GetDescription()
cmds, _ := bi.GetCommands()
fmt.Printf(" bot_info: user_id=%d description=%q commands=%d\n", uid, desc, len(cmds))
if uid != self.ID {
fmt.Printf(" [警告] bot_info.user_id=%d != self.id=%d —— TDesktop 会整体忽略 bot_info\n", uid, self.ID)
}
for _, c := range cmds {
fmt.Printf(" /%s - %s\n", c.Command, c.Description)
}
} else {
fmt.Println(" [警告] userFull 缺 bot_info —— TDesktop 会反复重拉 getFullUser")
}
fmt.Println("==== 自检通过 ====")
// 主动发起bot 主动给指定用户发消息(用户须先与 bot 交互过)。
// access_hash 经 getUsers 解析bot 重新登录时手里没有该用户的 update
if *sendTo != 0 && *sendText != "" {
target := &tg.InputPeerUser{UserID: *sendTo}
if list, err := raw.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUser{UserID: *sendTo}}); err == nil {
for _, uc := range list {
if u, ok := uc.(*tg.User); ok && u.ID == *sendTo {
target.AccessHash = u.AccessHash
fmt.Printf(" 解析目标用户: id=%d username=@%s name=%q\n", u.ID, u.Username, u.FirstName)
}
}
} else {
fmt.Printf(" [警告] getUsers(%d) 失败: %v仍尝试 access_hash=0 发送)\n", *sendTo, err)
}
rid, _ := randInt64()
if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
Peer: target,
Message: *sendText,
RandomID: rid,
}); err != nil {
return fmt.Errorf("主动发送: %w", err)
}
fmt.Printf(">> 已主动发送给 %d: %q\n", *sendTo, *sendText)
}
if !*echo {
return nil
}
fmt.Println(">> echo 模式:从 TDesktop/Android 给这个 bot 发私聊消息bot 会回 \"echo: <原文>\"")
if *runFor > 0 {
fmt.Printf(">> 运行 %s 后自动退出(或 Ctrl+C\n", *runFor)
t := time.NewTimer(*runFor)
defer t.Stop()
select {
case <-ctx.Done():
case <-t.C:
}
return nil
}
fmt.Println(">> Ctrl+C 退出")
<-ctx.Done()
return nil
}); err != nil {
logger.Fatal("运行失败", zap.Error(err))
}
fmt.Println("已退出")
}
// echoUpdates 解析推送的 updates对收到的 incoming 私聊消息回 "echo: <原文>"。
func echoUpdates(ctx context.Context, raw *tg.Client, u tg.UpdatesClass, logger *zap.Logger) error {
var ups []tg.UpdateClass
var users []tg.UserClass
switch v := u.(type) {
case *tg.Updates:
ups, users = v.Updates, v.Users
case *tg.UpdatesCombined:
ups, users = v.Updates, v.Users
case *tg.UpdateShort:
ups = []tg.UpdateClass{v.Update}
default:
return nil
}
hashByID := make(map[int64]int64, len(users))
for _, uc := range users {
if usr, ok := uc.(*tg.User); ok {
hashByID[usr.ID] = usr.AccessHash
}
}
for _, up := range ups {
nm, ok := up.(*tg.UpdateNewMessage)
if !ok {
continue
}
msg, ok := nm.Message.(*tg.Message)
if !ok || msg.Out {
continue
}
peer, ok := msg.PeerID.(*tg.PeerUser)
if !ok {
continue
}
from := peer.UserID
fmt.Printf("<< 收到来自 %d: %q\n", from, msg.Message)
rid, _ := randInt64()
if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: from, AccessHash: hashByID[from]},
Message: "echo: " + msg.Message,
RandomID: rid,
}); err != nil {
logger.Warn("echo 回复失败", zap.Int64("to", from), zap.Error(err))
continue
}
fmt.Printf(">> 已回复 %d: %q\n", from, "echo: "+msg.Message)
}
return nil
}
func randInt64() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return time.Now().UnixNano(), err
}
v := int64(binary.LittleEndian.Uint64(b[:]))
if v == 0 {
v = 1
}
return v, nil
}

479
cmd/botdemo/main.go Normal file
View file

@ -0,0 +1,479 @@
// Command botdemo 是一个「定时向群/用户主动发消息」的 bot 参考示例(开发/教学用)。
//
// 它演示一个外部 bot 程序如何:
// 1. 用 BotFather 发的 token 经 auth.importBotAuthorization 登录 telesrv
// 2. 解析目标 peer超级群 channel 或用户 user
// 3. 在一个定时循环里,按设定间隔【主动】发送三种内容:
// - 纯文本 messages.sendMessage
// - 图片 upload 文件 + messages.sendMedia(inputMediaUploadedPhoto)
// - 图文 (同上,但带 caption
//
// bot 在群里发消息和任何普通成员一样走 messages.sendMessage/sendMedia无需被「@/命令」
// 触发——这里的定时器就是「主动发」的触发源;换成业务事件/cron 也是同一套调用。隐私模式
// bot_chat_history只影响 bot 能【收到】哪些消息,不限制它【发送】。
//
// 用法示例(向超级群定时轮流发文本/图片/图文,每 15s 一条):
//
// go run ./cmd/botdemo \
// -token "<bot_id>:<secret>" \
// -chat <channel_id> -chat-hash <access_hash> \
// -interval 15s -mode rotate
//
// 其它常用参数:
//
// -to <user_id> -to-hash <hash> # 改为给某个用户私聊发(需用户先与 bot 交互过)
// -mode text|photo|caption|rotate # 只发文本 / 只发图片 / 只发图文 / 三者轮流(默认 rotate
// -image path/to/pic.jpg # 指定图片文件;不指定则程序自动生成一张 PNG
// -text "..." -caption "..." # 自定义文本与图文说明(程序会自动追加序号+时间使每条不同)
// -count 5 # 发 5 条后退出(默认 0 = 一直发到 Ctrl+C
//
// 连接 telesrv 用标准无-secret MTProto TCP obfuscationobfuscated2与生产 server 一致。
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
"flag"
"fmt"
"image"
"image/color"
"image/png"
"net"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"go.uber.org/zap"
"github.com/gotd/log/logzap"
"github.com/gotd/td/exchange"
"github.com/gotd/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/telegram/uploader"
"github.com/gotd/td/tg"
"github.com/gotd/td/transport"
"telesrv/internal/mtprotoedge"
)
// obfuscatedResolver 用标准无-secret MTProto TCP obfuscationobfuscated2连接 telesrv
// 匹配生产 server 的 transport.ObfuscatedListener。gotd 内置 dcs.Plain 的 obfuscated 路径
// 走 MTProxy强制 secret不适用这里所以自定义一个 Resolver。
type obfuscatedResolver struct {
host string
port int
}
func (r obfuscatedResolver) dial(ctx context.Context, dc int) (transport.Conn, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(r.host, strconv.Itoa(r.port)))
if err != nil {
return nil, err
}
obf := obfuscator.Obfuscated2(rand.Reader, conn)
if err := obf.Handshake(codec.IntermediateClientStart, dc, mtproxy.Secret{}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("obfuscated2 handshake: %w", err)
}
proto := transport.NewProtocol(func() transport.Codec { return codec.NoHeader{Codec: codec.Intermediate{}} })
tc, err := proto.Handshake(obf)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("transport handshake: %w", err)
}
return tc, nil
}
func (r obfuscatedResolver) Primary(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) MediaOnly(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) CDN(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
type config struct {
chatID, chatHash int64
toID, toHash int64
interval time.Duration
count int
mode string
text, caption string
imagePath string
}
func main() {
addr := flag.String("addr", "127.0.0.1:2398", "telesrv MTProto 地址")
dcID := flag.Int("dc", 2, "DC id")
token := flag.String("token", os.Getenv("TELESRV_BOT_TOKEN"), "bot token <bot_id>:<secret>(默认取 env TELESRV_BOT_TOKEN")
rsaPath := flag.String("rsa", "data/server_rsa.pem", "server RSA 公钥路径")
apiID := flag.Int("api-id", 1, "api_id")
apiHash := flag.String("api-hash", "hash", "api_hash")
chatID := flag.Int64("chat", 0, "目标超级群 channel id非 0 即发到群)")
chatHash := flag.Int64("chat-hash", 0, "目标群 access_hashtelesrv 下可留 0")
toID := flag.Int64("to", 0, "目标用户 id-chat 为 0 时改为私聊该用户)")
toHash := flag.Int64("to-hash", 0, "目标用户 access_hashtelesrv 下可留 0")
interval := flag.Duration("interval", 15*time.Second, "定时发送间隔")
count := flag.Int("count", 0, "发送条数上限0 = 一直发到 Ctrl+C")
mode := flag.String("mode", "rotate", "发送内容text | photo | caption | rotate")
text := flag.String("text", "telesrv bot demo · 定时主动消息", "文本内容(会自动追加 #序号+时间)")
caption := flag.String("caption", "telesrv bot demo · 图文消息", "图文说明(会自动追加 #序号+时间)")
imagePath := flag.String("image", "", "图片文件路径(留空则程序自动生成一张 PNG")
flag.Parse()
if *token == "" {
fmt.Fprintln(os.Stderr, "缺少 -token或设环境变量 TELESRV_BOT_TOKEN")
os.Exit(2)
}
if *chatID == 0 && *toID == 0 {
fmt.Fprintln(os.Stderr, "需指定目标:-chat <群id>(或 -to <用户id>")
os.Exit(2)
}
switch *mode {
case "text", "photo", "caption", "rotate":
default:
fmt.Fprintf(os.Stderr, "未知 -mode=%qtext|photo|caption|rotate\n", *mode)
os.Exit(2)
}
cfg := config{
chatID: *chatID, chatHash: *chatHash, toID: *toID, toHash: *toHash,
interval: *interval, count: *count, mode: *mode,
text: *text, caption: *caption, imagePath: *imagePath,
}
logger, _ := zap.NewDevelopment()
defer func() { _ = logger.Sync() }()
priv, err := mtprotoedge.LoadOrGenerateRSAKey(*rsaPath)
if err != nil {
logger.Fatal("加载 RSA key 失败", zap.Error(err))
}
host, portStr, err := net.SplitHostPort(*addr)
if err != nil {
logger.Fatal("解析地址失败", zap.Error(err))
}
port, _ := strconv.Atoi(portStr)
client := telegram.NewClient(*apiID, *apiHash, telegram.Options{
PublicKeys: []exchange.PublicKey{{RSA: &priv.PublicKey}},
Resolver: obfuscatedResolver{host: host, port: port},
DCList: dcs.List{Options: []tg.DCOption{{ID: *dcID, IPAddress: host, Port: port, Static: true}}},
Logger: logzap.New(logger.Named("client")),
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := client.Run(ctx, func(ctx context.Context) error {
api := tg.NewClient(client)
// 1) 登录为 bot。
self, err := loginBot(ctx, api, *apiID, *apiHash, *token)
if err != nil {
return err
}
fmt.Printf("==== bot 登录成功:@%s (id=%d) ====\n", self.Username, self.ID)
// 2) 解析目标 peer。
peer, label, err := resolveTarget(ctx, api, cfg)
if err != nil {
return err
}
fmt.Printf(">> 目标:%s\n>> 间隔:%s模式%s上限%s\n", label, cfg.interval, cfg.mode, countLabel(cfg.count))
// 3) 定时主动发送。
return runLoop(ctx, api, uploader.NewUploader(api), peer, cfg, logger)
}); err != nil {
logger.Fatal("运行失败", zap.Error(err))
}
fmt.Println("已退出")
}
// loginBot 用 token 登录并返回 self 用户。
func loginBot(ctx context.Context, api *tg.Client, apiID int, apiHash, token string) (*tg.User, error) {
authz, err := api.AuthImportBotAuthorization(ctx, &tg.AuthImportBotAuthorizationRequest{
APIID: apiID, APIHash: apiHash, BotAuthToken: token,
})
if err != nil {
return nil, fmt.Errorf("importBotAuthorization: %w", err)
}
a, ok := authz.(*tg.AuthAuthorization)
if !ok {
return nil, fmt.Errorf("authorization 类型 = %T", authz)
}
self, ok := a.User.(*tg.User)
if !ok || !self.Bot {
return nil, fmt.Errorf("登录账号不是 bot%T", a.User)
}
return self, nil
}
// resolveTarget 把命令行目标解析成 InputPeer。群优先access_hash 给 0 时尝试经
// getChannels/getUsers 解析出真实 hashtelesrv 允许 hash=0 旁路,但解析更稳、也更贴近真实客户端)。
func resolveTarget(ctx context.Context, api *tg.Client, cfg config) (tg.InputPeerClass, string, error) {
if cfg.chatID != 0 {
hash := cfg.chatHash
title := ""
if res, err := api.ChannelsGetChannels(ctx, []tg.InputChannelClass{
&tg.InputChannel{ChannelID: cfg.chatID, AccessHash: cfg.chatHash},
}); err == nil {
for _, c := range res.GetChats() {
if ch, ok := c.(*tg.Channel); ok && ch.ID == cfg.chatID {
hash, title = ch.AccessHash, ch.Title
}
}
}
return &tg.InputPeerChannel{ChannelID: cfg.chatID, AccessHash: hash},
fmt.Sprintf("超级群 %q (id=%d)", title, cfg.chatID), nil
}
hash := cfg.toHash
name := ""
if list, err := api.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUser{UserID: cfg.toID, AccessHash: cfg.toHash}}); err == nil {
for _, uc := range list {
if u, ok := uc.(*tg.User); ok && u.ID == cfg.toID {
hash, name = u.AccessHash, u.FirstName
}
}
}
return &tg.InputPeerUser{UserID: cfg.toID, AccessHash: hash},
fmt.Sprintf("用户 %q (id=%d)", name, cfg.toID), nil
}
// runLoop 是定时主动发送的核心:每 interval 发一条,按 mode 选内容count>0 时发够即停。
func runLoop(ctx context.Context, api *tg.Client, up *uploader.Uploader, peer tg.InputPeerClass, cfg config, logger *zap.Logger) error {
ticker := time.NewTicker(cfg.interval)
defer ticker.Stop()
seq := 0
send := func() error {
seq++
kind := pickKind(cfg.mode, seq)
stamp := time.Now().Format("15:04:05")
switch kind {
case "text":
msg := fmt.Sprintf("%s · #%d · %s", cfg.text, seq, stamp)
if err := sendText(ctx, api, peer, msg); err != nil {
return err
}
fmt.Printf("[%d] 文本已发:%q\n", seq, msg)
case "photo":
if err := sendPhoto(ctx, api, up, peer, seq, "", cfg.imagePath); err != nil {
return err
}
fmt.Printf("[%d] 图片已发\n", seq)
case "caption":
captionText := fmt.Sprintf("%s · #%d · %s", cfg.caption, seq, stamp)
if err := sendPhoto(ctx, api, up, peer, seq, captionText, cfg.imagePath); err != nil {
return err
}
fmt.Printf("[%d] 图文已发:%q\n", seq, captionText)
}
return nil
}
// 立即发第一条(不必等满一个 interval随后按 ticker 周期发。
if err := send(); err != nil {
logger.Warn("发送失败", zap.Error(err))
}
if cfg.count > 0 && seq >= cfg.count {
return nil
}
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if err := send(); err != nil {
logger.Warn("发送失败", zap.Error(err))
continue
}
if cfg.count > 0 && seq >= cfg.count {
return nil
}
}
}
}
// pickKind 决定本次发什么固定模式直接返回rotate 模式按序号轮流 text→photo→caption。
func pickKind(mode string, seq int) string {
if mode != "rotate" {
return mode
}
switch (seq - 1) % 3 {
case 0:
return "text"
case 1:
return "photo"
default:
return "caption"
}
}
// sendText 发一条纯文本消息。
func sendText(ctx context.Context, api *tg.Client, peer tg.InputPeerClass, message string) error {
rid, _ := randInt64()
_, err := api.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
Peer: peer,
Message: message,
RandomID: rid,
})
if err != nil {
return fmt.Errorf("sendMessage: %w", err)
}
return nil
}
// sendPhoto 上传一张图片并发出caption 非空即「图文」,为空即纯图片。
// 图片来源cfg.imagePath 指定的文件,或程序按序号生成的 PNG保证每条都不一样
func sendPhoto(ctx context.Context, api *tg.Client, up *uploader.Uploader, peer tg.InputPeerClass, seq int, caption, imagePath string) error {
var (
file tg.InputFileClass
err error
)
if imagePath != "" {
// uploader 会自动分片 upload.saveFilePart大文件走 big-file 路径)。
file, err = up.FromPath(ctx, imagePath)
if err != nil {
return fmt.Errorf("上传图片文件 %q: %w", imagePath, err)
}
} else {
data := generatePNG(seq)
file, err = up.FromBytes(ctx, fmt.Sprintf("botdemo-%d.png", seq), data)
if err != nil {
return fmt.Errorf("上传生成图片: %w", err)
}
}
rid, _ := randInt64()
_, err = api.MessagesSendMedia(ctx, &tg.MessagesSendMediaRequest{
Peer: peer,
Media: &tg.InputMediaUploadedPhoto{File: file},
Message: caption, // 空 = 纯图片;非空 = 图文(图片 + 说明文字)
RandomID: rid,
})
if err != nil {
return fmt.Errorf("sendMedia(photo): %w", err)
}
return nil
}
// generatePNG 程序化生成一张 600x400 的 PNG颜色随序号变化、并画一个随序号移动的方块
// 让连发的每张图肉眼可区分。纯标准库实现,无需外部图片文件,方便直接跑 demo。
func generatePNG(seq int) []byte {
const w, h = 600, 400
img := image.NewRGBA(image.Rect(0, 0, w, h))
// 背景:按序号轮转色相的横向渐变。
bg := hsv(float64((seq*37)%360), 0.55, 0.95)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
shade := uint8(40 + (x*40)/w)
img.Set(x, y, color.RGBA{
R: clamp(int(bg.R) - 20 + int(shade)),
G: clamp(int(bg.G) - 20 + int(shade)),
B: clamp(int(bg.B) - 20 + int(shade)),
A: 255,
})
}
}
// 一个随序号横向移动的对比色方块——直观体现「这是第几条」。
block := hsv(float64((seq*37+180)%360), 0.8, 0.9)
bx := 30 + (seq*45)%(w-130)
for y := 150; y < 250; y++ {
for x := bx; x < bx+100 && x < w; x++ {
img.Set(x, y, block)
}
}
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
// hsv 把 HSV(h∈[0,360), s,v∈[0,1]) 转成 RGBA标准库无 HSV简单实现一个
func hsv(hDeg, s, v float64) color.RGBA {
c := v * s
x := c * (1 - absf(modf(hDeg/60, 2)-1))
m := v - c
var r, g, b float64
switch {
case hDeg < 60:
r, g, b = c, x, 0
case hDeg < 120:
r, g, b = x, c, 0
case hDeg < 180:
r, g, b = 0, c, x
case hDeg < 240:
r, g, b = 0, x, c
case hDeg < 300:
r, g, b = x, 0, c
default:
r, g, b = c, 0, x
}
return color.RGBA{
R: uint8((r + m) * 255),
G: uint8((g + m) * 255),
B: uint8((b + m) * 255),
A: 255,
}
}
func absf(f float64) float64 {
if f < 0 {
return -f
}
return f
}
func modf(a, m float64) float64 {
for a >= m {
a -= m
}
for a < 0 {
a += m
}
return a
}
func clamp(v int) uint8 {
if v < 0 {
return 0
}
if v > 255 {
return 255
}
return uint8(v)
}
func countLabel(count int) string {
if count <= 0 {
return "无限"
}
return strconv.Itoa(count)
}
// randInt64 生成非零随机 random_id防 (sender,random_id) 幂等键碰撞)。
func randInt64() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return time.Now().UnixNano(), err
}
v := int64(binary.LittleEndian.Uint64(b[:]))
if v == 0 {
v = 1
}
return v, nil
}

190
cmd/catalogfetch/main.go Normal file
View file

@ -0,0 +1,190 @@
// Command catalogfetch 从官方 Telegram 拉取「目录/参考数据」并写成 telesrv
// internal/seed/catalog 的 JSON 种子:
// - 国家区号 help.getCountriesList (免登)
// - 时区 help.getTimezonesList (免登)
// - emoji 分组 messages.getEmojiGroups (需登录)
// - emoji 关键词 messages.getEmojiKeywords (需登录)
//
// 免登项即使会话未授权也能拉;emoji 两项需要授权会话,故复用 appearancefetch 建立的
// 已登录会话(SESSION env,默认 /tmp/appearance.session)。api 凭据用 TDesktop 开源公开 id/hash。
//
// 用法: SESSION=/tmp/appearance.session catalogfetch <out_dir> [langCode=en]
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/gotd/td/telegram"
"github.com/gotd/td/tg"
)
const (
tdesktopAPIID = 17349
tdesktopAPIHash = "344583e45741c457fe1862106095a5eb"
)
func sessionPath() string {
if p := os.Getenv("SESSION"); p != "" {
return p
}
return "/tmp/appearance.session"
}
type countryCodeJSON struct {
CountryCode string `json:"country_code"`
Prefixes []string `json:"prefixes,omitempty"`
Patterns []string `json:"patterns,omitempty"`
}
type countryJSON struct {
ISO2 string `json:"iso2"`
DefaultName string `json:"default_name"`
Name string `json:"name,omitempty"`
Hidden bool `json:"hidden,omitempty"`
CountryCodes []countryCodeJSON `json:"country_codes"`
}
type countriesFile struct {
Countries []countryJSON `json:"countries"`
}
type timezoneJSON struct {
ID string `json:"id"`
Name string `json:"name"`
UTCOffset int `json:"utc_offset"`
}
type timezonesFile struct {
Timezones []timezoneJSON `json:"timezones"`
}
type emojiGroupJSON struct {
Title string `json:"title"`
IconEmojiID int64 `json:"icon_emoji_id"`
Emoticons []string `json:"emoticons"`
}
type emojiGroupsFile struct {
Groups []emojiGroupJSON `json:"groups"`
}
type emojiKeywordJSON struct {
Keyword string `json:"keyword"`
Emoticons []string `json:"emoticons"`
}
type emojiKeywordsFile struct {
LangCode string `json:"lang_code"`
Version int `json:"version"`
Keywords []emojiKeywordJSON `json:"keywords"`
}
func writeJSON(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(b, '\n'), 0o644)
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: SESSION=/tmp/appearance.session catalogfetch <out_dir> [langCode=en]")
os.Exit(2)
}
out := os.Args[1]
lang := "en"
if len(os.Args) > 2 {
lang = os.Args[2]
}
if err := os.MkdirAll(out, 0o755); err != nil {
fmt.Fprintln(os.Stderr, "ERROR:", err)
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
defer cancel()
client := telegram.NewClient(tdesktopAPIID, tdesktopAPIHash, telegram.Options{
SessionStorage: &telegram.FileSessionStorage{Path: sessionPath()},
})
if err := client.Run(ctx, func(ctx context.Context) error {
api := client.API()
if status, err := client.Auth().Status(ctx); err == nil {
fmt.Printf("[session %s authorized=%v]\n", sessionPath(), status.Authorized)
}
// 国家区号(免登)
if cl, err := api.HelpGetCountriesList(ctx, &tg.HelpGetCountriesListRequest{LangCode: lang, Hash: 0}); err != nil {
fmt.Fprintln(os.Stderr, "skip countries:", err)
} else if full, ok := cl.(*tg.HelpCountriesList); ok {
var f countriesFile
for _, c := range full.Countries {
cj := countryJSON{ISO2: c.ISO2, DefaultName: c.DefaultName, Name: c.Name, Hidden: c.Hidden}
for _, cc := range c.CountryCodes {
cj.CountryCodes = append(cj.CountryCodes, countryCodeJSON{CountryCode: cc.CountryCode, Prefixes: cc.Prefixes, Patterns: cc.Patterns})
}
f.Countries = append(f.Countries, cj)
}
if err := writeJSON(filepath.Join(out, "countries.json"), f); err != nil {
return err
}
fmt.Printf("[countries] %d\n", len(f.Countries))
}
// 时区(免登)
if tl, err := api.HelpGetTimezonesList(ctx, 0); err != nil {
fmt.Fprintln(os.Stderr, "skip timezones:", err)
} else if full, ok := tl.(*tg.HelpTimezonesList); ok {
var f timezonesFile
for _, t := range full.Timezones {
f.Timezones = append(f.Timezones, timezoneJSON{ID: t.ID, Name: t.Name, UTCOffset: t.UtcOffset})
}
if err := writeJSON(filepath.Join(out, "timezones.json"), f); err != nil {
return err
}
fmt.Printf("[timezones] %d\n", len(f.Timezones))
}
// emoji 分组(需登录)
if eg, err := api.MessagesGetEmojiGroups(ctx, 0); err != nil {
fmt.Fprintln(os.Stderr, "skip emoji_groups:", err)
} else if full, ok := eg.(*tg.MessagesEmojiGroups); ok {
var f emojiGroupsFile
for _, g := range full.Groups {
if gg, ok := g.(*tg.EmojiGroup); ok {
f.Groups = append(f.Groups, emojiGroupJSON{Title: gg.Title, IconEmojiID: gg.IconEmojiID, Emoticons: gg.Emoticons})
}
}
if err := writeJSON(filepath.Join(out, "emoji_groups.json"), f); err != nil {
return err
}
fmt.Printf("[emoji_groups] %d\n", len(f.Groups))
}
// emoji 关键词(需登录)
if kw, err := api.MessagesGetEmojiKeywords(ctx, lang); err != nil {
fmt.Fprintln(os.Stderr, "skip emoji_keywords:", err)
} else {
f := emojiKeywordsFile{LangCode: kw.LangCode, Version: kw.Version}
for _, k := range kw.Keywords {
if kk, ok := k.(*tg.EmojiKeyword); ok {
f.Keywords = append(f.Keywords, emojiKeywordJSON{Keyword: kk.Keyword, Emoticons: kk.Emoticons})
}
}
name := fmt.Sprintf("emoji_keywords_%s.json", f.LangCode)
if f.LangCode == "" {
name = "emoji_keywords_" + lang + ".json"
}
if err := writeJSON(filepath.Join(out, name), f); err != nil {
return err
}
fmt.Printf("[emoji_keywords %s] %d (v%d)\n", f.LangCode, len(f.Keywords), f.Version)
}
return nil
}); err != nil {
fmt.Fprintln(os.Stderr, "ERROR:", err)
os.Exit(1)
}
}

294
cmd/giftcheck/main.go Normal file
View file

@ -0,0 +1,294 @@
// Command giftcheck 是 Star 礼物端到端验证工具(开发用,非生产组件)。
//
// 以用户身份登录本地 telesrv验证 star gift 全链路对 live 服务端:
// 1. payments.getStarGifts —— 打印礼物目录。
// 2. 从 dialogs 找一个收礼用户(或 -to 指定)。
// 3. payments.getPaymentForm(inputInvoiceStarGift) —— 必须返 paymentFormStarGiftXTR+非空 prices
// 4. payments.sendStarsForm —— 必须返 paymentResult{updates}(含礼物服务消息 + updateStarsBalance
// 5. payments.getStarsStatus 前后对比验证扣费。
//
// 用法: go run ./cmd/giftcheck -phone "+8618800000001"
package main
import (
"context"
"crypto/rand"
"flag"
"fmt"
"net"
"os"
"os/signal"
"strconv"
"syscall"
"go.uber.org/zap"
"github.com/gotd/log/logzap"
"github.com/gotd/td/exchange"
"github.com/gotd/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/gotd/td/transport"
"telesrv/internal/mtprotoedge"
)
type obfuscatedResolver struct {
host string
port int
}
func (r obfuscatedResolver) dial(ctx context.Context, dc int) (transport.Conn, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(r.host, strconv.Itoa(r.port)))
if err != nil {
return nil, err
}
obf := obfuscator.Obfuscated2(rand.Reader, conn)
if err := obf.Handshake(codec.IntermediateClientStart, dc, mtproxy.Secret{}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("obfuscated2 handshake: %w", err)
}
proto := transport.NewProtocol(func() transport.Codec { return codec.NoHeader{Codec: codec.Intermediate{}} })
tc, err := proto.Handshake(obf)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("transport handshake: %w", err)
}
return tc, nil
}
func (r obfuscatedResolver) Primary(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) MediaOnly(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) CDN(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func main() {
addr := flag.String("addr", "127.0.0.1:2398", "telesrv MTProto 地址")
dcID := flag.Int("dc", 2, "DC id")
rsaPath := flag.String("rsa", "data/server_rsa.pem", "server RSA key 路径")
apiID := flag.Int("api-id", 1, "api_id")
apiHash := flag.String("api-hash", "hash", "api_hash")
phone := flag.String("phone", "", "登录手机号")
code := flag.String("code", "12345", "开发登录码")
toID := flag.Int64("to", 0, "收礼用户 id0=自动从 dialogs 找)")
toHash := flag.Int64("to-hash", 0, "收礼用户 access_hash")
flag.Parse()
if *phone == "" {
fmt.Fprintln(os.Stderr, "缺少 -phone")
os.Exit(2)
}
logger, _ := zap.NewDevelopment()
defer func() { _ = logger.Sync() }()
priv, err := mtprotoedge.LoadOrGenerateRSAKey(*rsaPath)
if err != nil {
logger.Fatal("加载 RSA key 失败", zap.Error(err))
}
host, portStr, _ := net.SplitHostPort(*addr)
port, _ := strconv.Atoi(portStr)
client := telegram.NewClient(*apiID, *apiHash, telegram.Options{
PublicKeys: []exchange.PublicKey{{RSA: &priv.PublicKey}},
Resolver: obfuscatedResolver{host: host, port: port},
DCList: dcs.List{Options: []tg.DCOption{{ID: *dcID, IPAddress: host, Port: port, Static: true}}},
Logger: logzap.New(logger.Named("client")),
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := client.Run(ctx, func(ctx context.Context) error {
raw := tg.NewClient(client)
sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: *phone, APIID: *apiID, APIHash: *apiHash, Settings: tg.CodeSettings{}})
if err != nil {
return fmt.Errorf("sendCode: %w", err)
}
sc, ok := sent.(*tg.AuthSentCode)
if !ok {
return fmt.Errorf("sentCode 类型 = %T", sent)
}
authz, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: *phone, PhoneCodeHash: sc.PhoneCodeHash, PhoneCode: *code})
if err != nil {
return fmt.Errorf("signIn: %w", err)
}
self := authz.(*tg.AuthAuthorization).User.(*tg.User)
fmt.Printf("==== 登录: id=%d name=%q ====\n", self.ID, self.FirstName)
// 1. 目录。
gifts := giftCatalog(ctx, raw)
if len(gifts) == 0 {
return fmt.Errorf("礼物目录为空animated_emoji 未 seed")
}
gift := gifts[0]
fmt.Printf(" 目录 %d 个礼物;选第一个: id=%d stars=%d title=%q sticker=%T\n", len(gifts), gift.ID, gift.Stars, giftTitle(gift), gift.Sticker)
balBefore := starsBalance(ctx, raw, "扣费前")
// 2. 收礼用户(-to 指定 id 时从 dialogs 解析其 access_hash否则取首个 user
var to *tg.InputPeerUser
if *toID != 0 && *toHash != 0 {
to = &tg.InputPeerUser{UserID: *toID, AccessHash: *toHash}
} else {
to = findRecipient(ctx, raw, self.ID, *toID)
}
if to == nil {
return fmt.Errorf("找不到收礼用户(用 -to/-to-hash 指定)")
}
fmt.Printf(" 收礼用户: id=%d\n", to.UserID)
inv := &tg.InputInvoiceStarGift{Peer: to, GiftID: gift.ID}
// 3. getPaymentForm → paymentFormStarGift。
formRes, err := raw.PaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
return fmt.Errorf("getPaymentForm: %w", err)
}
form, ok := formRes.(*tg.PaymentsPaymentFormStarGift)
if !ok {
fmt.Printf(" [FAIL] getPaymentForm 返回 %Twant *PaymentsPaymentFormStarGiftTDesktop 单分支 match\n", formRes)
return nil
}
fmt.Printf(" getPaymentForm: paymentFormStarGift form_id=%d currency=%s prices=%d\n", form.FormID, form.Invoice.Currency, len(form.Invoice.Prices))
if form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) == 0 {
fmt.Printf(" [FAIL] invoice 须 XTR + 非空 prices\n")
return nil
}
// 4. sendStarsForm → paymentResult。
payRes, err := raw.PaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv})
if err != nil {
return fmt.Errorf("sendStarsForm: %w", err)
}
pay, ok := payRes.(*tg.PaymentsPaymentResult)
if !ok {
fmt.Printf(" [FAIL] sendStarsForm 返回 %Twant *PaymentsPaymentResultDrKLO 强转)\n", payRes)
return nil
}
inspectGiftUpdates(pay.Updates)
balAfter := starsBalance(ctx, raw, "扣费后")
fmt.Printf("==== 扣费: %d -> %d差 %d期望 -%d====\n", balBefore, balAfter, balBefore-balAfter, gift.Stars)
if balBefore-balAfter == gift.Stars {
fmt.Println("==== ✅ PASSstar gift 扣费正确 ====")
} else {
fmt.Println("==== ❌ FAIL扣费金额不符 ====")
}
return nil
}); err != nil {
logger.Fatal("运行失败", zap.Error(err))
}
}
func giftCatalog(ctx context.Context, raw *tg.Client) []*tg.StarGift {
res, err := raw.PaymentsGetStarGifts(ctx, 0)
if err != nil {
fmt.Printf(" getStarGifts 失败: %v\n", err)
return nil
}
full, ok := res.(*tg.PaymentsStarGifts)
if !ok {
fmt.Printf(" getStarGifts 返回 %T\n", res)
return nil
}
out := make([]*tg.StarGift, 0, len(full.Gifts))
for _, g := range full.Gifts {
if sg, ok := g.(*tg.StarGift); ok {
out = append(out, sg)
}
}
return out
}
func giftTitle(g *tg.StarGift) string {
t, _ := g.GetTitle()
return t
}
func starsBalance(ctx context.Context, raw *tg.Client, label string) int64 {
status, err := raw.PaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}})
if err != nil {
fmt.Printf(" [%s] getStarsStatus 失败: %v\n", label, err)
return -1
}
var bal int64
if amt, ok := status.Balance.(*tg.StarsAmount); ok {
bal = amt.Amount
}
fmt.Printf(" [%s] 余额=%d stars\n", label, bal)
return bal
}
func findRecipient(ctx context.Context, raw *tg.Client, selfID, targetID int64) *tg.InputPeerUser {
dlgs, err := raw.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 100})
if err != nil {
return nil
}
var users []tg.UserClass
switch v := dlgs.(type) {
case *tg.MessagesDialogs:
users = v.Users
case *tg.MessagesDialogsSlice:
users = v.Users
default:
return nil
}
for _, uc := range users {
u, ok := uc.(*tg.User)
if !ok || u.ID == selfID || u.Bot || u.Self {
continue
}
if targetID != 0 {
if u.ID == targetID {
return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash}
}
continue
}
return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash}
}
return nil
}
func inspectGiftUpdates(res tg.UpdatesClass) {
var ups []tg.UpdateClass
switch v := res.(type) {
case *tg.Updates:
ups = v.Updates
case *tg.UpdatesCombined:
ups = v.Updates
case *tg.UpdateShort:
ups = []tg.UpdateClass{v.Update}
default:
fmt.Printf(" [警告] paymentResult.updates 非 Updates 子类型: %T\n", res)
return
}
hasGiftMsg, hasBalance := false, false
for _, up := range ups {
switch u := up.(type) {
case *tg.UpdateNewMessage:
if svc, ok := u.Message.(*tg.MessageService); ok {
if a, ok := svc.Action.(*tg.MessageActionStarGift); ok {
hasGiftMsg = true
gid := int64(0)
if sg, ok := a.Gift.(*tg.StarGift); ok {
gid = sg.ID
}
fmt.Printf(" messageActionStarGift: msg_id=%d gift_id=%d\n", u.Message.(*tg.MessageService).ID, gid)
}
}
case *tg.UpdateStarsBalance:
hasBalance = true
if amt, ok := u.Balance.(*tg.StarsAmount); ok {
fmt.Printf(" updateStarsBalance: %d\n", amt.Amount)
}
}
}
fmt.Printf(" paymentResult: 含礼物服务消息=%v 含 updateStarsBalance=%v\n", hasGiftMsg, hasBalance)
}

127
cmd/langpackfetch/main.go Normal file
View file

@ -0,0 +1,127 @@
// Command langpackfetch 从官方 Telegram 拉取 langpack(免登公开方法 langpack.getLangPack),
// 转成 telesrv seed 的 .strings 格式(复数用 Key#one/#other 后缀)。api 凭据用 TDesktop
// 开源公开的 id/hash。
//
// 用法:
// langpackfetch languages [pack] 列出某 pack(默认 android)的可用语言
// langpackfetch <out_dir> <langCode> [pack...] 拉取语言包(默认 packs = android ios macos)
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gotd/td/telegram"
"github.com/gotd/td/tg"
)
const (
tdesktopAPIID = 17349
tdesktopAPIHash = "344583e45741c457fe1862106095a5eb"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage:\n langpackfetch languages [pack]\n langpackfetch <out_dir> <langCode> [pack...]")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
defer cancel()
client := telegram.NewClient(tdesktopAPIID, tdesktopAPIHash, telegram.Options{})
if err := client.Run(ctx, func(ctx context.Context) error {
api := client.API()
if os.Args[1] == "languages" {
pack := "android"
if len(os.Args) > 2 {
pack = os.Args[2]
}
langs, err := api.LangpackGetLanguages(ctx, pack)
if err != nil {
return fmt.Errorf("getLanguages %q: %w", pack, err)
}
fmt.Printf("pack=%s, %d languages:\n", pack, len(langs))
for _, l := range langs {
fmt.Printf(" %-18s %-26s official=%-5v beta=%-5v strings=%-6d translated=%-6d base=%q\n",
l.LangCode, l.Name, l.Official, l.Beta, l.StringsCount, l.TranslatedCount, l.BaseLangCode)
}
return nil
}
outRoot := os.Args[1]
langCode := "en"
if len(os.Args) > 2 {
langCode = os.Args[2]
}
packs := os.Args[3:]
if len(packs) == 0 {
packs = []string{"android", "ios", "macos"}
}
for _, pack := range packs {
diff, err := api.LangpackGetLangPack(ctx, &tg.LangpackGetLangPackRequest{
LangPack: pack,
LangCode: langCode,
})
if err != nil {
fmt.Fprintf(os.Stderr, "skip %s/%s: %v\n", pack, langCode, err)
continue
}
if err := writePack(outRoot, pack, diff); err != nil {
return err
}
}
return nil
}); err != nil {
fmt.Fprintln(os.Stderr, "ERROR:", err)
os.Exit(1)
}
}
func writePack(root, pack string, diff *tg.LangPackDifference) error {
dir := filepath.Join(root, pack)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
var b strings.Builder
count := 0
for _, s := range diff.Strings {
switch v := s.(type) {
case *tg.LangPackString:
fmt.Fprintf(&b, "%s = %s;\n", strconv.Quote(v.Key), strconv.Quote(v.Value))
count++
case *tg.LangPackStringPluralized:
emit := func(suffix, val string) {
if val != "" {
fmt.Fprintf(&b, "%s = %s;\n", strconv.Quote(v.Key+suffix), strconv.Quote(val))
}
}
emit("#zero", v.ZeroValue)
emit("#one", v.OneValue)
emit("#two", v.TwoValue)
emit("#few", v.FewValue)
emit("#many", v.ManyValue)
emit("#other", v.OtherValue)
count++
case *tg.LangPackStringDeleted:
// 跳过删除项
}
}
langCode := diff.LangCode
if langCode == "" {
langCode = "unknown"
}
name := fmt.Sprintf("%s_%s_v%d.strings", pack, langCode, diff.Version)
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
return err
}
fmt.Printf("wrote %s (%d strings, version %d)\n", path, count, diff.Version)
return nil
}

304
cmd/starcheck/main.go Normal file
View file

@ -0,0 +1,304 @@
// Command starcheck 是 Stars 付费 reaction 端到端验证工具(开发用,非生产组件)。
//
// 它以用户身份(开发码 12345登录本地 telesrv验证 Stars 本地账本与付费 reaction
// 1. payments.getStarsStatus —— 打印当前余额Phase 1惰性首读授予后应 >0
// 2. 自动从 dialogs 找一个广播频道 + 其顶部消息(或用 -channel-id/-access-hash/-msg-id 指定)。
// 3. messages.sendPaidReaction —— 发付费 reaction打印返回的 Updates 结构
// Phase 2 崩溃约束:必须是 *tg.Updates应含 updateMessageReactions + updateStarsBalance
// 4. payments.getStarsStatus —— 再次打印余额,验证已扣 -count。
//
// 用法:
//
// go run ./cmd/starcheck -phone "+8618800000001" -count 50
package main
import (
"context"
"crypto/rand"
"encoding/binary"
"flag"
"fmt"
"net"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"go.uber.org/zap"
"github.com/gotd/log/logzap"
"github.com/gotd/td/exchange"
"github.com/gotd/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/gotd/td/transport"
"telesrv/internal/mtprotoedge"
)
type obfuscatedResolver struct {
host string
port int
}
func (r obfuscatedResolver) dial(ctx context.Context, dc int) (transport.Conn, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(r.host, strconv.Itoa(r.port)))
if err != nil {
return nil, err
}
obf := obfuscator.Obfuscated2(rand.Reader, conn)
if err := obf.Handshake(codec.IntermediateClientStart, dc, mtproxy.Secret{}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("obfuscated2 handshake: %w", err)
}
proto := transport.NewProtocol(func() transport.Codec {
return codec.NoHeader{Codec: codec.Intermediate{}}
})
tc, err := proto.Handshake(obf)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("transport handshake: %w", err)
}
return tc, nil
}
func (r obfuscatedResolver) Primary(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) MediaOnly(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) CDN(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func main() {
addr := flag.String("addr", "127.0.0.1:2398", "telesrv MTProto 地址")
dcID := flag.Int("dc", 2, "DC id")
rsaPath := flag.String("rsa", "data/server_rsa.pem", "server RSA key 路径")
apiID := flag.Int("api-id", 1, "api_id")
apiHash := flag.String("api-hash", "hash", "api_hash")
phone := flag.String("phone", "", "登录手机号,如 +8618800000001")
code := flag.String("code", "12345", "开发登录码")
count := flag.Int("count", 50, "付费 reaction 星数")
channelID := flag.Int64("channel-id", 0, "指定频道 id0=自动从 dialogs 找广播频道)")
accessHash := flag.Int64("access-hash", 0, "指定频道 access_hash")
msgID := flag.Int("msg-id", 0, "指定消息 id0=用频道顶部消息)")
flag.Parse()
if *phone == "" {
fmt.Fprintln(os.Stderr, "缺少 -phone")
os.Exit(2)
}
logger, _ := zap.NewDevelopment()
defer func() { _ = logger.Sync() }()
priv, err := mtprotoedge.LoadOrGenerateRSAKey(*rsaPath)
if err != nil {
logger.Fatal("加载 RSA key 失败", zap.Error(err))
}
host, portStr, err := net.SplitHostPort(*addr)
if err != nil {
logger.Fatal("解析地址失败", zap.Error(err))
}
port, _ := strconv.Atoi(portStr)
client := telegram.NewClient(*apiID, *apiHash, telegram.Options{
PublicKeys: []exchange.PublicKey{{RSA: &priv.PublicKey}},
Resolver: obfuscatedResolver{host: host, port: port},
DCList: dcs.List{Options: []tg.DCOption{
{ID: *dcID, IPAddress: host, Port: port, Static: true},
}},
Logger: logzap.New(logger.Named("client")),
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := client.Run(ctx, func(ctx context.Context) error {
raw := tg.NewClient(client)
// 1. 用户登录(开发码)。
sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{
PhoneNumber: *phone, APIID: *apiID, APIHash: *apiHash,
Settings: tg.CodeSettings{},
})
if err != nil {
return fmt.Errorf("sendCode: %w", err)
}
sentCode, ok := sent.(*tg.AuthSentCode)
if !ok {
return fmt.Errorf("sentCode 类型 = %T", sent)
}
authz, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{
PhoneNumber: *phone, PhoneCodeHash: sentCode.PhoneCodeHash, PhoneCode: *code,
})
if err != nil {
return fmt.Errorf("signIn: %w", err)
}
a, ok := authz.(*tg.AuthAuthorization)
if !ok {
return fmt.Errorf("authorization 类型 = %T", authz)
}
self, _ := a.User.(*tg.User)
fmt.Printf("==== 登录成功: id=%d name=%q phone=%s ====\n", self.ID, self.FirstName, *phone)
// 2. 余额before
balBefore := printStarsBalance(ctx, raw, "扣费前")
// 3. 解析目标频道 + 消息。
var peer *tg.InputPeerChannel
mid := *msgID
if *channelID != 0 {
peer = &tg.InputPeerChannel{ChannelID: *channelID, AccessHash: *accessHash}
} else {
ch, topMsg, err := findBroadcastChannel(ctx, raw)
if err != nil {
return err
}
peer = &tg.InputPeerChannel{ChannelID: ch.ID, AccessHash: ch.AccessHash}
if mid == 0 {
mid = topMsg
}
fmt.Printf(" 目标频道: id=%d title=%q access_hash=%d top_msg=%d\n", ch.ID, ch.Title, ch.AccessHash, mid)
}
if mid == 0 {
return fmt.Errorf("无可用消息 id频道无消息用 -msg-id 指定)")
}
// 4. 发付费 reaction。
rid, _ := randInt64()
fmt.Printf(">> sendPaidReaction: channel=%d msg=%d count=%d\n", peer.ChannelID, mid, *count)
res, err := raw.MessagesSendPaidReaction(ctx, &tg.MessagesSendPaidReactionRequest{
Peer: peer, MsgID: mid, Count: *count, RandomID: rid,
})
if err != nil {
return fmt.Errorf("sendPaidReaction: %w", err)
}
inspectPaidReactionUpdates(res)
// 5. 余额after
balAfter := printStarsBalance(ctx, raw, "扣费后")
fmt.Printf("==== 扣费校验: %d -> %d差 %d期望 -%d====\n", balBefore, balAfter, balBefore-balAfter, *count)
if balBefore-balAfter == int64(*count) {
fmt.Println("==== ✅ PASS付费 reaction 扣费正确 ====")
} else {
fmt.Println("==== ❌ FAIL扣费金额不符 ====")
}
return nil
}); err != nil {
logger.Fatal("运行失败", zap.Error(err))
}
}
func printStarsBalance(ctx context.Context, raw *tg.Client, label string) int64 {
status, err := raw.PaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}})
if err != nil {
fmt.Printf(" [%s] getStarsStatus 失败: %v\n", label, err)
return -1
}
amount, _ := status.Balance.(*tg.StarsAmount)
var bal int64
if amount != nil {
bal = amount.Amount
}
fmt.Printf(" [%s] 余额=%d stars (balance 类型=%T, chats=%d users=%d)\n", label, bal, status.Balance, len(status.Chats), len(status.Users))
return bal
}
// findBroadcastChannel 从 dialogs 找第一个广播频道 + 其顶部消息 id。
func findBroadcastChannel(ctx context.Context, raw *tg.Client) (*tg.Channel, int, error) {
dlgs, err := raw.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{
OffsetPeer: &tg.InputPeerEmpty{}, Limit: 100,
})
if err != nil {
return nil, 0, fmt.Errorf("getDialogs: %w", err)
}
var chats []tg.ChatClass
var dialogs []tg.DialogClass
switch v := dlgs.(type) {
case *tg.MessagesDialogs:
chats, dialogs = v.Chats, v.Dialogs
case *tg.MessagesDialogsSlice:
chats, dialogs = v.Chats, v.Dialogs
default:
return nil, 0, fmt.Errorf("dialogs 类型 = %T", dlgs)
}
topByChannel := make(map[int64]int)
for _, d := range dialogs {
dd, ok := d.(*tg.Dialog)
if !ok {
continue
}
if pc, ok := dd.Peer.(*tg.PeerChannel); ok {
topByChannel[pc.ChannelID] = dd.TopMessage
}
}
for _, c := range chats {
ch, ok := c.(*tg.Channel)
if !ok || !ch.Broadcast || ch.Megagroup {
continue
}
return ch, topByChannel[ch.ID], nil
}
return nil, 0, fmt.Errorf("dialogs 中无广播频道")
}
// inspectPaidReactionUpdates 检查返回的 Updates 是否合法且含 updateMessageReactions/updateStarsBalance。
func inspectPaidReactionUpdates(res tg.UpdatesClass) {
fmt.Printf(" 返回类型=%T\n", res)
var ups []tg.UpdateClass
switch v := res.(type) {
case *tg.Updates:
ups = v.Updates
case *tg.UpdatesCombined:
ups = v.Updates
case *tg.UpdateShort:
ups = []tg.UpdateClass{v.Update}
default:
fmt.Printf(" [警告] 返回非 Updates 子类型DrKLO 会 ClassCastException 崩溃)\n")
return
}
hasReactions, hasBalance := false, false
for _, up := range ups {
switch u := up.(type) {
case *tg.UpdateMessageReactions:
hasReactions = true
paid := 0
for _, rc := range u.Reactions.Results {
if _, ok := rc.Reaction.(*tg.ReactionPaid); ok {
paid = rc.Count
}
}
reactors, _ := u.Reactions.GetTopReactors()
fmt.Printf(" updateMessageReactions: msg=%d paid_count=%d top_reactors=%d\n", u.MsgID, paid, len(reactors))
case *tg.UpdateStarsBalance:
hasBalance = true
amount, _ := u.Balance.(*tg.StarsAmount)
var b int64
if amount != nil {
b = amount.Amount
}
fmt.Printf(" updateStarsBalance: balance=%d\n", b)
}
}
fmt.Printf(" 合法 Updates=true, 含 updateMessageReactions=%v, 含 updateStarsBalance=%v\n", hasReactions, hasBalance)
}
func randInt64() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return time.Now().UnixNano(), err
}
v := int64(binary.LittleEndian.Uint64(b[:]))
if v == 0 {
v = 1
}
return v, nil
}

385
cmd/stickerfetch/main.go Normal file
View file

@ -0,0 +1,385 @@
// Command stickerfetch 从官方 Telegram 拉取贴纸集(messages.getStickerSet),写成 telesrv
// SeedMedia 可直接导入的 seed 目录格式:<out>/<setdir>/set_info.json + stickers/<docid>.tgs。
// 这样 masks / custom-emoji / 系统集等只需把 seed 文件放进 data/sticker-seed 即可被现有
// SeedMedia + handler 识别下发,无需改服务端代码。
//
// 支持的 set spec(命令行参数,可多个):
// short:<shortName> 按 short_name 拉(普通/mask/emoji 集)
// emoji_default_statuses inputStickerSetEmojiDefaultStatuses
// emoji_channel_default_statuses inputStickerSetEmojiChannelDefaultStatuses
// emoji_default_topic_icons inputStickerSetEmojiDefaultTopicIcons
//
// 需登录会话(复用 appearancefetch 的 /tmp/appearance.session,SESSION env 可覆盖)。
//
// 用法: SESSION=/tmp/appearance.session stickerfetch <out_dir> <spec> [spec...]
package main
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/downloader"
"github.com/gotd/td/tg"
)
const (
tdesktopAPIID = 17349
tdesktopAPIHash = "344583e45741c457fe1862106095a5eb"
)
func sessionPath() string {
if p := os.Getenv("SESSION"); p != "" {
return p
}
return "/tmp/appearance.session"
}
func specToInput(spec string) (tg.InputStickerSetClass, string, error) {
switch {
case strings.HasPrefix(spec, "short:"):
name := strings.TrimPrefix(spec, "short:")
return &tg.InputStickerSetShortName{ShortName: name}, name, nil
case spec == "emoji_default_statuses":
return &tg.InputStickerSetEmojiDefaultStatuses{}, "EmojiDefaultStatuses", nil
case spec == "emoji_channel_default_statuses":
return &tg.InputStickerSetEmojiChannelDefaultStatuses{}, "EmojiChannelDefaultStatuses", nil
case spec == "emoji_default_topic_icons":
return &tg.InputStickerSetEmojiDefaultTopicIcons{}, "EmojiDefaultTopicIcons", nil
default:
return nil, "", fmt.Errorf("unknown spec %q", spec)
}
}
// ---- seed JSON 结构(字段对齐 internal/app/files/seed.go 的解析) ----
type attrJSON struct {
Type string `json:"_"`
W int `json:"w,omitempty"`
H int `json:"h,omitempty"`
Alt string `json:"alt,omitempty"`
Mask bool `json:"mask,omitempty"`
Free bool `json:"free,omitempty"`
TextColor bool `json:"text_color,omitempty"`
FileName string `json:"file_name,omitempty"`
Duration float64 `json:"duration,omitempty"`
RoundMessage bool `json:"round_message,omitempty"`
SupportsStreaming bool `json:"supports_streaming,omitempty"`
Voice bool `json:"voice,omitempty"`
Title string `json:"title,omitempty"`
Performer string `json:"performer,omitempty"`
Stickerset *inputSetRefJSON `json:"stickerset,omitempty"`
}
type inputSetRefJSON struct {
ID int64 `json:"id"`
AccessHash int64 `json:"access_hash"`
}
type documentJSON struct {
ID int64 `json:"id"`
AccessHash int64 `json:"access_hash"`
FileReference string `json:"file_reference"`
Date string `json:"date"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
DCID int `json:"dc_id"`
Attributes []attrJSON `json:"attributes"`
}
type packJSON struct {
Emoticon string `json:"emoticon"`
Documents []int64 `json:"documents"`
}
type setJSON struct {
ID int64 `json:"id"`
AccessHash int64 `json:"access_hash"`
Title string `json:"title"`
ShortName string `json:"short_name"`
Count int `json:"count"`
Hash int `json:"hash"`
Official bool `json:"official"`
Masks bool `json:"masks"`
Emojis bool `json:"emojis"`
}
type resultJSON struct {
Type string `json:"_"`
Set setJSON `json:"set"`
Packs []packJSON `json:"packs"`
Documents []documentJSON `json:"documents"`
}
type fileJSON struct {
APICall string `json:"api_call"`
InputSetType string `json:"input_set_type"`
Result resultJSON `json:"result"`
}
func mapAttrs(in []tg.DocumentAttributeClass) []attrJSON {
out := make([]attrJSON, 0, len(in))
for _, a := range in {
switch v := a.(type) {
case *tg.DocumentAttributeImageSize:
out = append(out, attrJSON{Type: "DocumentAttributeImageSize", W: v.W, H: v.H})
case *tg.DocumentAttributeAnimated:
out = append(out, attrJSON{Type: "DocumentAttributeAnimated"})
case *tg.DocumentAttributeSticker:
aj := attrJSON{Type: "DocumentAttributeSticker", Alt: v.Alt, Mask: v.Mask}
if id, ok := v.Stickerset.(*tg.InputStickerSetID); ok {
aj.Stickerset = &inputSetRefJSON{ID: id.ID, AccessHash: id.AccessHash}
}
out = append(out, aj)
case *tg.DocumentAttributeCustomEmoji:
aj := attrJSON{Type: "DocumentAttributeCustomEmoji", Alt: v.Alt, Free: v.Free, TextColor: v.TextColor}
if id, ok := v.Stickerset.(*tg.InputStickerSetID); ok {
aj.Stickerset = &inputSetRefJSON{ID: id.ID, AccessHash: id.AccessHash}
}
out = append(out, aj)
case *tg.DocumentAttributeVideo:
out = append(out, attrJSON{Type: "DocumentAttributeVideo", W: v.W, H: v.H, Duration: v.Duration, RoundMessage: v.RoundMessage, SupportsStreaming: v.SupportsStreaming})
case *tg.DocumentAttributeFilename:
out = append(out, attrJSON{Type: "DocumentAttributeFilename", FileName: v.FileName})
}
}
return out
}
func main() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: SESSION=/tmp/appearance.session stickerfetch <out_dir> <spec> [spec...]\n spec: short:<name> | emoji_default_statuses | emoji_channel_default_statuses | emoji_default_topic_icons")
os.Exit(2)
}
out := os.Args[1]
specs := os.Args[2:]
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute)
defer cancel()
client := telegram.NewClient(tdesktopAPIID, tdesktopAPIHash, telegram.Options{
SessionStorage: &telegram.FileSessionStorage{Path: sessionPath()},
})
if err := client.Run(ctx, func(ctx context.Context) error {
api := client.API()
if status, err := client.Auth().Status(ctx); err != nil || !status.Authorized {
return fmt.Errorf("session %s 未授权(先用 appearancefetch 登录): %v", sessionPath(), err)
}
dl := downloader.NewDownloader()
for _, spec := range specs {
var err error
if spec == "effects" {
err = fetchEffects(ctx, api, dl, out)
} else {
err = fetchSet(ctx, api, dl, out, spec)
}
if err != nil {
fmt.Fprintf(os.Stderr, "skip %s: %v\n", spec, err)
}
}
return nil
}); err != nil {
fmt.Fprintln(os.Stderr, "ERROR:", err)
os.Exit(1)
}
}
func fetchSet(ctx context.Context, api *tg.Client, dl *downloader.Downloader, outRoot, spec string) error {
input, label, err := specToInput(spec)
if err != nil {
return err
}
res, err := api.MessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{Stickerset: input, Hash: 0})
if err != nil {
return fmt.Errorf("getStickerSet: %w", err)
}
full, ok := res.(*tg.MessagesStickerSet)
if !ok {
return fmt.Errorf("got %T, want messagesStickerSet", res)
}
setName := full.Set.ShortName
if setName == "" {
setName = label
}
setDir := filepath.Join(outRoot, fmt.Sprintf("%s_%d", setName, full.Set.ID))
stickersDir := filepath.Join(setDir, "stickers")
if err := os.MkdirAll(stickersDir, 0o755); err != nil {
return err
}
docs := make([]documentJSON, 0, len(full.Documents))
for _, d := range full.Documents {
doc, ok := d.(*tg.Document)
if !ok {
continue
}
// 下载主体 blob → stickers/<docid>.<ext>
ext := ".tgs"
if doc.MimeType == "video/webm" {
ext = ".webm"
} else if doc.MimeType == "image/webp" {
ext = ".webp"
}
path := filepath.Join(stickersDir, fmt.Sprintf("%d%s", doc.ID, ext))
f, err := os.Create(path)
if err != nil {
return err
}
loc := &tg.InputDocumentFileLocation{ID: doc.ID, AccessHash: doc.AccessHash, FileReference: doc.FileReference}
if _, err := dl.Download(api, loc).Stream(ctx, f); err != nil {
f.Close()
return fmt.Errorf("download doc %d: %w", doc.ID, err)
}
f.Close()
docs = append(docs, documentJSON{
ID: doc.ID,
AccessHash: doc.AccessHash,
FileReference: hex.EncodeToString(doc.FileReference),
Date: time.Unix(int64(doc.Date), 0).UTC().Format(time.RFC3339),
MimeType: doc.MimeType,
Size: doc.Size,
DCID: doc.DCID,
Attributes: mapAttrs(doc.Attributes),
})
}
packs := make([]packJSON, 0, len(full.Packs))
for _, p := range full.Packs {
packs = append(packs, packJSON{Emoticon: p.Emoticon, Documents: append([]int64(nil), p.Documents...)})
}
out := fileJSON{
APICall: "messages.getStickerSet",
InputSetType: fmt.Sprintf("%T", input),
Result: resultJSON{
Type: "StickerSet",
Set: setJSON{
ID: full.Set.ID,
AccessHash: full.Set.AccessHash,
Title: full.Set.Title,
ShortName: full.Set.ShortName,
Count: full.Set.Count,
Hash: full.Set.Hash,
Official: full.Set.Official,
Masks: full.Set.Masks,
Emojis: full.Set.Emojis,
},
Packs: packs,
Documents: docs,
},
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(setDir, "set_info.json"), append(b, '\n'), 0o644); err != nil {
return err
}
fmt.Printf("[%s] set=%q id=%d docs=%d masks=%v emojis=%v\n", spec, setName, full.Set.ID, len(docs), full.Set.Masks, full.Set.Emojis)
return nil
}
// ---- 消息特效(messages.getAvailableEffects)----
type effectJSON struct {
ID int64 `json:"id"`
Emoticon string `json:"emoticon"`
StaticIconID int64 `json:"static_icon_id,omitempty"`
EffectStickerID int64 `json:"effect_sticker_id"`
EffectAnimationID int64 `json:"effect_animation_id,omitempty"`
PremiumRequired bool `json:"premium_required,omitempty"`
}
type effectsFileJSON struct {
APICall string `json:"api_call"`
Result struct {
Effects []effectJSON `json:"effects"`
Documents []documentJSON `json:"documents"`
} `json:"result"`
}
// downloadDoc 下载一个文档主体到 dir/<docid>.<ext> 并返回其 seed 元数据。
func downloadDoc(ctx context.Context, api *tg.Client, dl *downloader.Downloader, dir string, doc *tg.Document) (documentJSON, error) {
ext := ".tgs"
switch doc.MimeType {
case "video/webm":
ext = ".webm"
case "image/webp":
ext = ".webp"
}
path := filepath.Join(dir, fmt.Sprintf("%d%s", doc.ID, ext))
f, err := os.Create(path)
if err != nil {
return documentJSON{}, err
}
loc := &tg.InputDocumentFileLocation{ID: doc.ID, AccessHash: doc.AccessHash, FileReference: doc.FileReference}
if _, err := dl.Download(api, loc).Stream(ctx, f); err != nil {
f.Close()
return documentJSON{}, fmt.Errorf("download doc %d: %w", doc.ID, err)
}
f.Close()
return documentJSON{
ID: doc.ID,
AccessHash: doc.AccessHash,
FileReference: hex.EncodeToString(doc.FileReference),
Date: time.Unix(int64(doc.Date), 0).UTC().Format(time.RFC3339),
MimeType: doc.MimeType,
Size: doc.Size,
DCID: doc.DCID,
Attributes: mapAttrs(doc.Attributes),
}, nil
}
func fetchEffects(ctx context.Context, api *tg.Client, dl *downloader.Downloader, outRoot string) error {
res, err := api.MessagesGetAvailableEffects(ctx, 0)
if err != nil {
return fmt.Errorf("getAvailableEffects: %w", err)
}
full, ok := res.(*tg.MessagesAvailableEffects)
if !ok {
return fmt.Errorf("got %T, want messagesAvailableEffects", res)
}
dir := filepath.Join(outRoot, "telegram_effects_export")
docsDir := filepath.Join(dir, "documents")
if err := os.MkdirAll(docsDir, 0o755); err != nil {
return err
}
var out effectsFileJSON
out.APICall = "messages.getAvailableEffects"
for _, d := range full.Documents {
doc, ok := d.(*tg.Document)
if !ok {
continue
}
dj, err := downloadDoc(ctx, api, dl, docsDir, doc)
if err != nil {
return err
}
out.Result.Documents = append(out.Result.Documents, dj)
}
for _, e := range full.Effects {
ej := effectJSON{ID: e.ID, Emoticon: e.Emoticon, EffectStickerID: e.EffectStickerID, PremiumRequired: e.PremiumRequired}
if v, ok := e.GetStaticIconID(); ok {
ej.StaticIconID = v
}
if v, ok := e.GetEffectAnimationID(); ok {
ej.EffectAnimationID = v
}
out.Result.Effects = append(out.Result.Effects, ej)
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "effects.json"), append(b, '\n'), 0o644); err != nil {
return err
}
fmt.Printf("[effects] effects=%d documents=%d\n", len(out.Result.Effects), len(out.Result.Documents))
return nil
}

118
cmd/telesrv-admin/main.go Normal file
View file

@ -0,0 +1,118 @@
package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
cfg, err := loadConfig()
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
pool, err := pgxpool.New(ctx, cfg.PostgresDSN)
if err != nil {
return fmt.Errorf("connect postgres: %w", err)
}
defer pool.Close()
srv, err := newServer(cfg, newReadStore(pool))
if err != nil {
return err
}
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: srv.routes(),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = httpServer.Shutdown(shutdownCtx)
}()
log.Printf("telesrv-admin listening on %s", cfg.Addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
type uiConfig struct {
Addr string
PostgresDSN string
AdminAPIURL string
AdminAPIToken string
Password string
Token string
SessionKey []byte
}
func loadConfig() (uiConfig, error) {
cfg := uiConfig{
Addr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2400"),
PostgresDSN: envOr("TELESRV_POSTGRES_DSN", "postgres://telesrv:telesrv@127.0.0.1:5432/telesrv?sslmode=disable"),
AdminAPIURL: adminAPIURL(envOr("TELESRV_ADMIN_API_ADDR", "127.0.0.1:2399")),
AdminAPIToken: os.Getenv("TELESRV_ADMIN_API_TOKEN"),
Password: os.Getenv("TELESRV_ADMIN_UI_PASSWORD"),
Token: os.Getenv("TELESRV_ADMIN_UI_TOKEN"),
}
if cfg.Password == "" && cfg.Token == "" {
return cfg, fmt.Errorf("TELESRV_ADMIN_UI_PASSWORD or TELESRV_ADMIN_UI_TOKEN is required")
}
if strings.TrimSpace(cfg.AdminAPIToken) == "" {
return cfg, fmt.Errorf("TELESRV_ADMIN_API_TOKEN is required for admin write actions")
}
rawKey := os.Getenv("TELESRV_ADMIN_SESSION_KEY")
if rawKey == "" {
return cfg, fmt.Errorf("TELESRV_ADMIN_SESSION_KEY is required")
}
sum := sha256.Sum256([]byte(rawKey))
cfg.SessionKey = sum[:]
return cfg, nil
}
func adminAPIURL(addr string) string {
addr = strings.TrimSpace(addr)
if addr == "" {
addr = "127.0.0.1:2399"
}
if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") {
return strings.TrimRight(addr, "/")
}
return "http://" + addr
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func newCommandID(prefix string) string {
var b [6]byte
_, _ = rand.Read(b[:])
return prefix + "-" + time.Now().UTC().Format("20060102T150405.000000000") + "-" + hex.EncodeToString(b[:])
}

View file

@ -0,0 +1,766 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
accountSearchLimit = 20
accountListDefaultLimit = 50
accountListMaxLimit = 100
channelSearchLimit = 50
channelListDefaultLimit = 50
channelListMaxLimit = 100
messagePageLimit = 100
)
type readStore struct {
pool *pgxpool.Pool
}
func newReadStore(pool *pgxpool.Pool) *readStore {
return &readStore{pool: pool}
}
type AccountRow struct {
ID int64
Phone string
Username string
FirstName string
LastName string
CreatedAt time.Time
UpdatedAt time.Time
Frozen bool
Reason string
Verified bool
PremiumUntil int64
LastActiveAt time.Time
DeviceCount int
}
type AccountDetail struct {
Account AccountRow
About string
LastSeenAt int64
Verified bool
Support bool
Bot bool
Restriction RestrictionRow
HasRestriction bool
Authorizations []AuthorizationRow
AuditLogs []AuditLogRow
}
type RestrictionRow struct {
Frozen bool
Reason string
Actor string
CommandID string
UpdatedAt time.Time
}
type AuthorizationRow struct {
AuthKeyID int64
Hash int64
Layer int
DeviceModel string
Platform string
SystemVersion string
APIID int
AppVersion string
IP string
PasswordPending bool
CreatedAt time.Time
ActiveAt time.Time
}
type AuditLogRow struct {
ID int64
CommandID string
Actor string
Action string
DryRun bool
Reason string
Status string
Error string
Result string
CreatedAt time.Time
}
type ChannelRow struct {
ID int64
AccessHash int64
CreatorUserID int64
Title string
About string
Username string
Broadcast bool
Megagroup bool
Forum bool
Monoforum bool
Verified bool
Deleted bool
ParticipantsCount int
AdminsCount int
KickedCount int
BannedCount int
TopMessageID int
PinnedMessageID int
PTS int
Date int
CreatedAt time.Time
UpdatedAt time.Time
}
type ChannelDetail struct {
Channel ChannelRow
ChannelJSON string
AuditLogs []AuditLogRow
}
func (s *readStore) SearchAccounts(ctx context.Context, q string) ([]AccountRow, error) {
q = strings.TrimSpace(q)
if q == "" {
return nil, nil
}
id := int64(-1)
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
id = n
}
phone := strings.TrimPrefix(strings.ReplaceAll(q, " ", ""), "+")
phoneRaw := strings.TrimSpace(q)
username := strings.ToLower(strings.TrimPrefix(q, "@"))
rows, err := s.pool.Query(ctx, `
WITH auth AS (
SELECT user_id, max(active_at) AS last_active_at, count(*)::int AS device_count
FROM authorizations
GROUP BY user_id
)
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified,
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(a.device_count, 0)::int,
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
FROM users u
LEFT JOIN account_send_restrictions r ON r.user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
LEFT JOIN auth a ON a.user_id = u.id
WHERE u.id = $1 OR u.phone = $2 OR u.phone = $3 OR lower(u.username) = $4 OR p.username_lower = $4
ORDER BY u.id
LIMIT $5`, id, phone, phoneRaw, username, accountSearchLimit)
if err != nil {
return nil, fmt.Errorf("search accounts: %w", err)
}
defer rows.Close()
out := make([]AccountRow, 0)
for rows.Next() {
var item AccountRow
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (s *readStore) SearchChannels(ctx context.Context, q string) ([]ChannelRow, error) {
q = strings.TrimSpace(q)
if q == "" {
return nil, nil
}
id := int64(-1)
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
id = n
}
username := strings.ToLower(strings.TrimPrefix(q, "@"))
rows, err := s.pool.Query(ctx, `
SELECT c.id, c.access_hash, c.creator_user_id, c.title, c.about,
COALESCE(NULLIF(c.username, ''), p.username_lower, '') AS display_username,
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.deleted,
c.participants_count, c.admins_count, c.kicked_count, c.banned_count,
c.top_message_id, c.pinned_message_id, c.pts, c.date, c.created_at, c.updated_at
FROM channels c
LEFT JOIN peer_usernames p ON p.peer_type = 'channel' AND p.peer_id = c.id
WHERE NOT c.deleted
AND NOT c.monoforum
AND (c.broadcast OR c.megagroup)
AND (
c.id = $1
OR lower(COALESCE(c.username, '')) = $2
OR p.username_lower = $2
OR lower(c.title) LIKE '%' || $2 || '%'
)
ORDER BY c.updated_at DESC, c.id DESC
LIMIT $3`, id, username, channelSearchLimit)
if err != nil {
return nil, fmt.Errorf("search channels: %w", err)
}
defer rows.Close()
return scanChannelRows(rows)
}
func (s *readStore) ListChannels(ctx context.Context, beforeUpdatedUS, beforeID int64, limit int) ([]ChannelRow, bool, error) {
if limit <= 0 {
limit = channelListDefaultLimit
}
if limit > channelListMaxLimit {
limit = channelListMaxLimit
}
rows, err := s.pool.Query(ctx, `
SELECT c.id, c.access_hash, c.creator_user_id, c.title, c.about,
COALESCE(NULLIF(c.username, ''), p.username_lower, '') AS display_username,
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.deleted,
c.participants_count, c.admins_count, c.kicked_count, c.banned_count,
c.top_message_id, c.pinned_message_id, c.pts, c.date, c.created_at, c.updated_at
FROM channels c
LEFT JOIN peer_usernames p ON p.peer_type = 'channel' AND p.peer_id = c.id
WHERE NOT c.deleted
AND NOT c.monoforum
AND (c.broadcast OR c.megagroup)
AND ($1::bigint = 0 OR (c.updated_at, c.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
ORDER BY c.updated_at DESC, c.id DESC
LIMIT $3`, beforeUpdatedUS, beforeID, limit+1)
if err != nil {
return nil, false, fmt.Errorf("list channels: %w", err)
}
defer rows.Close()
out, err := scanChannelRows(rows)
if err != nil {
return nil, false, err
}
hasMore := len(out) > limit
if hasMore {
out = out[:limit]
}
return out, hasMore, nil
}
func (s *readStore) ChannelDetail(ctx context.Context, channelID int64) (ChannelDetail, error) {
var out ChannelDetail
var raw []byte
err := s.pool.QueryRow(ctx, `
SELECT c.id, c.access_hash, c.creator_user_id, c.title, c.about,
COALESCE(NULLIF(c.username, ''), p.username_lower, '') AS display_username,
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.deleted,
c.participants_count, c.admins_count, c.kicked_count, c.banned_count,
c.top_message_id, c.pinned_message_id, c.pts, c.date, c.created_at, c.updated_at,
row_to_json(c)::jsonb
FROM channels c
LEFT JOIN peer_usernames p ON p.peer_type = 'channel' AND p.peer_id = c.id
WHERE c.id = $1
AND NOT c.deleted
AND NOT c.monoforum
AND (c.broadcast OR c.megagroup)`, channelID).Scan(channelScanDestWithRaw(&out.Channel, &raw)...)
if err != nil {
return out, fmt.Errorf("get channel: %w", err)
}
out.ChannelJSON = prettyJSON(raw)
out.AuditLogs, err = s.channelAuditLogs(ctx, channelID)
if err != nil {
return out, err
}
return out, nil
}
type channelScanner interface {
Scan(dest ...any) error
}
func scanChannelRows(rows pgx.Rows) ([]ChannelRow, error) {
out := make([]ChannelRow, 0)
for rows.Next() {
var item ChannelRow
if err := scanChannelRow(rows, &item); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func scanChannelRow(row channelScanner, item *ChannelRow) error {
return row.Scan(channelScanDest(item)...)
}
func channelScanDest(item *ChannelRow) []any {
return []any{
&item.ID, &item.AccessHash, &item.CreatorUserID, &item.Title, &item.About, &item.Username,
&item.Broadcast, &item.Megagroup, &item.Forum, &item.Monoforum, &item.Verified, &item.Deleted,
&item.ParticipantsCount, &item.AdminsCount, &item.KickedCount, &item.BannedCount,
&item.TopMessageID, &item.PinnedMessageID, &item.PTS, &item.Date, &item.CreatedAt, &item.UpdatedAt,
}
}
func channelScanDestWithRaw(item *ChannelRow, raw *[]byte) []any {
dest := channelScanDest(item)
return append(dest, raw)
}
func (s *readStore) ListAccounts(ctx context.Context, beforeActiveUS, beforeID int64, limit int) ([]AccountRow, bool, error) {
if limit <= 0 {
limit = accountListDefaultLimit
}
if limit > accountListMaxLimit {
limit = accountListMaxLimit
}
rows, err := s.pool.Query(ctx, `
WITH auth AS (
SELECT user_id, max(active_at) AS last_active_at, count(*)::int AS device_count
FROM authorizations
GROUP BY user_id
)
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified,
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
auth.last_active_at, auth.device_count,
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
FROM users u
JOIN auth ON auth.user_id = u.id
LEFT JOIN account_send_restrictions r ON r.user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
WHERE NOT u.is_bot
AND ($1::bigint = 0 OR (auth.last_active_at, u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
ORDER BY auth.last_active_at DESC, u.id DESC
LIMIT $3`, beforeActiveUS, beforeID, limit+1)
if err != nil {
return nil, false, fmt.Errorf("list accounts: %w", err)
}
defer rows.Close()
out := make([]AccountRow, 0, limit+1)
for rows.Next() {
var item AccountRow
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username); err != nil {
return nil, false, err
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, false, err
}
hasMore := len(out) > limit
if hasMore {
out = out[:limit]
}
return out, hasMore, nil
}
func (s *readStore) AccountDetail(ctx context.Context, userID int64) (AccountDetail, error) {
var out AccountDetail
err := s.pool.QueryRow(ctx, `
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
u.about, u.last_seen_at, u.verified, u.support, u.is_bot,
COALESCE(r.frozen, false), COALESCE(r.reason, ''),
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
FROM users u
LEFT JOIN account_send_restrictions r ON r.user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
WHERE u.id = $1`, userID).Scan(
&out.Account.ID, &out.Account.Phone, &out.Account.Username, &out.Account.FirstName, &out.Account.LastName,
&out.Account.CreatedAt, &out.Account.UpdatedAt, &out.About, &out.LastSeenAt, &out.Verified, &out.Support, &out.Bot,
&out.Account.Frozen, &out.Account.Reason, &out.Account.PremiumUntil, &out.Account.Username,
)
if err != nil {
return out, fmt.Errorf("get account: %w", err)
}
out.Restriction, out.HasRestriction, err = s.restriction(ctx, userID)
if err != nil {
return out, err
}
out.Authorizations, err = s.authorizations(ctx, userID)
if err != nil {
return out, err
}
out.AuditLogs, err = s.auditLogs(ctx, userID)
if err != nil {
return out, err
}
return out, nil
}
func (s *readStore) restriction(ctx context.Context, userID int64) (RestrictionRow, bool, error) {
var r RestrictionRow
err := s.pool.QueryRow(ctx, `
SELECT frozen, reason, actor, command_id, updated_at
FROM account_send_restrictions
WHERE user_id = $1`, userID).Scan(&r.Frozen, &r.Reason, &r.Actor, &r.CommandID, &r.UpdatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return RestrictionRow{}, false, nil
}
return RestrictionRow{}, false, fmt.Errorf("get restriction: %w", err)
}
return r, true, nil
}
func (s *readStore) authorizations(ctx context.Context, userID int64) ([]AuthorizationRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT auth_key_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending, created_at, active_at
FROM authorizations
WHERE user_id = $1
ORDER BY active_at DESC, created_at DESC
LIMIT 100`, userID)
if err != nil {
return nil, fmt.Errorf("list authorizations: %w", err)
}
defer rows.Close()
out := make([]AuthorizationRow, 0)
for rows.Next() {
var a AuthorizationRow
if err := rows.Scan(&a.AuthKeyID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion, &a.APIID, &a.AppVersion, &a.IP, &a.PasswordPending, &a.CreatedAt, &a.ActiveAt); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *readStore) auditLogs(ctx context.Context, userID int64) ([]AuditLogRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, command_id, actor, action, dry_run, reason, status, error, result, created_at
FROM admin_audit_logs
WHERE target_user_id = $1
ORDER BY id DESC
LIMIT 30`, userID)
if err != nil {
return nil, fmt.Errorf("list audit logs: %w", err)
}
defer rows.Close()
out := make([]AuditLogRow, 0)
for rows.Next() {
var a AuditLogRow
var result []byte
if err := rows.Scan(&a.ID, &a.CommandID, &a.Actor, &a.Action, &a.DryRun, &a.Reason, &a.Status, &a.Error, &result, &a.CreatedAt); err != nil {
return nil, err
}
a.Result = prettyJSON(result)
out = append(out, a)
}
return out, rows.Err()
}
func (s *readStore) channelAuditLogs(ctx context.Context, channelID int64) ([]AuditLogRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, command_id, actor, action, dry_run, reason, status, error, result, created_at
FROM admin_audit_logs
WHERE target_peer_type = 'channel' AND target_peer_id = $1
ORDER BY id DESC
LIMIT 30`, channelID)
if err != nil {
return nil, fmt.Errorf("list channel audit logs: %w", err)
}
defer rows.Close()
out := make([]AuditLogRow, 0)
for rows.Next() {
var a AuditLogRow
var result []byte
if err := rows.Scan(&a.ID, &a.CommandID, &a.Actor, &a.Action, &a.DryRun, &a.Reason, &a.Status, &a.Error, &result, &a.CreatedAt); err != nil {
return nil, err
}
a.Result = prettyJSON(result)
out = append(out, a)
}
return out, rows.Err()
}
type MessageRow struct {
OwnerUserID int64
BoxID int
PrivateMessageID int64
MessageSenderID int64
PeerID int64
FromUserID int64
Date int64
Outgoing bool
Body string
PTS int
Deleted bool
Media string
}
type GroupMessageRow struct {
ChannelID int64
ID int
SenderUserID int64
FromPeerType string
FromPeerID int64
Date int64
Post bool
Body string
PTS int
Deleted bool
Media string
ViewsCount int
EditDate int
Pinned bool
}
func (s *readStore) ListMessages(ctx context.Context, ownerUserID, peerID int64, beforeDate int64, beforeID int, limit int) ([]MessageRow, error) {
if limit <= 0 || limit > messagePageLimit {
limit = messagePageLimit
}
rows, err := s.pool.Query(ctx, `
SELECT owner_user_id, box_id, private_message_id, message_sender_id, peer_id, from_user_id,
message_date, outgoing, body, pts, deleted, COALESCE(media, '{}'::jsonb)
FROM message_boxes
WHERE owner_user_id = $1 AND peer_type = 'user' AND peer_id = $2
AND ($3::bigint = 0 OR (message_date, box_id) < ($3::bigint, $4::int))
ORDER BY message_date DESC, box_id DESC
LIMIT $5`, ownerUserID, peerID, beforeDate, beforeID, limit)
if err != nil {
return nil, fmt.Errorf("list messages: %w", err)
}
defer rows.Close()
out := make([]MessageRow, 0)
for rows.Next() {
var item MessageRow
var media []byte
if err := rows.Scan(&item.OwnerUserID, &item.BoxID, &item.PrivateMessageID, &item.MessageSenderID, &item.PeerID, &item.FromUserID, &item.Date, &item.Outgoing, &item.Body, &item.PTS, &item.Deleted, &media); err != nil {
return nil, err
}
item.Media = prettyJSON(media)
out = append(out, item)
}
return out, rows.Err()
}
func (s *readStore) ListGroupMessages(ctx context.Context, channelID int64, beforeDate int64, beforeID int, limit int) ([]GroupMessageRow, error) {
if limit <= 0 || limit > messagePageLimit {
limit = messagePageLimit
}
rows, err := s.pool.Query(ctx, `
SELECT channel_id, id, sender_user_id, from_peer_type, from_peer_id,
message_date, post, body, pts, deleted, COALESCE(media, '{}'::jsonb),
views_count, edit_date, pinned
FROM channel_messages
WHERE channel_id = $1 AND NOT deleted
AND ($2::bigint = 0 OR (message_date, id) < ($2::bigint, $3::int))
ORDER BY message_date DESC, id DESC
LIMIT $4`, channelID, beforeDate, beforeID, limit)
if err != nil {
return nil, fmt.Errorf("list group messages: %w", err)
}
defer rows.Close()
out := make([]GroupMessageRow, 0)
for rows.Next() {
var item GroupMessageRow
var media []byte
if err := rows.Scan(&item.ChannelID, &item.ID, &item.SenderUserID, &item.FromPeerType, &item.FromPeerID, &item.Date, &item.Post, &item.Body, &item.PTS, &item.Deleted, &media, &item.ViewsCount, &item.EditDate, &item.Pinned); err != nil {
return nil, err
}
item.Media = prettyJSON(media)
out = append(out, item)
}
return out, rows.Err()
}
type MessageDetail struct {
Message MessageRow
MessageJSON string
DialogJSON string
PrivateJSON string
UpdateEvents []UpdateEventRow
Outbox []OutboxRow
}
type GroupMessageDetail struct {
Message GroupMessageRow
MessageJSON string
ChannelJSON string
UpdateEvents []ChannelUpdateEventRow
}
type UpdateEventRow struct {
PTS int
PTSCount int
Type string
Date int64
JSON string
}
type ChannelUpdateEventRow struct {
PTS int
PTSCount int
Type string
MessageID int
Date int64
SenderUserID int64
JSON string
}
type OutboxRow struct {
ID int64
TargetUserID int64
PTS int
EventType string
Status string
Attempts int
CreatedAt time.Time
UpdatedAt time.Time
}
func (s *readStore) MessageDetail(ctx context.Context, ownerUserID int64, msgID int) (MessageDetail, error) {
var out MessageDetail
var media []byte
var messageJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT owner_user_id, box_id, private_message_id, message_sender_id, peer_id, from_user_id,
message_date, outgoing, body, pts, deleted, COALESCE(media, '{}'::jsonb), row_to_json(mb)::jsonb
FROM message_boxes mb
WHERE owner_user_id = $1 AND box_id = $2`, ownerUserID, msgID).Scan(
&out.Message.OwnerUserID, &out.Message.BoxID, &out.Message.PrivateMessageID, &out.Message.MessageSenderID, &out.Message.PeerID,
&out.Message.FromUserID, &out.Message.Date, &out.Message.Outgoing, &out.Message.Body, &out.Message.PTS, &out.Message.Deleted,
&media, &messageJSON,
)
if err != nil {
return out, fmt.Errorf("get message: %w", err)
}
out.Message.Media = prettyJSON(media)
out.MessageJSON = prettyJSON(messageJSON)
out.DialogJSON, _ = s.rowJSON(ctx, `SELECT row_to_json(d)::jsonb FROM dialogs d WHERE user_id = $1 AND peer_type = 'user' AND peer_id = $2`, ownerUserID, out.Message.PeerID)
out.PrivateJSON, _ = s.rowJSON(ctx, `SELECT row_to_json(pm)::jsonb FROM private_messages pm WHERE id = $1`, out.Message.PrivateMessageID)
events, err := s.updateEvents(ctx, ownerUserID, msgID)
if err != nil {
return out, err
}
out.UpdateEvents = events
outbox, err := s.outbox(ctx, ownerUserID, out.Message.PTS)
if err != nil {
return out, err
}
out.Outbox = outbox
return out, nil
}
func (s *readStore) GroupMessageDetail(ctx context.Context, channelID int64, msgID int) (GroupMessageDetail, error) {
var out GroupMessageDetail
var media []byte
var messageJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT channel_id, id, sender_user_id, from_peer_type, from_peer_id,
message_date, post, body, pts, deleted, COALESCE(media, '{}'::jsonb),
views_count, edit_date, pinned, row_to_json(cm)::jsonb
FROM channel_messages cm
WHERE channel_id = $1 AND id = $2`, channelID, msgID).Scan(
&out.Message.ChannelID, &out.Message.ID, &out.Message.SenderUserID, &out.Message.FromPeerType, &out.Message.FromPeerID,
&out.Message.Date, &out.Message.Post, &out.Message.Body, &out.Message.PTS, &out.Message.Deleted, &media,
&out.Message.ViewsCount, &out.Message.EditDate, &out.Message.Pinned, &messageJSON,
)
if err != nil {
return out, fmt.Errorf("get group message: %w", err)
}
out.Message.Media = prettyJSON(media)
out.MessageJSON = prettyJSON(messageJSON)
out.ChannelJSON, _ = s.rowJSON(ctx, `SELECT row_to_json(c)::jsonb FROM channels c WHERE id = $1`, channelID)
events, err := s.channelUpdateEvents(ctx, channelID, msgID)
if err != nil {
return out, err
}
out.UpdateEvents = events
return out, nil
}
func (s *readStore) rowJSON(ctx context.Context, sql string, args ...any) (string, error) {
var raw []byte
if err := s.pool.QueryRow(ctx, sql, args...).Scan(&raw); err != nil {
return "", err
}
return prettyJSON(raw), nil
}
func (s *readStore) updateEvents(ctx context.Context, ownerUserID int64, msgID int) ([]UpdateEventRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT pts, pts_count, event_type, date, row_to_json(e)::jsonb
FROM user_update_events e
WHERE user_id = $1 AND (
message_box_id = $2 OR message_ids @> $3::jsonb
)
ORDER BY pts DESC
LIMIT 20`, ownerUserID, msgID, fmt.Sprintf("[%d]", msgID))
if err != nil {
return nil, fmt.Errorf("list update events: %w", err)
}
defer rows.Close()
out := make([]UpdateEventRow, 0)
for rows.Next() {
var e UpdateEventRow
var raw []byte
if err := rows.Scan(&e.PTS, &e.PTSCount, &e.Type, &e.Date, &raw); err != nil {
return nil, err
}
e.JSON = prettyJSON(raw)
out = append(out, e)
}
return out, rows.Err()
}
func (s *readStore) channelUpdateEvents(ctx context.Context, channelID int64, msgID int) ([]ChannelUpdateEventRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT pts, pts_count, event_type, message_id, date, sender_user_id, row_to_json(e)::jsonb
FROM channel_update_events e
WHERE channel_id = $1 AND (
message_id = $2 OR message_ids @> $3::jsonb
)
ORDER BY pts DESC
LIMIT 20`, channelID, msgID, fmt.Sprintf("[%d]", msgID))
if err != nil {
return nil, fmt.Errorf("list channel update events: %w", err)
}
defer rows.Close()
out := make([]ChannelUpdateEventRow, 0)
for rows.Next() {
var e ChannelUpdateEventRow
var raw []byte
if err := rows.Scan(&e.PTS, &e.PTSCount, &e.Type, &e.MessageID, &e.Date, &e.SenderUserID, &raw); err != nil {
return nil, err
}
e.JSON = prettyJSON(raw)
out = append(out, e)
}
return out, rows.Err()
}
func (s *readStore) outbox(ctx context.Context, targetUserID int64, pts int) ([]OutboxRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, target_user_id, pts, event_type, status, attempts, created_at, updated_at
FROM dispatch_outbox
WHERE target_user_id = $1 AND pts = $2
ORDER BY id DESC
LIMIT 20`, targetUserID, pts)
if err != nil {
return nil, fmt.Errorf("list outbox: %w", err)
}
defer rows.Close()
out := make([]OutboxRow, 0)
for rows.Next() {
var row OutboxRow
if err := rows.Scan(&row.ID, &row.TargetUserID, &row.PTS, &row.EventType, &row.Status, &row.Attempts, &row.CreatedAt, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
func prettyJSON(raw []byte) string {
if len(raw) == 0 {
return "{}"
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return string(raw)
}
out, err := json.MarshalIndent(v, "", " ")
if err != nil {
return string(raw)
}
return string(out)
}

711
cmd/telesrv-admin/server.go Normal file
View file

@ -0,0 +1,711 @@
package main
import (
"bytes"
"context"
"crypto/subtle"
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"path"
"strconv"
"strings"
"time"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
//go:embed web/dist
var webDist embed.FS
type server struct {
cfg uiConfig
read *readStore
web fs.FS
webServer http.Handler
}
func newServer(cfg uiConfig, read *readStore) (*server, error) {
web, err := fs.Sub(webDist, "web/dist")
if err != nil {
return nil, err
}
return &server{
cfg: cfg,
read: read,
web: web,
webServer: http.FileServer(http.FS(web)),
}, nil
}
func (s *server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/login", s.handleAPILogin)
mux.HandleFunc("POST /api/logout", s.handleAPILogout)
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
mux.Handle("GET /api/channels/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleChannelDetailAPI)))
mux.Handle("GET /api/messages", s.requireAuthAPI(http.HandlerFunc(s.handleMessagesAPI)))
mux.Handle("GET /api/messages/detail", s.requireAuthAPI(http.HandlerFunc(s.handleMessageDetailAPI)))
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
mux.Handle("POST /api/actions/freeze-send", s.requireAuthAPI(http.HandlerFunc(s.handleFreezeSendAPI)))
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI)))
mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
mux.Handle("POST /api/actions/delete-history", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteHistoryAPI)))
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
writeAPIError(w, http.StatusNotFound, "api route not found")
})
mux.HandleFunc("/", s.handleApp)
return mux
}
type actorKey struct{}
func (s *server) requireAuthAPI(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
writeAPIError(w, http.StatusUnauthorized, "not authenticated")
return
}
claims, ok := verifySession(s.cfg.SessionKey, cookie.Value, time.Now())
if !ok {
clearSessionCookie(w)
writeAPIError(w, http.StatusUnauthorized, "not authenticated")
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), actorKey{}, claims.Actor)))
})
}
func actorFromContext(ctx context.Context) string {
if actor, ok := ctx.Value(actorKey{}).(string); ok && actor != "" {
return actor
}
return "admin"
}
func (s *server) handleApp(w http.ResponseWriter, r *http.Request) {
clean := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if clean != "." && clean != "" {
if info, err := fs.Stat(s.web, clean); err == nil && !info.IsDir() {
s.webServer.ServeHTTP(w, r)
return
}
}
r2 := r.Clone(r.Context())
r2.URL.Path = "/"
s.webServer.ServeHTTP(w, r2)
}
type loginRequest struct {
Secret string `json:"secret"`
}
func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := decodeJSON(r, &req); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if !s.validSecret(req.Secret) {
writeAPIError(w, http.StatusUnauthorized, "invalid credential")
return
}
value, err := signSession(s.cfg.SessionKey, sessionClaims{
Actor: "admin",
Exp: time.Now().Add(12 * time.Hour).Unix(),
Nonce: newCommandID("sess"),
})
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: value,
Path: "/",
MaxAge: int((12 * time.Hour).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]any{"actor": "admin"})
}
func (s *server) validSecret(secret string) bool {
if s.cfg.Password != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Password)) == 1 {
return true
}
if s.cfg.Token != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Token)) == 1 {
return true
}
return false
}
func (s *server) handleAPILogout(w http.ResponseWriter, _ *http.Request) {
clearSessionCookie(w)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"actor": actorFromContext(r.Context())})
}
func (s *server) handleAccountsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
q := r.URL.Query().Get("q")
beforeID, _ := parseInt64(r.URL.Query().Get("before_id"))
beforeActiveUS, _ := parseInt64(r.URL.Query().Get("before_active_us"))
limit, _ := parseInt(r.URL.Query().Get("limit"))
rows := []AccountRow{}
hasMore := false
var err error
if strings.TrimSpace(q) != "" {
rows, err = s.read.SearchAccounts(r.Context(), q)
} else {
rows, hasMore, err = s.read.ListAccounts(r.Context(), beforeActiveUS, beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := int64(0)
nextBeforeActiveUS := int64(0)
if hasMore && len(rows) > 0 {
last := rows[len(rows)-1]
nextBeforeID = last.ID
nextBeforeActiveUS = last.LastActiveAt.UnixMicro()
}
if limit <= 0 {
limit = accountListDefaultLimit
}
if limit > accountListMaxLimit {
limit = accountListMaxLimit
}
writeJSON(w, http.StatusOK, map[string]any{
"query": q,
"limit": limit,
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
"next_before_active_us": nextBeforeActiveUS,
"listing": strings.TrimSpace(q) == "",
})
}
func (s *server) handleAccountDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
userID, err := parseInt64(r.PathValue("id"))
if err != nil || userID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid id")
return
}
detail, err := s.read.AccountDetail(r.Context(), userID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, detail)
}
func (s *server) handleChannelsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
q := r.URL.Query().Get("q")
beforeID, _ := parseInt64(r.URL.Query().Get("before_id"))
beforeUpdatedUS, _ := parseInt64(r.URL.Query().Get("before_updated_us"))
limit, _ := parseInt(r.URL.Query().Get("limit"))
rows := []ChannelRow{}
hasMore := false
var err error
if strings.TrimSpace(q) != "" {
rows, err = s.read.SearchChannels(r.Context(), q)
} else {
rows, hasMore, err = s.read.ListChannels(r.Context(), beforeUpdatedUS, beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := int64(0)
nextBeforeUpdatedUS := int64(0)
if hasMore && len(rows) > 0 {
last := rows[len(rows)-1]
nextBeforeID = last.ID
nextBeforeUpdatedUS = last.UpdatedAt.UnixMicro()
}
if limit <= 0 {
limit = channelListDefaultLimit
}
if limit > channelListMaxLimit {
limit = channelListMaxLimit
}
writeJSON(w, http.StatusOK, map[string]any{
"query": q,
"limit": limit,
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
"next_before_updated_us": nextBeforeUpdatedUS,
"listing": strings.TrimSpace(q) == "",
})
}
func (s *server) handleChannelDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
channelID, err := parseInt64(r.PathValue("id"))
if err != nil || channelID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid id")
return
}
detail, err := s.read.ChannelDetail(r.Context(), channelID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, detail)
}
func (s *server) handleMessagesAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
q := r.URL.Query()
owner, _ := parseInt64(q.Get("owner_user_id"))
peer, _ := parseInt64(q.Get("peer_id"))
beforeDate, _ := parseInt64(q.Get("before_date"))
beforeID, _ := parseInt(q.Get("before_id"))
limit, _ := parseInt(q.Get("limit"))
rows := []MessageRow{}
var err error
if owner > 0 && peer > 0 {
rows, err = s.read.ListMessages(r.Context(), owner, peer, beforeDate, beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"owner_user_id": owner,
"peer_id": peer,
"before_date": beforeDate,
"before_id": beforeID,
"limit": limit,
"rows": rows,
})
}
func (s *server) handleMessageDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
owner, err1 := parseInt64(r.URL.Query().Get("owner_user_id"))
msgID, err2 := parseInt(r.URL.Query().Get("msg_id"))
if err1 != nil || err2 != nil || owner <= 0 || msgID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid owner/msg_id")
return
}
detail, err := s.read.MessageDetail(r.Context(), owner, msgID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, detail)
}
func (s *server) handleGroupMessagesAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
q := r.URL.Query()
channelID, _ := parseInt64(q.Get("channel_id"))
beforeDate, _ := parseInt64(q.Get("before_date"))
beforeID, _ := parseInt(q.Get("before_id"))
limit, _ := parseInt(q.Get("limit"))
rows := []GroupMessageRow{}
var err error
if channelID > 0 {
rows, err = s.read.ListGroupMessages(r.Context(), channelID, beforeDate, beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
if limit <= 0 || limit > messagePageLimit {
limit = messagePageLimit
}
writeJSON(w, http.StatusOK, map[string]any{
"channel_id": channelID,
"before_date": beforeDate,
"before_id": beforeID,
"limit": limit,
"rows": rows,
})
}
func (s *server) handleGroupMessageDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
channelID, err1 := parseInt64(r.URL.Query().Get("channel_id"))
msgID, err2 := parseInt(r.URL.Query().Get("msg_id"))
if err1 != nil || err2 != nil || channelID <= 0 || msgID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid channel_id/msg_id")
return
}
detail, err := s.read.GroupMessageDetail(r.Context(), channelID, msgID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, detail)
}
type freezeSendAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Frozen bool `json:"frozen"`
}
func (s *server) handleFreezeSendAPI(w http.ResponseWriter, r *http.Request) {
var body freezeSendAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetSendFrozenRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "freeze-send"),
UserID: body.UserID,
Frozen: body.Frozen,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/freeze-send", req)
writeCommandResultAPI(w, result, err)
}
type grantPremiumAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Months int `json:"months"`
}
func (s *server) handleGrantPremiumAPI(w http.ResponseWriter, r *http.Request) {
var body grantPremiumAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.GrantPremiumRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "grant-premium"),
UserID: body.UserID,
Months: body.Months,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/grant-premium", req)
writeCommandResultAPI(w, result, err)
}
type setVerifiedAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Verified bool `json:"verified"`
}
func (s *server) handleSetVerifiedAPI(w http.ResponseWriter, r *http.Request) {
var body setVerifiedAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetVerifiedRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-verified"),
UserID: body.UserID,
Verified: body.Verified,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-verified", req)
writeCommandResultAPI(w, result, err)
}
type setChannelVerifiedAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
Verified bool `json:"verified"`
}
func (s *server) handleSetChannelVerifiedAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelVerifiedAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelVerifiedRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-verified"),
ChannelID: body.ChannelID,
Verified: body.Verified,
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-verified", req)
writeCommandResultAPI(w, result, err)
}
type revokeSessionsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Hash int64 `json:"hash"`
KeepHash int64 `json:"keep_hash"`
RevokeAll bool `json:"revoke_all"`
}
func (s *server) handleRevokeSessionsAPI(w http.ResponseWriter, r *http.Request) {
var body revokeSessionsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.RevokeSessionsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "revoke-sessions"),
UserID: body.UserID,
Hash: body.Hash,
KeepHash: body.KeepHash,
RevokeAll: body.RevokeAll,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/revoke-sessions", req)
writeCommandResultAPI(w, result, err)
}
type deleteMessagesAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
OwnerUserID int64 `json:"owner_user_id"`
PeerID int64 `json:"peer_id"`
IDs []int `json:"ids"`
Revoke bool `json:"revoke"`
}
func (s *server) handleDeleteMessagesAPI(w http.ResponseWriter, r *http.Request) {
var body deleteMessagesAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.DeletePrivateMessagesRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-messages"),
OwnerUserID: body.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: body.PeerID},
IDs: body.IDs,
Revoke: body.Revoke,
}
result, err := s.callAdminAPI(r.Context(), "/v1/messages/delete", req)
writeCommandResultAPI(w, result, err)
}
type deleteHistoryAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
OwnerUserID int64 `json:"owner_user_id"`
PeerID int64 `json:"peer_id"`
MaxID int `json:"max_id"`
MinDate int `json:"min_date"`
MaxDate int `json:"max_date"`
MaxBatches int `json:"max_batches"`
JustClear bool `json:"just_clear"`
Revoke bool `json:"revoke"`
}
func (s *server) handleDeleteHistoryAPI(w http.ResponseWriter, r *http.Request) {
var body deleteHistoryAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.DeletePrivateHistoryRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-history"),
OwnerUserID: body.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: body.PeerID},
MaxID: body.MaxID,
MinDate: body.MinDate,
MaxDate: body.MaxDate,
JustClear: body.JustClear,
Revoke: body.Revoke,
MaxBatches: body.MaxBatches,
}
result, err := s.callAdminAPI(r.Context(), "/v1/messages/delete-history", req)
writeCommandResultAPI(w, result, err)
}
func (s *server) commandMetaFromAPI(r *http.Request, commandID, reason string, confirm bool, prefix string) admin.CommandMeta {
commandID = strings.TrimSpace(commandID)
if confirm && strings.HasPrefix(commandID, "dry-") {
commandID = ""
}
dryRun := !confirm
if commandID == "" {
scope := "dry"
if !dryRun {
scope = "exec"
}
commandID = newCommandID(scope + "-" + prefix)
}
return admin.CommandMeta{
CommandID: commandID,
Actor: actorFromContext(r.Context()),
Reason: reason,
DryRun: dryRun,
}
}
func (s *server) callAdminAPI(ctx context.Context, apiPath string, payload any) (admin.CommandResult, error) {
body, err := json.Marshal(payload)
if err != nil {
return admin.CommandResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.AdminAPIURL+apiPath, bytes.NewReader(body))
if err != nil {
return admin.CommandResult{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return admin.CommandResult{}, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var result admin.CommandResult
if err := json.Unmarshal(raw, &result); err != nil {
return result, fmt.Errorf("admin api %s: status=%d body=%s", apiPath, resp.StatusCode, string(raw))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if result.Error == "" {
result.Error = resp.Status
}
return result, errors.New(result.Error)
}
return result, nil
}
func decodeAction(w http.ResponseWriter, r *http.Request, dst any) bool {
if err := decodeJSON(r, dst); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return false
}
return true
}
func decodeJSON(r *http.Request, dst any) error {
defer r.Body.Close()
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return err
}
return nil
}
func writeCommandResultAPI(w http.ResponseWriter, result admin.CommandResult, err error) {
if err != nil {
if result.Status == "" {
result.Status = "failed"
}
if result.Message == "" {
result.Message = "command failed"
}
if result.Error == "" {
result.Error = err.Error()
}
writeJSON(w, http.StatusBadGateway, result)
return
}
writeJSON(w, http.StatusOK, result)
}
func writeAPIError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]any{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func parseInt64(v string) (int64, error) {
v = strings.TrimSpace(v)
if v == "" {
return 0, nil
}
return strconv.ParseInt(v, 10, 64)
}
func parseInt(v string) (int, error) {
n, err := parseInt64(v)
return int(n), err
}
func boolValue(v bool) string {
if v {
return "1"
}
return "0"
}
func displayPhone(v string) string {
v = strings.TrimSpace(v)
if v == "" || strings.HasPrefix(v, "+") {
return v
}
for _, r := range v {
if r < '0' || r > '9' {
return v
}
}
return "+" + v
}
func channelKind(ch ChannelRow) string {
if ch.Broadcast && !ch.Megagroup {
return "频道"
}
if ch.Megagroup {
if ch.Forum {
return "超级群/论坛"
}
return "超级群"
}
return "频道/群"
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}

View file

@ -0,0 +1,68 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"time"
)
const sessionCookieName = "telesrv_admin_session"
type sessionClaims struct {
Actor string `json:"actor"`
Exp int64 `json:"exp"`
Nonce string `json:"nonce"`
}
func signSession(key []byte, claims sessionClaims) (string, error) {
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
encPayload := base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, key)
mac.Write([]byte(encPayload))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return encPayload + "." + sig, nil
}
func verifySession(key []byte, value string, now time.Time) (sessionClaims, bool) {
parts := strings.Split(value, ".")
if len(parts) != 2 {
return sessionClaims{}, false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(parts[0]))
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(want)) != 1 {
return sessionClaims{}, false
}
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return sessionClaims{}, false
}
var claims sessionClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return sessionClaims{}, false
}
if claims.Actor == "" || claims.Exp <= now.Unix() {
return sessionClaims{}, false
}
return claims, true
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}

View file

@ -0,0 +1,44 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestSignedSessionRoundTripAndTamper(t *testing.T) {
key := []byte("01234567890123456789012345678901")
now := time.Unix(1_700_000_000, 0)
value, err := signSession(key, sessionClaims{Actor: "admin", Exp: now.Add(time.Hour).Unix(), Nonce: "n"})
if err != nil {
t.Fatalf("signSession: %v", err)
}
claims, ok := verifySession(key, value, now)
if !ok || claims.Actor != "admin" {
t.Fatalf("verify ok=%v claims=%+v", ok, claims)
}
if _, ok := verifySession(key, value+"x", now); ok {
t.Fatal("tampered session verified")
}
if _, ok := verifySession(key, value, now.Add(2*time.Hour)); ok {
t.Fatal("expired session verified")
}
}
func TestSPAFallbackSmoke(t *testing.T) {
srv, err := newServer(uiConfig{SessionKey: []byte("01234567890123456789012345678901")}, nil)
if err != nil {
t.Fatalf("newServer: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/accounts", nil)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `<div id="root"></div>`) {
t.Fatalf("spa body missing root: %s", rec.Body.String())
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
cmd/telesrv-admin/web/dist/index.html vendored Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title>
<script type="module" crossorigin src="/assets/index-m3onrdER.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-IDEWWIS9.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1017
cmd/telesrv-admin/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
{
"name": "telesrv-admin-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^6.0.3",
"typescript": "^5.6.3",
"vite": "^8.1.0"
}
}

View file

@ -0,0 +1,48 @@
import { useEffect, useState } from "react";
import { api, APIError } from "./api";
import { BootScreen, Shell } from "./components/Layout";
import { LoginPage } from "./pages/LoginPage";
import { Routes } from "./pages/Routes";
import { currentRoute, type RouteState } from "./routing";
export function App() {
const [actor, setActor] = useState<string | null | undefined>(undefined);
const [route, setRoute] = useState<RouteState>(() => currentRoute());
useEffect(() => {
const onPopState = () => setRoute(currentRoute());
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
useEffect(() => {
api.session()
.then((session) => setActor(session.actor))
.catch((error) => {
if (error instanceof APIError && error.status === 401) {
setActor(null);
return;
}
setActor(null);
});
}, []);
const navigate = (href: string) => {
window.history.pushState(null, "", href);
setRoute(currentRoute());
};
if (actor === undefined) {
return <BootScreen />;
}
if (actor === null) {
return <LoginPage onLogin={setActor} />;
}
return (
<Shell actor={actor} route={route} navigate={navigate} onLogout={() => setActor(null)}>
<Routes route={route} navigate={navigate} />
</Shell>
);
}

View file

@ -0,0 +1,72 @@
import type {
AccountDetail,
AccountListResponse,
ChannelDetail,
ChannelListResponse,
CommandResult,
GroupMessageDetail,
GroupMessageListResponse,
MessageDetail,
MessageListResponse
} from "./types";
export class APIError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
...(init.headers ?? {})
},
...init
});
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
const message = data?.error || data?.Error || data?.message || response.statusText;
throw new APIError(response.status, message);
}
return data as T;
}
export function errorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export const api = {
session: () => request<{ actor: string }>("/api/session"),
login: (secret: string) => request<{ actor: string }>("/api/login", {
method: "POST",
body: JSON.stringify({ secret })
}),
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
messages: (params: URLSearchParams) => request<MessageListResponse>(`/api/messages?${params.toString()}`),
message: (ownerUserID: number, msgID: number) => {
const params = new URLSearchParams({ owner_user_id: String(ownerUserID), msg_id: String(msgID) });
return request<MessageDetail>(`/api/messages/detail?${params.toString()}`);
},
groupMessages: (params: URLSearchParams) => request<GroupMessageListResponse>(`/api/messages/groups?${params.toString()}`),
groupMessage: (channelID: number, msgID: number) => {
const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) });
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
},
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
method: "POST",
body: JSON.stringify(payload)
})
};

View file

@ -0,0 +1,146 @@
import { CheckCircle2, CircleAlert, FileJson, Loader2, Play, X } from "lucide-react";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import type { CommandResult } from "../types";
import { Alert, JsonBlock } from "./ui";
type ActionTone = "neutral" | "warn" | "danger";
export function ActionButton({
label,
path,
payload,
icon,
compact = false,
tone = "danger",
onDone
}: {
label: string;
path: string;
payload: () => Record<string, unknown>;
icon?: ReactNode;
compact?: boolean;
tone?: ActionTone;
onDone?: () => void;
}) {
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [result, setResult] = useState<CommandResult | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
function reset() {
setReason("");
setResult(null);
setError("");
}
async function run(confirm: boolean) {
if (!reason.trim()) {
setError("请填写操作原因");
return;
}
setBusy(true);
setError("");
try {
const body = { ...payload(), reason, confirm };
const commandResult = await api.action(path, body);
setResult(commandResult);
if (confirm) {
onDone?.();
}
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
const canConfirm = result?.dry_run && !result.error;
const triggerClass = `btn ${tone === "danger" ? "danger" : tone === "warn" ? "warn" : ""} ${compact ? "compact-btn" : ""}`;
const previewPayload = useMemo(() => {
try {
return payload();
} catch (err) {
return { payload_error: errorMessage(err) };
}
}, [open, payload]);
return (
<>
<button
className={triggerClass}
type="button"
onClick={() => {
reset();
setOpen(true);
}}
>
{icon}
{label}
</button>
{open && createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={label}>
<div className="modal-head">
<div>
<div className="eyebrow"></div>
<h2>{label}</h2>
</div>
<button className="icon-btn" type="button" onClick={() => setOpen(false)} aria-label="关闭"><X size={15} /></button>
</div>
<div className="command-body">
<div className="command-steps">
<div className={`command-step ${reason.trim() ? "done" : "active"}`}>
<span>1</span><strong></strong>
</div>
<div className={`command-step ${result?.dry_run ? "done" : reason.trim() ? "active" : ""}`}>
<span>2</span><strong></strong>
</div>
<div className={`command-step ${result && !result.dry_run && !result.error ? "done" : canConfirm ? "active" : ""}`}>
<span>3</span><strong></strong>
</div>
</div>
<label className="form-field">
<span></span>
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder="说明本次操作原因" />
</label>
<div className="command-preview">
<div className="preview-head"><FileJson size={14} /> </div>
<JsonBlock value={JSON.stringify(previewPayload, null, 2)} />
</div>
{error && <Alert>{error}</Alert>}
{result && (
<div className="result-box">
<div className="result-title">
{result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />}
<strong>{result.message || result.error || "操作结果"}</strong>
</div>
<div className="result-line"><span> ID</span><strong>{result.command_id}</strong></div>
<div className="result-line"><span></span><strong>{result.status}</strong></div>
<div className="result-line"><span></span><strong>{result.dry_run ? "是" : "否"}</strong></div>
<div className="result-message">{result.message || result.error}</div>
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
</div>
)}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={() => setOpen(false)}></button>
<button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
{result ? "重新预演" : "先预演"}
</button>
<button className="btn danger icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
<CheckCircle2 size={15} />
</button>
</div>
</section>
</div>,
document.body
)}
</>
);
}

View file

@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import type { Navigate } from "../routing";
export function AppLink({
href,
navigate,
className,
children
}: {
href: string;
navigate: Navigate;
className?: string;
children: ReactNode;
}) {
return (
<a
className={className}
href={href}
onClick={(event) => {
event.preventDefault();
navigate(href);
}}
>
{children}
</a>
);
}

View file

@ -0,0 +1,82 @@
import { Cable, LogOut, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { formatDate } from "../lib/format";
import type { AuthorizationRow } from "../types";
import { ActionButton } from "./ActionButton";
import { EmptyRow } from "./ui";
export function AuthorizationTable({ rows, userID, onDone }: { rows: AuthorizationRow[]; userID: number; onDone: () => void }) {
const [removedHashes, setRemovedHashes] = useState<Set<number>>(() => new Set());
useEffect(() => {
setRemovedHashes(new Set());
}, [userID]);
const visibleRows = useMemo(
() => rows.filter((row) => !removedHashes.has(row.Hash)),
[rows, removedHashes]
);
function afterRevoke(mutator: (previous: Set<number>) => Set<number>) {
setRemovedHashes((previous) => mutator(previous));
onDone();
}
return (
<div className="authorization-block">
<div className="table-wrap">
<table className="data-table authorization-table">
<thead>
<tr>
<th></th>
<th></th>
<th>IP</th>
<th></th>
<th className="device-actions-head"></th>
</tr>
</thead>
<tbody>
{visibleRows.map((row) => (
<tr key={row.Hash}>
<td className="device-text">{row.DeviceModel} {row.SystemVersion}</td>
<td className="device-text">{row.Platform} {row.AppVersion}</td>
<td>{row.IP}</td>
<td>{formatDate(row.ActiveAt)}</td>
<td className="device-actions-cell">
<div className="device-actions">
<ActionButton
label="撤销当前"
icon={<LogOut size={13} />}
compact
path="/api/actions/revoke-sessions"
payload={() => ({ user_id: userID, hash: row.Hash })}
onDone={() => afterRevoke((previous) => new Set([...previous, row.Hash]))}
/>
<ActionButton
label="保留当前"
icon={<ShieldCheck size={13} />}
compact
path="/api/actions/revoke-sessions"
payload={() => ({ user_id: userID, keep_hash: row.Hash })}
onDone={() => afterRevoke(() => new Set(rows.filter((item) => item.Hash !== row.Hash).map((item) => item.Hash)))}
/>
</div>
</td>
</tr>
))}
{visibleRows.length === 0 && <EmptyRow colSpan={5} />}
</tbody>
</table>
</div>
<div className="danger-zone">
<ActionButton
label="撤销全部设备"
icon={<Cable size={15} />}
path="/api/actions/revoke-sessions"
payload={() => ({ user_id: userID, revoke_all: true })}
onDone={() => afterRevoke(() => new Set(rows.map((item) => item.Hash)))}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,192 @@
import { Check, Loader2, Search, X } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { channelKind, displayName, displayPhone, displayUsername } from "../lib/format";
import type { AccountRow, ChannelRow } from "../types";
import { Badge } from "./ui";
export function UserPicker({
label,
value,
onChange
}: {
label: string;
value: AccountRow | null;
onChange: (row: AccountRow | null) => void;
}) {
const [query, setQuery] = useState("");
const [rows, setRows] = useState<AccountRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function search() {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit: "20" });
if (query.trim()) {
params.set("q", query.trim());
}
try {
const result = await api.accounts(params);
setRows(result.rows);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void search();
}, []);
return (
<div className="entity-picker">
<div className="picker-head">
<span>{label}</span>
{value ? (
<button className="link-button" type="button" onClick={() => onChange(null)}>
<X size={13} />
</button>
) : null}
</div>
{value ? (
<div className="selected-entity">
<Check size={15} />
<div>
<strong>{displayName(value)}</strong>
<span className="mono">{value.ID}</span>
</div>
<span>{displayUsername(value.Username) || displayPhone(value.Phone) || "-"}</span>
</div>
) : null}
<div className="picker-search">
<Search size={15} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void search();
}
}}
placeholder="搜索 user_id / phone / username"
/>
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : "搜索"}
</button>
</div>
{error && <div className="picker-error">{error}</div>}
<div className="picker-results">
{rows.map((row) => (
<button
key={row.ID}
className={`picker-row ${value?.ID === row.ID ? "selected" : ""}`}
type="button"
onClick={() => onChange(row)}
>
<span className="mono">{row.ID}</span>
<strong>{displayName(row)}</strong>
<span>{displayUsername(row.Username) || displayPhone(row.Phone) || "-"}</span>
{row.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}
</button>
))}
{rows.length === 0 && !busy ? <div className="picker-empty"></div> : null}
</div>
</div>
);
}
export function ChannelPicker({
label,
value,
onChange
}: {
label: string;
value: ChannelRow | null;
onChange: (row: ChannelRow | null) => void;
}) {
const [query, setQuery] = useState("");
const [rows, setRows] = useState<ChannelRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function search() {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit: "20" });
if (query.trim()) {
params.set("q", query.trim());
}
try {
const result = await api.channels(params);
setRows(result.rows);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void search();
}, []);
return (
<div className="entity-picker">
<div className="picker-head">
<span>{label}</span>
{value ? (
<button className="link-button" type="button" onClick={() => onChange(null)}>
<X size={13} />
</button>
) : null}
</div>
{value ? (
<div className="selected-entity">
<Check size={15} />
<div>
<strong>{value.Title || "-"}</strong>
<span className="mono">{value.ID}</span>
</div>
<span>{displayUsername(value.Username) || channelKind(value)}</span>
</div>
) : null}
<div className="picker-search">
<Search size={15} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void search();
}
}}
placeholder="搜索 channel_id / username / title"
/>
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : "搜索"}
</button>
</div>
{error && <div className="picker-error">{error}</div>}
<div className="picker-results">
{rows.map((row) => (
<button
key={row.ID}
className={`picker-row ${value?.ID === row.ID ? "selected" : ""}`}
type="button"
onClick={() => onChange(row)}
>
<span className="mono">{row.ID}</span>
<strong>{row.Title || "-"}</strong>
<span>{displayUsername(row.Username) || channelKind(row)}</span>
{row.Verified ? <Badge tone="good"></Badge> : <Badge>{channelKind(row)}</Badge>}
</button>
))}
{rows.length === 0 && !busy ? <div className="picker-empty"></div> : null}
</div>
</div>
);
}

View file

@ -0,0 +1,155 @@
import {
ChevronDown,
Database,
LayoutDashboard,
LogOut,
MessageSquareText,
Server,
Shield,
ShieldCheck,
Users
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing";
import { AppLink } from "./AppLink";
export function BootScreen() {
return (
<div className="boot-screen">
<div className="brand compact brand-elevated">
<span className="brand-mark">T</span>
<span>
<strong>telesrv</strong>
<small></small>
</span>
</div>
<div className="loader-bar" />
</div>
);
}
export function Shell({
actor,
route,
navigate,
onLogout,
children
}: {
actor: string;
route: RouteState;
navigate: Navigate;
onLogout: () => void;
children: ReactNode;
}) {
const messagesActive = route.path.startsWith("/messages");
const [messagesOpen, setMessagesOpen] = useState(messagesActive);
useEffect(() => {
if (messagesActive) {
setMessagesOpen(true);
}
}, [messagesActive]);
async function logout() {
await api.logout().catch(() => undefined);
onLogout();
}
return (
<div className="shell">
<aside className="sidebar">
<AppLink className="brand" href="/" navigate={navigate}>
<span className="brand-mark">T</span>
<span>
<strong>telesrv</strong>
<small></small>
</span>
</AppLink>
<div className="sidebar-label"></div>
<nav className="nav-list" aria-label="主导航">
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}></NavLink>
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}></NavLink>
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>/</NavLink>
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button
className="nav-section-toggle"
type="button"
aria-expanded={messagesOpen}
onClick={() => setMessagesOpen((open) => !open)}
>
<MessageSquareText size={16} />
<span></span>
<ChevronDown className="nav-section-chevron" size={15} />
</button>
{messagesOpen && (
<div className="nav-children">
<NavLink
href="/messages/private"
route={route}
navigate={navigate}
activeWhen={(path) => path === "/messages" || path === "/messages/detail" || path.startsWith("/messages/private")}
>
</NavLink>
<NavLink
href="/messages/groups"
route={route}
navigate={navigate}
activeWhen={(path) => path.startsWith("/messages/groups")}
>
</NavLink>
</div>
)}
</div>
</nav>
<div className="sidebar-status">
<div className="sidebar-label"></div>
<div className="runtime-row"><Server size={14} /><span></span><strong></strong></div>
<div className="runtime-row"><Database size={14} /><span>PG </span><strong></strong></div>
<div className="runtime-row"><Shield size={14} /><span></span><strong></strong></div>
</div>
</aside>
<div className="workspace">
<header className="topbar">
<div>
<div className="eyebrow">{routeSubtitle(route.path)}</div>
<h1>{routeTitle(route.path)}</h1>
</div>
<div className="topbar-actions">
<span className="actor-pill">{actor}</span>
<button className="btn ghost icon-text" type="button" onClick={logout} title="退出">
<LogOut size={16} /> 退
</button>
</div>
</header>
<main className="content">{children}</main>
</div>
</div>
);
}
function NavLink({
href,
route,
navigate,
icon,
children,
activeWhen
}: {
href: string;
route: RouteState;
navigate: Navigate;
icon?: ReactNode;
children: ReactNode;
activeWhen?: (path: string) => boolean;
}) {
const active = activeWhen ? activeWhen(route.path) : href === "/" ? route.path === "/" : route.path.startsWith(href);
return (
<AppLink className={`nav-item ${active ? "active" : ""}`} href={href} navigate={navigate}>
{icon ?? <span aria-hidden="true" className="nav-dot" />}
<span>{children}</span>
</AppLink>
);
}

View file

@ -0,0 +1,128 @@
import { CircleAlert } from "lucide-react";
import type { ReactNode } from "react";
import { formatDate } from "../lib/format";
import type { AuditLogRow } from "../types";
type Tone = "neutral" | "good" | "danger" | "warn";
export function PageFrame({
title,
eyebrow,
children,
actions
}: {
title: string;
eyebrow?: string;
children: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="page-frame">
<div className="page-title-row">
<div>
{eyebrow && <div className="eyebrow">{eyebrow}</div>}
<h2>{title}</h2>
</div>
{actions && <div className="page-actions">{actions}</div>}
</div>
{children}
</div>
);
}
export function QueryPanel({ children }: { children: ReactNode }) {
return <div className="query-panel">{children}</div>;
}
export function SplitLayout({ main, side }: { main: ReactNode; side: ReactNode }) {
return (
<div className="split-layout">
<div className="split-main">{main}</div>
<aside className="split-side">{side}</aside>
</div>
);
}
export function SectionHead({ title, text, action }: { title: string; text?: string; action?: ReactNode }) {
return (
<div className="section-head">
<div>
<h2>{title}</h2>
{text && <p>{text}</p>}
</div>
{action && <div className="section-action">{action}</div>}
</div>
);
}
export function Alert({ children }: { children: ReactNode }) {
return <div className="alert"><CircleAlert size={16} /> <span>{children}</span></div>;
}
export function Badge({ children, tone = "neutral" }: { children: ReactNode; tone?: Tone }) {
return <span className={`badge ${tone}`}>{children}</span>;
}
export function StatusItem({ label, value, tone }: { label: string; value: string; tone: "neutral" | "good" | "warn" }) {
return (
<div className={`status-item ${tone}`}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
export function Metric({ label, value, tone = "neutral", mono = false }: { label: string; value: string; tone?: Tone; mono?: boolean }) {
return (
<div className={`metric ${tone}`}>
<span>{label}</span>
<strong className={mono ? "mono" : ""}>{value}</strong>
</div>
);
}
export function Summary({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return (
<div className="summary-item">
<span>{label}</span>
<strong className={mono ? "mono" : ""}>{value}</strong>
</div>
);
}
export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
return (
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>ID</th><th> ID</th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td>{row.ID}</td>
<td className="mono">{row.CommandID}</td>
<td>{row.Action}</td>
<td>{row.Actor}</td>
<td>{row.Status}</td>
<td>{row.DryRun ? "是" : "否"}</td>
<td className="truncate">{row.Reason}</td>
<td>{formatDate(row.CreatedAt)}</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
);
}
export function EmptyRow({ colSpan }: { colSpan: number }) {
return <tr><td colSpan={colSpan} className="empty-cell"></td></tr>;
}
export function LoadingSurface({ label }: { label: string }) {
return <section className="surface"><div className="loading-line">{label}</div></section>;
}
export function JsonBlock({ value }: { value: string }) {
return <pre className="json-block">{value || "{}"}</pre>;
}

View file

@ -0,0 +1,56 @@
import type { AccountRow, ChannelRow } from "../types";
export function displayPhone(value: string): string {
const phone = value.trim();
if (!phone || phone.startsWith("+")) return phone;
return /^\d+$/.test(phone) ? `+${phone}` : phone;
}
export function displayUsername(value: string): string {
const username = value.trim();
if (!username) return "";
return username.startsWith("@") ? username : `@${username}`;
}
export function displayName(row: Pick<AccountRow, "FirstName" | "LastName">): string {
return `${row.FirstName || ""} ${row.LastName || ""}`.trim() || "-";
}
export function channelKind(ch: ChannelRow): string {
if (ch.Broadcast && !ch.Megagroup) return "频道";
if (ch.Megagroup && ch.Forum) return "超级群/论坛";
if (ch.Megagroup) return "超级群";
return "频道/群";
}
export function formatDate(value: string): string {
if (!value || value.startsWith("0001-")) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString();
}
export function formatUnix(value: number): string {
if (!value || value <= 0) return "";
const date = new Date(value * 1000);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString();
}
export function toInt(value: string): number {
if (!value.trim()) return 0;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
export function parseIDs(value: string): number[] {
const ids = value
.split(/[\s,]+/)
.map((item) => item.trim())
.filter(Boolean)
.map((item) => Number.parseInt(item, 10));
if (ids.length === 0 || ids.some((id) => !Number.isFinite(id) || id <= 0)) {
throw new Error("msg ids invalid");
}
return ids;
}

View file

@ -0,0 +1,25 @@
import type { AccountRow, ChannelRow } from "../types";
export function accountMetrics(rows: AccountRow[]) {
return rows.reduce(
(acc, row) => {
acc.devices += row.DeviceCount;
if (row.PremiumUntil > 0) acc.premium += 1;
if (row.Frozen) acc.frozen += 1;
return acc;
},
{ devices: 0, premium: 0, frozen: 0 }
);
}
export function channelMetrics(rows: ChannelRow[]) {
return rows.reduce(
(acc, row) => {
if (row.Megagroup) acc.megagroups += 1;
if (row.Broadcast) acc.broadcasts += 1;
if (row.Verified) acc.verified += 1;
return acc;
},
{ megagroups: 0, broadcasts: 0, verified: 0 }
);
}

View file

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -0,0 +1,134 @@
import { ArrowLeft, BadgeCheck, CircleAlert, Sparkles } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { AuthorizationTable } from "../components/AuthorizationTable";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountDetail } from "../types";
export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<AccountDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [months, setMonths] = useState("1");
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.account(id));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load();
}, [id]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={busy ? "加载账号详情" : "等待数据"} />;
}
const account = detail.Account;
return (
<PageFrame
title={`账号 #${account.ID}`}
eyebrow="账号档案"
actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> </button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{displayName(account)}</div>
<div className="entity-subtitle">{displayUsername(account.Username) || "无用户名"} · {displayPhone(account.Phone) || "无手机号"}</div>
</div>
<div className="entity-badges">
{account.PremiumUntil > 0 ? <Badge tone="good"></Badge> : <Badge></Badge>}
{detail.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}
{account.Frozen ? <Badge tone="danger"></Badge> : <Badge></Badge>}
</div>
</section>
<div className="summary-grid">
<Summary label="用户 ID" value={String(account.ID)} mono />
<Summary label="最后在线" value={formatUnix(detail.LastSeenAt) || "-"} />
<Summary label="会员到期" value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : "无"} />
<Summary label="更新时间" value={formatDate(account.UpdatedAt) || "-"} />
<Summary label="授权设备" value={String(detail.Authorizations.length)} />
<Summary label="账号标记" value={`support=${detail.Support} bot=${detail.Bot}`} />
<Summary label="限制状态" value={detail.HasRestriction ? detail.Restriction.Reason || "已限制" : "无"} />
<Summary label="创建时间" value={formatDate(account.CreatedAt) || "-"} />
</div>
{detail.About && <p className="about-text">{detail.About}</p>}
<section className="section-block">
<SectionHead title="授权设备" text={`${detail.Authorizations.length} 个授权`} />
<AuthorizationTable rows={detail.Authorizations} userID={account.ID} onDone={load} />
</section>
<section className="section-block">
<SectionHead title="最近后台操作" text="最近 30 条审计" />
<AuditTable rows={detail.AuditLogs} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"></div>
<ActionButton
label={account.Frozen ? "解冻发消息" : "冻结发消息"}
icon={<CircleAlert size={15} />}
path="/api/actions/freeze-send"
payload={() => ({ user_id: account.ID, frozen: !account.Frozen })}
onDone={load}
/>
<label className="duration-field">
<span></span>
<input
aria-label="设置会员时长,单位月"
value={months}
onChange={(event) => setMonths(event.target.value)}
type="number"
min="1"
max="120"
/>
</label>
<div className="action-stack">
<ActionButton
label="设置会员"
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
payload={() => ({ user_id: account.ID, months: toInt(months) })}
onDone={load}
/>
<ActionButton
label="取消会员"
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
payload={() => ({ user_id: account.ID, months: 0 })}
onDone={load}
/>
<ActionButton
label={detail.Verified ? "取消认证" : "设置认证"}
icon={<BadgeCheck size={15} />}
tone="warn"
path="/api/actions/set-verified"
payload={() => ({ user_id: account.ID, verified: !detail.Verified })}
onDone={load}
/>
</div>
</section>
}
/>
</PageFrame>
);
}

View file

@ -0,0 +1,124 @@
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
import { accountMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { AccountListResponse } from "../types";
export function AccountsPage({ navigate }: { navigate: Navigate }) {
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [data, setData] = useState<AccountListResponse | null>(null);
const [cursor, setCursor] = useState({ beforeID: 0, beforeActiveUS: 0 });
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (q.trim()) {
params.set("q", q.trim());
} else if (next) {
params.set("before_id", String(cursor.beforeID));
params.set("before_active_us", String(cursor.beforeActiveUS));
}
try {
const result = await api.accounts(params);
setData(result);
setCursor({
beforeID: result.next_before_id,
beforeActiveUS: result.next_before_active_us
});
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const metrics = accountMetrics(data?.rows ?? []);
return (
<PageFrame
title="账号"
eyebrow={data?.listing === false ? "查询结果" : "最近活跃账号"}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} />
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label="当前页账号" value={String(data?.rows.length ?? 0)} />
<Metric label="在线设备记录" value={String(metrics.devices)} />
<Metric label="会员" value={String(metrics.premium)} tone="good" />
<Metric label="冻结" value={String(metrics.frozen)} tone={metrics.frozen > 0 ? "danger" : "neutral"} />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder="用户 ID / 手机号 / 用户名" />
</label>
<label className="field-inline">
<span></span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} />
</button>
)}
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data?.rows.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td>{displayPhone(row.Phone)}</td>
<td>{displayUsername(row.Username)}</td>
<td>{displayName(row)}</td>
<td>{row.DeviceCount}</td>
<td>{formatDate(row.LastActiveAt)}</td>
<td>{row.PremiumUntil > 0 ? <Badge tone="good"> {formatUnix(row.PremiumUntil)}</Badge> : <Badge></Badge>}</td>
<td>{row.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}</td>
<td>{row.Frozen ? <Badge tone="danger"></Badge> : <Badge></Badge>}</td>
<td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}> <ChevronRight size={14} /></button></td>
</tr>
))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={11} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,92 @@
import { ArrowLeft, BadgeCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { ChannelDetail } from "../types";
export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<ChannelDetail | null>(null);
const [error, setError] = useState("");
async function load() {
setError("");
try {
setDetail(await api.channel(id));
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load();
}, [id]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label="加载频道详情" />;
}
const ch = detail.Channel;
return (
<PageFrame
title={`${channelKind(ch)} #${ch.ID}`}
eyebrow="频道档案"
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> </button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{ch.Title || "-"}</div>
<div className="entity-subtitle">{displayUsername(ch.Username) || "无用户名"} · {ch.CreatorUserID}</div>
</div>
<div className="entity-badges">
<Badge>{channelKind(ch)}</Badge>
{ch.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}
{ch.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}
</div>
</section>
<div className="summary-grid">
<Summary label="频道 ID" value={String(ch.ID)} mono />
<Summary label="access_hash" value={String(ch.AccessHash)} mono />
<Summary label="成员" value={`${ch.ParticipantsCount} / 管理员 ${ch.AdminsCount}`} />
<Summary label="治理状态" value={`封禁 ${ch.BannedCount} / 踢出 ${ch.KickedCount}`} />
<Summary label="频道标记" value={`broadcast=${ch.Broadcast} megagroup=${ch.Megagroup} forum=${ch.Forum}`} />
<Summary label="top / pinned / PTS" value={`${ch.TopMessageID} / ${ch.PinnedMessageID} / ${ch.PTS}`} />
<Summary label="创建时间" value={formatUnix(ch.Date) || "-"} />
<Summary label="更新时间" value={formatDate(ch.UpdatedAt) || "-"} />
</div>
{ch.About && <p className="about-text">{ch.About}</p>}
<section className="section-block">
<SectionHead title="最近后台操作" text="最近 30 条审计" />
<AuditTable rows={detail.AuditLogs} />
</section>
<section className="section-block">
<SectionHead title="频道原始行" text="数据库只读快照" />
<JsonBlock value={detail.ChannelJSON} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"></div>
<ActionButton
label={ch.Verified ? "取消认证" : "设置认证"}
icon={<BadgeCheck size={15} />}
tone="warn"
path="/api/actions/set-channel-verified"
payload={() => ({ channel_id: ch.ID, verified: !ch.Verified })}
onDone={load}
/>
</section>
}
/>
</PageFrame>
);
}

View file

@ -0,0 +1,122 @@
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { channelKind, displayUsername, formatDate } from "../lib/format";
import { channelMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { ChannelListResponse } from "../types";
export function ChannelsPage({ navigate }: { navigate: Navigate }) {
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [data, setData] = useState<ChannelListResponse | null>(null);
const [cursor, setCursor] = useState({ beforeID: 0, beforeUpdatedUS: 0 });
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (q.trim()) {
params.set("q", q.trim());
} else if (next) {
params.set("before_id", String(cursor.beforeID));
params.set("before_updated_us", String(cursor.beforeUpdatedUS));
}
try {
const result = await api.channels(params);
setData(result);
setCursor({
beforeID: result.next_before_id,
beforeUpdatedUS: result.next_before_updated_us
});
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const metrics = channelMetrics(data?.rows ?? []);
return (
<PageFrame
title="超级群与频道"
eyebrow={data?.listing === false ? "查询结果" : "最近更新"}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} />
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label="当前页实体" value={String(data?.rows.length ?? 0)} />
<Metric label="超级群" value={String(metrics.megagroups)} />
<Metric label="频道" value={String(metrics.broadcasts)} />
<Metric label="已认证" value={String(metrics.verified)} tone="good" />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder="频道 ID / 用户名 / 标题" />
</label>
<label className="field-inline">
<span></span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} />
</button>
)}
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>PTS</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data?.rows.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td>{channelKind(row)}</td>
<td>{displayUsername(row.Username)}</td>
<td>{row.Title}</td>
<td>{row.ParticipantsCount}</td>
<td>{row.AdminsCount}</td>
<td>{row.PTS}</td>
<td>{row.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}</td>
<td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}> <ChevronRight size={14} /></button></td>
</tr>
))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={10} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,59 @@
import { CheckCircle2, ChevronRight, Clock3, FileJson, KeyRound, MessageSquareText, ShieldCheck, Users } from "lucide-react";
import type { ReactNode } from "react";
import { AppLink } from "../components/AppLink";
import { StatusItem } from "../components/ui";
import type { Navigate } from "../routing";
export function Dashboard({ navigate }: { navigate: Navigate }) {
return (
<div className="dashboard-layout">
<section className="overview-band">
<div>
<div className="eyebrow"></div>
<h2></h2>
</div>
<div className="overview-metrics">
<StatusItem label="读路径" value="PG 只读" tone="neutral" />
<StatusItem label="写路径" value="Admin API" tone="good" />
<StatusItem label="执行策略" value="先预演" tone="warn" />
</div>
</section>
<div className="command-grid">
<Launcher icon={<Users />} title="账号管理" text="账号状态、会员、认证、会话。" href="/accounts" navigate={navigate} />
<Launcher icon={<ShieldCheck />} title="超级群与频道" text="公开实体、成员计数、认证状态。" href="/channels" navigate={navigate} />
<Launcher icon={<MessageSquareText />} title="消息审计" text="消息盒、update、outbox 状态。" href="/messages" navigate={navigate} />
</div>
<section className="work-strip">
<div className="strip-item"><CheckCircle2 size={16} /><span></span></div>
<div className="strip-item"><KeyRound size={16} /><span> token</span></div>
<div className="strip-item"><Clock3 size={16} /><span>使</span></div>
<div className="strip-item"><FileJson size={16} /><span></span></div>
</section>
</div>
);
}
function Launcher({
icon,
title,
text,
href,
navigate
}: {
icon: ReactNode;
title: string;
text: string;
href: string;
navigate: Navigate;
}) {
return (
<AppLink className="launcher" href={href} navigate={navigate}>
<span className="launcher-icon">{icon}</span>
<span className="launcher-copy">
<strong>{title}</strong>
<span>{text}</span>
</span>
<ChevronRight size={16} />
</AppLink>
);
}

View file

@ -0,0 +1,100 @@
import { ArrowLeft } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
import { formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { GroupMessageDetail } from "../types";
export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channelID: number; msgID: number; navigate: Navigate }) {
const [detail, setDetail] = useState<GroupMessageDetail | null>(null);
const [error, setError] = useState("");
async function load() {
setError("");
try {
setDetail(await api.groupMessage(channelID, msgID));
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load();
}, [channelID, msgID]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label="加载群聊消息详情" />;
}
const msg = detail.Message;
return (
<PageFrame
title={`群聊消息 #${msg.ID}`}
eyebrow="消息详情"
actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> </button>}
>
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">/ {msg.ChannelID}</div>
<div className="entity-subtitle"> {msg.SenderUserID} · {formatUnix(msg.Date)}</div>
</div>
<div className="entity-badges">
{msg.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}
{msg.Pinned && <Badge tone="warn"></Badge>}
{msg.Post && <Badge></Badge>}
<Badge>pts {msg.PTS}</Badge>
</div>
</section>
<div className="summary-grid">
<Summary label="消息 ID" value={String(msg.ID)} mono />
<Summary label="频道 / 群" value={String(msg.ChannelID)} mono />
<Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} mono />
<Summary label="浏览" value={String(msg.ViewsCount)} />
</div>
<section className="section-block">
<SectionHead title="消息行" text="channel_messages 只读快照" />
<JsonBlock value={detail.MessageJSON} />
</section>
<section className="section-block">
<SectionHead title="频道行" text="channels 只读快照" />
<JsonBlock value={detail.ChannelJSON} />
</section>
<section className="section-block">
<SectionHead title="频道更新事件" text="durable channel_update_events" />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>PTS</th><th></th><th></th><th> ID</th><th></th><th></th></tr></thead>
<tbody>
{detail.UpdateEvents.map((row) => (
<tr key={`${row.PTS}-${row.Type}-${row.MessageID}`}>
<td>{row.PTS}</td>
<td>{row.PTSCount}</td>
<td>{row.Type}</td>
<td>{row.MessageID}</td>
<td>{row.SenderUserID}</td>
<td>{formatUnix(row.Date)}</td>
</tr>
))}
{detail.UpdateEvents.length === 0 && <EmptyRow colSpan={6} />}
</tbody>
</table>
</div>
</section>
<section className="section-block">
<SectionHead title="事件 JSON" />
<div className="raw-grid">
{detail.UpdateEvents.map((row) => (
<JsonBlock key={`${row.PTS}-${row.Type}-json`} value={row.JSON} />
))}
{detail.UpdateEvents.length === 0 && <div className="empty-panel"></div>}
</div>
</section>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,119 @@
import { ChevronRight, Search } from "lucide-react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { ChannelPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { channelKind, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { ChannelRow, GroupMessageListResponse } from "../types";
export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
const [channel, setChannel] = useState<ChannelRow | null>(null);
const [beforeDate, setBeforeDate] = useState("");
const [beforeID, setBeforeID] = useState("");
const [limit, setLimit] = useState("100");
const [data, setData] = useState<GroupMessageListResponse | null>(null);
const [error, setError] = useState("");
async function load(next = false) {
setError("");
if (!channel) {
setError("请先搜索并选择超级群或频道");
return;
}
const params = new URLSearchParams({
channel_id: String(channel.ID),
limit
});
if (next && data?.rows.length) {
const last = data.rows[data.rows.length - 1];
params.set("before_date", String(last.Date));
params.set("before_id", String(last.ID));
setBeforeDate(String(last.Date));
setBeforeID(String(last.ID));
} else {
if (beforeDate) params.set("before_date", beforeDate);
if (beforeID) params.set("before_id", beforeID);
}
try {
setData(await api.groupMessages(params));
} catch (err) {
setError(errorMessage(err));
}
}
function changeChannel(row: ChannelRow | null) {
setChannel(row);
setBeforeDate("");
setBeforeID("");
setData(null);
}
const rows = data?.rows ?? [];
return (
<PageFrame title="群聊消息" eyebrow="超级群 / 频道消息">
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="message-selector-grid single">
<ChannelPicker label="超级群 / 频道" value={channel} onChange={changeChannel} />
</div>
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder="before_date 游标" />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder="before_msg_id 游标" />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder="条数 <= 100" />
<button className="btn primary icon-text" type="submit"><Search size={15} /> </button>
{rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> </button> : null}
</form>
</QueryPanel>
<div className="metric-row">
<Metric label="当前页消息" value={String(rows.length)} />
<Metric label="有媒体" value={String(rows.filter((row) => row.Media && row.Media !== "{}").length)} />
<Metric label="频道帖子" value={String(rows.filter((row) => row.Post).length)} />
<Metric label="频道 / 群" value={channel ? `${channel.Title || channelKind(channel)} (${channel.ID})` : "-"} />
</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th>From Peer</th>
<th>PTS</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.ChannelID}-${row.ID}`}>
<td className="mono">{row.ID}</td>
<td>{formatUnix(row.Date)}</td>
<td className="mono">{row.SenderUserID}</td>
<td className="mono">{row.FromPeerType}:{row.FromPeerID}</td>
<td>{row.PTS}</td>
<td>{row.ViewsCount}</td>
<td>
{row.Deleted ? <Badge tone="danger"></Badge> : row.Pinned ? <Badge tone="warn"></Badge> : <Badge></Badge>}
</td>
<td className="truncate">{row.Body}</td>
<td>
<button
className="row-link"
onClick={() => navigate(`/messages/groups/detail?channel_id=${row.ChannelID}&msg_id=${row.ID}`)}
>
<ChevronRight size={14} />
</button>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={9} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,61 @@
import type { FormEvent } from "react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { Alert } from "../components/ui";
export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
const [secret, setSecret] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
const result = await api.login(secret);
onLogin(result.actor);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<main className="login-page">
<section className="login-panel">
<div className="login-head">
<div className="brand brand-elevated">
<span className="brand-mark">T</span>
<span>
<strong>telesrv</strong>
<small></small>
</span>
</div>
<span className="login-chip">访</span>
</div>
<div className="login-copy">
<h1></h1>
<p></p>
</div>
{error && <Alert>{error}</Alert>}
<form className="form-stack" onSubmit={submit}>
<label>
<span> token</span>
<input
autoFocus
type="password"
value={secret}
autoComplete="current-password"
onChange={(event) => setSecret(event.target.value)}
/>
</label>
<button className="btn primary full" type="submit" disabled={busy}>
{busy ? "登录中" : "登录"}
</button>
</form>
</section>
</main>
);
}

View file

@ -0,0 +1,116 @@
import { ArrowLeft, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { MessageDetail } from "../types";
export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserID: number; msgID: number; navigate: Navigate }) {
const [detail, setDetail] = useState<MessageDetail | null>(null);
const [error, setError] = useState("");
async function load() {
setError("");
try {
setDetail(await api.message(ownerUserID, msgID));
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load();
}, [ownerUserID, msgID]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label="加载消息详情" />;
}
const msg = detail.Message;
return (
<PageFrame
title={`消息 #${msg.BoxID}`}
eyebrow="消息详情"
actions={<button className="btn icon-text" onClick={() => navigate("/messages/private")}><ArrowLeft size={15} /> </button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title"> {msg.OwnerUserID} · {msg.PeerID}</div>
<div className="entity-subtitle"> {msg.FromUserID} · {formatUnix(msg.Date)}</div>
</div>
<div className="entity-badges">
{msg.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}
<Badge>pts {msg.PTS}</Badge>
<Badge>{msg.Outgoing ? "发出" : "收到"}</Badge>
</div>
</section>
<div className="summary-grid">
<Summary label="消息盒 ID" value={String(msg.BoxID)} mono />
<Summary label="私聊消息 ID" value={String(msg.PrivateMessageID)} mono />
<Summary label="发送方" value={String(msg.MessageSenderID)} mono />
<Summary label="时间" value={formatUnix(msg.Date)} />
</div>
<section className="section-block">
<SectionHead title="消息盒" text="message_boxes 只读快照" />
<JsonBlock value={detail.MessageJSON} />
</section>
<div className="raw-grid">
<section className="section-block">
<SectionHead title="会话行" text="dialogs 只读快照" />
<JsonBlock value={detail.DialogJSON} />
</section>
<section className="section-block">
<SectionHead title="私聊消息行" text="private_messages 只读快照" />
<JsonBlock value={detail.PrivateJSON} />
</section>
</div>
<section className="section-block">
<SectionHead title="更新事件" text="durable user_update_events" />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>PTS</th><th></th><th></th><th></th></tr></thead>
<tbody>
{detail.UpdateEvents.map((row) => <tr key={`${row.PTS}-${row.Type}`}><td>{row.PTS}</td><td>{row.PTSCount}</td><td>{row.Type}</td><td>{formatUnix(row.Date)}</td></tr>)}
{detail.UpdateEvents.length === 0 && <EmptyRow colSpan={4} />}
</tbody>
</table>
</div>
</section>
<section className="section-block">
<SectionHead title="分发队列" text="在线/离线 dispatch_outbox" />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>ID</th><th></th><th>PTS</th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{detail.Outbox.map((row) => <tr key={row.ID}><td>{row.ID}</td><td>{row.TargetUserID}</td><td>{row.PTS}</td><td>{row.EventType}</td><td>{row.Status}</td><td>{row.Attempts}</td><td>{formatDate(row.UpdatedAt)}</td></tr>)}
{detail.Outbox.length === 0 && <EmptyRow colSpan={7} />}
</tbody>
</table>
</div>
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"></div>
<ActionButton
label="删除此消息"
icon={<Trash2 size={15} />}
path="/api/actions/delete-messages"
payload={() => ({ owner_user_id: msg.OwnerUserID, peer_id: msg.PeerID, ids: [msg.BoxID], revoke: true })}
onDone={load}
/>
</section>
}
/>
</PageFrame>
);
}

View file

@ -0,0 +1,157 @@
import { ChevronRight, History, Search, Trash2 } from "lucide-react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { UserPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { displayName, formatUnix, parseIDs, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountRow, MessageListResponse } from "../types";
export function MessagesPage({ navigate }: { navigate: Navigate }) {
const [owner, setOwner] = useState<AccountRow | null>(null);
const [peer, setPeer] = useState<AccountRow | null>(null);
const [beforeDate, setBeforeDate] = useState("");
const [beforeID, setBeforeID] = useState("");
const [limit, setLimit] = useState("100");
const [ids, setIDs] = useState("");
const [revoke, setRevoke] = useState(true);
const [justClear, setJustClear] = useState(false);
const [maxID, setMaxID] = useState("");
const [maxBatches, setMaxBatches] = useState("1");
const [data, setData] = useState<MessageListResponse | null>(null);
const [error, setError] = useState("");
async function load(next = false) {
setError("");
if (!owner || !peer) {
setError("请先搜索并选择所属用户和对端用户");
return;
}
const params = new URLSearchParams({
owner_user_id: String(owner.ID),
peer_id: String(peer.ID),
limit
});
if (next && data?.rows.length) {
const last = data.rows[data.rows.length - 1];
params.set("before_date", String(last.Date));
params.set("before_id", String(last.BoxID));
setBeforeDate(String(last.Date));
setBeforeID(String(last.BoxID));
} else {
if (beforeDate) params.set("before_date", beforeDate);
if (beforeID) params.set("before_id", beforeID);
}
try {
setData(await api.messages(params));
} catch (err) {
setError(errorMessage(err));
}
}
function changeOwner(row: AccountRow | null) {
setOwner(row);
setBeforeDate("");
setBeforeID("");
setData(null);
}
function changePeer(row: AccountRow | null) {
setPeer(row);
setBeforeDate("");
setBeforeID("");
setData(null);
}
return (
<PageFrame title="私聊消息" eyebrow="私聊消息盒">
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="message-selector-grid">
<UserPicker label="所属用户" value={owner} onChange={changeOwner} />
<UserPicker label="对端用户" value={peer} onChange={changePeer} />
</div>
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder="before_date 游标" />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder="before_msg_id 游标" />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder="条数 <= 100" />
<button className="btn primary icon-text" type="submit"><Search size={15} /> </button>
{data?.rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> </button> : null}
</form>
</QueryPanel>
<div className="metric-row">
<Metric label="当前页消息" value={String(data?.rows.length ?? 0)} />
<Metric label="已删除" value={String((data?.rows ?? []).filter((row) => row.Deleted).length)} tone="danger" />
<Metric label="发出消息" value={String((data?.rows ?? []).filter((row) => row.Outgoing).length)} />
<Metric label="所属 / 对端" value={owner && peer ? `${displayName(owner)} / ${displayName(peer)}` : "-"} />
</div>
<div className="operation-row">
<div className="operation-box">
<div className="operation-title"><Trash2 size={15} /> </div>
<input value={ids} onChange={(event) => setIDs(event.target.value)} placeholder="消息 ID逗号分隔" />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> </label>
<ActionButton path="/api/actions/delete-messages" label="预演删除" payload={() => ({
owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0,
ids: parseIDs(ids),
revoke
})} />
</div>
<div className="operation-box">
<div className="operation-title"><History size={15} /> </div>
<input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder="max_id 截止消息" />
<input value={maxBatches} onChange={(event) => setMaxBatches(event.target.value)} placeholder="max_batches 批次数" />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> </label>
<label className="checkline"><input type="checkbox" checked={justClear} onChange={(event) => setJustClear(event.target.checked)} /> </label>
<ActionButton path="/api/actions/delete-history" label="预演清历史" payload={() => ({
owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0,
max_id: toInt(maxID),
max_batches: toInt(maxBatches),
just_clear: justClear,
revoke
})} />
</div>
</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th></th>
<th>PTS</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data?.rows.map((row) => (
<tr key={`${row.OwnerUserID}-${row.BoxID}`}>
<td className="mono">{row.BoxID}</td>
<td>{formatUnix(row.Date)}</td>
<td className="mono">{row.FromUserID}</td>
<td>{row.Outgoing ? "发出" : "收到"}</td>
<td>{row.PTS}</td>
<td>{row.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}</td>
<td className="truncate">{row.Body}</td>
<td>
<button
className="row-link"
onClick={() => navigate(`/messages/private/detail?owner_user_id=${row.OwnerUserID}&msg_id=${row.BoxID}`)}
>
<ChevronRight size={14} />
</button>
</td>
</tr>
))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,52 @@
import { type Navigate, type RouteState } from "../routing";
import { AccountDetailPage } from "./AccountDetailPage";
import { AccountsPage } from "./AccountsPage";
import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage";
import { Dashboard } from "./Dashboard";
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
import { GroupMessagesPage } from "./GroupMessagesPage";
import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
if (accountID) {
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
}
if (channelID) {
return <ChannelDetailPage id={Number(channelID)} navigate={navigate} />;
}
if (route.path === "/accounts") {
return <AccountsPage navigate={navigate} />;
}
if (route.path === "/channels") {
return <ChannelsPage navigate={navigate} />;
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return (
<MessageDetailPage
ownerUserID={Number(route.search.get("owner_user_id") || "0")}
msgID={Number(route.search.get("msg_id") || "0")}
navigate={navigate}
/>
);
}
if (route.path === "/messages/groups/detail") {
return (
<GroupMessageDetailPage
channelID={Number(route.search.get("channel_id") || "0")}
msgID={Number(route.search.get("msg_id") || "0")}
navigate={navigate}
/>
);
}
if (route.path === "/messages/groups") {
return <GroupMessagesPage navigate={navigate} />;
}
if (route.path === "/messages" || route.path === "/messages/private") {
return <MessagesPage navigate={navigate} />;
}
return <Dashboard navigate={navigate} />;
}

View file

@ -0,0 +1,29 @@
export type Navigate = (href: string) => void;
export type RouteState = {
href: string;
path: string;
search: URLSearchParams;
};
export function currentRoute(): RouteState {
return {
href: `${window.location.pathname}${window.location.search}`,
path: window.location.pathname,
search: new URLSearchParams(window.location.search)
};
}
export function routeTitle(pathname: string): string {
if (pathname.startsWith("/accounts")) return "账号管理";
if (pathname.startsWith("/channels")) return "超级群与频道";
if (pathname.startsWith("/messages")) return "消息审计";
return "运维控制台";
}
export function routeSubtitle(pathname: string): string {
if (pathname.startsWith("/accounts")) return "控制台 / 账号";
if (pathname.startsWith("/channels")) return "控制台 / 频道";
if (pathname.startsWith("/messages")) return "控制台 / 消息";
return "控制台 / 总览";
}

View file

@ -0,0 +1,5 @@
@import "./styles/01-foundation.css";
@import "./styles/02-pages-and-forms.css";
@import "./styles/03-entities-and-actions.css";
@import "./styles/04-modal-and-login.css";
@import "./styles/05-responsive.css";

View file

@ -0,0 +1,285 @@
:root {
color-scheme: light;
--bg: #f3f5f7;
--panel: #ffffff;
--panel-subtle: #f8fafb;
--panel-strong: #eef2f5;
--line: #d9e1e8;
--line-strong: #c2ccd6;
--text: #101828;
--muted: #667085;
--muted-2: #98a2b3;
--brand: #176d61;
--brand-2: #245b9d;
--good: #167447;
--warn: #a15c07;
--danger: #b42318;
--sidebar: #11161d;
--sidebar-soft: #1b222b;
--sidebar-line: #2c3541;
--focus: rgba(23, 109, 97, 0.16);
--shadow: 0 18px 52px rgba(16, 24, 40, 0.14);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
color: var(--text);
background: var(--bg);
font: 13px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
textarea {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
.shell {
display: grid;
min-height: 100vh;
grid-template-columns: 232px minmax(0, 1fr);
}
.sidebar {
position: sticky;
top: 0;
display: flex;
height: 100vh;
flex-direction: column;
gap: 16px;
padding: 18px 12px;
color: #eef2f6;
background: var(--sidebar);
border-right: 1px solid var(--sidebar-line);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
min-height: 42px;
padding: 0 4px;
}
.brand.compact {
justify-content: center;
}
.brand-elevated .brand-mark {
box-shadow: 0 8px 24px rgba(23, 109, 97, 0.26);
}
.brand-mark {
display: grid;
width: 34px;
height: 34px;
place-items: center;
color: #ffffff;
background: var(--brand);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
font-weight: 800;
}
.brand strong {
display: block;
font-size: 14px;
line-height: 1.1;
}
.brand small {
display: block;
margin-top: 3px;
color: #aeb8c4;
font-size: 11px;
}
.sidebar-label {
padding: 0 8px;
color: #8492a6;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.nav-list {
display: grid;
gap: 4px;
}
.nav-section {
display: grid;
gap: 4px;
}
.nav-section-toggle {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 16px;
width: 100%;
min-height: 38px;
align-items: center;
gap: 9px;
padding: 0 10px;
color: #8fa0b4;
background: transparent;
border: 1px solid transparent;
border-radius: 7px;
cursor: pointer;
font-size: 12px;
font-weight: 800;
text-align: left;
}
.nav-section-toggle:hover,
.nav-section.active .nav-section-toggle {
color: #ffffff;
background: var(--sidebar-soft);
border-color: #34404d;
}
.nav-section-chevron {
justify-self: end;
color: #8fa0b4;
transition: transform 140ms ease;
}
.nav-section.open .nav-section-chevron {
transform: rotate(180deg);
}
.nav-children {
display: grid;
gap: 4px;
padding: 2px 0 2px 18px;
}
.nav-item {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
min-height: 38px;
align-items: center;
gap: 9px;
padding: 0 10px;
color: #c6d0dc;
border: 1px solid transparent;
border-radius: 7px;
}
.nav-dot {
width: 6px;
height: 6px;
justify-self: center;
background: #687789;
border-radius: 999px;
}
.nav-item:hover,
.nav-item.active {
color: #ffffff;
background: var(--sidebar-soft);
border-color: #34404d;
}
.nav-item.active .nav-dot {
background: var(--brand);
}
.sidebar-status {
display: grid;
gap: 7px;
margin-top: auto;
}
.runtime-row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
min-height: 32px;
align-items: center;
gap: 7px;
padding: 0 8px;
color: #cbd5df;
background: #171d25;
border: 1px solid #27313c;
border-radius: 7px;
}
.runtime-row strong {
color: #ffffff;
font-size: 11px;
}
.workspace {
min-width: 0;
}
.topbar {
position: sticky;
z-index: 20;
top: 0;
display: flex;
min-height: 66px;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 12px 24px;
background: rgba(255, 255, 255, 0.94);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(12px);
}
.topbar h1 {
margin: 2px 0 0;
font-size: 20px;
line-height: 1.2;
}
.topbar-actions,
.page-actions,
.section-action,
.entity-badges,
.row-actions,
.modal-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.actor-pill {
display: inline-flex;
min-height: 30px;
align-items: center;
padding: 0 10px;
color: #344054;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 999px;
}
.content {
display: grid;
gap: 16px;
padding: 18px 24px 30px;
}
.eyebrow {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}

View file

@ -0,0 +1,559 @@
.dashboard-layout,
.stacked-sections {
display: grid;
gap: 14px;
}
.overview-band,
.page-frame {
min-width: 0;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.overview-band {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(420px, 0.9fr);
gap: 16px;
align-items: center;
padding: 16px;
}
.overview-band h2,
.page-title-row h2,
.section-head h2,
.modal h2 {
margin: 0;
font-size: 18px;
line-height: 1.25;
}
.overview-metrics,
.metric-row {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 8px;
}
.overview-metrics {
grid-template-columns: repeat(3, minmax(120px, 1fr));
}
.status-item,
.metric,
.summary-item {
min-width: 0;
padding: 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 7px;
}
.status-item span,
.metric span,
.summary-item span {
display: block;
margin-bottom: 6px;
color: var(--muted);
font-size: 11px;
}
.status-item strong,
.metric strong,
.summary-item strong {
display: block;
overflow-wrap: anywhere;
color: var(--text);
font-weight: 800;
}
.status-item.good,
.metric.good {
border-color: #afd8bf;
}
.status-item.warn,
.metric.warn {
border-color: #e7c77e;
}
.metric.danger {
border-color: #efb4ad;
}
.command-grid {
display: grid;
grid-template-columns: repeat(3, minmax(220px, 1fr));
gap: 12px;
}
.launcher {
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 18px;
min-height: 94px;
align-items: center;
gap: 12px;
padding: 14px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.launcher:hover {
border-color: var(--brand);
}
.launcher-icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
color: var(--brand);
background: #edf7f4;
border: 1px solid #c9e2dc;
border-radius: 8px;
}
.launcher-copy {
display: grid;
gap: 4px;
}
.launcher-copy strong {
font-size: 15px;
}
.launcher-copy span {
color: var(--muted);
}
.work-strip {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 8px;
}
.strip-item {
display: flex;
min-height: 38px;
align-items: center;
gap: 8px;
padding: 0 10px;
color: #344054;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.page-frame {
display: grid;
gap: 14px;
padding: 14px;
}
.page-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.query-panel {
padding: 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.message-query input {
width: 150px;
}
.message-selector-grid {
display: grid;
grid-template-columns: repeat(2, minmax(280px, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.message-selector-grid.single {
grid-template-columns: minmax(320px, 620px);
}
.entity-picker {
display: grid;
min-width: 0;
gap: 8px;
padding: 10px;
background: #ffffff;
border: 1px solid var(--line);
border-radius: 8px;
}
.picker-head {
display: flex;
min-height: 24px;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #344054;
font-weight: 800;
}
.selected-entity {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
min-height: 40px;
align-items: center;
gap: 8px;
padding: 7px 9px;
color: #0f3f38;
background: #eef8f5;
border: 1px solid #b9dcd3;
border-radius: 7px;
}
.selected-entity strong,
.selected-entity span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.selected-entity div {
display: grid;
min-width: 0;
gap: 2px;
}
.selected-entity div span {
color: #52606d;
font-size: 11px;
}
.picker-search {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
height: 34px;
align-items: center;
gap: 7px;
padding: 0 6px 0 9px;
background: var(--panel-subtle);
border: 1px solid var(--line-strong);
border-radius: 7px;
}
.picker-search input {
width: 100%;
height: 30px;
padding: 0;
background: transparent;
border: 0;
box-shadow: none;
}
.picker-results {
display: grid;
max-height: 236px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 7px;
}
.picker-row {
display: grid;
grid-template-columns: 96px minmax(120px, 1fr) minmax(120px, 1fr) auto;
min-height: 36px;
align-items: center;
gap: 8px;
padding: 6px 8px;
color: var(--text);
background: #ffffff;
border: 0;
border-bottom: 1px solid var(--line);
cursor: pointer;
text-align: left;
}
.picker-row:last-child {
border-bottom: 0;
}
.picker-row:hover,
.picker-row.selected {
background: #f3f8f6;
}
.picker-row strong,
.picker-row span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-empty,
.picker-error {
padding: 9px;
color: var(--muted);
text-align: center;
}
.picker-error {
color: var(--danger);
background: #fff2f0;
border: 1px solid #efb4ad;
border-radius: 7px;
}
input,
textarea {
color: var(--text);
background: #ffffff;
border: 1px solid var(--line-strong);
border-radius: 7px;
outline: none;
}
input {
width: 190px;
height: 34px;
padding: 0 10px;
}
textarea {
width: 100%;
padding: 9px 10px;
resize: vertical;
}
input:focus,
textarea:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px var(--focus);
}
.small-input {
width: 88px;
}
.field-inline {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
}
.field-inline span {
font-size: 11px;
font-weight: 700;
}
.searchbox {
display: inline-flex;
align-items: center;
gap: 8px;
width: min(380px, 100%);
height: 34px;
padding: 0 10px;
background: #ffffff;
border: 1px solid var(--line-strong);
border-radius: 7px;
}
.searchbox input {
width: 100%;
height: 30px;
padding: 0;
border: 0;
box-shadow: none;
}
.btn {
display: inline-flex;
min-height: 34px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 12px;
color: #1d2939;
background: #ffffff;
border: 1px solid var(--line-strong);
border-radius: 7px;
cursor: pointer;
white-space: nowrap;
}
.btn:hover:not(:disabled) {
background: #f7f9fb;
}
.btn:disabled {
color: var(--muted-2);
cursor: not-allowed;
}
.btn.primary {
color: #ffffff;
background: var(--brand);
border-color: var(--brand);
}
.btn.primary:hover:not(:disabled) {
background: #12594f;
}
.btn.ghost {
background: var(--panel-subtle);
}
.btn.danger {
color: var(--danger);
background: #fff7f5;
border-color: #efb4ad;
}
.btn.danger:hover:not(:disabled) {
background: #ffeceb;
}
.btn.warn {
color: var(--warn);
background: #fff8ec;
border-color: #e7c77e;
}
.btn.warn:hover:not(:disabled) {
background: #fff1d6;
}
.btn:disabled,
.btn.primary:disabled,
.btn.warn:disabled,
.btn.danger:disabled {
color: var(--muted-2);
background: #f3f5f7;
border-color: var(--line);
cursor: not-allowed;
}
.btn.full {
width: 100%;
}
.icon-text {
gap: 7px;
}
.compact-btn {
min-height: 28px;
padding: 0 8px;
font-size: 12px;
}
.row-link,
.link-button {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
color: var(--brand-2);
background: transparent;
border: 0;
cursor: pointer;
}
.table-wrap {
width: 100%;
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 8px;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
.data-table th,
.data-table td {
height: 38px;
padding: 7px 9px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: middle;
white-space: nowrap;
}
.data-table th {
position: sticky;
top: 0;
z-index: 0;
color: #475467;
background: var(--panel-strong);
font-weight: 800;
}
.data-table tbody tr:hover {
background: #fbfcfd;
}
.data-table tr:last-child td {
border-bottom: 0;
}
.mono {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
.truncate {
max-width: 380px;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
display: inline-flex;
min-height: 22px;
align-items: center;
padding: 1px 8px;
color: #4f5b68;
background: #f3f6f8;
border: 1px solid #d7e0e8;
border-radius: 999px;
white-space: nowrap;
}
.badge.good {
color: var(--good);
background: #eef8f2;
border-color: #b9dcc7;
}
.badge.danger {
color: var(--danger);
background: #fff2f0;
border-color: #efb4ad;
}
.badge.warn {
color: var(--warn);
background: #fff8e7;
border-color: #e7c77e;
}
.empty-cell {
color: var(--muted);
text-align: center;
}

View file

@ -0,0 +1,254 @@
.split-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 14px;
align-items: start;
}
.split-main,
.split-side {
min-width: 0;
}
.entity-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 14px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
}
.entity-title {
font-size: 20px;
font-weight: 800;
line-height: 1.25;
}
.entity-subtitle {
margin-top: 4px;
color: var(--muted);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 8px;
}
.about-text {
margin: 0;
padding: 10px;
color: #344054;
background: #fbfcfd;
border: 1px solid var(--line);
border-radius: 8px;
}
.section-block,
.action-dock,
.surface {
min-width: 0;
padding: 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.section-head p {
margin: 5px 0 0;
color: var(--muted);
}
.action-dock {
position: sticky;
top: 82px;
display: grid;
gap: 10px;
}
.dock-title {
padding-bottom: 4px;
color: #344054;
font-weight: 800;
border-bottom: 1px solid var(--line);
}
.action-dock > .btn,
.action-dock .action-stack .btn {
width: 100%;
justify-content: center;
}
.duration-field {
display: grid;
gap: 4px;
}
.duration-field span {
color: var(--muted);
font-size: 11px;
font-weight: 800;
}
.duration-field input {
width: 100%;
}
.action-stack {
display: grid;
gap: 10px;
}
.action-stack .btn,
.action-dock > .btn {
min-height: 42px;
}
.danger-zone {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.authorization-block {
display: grid;
gap: 10px;
}
.authorization-table {
min-width: 720px;
table-layout: fixed;
}
.authorization-table th,
.authorization-table td {
height: 46px;
}
.device-text {
overflow: hidden;
text-overflow: ellipsis;
}
.device-text {
max-width: 260px;
}
.device-actions-head {
width: 190px;
}
.device-actions-cell {
width: 190px;
min-width: 190px;
}
.device-actions {
display: grid;
grid-template-columns: repeat(2, minmax(82px, 1fr));
gap: 6px;
min-width: 178px;
white-space: normal;
}
.device-actions .btn {
width: 100%;
justify-content: center;
}
.operation-row {
display: grid;
grid-template-columns: repeat(2, minmax(280px, 1fr));
gap: 10px;
}
.operation-box {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
}
.operation-title {
display: flex;
width: 100%;
align-items: center;
gap: 6px;
font-weight: 800;
}
.checkline {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
}
.checkline input {
width: auto;
height: auto;
}
.alert {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 9px 10px;
color: #8a251d;
background: #fff2f0;
border: 1px solid #efb4ad;
border-radius: 8px;
}
.json-block {
max-height: 520px;
overflow: auto;
margin: 0;
padding: 12px;
color: #d8e6f0;
background: #141a22;
border: 1px solid #2a3542;
border-radius: 8px;
font-size: 12px;
}
.raw-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.loading-line {
min-height: 80px;
display: grid;
place-items: center;
color: var(--muted);
}
.empty-panel {
display: grid;
min-height: 92px;
place-items: center;
color: var(--muted);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
}

View file

@ -0,0 +1,261 @@
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: center;
padding: 24px;
background: rgba(17, 24, 39, 0.52);
}
.modal {
width: min(760px, 100%);
max-height: min(820px, calc(100vh - 48px));
overflow: hidden;
padding: 0;
background: #ffffff;
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.command-modal {
display: flex;
flex-direction: column;
}
.modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--line);
}
.icon-btn {
display: grid;
width: 30px;
height: 30px;
place-items: center;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 7px;
cursor: pointer;
}
.command-steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.command-body {
display: grid;
gap: 12px;
overflow: auto;
padding: 14px 18px;
}
.command-step {
display: flex;
min-height: 38px;
align-items: center;
gap: 8px;
padding: 0 10px;
color: var(--muted);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
}
.command-step span {
display: grid;
width: 20px;
height: 20px;
place-items: center;
background: #ffffff;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 11px;
font-weight: 800;
}
.command-step.active {
color: var(--brand);
border-color: #a9d8ce;
}
.command-step.done {
color: var(--good);
border-color: #b9dcc7;
}
.form-field {
display: grid;
gap: 6px;
}
.form-field span,
.form-stack span {
color: #4b5563;
font-weight: 800;
}
.command-preview {
display: grid;
gap: 8px;
}
.command-preview .json-block {
max-height: 150px;
}
.preview-head,
.result-title {
display: flex;
align-items: center;
gap: 7px;
color: #344054;
font-weight: 800;
}
.result-box {
display: grid;
gap: 8px;
padding: 10px;
background: #fbfcfd;
border: 1px solid var(--line);
border-radius: 8px;
}
.result-line {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 8px;
}
.result-line span {
color: var(--muted);
}
.result-line strong {
overflow-wrap: anywhere;
}
.result-message {
color: #344054;
}
.modal-actions {
justify-content: flex-end;
padding: 12px 18px;
background: #ffffff;
border-top: 1px solid var(--line);
}
.login-page {
display: grid;
min-height: 100vh;
place-items: center;
padding: 24px;
background: var(--bg);
}
.login-panel {
display: grid;
width: min(420px, 100%);
gap: 18px;
padding: 22px;
background: #ffffff;
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.login-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-chip {
display: inline-flex;
min-height: 24px;
align-items: center;
padding: 0 8px;
color: var(--brand);
background: #edf7f4;
border: 1px solid #c9e2dc;
border-radius: 999px;
font-size: 12px;
}
.login-copy h1 {
margin: 0;
font-size: 22px;
}
.login-copy p {
margin: 8px 0 0;
color: var(--muted);
}
.form-stack {
display: grid;
gap: 12px;
}
.form-stack label {
display: grid;
gap: 6px;
}
.form-stack input {
width: 100%;
}
.boot-screen {
display: grid;
min-height: 100vh;
place-items: center;
align-content: center;
gap: 18px;
}
.loader-bar {
width: 180px;
height: 4px;
overflow: hidden;
background: #d7dde4;
border-radius: 999px;
}
.loader-bar::before {
display: block;
width: 42%;
height: 100%;
content: "";
background: var(--brand);
animation: load 1s infinite ease-in-out;
}
.spin {
animation: spin 0.8s linear infinite;
}
@keyframes load {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(260%);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,78 @@
@media (max-width: 1120px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
}
.nav-list {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.sidebar-status {
display: none;
}
.overview-band,
.split-layout,
.operation-row,
.raw-grid,
.message-selector-grid,
.message-selector-grid.single {
grid-template-columns: 1fr;
}
.action-dock {
position: static;
}
}
@media (max-width: 760px) {
.content,
.topbar {
padding-left: 14px;
padding-right: 14px;
}
.command-grid,
.work-strip,
.overview-metrics,
.metric-row,
.summary-grid,
.command-steps {
grid-template-columns: 1fr;
}
.sidebar {
gap: 12px;
padding: 14px;
}
.nav-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.topbar,
.page-title-row,
.entity-head {
align-items: flex-start;
flex-direction: column;
}
input,
.searchbox {
width: 100%;
}
.toolbar {
align-items: stretch;
}
.picker-row,
.selected-entity {
grid-template-columns: 1fr;
}
}

View file

@ -0,0 +1,222 @@
export type AccountRow = {
ID: number;
Phone: string;
Username: string;
FirstName: string;
LastName: string;
CreatedAt: string;
UpdatedAt: string;
Frozen: boolean;
Reason: string;
Verified: boolean;
PremiumUntil: number;
LastActiveAt: string;
DeviceCount: number;
};
export type RestrictionRow = {
Frozen: boolean;
Reason: string;
Actor: string;
CommandID: string;
UpdatedAt: string;
};
export type AuthorizationRow = {
AuthKeyID: number;
Hash: number;
Layer: number;
DeviceModel: string;
Platform: string;
SystemVersion: string;
APIID: number;
AppVersion: string;
IP: string;
PasswordPending: boolean;
CreatedAt: string;
ActiveAt: string;
};
export type AuditLogRow = {
ID: number;
CommandID: string;
Actor: string;
Action: string;
DryRun: boolean;
Reason: string;
Status: string;
Error: string;
Result: string;
CreatedAt: string;
};
export type AccountDetail = {
Account: AccountRow;
About: string;
LastSeenAt: number;
Verified: boolean;
Support: boolean;
Bot: boolean;
Restriction: RestrictionRow;
HasRestriction: boolean;
Authorizations: AuthorizationRow[];
AuditLogs: AuditLogRow[];
};
export type ChannelRow = {
ID: number;
AccessHash: number;
CreatorUserID: number;
Title: string;
About: string;
Username: string;
Broadcast: boolean;
Megagroup: boolean;
Forum: boolean;
Monoforum: boolean;
Verified: boolean;
Deleted: boolean;
ParticipantsCount: number;
AdminsCount: number;
KickedCount: number;
BannedCount: number;
TopMessageID: number;
PinnedMessageID: number;
PTS: number;
Date: number;
CreatedAt: string;
UpdatedAt: string;
};
export type ChannelDetail = {
Channel: ChannelRow;
ChannelJSON: string;
AuditLogs: AuditLogRow[];
};
export type MessageRow = {
OwnerUserID: number;
BoxID: number;
PrivateMessageID: number;
MessageSenderID: number;
PeerID: number;
FromUserID: number;
Date: number;
Outgoing: boolean;
Body: string;
PTS: number;
Deleted: boolean;
Media: string;
};
export type GroupMessageRow = {
ChannelID: number;
ID: number;
SenderUserID: number;
FromPeerType: string;
FromPeerID: number;
Date: number;
Post: boolean;
Body: string;
PTS: number;
Deleted: boolean;
Media: string;
ViewsCount: number;
EditDate: number;
Pinned: boolean;
};
export type UpdateEventRow = {
PTS: number;
PTSCount: number;
Type: string;
Date: number;
JSON: string;
};
export type ChannelUpdateEventRow = {
PTS: number;
PTSCount: number;
Type: string;
MessageID: number;
Date: number;
SenderUserID: number;
JSON: string;
};
export type OutboxRow = {
ID: number;
TargetUserID: number;
PTS: number;
EventType: string;
Status: string;
Attempts: number;
CreatedAt: string;
UpdatedAt: string;
};
export type MessageDetail = {
Message: MessageRow;
MessageJSON: string;
DialogJSON: string;
PrivateJSON: string;
UpdateEvents: UpdateEventRow[];
Outbox: OutboxRow[];
};
export type GroupMessageDetail = {
Message: GroupMessageRow;
MessageJSON: string;
ChannelJSON: string;
UpdateEvents: ChannelUpdateEventRow[];
};
export type CommandResult = {
command_id: string;
action: string;
status: string;
already_executed: boolean;
dry_run: boolean;
target_user_id?: number;
target_peer?: unknown;
message: string;
details?: Record<string, unknown>;
error?: string;
};
export type AccountListResponse = {
query: string;
limit: number;
rows: AccountRow[];
has_more: boolean;
next_before_id: number;
next_before_active_us: number;
listing: boolean;
};
export type ChannelListResponse = {
query: string;
limit: number;
rows: ChannelRow[];
has_more: boolean;
next_before_id: number;
next_before_updated_us: number;
listing: boolean;
};
export type MessageListResponse = {
owner_user_id: number;
peer_id: number;
before_date: number;
before_id: number;
limit: number;
rows: MessageRow[];
};
export type GroupMessageListResponse = {
channel_id: number;
before_date: number;
before_id: number;
limit: number;
rows: GroupMessageRow[];
};

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

View file

@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
outDir: "dist",
emptyOutDir: true
},
server: {
port: 2410,
proxy: {
"/api": "http://127.0.0.1:2400"
}
}
});

54
cmd/telesrv/buildinfo.go Normal file
View file

@ -0,0 +1,54 @@
package main
import "runtime/debug"
var (
gitCommit = ""
gitBranch = ""
gitTreeState = ""
buildTime = ""
)
type buildMetadata struct {
Commit string
Branch string
TreeState string
BuildTime string
GoVersion string
}
func currentBuildMetadata() buildMetadata {
meta := buildMetadata{
Commit: valueOrUnknown(gitCommit),
Branch: valueOrUnknown(gitBranch),
TreeState: valueOrUnknown(gitTreeState),
BuildTime: valueOrUnknown(buildTime),
}
if info, ok := debug.ReadBuildInfo(); ok {
meta.GoVersion = info.GoVersion
settings := map[string]string{}
for _, setting := range info.Settings {
settings[setting.Key] = setting.Value
}
if meta.Commit == "unknown" {
meta.Commit = valueOrUnknown(settings["vcs.revision"])
}
if meta.TreeState == "unknown" {
switch settings["vcs.modified"] {
case "true":
meta.TreeState = "dirty"
case "false":
meta.TreeState = "clean"
}
}
}
meta.GoVersion = valueOrUnknown(meta.GoVersion)
return meta
}
func valueOrUnknown(v string) string {
if v == "" {
return "unknown"
}
return v
}

View file

@ -13,6 +13,7 @@ import (
"go.uber.org/zap/zaptest"
"github.com/gotd/log/logzap"
tdcrypto "github.com/gotd/td/crypto"
"github.com/gotd/td/exchange"
"github.com/gotd/td/mtproxy"
@ -104,7 +105,7 @@ func TestExecutablePrivateMessageRoundTrip(t *testing.T) {
PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}},
Resolver: noSecretObfuscatedResolver{addr: addr, dc: 2},
DCList: dcs.List{Options: []tg.DCOption{{ID: 2, IPAddress: host, Port: port, Static: true}}},
Logger: zaptest.NewLogger(t).Named("exe-client"),
Logger: logzap.New(zaptest.NewLogger(t).Named("exe-client")),
SessionStorage: storage,
UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }),
}

View file

@ -5,39 +5,65 @@ import (
"context"
"fmt"
"net"
"net/http"
"net/http/pprof"
"os"
"os/signal"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/gotd/td/clock"
"github.com/gotd/td/exchange"
"github.com/gotd/td/tg"
adminapp "telesrv/internal/admin"
"telesrv/internal/adminapi"
"telesrv/internal/app/account"
"telesrv/internal/app/auth"
botsapp "telesrv/internal/app/bots"
channelapp "telesrv/internal/app/channels"
"telesrv/internal/app/contacts"
"telesrv/internal/app/dialogs"
filesapp "telesrv/internal/app/files"
groupcallsapp "telesrv/internal/app/groupcalls"
"telesrv/internal/app/help"
"telesrv/internal/app/langpack"
"telesrv/internal/app/maintenance"
messageapp "telesrv/internal/app/messages"
passkeyapp "telesrv/internal/app/passkey"
phoneapp "telesrv/internal/app/phone"
pollsapp "telesrv/internal/app/polls"
privacyapp "telesrv/internal/app/privacy"
secretchatapp "telesrv/internal/app/secretchat"
"telesrv/internal/app/stargifts"
"telesrv/internal/app/stars"
storiesapp "telesrv/internal/app/stories"
themesapp "telesrv/internal/app/themes"
"telesrv/internal/app/updates"
"telesrv/internal/app/userprojection"
"telesrv/internal/app/users"
"telesrv/internal/botapi"
"telesrv/internal/config"
"telesrv/internal/domain"
"telesrv/internal/mtprotoedge"
"telesrv/internal/rpc"
"telesrv/internal/seed/catalog"
"telesrv/internal/sfu"
storepkg "telesrv/internal/store"
"telesrv/internal/store/memory"
"telesrv/internal/store/postgres"
"telesrv/internal/store/redisstore"
"telesrv/internal/turnsrv"
)
func main() {
logger, err := zap.NewDevelopment()
logger, err := newLogger()
if err != nil {
fmt.Fprintln(os.Stderr, "init logger:", err)
os.Exit(1)
@ -46,15 +72,116 @@ func main() {
if err := run(logger); err != nil {
logger.Error("telesrv 退出", zap.Error(err))
_ = logger.Sync() // os.Exit 跳过 defer缓冲写需显式 flush 错误日志
os.Exit(1)
}
}
// newLogger 构建运行日志器。两项关键改造(相对旧的 zap.NewDevelopment
// - 级别可配TELESRV_LOG_LEVEL默认 info旧版固定 Debug热路径 65 处 Debug
// mtprotoedge 每帧一条)在连接洪峰会刷爆日志。生产/压测用 info 即可,需要时设 debug。
// - 缓冲异步写BufferedWriteSyncer旧版每条日志一次 stderr 同步写 + 全局锁,高并发下
// 在日志锁上串行累积——实测连接时 12 个并发 RPC 的 client_info 阶段被拖成 ~1s 惊群
// mutex profile 91% 竞争在 zap 写)。缓冲后写入批量化,锁持有时间从「每条一次系统调用」
// 降到「攒一批刷一次」。FlushInterval 控制日志可见延迟上界。
func newLogger() (*zap.Logger, error) {
level := zapcore.InfoLevel
if v := strings.TrimSpace(os.Getenv("TELESRV_LOG_LEVEL")); v != "" {
if err := level.UnmarshalText([]byte(strings.ToLower(v))); err != nil {
return nil, fmt.Errorf("parse TELESRV_LOG_LEVEL %q: %w", v, err)
}
}
ws := &zapcore.BufferedWriteSyncer{
WS: zapcore.AddSync(os.Stderr),
FlushInterval: 500 * time.Millisecond,
}
core := zapcore.NewCore(zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()), ws, level)
return zap.New(core,
zap.AddCaller(),
zap.AddStacktrace(zapcore.ErrorLevel),
zap.ErrorOutput(zapcore.AddSync(os.Stderr)),
), nil
}
func newBusinessAutomationOptions(cfg config.Config, online messageapp.BusinessAutomationOnlineChecker, logger *zap.Logger) []messageapp.BusinessAutomationOption {
opts := []messageapp.BusinessAutomationOption{
messageapp.WithBusinessAutomationOnlineChecker(online),
}
provider := strings.ToLower(strings.TrimSpace(cfg.BusinessAIProvider))
switch provider {
case "", "echo":
opts = append(opts, messageapp.WithBusinessAutomationReplyProvider(messageapp.NewEchoBusinessAutomationProvider()))
logger.Info("Business automation reply provider", zap.String("provider", "echo"))
case "template", "quick_reply", "quick-reply":
logger.Info("Business automation reply provider", zap.String("provider", "template"))
default:
logger.Warn("未知 Business automation AI provider回退 quick reply 模板", zap.String("provider", cfg.BusinessAIProvider))
}
return opts
}
// startDebugServer 在 addr 上挂起 net/http/pprof 调试端点addr 为空则关闭)。
// 用独立 mux不污染 http.DefaultServeMux仅注册 pprof 路由:
// - /debug/pprof/profile CPU 剖析(?seconds=30
// - /debug/pprof/heap 堆内存快照
// - /debug/pprof/goroutine goroutine 栈(排查泄漏/阻塞)
// - /debug/pprof/mutex 锁竞争(需 SetMutexProfileFraction
// - /debug/pprof/block 阻塞剖析(需 SetBlockProfileRate
// - /debug/pprof/allocs 累计分配(带宽/序列化热点常与之相关)
//
// mutex/block 采样在低流量测试环境开销可忽略;高流量生产如担心扰动,置空 DebugAddr 关闭整端点。
func startDebugServer(ctx context.Context, addr string, logger *zap.Logger) {
if addr == "" {
return
}
runtime.SetMutexProfileFraction(5) // 采样 1/5 的锁竞争事件
runtime.SetBlockProfileRate(10000) // 每阻塞约 10µs 采一次样
mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index) // 含 heap/goroutine/mutex/block/allocs 等命名 profile
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
srv := &http.Server{Addr: addr, Handler: mux}
go func() {
logger.Info("pprof 调试端点已启用", zap.String("addr", addr),
zap.String("hint", "go tool pprof http://"+addr+"/debug/pprof/profile?seconds=30"))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Warn("pprof 端点退出", zap.Error(err))
}
}()
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
}
// externalMediaOption 按配置启用外链媒体抓取;禁用时返回 nilNewService 跳过 nil option
func externalMediaOption(cfg config.Config) filesapp.Option {
if !cfg.ExternalMediaEnable {
return nil
}
return filesapp.WithExternalMedia(cfg.ExternalMediaMaxBytes, cfg.ExternalMediaRatePerMin)
}
// webPagePreviewOption 按配置启用链接预览抓取;禁用时返回 nilNewService 跳过 nil option
func webPagePreviewOption(cfg config.Config) filesapp.Option {
if !cfg.WebPagePreviewEnable {
return nil
}
return filesapp.WithWebPagePreview(cfg.WebPagePreviewMaxBytes, cfg.WebPagePreviewRatePerMin)
}
func run(logger *zap.Logger) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
}
buildMeta := currentBuildMetadata()
rsaKey, err := mtprotoedge.LoadOrGenerateRSAKey(cfg.RSAKeyPath)
if err != nil {
@ -71,12 +198,17 @@ func run(logger *zap.Logger) error {
return fmt.Errorf("parse listen port %q: %w", portStr, err)
}
// tg.Layer 来自 gotd/td v0.144.0(应为 225与目标 TDesktop 基线对齐。
// tg.Layer 来自 gotd/td v0.158.0Layer 227与目标 TDesktop 基线对齐。
logger.Info("telesrv 启动",
zap.String("listen", cfg.ListenAddr),
zap.Int("dc", cfg.DC),
zap.String("advertise", net.JoinHostPort(cfg.AdvertiseIP, portStr)),
zap.Int("tl_layer", tg.Layer),
zap.String("git_commit", buildMeta.Commit),
zap.String("git_branch", buildMeta.Branch),
zap.String("git_tree_state", buildMeta.TreeState),
zap.String("build_time", buildMeta.BuildTime),
zap.String("go_version", buildMeta.GoVersion),
zap.String("rsa_key", cfg.RSAKeyPath),
zap.Int64("rsa_fingerprint", fingerprint),
)
@ -84,11 +216,21 @@ func run(logger *zap.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// pprof 调试端点telesrv 是宿主进程(不在 docker 内docker stats 看不到它CPU/内存/
// goroutine/锁竞争的定位全靠此端点。早于重负载初始化启动,连 seed/预热阶段也可剖析。
startDebugServer(ctx, cfg.DebugAddr, logger)
// 持久化依赖:先迁移 schema再建立连接。auth_key 落 PostgreSQL、session 落 Redis。
// 依赖由 deploy/docker-compose.yml 启动;连不上则启动失败(开发期须先 docker compose up
if err := postgres.Migrate(cfg.PostgresDSN); err != nil {
migrationStatus, err := postgres.MigrateAndStatus(cfg.PostgresDSN)
if err != nil {
return fmt.Errorf("postgres migrate: %w", err)
}
logger.Info("PostgreSQL schema 已迁移",
zap.Uint("schema_version", migrationStatus.Version),
zap.Bool("schema_dirty", migrationStatus.Dirty),
zap.Bool("schema_empty", migrationStatus.Empty),
)
pool, err := postgres.Open(ctx, cfg.PostgresDSN,
postgres.WithMaxConns(cfg.PostgresMaxConns),
postgres.WithMinConns(cfg.PostgresMinConns),
@ -113,25 +255,68 @@ func run(logger *zap.Logger) error {
authKeyStore := postgres.NewAuthKeyStore(pool)
userStore := postgres.NewUserStore(pool)
authzStore := postgres.NewAuthorizationStore(pool)
adminStore := postgres.NewAdminStore(pool)
updateStateStore := postgres.NewUpdateStateStore(pool)
updateEventStore := postgres.NewUpdateEventStore(pool)
updateEventStore := postgres.NewUpdateEventStore(pool, postgres.WithUpdateEventLogger(logger.Named("store").Named("updates")))
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(postgres.NewReadModelVersionStore(pool), 0, 0)
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
ptsAllocator := redisstore.NewPtsAllocator(rdb, updateEventStore)
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
channelPtsAllocator := redisstore.NewChannelPtsAllocator(rdb, postgres.NewChannelPtsCounterSource(pool))
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
contactStore := postgres.NewContactStore(pool)
secretChatIDAllocator := redisstore.NewSecretChatIDAllocator(rdb, postgres.NewSecretChatIDCounterSource(pool))
contactStore := userprojection.NewCachedContactStore(postgres.NewContactStore(pool), 0)
dialogStore := postgres.NewDialogStore(pool)
messageStore := postgres.NewMessageStore(pool, postgres.WithMessageAllocators(boxIDAllocator, ptsAllocator))
channelStore := postgres.NewChannelStore(pool, postgres.WithChannelAllocators(channelIDAllocator, channelPtsAllocator, channelMessageIDAllocator))
messageStore := postgres.NewMessageStore(pool,
postgres.WithMessageAllocators(boxIDAllocator),
postgres.WithMessageLogger(logger.Named("store").Named("messages")))
// 共享频道行/成员缓存 + 统一 read-model LISTEN/NOTIFY 实时失效:消除高频「逐 RPC
// 解析频道/成员」在客户端重连同步突发里重复读同一行的放大。
channelRowCache := postgres.NewChannelRowCache(cfg.ChannelRowCacheMaxEntries)
channelMemberCache := postgres.NewChannelMemberCache(cfg.ChannelMemberCacheMaxEntries)
channelDialogCache := postgres.NewChannelDialogCache(cfg.ChannelDialogCacheMaxEntries)
channelBoostCache := postgres.NewChannelBoostCache(cfg.ChannelBoostCacheMaxEntries, cfg.ChannelBoostCacheTTL)
channelStore := postgres.NewChannelStore(pool,
postgres.WithChannelAllocators(channelIDAllocator, channelMessageIDAllocator),
postgres.WithChannelLogger(logger.Named("store").Named("channels")),
postgres.WithChannelRowCache(channelRowCache),
postgres.WithChannelMemberCache(channelMemberCache),
postgres.WithChannelDialogCache(channelDialogCache),
postgres.WithChannelBoostCache(channelBoostCache))
pollStore := postgres.NewPollStore(pool)
mediaStore := postgres.NewMediaStore(pool)
privacyStore := postgres.NewPrivacyStore(pool)
// 头像投影缓存:所有 projector 共用一层短 TTL owner→头像缓存消除高频「返回用户」RPC
// 每次投影对每批 owner 固定 2 次的 CurrentProfilePhotosKind PG 查询。
cachedPhotos := userprojection.NewCachedPhotoProvider(mediaStore, userprojection.DefaultPhotoCacheTTL)
privacyStore := privacyapp.NewCachedPrivacyStore(postgres.NewPrivacyStore(pool), 0)
storyStore := postgres.NewStoryStore(pool)
blobBackend, err := filesapp.NewLocalFS(cfg.BlobDir)
if err != nil {
return fmt.Errorf("init blob backend: %w", err)
}
filesService := filesapp.NewService(mediaStore, blobBackend, cfg.DC)
logger.Info("blob backend 就绪",
zap.String("backend", "localfs"),
zap.String("dir", cfg.BlobDir),
)
filesService := filesapp.NewService(mediaStore, blobBackend, cfg.DC,
filesapp.WithLogger(logger),
filesapp.WithUploadPartQuota(domain.UploadPartQuota{
MaxBytes: cfg.UploadInFlightMaxBytes,
MaxParts: cfg.UploadInFlightMaxParts,
MaxFiles: cfg.UploadInFlightMaxFiles,
}),
filesapp.WithMapboxMapTiles(cfg.MapboxToken, cfg.MapTileCacheDir),
externalMediaOption(cfg),
webPagePreviewOption(cfg),
)
if cfg.MapboxToken != "" {
logger.Info("地图缩略图代理已启用", zap.String("provider", "mapbox"), zap.String("cache_dir", cfg.MapTileCacheDir))
}
if cfg.ExternalMediaEnable {
logger.Info("外链媒体抓取已启用", zap.Int64("max_bytes", cfg.ExternalMediaMaxBytes), zap.Int("rate_per_min", cfg.ExternalMediaRatePerMin))
}
if cfg.WebPagePreviewEnable {
logger.Info("链接预览抓取已启用", zap.Int64("max_bytes", cfg.WebPagePreviewMaxBytes), zap.Int("rate_per_min", cfg.WebPagePreviewRatePerMin))
}
if stats, err := filesService.SeedMedia(ctx, cfg.StickerSeedDir, cfg.StickerSeedMaxSets); err != nil {
return fmt.Errorf("seed media: %w", err)
} else if !stats.Skipped {
@ -139,6 +324,17 @@ func run(logger *zap.Logger) error {
zap.String("dir", cfg.StickerSeedDir),
zap.Int("reactions", stats.Reactions),
zap.Int("sticker_sets", stats.StickerSets),
zap.Int("effects", stats.Effects),
zap.Int("documents", stats.Documents),
zap.Int("blobs", stats.Blobs),
)
}
if stats, err := filesService.SeedAppearance(ctx); err != nil {
return fmt.Errorf("seed appearance: %w", err)
} else if !stats.Skipped {
logger.Info("外观种子导入完成",
zap.String("source", "default-seed"),
zap.Int("wallpapers", stats.Wallpapers),
zap.Int("documents", stats.Documents),
zap.Int("blobs", stats.Blobs),
)
@ -152,65 +348,303 @@ func run(logger *zap.Logger) error {
zap.Int("blobs", stats.Blobs),
)
}
// 默认 emoji status 系统集:从 animated_emoji 精选合成(幂等,已 seed 的存量
// 库重启后自动补上);缺失时 premium 用户的 status 选择器会是空的。
if count, created, err := filesService.EnsureDefaultEmojiStatusSet(ctx); err != nil {
logger.Warn("默认 emoji status 系统集合成失败", zap.Error(err))
} else if created {
logger.Info("默认 emoji status 系统集已合成", zap.Int("documents", count))
}
langPackStore := postgres.NewLangPackStore(pool)
passwordStore := postgres.NewPasswordStore(pool)
helpStore := postgres.NewHelpStore(pool)
tempAuthKeyStore := postgres.NewTempAuthKeyBindingStore(pool)
sessionStore := redisstore.NewSessionStore(rdb, redisstore.DefaultSessionTTL)
inlineRegistryStore := redisstore.NewInlineRegistryStore(rdb)
codeStore := redisstore.NewCodeStore(rdb)
rateLimiter := redisstore.NewRateLimiter(rdb)
activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions"))
go maintenance.NewRetentionWorker(dispatchOutboxStore, logger.Named("maintenance").Named("retention"),
adminService := adminapp.NewService(adminapp.Dependencies{
Commands: adminStore,
Restrictions: adminStore,
})
go maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"),
cfg.UpdateEventRetention,
cfg.RetentionInterval,
cfg.RetentionBatch,
).Run(ctx)
go rpc.NewOutboxDispatcher(updateEventStore, dispatchOutboxStore, activeSessions, logger.Named("rpc").Named("outbox"),
rpc.WithOutboxWorkers(cfg.OutboxWorkers),
rpc.WithOutboxBatch(cfg.OutboxBatch),
rpc.WithOutboxInterval(cfg.OutboxInterval),
rpc.WithOutboxPushTimeout(cfg.OutboundPushTimeout),
go filesapp.NewUploadPartGCWorker(filesService, logger.Named("files").Named("upload_gc"),
cfg.UploadPartTTL,
cfg.UploadPartGCInterval,
cfg.UploadPartGCBatch,
).Run(ctx)
langPackService := langpack.NewService(langPackStore)
privacyService := privacyapp.NewService(privacyStore, contactStore)
contactsService := contacts.NewService(contactStore, userStore).Configure(
contacts.WithPhotoProvider(cachedPhotos),
contacts.WithPrivacyEvaluator(privacyService),
contacts.WithReadModelVersions(readModelVersionStore),
)
if seeded, err := langPackService.SeedDirectory(ctx, cfg.LangPackSeedDir); err != nil {
return fmt.Errorf("seed langpack: %w", err)
} else if seeded > 0 {
logger.Info("语言包种子导入完成", zap.String("dir", cfg.LangPackSeedDir), zap.Int("strings", seeded))
}
// 国家区号目录:把 catalog 固化的官方全量(~235 国)幂等 upsert 进 PG,覆盖迁移里仅
// seed 的 2 国(US/CN)默认值。否则 countries 表非空,ListCountries 返回那 2 行就会
// 绕过 catalog,登录页/号码格式只显示 2 国。upsert 失败仅告警不阻断启动(回退旧 2 行)。
if cs := catalog.Countries().Countries; len(cs) > 0 {
if err := helpStore.UpsertCountries(ctx, cs); err != nil {
logger.Warn("国家区号种子导入失败", zap.Error(err))
} else {
logger.Info("国家区号种子导入完成", zap.Int("countries", len(cs)))
}
}
botStore := postgres.NewBotStore(pool)
// userCache 与 users 服务共享同一实例bot 元数据写入version bump后必须
// 失效缓存,否则 TTL 内 getUsers 回旧 first_name/旧 bot_info_version。
userCache := redisstore.NewUserCache(rdb, redisstore.DefaultUserCacheTTL)
botsService := botsapp.NewService(userStore, botStore, messageStore,
botsapp.WithLogger(logger.Named("bots")),
botsapp.WithBlockChecker(contactStore),
botsapp.WithPublicChannelUsernameResolver(channelStore),
botsapp.WithUserCache(userCache))
groupCallStore := postgres.NewGroupCallStore(pool)
groupCallsService := groupcallsapp.NewService(groupCallStore)
// 群通话媒体面:内嵌 pion SFUM1+。SFU 的 liveness reporter 把媒体面存活
// 回报给信令侧保活水位sweeper 双过期判据的实现未启用则退化为纯信令M0
sfuService := sfu.Service(sfu.Disabled())
if cfg.SFUEnable {
sfuAdvertise := cfg.SFUAdvertiseIP
if sfuAdvertise == "" {
sfuAdvertise = cfg.AdvertiseIP
}
pionSFU, err := sfu.NewPion(sfu.PionConfig{
UDPPort: cfg.SFUUDPPort,
AdvertiseIP: sfuAdvertise,
Logger: logger.Named("sfu"),
Touch: func(callID, userID int64) {
if _, _, err := groupCallsService.Touch(context.Background(), callID, userID, int(time.Now().Unix())); err != nil {
logger.Debug("sfu liveness touch", zap.Int64("call_id", callID), zap.Int64("user_id", userID), zap.Error(err))
}
},
})
if err != nil {
return fmt.Errorf("init sfu: %w", err)
}
sfuService = pionSFU
}
// 私聊通话中继P3内嵌 TURN/STUNphoneCall.connections 经 phoneConnectionWebrtc
// 下发。未启用时退回 P1 的纯信令 LAN 直连。
turnService := turnsrv.Service(turnsrv.Disabled())
if cfg.TURNEnable {
turnAdvertise := cfg.TURNAdvertiseIP
if turnAdvertise == "" {
turnAdvertise = cfg.SFUAdvertiseIP
}
if turnAdvertise == "" {
turnAdvertise = cfg.AdvertiseIP
}
t, err := turnsrv.New(turnsrv.Config{
UDPPort: cfg.TURNUDPPort,
AdvertiseIP: turnAdvertise,
SharedSecret: cfg.TURNSecret,
RelayMinPort: cfg.TURNRelayMinPort,
RelayMaxPort: cfg.TURNRelayMaxPort,
CredentialTTL: cfg.CallTURNCredentialTTL,
Logger: logger.Named("turn"),
})
if err != nil {
return fmt.Errorf("init turn: %w", err)
}
defer t.Close()
turnService = t
}
// 服务端重启恢复SFU 状态全失,把全部活跃通话的参与者批量置 leftversion++
// 客户端经 checkGroupCall 发现自己 ssrc 消失后自动 rejoin。
if calls, err := groupCallsService.ResetAllParticipants(ctx, int(time.Now().Unix())); err != nil {
logger.Warn("重启清理群通话参与者失败", zap.Error(err))
} else if len(calls) > 0 {
logger.Info("重启清理群通话参与者", zap.Int("calls", len(calls)))
}
phoneService := phoneapp.NewService(phoneapp.Config{
RingTimeout: cfg.CallRingTimeout,
TombstoneTTL: cfg.CallTombstoneTTL,
MaxActivePerUser: cfg.CallMaxActivePerUser,
SignalingRatePerSecond: cfg.CallSignalingRate,
})
// 私聊端对端加密Secret Chat握手状态机 + qts 投递队列(盲中继)。
secretChatStore := postgres.NewSecretChatStore(pool)
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator)
starsStore := postgres.NewStarsStore(pool)
starsService := stars.NewService(starsStore, stars.WithStartingGrant(cfg.StarsStartingGrant))
starGiftStore := postgres.NewStarGiftStore(pool)
giftsService := stargifts.NewService(starGiftStore, filesService)
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
// 同属进程内一次性凭据,不跨实例)。
passkeyStore := postgres.NewPasskeyStore(pool)
passkeyChallengeStore := memory.NewPasskeyChallengeStore()
passkeyService := passkeyapp.NewService(passkeyStore, passkeyChallengeStore, cfg.PasskeyRPID, cfg.DC,
passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins))
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
themeService := themesapp.NewService(postgres.NewThemeStore(pool))
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService))
dialogsService := dialogs.NewService(dialogStore, channelStore).Configure(
dialogs.WithContactStore(contactStore),
dialogs.WithPhotoProvider(cachedPhotos),
dialogs.WithPrivacyEvaluator(privacyService),
dialogs.WithPremiumChecker(usersService.PremiumActive),
dialogs.WithReadModelVersions(readModelVersionStore),
)
// 编译期保证 *users.Service 满足 channel fan-out 跨 viewer 投影预热的可选能力;签名漂移会在
// 这里立刻断编译,而非在运行时静默退化回 O(viewer) 逐 viewer 投影。
var _ rpc.BatchViewerUsersResolver = usersService
channelsService := channelapp.NewService(channelStore,
channelapp.WithBotProfileResolver(botsService),
channelapp.WithReadModelVersions(readModelVersionStore),
channelapp.WithSendPermissionChecker(adminService),
)
businessAutomationOptions := newBusinessAutomationOptions(cfg, activeSessions, logger)
messagesService := messageapp.NewService(messageStore, dialogStore,
messageapp.WithContactStore(contactStore),
messageapp.WithPhotoProvider(cachedPhotos),
messageapp.WithPrivacyEvaluator(privacyService),
messageapp.WithReadModelVersions(readModelVersionStore),
messageapp.WithBotResponder(botsService),
messageapp.WithSendPermissionChecker(adminService),
messageapp.WithBusinessAutomation(passwordStore, businessAutomationOptions...),
)
authService := auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, auth.WithLoginMessages(messageStore, dialogStore), auth.WithPasswords(passwordStore), auth.WithBotLogin(botStore), auth.WithPremiumGrant(cfg.PremiumGrantMonths))
accountService := account.NewService(passwordStore,
account.WithReactionSettings(passwordStore),
account.WithAccountSettings(passwordStore),
account.WithNotifySettings(passwordStore),
account.WithStickerCollections(passwordStore),
account.WithSavedMusic(passwordStore),
account.WithBusinessAutomation(passwordStore),
account.WithUsers(userStore))
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
router := rpc.New(rpc.Config{
DC: cfg.DC,
IP: cfg.AdvertiseIP,
Port: port,
OutboundPushTimeout: cfg.OutboundPushTimeout,
DC: cfg.DC,
IP: cfg.AdvertiseIP,
Port: port,
OutboundPushTimeout: cfg.OutboundPushTimeout,
SendRateLimit: cfg.SendRateLimit,
SendRateWindow: cfg.SendRateWindow,
CatchupRateLimit: cfg.CatchupRateLimit,
CatchupRateWindow: cfg.CatchupRateWindow,
ChannelNudgeMaxTargets: cfg.ChannelNudgeMaxTargets,
CallSignalingMaxBytes: cfg.CallSignalingMaxBytes,
CallForceRelay: cfg.CallForceRelay,
GroupCallMaxParticipants: cfg.GroupCallMaxParticipants,
// PFS temp→perm 解析缓存 5s削减每帧 ResolveAuthKey 的 PG 查询。显式撤销会清缓存并
// 断开连接re-bind 即时失效onAuthBindTempAuthKey
TempKeyResolveCacheTTL: 5 * time.Second,
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
}, rpc.Deps{
Auth: auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, auth.WithLoginMessages(messageStore, dialogStore), auth.WithPasswords(passwordStore)),
Account: account.NewService(passwordStore, account.WithReactionSettings(passwordStore)),
Privacy: privacyService,
Help: help.NewService(helpStore, helpStore),
Users: users.NewService(userStore, users.WithContactStore(contactStore), users.WithPhotoProvider(mediaStore), users.WithPrivacyEvaluator(privacyService)),
Updates: updates.NewService(updateStateStore, updateEventStore, updates.WithPtsAllocator(ptsAllocator)),
Contacts: contacts.NewService(contactStore, userStore).Configure(contacts.WithPhotoProvider(mediaStore), contacts.WithPrivacyEvaluator(privacyService)),
Dialogs: dialogs.NewService(dialogStore, channelStore).Configure(dialogs.WithContactStore(contactStore), dialogs.WithPhotoProvider(mediaStore), dialogs.WithPrivacyEvaluator(privacyService)),
Messages: messageapp.NewService(messageStore, dialogStore, messageapp.WithContactStore(contactStore), messageapp.WithPhotoProvider(mediaStore), messageapp.WithPrivacyEvaluator(privacyService)),
Channels: channelapp.NewService(channelStore),
Files: filesService,
LangPack: langPackService,
Sessions: activeSessions,
Limiter: rateLimiter,
Auth: authService,
Account: accountService,
Privacy: privacyService,
Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken)),
Users: usersService,
Updates: updatesService,
Contacts: contactsService,
Dialogs: dialogsService,
Messages: messagesService,
Channels: channelsService,
Files: filesService,
Bots: botsService,
Polls: pollsapp.NewService(pollStore),
Stories: storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService)),
Phone: phoneService,
SecretChats: secretChatService,
Stars: starsService,
Gifts: giftsService,
Passkey: passkeyService,
Themes: themeService,
GroupCalls: groupCallsService,
SFU: sfuService,
TURN: turnService,
LangPack: langPackService,
Sessions: activeSessions,
Inline: inlineRegistryStore,
Limiter: rateLimiter,
}, logger.Named("rpc"), clock.System)
readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{
ReadModelVersions: readModelVersionStore,
ChannelRows: channelRowCache,
ChannelMembers: channelMemberCache,
ChannelDialogs: channelDialogCache,
ChannelBoosts: channelBoostCache,
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
Dialogs: dialogsService,
Privacy: privacyStore,
ProfilePhotos: cachedPhotos,
Stories: router,
ChannelFullBots: router,
ChannelMediaCounts: channelsService,
PrivateMediaCounts: messagesService,
RPCProjections: router,
BaseUsers: userCache,
BotProfiles: botsService,
}, logger.Named("store").Named("read-model-listener"))
go readModelListener.Run(ctx)
activeSessions.SetLifecycleObserver(router)
adminService.Configure(adminapp.Dependencies{
Auth: authService,
Revoker: router,
Users: usersService,
UserNotifier: router,
Channels: channelsService,
ChannelNotifier: router,
Messages: messagesService,
})
// token revoke 后踢已登录 bot session 经 router 实现(需 tg.* 边界router 创建后注入。
botsService.SetRouterHooks(router)
go rpc.NewOutboxDispatcher(updateEventStore, dispatchOutboxStore, activeSessions, logger.Named("rpc").Named("outbox"),
rpc.WithOutboxWorkers(cfg.OutboxWorkers),
rpc.WithOutboxBatch(cfg.OutboxBatch),
rpc.WithOutboxInterval(cfg.OutboxInterval),
rpc.WithOutboxPushTimeout(cfg.OutboundPushTimeout),
rpc.WithOutboxUpdateBuilder(router.BuildOutboxUpdates),
).Run(ctx)
go rpc.NewScheduledDispatcher(router, logger.Named("rpc").Named("scheduled")).Run(ctx)
go rpc.NewExpiryDispatcher(router, logger.Named("rpc").Named("expiry")).Run(ctx)
go rpc.NewPhoneExpiryDispatcher(router, logger.Named("rpc").Named("phone-expiry"), cfg.CallExpiryInterval).Run(ctx)
go rpc.NewGroupCallSweepDispatcher(router, logger.Named("rpc").Named("groupcall-sweep"), cfg.GroupCallSweepInterval, cfg.GroupCallCheckTTL).Run(ctx)
go router.RunChannelFanout(ctx)
go router.RunPresenceSweeper(ctx, time.Minute)
go activeSessions.RunPendingSweeper(ctx, time.Minute)
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
go router.RunInlineBotPushSubscriber(ctx)
if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, logger.Named("botapi")); err != nil {
return fmt.Errorf("start bot api: %w", err)
}
if _, err := adminapi.Start(ctx, adminapi.Config{Addr: cfg.AdminAPIAddr, Token: cfg.AdminAPIToken}, adminService, logger.Named("adminapi")); err != nil {
return fmt.Errorf("start admin api: %w", err)
}
srv := mtprotoedge.New(mtprotoedge.Options{
Logger: logger.Named("mtprotoedge"),
DC: cfg.DC,
RSAKey: rsaKey,
RPC: router,
AuthKeys: authKeyStore,
Sessions: sessionStore,
ActiveSessions: activeSessions,
ObfuscatedTCP: true,
Logger: logger.Named("mtprotoedge"),
DC: cfg.DC,
RSAKey: rsaKey,
RPC: router,
AuthKeys: authKeyStore,
Sessions: sessionStore,
ActiveSessions: activeSessions,
ObfuscatedTCP: true,
WebSocket: cfg.WebSocketEnable,
WebSocketAllowedOrigins: cfg.WebSocketAllowedOrigins,
})
logger.Info("telesrv 服务就绪",
zap.String("listen", cfg.ListenAddr),
zap.String("advertise", net.JoinHostPort(cfg.AdvertiseIP, portStr)),
zap.Int("pid", os.Getpid()),
zap.String("git_commit", buildMeta.Commit),
zap.Uint("schema_version", migrationStatus.Version),
zap.String("blob_backend", "localfs"),
)
return srv.Serve(ctx, ln)
}

937
cmd/walletminiapp/main.go Normal file
View file

@ -0,0 +1,937 @@
// Command walletminiapp is a runnable Mini App demo for telesrv bot developers.
//
// It intentionally behaves like an external bot program: it serves the Mini App
// over HTTP and configures telesrv through the Bot API gateway. It does not write
// directly to telesrv stores, so the demo remains close to a real integration.
package main
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
const (
defaultBalanceCents = int64(125000)
maxMemoLen = 180
maxTransferCents = int64(500000)
)
type config struct {
listen string
publicURL string
botAPI string
token string
menuText string
register bool
initMax time.Duration
}
type appServer struct {
cfg config
botapi *botAPIClient
wallet *walletStore
log *log.Logger
}
func main() {
cfg := config{}
flag.StringVar(&cfg.listen, "listen", envOr("TELESRV_WALLET_LISTEN", "127.0.0.1:8091"), "wallet mini app HTTP listen address")
flag.StringVar(&cfg.publicURL, "public-url", os.Getenv("TELESRV_WALLET_PUBLIC_URL"), "public HTTPS URL used in the Telegram menu button")
flag.StringVar(&cfg.botAPI, "bot-api", envOr("TELESRV_BOT_API_URL", "http://127.0.0.1:8081"), "telesrv Bot API base URL")
flag.StringVar(&cfg.token, "token", os.Getenv("TELESRV_BOT_TOKEN"), "bot token <bot_id>:<secret>")
flag.StringVar(&cfg.menuText, "menu-text", envOr("TELESRV_WALLET_MENU_TEXT", "Wallet"), "menu button label")
flag.BoolVar(&cfg.register, "register", true, "call Bot API setChatMenuButton on startup when token and public-url are set")
flag.DurationVar(&cfg.initMax, "init-max-age", 24*time.Hour, "maximum accepted tgWebAppData age, 0 disables age checks")
flag.Parse()
logger := log.New(os.Stdout, "walletminiapp ", log.LstdFlags|log.Lmicroseconds)
if cfg.publicURL == "" {
cfg.publicURL = "http://" + cfg.listen + "/"
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
app := &appServer{
cfg: cfg,
botapi: newBotAPIClient(cfg.botAPI, cfg.token),
wallet: newWalletStore(),
log: logger,
}
if err := app.configureBotMenu(ctx); err != nil {
logger.Fatalf("configure bot menu: %v", err)
}
srv := &http.Server{
Addr: cfg.listen,
Handler: app.routes(),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
logger.Printf("serving wallet mini app on http://%s/", cfg.listen)
logger.Printf("configured public URL: %s", cfg.publicURL)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Fatalf("serve: %v", err)
}
}
func (s *appServer) configureBotMenu(ctx context.Context) error {
if !s.cfg.register {
s.log.Printf("bot menu registration disabled")
return nil
}
if strings.TrimSpace(s.cfg.token) == "" {
s.log.Printf("bot token missing; serving UI only, Bot API calls disabled")
return nil
}
if !isHTTPSURL(s.cfg.publicURL) {
s.log.Printf("public-url is not HTTPS; serving UI only, skip setChatMenuButton")
return nil
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := s.botapi.setChatMenuButton(ctx, s.cfg.menuText, s.cfg.publicURL); err != nil {
return err
}
s.log.Printf("registered menu button %q -> %s", s.cfg.menuText, s.cfg.publicURL)
return nil
}
func (s *appServer) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
})
mux.HandleFunc("/api/session", s.handleSession)
mux.HandleFunc("/api/transfer", s.handleTransfer)
mux.HandleFunc("/api/share-prepared", s.handleSharePrepared)
mux.HandleFunc("/api/answer-webapp-query", s.handleAnswerWebAppQuery)
mux.HandleFunc("/api/payment-intent", s.handlePaymentIntent)
mux.HandleFunc("/", s.handleIndex)
return withSecurityHeaders(mux)
}
func withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
next.ServeHTTP(w, r)
})
}
func (s *appServer) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed")
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, walletHTML)
}
func (s *appServer) handleSession(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed")
return
}
raw := firstNonEmpty(r.Header.Get("X-Telegram-Init-Data"), r.URL.Query().Get("init_data"))
session, err := parseWebAppSession(raw, s.cfg.token, s.cfg.initMax)
if err != nil {
writeError(w, http.StatusUnauthorized, "INIT_DATA_INVALID", err.Error())
return
}
snapshot := s.wallet.snapshot(session.User.ID)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"session": session,
"wallet": snapshot,
"bot_api": s.botapi.enabled(),
"demo_url": s.cfg.publicURL,
})
}
func (s *appServer) handleTransfer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed")
return
}
var payload transferRequest
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid JSON")
return
}
session, err := parseWebAppSession(payload.InitData, s.cfg.token, s.cfg.initMax)
if err != nil {
writeError(w, http.StatusUnauthorized, "INIT_DATA_INVALID", err.Error())
return
}
tx, snapshot, err := s.wallet.transfer(session.User.ID, payload.To, payload.AmountCents, payload.Memo)
if err != nil {
writeError(w, http.StatusBadRequest, "TRANSFER_INVALID", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "session": session, "tx": tx, "wallet": snapshot})
}
func (s *appServer) handleSharePrepared(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed")
return
}
if !s.botapi.enabled() {
writeError(w, http.StatusServiceUnavailable, "BOT_API_DISABLED", "bot token is required")
return
}
var payload shareRequest
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid JSON")
return
}
session, err := parseWebAppSession(payload.InitData, s.cfg.token, s.cfg.initMax)
if err != nil {
writeError(w, http.StatusUnauthorized, "INIT_DATA_INVALID", err.Error())
return
}
result := walletInlineResult("wallet-share-"+strconv.FormatInt(time.Now().UnixNano(), 36), "Wallet receipt", receiptText(session, payload), s.publicResultURL())
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
prepared, err := s.botapi.savePreparedInlineMessage(ctx, session.User.ID, result)
if err != nil {
writeError(w, http.StatusBadGateway, "BOT_API_ERROR", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "prepared": prepared})
}
func (s *appServer) handleAnswerWebAppQuery(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed")
return
}
if !s.botapi.enabled() {
writeError(w, http.StatusServiceUnavailable, "BOT_API_DISABLED", "bot token is required")
return
}
var payload shareRequest
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid JSON")
return
}
session, err := parseWebAppSession(payload.InitData, s.cfg.token, s.cfg.initMax)
if err != nil {
writeError(w, http.StatusUnauthorized, "INIT_DATA_INVALID", err.Error())
return
}
if session.QueryID == "" {
writeError(w, http.StatusBadRequest, "QUERY_ID_INVALID", "current launch has no web_app query_id")
return
}
result := walletInlineResult("wallet-answer-"+strconv.FormatInt(time.Now().UnixNano(), 36), "Wallet receipt", receiptText(session, payload), s.publicResultURL())
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
out, err := s.botapi.answerWebAppQuery(ctx, session.QueryID, result)
if err != nil {
writeError(w, http.StatusBadGateway, "BOT_API_ERROR", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "sent": out})
}
func (s *appServer) handlePaymentIntent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed")
return
}
writeError(w, http.StatusNotImplemented, "PAYMENTS_BLOCKED", "durable invoice/payment state is not implemented in telesrv yet")
}
func (s *appServer) publicResultURL() string {
if isHTTPSURL(s.cfg.publicURL) {
return s.cfg.publicURL
}
return ""
}
type transferRequest struct {
InitData string `json:"init_data"`
To string `json:"to"`
AmountCents int64 `json:"amount_cents"`
Memo string `json:"memo"`
}
type shareRequest struct {
InitData string `json:"init_data"`
AmountCents int64 `json:"amount_cents"`
Memo string `json:"memo"`
}
type webAppSession struct {
Demo bool `json:"demo"`
Verified bool `json:"verified"`
QueryID string `json:"query_id,omitempty"`
StartParam string `json:"start_param,omitempty"`
AuthDate int64 `json:"auth_date,omitempty"`
User webAppUser `json:"user"`
}
type webAppUser struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name,omitempty"`
Username string `json:"username,omitempty"`
}
func parseWebAppSession(raw, botToken string, maxAge time.Duration) (webAppSession, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return webAppSession{
Demo: true,
User: webAppUser{ID: 0, FirstName: "Browser", Username: "preview"},
}, nil
}
values, err := url.ParseQuery(raw)
if err != nil {
return webAppSession{}, err
}
if strings.TrimSpace(botToken) != "" {
hash := values.Get("hash")
if hash == "" || !validWebAppHash(values, botToken) {
return webAppSession{}, errors.New("hash mismatch")
}
}
var authDate int64
if rawDate := values.Get("auth_date"); rawDate != "" {
authDate, err = strconv.ParseInt(rawDate, 10, 64)
if err != nil || authDate <= 0 {
return webAppSession{}, errors.New("auth_date invalid")
}
if maxAge > 0 && time.Since(time.Unix(authDate, 0)) > maxAge {
return webAppSession{}, errors.New("auth_date expired")
}
}
var user webAppUser
if rawUser := values.Get("user"); rawUser != "" {
if err := json.Unmarshal([]byte(rawUser), &user); err != nil {
return webAppSession{}, errors.New("user invalid")
}
}
if user.ID <= 0 {
return webAppSession{}, errors.New("user missing")
}
return webAppSession{
Verified: strings.TrimSpace(botToken) != "",
QueryID: values.Get("query_id"),
StartParam: values.Get("start_param"),
AuthDate: authDate,
User: user,
}, nil
}
func validWebAppHash(values url.Values, botToken string) bool {
got := values.Get("hash")
if got == "" {
return false
}
want := webAppInitDataHash(values, botToken)
return hmac.Equal([]byte(got), []byte(want))
}
func webAppInitDataHash(values url.Values, botToken string) string {
check := webAppDataCheckString(values)
secretMAC := hmac.New(sha256.New, []byte("WebAppData"))
_, _ = secretMAC.Write([]byte(botToken))
secret := secretMAC.Sum(nil)
dataMAC := hmac.New(sha256.New, secret)
_, _ = dataMAC.Write([]byte(check))
return hex.EncodeToString(dataMAC.Sum(nil))
}
func webAppDataCheckString(values url.Values) string {
keys := make([]string, 0, len(values))
for key := range values {
if key != "hash" {
keys = append(keys, key)
}
}
sort.Strings(keys)
var b strings.Builder
for i, key := range keys {
if i > 0 {
b.WriteByte('\n')
}
b.WriteString(key)
b.WriteByte('=')
b.WriteString(values.Get(key))
}
return b.String()
}
type walletStore struct {
mu sync.Mutex
balances map[int64]int64
txs map[int64][]walletTx
nextID int64
}
type walletSnapshot struct {
BalanceCents int64 `json:"balance_cents"`
Currency string `json:"currency"`
Recent []walletTx `json:"recent"`
}
type walletTx struct {
ID int64 `json:"id"`
To string `json:"to"`
AmountCents int64 `json:"amount_cents"`
Memo string `json:"memo"`
CreatedAt int64 `json:"created_at"`
}
func newWalletStore() *walletStore {
return &walletStore{
balances: map[int64]int64{},
txs: map[int64][]walletTx{},
}
}
func (s *walletStore) snapshot(userID int64) walletSnapshot {
s.mu.Lock()
defer s.mu.Unlock()
s.ensure(userID)
return s.snapshotLocked(userID)
}
func (s *walletStore) transfer(userID int64, to string, amount int64, memo string) (walletTx, walletSnapshot, error) {
to = strings.TrimSpace(to)
memo = strings.TrimSpace(memo)
if to == "" {
return walletTx{}, walletSnapshot{}, errors.New("recipient is required")
}
if amount <= 0 || amount > maxTransferCents {
return walletTx{}, walletSnapshot{}, errors.New("amount is out of demo bounds")
}
if len(memo) > maxMemoLen {
return walletTx{}, walletSnapshot{}, errors.New("memo is too long")
}
s.mu.Lock()
defer s.mu.Unlock()
s.ensure(userID)
if s.balances[userID] < amount {
return walletTx{}, walletSnapshot{}, errors.New("insufficient balance")
}
s.nextID++
tx := walletTx{ID: s.nextID, To: to, AmountCents: amount, Memo: memo, CreatedAt: time.Now().Unix()}
s.balances[userID] -= amount
s.txs[userID] = append([]walletTx{tx}, s.txs[userID]...)
if len(s.txs[userID]) > 8 {
s.txs[userID] = s.txs[userID][:8]
}
return tx, s.snapshotLocked(userID), nil
}
func (s *walletStore) ensure(userID int64) {
if _, ok := s.balances[userID]; !ok {
s.balances[userID] = defaultBalanceCents
}
}
func (s *walletStore) snapshotLocked(userID int64) walletSnapshot {
recent := append([]walletTx(nil), s.txs[userID]...)
return walletSnapshot{BalanceCents: s.balances[userID], Currency: "TSC", Recent: recent}
}
type botAPIClient struct {
base string
token string
http *http.Client
}
func newBotAPIClient(base, token string) *botAPIClient {
return &botAPIClient{
base: strings.TrimRight(base, "/"),
token: strings.TrimSpace(token),
http: &http.Client{Timeout: 8 * time.Second},
}
}
func (c *botAPIClient) enabled() bool {
return c != nil && c.base != "" && c.token != ""
}
func (c *botAPIClient) setChatMenuButton(ctx context.Context, text, webURL string) error {
payload := map[string]any{
"menu_button": map[string]any{
"type": "web_app",
"text": text,
"web_app": map[string]any{
"url": webURL,
},
},
}
var out botAPIResponse[bool]
return c.post(ctx, "setChatMenuButton", payload, &out)
}
func (c *botAPIClient) answerWebAppQuery(ctx context.Context, queryID string, result map[string]any) (map[string]any, error) {
payload := map[string]any{"web_app_query_id": queryID, "result": result}
var out botAPIResponse[map[string]any]
if err := c.post(ctx, "answerWebAppQuery", payload, &out); err != nil {
return nil, err
}
return out.Result, nil
}
func (c *botAPIClient) savePreparedInlineMessage(ctx context.Context, userID int64, result map[string]any) (map[string]any, error) {
payload := map[string]any{
"user_id": userID,
"result": result,
"allow_user_chats": true,
"allow_group_chats": true,
"allow_channel_chats": true,
}
var out botAPIResponse[map[string]any]
if err := c.post(ctx, "savePreparedInlineMessage", payload, &out); err != nil {
return nil, err
}
return out.Result, nil
}
func (c *botAPIClient) post(ctx context.Context, method string, payload any, out any) error {
if !c.enabled() {
return errors.New("bot api disabled")
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/bot"+url.PathEscape(c.token)+"/"+method, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
limited := io.LimitReader(resp.Body, 1<<20)
if err := json.NewDecoder(limited).Decode(out); err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s failed with HTTP %d", method, resp.StatusCode)
}
return nil
}
type botAPIResponse[T any] struct {
OK bool `json:"ok"`
Description string `json:"description"`
Result T `json:"result"`
}
func walletInlineResult(id, title, message, link string) map[string]any {
result := map[string]any{
"type": "article",
"id": id,
"title": title,
"description": "Telesrv wallet demo receipt",
"input_message_content": map[string]any{
"message_text": message,
"disable_web_page_preview": true,
},
}
if link != "" {
result["url"] = link
result["reply_markup"] = map[string]any{
"inline_keyboard": [][]map[string]any{{
{"text": "Open Wallet", "url": link},
}},
}
}
return result
}
func receiptText(session webAppSession, payload shareRequest) string {
name := session.User.FirstName
if name == "" {
name = "Wallet user"
}
memo := strings.TrimSpace(payload.Memo)
if memo == "" {
memo = "wallet demo transfer"
}
return fmt.Sprintf("%s shared a wallet receipt: %s TSC - %s", name, formatCents(payload.AmountCents), memo)
}
func decodeJSON(r *http.Request, out any) error {
defer r.Body.Close()
return json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(out)
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]any{"ok": false, "error": code, "message": message})
}
func isHTTPSURL(raw string) bool {
parsed, err := url.Parse(raw)
return err == nil && parsed.Scheme == "https" && parsed.Host != ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func envOr(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func formatCents(cents int64) string {
sign := ""
if cents < 0 {
sign = "-"
cents = -cents
}
return fmt.Sprintf("%s%d.%02d", sign, cents/100, cents%100)
}
const walletHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Telesrv Wallet</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
:root {
color-scheme: light dark;
--bg: #f5f7f9;
--surface: #ffffff;
--surface-2: #eef3f7;
--text: #13202b;
--muted: #637381;
--line: #d8e0e7;
--accent: #0f8b8d;
--accent-2: #22356f;
--danger: #bf3f3f;
--ok: #237a57;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #101418;
--surface: #171e25;
--surface-2: #202a33;
--text: #edf2f7;
--muted: #9aabba;
--line: #2d3944;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
min-width: 320px;
background: var(--bg);
color: var(--text);
font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
main {
width: min(760px, 100%);
margin: 0 auto;
padding: 18px;
}
header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
padding: 14px 0 18px;
}
h1 { margin: 0; font-size: 24px; font-weight: 760; }
.user { color: var(--muted); margin-top: 4px; }
.pill {
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 10px;
color: var(--muted);
background: var(--surface);
white-space: nowrap;
}
.balance {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
margin-bottom: 14px;
}
.balance label, .field label { color: var(--muted); display: block; margin-bottom: 6px; }
.amount { font-size: 40px; font-weight: 780; line-height: 1.05; }
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
section {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
}
h2 { margin: 0 0 14px; font-size: 16px; }
.field { margin-bottom: 12px; }
input, textarea {
width: 100%;
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--text);
border-radius: 7px;
padding: 10px 11px;
font: inherit;
}
textarea { min-height: 76px; resize: vertical; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
button {
border: 0;
border-radius: 7px;
background: var(--accent);
color: white;
font: inherit;
font-weight: 680;
min-height: 40px;
padding: 10px 13px;
cursor: pointer;
}
button.secondary { background: var(--accent-2); }
button.neutral {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--line);
}
button.danger { background: var(--danger); }
button:disabled { opacity: .55; cursor: default; }
.activity { list-style: none; padding: 0; margin: 0; display: grid; gap: 9px; }
.activity li {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-top: 1px solid var(--line);
}
.activity li:first-child { border-top: 0; }
.muted { color: var(--muted); }
.status { margin-top: 12px; min-height: 22px; color: var(--muted); }
.status.ok { color: var(--ok); }
.status.err { color: var(--danger); }
@media (max-width: 680px) {
main { padding: 14px; }
header { align-items: stretch; flex-direction: column; }
.grid { grid-template-columns: 1fr; }
.amount { font-size: 34px; }
.pill { width: fit-content; }
}
</style>
</head>
<body>
<main>
<header>
<div>
<h1>Telesrv Wallet</h1>
<div class="user" id="userLine">Loading session...</div>
</div>
<div class="pill" id="sessionPill">Mini App</div>
</header>
<div class="balance">
<label>Available balance</label>
<div class="amount" id="balance">0.00 TSC</div>
</div>
<div class="grid">
<section>
<h2>Transfer</h2>
<div class="field">
<label for="to">Recipient</label>
<input id="to" autocomplete="off" value="@alice" maxlength="64">
</div>
<div class="field">
<label for="amount">Amount</label>
<input id="amount" type="number" min="0.01" step="0.01" value="12.50">
</div>
<div class="field">
<label for="memo">Memo</label>
<textarea id="memo" maxlength="180">Coffee settlement</textarea>
</div>
<div class="actions">
<button id="sendBtn">Send</button>
<button class="secondary" id="shareBtn">Share</button>
</div>
<div class="status" id="formStatus"></div>
</section>
<section>
<h2>Recent activity</h2>
<ul class="activity" id="activity"></ul>
<div class="actions" style="margin-top:14px">
<button class="neutral" id="receiptBtn">Send receipt</button>
<button class="danger" id="payBtn">Pay invoice</button>
</div>
<div class="status" id="sideStatus"></div>
</section>
</div>
</main>
<script>
const tg = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null;
if (tg) {
tg.ready();
tg.expand();
}
const initData = tg ? tg.initData : "";
let currentSession = null;
const apiURL = (path) => {
const base = location.pathname.endsWith("/") ? location.href : location.href.replace(/[^/]*$/, "");
return new URL(path.replace(/^\//, ""), base);
};
const money = (cents) => (Number(cents || 0) / 100).toFixed(2) + " TSC";
const status = (id, text, kind) => {
const el = document.getElementById(id);
el.textContent = text || "";
el.className = "status " + (kind || "");
};
async function request(path, options) {
const res = await fetch(apiURL(path), {
...options,
headers: { "Content-Type": "application/json", ...(options && options.headers || {}) }
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body.ok === false) throw new Error(body.message || body.error || res.statusText);
return body;
}
function amountCents() {
return Math.round(Number(document.getElementById("amount").value || "0") * 100);
}
function currentPayload() {
return {
init_data: initData,
to: document.getElementById("to").value,
amount_cents: amountCents(),
memo: document.getElementById("memo").value
};
}
function render(data) {
const session = data.session || currentSession || {};
if (data.session) currentSession = data.session;
const wallet = data.wallet || {};
const user = session.user || {};
document.getElementById("userLine").textContent = user.username ? "@" + user.username : (user.first_name || "Browser preview");
document.getElementById("sessionPill").textContent = session.demo ? "Preview" : (session.verified ? "Verified" : "Unsigned");
document.getElementById("balance").textContent = money(wallet.balance_cents);
const activity = document.getElementById("activity");
activity.innerHTML = "";
const recent = wallet.recent || [];
if (!recent.length) {
const li = document.createElement("li");
li.innerHTML = '<span class="muted">No transfers yet</span><span></span>';
activity.appendChild(li);
return;
}
for (const tx of recent) {
const li = document.createElement("li");
li.innerHTML = '<span><strong>' + escapeHTML(tx.to) + '</strong><br><span class="muted">' + escapeHTML(tx.memo || "") + '</span></span><span>-' + money(tx.amount_cents) + '</span>';
activity.appendChild(li);
}
}
function escapeHTML(value) {
return String(value).replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
}
async function load() {
const body = await request("api/session", { headers: { "X-Telegram-Init-Data": initData } });
render(body);
}
document.getElementById("sendBtn").addEventListener("click", async () => {
status("formStatus", "Sending...", "");
try {
const body = await request("api/transfer", { method: "POST", body: JSON.stringify(currentPayload()) });
render(body);
status("formStatus", "Transfer recorded.", "ok");
if (tg && tg.HapticFeedback) tg.HapticFeedback.notificationOccurred("success");
} catch (err) {
status("formStatus", err.message, "err");
}
});
document.getElementById("shareBtn").addEventListener("click", async () => {
status("formStatus", "Preparing share...", "");
try {
const body = await request("api/share-prepared", { method: "POST", body: JSON.stringify(currentPayload()) });
const prepared = body.prepared || {};
if (tg && typeof tg.sendPreparedMessage === "function" && prepared.id) {
tg.sendPreparedMessage(prepared.id);
status("formStatus", "Share sheet opened.", "ok");
} else {
status("formStatus", "Prepared id: " + (prepared.id || "n/a"), "ok");
}
} catch (err) {
status("formStatus", err.message, "err");
}
});
document.getElementById("receiptBtn").addEventListener("click", async () => {
status("sideStatus", "Sending receipt...", "");
try {
await request("api/answer-webapp-query", { method: "POST", body: JSON.stringify(currentPayload()) });
status("sideStatus", "Receipt sent to the source chat.", "ok");
} catch (err) {
status("sideStatus", err.message, "err");
}
});
document.getElementById("payBtn").addEventListener("click", async () => {
status("sideStatus", "Opening invoice...", "");
try {
await request("api/payment-intent", { method: "POST", body: JSON.stringify(currentPayload()) });
} catch (err) {
status("sideStatus", err.message, "err");
}
});
load().catch(err => status("formStatus", err.message, "err"));
</script>
</body>
</html>
`

View file

@ -0,0 +1,51 @@
package main
import (
"net/url"
"strconv"
"testing"
"time"
)
func TestParseWebAppSessionValidatesHash(t *testing.T) {
token := "1900000001:test-secret"
values := url.Values{}
values.Set("query_id", "web-query-1")
values.Set("auth_date", strconv.FormatInt(time.Now().Unix(), 10))
values.Set("start_param", "wallet")
values.Set("user", `{"id":1780243210,"first_name":"Alice","username":"alice"}`)
values.Set("hash", webAppInitDataHash(values, token))
session, err := parseWebAppSession(values.Encode(), token, time.Hour)
if err != nil {
t.Fatalf("parseWebAppSession: %v", err)
}
if !session.Verified || session.QueryID != "web-query-1" || session.User.ID != 1780243210 || session.User.Username != "alice" {
t.Fatalf("session = %+v", session)
}
values.Set("user", `{"id":1780243211,"first_name":"Bob"}`)
if _, err := parseWebAppSession(values.Encode(), token, time.Hour); err == nil {
t.Fatal("tampered init data accepted")
}
}
func TestWalletTransferBoundsAndSnapshot(t *testing.T) {
store := newWalletStore()
tx, snapshot, err := store.transfer(1001, "@bob", 1250, "coffee")
if err != nil {
t.Fatalf("transfer: %v", err)
}
if tx.ID == 0 || tx.To != "@bob" || tx.AmountCents != 1250 {
t.Fatalf("tx = %+v", tx)
}
if snapshot.BalanceCents != defaultBalanceCents-1250 || len(snapshot.Recent) != 1 {
t.Fatalf("snapshot = %+v", snapshot)
}
if _, _, err := store.transfer(1001, "", 1250, "coffee"); err == nil {
t.Fatal("empty recipient accepted")
}
if _, _, err := store.transfer(1001, "@bob", maxTransferCents+1, "coffee"); err == nil {
t.Fatal("oversized transfer accepted")
}
}