Skip to content

Commit 9d56456

Browse files
ernadoclaude
andcommitted
feat(file): implement GetFile, downloads and file_unique_id
Resolve the file_unique_id stub: derive it locally from the decoded file_id with the TDLib scheme (LE record of unique type + identifying fields, RLE-zero encoded, base64url no padding). Web and document-family files are exact; legacy photos use volume/local id, newer photo sources fall back to the media id (stable per file). Add GetFile (decode-only, no file server in the MTProto-native model), DownloadFile (streams via the DC-migration-aware client) and DownloadFileToPath. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c01516b commit 9d56456

2 files changed

Lines changed: 218 additions & 0 deletions

File tree

file.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/binary"
7+
"io"
8+
9+
"github.com/gotd/td/fileid"
10+
)
11+
12+
// GetFile decodes a file_id and returns a File describing it.
13+
//
14+
// Unlike the HTTP Bot API, this library is MTProto-native: there is no file
15+
// server, so GetFile performs no network I/O and FilePath is left empty. Use
16+
// DownloadFile or DownloadFileToPath to fetch the contents. FileUniqueID is
17+
// derived locally from the file_id.
18+
func (b *Bot) GetFile(_ context.Context, fileID string) (*File, error) {
19+
f, err := fileid.DecodeFileID(fileID)
20+
if err != nil {
21+
return nil, &Error{Code: 400, Description: "Bad Request: invalid file_id"}
22+
}
23+
return &File{
24+
FileID: fileID,
25+
FileUniqueID: fileUniqueID(f),
26+
}, nil
27+
}
28+
29+
// DownloadFile streams the file referenced by file_id into w. It follows DC
30+
// migration transparently. The number of bytes written is returned.
31+
func (b *Bot) DownloadFile(ctx context.Context, fileID string, w io.Writer) (int64, error) {
32+
f, err := fileid.DecodeFileID(fileID)
33+
if err != nil {
34+
return 0, &Error{Code: 400, Description: "Bad Request: invalid file_id"}
35+
}
36+
37+
counter := &countWriter{w: w}
38+
if loc, ok := f.AsInputFileLocation(); ok {
39+
if _, err := b.client.Download(loc).Stream(ctx, counter); err != nil {
40+
return counter.n, asAPIError(err)
41+
}
42+
return counter.n, nil
43+
}
44+
if loc, ok := f.AsInputWebFileLocation(); ok {
45+
if _, err := b.client.DownloadWeb(loc).Stream(ctx, counter); err != nil {
46+
return counter.n, asAPIError(err)
47+
}
48+
return counter.n, nil
49+
}
50+
return 0, &Error{Code: 400, Description: "Bad Request: file_id is not downloadable"}
51+
}
52+
53+
// DownloadFileToPath downloads the file referenced by file_id to a local path.
54+
func (b *Bot) DownloadFileToPath(ctx context.Context, fileID, path string) error {
55+
f, err := fileid.DecodeFileID(fileID)
56+
if err != nil {
57+
return &Error{Code: 400, Description: "Bad Request: invalid file_id"}
58+
}
59+
loc, ok := f.AsInputFileLocation()
60+
if !ok {
61+
return &Error{Code: 400, Description: "Bad Request: file_id is not downloadable"}
62+
}
63+
if _, err := b.client.Download(loc).ToPath(ctx, path); err != nil {
64+
return asAPIError(err)
65+
}
66+
return nil
67+
}
68+
69+
// countWriter counts bytes forwarded to the wrapped writer.
70+
type countWriter struct {
71+
w io.Writer
72+
n int64
73+
}
74+
75+
func (c *countWriter) Write(p []byte) (int, error) {
76+
n, err := c.w.Write(p)
77+
c.n += int64(n)
78+
return n, err
79+
}
80+
81+
// file_unique_id "unique types", matching the TDLib/Bot API scheme.
82+
const (
83+
uniqueTypeWeb = 0
84+
uniqueTypePhoto = 1
85+
uniqueTypeDocument = 2
86+
uniqueTypeSecure = 3
87+
uniqueTypeEncrypted = 4
88+
uniqueTypeTemp = 5
89+
)
90+
91+
// fileUniqueID derives the Bot API file_unique_id from a decoded file_id.
92+
//
93+
// It mirrors the TDLib algorithm: a little-endian record of the unique type and
94+
// the file's identifying fields, RLE-encoded over zero bytes and base64url
95+
// encoded without padding. Web and document-family files (documents, video,
96+
// audio, voice, stickers, animations, video notes) are exact; legacy photos use
97+
// their volume/local id. Newer photo-size sources that carry no volume id fall
98+
// back to the media id, which stays stable per file but may differ from the
99+
// value Telegram's own server would return.
100+
func fileUniqueID(f fileid.FileID) string {
101+
var buf []byte
102+
switch {
103+
case f.URL != "":
104+
buf = make([]byte, 4, 4+len(f.URL))
105+
binary.LittleEndian.PutUint32(buf, uniqueTypeWeb)
106+
buf = append(buf, f.URL...)
107+
case isPhotoType(f.Type) && f.PhotoSizeSource.VolumeID != 0:
108+
buf = make([]byte, 16)
109+
binary.LittleEndian.PutUint32(buf[0:], uniqueTypePhoto)
110+
binary.LittleEndian.PutUint64(buf[4:], uint64(f.PhotoSizeSource.VolumeID))
111+
binary.LittleEndian.PutUint32(buf[12:], uint32(int32(f.PhotoSizeSource.LocalID)))
112+
default:
113+
buf = make([]byte, 12)
114+
binary.LittleEndian.PutUint32(buf[0:], uint32(uniqueTypeFor(f.Type)))
115+
binary.LittleEndian.PutUint64(buf[4:], uint64(f.ID))
116+
}
117+
return base64.RawURLEncoding.EncodeToString(rleEncode(buf))
118+
}
119+
120+
func isPhotoType(t fileid.Type) bool {
121+
switch t {
122+
case fileid.Thumbnail, fileid.ProfilePhoto, fileid.Photo:
123+
return true
124+
default:
125+
return false
126+
}
127+
}
128+
129+
func uniqueTypeFor(t fileid.Type) int {
130+
switch t {
131+
case fileid.Thumbnail, fileid.ProfilePhoto, fileid.Photo:
132+
return uniqueTypePhoto
133+
case fileid.Secure, fileid.SecureRaw:
134+
return uniqueTypeSecure
135+
case fileid.Encrypted, fileid.EncryptedThumbnail:
136+
return uniqueTypeEncrypted
137+
case fileid.Temp:
138+
return uniqueTypeTemp
139+
default:
140+
return uniqueTypeDocument
141+
}
142+
}
143+
144+
// rleEncode run-length-encodes runs of zero bytes, matching the encoding TDLib
145+
// applies before base64url-encoding a file_unique_id. A copy of the unexported
146+
// helper in github.com/gotd/td/fileid.
147+
func rleEncode(s []byte) (r []byte) {
148+
var count byte
149+
for _, cur := range s {
150+
if cur == 0 {
151+
count++
152+
continue
153+
}
154+
if count > 0 {
155+
r = append(r, 0, count)
156+
count = 0
157+
}
158+
r = append(r, cur)
159+
}
160+
if count > 0 {
161+
r = append(r, 0, count)
162+
}
163+
return r
164+
}

file_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package botapi
2+
3+
import (
4+
"encoding/base64"
5+
"encoding/binary"
6+
"testing"
7+
8+
"github.com/gotd/td/fileid"
9+
)
10+
11+
func TestFileUniqueID_Document(t *testing.T) {
12+
f := fileid.FileID{Type: fileid.Document, ID: 0x1122334455667788}
13+
got := fileUniqueID(f)
14+
15+
// Expected: little-endian [uint32 type=2][int64 id], RLE-zero encoded, base64url.
16+
raw := make([]byte, 12)
17+
binary.LittleEndian.PutUint32(raw[0:], uniqueTypeDocument)
18+
binary.LittleEndian.PutUint64(raw[4:], 0x1122334455667788)
19+
want := base64.RawURLEncoding.EncodeToString(rleEncode(raw))
20+
21+
if got != want {
22+
t.Fatalf("got %q, want %q", got, want)
23+
}
24+
if got == "" || got == "todo" {
25+
t.Fatalf("unique id not derived: %q", got)
26+
}
27+
}
28+
29+
func TestFileUniqueID_Web(t *testing.T) {
30+
f := fileid.FileID{URL: "https://example.com/a.jpg"}
31+
got := fileUniqueID(f)
32+
if got == "" {
33+
t.Fatal("empty unique id for web file")
34+
}
35+
// Different URLs must yield different unique ids.
36+
other := fileUniqueID(fileid.FileID{URL: "https://example.com/b.jpg"})
37+
if got == other {
38+
t.Fatal("web unique ids collide across URLs")
39+
}
40+
}
41+
42+
func TestFileUniqueID_Stable(t *testing.T) {
43+
f := fileid.FileID{Type: fileid.Video, ID: 42}
44+
if a, b := fileUniqueID(f), fileUniqueID(f); a != b {
45+
t.Fatalf("unstable unique id: %q vs %q", a, b)
46+
}
47+
}
48+
49+
func TestGetFileInvalid(t *testing.T) {
50+
b := &Bot{}
51+
if _, err := b.GetFile(t.Context(), "not-a-valid-file-id"); err == nil {
52+
t.Fatal("expected error for invalid file_id")
53+
}
54+
}

0 commit comments

Comments
 (0)