mirror of
https://github.com/tiennm99/telegram-exporter.git
synced 2026-10-11 03:13:49 +00:00
rclone does not address a file by the bytes os.OpenFile wrote. Names handed to an Fs go through the backend encoder and names listed back are re-encoded to the standard set, neither of which the write path performs. A filename holding one of the rewritten characters was therefore stored under one string and looked up under another: confirmed against the local backend, where a written "a‛b.jpg" reports object not found and a written "a\nb.jpg" lists back as "a␊b.jpg". That is the same two-derivation divergence this program was written to remove, with rclone's encoder standing where filenamify used to. Such names are rejected, not encoded, for the same reason every other name is. The length limit ignored the ".part" suffix that is opened first, so a name just inside NAME_MAX passed the check and then failed to open on every pass, stalling the walk on that message forever. The suffix now lives beside the limit that has to account for it. filter.NewFilter(nil) does not build a neutral filter; it copies the package global, which rclone has already filled from RCLONE_*. Indexing inherited the operator's environment, so a stray RCLONE_MIN_SIZE emptied the index and re-downloaded the archive. Every narrowing field is now set explicitly and the result is asserted inactive. A subprocess test covers it, since the env is read at package init and t.Setenv is too late to observe anything. An ErrorDirNotFound from a subdirectory was also treated as an empty destination, returning a partial index as authoritative. t.me/c/<id> and t.me/s/<name> were passed through whole, and gotd reads the first path component as the username — resolving "c" or "s", which is a confusing failure at best and someone else's chat at worst, since one-character usernames exist. Both now yield the chat, and t.me/s/<name>/<id> is refused like any other message link. Two tests asserted the old behaviour. core's dcpool.Takeout deadlocks when takeout init fails: it holds the pool mutex and recovers by calling Client, which takes the same non-reentrant mutex. Telegram returns TAKEOUT_INIT_DELAY for a takeout started recently and takeout is on by default, so two runs in succession hang the process with no output and no response to cancellation. The session is established once here instead, falling back to a plain client, and the pool's own Takeout is never called.
191 lines
6.1 KiB
Go
191 lines
6.1 KiB
Go
package tgsource
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/gotd/td/telegram/peers"
|
|
|
|
"github.com/iyear/tdl/core/util/tutil"
|
|
)
|
|
|
|
// botAPIID matches a Bot API chat id: the same channel as an MTProto id, but
|
|
// with a -100 prefix that MTProto itself does not use.
|
|
var botAPIID = regexp.MustCompile(`^-100(\d+)$`)
|
|
|
|
// telegramHosts are the hosts Telegram deep links use. gotd accepts all three
|
|
// (telegram/deeplink/deeplink.go hasTelegramPrefix), so checking only t.me would
|
|
// let a telegram.me or telegram.dog message link through — and gotd's parser
|
|
// keeps just the domain and silently drops the message number, which would walk
|
|
// an entire chat when the operator asked for one message.
|
|
var telegramHosts = []string{"t.me/", "telegram.me/", "telegram.dog/"}
|
|
|
|
// isMessageLink reports whether s points at a single message rather than a chat.
|
|
//
|
|
// This is parsed rather than pattern-matched because the two HTTPS shapes overlap
|
|
// in a way a regex gets wrong: a private link is t.me/c/<id>/<msg> and a public
|
|
// one is t.me/<name>/<msg>, so "t.me/c/1234567890" — a private channel link —
|
|
// looks exactly like a public message link with the username "c". The
|
|
// distinction is whether a trailing numeric component follows the chat, and
|
|
// where that component sits depends on the leading marker.
|
|
func isMessageLink(s string) bool {
|
|
lower := strings.ToLower(s)
|
|
|
|
// tg:// links name the message in a query parameter rather than the path.
|
|
if strings.HasPrefix(lower, "tg://") {
|
|
for _, key := range []string{"post=", "message_id="} {
|
|
if strings.Contains(lower, "?"+key) || strings.Contains(lower, "&"+key) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
rest, ok := afterHost(lower)
|
|
if !ok {
|
|
return false
|
|
}
|
|
rest, _, _ = strings.Cut(rest, "?")
|
|
rest, _, _ = strings.Cut(rest, "#")
|
|
|
|
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
|
if len(parts) >= 1 && (parts[0] == "c" || parts[0] == "s") {
|
|
// Both put the chat in the second component, so a message is the third:
|
|
// c/<id>/<msg> and s/<name>/<msg>.
|
|
return len(parts) >= 3 && isDigits(parts[2])
|
|
}
|
|
// t.me/joinchat/<hash> is a chat, and its second component is not a bare
|
|
// number — except for a hypothetical all-digit invite hash, which is not
|
|
// worth mis-parsing every real link to guard.
|
|
if len(parts) >= 1 && parts[0] == "joinchat" {
|
|
return false
|
|
}
|
|
return len(parts) >= 2 && isDigits(parts[1])
|
|
}
|
|
|
|
// linkChat extracts the chat from a link whose first path component is a marker
|
|
// rather than the chat itself.
|
|
//
|
|
// Without this the marker *is* the chat as far as the resolver is concerned.
|
|
// gotd's deeplink parser takes the first path component as the domain and drops
|
|
// the rest (deeplink.go:106-148), and ValidateDomain accepts a single letter, so
|
|
// "t.me/s/mychannel" resolves the username "s" — either a hard-to-read
|
|
// USERNAME_NOT_OCCUPIED, or, since one-character usernames exist, somebody
|
|
// else's chat archived into the operator's remote.
|
|
//
|
|
// t.me/s/<name> is the preview page for a public channel, and the form most
|
|
// likely to be copied out of a browser. t.me/c/<id> carries the bare MTProto
|
|
// channel id — the same value a -100 Bot API id strips to.
|
|
func linkChat(s string) (string, bool) {
|
|
lower := strings.ToLower(s)
|
|
if strings.HasPrefix(lower, "tg://") {
|
|
return "", false
|
|
}
|
|
i, ok := hostEnd(lower)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
rest := s[i:]
|
|
rest, _, _ = strings.Cut(rest, "?")
|
|
rest, _, _ = strings.Cut(rest, "#")
|
|
|
|
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
|
if len(parts) < 2 || parts[1] == "" {
|
|
return "", false
|
|
}
|
|
switch strings.ToLower(parts[0]) {
|
|
case "c":
|
|
if isDigits(parts[1]) {
|
|
return parts[1], true
|
|
}
|
|
case "s":
|
|
return parts[1], true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// afterHost returns the path following a Telegram host, if s names one.
|
|
func afterHost(lower string) (string, bool) {
|
|
i, ok := hostEnd(lower)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return lower[i:], true
|
|
}
|
|
|
|
// hostEnd returns the offset just past a Telegram host in an already-lowercased
|
|
// string. Offsets rather than a substring, so a caller can slice the original
|
|
// and keep the chat's real case.
|
|
func hostEnd(lower string) (int, bool) {
|
|
for _, host := range telegramHosts {
|
|
if i := strings.Index(lower, host); i >= 0 {
|
|
return i + len(host), true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func isDigits(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// NormalizeChat converts a chat argument into the form the resolver expects.
|
|
//
|
|
// The accepted forms are the ones the shell pipeline accepted, because they are
|
|
// what an operator already has to hand: a numeric MTProto id as printed by
|
|
// `tdl chat ls`, a username with or without '@', or a t.me/tg:// link. Two need
|
|
// help. A Bot API id carries a -100 prefix that MTProto does not use, and a
|
|
// message link is not a chat — silently treating one as a chat would export the
|
|
// wrong thing, so it is refused with an explanation rather than guessed at.
|
|
func NormalizeChat(chat string) (string, error) {
|
|
chat = strings.TrimSpace(chat)
|
|
if chat == "" {
|
|
return "", fmt.Errorf("a chat is required")
|
|
}
|
|
|
|
if isMessageLink(chat) {
|
|
return "", fmt.Errorf("%q is a message link, not a chat — "+
|
|
"pass the chat's username or id instead", chat)
|
|
}
|
|
|
|
if c, ok := linkChat(chat); ok {
|
|
return c, nil
|
|
}
|
|
|
|
if m := botAPIID.FindStringSubmatch(chat); m != nil {
|
|
return m[1], nil
|
|
}
|
|
|
|
// The resolver takes a bare username; '@' is how humans write it.
|
|
return strings.TrimPrefix(chat, "@"), nil
|
|
}
|
|
|
|
// ResolveChat turns a chat argument into a peer.
|
|
//
|
|
// Numeric arguments are looked up as channel, then user, then chat ids;
|
|
// everything else goes through the resolver, which handles usernames and
|
|
// t.me/tg:// links. That ordering is tdl's (core/util/tutil.GetInputPeer), kept
|
|
// so an id that works in `tdl` works here.
|
|
func ResolveChat(ctx context.Context, manager *peers.Manager, chat string) (peers.Peer, error) {
|
|
normalized, err := NormalizeChat(chat)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
peer, err := tutil.GetInputPeer(ctx, manager, normalized)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot resolve chat %q: %w", chat, err)
|
|
}
|
|
return peer, nil
|
|
}
|