Вступление
Заикнулся в присутствии нового коллеги о своем блоге, первый его вопрос был “что за блог?”, а второй “а телеграм бот у тебя есть?”. Поймал себя на мысли неужели я стал ретроградом. Когда начался хайп вокруг влогов и 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