# TG Protocol Number Import — Implementation Plan > **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 (`.json` + `.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/.json`. Fallback to `tdata/` via `gotd/td/session/tdesktop` if Pyrogram fails. Metadata (api_id, api_hash, device, 2FA) parsed from `.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` --- ## File Structure **New files:** - `internal/telegram/crypto.go` — AES-GCM encrypt/decrypt helper - `internal/telegram/crypto_test.go` - `internal/telegram/sessionimport/dcaddr.go` — `dc_id` → IP:port mapping - `internal/telegram/sessionimport/dcaddr_test.go` - `internal/telegram/sessionimport/pyrogram.go` — Pyrogram SQLite → `session.Data` - `internal/telegram/sessionimport/pyrogram_test.go` - `internal/telegram/sessionimport/tdesktop.go` — tdata fallback - `internal/telegram/sessionimport/importer.go` — orchestration, parses `.json`, writes session + backup - `deploy/.env.example` — `TG_SECRET_KEY` template **Modified files:** - `go.mod` / `go.sum` — add `modernc.org/sqlite` - `internal/model/user.go` — `TgAccount` new columns - `internal/telegram/types.go` — `Account` struct device fields - `internal/telegram/client.go` — `Connect` passes `telegram.DeviceConfig` - `internal/config/config.go` — `TelegramConfig.SessionsDir` + `SecretKey` - `configs/config.yaml` — two new keys - `internal/handler/tg_account.go` — `Import`, `RevealTwoFA`, `reloadAccounts` enriched - `internal/handler/router.go` — register new routes - `cmd/server/main.go` — construct `*telegram.Crypto`, ensure `sessions_dir` exists, inject into handler - `web/src/pages/TgAccounts.tsx` — Import button, upload modal, new columns **Not modified (already good):** - `deploy/docker-compose.yml` — `sessions/` volume already mounted at `/app/sessions` - `internal/telegram/account_manager.go` — no changes needed (new fields flow through `Account`) --- ## Task 1: Add `modernc.org/sqlite` dependency **Files:** - Modify: `go.mod`, `go.sum` (auto) - [ ] **Step 1: Add dependency** Run: ```bash 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. - [ ] **Step 2: Verify no CGO requirement** Run: ```bash 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** ```bash git add go.mod go.sum git commit -m "deps: add modernc.org/sqlite for Pyrogram session import" ``` --- ## Task 2: DC address mapping **Files:** - Create: `internal/telegram/sessionimport/dcaddr.go` - Test: `internal/telegram/sessionimport/dcaddr_test.go` - [ ] **Step 1: Write the failing test** Create `internal/telegram/sessionimport/dcaddr_test.go`: ```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") } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd D:/spider && go test ./internal/telegram/sessionimport/ -run TestTGDCAddr -v` Expected: FAIL — "undefined: TGDCAddr". - [ ] **Step 3: Write minimal implementation** Create `internal/telegram/sessionimport/dcaddr.go`: ```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) } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd D:/spider && go test ./internal/telegram/sessionimport/ -run TestTGDCAddr -v` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add internal/telegram/sessionimport/dcaddr.go internal/telegram/sessionimport/dcaddr_test.go git commit -m "feat(sessionimport): add Telegram DC address mapping" ``` --- ## Task 3: Pyrogram SQLite → gotd session **Files:** - Create: `internal/telegram/sessionimport/pyrogram.go` - Test: `internal/telegram/sessionimport/pyrogram_test.go` - [ ] **Step 1: Write the failing test** Create `internal/telegram/sessionimport/pyrogram_test.go`: ```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") } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd D:/spider && go test ./internal/telegram/sessionimport/ -run TestConvertPyrogramSession -v` Expected: FAIL — "undefined: ConvertPyrogramSession". - [ ] **Step 3: Write minimal implementation** Create `internal/telegram/sessionimport/pyrogram.go`: ```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 } ``` - [ ] **Step 4: Run test to verify it passes** 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** ```bash 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" ``` --- ## Task 4: tdata fallback converter **Files:** - Create: `internal/telegram/sessionimport/tdesktop.go` - [ ] **Step 1: Write implementation** Create `internal/telegram/sessionimport/tdesktop.go`: ```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 } ``` - [ ] **Step 2: Verify build** 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** ```bash git add internal/telegram/sessionimport/tdesktop.go git commit -m "feat(sessionimport): add tdata fallback via gotd tdesktop reader" ``` --- ## Task 5: AES-GCM Crypto helper for 2FA **Files:** - Create: `internal/telegram/crypto.go` - Test: `internal/telegram/crypto_test.go` - [ ] **Step 1: Write the failing test** Create `internal/telegram/crypto_test.go`: ```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") } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd D:/spider && go test ./internal/telegram/ -run TestCrypto -v` Expected: FAIL — "undefined: NewCrypto". - [ ] **Step 3: Write implementation** Create `internal/telegram/crypto.go`: ```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 } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cd D:/spider && go test ./internal/telegram/ -run TestCrypto -v` Expected: PASS on all four test cases. - [ ] **Step 5: Commit** ```bash 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" ``` --- ## Task 6: Config — `sessions_dir` + `secret_key` **Files:** - Modify: `internal/config/config.go` - Modify: `configs/config.yaml` - Create: `deploy/.env.example` - [ ] **Step 1: Extend `TelegramConfig`** In `internal/config/config.go`, replace the `TelegramConfig` struct: ```go 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} } ``` - [ ] **Step 2: Expand `${VAR}` from environment in `Load`** Viper does not auto-expand environment variables inside YAML. At the end of `Load`, before `global = cfg`, add: ```go // 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: ```go // 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. - [ ] **Step 3: Update `configs/config.yaml`** Replace the `telegram:` block with: ```yaml telegram: app_id: 0 app_hash: "" accounts: [] sessions_dir: "/app/sessions" secret_key: "${TG_SECRET_KEY}" # 32-byte base64 loaded from env ``` - [ ] **Step 4: Create `deploy/.env.example`** Create `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= ``` - [ ] **Step 5: Verify config loads** Run: ```bash 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** ```bash 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" ``` --- ## Task 7: Extend `TgAccount` model **Files:** - Modify: `internal/model/user.go` - [ ] **Step 1: Extend the struct** Replace the `TgAccount` struct in `internal/model/user.go` with: ```go // 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/.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. - [ ] **Step 2: Verify auto-migrate adds columns** Run: ```bash 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: ```bash 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** ```bash git add internal/model/user.go git commit -m "feat(model): add protocol-number fields to TgAccount (device, 2fa, origin_dir...)" ``` --- ## Task 8: Extend `telegram.Account` + `Client.Connect` device fingerprint **Files:** - Modify: `internal/telegram/types.go` - Modify: `internal/telegram/client.go` - [ ] **Step 1: Extend `Account` struct** Replace the `Account` struct in `internal/telegram/types.go`: ```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" } ``` - [ ] **Step 2: Pass device config into gotd** In `internal/telegram/client.go`, inside `Connect`, change the `opts := telegram.Options{...}` block from: ```go opts := telegram.Options{ SessionStorage: storage, NoUpdates: true, } ``` to: ```go 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. - [ ] **Step 3: Verify build** Run: `cd D:/spider && go build ./...` Expected: succeeds. - [ ] **Step 4: Commit** ```bash git add internal/telegram/types.go internal/telegram/client.go git commit -m "feat(telegram): thread device fingerprint into Client.Connect InitConnection" ``` --- ## Task 9: Importer orchestration **Files:** - Create: `internal/telegram/sessionimport/importer.go` - [ ] **Step 1: Write implementation** Create `internal/telegram/sessionimport/importer.go`: ```go package sessionimport import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "github.com/gotd/td/session" ) // PyrogramMeta is the parsed content of .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/.json OriginDir string // sessions/.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/.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 .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(".json missing phone field") } if meta.APIID == 0 || meta.APIHash == "" { return nil, errors.New(".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 } ``` - [ ] **Step 2: Verify build** Run: `cd D:/spider && go build ./internal/telegram/sessionimport/` Expected: succeeds. - [ ] **Step 3: Commit** ```bash git add internal/telegram/sessionimport/importer.go git commit -m "feat(sessionimport): importer orchestration (pyrogram→tdata fallback + backup)" ``` --- ## Task 10: Import HTTP handler **Files:** - Modify: `internal/handler/tg_account.go` - [ ] **Step 1: Add dependencies & fields** In `internal/handler/tg_account.go`, replace the existing imports + struct with: ```go 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 // /.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`. - [ ] **Step 2: Add a constructor** Append this constructor to `internal/handler/tg_account.go`: ```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())) } } } ``` - [ ] **Step 3: Add the Import method** Append to `internal/handler/tg_account.go`: ```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//. 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 } ``` - [ ] **Step 4: Update `reloadAccounts` to populate new fields** Replace the existing `reloadAccounts` method body in `internal/handler/tg_account.go` with: ```go // 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) } ``` - [ ] **Step 5: Verify build** Run: `cd D:/spider && go build ./...` Expected: succeeds. If `uuid` import fails, run `go get github.com/google/uuid` then retry. - [ ] **Step 6: Commit** ```bash git add internal/handler/tg_account.go git commit -m "feat(handler): TG protocol-number import endpoint with device/2FA fields" ``` --- ## Task 11: RevealTwoFA handler **Files:** - Modify: `internal/handler/tg_account.go` - [ ] **Step 1: Append RevealTwoFA method** Append to `internal/handler/tg_account.go`: ```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}) } ``` - [ ] **Step 2: Verify build** Run: `cd D:/spider && go build ./...` Expected: succeeds. - [ ] **Step 3: Commit** ```bash git add internal/handler/tg_account.go git commit -m "feat(handler): POST /tg-accounts/:id/reveal-2fa with audit logging" ``` --- ## Task 12: Wire Crypto + routes + main.go **Files:** - Modify: `cmd/server/main.go` - Modify: `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: ```go func SetupRouter(s *store.Store, taskMgr *task.Manager, rdb *redis.Client, tgMgr *telegram.AccountManager) *gin.Engine { ``` with: ```go func SetupRouter(s *store.Store, taskMgr *task.Manager, rdb *redis.Client, tgMgr *telegram.AccountManager, tgAccountHandler *TgAccountHandler) *gin.Engine { ``` Then inside `SetupRouter`, replace the block: ```go // 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: ```go // 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) ``` - [ ] **Step 2: Update main.go to construct and pass the handler** In `cmd/server/main.go`, after step 8 ("Initialize external clients") and before step 9 ("Load TG accounts from DB"), insert: ```go // 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: ```go r := handler.SetupRouter(s, taskMgr, rdb, tgManager) ``` with: ```go tgAccountHandler := handler.NewTgAccountHandler(s, tgManager, tgCrypto, sessionsDir) r := handler.SetupRouter(s, taskMgr, rdb, tgManager, tgAccountHandler) ``` - [ ] **Step 3: Verify build and missing-key fail-fast** Run: `cd D:/spider && go build ./...` Expected: succeeds. Run without the env var set: ```bash 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: ```bash 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** ```bash git add cmd/server/main.go internal/handler/router.go git commit -m "feat: wire TG crypto + import/reveal-2fa routes into server boot" ``` --- ## Task 13: Frontend — TgAccounts import UI **Files:** - Modify: `web/src/pages/TgAccounts.tsx` - [ ] **Step 1: Replace the entire file** Overwrite `web/src/pages/TgAccounts.tsx` with: ```tsx 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 = { idle: 'default', online: 'green', cooling: 'orange', dead: 'red', } const sourceColors: Record = { protocol: 'purple', manual: 'default', } const importStatusColors: Record = { ok: 'green', session_invalid: 'red', dead: 'orange', } export default function TgAccounts() { const [accounts, setAccounts] = useState([]) 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(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(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 ? {v === 'protocol' ? '协议号' : '手填'} : '-', }, { title: '设备', dataIndex: 'device', width: 160, ellipsis: true, render: (v: string) => v ? {v} : '-', }, { title: '状态', dataIndex: 'status', render: (v: string) => {v}, }, { title: '导入状态', dataIndex: 'import_status', render: (v: string) => v ? {v} : '-', }, { title: '备注', dataIndex: 'remark', render: (v: string) => v || '-' }, { title: '启用', dataIndex: 'enabled', render: (v: boolean, record: TgAccount) => ( handleToggle(record, c)} checkedChildren="启用" unCheckedChildren="禁用" /> ), }, { title: '操作', key: 'action', width: 260, render: (_: unknown, record: TgAccount) => ( handleDelete(record.id)}> ), }, ] return (
setImportOpen(false)} okText="导入" width={500} >

选择一个协议号目录(如 D:\spider\tgs\13252753163\),目录下应包含 <phone>.json<phone>.session2fa_password.txt 和(可选) tdata/

setManualOpen(false)} okText="添加" width={500} >
) } ``` - [ ] **Step 2: Build frontend** Run: ```bash cd D:/spider/web && npm run build 2>&1 | tail -10 ``` Expected: build succeeds (no TS errors). - [ ] **Step 3: Commit** ```bash git add web/src/pages/TgAccounts.tsx git commit -m "feat(web): TG protocol-number import UI with webkitdirectory upload" ``` --- ## Task 14: End-to-end smoke test **Files:** none (manual verification) - [ ] **Step 1: Rebuild and start the Docker stack** Run: ```bash 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. - [ ] **Step 2: Ensure `TG_SECRET_KEY` is set in the API container** Edit `deploy/docker-compose.yml` → `api.environment` (if not already set), add: ```yaml - TG_SECRET_KEY=${TG_SECRET_KEY} ``` Create `deploy/.env` (NOT committed) with a real key: ```bash 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. - [ ] **Step 3: Import a real protocol number via UI** 1. Open `http://localhost:8300` in a browser, log in as admin. 2. Navigate to TG 账号 page. 3. Click "导入协议号" → in the file picker, select the folder `D:\spider\tgs\13252753163\`. 4. Click 导入. Expected: toast "导入成功并测试通过" (or "冷却中" if the account is flood-waited — still counts as import success). New row appears with `来源=协议号`, `设备=Xiaomi Mix 4`, `状态=idle`|`online`, `导入状态=ok`. - [ ] **Step 4: Verify filesystem artifacts** Run on host: ```bash ls D:/spider/sessions/ ``` Expected: `13252753163.json` + `13252753163.origin/` (containing copies of the uploaded files). - [ ] **Step 5: Verify the account is usable** 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). - [ ] **Step 6: Verify duplicate protection** Re-import the same folder. Expected: toast `该手机号已存在` (HTTP 409). - [ ] **Step 7: Verify 2FA reveal** In the TG 账号 row, click `2FA`. Modal opens showing `xing` (or whatever the folder's `2fa_password.txt` contained). - [ ] **Step 8: Commit verification notes** If any small follow-up fixes were needed in prior tasks, commit them here. Otherwise this task has no commit. --- ## Self-Review Notes **Spec coverage check:** - §1 data model → Task 7 - §2 file layout + docker + config → Tasks 6 (config), 12 (main.go mkdir); docker-compose already mounts sessions/ per existing config - §3 Pyrogram conversion → Tasks 1 (dep), 2 (dc addr), 3 (conversion), 4 (tdata fallback), 9 (orchestration) - §4 2FA crypto → Task 5; reveal endpoint Task 11 - §5 device fingerprint → Task 8; plumbing in reloadAccounts Task 10; import insert Task 10 - §6 frontend + API → Tasks 10 (import), 11 (reveal-2fa), 12 (routes), 13 (UI) - §7 error handling → built into Tasks 9–10 (pyrogram err, tdata err, FloodWait, duplicate, tmp dir retention via `tmpCleanupLoop`) - Non-goals → respected (no auto-scan, no re-login, no Pyrogram writeback, no tdata export, no batch) **Type consistency:** - `TgAccount.SDK` (DB field) → `Account.SystemVersion` (runtime) — mapping performed identically in Task 10's `reloadAccounts` and Task 10's `runTest` - `session.Data` fields used in Task 3 match what Task 9 writes via `session.Loader{}.Save` - HTTP status codes match spec: 400 (form error), 409 (dup), 422 (conversion fail), 500 (io/server)