ソースを参照

feat(clone_group): auto-join channel + basic chat fallback

- New Client.JoinChannel: calls channels.joinChannel; USER_ALREADY_PARTICIPANT
  counts as success. Side-effect: account becomes visibly a group member —
  necessary for private groups that hide members from non-participants.
- New Client.ResolveGroupPeer: extended resolver that also returns basic-chat
  IDs (for @username resolving to a PeerChat rather than PeerChannel).
- New Client.GetChatParticipantsByID: thin wrapper exposing the existing
  basic-chat participants fetcher.
- CloneGroupMembers now routes basic chats through GetChatParticipantsByID
  (no pagination, one call completes) and auto-joins supergroups/channels
  before running GetParticipants phases. Join failures are logged and
  non-fatal: we still try GetParticipants in case the group permits
  non-member listing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dot 2 週間 前
コミット
fef779bca2
2 ファイル変更90 行追加1 行削除
  1. 55 0
      internal/telegram/client.go
  2. 35 1
      internal/telegram/clonegroup.go

+ 55 - 0
internal/telegram/client.go

@@ -372,6 +372,61 @@ func (c *Client) ResolveGroupChannel(ctx context.Context, username string) (*tg.
 	return nil, nil, fmt.Errorf("无法解析群组为超级群组: %s", username)
 }
 
+// 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
+// the caller actually wants that (private groups require it to see the member
+// list, but it leaves a trace in group join/leave activity logs).
+func (c *Client) JoinChannel(ctx context.Context, ch *tg.InputChannel) error {
+	api, err := c.waitReady(ctx)
+	if err != nil {
+		return err
+	}
+	_, err = api.ChannelsJoinChannel(ctx, ch)
+	if err != nil {
+		if tgerr.Is(err, "USER_ALREADY_PARTICIPANT") {
+			return nil
+		}
+		return wrapFloodWait(err)
+	}
+	return nil
+}
+
+// GetChatParticipantsByID fetches members of a basic (non-supergroup) chat.
+// Basic chats have no pagination — this returns everyone in one call.
+func (c *Client) GetChatParticipantsByID(ctx context.Context, chatID int64) ([]GroupParticipant, error) {
+	api, err := c.waitReady(ctx)
+	if err != nil {
+		return nil, err
+	}
+	return c.getChatParticipants(ctx, api, chatID)
+}
+
+// ResolveGroupPeer is a broader resolver than ResolveGroupChannel: it also
+// returns a basic-chat ID when the target is not a supergroup/channel.
+// Exactly one of (inputCh, chatID) will be non-zero on success.
+func (c *Client) ResolveGroupPeer(ctx context.Context, username string) (*tg.InputChannel, *tg.Channel, int64, error) {
+	api, err := c.waitReady(ctx)
+	if err != nil {
+		return nil, nil, 0, err
+	}
+	username = strings.TrimPrefix(username, "@")
+	resolved, err := api.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: username})
+	if err != nil {
+		return nil, nil, 0, wrapFloodWait(err)
+	}
+	for _, ch := range resolved.Chats {
+		if v, ok := ch.(*tg.Channel); ok {
+			accessHash, _ := v.GetAccessHash()
+			return &tg.InputChannel{ChannelID: v.GetID(), AccessHash: accessHash}, v, 0, nil
+		}
+	}
+	if p, ok := resolved.Peer.(*tg.PeerChat); ok {
+		return nil, nil, p.ChatID, nil
+	}
+	return nil, nil, 0, fmt.Errorf("无法解析群组: %s", username)
+}
+
 // FetchParticipantsByQuery runs ChannelParticipantsSearch for one query string,
 // paginating through all pages. Returns the users surfaced by this query and
 // the total count reported by TG. On FloodWait, returns a *FloodWaitError.

+ 35 - 1
internal/telegram/clonegroup.go

@@ -224,17 +224,51 @@ func cloneGroupWithAccount(
 	}
 	defer acc.Client.Disconnect()
 
-	inputCh, ch, err := acc.Client.ResolveGroupChannel(ctx, username)
+	inputCh, ch, chatID, err := acc.Client.ResolveGroupPeer(ctx, username)
 	if err != nil {
 		if fwe, ok := err.(*FloodWaitError); ok {
 			return fwe.Seconds, nil
 		}
 		return 0, fmt.Errorf("resolve %s: %w", username, err)
 	}
+
+	// Basic chat path: no pagination, one call returns everyone visible.
+	// There's no search query mechanism for basic chats, so the full response
+	// saturates the collection in one shot.
+	if inputCh == nil && chatID != 0 {
+		participants, err := acc.Client.GetChatParticipantsByID(ctx, chatID)
+		if err != nil {
+			if fwe, ok := err.(*FloodWaitError); ok {
+				return fwe.Seconds, nil
+			}
+			return 0, fmt.Errorf("basic chat participants %s: %w", username, err)
+		}
+		addUsers(participants)
+		setTotal(len(participants))
+		// Mark phase 1 and every query as done to terminate the outer loop immediately.
+		markQueryDone("")
+		for _, q := range queries {
+			markQueryDone(q)
+		}
+		log.Printf("[clone_group] %s basic chat: +%d (done)", username, len(participants))
+		return 0, nil
+	}
+
 	if res.GroupTitle == "" && ch != nil {
 		res.GroupTitle = ch.Title
 	}
 
+	// Auto-join: private groups and some supergroups refuse GetParticipants for
+	// non-members. USER_ALREADY_PARTICIPANT is treated as success. Failure is
+	// non-fatal (we still try GetParticipants — may work for public groups
+	// that block joining but allow member listing).
+	if err := acc.Client.JoinChannel(ctx, inputCh); err != nil {
+		if fwe, ok := err.(*FloodWaitError); ok {
+			return fwe.Seconds, nil
+		}
+		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 {