mirror of
https://github.com/tiennm99/telegram-exporter.git
synced 2026-10-13 10:15:59 +00:00
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.
109 lines
3.9 KiB
Go
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)
|
|
}
|