2026-04-20-tg-protocol-number-import.md 54 KB

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 (<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


File Structure

New files:

  • internal/telegram/crypto.go — AES-GCM encrypt/decrypt helper
  • internal/telegram/crypto_test.go
  • internal/telegram/sessionimport/dcaddr.godc_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 <phone>.json, writes session + backup
  • deploy/.env.exampleTG_SECRET_KEY template

Modified files:

  • go.mod / go.sum — add modernc.org/sqlite
  • internal/model/user.goTgAccount new columns
  • internal/telegram/types.goAccount struct device fields
  • internal/telegram/client.goConnect passes telegram.DeviceConfig
  • internal/config/config.goTelegramConfig.SessionsDir + SecretKey
  • configs/config.yaml — two new keys
  • internal/handler/tg_account.goImport, 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.ymlsessions/ 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:

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:

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"
    

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:

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:

// 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

    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:

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:

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

    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:

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

    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:

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:

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

    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:

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:

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

  • Step 3: Update configs/config.yaml

Replace 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
  • 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:

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"
    

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:

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

  • Step 2: Verify auto-migrate adds columns

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...)"
    

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:

// 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:

	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.

  • Step 3: Verify build

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"
    

Task 9: Importer orchestration

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
}
  • Step 2: Verify build

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

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:

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.

  • Step 2: Add a constructor

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()))
		}
	}
}
  • Step 3: Add the Import method

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
}
  • Step 4: Update reloadAccounts to populate new fields

Replace 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)
}
  • 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

    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:

// 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

    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:

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)
  • 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:

	// 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)
  • Step 3: Verify build and missing-key fail-fast

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"
    

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:

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>&lt;phone&gt;.json</code>、<code>&lt;phone&gt;.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>
  )
}
  • Step 2: Build frontend

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"
    

Task 14: End-to-end smoke test

Files: none (manual verification)

  • Step 1: Rebuild and start the Docker stack

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.

  • Step 2: Ensure TG_SECRET_KEY is set in the API container

Edit deploy/docker-compose.ymlapi.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.

  • 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:

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)