|
@@ -1,7 +1,13 @@
|
|
|
package telegram
|
|
package telegram
|
|
|
|
|
|
|
|
import (
|
|
import (
|
|
|
|
|
+ "bufio"
|
|
|
"context"
|
|
"context"
|
|
|
|
|
+ "encoding/base64"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "log"
|
|
|
|
|
+ "net"
|
|
|
|
|
+ "net/url"
|
|
|
"regexp"
|
|
"regexp"
|
|
|
"sort"
|
|
"sort"
|
|
|
"strings"
|
|
"strings"
|
|
@@ -10,8 +16,10 @@ import (
|
|
|
|
|
|
|
|
"github.com/gotd/td/session"
|
|
"github.com/gotd/td/session"
|
|
|
"github.com/gotd/td/telegram"
|
|
"github.com/gotd/td/telegram"
|
|
|
|
|
+ "github.com/gotd/td/telegram/dcs"
|
|
|
"github.com/gotd/td/tg"
|
|
"github.com/gotd/td/tg"
|
|
|
"github.com/gotd/td/tgerr"
|
|
"github.com/gotd/td/tgerr"
|
|
|
|
|
+ xproxy "golang.org/x/net/proxy"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
var tmeRegexp = regexp.MustCompile(`https?://t\.me/[^\s"'<>)\]]+`)
|
|
var tmeRegexp = regexp.MustCompile(`https?://t\.me/[^\s"'<>)\]]+`)
|
|
@@ -20,6 +28,7 @@ var tmeRegexp = regexp.MustCompile(`https?://t\.me/[^\s"'<>)\]]+`)
|
|
|
type Client struct {
|
|
type Client struct {
|
|
|
account Account
|
|
account Account
|
|
|
sessionPath string
|
|
sessionPath string
|
|
|
|
|
+ proxyURL string // SOCKS5/HTTP proxy URL
|
|
|
|
|
|
|
|
mu sync.Mutex
|
|
mu sync.Mutex
|
|
|
tgc *telegram.Client
|
|
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 文件恢复)
|
|
// Connect 连接并认证(从 session 文件恢复)
|
|
|
// session 文件不存在时返回错误(不做交互式登录,session 需要预先生成)
|
|
// session 文件不存在时返回错误(不做交互式登录,session 需要预先生成)
|
|
|
func (c *Client) Connect(ctx context.Context) error {
|
|
func (c *Client) Connect(ctx context.Context) error {
|
|
@@ -46,6 +62,28 @@ func (c *Client) Connect(ctx context.Context) error {
|
|
|
opts := telegram.Options{
|
|
opts := telegram.Options{
|
|
|
SessionStorage: storage,
|
|
SessionStorage: storage,
|
|
|
NoUpdates: true,
|
|
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)
|
|
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
|
|
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
|
|
// resolveInputPeer resolves a username to an InputPeer
|
|
|
func (c *Client) resolveInputPeer(ctx context.Context, api *tg.Client, username string) (tg.InputPeerClass, error) {
|
|
func (c *Client) resolveInputPeer(ctx context.Context, api *tg.Client, username string) (tg.InputPeerClass, error) {
|
|
|
resolved, err := api.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{
|
|
resolved, err := api.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{
|