Вступление
Заикнулся в присутствии нового коллеги о своем блоге, первый его вопрос был “что за блог?”, а второй “а телеграм канал у тебя есть?”. Поймал себя на мысли неужели я стал ретроградом. Когда начался хайп вокруг влогов и Youtube каналов я остался верен теплому ламповому формату текстовых статей, так и сейчас, считаю что нет необходимости иметь свой Telegram канал, но задача меня заинтересовала.
Вокруг Telegram ботов сейчас много шумихи, как завести своего бота или канал написано в официальной документации. Не хотелось делать что-то надуманное, только ради “попробовать” и я решил что более менее полезной задачей будет уведомление о новых статьях в этом же блоге и только потом понял, что в принципе, мою реализацию можно использовать для любой RSS
ленты c небольшими правками под себя.
Обзор
Наивная реализация или тупой бот
Первое что нужно сделать это создать бота и получить API токен.
Недолго думая, я взял самую популярную либу-обертку для ботов Telegram - gem telegram-bot-ruby и наваял следующий код:
require 'rss'
require 'telegram/bot'
token = ENV['TELEGRAM_BOT_API_KEY']
rss = RSS::Parser.parse('https://doam.ru/feed.xml', false)
Telegram::Bot::Client.run(token) do |bot|
rss.items.each do |item|
bot.api.sendMessage(chat_id: "@doam_ru", text: item.link.href)
end
end
Что же тут происходит? Все очень просто, вот здесь:
require 'rss'
require 'telegram/bot'
подключаем библиотеки для работы с RSS и Telegram, а вот здесь:
token = ENV['TELEGRAM_BOT_API_KEY']
сетим в переменную с именем token
наш API токен, который получили от @BotFather
.
Здесь я использую переменные окружения (Environment variables), чтобы это работало, перед запуском скрипта нужно выполнить в командной строке
export TELEGRAM_BOT_API_KEY=123456789
, где вместо 123456789 нужно вставить собственно токен. Использование переменных окружения один из двенадцати факторов приложения согласно - Adam Wiggins
Затем, с помощью строчки:
rss = RSS::Parser.parse('https://doam.ru/feed.xml', false)
сохраняем в объект rss
всю RSS
ленту моего блога (я точно знаю что у меня там только 10 записей, поэтому не боюсь никаких переполнений или задержек).
Telegram::Bot::Client.run(token) do |bot|
rss.items.each do |item|
bot.api.sendMessage(chat_id: "@doam_ru", text: item.link.href)
end
end
После этого мы создаем бот клиента, обходим каждый item
внутри rss
и отправляем его ссылку в канал Telegram.
Таким образом, при каждом запуске скрипта в канал будет отправляться 10 сообщений с одинаковыми ссылками. Я использую бота, хотя для такого же функционала, например, в Slack я бы использовал Incoming Hooks.
Кстати, чтобы бот мог слать сообщения в канал, его нужно добавить в администраторы этого канала.
На этом этапе я понял что нужно хранить состояние постов, т.е. запоминать информацию какие записи уже отправлены в канал, а какие еще нет.
Используем простую БД или чуть более умный бот
На самом деле новая версия бота растянулась на 55 строк кода, и вот он целиком:
require 'telegram/bot'
require 'rss'
require 'sdbm'
require 'json'
require 'logger'
logger = Logger.new(STDOUT)
if ENV['TELEGRAM_BOT_API_KEY'].nil?
logger.fatal "Environment variable TELEGRAM_BOT_API_KEY not set!"
exit 0
else
token = ENV['TELEGRAM_BOT_API_KEY']
end
rss = RSS::Parser.parse('https://doam.ru/feed.xml', false)
SDBM.open 'doam_posts.db' do |posts|
rss.items.each do |item|
key = item.link.href
title = item.title.content
published = item.published.content
# next if posts[key]
if posts.has_key?(key)
logger.info "Post exist in DB will not rewrite"
else
posts[key] = JSON.dump(
title: title,
published: published,
sended: 0
)
end
end
hash = {}
posts.each do |k,v|
hash[k] = JSON.parse(v)
if hash[k]["sended"] == 0
text = "Новая запись в блоге: #{hash[k]["title"]} - #{k}"
Telegram::Bot::Client.run(token) do |bot|
if bot.api.sendMessage(chat_id: "@doam_ru", text: text)
posts[k] = JSON.dump(
title: hash[k]["title"],
published: hash[k]["published"],
sended: 1
)
logger.info "Successfuly send #{hash[k]} to telegram!"
else
logger.error "Can not send #{hash[k]} to telegram!"
end
end
end
end
end
Опять же приведу разбор этого кода по кусочкам далее по тексту.
require 'telegram/bot'
require 'rss'
require 'sdbm'
require 'json'
require 'logger'
Я использую теже две библиотеки что и раньше rss
и telegram
, затем подключаю sdbm
это встроенная либа Ruby, которая предоставляет простое хранилище типа ключ-значение (key-value), в качестве ключей или значений могут выступать только строки. Далее я подключаю json
, чтобы легко кодировать объекты в строки и декодировать обратно. И еще одной библиотекой является logger
, который предоставляет простой способ для отладки.
logger = Logger.new(STDOUT)
if ENV['TELEGRAM_BOT_API_KEY'].nil?
logger.fatal "Environment variable TELEGRAM_BOT_API_KEY not set!"
exit 1
else
token = ENV['TELEGRAM_BOT_API_KEY']
end
rss = RSS::Parser.parse('https://doam.ru/feed.xml', false)
Сперва делаем простые вещи, создаем объект для логирования и сетим переменную окружения.
Устанавливаем переменную окружения в этот раз мы чуть сложнее. Вначале мы проверяем что ENV пустая и если это так то выбрасываем ошибку через логгер и выходим из программы c использованием
exit
кода 1, в обратном случае, если переменная не пустая, записываем её значение в переменную, которую мы будем использовать в дальнейшем.
Парсинг RSS
ленты происходит таким же образом как и ранее.
SDBM.open 'doam_posts.db' do |posts|
Далее, этой строчкой мы открываем нашу базу данных, которая является просто файлами в нашей файловой системе, в случае если базы не существует она будет создана с нуля.
rss.items.each do |item|
key = item.link.href
title = item.title.content
published = item.published.content
# next if posts[key]
if posts.has_key?(key)
logger.info "Post exist in DB will not rewrite"
else
posts[key] = JSON.dump(
title: title,
published: published,
sended: 0
)
end
end
И после этого мы начинаем первый цикл: обходим все полученные rss итемы, записываем ссылки, заголовок и дату публикации соответственно в переменные key, title и published. Проверяем есть ли в нашей базе ключ с таким же значением как наш и если есть просто выводим в лог текст, что не будем ничего перезаписывать (это нужно только на этапе отладки, но вообще не обязательно), в обратном случае, если в базе нет записи с таким ключом мы генерируем json строку и записываем её в базу. В качестве ключа я выбрал URL, так как они обеспечивают уникальность, всегда написаны в одном регистре и латиницей.
hash = {}
posts.each do |k,v|
hash[k] = JSON.parse(v)
if hash[k]["sended"] == 0
text = "Новая запись в блоге: #{hash[k]["title"]} - #{k}"
Telegram::Bot::Client.run(token) do |bot|
if bot.api.sendMessage(chat_id: "@doam_ru", text: text)
posts[k] = JSON.dump(
title: hash[k]["title"],
published: hash[k]["published"],
sended: 1
)
logger.info "Successfuly send #{hash[k]} to telegram!"
else
logger.error "Can not send #{hash[k]} to telegram!"
end
end
end
end
Следующим шагом я создаю объект типа Hash
и прохожусь по всем записям которые есть в базе. С помощью hash[k] = JSON.parse(v)
я делаю парсинг строки значения и создаю вложенные хеши. Затем проверяю значение поля sended
, если там 0 то генерирую текст и отправляю его в канал, после чего перезаписываю объект cо значением 1, чтобы не отправить эту же запись при следующем запуске.
На самом деле это не совсем рабочая версия кода, я почему-то не закоммитил тот момент когда довел работу с SDBM до ума. Можете попробовать сами понять что здесь не так.
Теперь уже я решил попробовать задеплоить мой код на Heroku и выполнить его там. С использоавнием раннера задач все получилось, да только вот Heroku не сохраняет файлы между запусками задачи (да и вообще). Так что мое решение с SDBM является быстрым и простым, но может быть использовано только как selfhosted.
Прикручиваем РСУБД или умеренно сообразительный бот
Пост становится довольно длинным, но вы же вместе сомной прошли все этапы разработки этого бота и уже в курсе дела, поэтому привожу код обновленной версии:
require 'telegram/bot'
require 'rss'
require 'sdbm'
require 'json'
require 'logger'
require 'pg'
class DoamTelegramBot
def initialize(url, channel)
@logger = Logger.new(STDOUT)
if ENV['TELEGRAM_BOT_API_KEY'].nil?
@logger.fatal "Environment variable TELEGRAM_BOT_API_KEY not set!"
exit 0
else
@token = ENV['TELEGRAM_BOT_API_KEY']
end
@url = url
@channel = channel
uri = URI.parse(ENV['DATABASE_URL'])
@db = PG.connect(uri.hostname, uri.port, nil, nil, uri.path[1..-1], uri.user, uri.password)
@rss = RSS::Parser.parse(url, false)
@db.exec("CREATE TABLE IF NOT EXISTS posts (id serial, url varchar(450) NOT NULL, sended bool DEFAULT false)")
end
def sync
@rss.items.each do |item|
url = item.link.href
if @db.exec("SELECT exists (SELECT 1 FROM posts WHERE url = '#{url}' LIMIT 1)::int").values[0][0].to_i == 1
@logger.info "Post exist in DB will not rewrite"
else
if @db.exec("INSERT INTO posts (url) VALUES ('#{url}')")
@logger.info "Write post to DB #{url}"
end
end
end
end
def send
urls = @db.exec("SELECT url FROM posts WHERE sended = false")
urls.each do |url|
text = "Новая запись в блоге - #{url['url']}"
if telegram_send(text)
@db.exec("UPDATE posts SET sended = true WHERE url = '#{url['url']}'")
end
end
end
private
def telegram_send(message)
Telegram::Bot::Client.run(@token) do |bot|
if bot.api.sendMessage(chat_id: "#{@channel}", text: message)
@logger.info "Successfuly send #{message} to telegram!"
true
else
@logger.error "Can not send #{message} to telegram!"
false
end
end
end
end
Здесь проделывается точно такая же работа: иницилизируются необходимые компоннеты, синхронизируется rss
лента и локальная база данных и отправляются ссылки на записи которые числятся в базе как “неотправленные”. Только в качестве базы используется PostgreSQL
и все это обернуто в класс и методы. Метод initialize
выполняется при вызове метода new
на объекте класса DoamTelegramBot
, остальные методы нужно вызывать отдельно. И чтобы выполнить всё правильно, я создал каталог bin
в который положил файл с именем doam_bot
и содержимым:
#!/usr/bin/env ruby
require_relative '../app'
telegram = DoamTelegramBot.new("https://doam.ru/feed.xml", "@doam_ru")
telegram.sync
telegram.send
В котором я указываю что для выполнения скрипта нужно использовать язык Ruby, подключаю созданный мной класс через файл app.rb
, создаю объект telegram
и передаю в него урл для парсинга и id канала, вызываю методы sync
и send
. После чего я заливаю код на Heroku и создаю периодическую задачу, например раз в сутки. И если за сутки появились новые записи то в мой канал придет уведомление, уже без моего содействия в автоматическом режиме.
Заключение
Полностью это маленькое приложение можно посмотреть у меня в gitlab (для этого нужно залогиниться в gitlab.com). Лицензия пока не указана, но она MIT, т.е. можете править и использовать в своих целях. Если будет время я хотел бы прикрутить еще несколько функций, например отправку не только в телеграм но и другие сервисы, а также решить проблему первого запуска, когда база наполняется новыми записями с флагом “не отправлено” но неизвестно точно какие записи уже были отправлены в канал. В любом случае готов рассмотреть ваши Merge Requests.
#Ruby #Telegram #Heroku #TechAndDev