Мы бекапим важные для нас данные, но почему бы не забекапить гифки из Телеграма?
Официальные клиенты такое не умеют, но открытый API позволит нам
автоматизировать это дело, избавив от необходимости скачивать гифки по одной.


В статье я расскажу, как это сделать на Go с помощью клиента gotd,
а для нетерпеливых дам сразу ссылку на готовую утилиту.


Регистрируем приложение


Чтобы делать прямые MTProto запросы к Telegram API, нужно иметь приложение.
На каждом аккаунте можно зарегистрировать только одно, но зато это довольно просто:


  1. Идем на https://my.telegram.org/apps
  2. Логинимся
  3. Создаем приложение, заполняя его название и описание
  4. Сохраняем api_id и api_hash, они нам пригодятся

Создаем клиент


Получив api_id и api_hash, можно записать их в переменные окружения
APP_ID и APP_HASH, тогда с помощью ClientFromEnvironment мы сможем начать работу с
Телеграмом из Go:


// Initializing client from environment.
// Available environment variables:
//  APP_ID:         api_id of Telegram app.
//  APP_HASH:       api_hash of Telegram app.
//  SESSION_FILE:   path to session file
//  SESSION_DIR:    path to session directory, if SESSION_FILE is not set
client, err := telegram.ClientFromEnvironment(telegram.Options{
    Logger: log,
})
if err != nil {
    return err
}

Чтобы каждый раз не логиниться (Телеграм такое не любит и довольно сильно ограничивает новые сессии),
лучше задать переменную SESSION_FILE, тогда ключ сессии будет сохранен и переиспользован при последующих
запусках.


Кстати, использовать ClientFromEnvironment не обязательно, никто не запрещает вызывать NewClient напрямую
и вручную задавать все параметры.


Логинимся


Очевидно, что нам нужно аутентифицироваться ровно так же, как это делают обычные клиенты:
ввести телефон, код подтверждения (и двухфакторный пароль, если есть).
В gotd для этого процесса есть интерфейс UserAuthenticator:


// UserAuthenticator asks user for phone, password and received authentication code.
type UserAuthenticator interface {
    Phone(ctx context.Context) (string, error)
    Password(ctx context.Context) (string, error)
    AcceptTermsOfService(ctx context.Context, tos tg.HelpTermsOfService) error
    SignUp(ctx context.Context) (UserInfo, error)
    Code(ctx context.Context, sentCode *tg.AuthSentCode) (string, error)
}

Мы пишем консольную утилиту, поэтому будем читать нужные данные из терминала:


// terminalAuth implements auth.UserAuthenticator prompting the terminal for
// input.
type terminalAuth struct{}

func (terminalAuth) Phone(_ context.Context) (string, error) {
    fmt.Print("Enter phone: ")
    code, err := bufio.NewReader(os.Stdin).ReadString('\n')
    if err != nil {
        return "", err
    }
    return strings.TrimSpace(code), nil
}

Аналогично для Code и Phone. Для Password лучше использовать пакет golang.org/x/crypto/ssh/terminal:


bytePwd, err := terminal.ReadPassword(syscall.Stdin)

Таким образом, ввод пароля не будет печататься в терминале.
Для методов AcceptTermsOfService и SignUp можно просто возвращать ошибку или вызывать panic(),
мы собираемся использовать только существующие аккаунты и эти методы вызываться не будут.


Чтобы использовать этот способ ввода данных, сконструируем flow аутентификации:


// Setting up authentication flow.
// Current flow will read phone, code and 2FA password from terminal.
flow := auth.NewFlow(terminalAuth{}, auth.SendCodeOptions{})

Этот flow управляет всем процессом аутентификации, вызывая методы из terminalAuth
по необходимости, например при запросе кода.


Делаем первый запрос


Теперь у нас почти всё готово для того, чтобы пройти аутентификацию и начать делать запросы:


client, err := telegram.ClientFromEnvironment(telegram.Options{
    Logger: log,
})
if err != nil {
    return err
}
flow := auth.NewFlow(terminalAuth{}, auth.SendCodeOptions{})
client.Run(ctx, func(ctx context.Context) error {
    // Perform auth if no session is available.
    if err := client.Auth().IfNecessary(ctx, flow); err != nil {
        return xerrors.Errorf("auth: %w", err)
    }

    // Now we can make API calls.

    return nil
}

После того как мы вызвали client.Auth().IfNecessary(ctx, flow), наш клиент
должен быть уже аутентифицирован под нужным аккаунтом и можно начинать сохранять гифки.


Для этого нам потребуется метод messages.getSavedGifs и
и его автоматически сгенерированный брат-близнец на Go, tg.Client.MessagesGetSavedGifs.


Чтобы непосредственно начать делать сырые запросы, нам нужно инициализировать сгенерированную обертку над клиентом:


api := client.API()

Наконец-то, теперь можно запросить все наши гифки:


result, err := api.MessagesGetSavedGifs(ctx, 0)
if err != nil {
    return xerrors.Errorf("get: %w", err)
}

В ответ мы получаем MessagesSavedGifsClass, одним из конструкторов которого будет MessagesSavedGifs:


type MessagesSavedGifs struct {
    // Hash for pagination, for more info click here?
    //
    // Links:
    //  1) https://core.telegram.org/api/offsets#hash-generation
    Hash int
    // List of saved gifs
    Gifs []DocumentClass
}

Осталось взять все элементы из Gifs и скачать их.


Скачиваем


Для скачивания есть пакет downloader, которым мы и воспользуемся.


d := downloader.NewDownloader()

На вход он принимает tg.InputFileLocationClass, мы должны привести DocumentClass к необходимому виду,
делается это примерно вот так:


result, err := api.MessagesGetSavedGifs(ctx, 0)
if err != nil {
    return xerrors.Errorf("get: %w", err)
}
switch result := result.(type) {
case *tg.MessagesSavedGifsNotModified:
    // Done.
    return nil
case *tg.MessagesSavedGifs:
    for _, doc := range result.Gifs {
        doc, ok := doc.AsNotEmpty()
        if !ok {
            continue
        }

        loc := doc.AsInputDocumentFileLocation()
        if _, err := d.Download(api, loc).ToPath(ctx, fmt.Sprintf("%d.mp4", doc.ID)); err != nil {
            return xerrors.Errorf("download: %w", err)
        }
    }
}

Выглядит сложно, т.к. мы взаимодействуем напрямую с API телеграма и такое обычно требует
приведения одних типов к другим.


Сначала мы взяли doc.AsNotEmpty(), потом doc.AsInputDocumentFileLocation(), и подходящий
интерфейс уже позволяет начать загрузку через d.Download().


Метод ToPath сохраняет файл на диск по выбранному пути, а значит мы уже решили поставленную задачу.


В итоге у нас получится что-то вроде такого:


client, err := telegram.ClientFromEnvironment(telegram.Options{
    Logger: log,
})
if err != nil {
    return err
}

flow := auth.NewFlow(terminalAuth{}, auth.SendCodeOptions{})
api := client.API()
d := downloader.NewDownloader()

return client.Run(ctx, func (ctx context.Context) error {
    if err := client.Auth().IfNecessary(ctx, flow); err != nil {
        return xerrors.Errorf("auth: %w", err)
    }

    result, err := api.MessagesGetSavedGifs(ctx, 0)
    if err != nil {
        return xerrors.Errorf("get: %w", err)
    }
    switch result := result.(type) {
    case *tg.MessagesSavedGifsNotModified:
        return nil
    case *tg.MessagesSavedGifs:
        for _, doc := range result.Gifs {
            doc, ok := doc.AsNotEmpty()
            if !ok {
                continue
            }

            loc := doc.AsInputDocumentFileLocation()
            if _, err := d.Download(api, loc).ToPath(ctx, fmt.Sprintf("%d.mp4", doc.ID)); err != nil {
                return xerrors.Errorf("download: %w", err)
            }
        }
    }
    return nil
})

После выполнения этой программы мы получим все сохраненные гифки в текущей директории.


Удаляем


Удостоверившись, что скачивание надежно работает, гифки можно и удалить.
Это делается через messages.saveGif с флагом unsave:


if _, err := api.MessagesSaveGif(ctx, &tg.MessagesSaveGifRequest{
    ID:     doc.AsInput(),
    Unsave: true,
}); err != nil {
    return xerrors.Errorf("remove: %w", err)
}

После того как мы удалим все доступные нам 200 гифок, нас может поджидать сюрприз:
в списке "Saved gifs" откуда-то появились другие гифки, а не ожидаемая пустота.


Дело в том, что сервер может не удалять выходящие за лимит гифки из этого списка,
а только скрывать, поэтому запусков может понадобиться несколько (либо делать удаление
в цикле).


Загружаем обратно


Бекапы без возможности восстановления — довольно странная штука. Но загружать в телеграм гифки
сложнее, чем оттуда их выкачивать, потому что прямого способа это сделать нет.
Зато есть "альтернативный":


  1. Загрузить файл
  2. Отослать его себе в "Избранное"
  3. Сохранить его себе в гифки
  4. Удалить лишнее сообщение (по желанию)

Для загрузки файлов будем использовать пакет uploader:


u := uploader.NewUploader(api)
f, err := u.FromPath(ctx, name)
if err != nil {
    return err
}

Теперь в f доступен загруженный файл, который можно прикрепить к сообщению.


Для отправки сообщений в gotd есть пакет messages:


sender := message.NewSender(api).Self()

Телеграм признаёт за гифки файлы с mime-type video/mp4 и атрибутом documentAttributeAnimated,
загрузим файл f подходящим для этого образом:


sender.Media(ctx, message.UploadedDocument(f).
    Attributes(&tg.DocumentAttributeAnimated{}).
    MIME("video/mp4"),
)

Проблема в том, что sender.Media возвращает tg.UpdatesClass, откуда нам нужно как-то
вытащить отправленное сообщение, которое Телеграм присылает в качестве результата вызова метода.
Для этого есть пакет message/unpack:


msg, err := unpack.Message(sender.Media(ctx, message.UploadedDocument(f).
    Attributes(&tg.DocumentAttributeAnimated{}).
    MIME("video/mp4"),
))

Теперь в msg хранится отправленное самим себе сообщение с прикрепленной гифкой, которую мы сохраним себе,
немного поупражнявшись в ручном привидении типов:


doc, ok := msg.Media.(*tg.MessageMediaDocument).Document.AsNotEmpty()
if !ok {
    return xerrors.New("unexpected document")
}

_, saveErr := api.MessagesSaveGif(ctx, &tg.MessagesSaveGifRequest{
    ID:     doc.AsInput(),
})

Теперь ненужное сообщение можно и удалить:


if _, err := sender.Revoke().Messages(ctx, msg.ID); err != nil {
    return xerrors.Errorf("delete: %w", err)
}

Итоги


На этом всё, готовая консольная утилита с комментариями в коде доступна на гитхабе.


Видно, что даже при наличии пакетов-хелперов, работа с сырым API Телеграма не очень проста.


К счастью, разработка gotd продолжается, и это можно будет делать намного проще.