Антон Рябов bio photo

Антон Рябов

Не люблю бриться и у меня умный взгляд.

Email Twitter Telegram Github PGP RSS

Вступление

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