Files
tiennm99 fafeab4750 feat: report progress while reading a chat and listing a remote
Both phases ran for minutes printing nothing between their opening line and
their result, so a working run looked exactly like a hung one — which is how it
was reported.

The message counter lives in Walk rather than in the caller's loop because most
of a chat is not media: text-only and service messages are filtered out inside
the walk, so a caller counting yielded items still sees nothing while crossing a
long stretch of conversation.

Cadence follows the existing reporter: a terminal redraws one line, a redirected
run gets a periodic one, since ANSI redraws are what turned the shell pipeline's
captured logs into megabytes of control characters.
2026-09-06 21:05:59 +07:00

109 lines
3.9 KiB
Go

package tgsource
import (
"context"
"fmt"
"iter"
"github.com/gotd/td/telegram/peers"
"github.com/gotd/td/telegram/query"
"github.com/gotd/td/tg"
"github.com/iyear/tdl/core/tmedia"
"github.com/tiennm99dev/telegram-exporter/internal/naming"
)
// Item is one downloadable media message.
//
// Name is filled here, at the single point where the message is seen, and is the
// same string used to check the remote and to write the file. See the naming
// package for why that matters. Media carries the location, size and DC that the
// downloader needs, so nothing has to be looked up a second time.
type Item struct {
DialogID int64
MessageID int
Name string
Media *tmedia.Media
}
// Size reports the media size in bytes.
func (i Item) Size() int64 { return i.Media.Size }
// Walk yields every media message in a chat, newest first.
//
// Order is Telegram's: GetHistory pages backwards from the most recent message.
// The shell pipeline fetched oldest-first, so an interrupted run leaves a
// different subset archived than the old one would have.
//
// A sequence rather than a callback because the downloader consumes a pull
// iterator (Next/Value/Err), and range-over-func converts either way for free:
// callers that want the callback shape just range over it, while iter.Pull2
// gives the pull shape without anyone owning a goroutine. Messages are streamed,
// never collected — an 18k-message chat is tens of thousands of descriptors and
// the caller decides what to keep.
//
// Text-only and service messages carry no file and are skipped, the same rule
// the export JSON encoded as an empty "file" field. On error the sequence yields
// a zero Item with that error and stops; cancelling ctx stops it too, so an
// interrupted run does not keep paging.
// onScan, when non-nil, is called with the number of messages read so far. It
// has to live here rather than in the caller's loop because most of a chat is
// not media: text-only and service messages are filtered out below, so a caller
// counting yielded items sees nothing at all while the walk crosses a long
// stretch of conversation, and a working run is indistinguishable from a hung
// one.
func Walk(ctx context.Context, api *tg.Client, peer peers.Peer, onScan func(scanned int)) iter.Seq2[Item, error] {
return func(yield func(Item, error) bool) {
dialogID := peer.ID()
it := query.NewQuery(api).Messages().GetHistory(peer.InputPeer()).BatchSize(100).Iter()
scanned := 0
for it.Next(ctx) {
scanned++
if onScan != nil {
onScan(scanned)
}
msg, ok := it.Value().Msg.(*tg.Message)
if !ok {
continue // service messages have no media
}
media, ok := tmedia.GetMedia(msg)
if !ok {
continue // text-only, or a media kind tmedia cannot download
}
if !yield(Item{
DialogID: dialogID,
MessageID: msg.ID,
Name: naming.For(dialogID, msg.ID, media),
Media: media,
}, nil) {
return
}
}
if err := it.Err(); err != nil {
yield(Item{}, fmt.Errorf("walk chat history: %w", err))
}
// There is no cross-check that the walk saw the whole history, and the
// obvious one does not work. gotd's iterator ends with a nil error if a
// fetch yields an empty buffer (messages/iter.go:97,103,155-160), so a
// truncated walk is indistinguishable from a complete one — but
// Iterator.Total is the server's history count, which includes the
// deleted slots that Next skips at iter.go:159-162. Comparing the two
// reports a short read on any chat that has ever had a message deleted,
// which is nearly all of them. A real check would need a count of
// non-empty messages, which the API does not offer.
}
}
// Manager builds a peers manager over the session's peer cache, so resolving the
// same chat twice does not cost a second round trip.
func Manager(api *tg.Client, storage peers.Storage) *peers.Manager {
return peers.Options{Storage: storage}.Build(api)
}