Przeglądaj źródła

feat(clone_group): authoritative total from full_channel + Recent filter phase 1

Problem: small TG accounts (fresh 协议号 esp.) get heavily restricted
member visibility via ChannelParticipantsSearch{Q:""}, which returns only
4-5 users with cp.Count matching. For a 1935-member group, len(seen) >=
cp.Count immediately triggered saturation and skipped phase 2 entirely.

Fix:
- Call channels.getFullChannel once after auto-join to obtain the TRUE
  participants_count (1935), not the filtered one (4). This becomes the
  authoritative target for saturation checks.
- Replace phase 1's Search{Q:""} with ChannelParticipantsRecent — the
  Recent filter is not search-restricted and returns up to ~200 active
  members for non-admins.
- New client methods: GetFullChannelTotal, FetchRecentParticipants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dot 2 tygodni temu
rodzic
commit
a62880a620
2 zmienionych plików z 113 dodań i 7 usunięć
  1. 88 0
      internal/telegram/client.go
  2. 25 7
      internal/telegram/clonegroup.go

+ 88 - 0
internal/telegram/client.go

@@ -372,6 +372,94 @@ func (c *Client) ResolveGroupChannel(ctx context.Context, username string) (*tg.
 	return nil, nil, fmt.Errorf("无法解析群组为超级群组: %s", username)
 }
 
+// GetFullChannelTotal calls channels.getFullChannel to obtain the authoritative
+// participants_count (which may be much larger than what a restricted member
+// can see via ChannelParticipantsSearch).
+func (c *Client) GetFullChannelTotal(ctx context.Context, ch *tg.InputChannel) (int, error) {
+	api, err := c.waitReady(ctx)
+	if err != nil {
+		return 0, err
+	}
+	full, err := api.ChannelsGetFullChannel(ctx, ch)
+	if err != nil {
+		return 0, wrapFloodWait(err)
+	}
+	cf, ok := full.FullChat.(*tg.ChannelFull)
+	if !ok {
+		return 0, fmt.Errorf("unexpected FullChat type")
+	}
+	if n, ok := cf.GetParticipantsCount(); ok {
+		return n, nil
+	}
+	return 0, nil
+}
+
+// FetchRecentParticipants paginates channels.getParticipants with the Recent
+// filter, which returns active participants sorted by recency. This is the
+// preferred Phase 1 filter for large groups — empty-string Search filter is
+// often heavily restricted (returns 4-5 results) while Recent returns up to
+// ~200 and is not treated as "searching".
+func (c *Client) FetchRecentParticipants(ctx context.Context, channel *tg.InputChannel) ([]GroupParticipant, int, error) {
+	api, err := c.waitReady(ctx)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	const pageSize = 200
+	offset := 0
+	totalCount := 0
+	var out []GroupParticipant
+
+	for {
+		result, err := api.ChannelsGetParticipants(ctx, &tg.ChannelsGetParticipantsRequest{
+			Channel: channel,
+			Filter:  &tg.ChannelParticipantsRecent{},
+			Offset:  offset,
+			Limit:   pageSize,
+			Hash:    0,
+		})
+		if err != nil {
+			return out, totalCount, wrapFloodWait(err)
+		}
+		cp, ok := result.(*tg.ChannelsChannelParticipants)
+		if !ok || len(cp.Users) == 0 {
+			break
+		}
+		if cp.Count > totalCount {
+			totalCount = cp.Count
+		}
+		for _, u := range cp.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
+			}
+			out = append(out, p)
+		}
+		offset += len(cp.Users)
+		if offset >= cp.Count {
+			break
+		}
+		if err := jitterSleep(ctx, 800*time.Millisecond, 1500*time.Millisecond); err != nil {
+			return out, totalCount, err
+		}
+	}
+	return out, totalCount, nil
+}
+
 // JoinChannel makes the current account a member of the given channel/supergroup.
 // USER_ALREADY_PARTICIPANT is treated as success. FloodWait is wrapped normally.
 // Side effect: this account becomes visibly a member of the group — make sure

+ 25 - 7
internal/telegram/clonegroup.go

@@ -269,21 +269,39 @@ func cloneGroupWithAccount(
 		log.Printf("[clone_group] %s join failed (continuing anyway): %v", username, err)
 	}
 
-	// Phase 1: empty query. Run only once per clone session (when total is not
-	// yet known). This both seeds seen with the visible batch AND captures total.
-	if res.Total == 0 {
-		users, total, err := acc.Client.FetchParticipantsByQuery(ctx, inputCh, "")
+	// Authoritative total from channels.getFullChannel. This is the TRUE participant
+	// count reported by TG (e.g. 1935), not the filtered count a restricted member
+	// sees via ChannelParticipantsSearch. Without this, phase 1 returning only 4-5
+	// users would prematurely satisfy the saturation check and skip phase 2.
+	if fullTotal, err := acc.Client.GetFullChannelTotal(ctx, inputCh); err == nil && fullTotal > 0 {
+		setTotal(fullTotal)
+		log.Printf("[clone_group] %s full_channel total=%d", username, fullTotal)
+	} else if err != nil {
+		if fwe, ok := err.(*FloodWaitError); ok {
+			return fwe.Seconds, nil
+		}
+		log.Printf("[clone_group] %s get_full_channel failed: %v (continuing)", username, err)
+	}
+
+	// Phase 1: Recent filter (returns up to ~200 active members). Replaces the
+	// empty-Q Search filter, which is often heavily restricted for non-admin
+	// members (returns only 4-5 results instead of ~200).
+	if !doneQueries[""] {
+		users, recentTotal, err := acc.Client.FetchRecentParticipants(ctx, inputCh)
 		if err != nil {
 			if fwe, ok := err.(*FloodWaitError); ok {
 				return fwe.Seconds, nil
 			}
-			log.Printf("[clone_group] phase1 error for %s: %v", username, err)
+			log.Printf("[clone_group] phase1 (recent) error for %s: %v", username, err)
 			return 0, nil // non-FloodWait; abort this account, let outer loop retry
 		}
 		added := addUsers(users)
-		setTotal(total)
+		if recentTotal > res.Total {
+			setTotal(recentTotal)
+		}
 		markQueryDone("") // empty string marks phase 1 as complete
-		log.Printf("[clone_group] %s phase1: +%d (total=%d)", username, added, total)
+		log.Printf("[clone_group] %s phase1 recent: +%d (visible=%d, target=%d)",
+			username, added, recentTotal, res.Total)
 		if res.Total > 0 && len(seen) >= res.Total {
 			return 0, nil // saturated
 		}