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

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