cmd/stickerfetch/main.go
Rama main · réplica de lectura del repositorio original.
// Command stickerfetch pulls sticker sets from official Telegram
// (messages.getStickerSet) and writes them as a seed directory format that telesrv
// SeedMedia can import directly: <out>/<setdir>/set_info.json + stickers/<docid>.tgs.
// This way masks / custom-emoji / system sets etc. only need their seed files
// placed in data/sticker-seed to be recognized and served by the existing
// SeedMedia + handler, with no server code change.
//
// Supported set specs (command-line arguments, can be multiple):
//
// short:<shortName> pull by short_name (normal/mask/emoji sets)
// emoji_default_statuses inputStickerSetEmojiDefaultStatuses
// emoji_channel_default_statuses inputStickerSetEmojiChannelDefaultStatuses
// emoji_default_topic_icons inputStickerSetEmojiDefaultTopicIcons
// premium_gifts inputStickerSetPremiumGifts
// ton_gifts inputStickerSetTonGifts
// id:<id>:<access_hash> inputStickerSetID (any set, e.g. from featured lists)
// dice:<emoji> inputStickerSetDice
// animated_emoji inputStickerSetAnimatedEmoji
// animated_emoji_animations inputStickerSetAnimatedEmojiAnimations
// emoji_generic_animations inputStickerSetEmojiGenericAnimations
// effects messages.getAvailableEffects
// reactions messages.getAvailableReactions -> telegram_reactions_export/
// featured_emoji every set from messages.getFeaturedEmojiStickers
// featured_stickers every set from messages.getFeaturedStickers
//
// Documents are exported with their thumbs: Android renders .tgs stickers and
// custom emoji as such only when thumbs are present.
//
// Login session required (reuses appearancefetch's /tmp/appearance.session, overridable via SESSION env).
// TG_API_ID / TG_API_HASH override the built-in TDesktop credentials, which
// Telegram may answer with API_ID_PUBLISHED_FLOOD.
//
// Usage: SESSION=/tmp/appearance.session stickerfetch [-timeout 12m] <out_dir> <spec> [spec...]
package main
import (
"context"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/iamxvbaba/td/telegram"
"github.com/iamxvbaba/td/telegram/downloader"
"github.com/iamxvbaba/td/tg"
)
const (
tdesktopAPIID = 17349
tdesktopAPIHash = "344583e45741c457fe1862106095a5eb"
)
func sessionPath() string {
if p := os.Getenv("SESSION"); p != "" {
return p
}
return "/tmp/appearance.session"
}
func specToInput(spec string) (tg.InputStickerSetClass, string, error) {
switch {
case strings.HasPrefix(spec, "short:"):
name := strings.TrimPrefix(spec, "short:")
return &tg.InputStickerSetShortName{ShortName: name}, name, nil
case strings.HasPrefix(spec, "id:"):
parts := strings.Split(strings.TrimPrefix(spec, "id:"), ":")
if len(parts) != 2 {
return nil, "", fmt.Errorf("bad spec %q, want id:<id>:<access_hash>", spec)
}
id, err1 := strconv.ParseInt(parts[0], 10, 64)
hash, err2 := strconv.ParseInt(parts[1], 10, 64)
if err1 != nil || err2 != nil {
return nil, "", fmt.Errorf("bad spec %q", spec)
}
return &tg.InputStickerSetID{ID: id, AccessHash: hash}, "", nil
case strings.HasPrefix(spec, "dice:"):
emoticon := strings.TrimPrefix(spec, "dice:")
return &tg.InputStickerSetDice{Emoticon: emoticon}, "Dice", nil
case spec == "animated_emoji":
return &tg.InputStickerSetAnimatedEmoji{}, "AnimatedEmoji", nil
case spec == "animated_emoji_animations":
return &tg.InputStickerSetAnimatedEmojiAnimations{}, "AnimatedEmojiAnimations", nil
case spec == "emoji_generic_animations":
return &tg.InputStickerSetEmojiGenericAnimations{}, "EmojiGenericAnimations", nil
case spec == "emoji_default_statuses":
return &tg.InputStickerSetEmojiDefaultStatuses{}, "EmojiDefaultStatuses", nil
case spec == "emoji_channel_default_statuses":
return &tg.InputStickerSetEmojiChannelDefaultStatuses{}, "EmojiChannelDefaultStatuses", nil
case spec == "emoji_default_topic_icons":
return &tg.InputStickerSetEmojiDefaultTopicIcons{}, "EmojiDefaultTopicIcons", nil
case spec == "premium_gifts":
return &tg.InputStickerSetPremiumGifts{}, "PremiumGifts", nil
case spec == "ton_gifts":
return &tg.InputStickerSetTonGifts{}, "TonGifts", nil
default:
return nil, "", fmt.Errorf("unknown spec %q", spec)
}
}
// ---- seed JSON structures (fields aligned with internal/app/files/seed.go's parsing) ----
type attrJSON struct {
Type string `json:"_"`
W int `json:"w,omitempty"`
H int `json:"h,omitempty"`
Alt string `json:"alt,omitempty"`
Mask bool `json:"mask,omitempty"`
Free bool `json:"free,omitempty"`
TextColor bool `json:"text_color,omitempty"`
FileName string `json:"file_name,omitempty"`
Duration float64 `json:"duration,omitempty"`
RoundMessage bool `json:"round_message,omitempty"`
SupportsStreaming bool `json:"supports_streaming,omitempty"`
Voice bool `json:"voice,omitempty"`
Title string `json:"title,omitempty"`
Performer string `json:"performer,omitempty"`
Stickerset *inputSetRefJSON `json:"stickerset,omitempty"`
}
type inputSetRefJSON struct {
ID int64 `json:"id"`
AccessHash int64 `json:"access_hash"`
}
type documentJSON struct {
ID int64 `json:"id"`
AccessHash int64 `json:"access_hash"`
FileReference string `json:"file_reference"`
Date string `json:"date"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
DCID int `json:"dc_id"`
Attributes []attrJSON `json:"attributes"`
Thumbs []thumbJSON `json:"thumbs,omitempty"`
}
// thumbJSON mirrors internal/app/files seedThumbJSON. Inline sizes carry their
// bytes as hex; downloadable PhotoSize files sit next to the document as
// <docid>_thumb<N>_PhotoSize_type<t>.jpg (the name SeedMedia's index expects;
// the content may be WebP, the server sniffs the MIME type).
type thumbJSON struct {
Type string `json:"_"`
SizeType string `json:"type"`
W int `json:"w,omitempty"`
H int `json:"h,omitempty"`
Size int `json:"size,omitempty"`
Bytes string `json:"bytes,omitempty"`
Sizes []int `json:"sizes,omitempty"`
}
type packJSON struct {
Emoticon string `json:"emoticon"`
Documents []int64 `json:"documents"`
}
type setJSON struct {
ID int64 `json:"id"`
AccessHash int64 `json:"access_hash"`
Title string `json:"title"`
ShortName string `json:"short_name"`
Count int `json:"count"`
Hash int `json:"hash"`
Official bool `json:"official"`
Masks bool `json:"masks"`
Emojis bool `json:"emojis"`
Thumbs []thumbJSON `json:"thumbs,omitempty"`
ThumbDCID int `json:"thumb_dc_id,omitempty"`
ThumbVersion int `json:"thumb_version,omitempty"`
ThumbDocumentID *int64 `json:"thumb_document_id,omitempty"`
}
type resultJSON struct {
Type string `json:"_"`
Set setJSON `json:"set"`
Packs []packJSON `json:"packs"`
Documents []documentJSON `json:"documents"`
}
type fileJSON struct {
APICall string `json:"api_call"`
InputSetType string `json:"input_set_type"`
Result resultJSON `json:"result"`
}
func mapAttrs(in []tg.DocumentAttributeClass) []attrJSON {
out := make([]attrJSON, 0, len(in))
for _, a := range in {
switch v := a.(type) {
case *tg.DocumentAttributeImageSize:
out = append(out, attrJSON{Type: "DocumentAttributeImageSize", W: v.W, H: v.H})
case *tg.DocumentAttributeAnimated:
out = append(out, attrJSON{Type: "DocumentAttributeAnimated"})
case *tg.DocumentAttributeSticker:
aj := attrJSON{Type: "DocumentAttributeSticker", Alt: v.Alt, Mask: v.Mask}
if id, ok := v.Stickerset.(*tg.InputStickerSetID); ok {
aj.Stickerset = &inputSetRefJSON{ID: id.ID, AccessHash: id.AccessHash}
}
out = append(out, aj)
case *tg.DocumentAttributeCustomEmoji:
aj := attrJSON{Type: "DocumentAttributeCustomEmoji", Alt: v.Alt, Free: v.Free, TextColor: v.TextColor}
if id, ok := v.Stickerset.(*tg.InputStickerSetID); ok {
aj.Stickerset = &inputSetRefJSON{ID: id.ID, AccessHash: id.AccessHash}
}
out = append(out, aj)
case *tg.DocumentAttributeVideo:
out = append(out, attrJSON{Type: "DocumentAttributeVideo", W: v.W, H: v.H, Duration: v.Duration, RoundMessage: v.RoundMessage, SupportsStreaming: v.SupportsStreaming})
case *tg.DocumentAttributeFilename:
out = append(out, attrJSON{Type: "DocumentAttributeFilename", FileName: v.FileName})
}
}
return out
}
const usageSpecs = "short:<name> | id:<id>:<access_hash> | emoji_default_statuses | emoji_channel_default_statuses |\n" +
" emoji_default_topic_icons | premium_gifts | ton_gifts | dice:<emoji> | animated_emoji |\n" +
" animated_emoji_animations | emoji_generic_animations | effects | reactions | featured_emoji | featured_stickers"
func main() {
timeout := flag.Duration("timeout", 12*time.Minute, "overall run timeout; featured_emoji/featured_stickers exports can take hours")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: SESSION=/tmp/appearance.session stickerfetch [-timeout 12m] <out_dir> <spec> [spec...]\n spec: %s\n", usageSpecs)
flag.PrintDefaults()
}
flag.Parse()
if flag.NArg() < 2 {
flag.Usage()
os.Exit(2)
}
out := flag.Arg(0)
specs := flag.Args()[1:]
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
id, hash := apiCredentials(tdesktopAPIID, tdesktopAPIHash)
client := telegram.NewClient(id, hash, telegram.Options{
SessionStorage: &telegram.FileSessionStorage{Path: sessionPath()},
})
if err := client.Run(ctx, func(ctx context.Context) error {
api := client.API()
if status, err := client.Auth().Status(ctx); err != nil || !status.Authorized {
return fmt.Errorf("session %s unauthorized (log in with appearancefetch first): %v", sessionPath(), err)
}
dl := downloader.NewDownloader()
for _, spec := range specs {
var err error
if spec == "effects" {
err = fetchEffects(ctx, api, dl, out)
} else if spec == "reactions" {
err = fetchReactions(ctx, api, dl, out)
} else if spec == "featured_emoji" || spec == "featured_stickers" {
err = fetchFeatured(ctx, api, dl, out, spec == "featured_emoji")
} else {
err = fetchSet(ctx, api, dl, out, spec)
}
if err != nil {
fmt.Fprintf(os.Stderr, "skip %s: %v\n", spec, err)
}
}
return nil
}); err != nil {
fmt.Fprintln(os.Stderr, "ERROR:", err)
os.Exit(1)
}
}
func fetchSet(ctx context.Context, api *tg.Client, dl *downloader.Downloader, outRoot, spec string) error {
input, label, err := specToInput(spec)
if err != nil {
return err
}
res, err := api.MessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{Stickerset: input, Hash: 0})
if err != nil {
return fmt.Errorf("getStickerSet: %w", err)
}
full, ok := res.(*tg.MessagesStickerSet)
if !ok {
return fmt.Errorf("got %T, want messagesStickerSet", res)
}
setName := full.Set.ShortName
if setName == "" {
setName = label
}
setDir := filepath.Join(outRoot, fmt.Sprintf("%s_%d", setName, full.Set.ID))
stickersDir := filepath.Join(setDir, "stickers")
if err := os.MkdirAll(stickersDir, 0o755); err != nil {
return err
}
fmt.Printf("[%s] set=%q id=%d docs=%d downloading...\n", spec, setName, full.Set.ID, len(full.Documents))
docs := make([]documentJSON, 0, len(full.Documents))
for i, d := range full.Documents {
doc, ok := d.(*tg.Document)
if !ok {
continue
}
// Download the body blob → stickers/<docid>.<ext>
ext := ".tgs"
if doc.MimeType == "video/webm" {
ext = ".webm"
} else if doc.MimeType == "image/webp" {
ext = ".webp"
}
path := filepath.Join(stickersDir, fmt.Sprintf("%d%s", doc.ID, ext))
if !completeExistingFile(path, doc.Size) {
loc := &tg.InputDocumentFileLocation{ID: doc.ID, AccessHash: doc.AccessHash, FileReference: doc.FileReference}
if err := downloadToFile(ctx, dl, api, loc, stickersDir, path); err != nil {
return fmt.Errorf("download doc %d (%d/%d): %w", doc.ID, i+1, len(full.Documents), err)
}
}
thumbs, err := exportThumbs(ctx, fetcher(api, dl), stickersDir, doc)
if err != nil {
return fmt.Errorf("thumbs of doc %d: %w", doc.ID, err)
}
docs = append(docs, documentJSON{
ID: doc.ID,
AccessHash: doc.AccessHash,
FileReference: hex.EncodeToString(doc.FileReference),
Date: time.Unix(int64(doc.Date), 0).UTC().Format(time.RFC3339),
MimeType: doc.MimeType,
Size: doc.Size,
DCID: doc.DCID,
Attributes: mapAttrs(doc.Attributes),
Thumbs: thumbs,
})
if (i+1)%25 == 0 || i+1 == len(full.Documents) {
fmt.Printf("[%s] docs %d/%d\n", spec, i+1, len(full.Documents))
}
}
packs := make([]packJSON, 0, len(full.Packs))
for _, p := range full.Packs {
packs = append(packs, packJSON{Emoticon: p.Emoticon, Documents: append([]int64(nil), p.Documents...)})
}
out := fileJSON{
APICall: "messages.getStickerSet",
InputSetType: fmt.Sprintf("%T", input),
Result: resultJSON{
Type: "StickerSet",
Set: setJSON{
ID: full.Set.ID,
AccessHash: full.Set.AccessHash,
Title: full.Set.Title,
ShortName: full.Set.ShortName,
Count: full.Set.Count,
Hash: full.Set.Hash,
Official: full.Set.Official,
Masks: full.Set.Masks,
Emojis: full.Set.Emojis,
Thumbs: inlineThumbs(full.Set.Thumbs),
ThumbDCID: full.Set.ThumbDCID,
ThumbVersion: full.Set.ThumbVersion,
ThumbDocumentID: optionalInt64(full.Set.GetThumbDocumentID()),
},
Packs: packs,
Documents: docs,
},
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(setDir, "set_info.json"), append(b, '\n'), 0o644); err != nil {
return err
}
fmt.Printf("[%s] set=%q id=%d docs=%d masks=%v emojis=%v\n", spec, setName, full.Set.ID, len(docs), full.Set.Masks, full.Set.Emojis)
return nil
}
func completeExistingFile(path string, wantSize int64) bool {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return false
}
if wantSize > 0 {
return info.Size() == wantSize
}
return info.Size() > 0
}
// fetchFunc stores one file location at path. Production uses downloadToFile;
// tests substitute a fake so the export format can be checked offline.
type fetchFunc func(ctx context.Context, loc tg.InputFileLocationClass, dir, path string) error
func fetcher(api *tg.Client, dl *downloader.Downloader) fetchFunc {
return func(ctx context.Context, loc tg.InputFileLocationClass, dir, path string) error {
return downloadToFile(ctx, dl, api, loc, dir, path)
}
}
func downloadToFile(ctx context.Context, dl *downloader.Downloader, api *tg.Client, loc tg.InputFileLocationClass, dir, path string) error {
tmp, err := os.CreateTemp(dir, ".download-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer func() {
_ = os.Remove(tmpPath)
}()
if _, err := dl.Download(api, loc).Stream(ctx, tmp); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
// os.CreateTemp creates 0600 files; seeds are read by the server under another uid.
if err := os.Chmod(tmpPath, 0o644); err != nil {
return err
}
_ = os.Remove(path)
return os.Rename(tmpPath, path)
}
// ---- Message effects (messages.getAvailableEffects) ----
type effectJSON struct {
ID int64 `json:"id"`
Emoticon string `json:"emoticon"`
StaticIconID int64 `json:"static_icon_id,omitempty"`
EffectStickerID int64 `json:"effect_sticker_id"`
EffectAnimationID int64 `json:"effect_animation_id,omitempty"`
PremiumRequired bool `json:"premium_required,omitempty"`
}
type effectsFileJSON struct {
APICall string `json:"api_call"`
Result struct {
Effects []effectJSON `json:"effects"`
Documents []documentJSON `json:"documents"`
} `json:"result"`
}
// downloadDoc downloads a document body to dir/<docid>.<ext> and returns its seed metadata.
func downloadDoc(ctx context.Context, fetch fetchFunc, dir string, doc *tg.Document) (documentJSON, error) {
ext := ".tgs"
switch doc.MimeType {
case "video/webm":
ext = ".webm"
case "image/webp":
ext = ".webp"
}
path := filepath.Join(dir, fmt.Sprintf("%d%s", doc.ID, ext))
if !completeExistingFile(path, doc.Size) {
loc := &tg.InputDocumentFileLocation{ID: doc.ID, AccessHash: doc.AccessHash, FileReference: doc.FileReference}
if err := fetch(ctx, loc, dir, path); err != nil {
return documentJSON{}, fmt.Errorf("download doc %d: %w", doc.ID, err)
}
}
thumbs, err := exportThumbs(ctx, fetch, dir, doc)
if err != nil {
return documentJSON{}, fmt.Errorf("thumbs of doc %d: %w", doc.ID, err)
}
return documentJSON{
ID: doc.ID,
AccessHash: doc.AccessHash,
FileReference: hex.EncodeToString(doc.FileReference),
Date: time.Unix(int64(doc.Date), 0).UTC().Format(time.RFC3339),
MimeType: doc.MimeType,
Size: doc.Size,
DCID: doc.DCID,
Attributes: mapAttrs(doc.Attributes),
Thumbs: thumbs,
}, nil
}
func fetchEffects(ctx context.Context, api *tg.Client, dl *downloader.Downloader, outRoot string) error {
fetch := fetcher(api, dl)
res, err := api.MessagesGetAvailableEffects(ctx, 0)
if err != nil {
return fmt.Errorf("getAvailableEffects: %w", err)
}
full, ok := res.(*tg.MessagesAvailableEffects)
if !ok {
return fmt.Errorf("got %T, want messagesAvailableEffects", res)
}
dir := filepath.Join(outRoot, "telegram_effects_export")
docsDir := filepath.Join(dir, "documents")
if err := os.MkdirAll(docsDir, 0o755); err != nil {
return err
}
var out effectsFileJSON
out.APICall = "messages.getAvailableEffects"
for _, d := range full.Documents {
doc, ok := d.(*tg.Document)
if !ok {
continue
}
dj, err := downloadDoc(ctx, fetch, docsDir, doc)
if err != nil {
return err
}
out.Result.Documents = append(out.Result.Documents, dj)
}
for _, e := range full.Effects {
ej := effectJSON{ID: e.ID, Emoticon: e.Emoticon, EffectStickerID: e.EffectStickerID, PremiumRequired: e.PremiumRequired}
if v, ok := e.GetStaticIconID(); ok {
ej.StaticIconID = v
}
if v, ok := e.GetEffectAnimationID(); ok {
ej.EffectAnimationID = v
}
out.Result.Effects = append(out.Result.Effects, ej)
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "effects.json"), append(b, '\n'), 0o644); err != nil {
return err
}
fmt.Printf("[effects] effects=%d documents=%d\n", len(out.Result.Effects), len(out.Result.Documents))
return nil
}
// ---- Available reactions (messages.getAvailableReactions) ----
// Written in the layout SeedMedia.seedReactions reads:
// <out>/telegram_reactions_export/global_json/available_reactions_raw.json + reactions/<docid>.<ext>
type reactionJSON struct {
Reaction string `json:"reaction"`
Title string `json:"title"`
Inactive bool `json:"inactive,omitempty"`
Premium bool `json:"premium,omitempty"`
StaticIcon *documentJSON `json:"static_icon,omitempty"`
AppearAnimation *documentJSON `json:"appear_animation,omitempty"`
SelectAnimation *documentJSON `json:"select_animation,omitempty"`
ActivateAnimation *documentJSON `json:"activate_animation,omitempty"`
EffectAnimation *documentJSON `json:"effect_animation,omitempty"`
AroundAnimation *documentJSON `json:"around_animation,omitempty"`
CenterIcon *documentJSON `json:"center_icon,omitempty"`
}
func fetchReactions(ctx context.Context, api *tg.Client, dl *downloader.Downloader, outRoot string) error {
res, err := api.MessagesGetAvailableReactions(ctx, 0)
if err != nil {
return fmt.Errorf("getAvailableReactions: %w", err)
}
full, ok := res.(*tg.MessagesAvailableReactions)
if !ok {
return fmt.Errorf("got %T, want messagesAvailableReactions", res)
}
return writeReactionsExport(ctx, fetcher(api, dl), outRoot, full.Reactions)
}
// writeReactionsExport writes the layout seedReactions imports:
// telegram_reactions_export/global_json/available_reactions_raw.json plus the
// documents and their thumbs under telegram_reactions_export/reactions.
func writeReactionsExport(ctx context.Context, fetch fetchFunc, outRoot string, list []tg.AvailableReaction) error {
dir := filepath.Join(outRoot, "telegram_reactions_export")
docsDir := filepath.Join(dir, "reactions")
if err := os.MkdirAll(docsDir, 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(dir, "global_json"), 0o755); err != nil {
return err
}
seen := map[int64]*documentJSON{}
get := func(d tg.DocumentClass) (*documentJSON, error) {
doc, ok := d.(*tg.Document)
if !ok {
return nil, nil
}
if dj, ok := seen[doc.ID]; ok {
return dj, nil
}
dj, err := downloadDoc(ctx, fetch, docsDir, doc)
if err != nil {
return nil, err
}
seen[doc.ID] = &dj
return &dj, nil
}
reactions := make([]reactionJSON, 0, len(list))
for i, r := range list {
rj := reactionJSON{Reaction: r.Reaction, Title: r.Title, Inactive: r.Inactive, Premium: r.Premium}
docs := []struct {
dst **documentJSON
src tg.DocumentClass
}{
{&rj.StaticIcon, r.StaticIcon},
{&rj.AppearAnimation, r.AppearAnimation},
{&rj.SelectAnimation, r.SelectAnimation},
{&rj.ActivateAnimation, r.ActivateAnimation},
{&rj.EffectAnimation, r.EffectAnimation},
}
if v, ok := r.GetAroundAnimation(); ok {
docs = append(docs, struct {
dst **documentJSON
src tg.DocumentClass
}{&rj.AroundAnimation, v})
}
if v, ok := r.GetCenterIcon(); ok {
docs = append(docs, struct {
dst **documentJSON
src tg.DocumentClass
}{&rj.CenterIcon, v})
}
for _, d := range docs {
if d.src == nil {
continue
}
dj, err := get(d.src)
if err != nil {
return fmt.Errorf("reaction %s: %w", r.Reaction, err)
}
*d.dst = dj
}
reactions = append(reactions, rj)
fmt.Printf("[reactions] %d/%d %s\n", i+1, len(list), r.Reaction)
}
b, err := json.MarshalIndent(map[string]any{"result": map[string]any{"reactions": reactions}}, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "global_json", "available_reactions_raw.json"), append(b, '\n'), 0o644); err != nil {
return err
}
fmt.Printf("[reactions] reactions=%d documents=%d\n", len(reactions), len(seen))
return nil
}
// fetchFeatured pulls the sticker/emoji sets Telegram currently features
// (messages.getFeatured[Emoji]Stickers), exporting each by ID.
func fetchFeatured(ctx context.Context, api *tg.Client, dl *downloader.Downloader, outRoot string, emoji bool) error {
var res tg.MessagesFeaturedStickersClass
var err error
if emoji {
res, err = api.MessagesGetFeaturedEmojiStickers(ctx, 0)
} else {
res, err = api.MessagesGetFeaturedStickers(ctx, 0)
}
if err != nil {
return fmt.Errorf("getFeatured: %w", err)
}
full, ok := res.(*tg.MessagesFeaturedStickers)
if !ok {
return fmt.Errorf("got %T, want messagesFeaturedStickers", res)
}
fmt.Printf("[featured] emoji=%v sets=%d\n", emoji, len(full.Sets))
for _, covered := range full.Sets {
set := covered.GetSet()
spec := fmt.Sprintf("id:%d:%d", set.ID, set.AccessHash)
if err := fetchSet(ctx, api, dl, outRoot, spec); err != nil {
fmt.Fprintf(os.Stderr, "skip featured %s (%s): %v\n", set.ShortName, spec, err)
}
}
return nil
}
// exportThumbs maps a document's thumbs to seed JSON, downloading PhotoSize
// renditions next to the document. Android only renders .tgs stickers and
// custom emoji as such when thumbs are present, and the server draws emoji
// avatars from them, so they are not optional.
func exportThumbs(ctx context.Context, fetch fetchFunc, dir string, doc *tg.Document) ([]thumbJSON, error) {
out := make([]thumbJSON, 0, len(doc.Thumbs))
for i, th := range doc.Thumbs {
switch v := th.(type) {
case *tg.PhotoPathSize:
out = append(out, thumbJSON{Type: "PhotoPathSize", SizeType: v.Type, Bytes: hex.EncodeToString(v.Bytes)})
case *tg.PhotoStrippedSize:
out = append(out, thumbJSON{Type: "PhotoStrippedSize", SizeType: v.Type, Bytes: hex.EncodeToString(v.Bytes)})
case *tg.PhotoCachedSize:
out = append(out, thumbJSON{Type: "PhotoCachedSize", SizeType: v.Type, W: v.W, H: v.H, Bytes: hex.EncodeToString(v.Bytes)})
case *tg.PhotoSize, *tg.PhotoSizeProgressive:
var typ string
var w, h, size int
var sizes []int
kind := "PhotoSize"
if ps, ok := v.(*tg.PhotoSize); ok {
typ, w, h, size = ps.Type, ps.W, ps.H, ps.Size
} else {
pp := v.(*tg.PhotoSizeProgressive)
typ, w, h, sizes, kind = pp.Type, pp.W, pp.H, pp.Sizes, "PhotoSizeProgressive"
if len(sizes) > 0 {
size = sizes[len(sizes)-1]
}
}
path := filepath.Join(dir, fmt.Sprintf("%d_thumb%d_PhotoSize_type%s.jpg", doc.ID, i, typ))
if !completeExistingFile(path, int64(size)) {
loc := &tg.InputDocumentFileLocation{ID: doc.ID, AccessHash: doc.AccessHash, FileReference: doc.FileReference, ThumbSize: typ}
if err := fetch(ctx, loc, dir, path); err != nil {
return nil, fmt.Errorf("thumb %s: %w", typ, err)
}
}
out = append(out, thumbJSON{Type: kind, SizeType: typ, W: w, H: h, Size: size, Sizes: sizes})
}
}
return out, nil
}
// inlineThumbs keeps the set thumbs SeedMedia stores inline (path/stripped/cached).
func inlineThumbs(in []tg.PhotoSizeClass) []thumbJSON {
var out []thumbJSON
for _, th := range in {
switch v := th.(type) {
case *tg.PhotoPathSize:
out = append(out, thumbJSON{Type: "PhotoPathSize", SizeType: v.Type, Bytes: hex.EncodeToString(v.Bytes)})
case *tg.PhotoStrippedSize:
out = append(out, thumbJSON{Type: "PhotoStrippedSize", SizeType: v.Type, Bytes: hex.EncodeToString(v.Bytes)})
case *tg.PhotoCachedSize:
out = append(out, thumbJSON{Type: "PhotoCachedSize", SizeType: v.Type, W: v.W, H: v.H, Bytes: hex.EncodeToString(v.Bytes)})
}
}
return out
}
func optionalInt64(v int64, ok bool) *int64 {
if !ok {
return nil
}
return &v
}
// apiCredentials returns TG_API_ID/TG_API_HASH when set (the public TDesktop id
// is rate-limited with API_ID_PUBLISHED_FLOOD), otherwise the given defaults.
func apiCredentials(defID int, defHash string) (int, string) {
id, hash := defID, defHash
if v := strings.TrimSpace(os.Getenv("TG_API_ID")); v != "" {
if n, err := strconv.Atoi(v); err == nil {
id = n
}
}
if v := strings.TrimSpace(os.Getenv("TG_API_HASH")); v != "" {
hash = v
}
return id, hash
}