For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let admins upload a Chinese "协议号" folder (<phone>.json + <phone>.session Pyrogram SQLite + tdata/ + 2fa_password.txt) through the management UI, auto-convert it to gotd's native session format, and persist device fingerprint + encrypted 2FA so the existing TG client can use it without modification.
Architecture: One-shot conversion at upload time. Pyrogram SQLite → extract dc_id + 256-byte auth_key → build gotd/td session.Data → write to sessions/<phone>.json. Fallback to tdata/ via gotd/td/session/tdesktop if Pyrogram fails. Metadata (api_id, api_hash, device, 2FA) parsed from <phone>.json and stored in tg_accounts table; 2FA encrypted with AES-GCM. Runtime telegram.Client only reads the converted gotd JSON — unaware of Pyrogram/tdata.
Tech Stack: Go 1.26 + github.com/gotd/td v0.143.0 + modernc.org/sqlite (new, CGO-free) + Gin + GORM + MySQL + Redis; React 18 + Ant Design + axios.
Spec: docs/superpowers/specs/2026-04-20-tg-protocol-number-import-design.md
New files:
internal/telegram/crypto.go — AES-GCM encrypt/decrypt helperinternal/telegram/crypto_test.gointernal/telegram/sessionimport/dcaddr.go — dc_id → IP:port mappinginternal/telegram/sessionimport/dcaddr_test.gointernal/telegram/sessionimport/pyrogram.go — Pyrogram SQLite → session.Datainternal/telegram/sessionimport/pyrogram_test.gointernal/telegram/sessionimport/tdesktop.go — tdata fallbackinternal/telegram/sessionimport/importer.go — orchestration, parses <phone>.json, writes session + backupdeploy/.env.example — TG_SECRET_KEY templateModified files:
go.mod / go.sum — add modernc.org/sqliteinternal/model/user.go — TgAccount new columnsinternal/telegram/types.go — Account struct device fieldsinternal/telegram/client.go — Connect passes telegram.DeviceConfiginternal/config/config.go — TelegramConfig.SessionsDir + SecretKeyconfigs/config.yaml — two new keysinternal/handler/tg_account.go — Import, RevealTwoFA, reloadAccounts enrichedinternal/handler/router.go — register new routescmd/server/main.go — construct *telegram.Crypto, ensure sessions_dir exists, inject into handlerweb/src/pages/TgAccounts.tsx — Import button, upload modal, new columnsNot modified (already good):
deploy/docker-compose.yml — sessions/ volume already mounted at /app/sessionsinternal/telegram/account_manager.go — no changes needed (new fields flow through Account)modernc.org/sqlite dependencyFiles:
Modify: go.mod, go.sum (auto)
[ ] Step 1: Add dependency
Run:
cd D:/spider && go get modernc.org/sqlite@latest
Expected: go.mod gains modernc.org/sqlite vX.Y.Z in the require block; go.sum updated.
Run:
cd D:/spider && CGO_ENABLED=0 go build ./... 2>&1 | head -20
Expected: build succeeds (no cgo errors). modernc.org/sqlite is pure Go, must not break CGO_ENABLED=0 builds (our Docker image uses CGO_ENABLED=0).
[ ] Step 3: Commit
git add go.mod go.sum
git commit -m "deps: add modernc.org/sqlite for Pyrogram session import"
Files:
internal/telegram/sessionimport/dcaddr.goTest: internal/telegram/sessionimport/dcaddr_test.go
[ ] Step 1: Write the failing test
Create internal/telegram/sessionimport/dcaddr_test.go:
package sessionimport
import "testing"
func TestTGDCAddr(t *testing.T) {
cases := []struct {
dc int
want string
}{
{1, "149.154.175.53:443"},
{2, "149.154.167.51:443"},
{3, "149.154.175.100:443"},
{4, "149.154.167.91:443"},
{5, "91.108.56.130:443"},
}
for _, tc := range cases {
got, err := TGDCAddr(tc.dc)
if err != nil {
t.Fatalf("dc=%d unexpected err: %v", tc.dc, err)
}
if got != tc.want {
t.Errorf("dc=%d got %q want %q", tc.dc, got, tc.want)
}
}
if _, err := TGDCAddr(99); err == nil {
t.Error("expected err for unknown dc=99")
}
}
Run: cd D:/spider && go test ./internal/telegram/sessionimport/ -run TestTGDCAddr -v
Expected: FAIL — "undefined: TGDCAddr".
Create internal/telegram/sessionimport/dcaddr.go:
// Package sessionimport converts third-party Telegram session formats
// (Pyrogram SQLite, Telegram Desktop tdata) to gotd/td's native session.Data.
package sessionimport
import "fmt"
// TGDCAddr maps a Telegram data-center id to its production IP:port.
// Source: https://core.telegram.org/api/datacenter
func TGDCAddr(dc int) (string, error) {
switch dc {
case 1:
return "149.154.175.53:443", nil
case 2:
return "149.154.167.51:443", nil
case 3:
return "149.154.175.100:443", nil
case 4:
return "149.154.167.91:443", nil
case 5:
return "91.108.56.130:443", nil
}
return "", fmt.Errorf("unsupported telegram dc_id: %d", dc)
}
Run: cd D:/spider && go test ./internal/telegram/sessionimport/ -run TestTGDCAddr -v
Expected: PASS.
[ ] Step 5: Commit
git add internal/telegram/sessionimport/dcaddr.go internal/telegram/sessionimport/dcaddr_test.go
git commit -m "feat(sessionimport): add Telegram DC address mapping"
Files:
internal/telegram/sessionimport/pyrogram.goTest: internal/telegram/sessionimport/pyrogram_test.go
[ ] Step 1: Write the failing test
Create internal/telegram/sessionimport/pyrogram_test.go:
package sessionimport
import (
"os"
"path/filepath"
"testing"
)
// Real protocol-number session file lives at D:\spider\tgs\13252753163\13252753163.session
// Test skips when that file is missing so CI environments don't break.
func TestConvertPyrogramSession_RealFile(t *testing.T) {
path := `D:\spider\tgs\13252753163\13252753163.session`
if _, err := os.Stat(path); err != nil {
t.Skipf("real session file absent: %v", err)
}
data, err := ConvertPyrogramSession(path)
if err != nil {
t.Fatalf("convert failed: %v", err)
}
if data.DC < 1 || data.DC > 5 {
t.Errorf("unexpected dc: %d", data.DC)
}
if len(data.AuthKey) != 256 {
t.Errorf("auth_key length = %d, want 256", len(data.AuthKey))
}
if len(data.AuthKeyID) != 8 {
t.Errorf("auth_key_id length = %d, want 8", len(data.AuthKeyID))
}
if data.Addr == "" {
t.Error("addr empty")
}
}
func TestConvertPyrogramSession_MissingFile(t *testing.T) {
missing := filepath.Join(t.TempDir(), "does-not-exist.session")
if _, err := ConvertPyrogramSession(missing); err == nil {
t.Error("expected error for missing file")
}
}
Run: cd D:/spider && go test ./internal/telegram/sessionimport/ -run TestConvertPyrogramSession -v
Expected: FAIL — "undefined: ConvertPyrogramSession".
Create internal/telegram/sessionimport/pyrogram.go:
package sessionimport
import (
"crypto/sha1"
"database/sql"
"fmt"
"github.com/gotd/td/session"
_ "modernc.org/sqlite"
)
// ConvertPyrogramSession reads a Pyrogram/Telethon SQLite session file and
// builds a gotd/td session.Data representing the same authorization.
//
// Pyrogram schema (single row in `sessions` table):
// CREATE TABLE sessions (
// dc_id INTEGER PRIMARY KEY,
// api_id INTEGER,
// test_mode INTEGER,
// auth_key BLOB, -- 256 bytes
// date INTEGER NOT NULL,
// user_id INTEGER,
// is_bot INTEGER);
func ConvertPyrogramSession(sqlitePath string) (*session.Data, error) {
db, err := sql.Open("sqlite", sqlitePath)
if err != nil {
return nil, fmt.Errorf("open pyrogram sqlite: %w", err)
}
defer db.Close()
var dcID int
var authKey []byte
err = db.QueryRow(`SELECT dc_id, auth_key FROM sessions LIMIT 1`).Scan(&dcID, &authKey)
if err != nil {
return nil, fmt.Errorf("read pyrogram sessions row: %w", err)
}
if len(authKey) != 256 {
return nil, fmt.Errorf("invalid auth_key length: %d (want 256)", len(authKey))
}
addr, err := TGDCAddr(dcID)
if err != nil {
return nil, err
}
// auth_key_id = SHA-1(auth_key)[12:20] — lower 64 bits of the SHA-1 digest.
sum := sha1.Sum(authKey)
keyID := make([]byte, 8)
copy(keyID, sum[12:20])
return &session.Data{
DC: dcID,
Addr: addr,
AuthKey: authKey,
AuthKeyID: keyID,
}, nil
}
Run: cd D:/spider && go test ./internal/telegram/sessionimport/ -run TestConvertPyrogramSession -v
Expected: PASS — both the real-file test and the missing-file test pass. If the real-file test is skipped, that is acceptable; the missing-file test must pass.
[ ] Step 5: Commit
git add internal/telegram/sessionimport/pyrogram.go internal/telegram/sessionimport/pyrogram_test.go
git commit -m "feat(sessionimport): convert Pyrogram SQLite session to gotd session.Data"
Files:
Create: internal/telegram/sessionimport/tdesktop.go
[ ] Step 1: Write implementation
Create internal/telegram/sessionimport/tdesktop.go:
package sessionimport
import (
"fmt"
"github.com/gotd/td/session"
"github.com/gotd/td/session/tdesktop"
)
// ConvertTdataSession reads a Telegram Desktop tdata/ folder and builds a
// gotd session.Data. localPassword is the local-storage password used to decrypt
// the tdata blob; pass an empty string when the desktop client did not set a
// local password (the common case for 协议号).
func ConvertTdataSession(tdataDir, localPassword string) (*session.Data, error) {
acc, err := tdesktop.Read(tdataDir, []byte(localPassword))
if err != nil {
return nil, fmt.Errorf("read tdata: %w", err)
}
data, err := session.TDesktopSession(acc)
if err != nil {
return nil, fmt.Errorf("convert tdesktop account: %w", err)
}
return data, nil
}
Run: cd D:/spider && go build ./internal/telegram/sessionimport/
Expected: build succeeds. No new test file — this thin wrapper is exercised by Task 9's importer tests.
[ ] Step 3: Commit
git add internal/telegram/sessionimport/tdesktop.go
git commit -m "feat(sessionimport): add tdata fallback via gotd tdesktop reader"
Files:
internal/telegram/crypto.goTest: internal/telegram/crypto_test.go
[ ] Step 1: Write the failing test
Create internal/telegram/crypto_test.go:
package telegram
import (
"encoding/base64"
"testing"
)
func mustKey(t *testing.T) string {
t.Helper()
// 32 zero-bytes → stable base64 for test determinism.
return base64.StdEncoding.EncodeToString(make([]byte, 32))
}
func TestCryptoRoundTrip(t *testing.T) {
c, err := NewCrypto(mustKey(t))
if err != nil {
t.Fatal(err)
}
ct, err := c.Encrypt("xing")
if err != nil {
t.Fatal(err)
}
pt, err := c.Decrypt(ct)
if err != nil {
t.Fatal(err)
}
if pt != "xing" {
t.Errorf("roundtrip got %q want %q", pt, "xing")
}
}
func TestCryptoRejectsShortKey(t *testing.T) {
short := base64.StdEncoding.EncodeToString(make([]byte, 16))
if _, err := NewCrypto(short); err == nil {
t.Error("expected err for 16-byte key")
}
}
func TestCryptoRejectsTamperedCiphertext(t *testing.T) {
c, _ := NewCrypto(mustKey(t))
ct, _ := c.Encrypt("secret")
ct[len(ct)-1] ^= 0xFF
if _, err := c.Decrypt(ct); err == nil {
t.Error("expected tamper detection")
}
}
func TestCryptoNonceRandomness(t *testing.T) {
c, _ := NewCrypto(mustKey(t))
a, _ := c.Encrypt("same")
b, _ := c.Encrypt("same")
if string(a) == string(b) {
t.Error("identical ciphertexts imply nonce reuse")
}
}
Run: cd D:/spider && go test ./internal/telegram/ -run TestCrypto -v
Expected: FAIL — "undefined: NewCrypto".
Create internal/telegram/crypto.go:
package telegram
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
)
// Crypto wraps AES-GCM with a fixed 32-byte key and random 12-byte nonces.
// Used for encrypting 2FA passwords at rest.
type Crypto struct {
gcm cipher.AEAD
}
// NewCrypto builds a Crypto from a base64-encoded 32-byte key.
func NewCrypto(b64Key string) (*Crypto, error) {
key, err := base64.StdEncoding.DecodeString(b64Key)
if err != nil {
return nil, fmt.Errorf("decode secret key: %w", err)
}
if len(key) != 32 {
return nil, fmt.Errorf("secret key must be 32 bytes, got %d", len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &Crypto{gcm: gcm}, nil
}
// Encrypt returns [nonce || ciphertext || tag].
func (c *Crypto) Encrypt(plain string) ([]byte, error) {
nonce := make([]byte, c.gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
return c.gcm.Seal(nonce, nonce, []byte(plain), nil), nil
}
// Decrypt expects the [nonce || ciphertext || tag] layout produced by Encrypt.
func (c *Crypto) Decrypt(blob []byte) (string, error) {
ns := c.gcm.NonceSize()
if len(blob) < ns {
return "", errors.New("ciphertext too short")
}
nonce, ct := blob[:ns], blob[ns:]
pt, err := c.gcm.Open(nil, nonce, ct, nil)
if err != nil {
return "", err
}
return string(pt), nil
}
Run: cd D:/spider && go test ./internal/telegram/ -run TestCrypto -v
Expected: PASS on all four test cases.
[ ] Step 5: Commit
git add internal/telegram/crypto.go internal/telegram/crypto_test.go
git commit -m "feat(telegram): AES-GCM crypto helper for 2FA at-rest encryption"
sessions_dir + secret_keyFiles:
internal/config/config.goconfigs/config.yamlCreate: deploy/.env.example
[ ] Step 1: Extend TelegramConfig
In internal/config/config.go, replace the TelegramConfig struct:
type TelegramConfig struct {
AppID int `mapstructure:"app_id"`
AppHash string `mapstructure:"app_hash"`
Accounts []TGAccount
SessionsDir string `mapstructure:"sessions_dir"` // absolute path, e.g. /app/sessions
SecretKey string `mapstructure:"secret_key"` // base64 32-byte, or literal ${TG_SECRET_KEY}
}
${VAR} from environment in LoadViper does not auto-expand environment variables inside YAML. At the end of Load, before global = cfg, add:
// Resolve ${ENV_VAR} placeholders for secret fields.
cfg.Telegram.SecretKey = expandEnvPlaceholder(cfg.Telegram.SecretKey)
cfg.Security.JWTSecret = expandEnvPlaceholder(cfg.Security.JWTSecret)
if cfg.Telegram.SessionsDir == "" {
cfg.Telegram.SessionsDir = "/app/sessions"
}
Add at the bottom of the file:
// expandEnvPlaceholder replaces a value of the form "${VAR}" with os.Getenv("VAR").
// A non-placeholder value is returned unchanged.
func expandEnvPlaceholder(v string) string {
if len(v) > 3 && v[0] == '$' && v[1] == '{' && v[len(v)-1] == '}' {
return os.Getenv(v[2 : len(v)-1])
}
return v
}
Add "os" to the import block.
configs/config.yamlReplace the telegram: block with:
telegram:
app_id: 0
app_hash: ""
accounts: []
sessions_dir: "/app/sessions"
secret_key: "${TG_SECRET_KEY}" # 32-byte base64 loaded from env
deploy/.env.exampleCreate deploy/.env.example:
# 32-byte AES-GCM master key for 2FA encryption. Generate with:
# openssl rand -base64 32
TG_SECRET_KEY=CHANGE_ME_TO_32_RANDOM_BASE64_BYTES_XXXXXXXXXXX=
Run:
cd D:/spider && TG_SECRET_KEY=$(openssl rand -base64 32) go run ./cmd/server 2>&1 | head -5
Expected: server starts (or MySQL connect error if DB not running — that is OK here). The important check is no config-parsing panic.
Stop the server with Ctrl+C.
[ ] Step 6: Commit
git add internal/config/config.go configs/config.yaml deploy/.env.example
git commit -m "feat(config): add telegram.sessions_dir and telegram.secret_key with env expansion"
TgAccount modelFiles:
Modify: internal/model/user.go
[ ] Step 1: Extend the struct
Replace the TgAccount struct in internal/model/user.go with:
// TgAccount represents a Telegram account managed via the admin panel.
type TgAccount struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Phone string `gorm:"uniqueIndex;size:50;not null" json:"phone"`
SessionFile string `gorm:"size:500;not null" json:"session_file"`
AppID int `gorm:"not null" json:"app_id"`
AppHash string `gorm:"size:100;not null" json:"app_hash"`
Remark string `gorm:"size:255" json:"remark"`
Enabled bool `gorm:"default:true" json:"enabled"`
Status string `gorm:"size:20;default:'idle'" json:"status"` // idle / online / cooling
// Protocol-number import additions (all nullable to keep legacy rows valid).
TwoFAEnc []byte `gorm:"type:varbinary(255)" json:"-"` // AES-GCM ciphertext; never emitted in JSON
Device string `gorm:"size:100" json:"device"` // e.g. "Xiaomi Mix 4"
AppVersion string `gorm:"size:50" json:"app_version"` // e.g. "12.0.0 (6163)"
SDK string `gorm:"size:100" json:"sdk"` // e.g. "Android 13 (33)"
LangPack string `gorm:"size:50" json:"lang_pack"` // e.g. "android"
LangCode string `gorm:"size:20" json:"lang_code"` // e.g. "en"
SystemLangCode string `gorm:"size:20" json:"system_lang_code"` // e.g. "en-US"
FirstName string `gorm:"size:100" json:"first_name"`
LastName string `gorm:"size:100" json:"last_name"`
TgUsername string `gorm:"size:100;column:tg_username" json:"tg_username"`
OriginDir string `gorm:"size:500" json:"origin_dir"` // sessions/<phone>.origin/ absolute path
ImportStatus string `gorm:"size:20" json:"import_status"` // ok / session_invalid / dead
Source string `gorm:"size:20" json:"source"` // protocol / manual
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
Note: column name tg_username (not username) to avoid colliding with the User.Username column concept if tables are ever joined; field name TgUsername distinguishes from other Username-ish fields in the codebase.
Run:
cd D:/spider && TG_SECRET_KEY=$(openssl rand -base64 32) go run ./cmd/server 2>&1 | head -20
Expected: MySQL tables migrated log line; no error. If MySQL is not running this step can be skipped — go build ./... alone is enough to confirm the model compiles.
If MySQL is reachable, verify the columns exist:
docker exec -i im_mysql mysql -uroot -proot123 spider -e "DESCRIBE tg_accounts;" 2>&1 | grep -E 'two_fa_enc|device|app_version|sdk|lang_pack|origin_dir|import_status|source'
Expected: all new columns listed.
[ ] Step 3: Commit
git add internal/model/user.go
git commit -m "feat(model): add protocol-number fields to TgAccount (device, 2fa, origin_dir...)"
telegram.Account + Client.Connect device fingerprintFiles:
internal/telegram/types.goModify: internal/telegram/client.go
[ ] Step 1: Extend Account struct
Replace the Account struct in internal/telegram/types.go:
// Account TG 账号信息
type Account struct {
Phone string
SessionFile string
AppID int
AppHash string
Device string // DeviceModel e.g. "Xiaomi Mix 4" — empty = gotd default
AppVersion string // e.g. "12.0.0 (6163)"
SystemVersion string // e.g. "Android 13 (33)" — mapped from DB.sdk
LangPack string // e.g. "android"
LangCode string // e.g. "en"
SystemLangCode string // e.g. "en-US"
}
In internal/telegram/client.go, inside Connect, change the opts := telegram.Options{...} block from:
opts := telegram.Options{
SessionStorage: storage,
NoUpdates: true,
}
to:
opts := telegram.Options{
SessionStorage: storage,
NoUpdates: true,
Device: telegram.DeviceConfig{
DeviceModel: c.account.Device,
AppVersion: c.account.AppVersion,
SystemVersion: c.account.SystemVersion,
LangPack: c.account.LangPack,
SystemLangCode: c.account.SystemLangCode,
LangCode: c.account.LangCode,
},
}
Per gotd docs, empty fields inside DeviceConfig fall back to gotd's default values — so legacy accounts (no device info stored) behave exactly as before.
Run: cd D:/spider && go build ./...
Expected: succeeds.
[ ] Step 4: Commit
git add internal/telegram/types.go internal/telegram/client.go
git commit -m "feat(telegram): thread device fingerprint into Client.Connect InitConnection"
Files:
Create: internal/telegram/sessionimport/importer.go
[ ] Step 1: Write implementation
Create internal/telegram/sessionimport/importer.go:
package sessionimport
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/gotd/td/session"
)
// PyrogramMeta is the parsed content of <phone>.json inside a 协议号 folder.
// Unknown fields are ignored.
type PyrogramMeta struct {
Phone string `json:"phone"`
APIID int `json:"api_id"`
APIHash string `json:"api_hash"`
AppID int `json:"app_id"` // some generators use app_id/app_hash instead
AppHash string `json:"app_hash"`
Device string `json:"device"`
AppVersion string `json:"app_version"`
SDK string `json:"sdk"`
LangPack string `json:"lang_pack"`
LangCode string `json:"lang_code"`
SystemLangCode string `json:"system_lang_code"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
TwoFA string `json:"twoFA"`
}
// Normalize resolves API credential aliasing. 协议号 JSON files use api_id/api_hash
// or app_id/app_hash interchangeably; we coalesce them.
func (m *PyrogramMeta) Normalize() {
if m.APIID == 0 && m.AppID != 0 {
m.APIID = m.AppID
}
if m.APIHash == "" && m.AppHash != "" {
m.APIHash = m.AppHash
}
}
// Result is the outcome of importing one protocol-number folder.
type Result struct {
Meta PyrogramMeta
TwoFAPlaintext string // from 2fa_password.txt if Meta.TwoFA empty
SessionData *session.Data
UsedSource string // "pyrogram" or "tdata"
SessionFile string // sessions/<phone>.json
OriginDir string // sessions/<phone>.origin/
PyrogramErr string // empty on success
TdataErr string // empty on success or if tdata absent
}
// StagedFile is one file extracted from a multipart upload, already written to a
// temp directory. RelPath preserves the browser's webkitRelativePath minus the
// top-level folder name (so e.g. "13252753163/tdata/key_datas" becomes
// "tdata/key_datas").
type StagedFile struct {
RelPath string
AbsPath string
}
// Import parses staged files from a 协议号 folder, converts the session, and
// materializes sessions/<phone>.json + origin/ backup under sessionsDir.
// The caller (HTTP handler) persists the returned metadata to the DB.
//
// Precondition: all StagedFile.AbsPath must exist on disk. stagedFiles is the
// output of the handler's multipart parser.
func Import(ctx context.Context, sessionsDir string, stagedFiles []StagedFile) (*Result, error) {
if len(stagedFiles) == 0 {
return nil, errors.New("no files uploaded")
}
var metaPath, sessionPath, twoFAPath, tdataDir string
for _, f := range stagedFiles {
switch {
case filepath.Ext(f.RelPath) == ".json" && !hasSep(f.RelPath):
metaPath = f.AbsPath
case filepath.Ext(f.RelPath) == ".session":
sessionPath = f.AbsPath
case filepath.Base(f.RelPath) == "2fa_password.txt":
twoFAPath = f.AbsPath
case hasTdataPrefix(f.RelPath):
// Remember the parent dir containing tdata/
dir := filepath.Dir(f.AbsPath)
// Walk up until we find a folder literally named "tdata".
for dir != "" && filepath.Base(dir) != "tdata" {
dir = filepath.Dir(dir)
}
if dir != "" {
tdataDir = dir
}
}
}
if metaPath == "" {
return nil, errors.New("missing <phone>.json in upload")
}
if sessionPath == "" && tdataDir == "" {
return nil, errors.New("upload has neither .session nor tdata/")
}
meta, err := readMeta(metaPath)
if err != nil {
return nil, err
}
meta.Normalize()
if meta.Phone == "" {
return nil, errors.New("<phone>.json missing phone field")
}
if meta.APIID == 0 || meta.APIHash == "" {
return nil, errors.New("<phone>.json missing api_id / api_hash")
}
res := &Result{Meta: meta}
// Prefer 2FA from JSON; fall back to 2fa_password.txt.
if meta.TwoFA == "" && twoFAPath != "" {
b, err := os.ReadFile(twoFAPath)
if err == nil {
res.TwoFAPlaintext = sanitizeTwoFA(string(b))
}
} else {
res.TwoFAPlaintext = meta.TwoFA
}
// Try Pyrogram first.
if sessionPath != "" {
data, err := ConvertPyrogramSession(sessionPath)
if err == nil {
res.SessionData = data
res.UsedSource = "pyrogram"
} else {
res.PyrogramErr = err.Error()
}
}
// Fallback to tdata.
if res.SessionData == nil && tdataDir != "" {
data, err := ConvertTdataSession(tdataDir, res.TwoFAPlaintext)
if err == nil {
res.SessionData = data
res.UsedSource = "tdata"
} else {
res.TdataErr = err.Error()
}
}
if res.SessionData == nil {
return res, fmt.Errorf("session conversion failed (pyrogram: %s; tdata: %s)",
nonempty(res.PyrogramErr, "n/a"), nonempty(res.TdataErr, "n/a"))
}
// Materialize session JSON.
if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
return res, fmt.Errorf("create sessions dir: %w", err)
}
res.SessionFile = filepath.Join(sessionsDir, meta.Phone+".json")
storage := &session.FileStorage{Path: res.SessionFile}
loader := session.Loader{Storage: storage}
if err := loader.Save(ctx, res.SessionData); err != nil {
return res, fmt.Errorf("save gotd session: %w", err)
}
// Materialize origin backup (best-effort; non-fatal).
res.OriginDir = filepath.Join(sessionsDir, meta.Phone+".origin")
_ = os.RemoveAll(res.OriginDir)
if err := os.MkdirAll(res.OriginDir, 0o755); err == nil {
for _, f := range stagedFiles {
dst := filepath.Join(res.OriginDir, f.RelPath)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil {
_ = copyFile(f.AbsPath, dst)
}
}
}
return res, nil
}
func readMeta(path string) (PyrogramMeta, error) {
var m PyrogramMeta
b, err := os.ReadFile(path)
if err != nil {
return m, fmt.Errorf("read meta json: %w", err)
}
if err := json.Unmarshal(b, &m); err != nil {
return m, fmt.Errorf("parse meta json: %w", err)
}
return m, nil
}
func sanitizeTwoFA(s string) string {
// Trim CR/LF/space — 2fa_password.txt often has a trailing newline.
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r' || s[len(s)-1] == ' ') {
s = s[:len(s)-1]
}
return s
}
func hasSep(p string) bool {
for i := 0; i < len(p); i++ {
if p[i] == '/' || p[i] == '\\' {
return true
}
}
return false
}
func hasTdataPrefix(p string) bool {
// "tdata/..." or "tdata\..."
if len(p) < 6 {
return false
}
return (p[:5] == "tdata" && (p[5] == '/' || p[5] == '\\'))
}
func nonempty(s, alt string) string {
if s == "" {
return alt
}
return s
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
Run: cd D:/spider && go build ./internal/telegram/sessionimport/
Expected: succeeds.
[ ] Step 3: Commit
git add internal/telegram/sessionimport/importer.go
git commit -m "feat(sessionimport): importer orchestration (pyrogram→tdata fallback + backup)"
Files:
Modify: internal/handler/tg_account.go
[ ] Step 1: Add dependencies & fields
In internal/handler/tg_account.go, replace the existing imports + struct with:
package handler
import (
"context"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"spider/internal/model"
"spider/internal/store"
"spider/internal/telegram"
"spider/internal/telegram/sessionimport"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// TgAccountHandler handles Telegram account management.
type TgAccountHandler struct {
store *store.Store
tgManager *telegram.AccountManager
crypto *telegram.Crypto
sessionsDir string
tmpDir string // <sessionsDir>/.tmp — staging area for multipart uploads
}
github.com/google/uuid is already an indirect dependency (from gotd/td). If go build later complains, promote it with go get github.com/google/uuid.
Append this constructor to internal/handler/tg_account.go:
// NewTgAccountHandler builds the handler with dependencies injected from main.
func NewTgAccountHandler(s *store.Store, tgMgr *telegram.AccountManager, crypto *telegram.Crypto, sessionsDir string) *TgAccountHandler {
tmp := filepath.Join(sessionsDir, ".tmp")
_ = os.MkdirAll(tmp, 0o755)
h := &TgAccountHandler{
store: s,
tgManager: tgMgr,
crypto: crypto,
sessionsDir: sessionsDir,
tmpDir: tmp,
}
go h.tmpCleanupLoop()
return h
}
// tmpCleanupLoop deletes tmp subdirs older than 30 minutes every 10 minutes.
func (h *TgAccountHandler) tmpCleanupLoop() {
t := time.NewTicker(10 * time.Minute)
defer t.Stop()
for range t.C {
entries, err := os.ReadDir(h.tmpDir)
if err != nil {
continue
}
cutoff := time.Now().Add(-30 * time.Minute)
for _, e := range entries {
if !e.IsDir() {
continue
}
info, err := e.Info()
if err != nil || info.ModTime().After(cutoff) {
continue
}
_ = os.RemoveAll(filepath.Join(h.tmpDir, e.Name()))
}
}
}
Append to internal/handler/tg_account.go:
// Import handles POST /tg-accounts/import — admin uploads a 协议号 folder.
func (h *TgAccountHandler) Import(c *gin.Context) {
form, err := c.MultipartForm()
if err != nil {
Fail(c, 400, "invalid multipart form: "+err.Error())
return
}
files := form.File["files"]
if len(files) == 0 {
Fail(c, 400, "no files uploaded (expected field name: files)")
return
}
// Stage uploaded files to tmp/<uuid>/.
stageID := uuid.NewString()
stageDir := filepath.Join(h.tmpDir, stageID)
if err := os.MkdirAll(stageDir, 0o755); err != nil {
Fail(c, 500, "create stage dir: "+err.Error())
return
}
// Top-level folder name in webkitRelativePath (e.g. "13252753163"). Used to
// strip the prefix so RelPath inside the Result is relative to the folder.
topPrefix := detectTopPrefix(files)
staged := make([]sessionimport.StagedFile, 0, len(files))
for _, fh := range files {
// Gin stores the relative path in fh.Filename (browsers send it as the
// form part filename when webkitdirectory is used).
rel := normalizeRel(fh.Filename, topPrefix)
if rel == "" {
continue
}
abs := filepath.Join(stageDir, rel)
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
Fail(c, 500, "mkdir stage: "+err.Error())
return
}
if err := saveUpload(fh, abs); err != nil {
Fail(c, 500, "save upload: "+err.Error())
return
}
staged = append(staged, sessionimport.StagedFile{RelPath: rel, AbsPath: abs})
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
defer cancel()
res, err := sessionimport.Import(ctx, h.sessionsDir, staged)
if err != nil {
Fail(c, 422, err.Error())
return
}
// Duplicate check.
var count int64
h.store.DB.Model(&model.TgAccount{}).Where("phone = ?", res.Meta.Phone).Count(&count)
if count > 0 {
Fail(c, 409, "该手机号已存在")
return
}
// Encrypt 2FA if present.
var twoFAEnc []byte
if res.TwoFAPlaintext != "" && h.crypto != nil {
twoFAEnc, err = h.crypto.Encrypt(res.TwoFAPlaintext)
if err != nil {
Fail(c, 500, "encrypt 2fa: "+err.Error())
return
}
}
acc := model.TgAccount{
Phone: res.Meta.Phone,
SessionFile: res.SessionFile,
AppID: res.Meta.APIID,
AppHash: res.Meta.APIHash,
Enabled: true,
Status: "idle",
TwoFAEnc: twoFAEnc,
Device: res.Meta.Device,
AppVersion: res.Meta.AppVersion,
SDK: res.Meta.SDK,
LangPack: res.Meta.LangPack,
LangCode: res.Meta.LangCode,
SystemLangCode: res.Meta.SystemLangCode,
FirstName: res.Meta.FirstName,
LastName: res.Meta.LastName,
TgUsername: res.Meta.Username,
OriginDir: res.OriginDir,
ImportStatus: "ok",
Source: "protocol",
}
if err := h.store.DB.Create(&acc).Error; err != nil {
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
Fail(c, 409, "该手机号已存在")
return
}
Fail(c, 500, "db create: "+err.Error())
return
}
// Rebuild account manager so new account is Acquireable.
h.reloadAccounts()
// Auto-test.
testResult := h.runTest(c.Request.Context(), &acc)
if testResult["status"] == "fail" {
h.store.DB.Model(&acc).Updates(map[string]any{
"enabled": false,
"import_status": "dead",
})
}
LogAudit(h.store, c, "import", "tg_account", fmt.Sprintf("%d", acc.ID), gin.H{
"phone": acc.Phone,
"source": res.UsedSource,
})
// Reload original so JSON response reflects updates.
h.store.DB.First(&acc, acc.ID)
// Cleanup stage dir on success.
_ = os.RemoveAll(stageDir)
OK(c, gin.H{"account": acc, "test_result": testResult})
}
// runTest calls Client.Connect + waits for ready — no GetMe, kept minimal.
func (h *TgAccountHandler) runTest(ctx context.Context, acc *model.TgAccount) gin.H {
client := telegram.New(telegram.Account{
Phone: acc.Phone,
SessionFile: acc.SessionFile,
AppID: acc.AppID,
AppHash: acc.AppHash,
Device: acc.Device,
AppVersion: acc.AppVersion,
SystemVersion: acc.SDK,
LangPack: acc.LangPack,
LangCode: acc.LangCode,
SystemLangCode: acc.SystemLangCode,
})
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := client.Connect(tctx); err != nil {
if fwe, ok := err.(*telegram.FloodWaitError); ok {
return gin.H{"status": "cooling", "message": fmt.Sprintf("FloodWait: 需等待 %d 秒", fwe.Seconds)}
}
return gin.H{"status": "fail", "error": err.Error()}
}
client.Disconnect()
return gin.H{"status": "ok", "message": "连接成功"}
}
func detectTopPrefix(files []*multipart.FileHeader) string {
if len(files) == 0 {
return ""
}
first := files[0].Filename
idx := strings.IndexAny(first, "/\\")
if idx <= 0 {
return ""
}
return first[:idx]
}
func normalizeRel(filename, topPrefix string) string {
// Convert backslashes to forward slashes for consistent path handling.
p := strings.ReplaceAll(filename, "\\", "/")
// Strip the top-level directory name so e.g. "13252753163/tdata/key_datas"
// becomes "tdata/key_datas".
if topPrefix != "" && strings.HasPrefix(p, topPrefix+"/") {
p = strings.TrimPrefix(p, topPrefix+"/")
}
// Reject any path that tries to escape.
if strings.Contains(p, "../") || strings.HasPrefix(p, "/") {
return ""
}
return p
}
func saveUpload(fh *multipart.FileHeader, dst string) error {
src, err := fh.Open()
if err != nil {
return err
}
defer src.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, src)
return err
}
reloadAccounts to populate new fieldsReplace the existing reloadAccounts method body in internal/handler/tg_account.go with:
// reloadAccounts loads enabled TG accounts from DB and reinitializes the account manager.
func (h *TgAccountHandler) reloadAccounts() {
var dbAccounts []model.TgAccount
h.store.DB.Where("enabled = ?", true).Find(&dbAccounts)
accounts := make([]telegram.Account, 0, len(dbAccounts))
for _, a := range dbAccounts {
accounts = append(accounts, telegram.Account{
Phone: a.Phone,
SessionFile: a.SessionFile,
AppID: a.AppID,
AppHash: a.AppHash,
Device: a.Device,
AppVersion: a.AppVersion,
SystemVersion: a.SDK,
LangPack: a.LangPack,
LangCode: a.LangCode,
SystemLangCode: a.SystemLangCode,
})
}
h.tgManager.Init(accounts)
}
Run: cd D:/spider && go build ./...
Expected: succeeds. If uuid import fails, run go get github.com/google/uuid then retry.
[ ] Step 6: Commit
git add internal/handler/tg_account.go
git commit -m "feat(handler): TG protocol-number import endpoint with device/2FA fields"
Files:
Modify: internal/handler/tg_account.go
[ ] Step 1: Append RevealTwoFA method
Append to internal/handler/tg_account.go:
// RevealTwoFA returns the decrypted 2FA password for an account.
// Admin-only; every call is audit-logged.
func (h *TgAccountHandler) RevealTwoFA(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
Fail(c, 400, "invalid id")
return
}
var acc model.TgAccount
if err := h.store.DB.First(&acc, id).Error; err != nil {
Fail(c, 404, "账号不存在")
return
}
if len(acc.TwoFAEnc) == 0 {
Fail(c, 404, "该账号未存储 2FA")
return
}
if h.crypto == nil {
Fail(c, 500, "crypto not configured")
return
}
plain, err := h.crypto.Decrypt(acc.TwoFAEnc)
if err != nil {
Fail(c, 500, "decrypt: "+err.Error())
return
}
LogAudit(h.store, c, "reveal_2fa", "tg_account", fmt.Sprintf("%d", id), nil)
OK(c, gin.H{"password": plain})
}
Run: cd D:/spider && go build ./...
Expected: succeeds.
[ ] Step 3: Commit
git add internal/handler/tg_account.go
git commit -m "feat(handler): POST /tg-accounts/:id/reveal-2fa with audit logging"
Files:
cmd/server/main.goModify: internal/handler/router.go
[ ] Step 1: Update SetupRouter signature
In internal/handler/router.go, change the signature of SetupRouter to accept the handler instance rather than building it inline. Replace the existing line:
func SetupRouter(s *store.Store, taskMgr *task.Manager, rdb *redis.Client, tgMgr *telegram.AccountManager) *gin.Engine {
with:
func SetupRouter(s *store.Store, taskMgr *task.Manager, rdb *redis.Client, tgMgr *telegram.AccountManager, tgAccountHandler *TgAccountHandler) *gin.Engine {
Then inside SetupRouter, replace the block:
// TG account management
ta := &TgAccountHandler{store: s, tgManager: tgMgr}
protected.GET("/tg-accounts", ta.List) // all logged-in users can view
adminOnly.POST("/tg-accounts", ta.Create)
adminOnly.PUT("/tg-accounts/:id", ta.Update)
adminOnly.POST("/tg-accounts/:id/test", ta.Test)
adminOnly.DELETE("/tg-accounts/:id", ta.Delete)
with:
// TG account management
ta := tgAccountHandler
protected.GET("/tg-accounts", ta.List) // all logged-in users can view
adminOnly.POST("/tg-accounts", ta.Create)
adminOnly.POST("/tg-accounts/import", ta.Import)
adminOnly.PUT("/tg-accounts/:id", ta.Update)
adminOnly.POST("/tg-accounts/:id/test", ta.Test)
adminOnly.POST("/tg-accounts/:id/reveal-2fa", ta.RevealTwoFA)
adminOnly.DELETE("/tg-accounts/:id", ta.Delete)
In cmd/server/main.go, after step 8 ("Initialize external clients") and before step 9 ("Load TG accounts from DB"), insert:
// 8b. Construct TG crypto helper (required, fails fast on missing/invalid key).
tgCrypto, err := telegram.NewCrypto(cfg.Telegram.SecretKey)
if err != nil {
log.Fatalf("TG_SECRET_KEY invalid: %v — set a 32-byte base64 value in env", err)
}
sessionsDir := cfg.Telegram.SessionsDir
if sessionsDir == "" {
sessionsDir = "/app/sessions"
}
if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
log.Fatalf("create sessions dir %s: %v", sessionsDir, err)
}
Then replace the existing line:
r := handler.SetupRouter(s, taskMgr, rdb, tgManager)
with:
tgAccountHandler := handler.NewTgAccountHandler(s, tgManager, tgCrypto, sessionsDir)
r := handler.SetupRouter(s, taskMgr, rdb, tgManager, tgAccountHandler)
Run: cd D:/spider && go build ./...
Expected: succeeds.
Run without the env var set:
cd D:/spider && unset TG_SECRET_KEY && go run ./cmd/server 2>&1 | head -3
Expected: process exits with log line TG_SECRET_KEY invalid: secret key must be 32 bytes....
Run with a valid key:
cd D:/spider && TG_SECRET_KEY=$(openssl rand -base64 32) go run ./cmd/server 2>&1 | head -10
Expected: MySQL tables migrated + Server starting on :8080 (or a DB/Redis connection error, which is fine here — we only need to confirm config + route wiring didn't crash). Stop with Ctrl+C.
[ ] Step 4: Commit
git add cmd/server/main.go internal/handler/router.go
git commit -m "feat: wire TG crypto + import/reveal-2fa routes into server boot"
Files:
Modify: web/src/pages/TgAccounts.tsx
[ ] Step 1: Replace the entire file
Overwrite web/src/pages/TgAccounts.tsx with:
import { useEffect, useState, useCallback, useRef } from 'react'
import { Table, Tag, Button, Modal, Form, Input, InputNumber, Switch, Space, message, Popconfirm, Tooltip } from 'antd'
import { PlusOutlined, DeleteOutlined, CheckCircleOutlined, CloudUploadOutlined, EyeOutlined } from '@ant-design/icons'
import api from '../api/client'
interface TgAccount {
id: number
phone: string
session_file: string
app_id: number
app_hash: string
remark: string
enabled: boolean
status: string
device?: string
app_version?: string
sdk?: string
import_status?: string
source?: string
first_name?: string
last_name?: string
tg_username?: string
created_at: string
}
const statusColors: Record<string, string> = {
idle: 'default',
online: 'green',
cooling: 'orange',
dead: 'red',
}
const sourceColors: Record<string, string> = {
protocol: 'purple',
manual: 'default',
}
const importStatusColors: Record<string, string> = {
ok: 'green',
session_invalid: 'red',
dead: 'orange',
}
export default function TgAccounts() {
const [accounts, setAccounts] = useState<TgAccount[]>([])
const [loading, setLoading] = useState(false)
const [manualOpen, setManualOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const [importing, setImporting] = useState(false)
const [form] = Form.useForm()
const dirInputRef = useRef<HTMLInputElement | null>(null)
const fetchAccounts = useCallback(async () => {
setLoading(true)
try {
const res = await api.get('/tg-accounts')
setAccounts(res.data || [])
} catch { message.error('获取TG账号列表失败') }
finally { setLoading(false) }
}, [])
useEffect(() => { fetchAccounts() }, [fetchAccounts])
const handleManualCreate = async () => {
try {
const values = await form.validateFields()
await api.post('/tg-accounts', values)
message.success('TG账号已添加')
setManualOpen(false)
fetchAccounts()
} catch (err: any) {
if (err?.errorFields) return
message.error(err?.response?.data?.message || '添加失败')
}
}
const handleImport = async () => {
const files = dirInputRef.current?.files
if (!files || files.length === 0) {
message.warning('请先选择协议号文件夹')
return
}
const fd = new FormData()
for (let i = 0; i < files.length; i++) {
const f = files[i]
// The third arg preserves webkitRelativePath as the multipart filename.
fd.append('files', f, (f as any).webkitRelativePath || f.name)
}
setImporting(true)
try {
const res = await api.post('/tg-accounts/import', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const tr = res.data?.test_result
if (tr?.status === 'ok') message.success('导入成功并测试通过')
else if (tr?.status === 'cooling') message.warning(`导入成功,但账号冷却中:${tr.message}`)
else message.error(`导入成功但连接失败:${tr?.error || '未知错误'}(账号已禁用)`)
setImportOpen(false)
fetchAccounts()
} catch (err: any) {
message.error(err?.response?.data?.message || '导入失败')
} finally {
setImporting(false)
}
}
const handleToggle = async (acc: TgAccount, enabled: boolean) => {
await api.put(`/tg-accounts/${acc.id}`, { enabled })
message.success('状态已更新')
fetchAccounts()
}
const [testingId, setTestingId] = useState<number | null>(null)
const handleTest = async (id: number) => {
setTestingId(id)
try {
const res = await api.post(`/tg-accounts/${id}/test`)
const data = res.data as { status: string; message?: string; error?: string }
if (data.status === 'ok') message.success(data.message || '连接成功')
else if (data.status === 'cooling') message.warning(data.message || 'FloodWait 冷却中')
else message.error(`连接失败: ${data.error || '未知错误'}`)
} catch {
message.error('测试请求失败')
}
setTestingId(null)
}
const handleReveal2FA = async (id: number) => {
try {
const res = await api.post(`/tg-accounts/${id}/reveal-2fa`)
Modal.info({
title: '2FA 密码',
content: res.data?.password ?? '(空)',
})
} catch (err: any) {
message.error(err?.response?.data?.message || '无法获取 2FA')
}
}
const handleDelete = async (id: number) => {
await api.delete(`/tg-accounts/${id}`)
message.success('已删除')
fetchAccounts()
}
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '手机号', dataIndex: 'phone' },
{
title: '来源',
dataIndex: 'source',
width: 80,
render: (v: string) => v ? <Tag color={sourceColors[v] ?? 'default'}>{v === 'protocol' ? '协议号' : '手填'}</Tag> : '-',
},
{
title: '设备',
dataIndex: 'device',
width: 160,
ellipsis: true,
render: (v: string) => v ? <Tooltip title={v}>{v}</Tooltip> : '-',
},
{
title: '状态',
dataIndex: 'status',
render: (v: string) => <Tag color={statusColors[v] ?? 'default'}>{v}</Tag>,
},
{
title: '导入状态',
dataIndex: 'import_status',
render: (v: string) => v ? <Tag color={importStatusColors[v] ?? 'default'}>{v}</Tag> : '-',
},
{ title: '备注', dataIndex: 'remark', render: (v: string) => v || '-' },
{
title: '启用',
dataIndex: 'enabled',
render: (v: boolean, record: TgAccount) => (
<Switch checked={v} onChange={(c) => handleToggle(record, c)} checkedChildren="启用" unCheckedChildren="禁用" />
),
},
{
title: '操作',
key: 'action',
width: 260,
render: (_: unknown, record: TgAccount) => (
<Space>
<Button
size="small"
icon={<CheckCircleOutlined />}
loading={testingId === record.id}
onClick={() => handleTest(record.id)}
>
测试
</Button>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleReveal2FA(record.id)}>2FA</Button>
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
]
return (
<div>
<Space style={{ marginBottom: 16 }}>
<Button
type="primary"
icon={<CloudUploadOutlined />}
onClick={() => setImportOpen(true)}
>
导入协议号
</Button>
<Button
icon={<PlusOutlined />}
onClick={() => { form.resetFields(); setManualOpen(true) }}
>
手填添加
</Button>
</Space>
<Table dataSource={accounts} columns={columns} rowKey="id" loading={loading} pagination={false} />
<Modal
title="导入协议号"
open={importOpen}
onOk={handleImport}
confirmLoading={importing}
onCancel={() => setImportOpen(false)}
okText="导入"
width={500}
>
<div style={{ marginTop: 16 }}>
<p>选择一个协议号目录(如 <code>D:\spider\tgs\13252753163\</code>),目录下应包含 <code><phone>.json</code>、<code><phone>.session</code>、<code>2fa_password.txt</code> 和(可选) <code>tdata/</code>。</p>
<input
ref={dirInputRef}
type="file"
multiple
/* @ts-expect-error non-standard HTML5 folder picker */
webkitdirectory=""
directory=""
/>
</div>
</Modal>
<Modal
title="手填 TG 账号"
open={manualOpen}
onOk={handleManualCreate}
onCancel={() => setManualOpen(false)}
okText="添加"
width={500}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
<Input placeholder="+86xxxxxxxxx" />
</Form.Item>
<Form.Item name="session_file" label="Session文件路径" rules={[{ required: true }]}>
<Input placeholder="sessions/+86xxxxxxxxx.session" />
</Form.Item>
<Form.Item name="app_id" label="App ID" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} placeholder="从 my.telegram.org 获取" />
</Form.Item>
<Form.Item name="app_hash" label="App Hash" rules={[{ required: true }]}>
<Input placeholder="从 my.telegram.org 获取" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input placeholder="可选" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
Run:
cd D:/spider/web && npm run build 2>&1 | tail -10
Expected: build succeeds (no TS errors).
[ ] Step 3: Commit
git add web/src/pages/TgAccounts.tsx
git commit -m "feat(web): TG protocol-number import UI with webkitdirectory upload"
Files: none (manual verification)
Run:
cd D:/spider/deploy && docker compose build api web && docker compose up -d
Expected: both containers start; docker compose ps shows api healthy after ~30s.
TG_SECRET_KEY is set in the API containerEdit deploy/docker-compose.yml → api.environment (if not already set), add:
- TG_SECRET_KEY=${TG_SECRET_KEY}
Create deploy/.env (NOT committed) with a real key:
cd D:/spider/deploy && echo "TG_SECRET_KEY=$(openssl rand -base64 32)" > .env
docker compose up -d --force-recreate api
Expected: api healthy; no TG_SECRET_KEY invalid log.
http://localhost:8300 in a browser, log in as admin.D:\spider\tgs\13252753163\.Expected: toast "导入成功并测试通过" (or "冷却中" if the account is flood-waited — still counts as import success). New row appears with 来源=协议号, 设备=Xiaomi Mix 4, 状态=idle|online, 导入状态=ok.
Run on host:
ls D:/spider/sessions/
Expected: 13252753163.json + 13252753163.origin/ (containing copies of the uploaded files).
In the UI, go to Groups page → pick an existing group → click "克隆成员". The clone should succeed (or return a meaningful TG error like USERNAME_NOT_OCCUPIED — anything other than no TG accounts configured proves the account was acquired).
Re-import the same folder. Expected: toast 该手机号已存在 (HTTP 409).
In the TG 账号 row, click 2FA. Modal opens showing xing (or whatever the folder's 2fa_password.txt contained).
If any small follow-up fixes were needed in prior tasks, commit them here. Otherwise this task has no commit.
Spec coverage check:
tmpCleanupLoop)Type consistency:
TgAccount.SDK (DB field) → Account.SystemVersion (runtime) — mapping performed identically in Task 10's reloadAccounts and Task 10's runTestsession.Data fields used in Task 3 match what Task 9 writes via session.Loader{}.Save