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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.


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


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


И вот, среди прочих родилась простая идея: "А почему бы не автоматизировать отслеживание новых выпусков ИТ-подкастов с помощью 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



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


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


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


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


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

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


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


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



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


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


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


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


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



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



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


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


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



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


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


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



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


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


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



В поле 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<Options>(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'ы из входного файла, в которых хранится количество эпизодов для каждого подкаста
    // сам метод возвращает коллекцию строк вида: '<span itunes-id="1120110650" class="episodes" hashtag="Ивент_Кухня">35 (<font color="red">0</font>)</span>'
    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> {
                                    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.


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

Источник: https://habr.com/ru/post/499180/


Интересные статьи

Интересные статьи

Программист тратит многие часы на разработку некоего функционала и на то, чтобы код соответствовал передовым практическим приёмам, принятым в той или иной среде. В эти часы не должно ...
Всем привет! Сегодня я хочу рассказать историю о РКН, чужом сайте с фильмами и моем сайте, который не имеет к нему никакого отношения. У меня есть сайт для поиска виртуальных серверов со стаби...
Куб-на-кубе, метакластеры, соты, распределение ресурсов Рис. 1. Экосистема Kubernetes в облаке Alibaba Cloud С 2015 года Alibaba Cloud Container Service for Kubernetes (ACK) является одним ...
Кому предназначена данная статья Данная статья может быть интересна системным администраторам, перед которыми вставала задача создать сервис «одноразовых» рабочих мест. Пролог В отдел ИТ со...
Об авторе. Энди Томасон — ведущий программист Genomics PLC. Он с 70-х годов занимается графическими системами, играми и компиляторами; специализация — производительность кода. Гены: краткое в...