Ведение Telegram-канала с помощью GitHub Actions

qjzpctzyvhpoehjvj6jasakfla0.jpeg

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

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

И вот, среди прочих родилась простая идея: «А почему бы не автоматизировать отслеживание новых выпусков ИТ-подкастов с помощью Telegram-бота и GitHub Actions? Чтобы просто подписаться на telegram-канал и получать актуальные выпуски подкастов по мере их выхода.

Конечно, можно скачать специализированные приложения, типа «Poket Casts», либо подписаться на RSS, но лично для меня использование Telegram-канала является самым удобным, простым и привычным.

Так был создан telegram-канал @awesome_russian_podcasts, куда в автоматическом режиме публикуются новые выпуски множества ИТ-подкастов, собранных в моём репозитории. Собственно, о процессе создания этого канала (его техническую часть) я и хочу рассказать далее.


Используемые инструменты

Для решения задачи я использовал следующие инструменты:


  1. GitHub Actions;
  2. .NET Core Console App;
  3. Luandersonn.iTunesPodcastFinder — для получения данных о подкастах из iTunes;
  4. Telegram.Bot — для взаимодействия с API Telegram-бота;
  5. Git.

Пункты 2–4 можно легко заменить, чтобы реализовать описанный сценарий на удобном вам языке программирования. Кроме того, с Telegram API можно взаимодействовать напрямую через HTTP-запросы, как и с API iTunes.


GitHub Actions

tox62mifxfdj70cxnkdiyc2kwrs.png
Описание функционала GitHub Actions

С появлением GitHub Actions для разработчиков открылось новое поле для исследований и экспериментов, ведь этот инструмент позволяет автоматизировать вещи, которые ранее приходилось делать вручную или с помощью сторонних не всегда простых в освоении и интеграции сервисов.

Подробная документация (только на английском языке) будет понятна даже тем, кто никогда не работал с подобными инструментами. И времени на её изучение понадобится не очень много.

Для решения своей задачи я выбрал GitHub Actions по нескольким причинам:


  • Он бесплатный для публичных репозиториев;
  • Позволяет запускать «события» по таймеру;
  • Его легко изучить и адаптировать под новые задачи.

Идея состоит в том, что по заданному таймеру запускается Action-скрипт, сконфигурированный таким образом, что при изменении числа выпусков для каждого подкаста в репозитории, данные о новых выпусках публикуются в Telegram-канале через Telegram-бота, управляемого консольным .NET Core приложением.


Задание секретов репозитория

Сначала необходимо задать «секреты» (API-ключ Telegram-бота и id Telegram-канала, куда будут публиковаться новые выпуски) в репозитории с GitHub Action, как это указано на скриншоте:

wi5gndmxpv_rydiibr248tshev4.png

На самом деле, id Telegram-канала вы можете указать непосредственно в Action-скрипте или в приложении, но для решения однотипных задач лучше иметь возможность изменять эти данные.

Они будут использоваться для передачи в качестве параметров командной строки в консольное .NET Core приложение.


Получение API-ключа Telegram-бота

Для взаимодействия с Telegram-ботом необходимо его создать и получить специальный API-ключ. Сделать это очень просто.

В строке поиска Telegram необходимо ввести BotFather — это официальный Telegram-бот для создания своих ботов:

nlmhqiaslst9ioqgqjcbe-y2a0g.png

Для создания нового бота нужно в чате BotFather ввести команду /newbot, на что он попросит указать название нового бота:

w7lxdwhampijaxw4miyqp1h-pbg.png

Вы можете дать любое название вашему боту.

Далее BotFather попросит ввести username для нового бота. По сути, это будет его уникальный идентификатор в Telegram.

Здесь есть небольшие ограничения: username должен заканчиваться на bot (без учёта регистра) и он должен быть уникальным (если такой username уже существует, то вам не удастся создать нового бота):

meqonfaeoos73mskhdhhpe3hyia.png

В итоге ваш бот будет создан, а вам отобразится API-ключ для управления им извне (первая чать API-ключа — это идентификатор бота).

P.S. Если вы забыли API-ключ, его всегда можно посмотреть, введя команду /mybots в чате BotFather, выбрать нужного бота из списка и нажать кнопку «API Token». Там же можно обновить ключ в случае его компрометации.

Далее необходимо найти в строке поиска Telegram созданного бота по его username, перейти в него и нажать кнопку «Запустить». Теперь бот готов к работе:

otqag3enkcfcwcezolqy5bybedy.png


Получение id Telegram-канала

Чтобы Telegram-бот знал, куда ему отправлять сообщения, необходимо узнать id Telegram-канала для публикаций.

Для это необходимо добавить бота в созданный вами Telegram-канал (бота в канал можно добавить только в качестве администратора), написать какое-нибудь сообщение в этом канале, и вызвать HTTP GET-запрос: https://api.telegram.org/bot/getUpdates, где  — это API-ключ бота, полученный ранее:

jxffptgh7kgiehgthwo2dqpuhek.png

В поле id внутри chat получите необходимый id Telegram-канала.

P.S. Так как Telegram, к сожалению, всё ещё заблокирован на территории Российской Федерации, то указанный выше HTTP GET-запрос необходимо отправить через прокси. Например, можно воспользоваться браузером Opera и его встроенным прокси, как это сделал я.


Структура скрипта

Далее перейдём непосредственно к Action-скрипту.

О том, как создавать и редактировать скрипты подробно описано в документации. Разобраться в этом не составит труда. Здесь я лишь приведу свой скрипт с комментариями:

name: Update_Podcasts_Data      # название скрипта, которое будет отображаться во вкладке Actions репозитория

on:                             # действие, по которому запускается скрипт
  schedule:                     # в данном случае, это выполнение по таймеру
    - cron: '0 5-20/2 * * *'    # 'каждый день каждые 2 часа в часы с 5 по 20 по UTC+0', то есть в 5, 7, 9, 11, 13, 15, 17, 19 по UTC+0

jobs:                           # выполняемые в рамках скрипта работы
  build:

    runs-on: ubuntu-latest      # запускаем на образе последней версии ubuntu

    steps:                      # шаги, выполняемые после запуска образа
    - uses: actions/checkout@v2 # переходим в актуальную ветку
    - name: Setup .NET Core                                 # имя 1-ой работы
      uses: actions/setup-dotnet@v1                         # устанавливаем компоненты, необходимые для запуска .NET приложений
      with:                                                 # с параметрами:
        dotnet-version: 3.1.101                             # указываем конкретную версию устанавливаемых компонент .NET Core
    - name: Set chmod to Unchase.HtmlTagsUpdater            # имя 2-ой работы
      run: chmod 777 ./utils/Unchase.HtmlTagsUpdater        # выдаем права, необходимые для запуска и выполнения .NET Core Console App - "Unchase.HtmlTagsUpdater"
    - name: Run Unchase.HtmlTagsUpdater (with Telegram Bot) # имя 3-ей работы
      env:                                                  # задаём переменные среды для текущей работы
        TG_KEY: ${{ secrets.TgKey }}                        # TG_KEY берется из "секрета" репозитория с именем "TgKey" - это API-ключ для управления Telegram-ботом 
        TG_CHANNEL_ID: ${{ secrets.TgChannelId }}           # TG_CHANNEL_ID берется из "секрета" репозитория с именем "TgChannelId" - это id канала, куда будут публиковаться сообщения от бота

      # далее запускается консольное .NET Core приложение с передачей параметров командной строки:
      # -f - обрабатываемый файл ("Podcasts.md" - в нем содержится список ИТ-подкастов с указанием количества выпусков на момент предыдущей проверки)
      # -t - тип обрабатываемых данных (в моем приложении есть 2 типа: для подкастов - "iTunesPodcast", и для YouTube-каналов)
      # -a - API-ключ для Telegram-бота, который берётся из переменной среды TG_KEY
      # -c - id канала для публикации из переменной среды TG_CHANNEL_ID
      # -i - таймаут запроса Telegram.Bot в секундах
      # есть еще несколько дополнительных параметров, которые нет необходимости сейчас рассматривать
      run: ./utils/Unchase.HtmlTagsUpdater -f "Podcasts.md" -t "iTunesPodcast" -a "$TG_KEY" -c "$TG_CHANNEL_ID" -i "90"

    # следующие работы не относятся к взаимодействию с telegram-каналом, но необходимы для сохранения изменений файла "Podcasts.md" в исходном репозитории
    - name: Git set author (email)  # имя 4-ой работы
      run: /usr/bin/git config --global user.name "GitHub Action Unchase"   # задаем имя пользователя, от которого будет сделан commit

    - name: Git set author (email)  # имя 5-ой работы
      run: /usr/bin/git config --global user.email "spiritkola@hotmail.com" # задаем email пользователя, от которого будет сделан commit

    - name: Git add                 # имя 6-ой работы
      run: /usr/bin/git add Podcasts.md # добавляем (индексируем) изменённый файл для последующего commit                 

    - name: Git commit              # имя 7-ой работы
      run: /usr/bin/git commit -m "Update podcasts data"    # делаем commit

    - name: Git push
      run: /usr/bin/git push origin master  # делаем push с изменениями в исходный репозиторий

Как можно убедиться, сам Action-скрипт довольно прост. Его можно улучшить, например, добавив возможность не делать commit, если изменений не было. Но пока в этом нет необходимости. Вся полезная работа кроется в консольном .NET Core приложении «Unchase.HtmlTagsUpdater». Давайте посмотрим, что у него внутри.


.NET Core Console App

«Unchase.HtmlTagsUpdater» — это обычное консольное .NET Core приложение, в которое передаются заданные параметры командной строки. Здесь я приведу упрощенный код: без допоплнительных проверок, обработок, промежуточных частей и частей, не относящихся к задаче.

Для разбора параметров командной строки удобно использовать nuget-пакет CommandLineParser. Он позволяет поместить входные параметры приложения в заданный класс:

using System.Collections.Generic;
using System.IO;
using CommandLine;
using Telegram.Bot.Types;
using File = System.IO.File;

public enum UtilType
{
    YouTube,

    iTunesPodcast
}

public class Options
{
    [Option('f', "file", Required = true)]
    public string InputFile { get; set; }

    [Option('t', "type", Required = true)]
    public UtilType Type { get; set; }

    [Option('a', "tgapi", Required = false)]
    public string TelegramBotApiKey { get; set; }

    [Option('c', "tgchannel", Required = false)]
    public ChatId TelegramChannelId { get; set; }

    [Option('i', "tgtimeout", Required = false)]
    public int TelegramTimeout { get; set; }

    public string ReadAllTextFromInputFile()
    {
        if (!File.Exists(InputFile))
        {
            throw new FileNotFoundException("Input file does not exist!", InputFile);
        }
        return File.ReadAllText(InputFile);
    }

    public void WriteAllTextToInputFile(string text)
    {
        if (!File.Exists(InputFile))
        {
            throw new FileNotFoundException("Input file does not exist!", InputFile);
        }
        File.WriteAllText(InputFile, text);
    }
}

В основном методе Main приложения необходимо вызвать Parser.Default.ParseArguments:

internal static Options Options { get; private set; }

static void Main(string[] args)
{
    Console.WriteLine("Start!");

    // разбираем входные параметры командной строки, поместив их в Options
    var parseResult = Parser.Default.ParseArguments(args)
        .WithParsed(o =>
        {
            Options = o;
        });

    // если разбор параметров не был успешен, то завершаем работу программы
    if (Options == null || parseResult.Tag != ParserResultType.Parsed)
    {
        // сообщение об ошибке будет выведено в консоли GitHub Action
        Console.WriteLine("Error: Options was not parsed!");
        return;
    }

    //...

    // считываем текстовые данные из входного файла
    var text = Options.ReadAllTextFromInputFile();

    switch (Options.Type)
    {
        // ...
        case UtilType.iTunesPodcast:
            // обрабатываем данные iTunes-подкастов
            text = ProcessPodcasts(text);
            break;
    }

    // записываем изменённые текстовые данные в выходной файл
    Options.WriteAllTextToInputFile(text);

    Console.WriteLine("Done!");
}

Дальнейшаая обработка происходит в методах ProcessPodcasts и SendPodcastData:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using iTunesPodcastFinder;
using iTunesPodcastFinder.Models;
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;

// клиент для работы с API Telegram-бота
private static ITelegramBotClient _telegramBotClient;
internal static ITelegramBotClient TelegramBotClient
{
    get
    {
        if (_telegramBotClient != null)
        {
            return _telegramBotClient;
        }

        if (!string.IsNullOrWhiteSpace(Options.TelegramBotApiKey) && !string.IsNullOrWhiteSpace(Options.TelegramChannelId))
        {
            // создаём клиента для обращения к API Telegram-бота
            // если соединения с ботом нет, то, скорей всего необходимо передать в TelegramBotClient в качестве второго параметра какой-нибудь прокси
            // например, такой прокси можно задать с помощью nuget-пакета 'HttpToSocks5Proxy'
            // для работы из GitHub Actions прокси, к счастью, не требуется
            _telegramBotClient = new TelegramBotClient(Options.TelegramBotApiKey) { Timeout = new TimeSpan(0, 0, Options.TelegramTimeout) };
        }

        return _telegramBotClient;
    }
}

private static string ProcessPodcasts(string text)
{
    // получаем все span'ы из входного файла, в которых хранится количество эпизодов для каждого подкаста
    // сам метод возвращает коллекцию строк вида: '35 (0)'
    foreach (var span in GetSpans(text, "episodes"))
    {
        // метод возвращает значение id подкаста в iTunes. Например, '1120110650'
        var iTunesPodcastId = GetAttributeValue(span, "itunes-id");
        if (string.IsNullOrWhiteSpace(iTunesPodcastId))
            continue;

        try
        {
            // получаем данные о подкасте по его id в iTunes
            Podcast podcast = PodcastFinder.GetPodcastAsync(iTunesPodcastId).GetAwaiter().GetResult();
            if (podcast == null)
                continue;

            // получаем новое значение (с актуальным количеством эпизодов подкаста) для span'а
            var newValue = podcast.EpisodesCount.ToString();

            // отправляем данные о новых эпизодах в Telegram-канал
            SendPodcastData(podcast, span, newValue);

            // ...

            // заменяем количество эпизодов подкаста на новое
            // измененные данные будут записаны во входной файл
            text = text.Replace(span, SetSpanValue(span, newValue));
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error: {ex.Message}{Environment.NewLine}Podcast id = {iTunesPodcastId}");
        }
    }

    // возвращаем текстовые данные о подкастах с изменёнными новыми значениями
    return text;
}

// ...

private static void SendPodcastData(Podcast podcast, string span, string newValue)
{
    // получаем текущее значение количества эпизодов подкаста
    var currentSpanValue = GetSpanValue(span);
    if (long.TryParse(currentSpanValue, out var currentSpanLongValue) && long.TryParse(newValue, out var newSpanLongValue))
    {
        var diff = newSpanLongValue - currentSpanLongValue;

        // если количество эпизодов выросло...
        if (diff > 0)
        {
            try
            {
                // получаем список эпизодов подкаста
                var episodes = PodcastFinder.GetPodcastEpisodesAsync(podcast.FeedUrl).GetAwaiter().GetResult()
                    ?.Episodes?.OrderByDescending(e => e.PublishedDate)?.ToList();

                if (episodes?.Any() == true && episodes.Count >= diff)
                {
                    for (int i = (int)diff - 1; i >= 0; i--)
                    {
                        // формируем текст сообщения, публикуемого в Telegram-канале
                        var message = new StringBuilder();

                        // ...    

                        message.AppendLine("@awesome\\_russian\\_podcasts");

                        if (!string.IsNullOrWhiteSpace(Options.TelegramBotApiKey) && TelegramBotClient != null)
                        {
                            // отправляем сообщение в Telegram-канал через Telegram-бота
                            TelegramBotClient.SendPhotoAsync(Options.TelegramChannelId,    // id Telegram-канала
                                podcast.ArtWork,    // изображение подкаста в iTunes
                                $"{message.ToString()}",    // текст (данные о подкасте)
                                ParseMode.Markdown, // указываем, что текст передаётся в Markdown
                                true,   // отправлять push-уведомление о новом сообщении
                                // добавляем кнопки под сообщением
                                replyMarkup: new InlineKeyboardMarkup(new List {
                                    InlineKeyboardButton.WithUrl(
                                        "iTunes",
                                        podcast.ItunesLink),
                                    InlineKeyboardButton.WithUrl(
                                        "Episode",
                                        episodes[i].FileUrl.ToString()),
                                    InlineKeyboardButton.WithUrl(
                                        "Feed URL",
                                        podcast.FeedUrl)
                                })).GetAwaiter().GetResult();

                            // ...
                        }
                    }
                }
            }
            catch (Exception e)
            {
                var errorMessage = new StringBuilder();
                // формируем информативный текст ошибки для вывода в консоль
                // ...

                Console.WriteLine(errorMessage.ToString());
            }
        }
    }
}

Это всё, что минимально необходимо выполнить для решения поставленной задачи.


Вывод

GitHub Actions можно использовать как для работы с Telegram-каналами: публикация сообщений, модерация, интерактивное взаимодействие с участниками канала… Так и для множества других задач: CI/CD для ваших проектов, обновление статических страниц для GitHub Pages, взаимодействие с любыми сторонними сервисами по описанному сценарию и т.д.

Применений этому действительно полезному и удобному инструменту можно найти много. Я лишь постарался описать один из возможных вариантов.

Я убеждён, что каналы должны приносить пользу не только его создателям, поэтому если вам нравится слушать ИТ-подкасты, или вы в поисках новых знаний, — присоединяйтесь к каналу @awesome_russian_podcasts и добавляйте свои любимые русскоязычные ИТ-подкасты в репозиторий, чтобы и о них могли услышать другие люди.

Если же вы адепт youtub’а и просмотра видео, то и для вас есть аналогичный канал — @awesome_russian_youtube.

Спасибо, что дошли до конца. Будьте здоровы и не сходите с пути познания. Удачи!

© Habrahabr.ru