Kaynağa Gözat

feat(telegram): thread device fingerprint into Client.Connect InitConnection

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dot 2 hafta önce
ebeveyn
işleme
1f6f970f0a
2 değiştirilmiş dosya ile 369 ekleme ve 4 silme
  1. 349 0
      internal/telegram/client.go
  2. 20 4
      internal/telegram/types.go

+ 349 - 0
internal/telegram/client.go

@@ -1,7 +1,13 @@
 package telegram
 
 import (
+	"bufio"
 	"context"
+	"encoding/base64"
+	"fmt"
+	"log"
+	"net"
+	"net/url"
 	"regexp"
 	"sort"
 	"strings"
@@ -10,8 +16,10 @@ import (
 
 	"github.com/gotd/td/session"
 	"github.com/gotd/td/telegram"
+	"github.com/gotd/td/telegram/dcs"
 	"github.com/gotd/td/tg"
 	"github.com/gotd/td/tgerr"
+	xproxy "golang.org/x/net/proxy"
 )
 
 var tmeRegexp = regexp.MustCompile(`https?://t\.me/[^\s"'<>)\]]+`)
@@ -20,6 +28,7 @@ var tmeRegexp = regexp.MustCompile(`https?://t\.me/[^\s"'<>)\]]+`)
 type Client struct {
 	account     Account
 	sessionPath string
+	proxyURL    string // SOCKS5/HTTP proxy URL
 
 	mu     sync.Mutex
 	tgc    *telegram.Client
@@ -38,6 +47,13 @@ func New(account Account) *Client {
 	}
 }
 
+// SetProxy sets the proxy URL for this client's connections.
+func (c *Client) SetProxy(proxyURL string) {
+	c.mu.Lock()
+	c.proxyURL = proxyURL
+	c.mu.Unlock()
+}
+
 // Connect 连接并认证(从 session 文件恢复)
 // session 文件不存在时返回错误(不做交互式登录,session 需要预先生成)
 func (c *Client) Connect(ctx context.Context) error {
@@ -46,6 +62,28 @@ func (c *Client) Connect(ctx context.Context) error {
 	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,
+		},
+	}
+
+	// Apply proxy if configured
+	c.mu.Lock()
+	proxyURL := c.proxyURL
+	c.mu.Unlock()
+	if proxyURL != "" {
+		dialFunc, err := buildProxyDialer(proxyURL)
+		if err != nil {
+			log.Printf("[tg_client] failed to create proxy dialer: %v, connecting without proxy", err)
+		} else {
+			opts.Resolver = dcs.Plain(dcs.PlainOptions{Dial: dialFunc})
+			log.Printf("[tg_client] connecting via proxy: %s", proxyURL)
+		}
 	}
 
 	client := telegram.NewClient(c.account.AppID, c.account.AppHash, opts)
@@ -309,6 +347,317 @@ func (c *Client) VerifyUser(ctx context.Context, username string) (*UserInfo, er
 	return &UserInfo{Username: username, Exists: false}, nil
 }
 
+// GetGroupParticipants 获取群组/超级群组的成员列表(分页拉取全部)
+func (c *Client) GetGroupParticipants(ctx context.Context, username string) ([]GroupParticipant, error) {
+	api, err := c.waitReady(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	username = strings.TrimPrefix(username, "@")
+
+	// Resolve the channel/group
+	resolved, err := api.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{
+		Username: username,
+	})
+	if err != nil {
+		return nil, wrapFloodWait(err)
+	}
+
+	// Find the channel in resolved chats
+	var inputChannel *tg.InputChannel
+	for _, ch := range resolved.Chats {
+		switch v := ch.(type) {
+		case *tg.Channel:
+			accessHash, _ := v.GetAccessHash()
+			inputChannel = &tg.InputChannel{
+				ChannelID:  v.GetID(),
+				AccessHash: accessHash,
+			}
+		}
+	}
+
+	if inputChannel == nil {
+		// Try as basic chat - get participants via MessagesGetFullChat
+		if p, ok := resolved.Peer.(*tg.PeerChat); ok {
+			return c.getChatParticipants(ctx, api, p.ChatID)
+		}
+		return nil, fmt.Errorf("无法解析群组: %s", username)
+	}
+
+	// Strategy: use ChannelParticipantsSearch with empty query (returns more than Recent),
+	// then iterate alphabet queries to discover members beyond the 200 limit per query.
+	seen := make(map[int64]bool)
+	var allParticipants []GroupParticipant
+
+	// Helper to extract users from a page
+	extractUsers := func(cp *tg.ChannelsChannelParticipants) int {
+		added := 0
+		for _, u := range cp.Users {
+			user, ok := u.(*tg.User)
+			if !ok || seen[user.GetID()] {
+				continue
+			}
+			seen[user.GetID()] = true
+			p := GroupParticipant{
+				ID:        user.GetID(),
+				IsBot:     user.GetBot(),
+				IsPremium: user.GetPremium(),
+			}
+			if un, ok := user.GetUsername(); ok {
+				p.Username = un
+			}
+			if fn, ok := user.GetFirstName(); ok {
+				p.FirstName = fn
+			}
+			if ln, ok := user.GetLastName(); ok {
+				p.LastName = ln
+			}
+			allParticipants = append(allParticipants, p)
+			added++
+		}
+		return added
+	}
+
+	// Phase 1: Search with empty query (gets up to ~200)
+	totalCount := 0
+	if err := c.fetchParticipantPages(ctx, api, inputChannel, "", seen, extractUsers, &totalCount); err != nil {
+		if len(allParticipants) > 0 {
+			return allParticipants, err
+		}
+		return nil, err
+	}
+
+	// Phase 2: If group has more members than we found, search by character sets to discover more
+	if totalCount > len(allParticipants) && totalCount <= 10000 {
+		queries := participantSearchQueries()
+		for _, q := range queries {
+			if ctx.Err() != nil {
+				break
+			}
+			beforeCount := len(allParticipants)
+			_ = c.fetchParticipantPages(ctx, api, inputChannel, q, seen, extractUsers, nil)
+			if len(allParticipants) == beforeCount {
+				continue // No new results for this query
+			}
+			select {
+			case <-ctx.Done():
+				return allParticipants, ctx.Err()
+			case <-time.After(300 * time.Millisecond):
+			}
+		}
+	}
+
+	log.Printf("[tg_client] fetched %d/%d participants for %s", len(allParticipants), totalCount, username)
+	return allParticipants, nil
+}
+
+// participantSearchQueries returns search queries covering Latin, Cyrillic, CJK and other scripts.
+func participantSearchQueries() []string {
+	queries := make([]string, 0, 80)
+	// Latin a-z
+	for c := 'a'; c <= 'z'; c++ {
+		queries = append(queries, string(c))
+	}
+	// Digits 0-9
+	for c := '0'; c <= '9'; c++ {
+		queries = append(queries, string(c))
+	}
+	// Cyrillic а-я
+	for c := 'а'; c <= 'я'; c++ {
+		queries = append(queries, string(c))
+	}
+	// Common CJK first characters (high frequency Chinese surnames and words)
+	cjk := []string{"王", "李", "张", "刘", "陈", "杨", "黄", "赵", "周", "吴",
+		"徐", "孙", "马", "朱", "胡", "林", "何", "高", "郭", "罗",
+		"大", "小", "新", "老", "中", "天", "金", "一"}
+	queries = append(queries, cjk...)
+	return queries
+}
+
+// fetchParticipantPages paginates through ChannelParticipantsSearch results.
+func (c *Client) fetchParticipantPages(
+	ctx context.Context,
+	api *tg.Client,
+	channel *tg.InputChannel,
+	query string,
+	seen map[int64]bool,
+	extractUsers func(*tg.ChannelsChannelParticipants) int,
+	outTotalCount *int,
+) error {
+	const pageSize = 200
+	offset := 0
+
+	for {
+		result, err := api.ChannelsGetParticipants(ctx, &tg.ChannelsGetParticipantsRequest{
+			Channel: channel,
+			Filter:  &tg.ChannelParticipantsSearch{Q: query},
+			Offset:  offset,
+			Limit:   pageSize,
+			Hash:    0,
+		})
+		if err != nil {
+			return wrapFloodWait(err)
+		}
+
+		cp, ok := result.(*tg.ChannelsChannelParticipants)
+		if !ok || len(cp.Users) == 0 {
+			break
+		}
+
+		if outTotalCount != nil && cp.Count > *outTotalCount {
+			*outTotalCount = cp.Count
+		}
+
+		added := extractUsers(cp)
+		offset += len(cp.Users)
+
+		// If no new users were added in this page, stop
+		if added == 0 || offset >= cp.Count {
+			break
+		}
+
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-time.After(500 * time.Millisecond):
+		}
+	}
+	return nil
+}
+
+// getChatParticipants 获取普通群组的成员
+func (c *Client) getChatParticipants(ctx context.Context, api *tg.Client, chatID int64) ([]GroupParticipant, error) {
+	full, err := api.MessagesGetFullChat(ctx, chatID)
+	if err != nil {
+		return nil, wrapFloodWait(err)
+	}
+
+	var participants []GroupParticipant
+	for _, u := range full.Users {
+		user, ok := u.(*tg.User)
+		if !ok {
+			continue
+		}
+		p := GroupParticipant{
+			ID:        user.GetID(),
+			IsBot:     user.GetBot(),
+			IsPremium: user.GetPremium(),
+		}
+		if un, ok := user.GetUsername(); ok {
+			p.Username = un
+		}
+		if fn, ok := user.GetFirstName(); ok {
+			p.FirstName = fn
+		}
+		if ln, ok := user.GetLastName(); ok {
+			p.LastName = ln
+		}
+		participants = append(participants, p)
+	}
+
+	return participants, nil
+}
+
+// buildProxyDialer creates a DialFunc that routes connections through the given proxy URL.
+// Supports socks5://, http://, https:// proxy protocols.
+func buildProxyDialer(rawURL string) (dcs.DialFunc, error) {
+	u, err := url.Parse(rawURL)
+	if err != nil {
+		return nil, fmt.Errorf("parse proxy URL: %w", err)
+	}
+
+	switch u.Scheme {
+	case "socks5", "socks5h":
+		var auth *xproxy.Auth
+		if u.User != nil {
+			auth = &xproxy.Auth{User: u.User.Username()}
+			if p, ok := u.User.Password(); ok {
+				auth.Password = p
+			}
+		}
+		dialer, err := xproxy.SOCKS5("tcp", u.Host, auth, xproxy.Direct)
+		if err != nil {
+			return nil, fmt.Errorf("create SOCKS5 dialer: %w", err)
+		}
+		ctxDialer, ok := dialer.(xproxy.ContextDialer)
+		if !ok {
+			return nil, fmt.Errorf("SOCKS5 dialer does not support DialContext")
+		}
+		return ctxDialer.DialContext, nil
+
+	case "http", "https":
+		// For HTTP proxies, use CONNECT tunneling
+		return func(ctx context.Context, network, addr string) (net.Conn, error) {
+			proxyConn, err := (&net.Dialer{Timeout: 15 * time.Second}).DialContext(ctx, "tcp", u.Host)
+			if err != nil {
+				return nil, fmt.Errorf("connect to proxy: %w", err)
+			}
+
+			// Set deadline for the CONNECT handshake
+			if deadline, ok := ctx.Deadline(); ok {
+				proxyConn.SetDeadline(deadline)
+			} else {
+				proxyConn.SetDeadline(time.Now().Add(15 * time.Second))
+			}
+
+			// Send CONNECT request
+			connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n", addr, addr)
+			if u.User != nil {
+				pass, _ := u.User.Password()
+				connectReq += fmt.Sprintf("Proxy-Authorization: Basic %s\r\n",
+					encodeBasicAuth(u.User.Username(), pass))
+			}
+			connectReq += "\r\n"
+
+			if _, err := proxyConn.Write([]byte(connectReq)); err != nil {
+				proxyConn.Close()
+				return nil, fmt.Errorf("write CONNECT: %w", err)
+			}
+
+			// Read HTTP response using bufio for proper line parsing
+			br := bufio.NewReader(proxyConn)
+			statusLine, err := br.ReadString('\n')
+			if err != nil {
+				proxyConn.Close()
+				return nil, fmt.Errorf("read CONNECT status: %w", err)
+			}
+
+			// Parse status code from "HTTP/1.x NNN reason"
+			parts := strings.SplitN(strings.TrimSpace(statusLine), " ", 3)
+			if len(parts) < 2 || parts[1] != "200" {
+				proxyConn.Close()
+				return nil, fmt.Errorf("CONNECT failed: %s", strings.TrimSpace(statusLine))
+			}
+
+			// Consume remaining headers until empty line
+			for {
+				line, err := br.ReadString('\n')
+				if err != nil {
+					proxyConn.Close()
+					return nil, fmt.Errorf("read CONNECT headers: %w", err)
+				}
+				if strings.TrimSpace(line) == "" {
+					break
+				}
+			}
+
+			// Clear deadline — gotd manages its own timeouts
+			proxyConn.SetDeadline(time.Time{})
+
+			return proxyConn, nil
+		}, nil
+
+	default:
+		return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme)
+	}
+}
+
+// encodeBasicAuth returns base64-encoded "user:password" for proxy auth.
+func encodeBasicAuth(user, password string) string {
+	return base64.StdEncoding.EncodeToString([]byte(user + ":" + password))
+}
+
 // resolveInputPeer resolves a username to an InputPeer
 func (c *Client) resolveInputPeer(ctx context.Context, api *tg.Client, username string) (tg.InputPeerClass, error) {
 	resolved, err := api.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{

+ 20 - 4
internal/telegram/types.go

@@ -7,10 +7,16 @@ import (
 
 // Account TG 账号信息
 type Account struct {
-	Phone       string
-	SessionFile string
-	AppID       int
-	AppHash     string
+	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"
 }
 
 // ChannelInfo 频道基本信息
@@ -46,6 +52,16 @@ type UserInfo struct {
 	Exists    bool // false 表示不存在/已注销
 }
 
+// GroupParticipant 群成员信息
+type GroupParticipant struct {
+	ID        int64  `json:"id"`
+	Username  string `json:"username"`
+	FirstName string `json:"first_name"`
+	LastName  string `json:"last_name"`
+	IsBot     bool   `json:"is_bot"`
+	IsPremium bool   `json:"is_premium"`
+}
+
 // FloodWaitError FloodWait 错误,包含等待时长
 type FloodWaitError struct {
 	Seconds int