Ведение Telegram-канала с помощью GitHub Actions
Наверное каждому разработчику хотя бы раз в жизни приходила идея что-нибудь автоматизировать. Ведь если есть возможность избавиться от рутины, то грех ей не воспользоваться.
Для меня эта идея стала основой многих собственных разработок, начиная с программ для решения Судоку, подсчёта времени нахождения за компьютером, имитации работы пользователя ПК с помощью самописных скриптов (всё это ещё в давние времена), и заканчивая более сложными проектами.
И вот, среди прочих родилась простая идея: «А почему бы не автоматизировать отслеживание новых выпусков ИТ-подкастов с помощью Telegram-бота и GitHub Actions? Чтобы просто подписаться на telegram-канал и получать актуальные выпуски подкастов по мере их выхода.
Конечно, можно скачать специализированные приложения, типа «Poket Casts», либо подписаться на RSS, но лично для меня использование Telegram-канала является самым удобным, простым и привычным.
Так был создан telegram-канал @awesome_russian_podcasts, куда в автоматическом режиме публикуются новые выпуски множества ИТ-подкастов, собранных в моём репозитории. Собственно, о процессе создания этого канала (его техническую часть) я и хочу рассказать далее.
Используемые инструменты
Для решения задачи я использовал следующие инструменты:
- GitHub Actions;
- .NET Core Console App;
- Luandersonn.iTunesPodcastFinder — для получения данных о подкастах из iTunes;
- Telegram.Bot — для взаимодействия с API Telegram-бота;
- 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-ключ бота, полученный ранее:
В поле 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.
Спасибо, что дошли до конца. Будьте здоровы и не сходите с пути познания. Удачи!