From 84aebba223b6a8f9eb2d1272c3ed2083d561f2b9 Mon Sep 17 00:00:00 2001 From: ilotterytea Date: Tue, 22 Jul 2025 23:12:52 +0500 Subject: feat: RSS support --- bot/src/rss.cpp | 276 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 bot/src/rss.cpp (limited to 'bot/src/rss.cpp') diff --git a/bot/src/rss.cpp b/bot/src/rss.cpp new file mode 100644 index 0000000..a52c176 --- /dev/null +++ b/bot/src/rss.cpp @@ -0,0 +1,276 @@ +#include "rss.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cpr/api.h" +#include "cpr/cprtypes.h" +#include "cpr/response.h" +#include "database.hpp" +#include "fmt/format.h" +#include "logger.hpp" +#include "schemas/event.hpp" +#include "schemas/stream.hpp" +#include "utils/events.hpp" + +namespace bot { + sol::table RSSChannel::as_lua_table(std::shared_ptr state) const { + sol::table t = state->create_table(); + t["name"] = this->name; + t["url"] = this->url; + + if (this->event.has_value()) { + sol::table e = state->create_table(); + e["name"] = this->event->name; + e["type"] = this->event->type; + t["event"] = e; + } else { + t["event"] = sol::lua_nil; + } + + sol::table ms = state->create_table(); + + for (const RSSMessage &v : this->messages) { + sol::table m = state->create_table(); + m["title"] = v.title; + m["message"] = v.message; + m["id"] = v.id; + m["timestamp"] = v.timestamp; + ms.add(m); + } + + t["messages"] = ms; + + return t; + } + + void RSSListener::run() { + if (!this->configuration.url.rssbridge.has_value()) { + log::error("RSSListener", "RSS Bridge is not set!"); + return; + } + + while (true) { + this->add_channels(); + this->check_channels(); + std::this_thread::sleep_for(std::chrono::seconds(30)); + } + } + + bool RSSListener::has_channel(const std::string &url) const { + return std::any_of(this->channels.begin(), this->channels.end(), + [&url](const RSSChannel &c) { return c.url == url; }); + } + + void RSSListener::add_channel(const std::string &url) { + if (this->has_channel(url)) return; + + std::optional channel = get_rss_channel(url); + if (channel.has_value()) { + this->channels.push_back(channel.value()); + } + } + + void RSSListener::remove_channel(const std::string &url) { + if (!this->has_channel(url)) return; + + std::remove_if(this->channels.begin(), this->channels.end(), + [&url](const RSSChannel &c) { return c.url == url; }); + } + + void RSSListener::add_channels() { + std::unique_ptr conn = + db::create_connection(this->configuration); + + db::DatabaseRows events = conn->exec( + "SELECT event_type, name " + "FROM events " + "WHERE event_type BETWEEN $1 AND $2", + {std::to_string(static_cast(schemas::EventType::RSS)), + std::to_string(static_cast(schemas::EventType::TELEGRAM))}); + + // adding new events + for (db::DatabaseRow event : events) { + std::string name = event.at("name"); + int type = std::stoi(event.at("event_type")); + std::string bridge = ""; + bool useRSSBridge = true; + + switch (type) { + case schemas::RSS: + bridge = "RSS"; + useRSSBridge = false; + break; + case schemas::TWITTER: + bridge = "FarsideNitterBridge"; + break; + case schemas::TELEGRAM: + bridge = "TelegramBridge"; + if (name[0] != '@') { + name = "%40" + name; + } + break; + default: + break; + } + + if (bridge.empty()) { + log::warn("RSSListener", + "Failed to specify bridge: " + event.at("event_type")); + continue; + } + + if (std::any_of(this->channels.begin(), this->channels.end(), + [&name, &type](const RSSChannel &c) { + auto e = c.event; + if (!e.has_value()) { + return false; + } + return e->name == name && e->type == type; + })) { + continue; + } + + std::string url = + useRSSBridge + ? fmt::format(*this->configuration.url.rssbridge, bridge, name) + : name; + + std::optional channel = get_rss_channel(url); + if (!channel.has_value()) { + log::warn("RSSListener", "No RSS feed on " + url); + continue; + } + + channel->event = {name, type}; + + this->channels.push_back(*channel); + } + + // removing old events + auto channels = this->channels; + for (RSSChannel c : channels) { + if (!c.event.has_value()) { + continue; + } + + if (!std::any_of(events.begin(), events.end(), + [&c](const db::DatabaseRow &r) { + return r.at("name") == c.event->name && + std::stoi(r.at("event_type")) == c.event->type; + })) { + this->remove_channel(c.url); + } + } + } + + void RSSListener::check_channels() { + for (auto it = this->channels.begin(); it != this->channels.end(); ++it) { + if (!it->event.has_value()) { + continue; + } + + std::optional channel = get_rss_channel(it->url); + if (!channel.has_value()) { + continue; + } + + std::vector messages; + for (auto mit = channel->messages.begin(); mit != channel->messages.end(); + ++mit) { + if (!std::any_of( + it->messages.begin(), it->messages.end(), + [&mit](const RSSMessage &m) { return m.id == mit->id; })) { + messages.push_back(*mit); + } + } + + if (messages.empty()) { + continue; + } + + // getting channels + std::vector events = utils::get_events( + db::create_connection(this->configuration), this->helix_client, + this->irc_client.get_bot_id(), it->event->type, it->event->name); + + for (const schemas::Event &event : events) { + int count = 0; + + for (RSSMessage message : messages) { + if (count > 5) break; + count++; + + std::string msg = event.message; + + int pos = msg.find("{channel_name}"); + if (pos != std::string::npos) msg.replace(pos, 14, it->name); + + pos = msg.find("{title}"); + if (pos != std::string::npos) msg.replace(pos, 7, message.title); + + pos = msg.find("{message}"); + if (pos != std::string::npos) msg.replace(pos, 9, message.message); + + pos = msg.find("{link}"); + if (pos != std::string::npos) msg.replace(pos, 6, message.id); + + this->irc_client.say(event.channel_alias_name, msg); + } + } + } + } + + std::optional get_rss_channel(const std::string &url) { + cpr::Response response = cpr::Get( + cpr::Url{url}, + cpr::Header{{"Accept", "application/xml"}, + {"User-Agent", "https://github.com/ilotterytea/bot"}}); + + if (response.status_code != 200) { + return std::nullopt; + } + + pugi::xml_document doc; + if (!doc.load_string(response.text.c_str())) { + return std::nullopt; + } + + pugi::xml_node channel = doc.child("rss").child("channel"); + + std::string channel_name = channel.child("title").text().as_string(); + std::string channel_url = channel.child("link").text().as_string(); + + std::vector messages; + for (pugi::xml_node item : channel.children("item")) { + // parsing timestamp + long timestamp = 0; + std::string pubdate = item.child("pubDate").text().as_string(); + pubdate = pubdate.substr(0, pubdate.size() - 6); + std::tm tm = {}; + std::istringstream ss(pubdate); + ss >> std::get_time(&tm, "%a, %d %b %Y %H:%M:%S"); + if (!ss.fail()) { + timestamp = timegm(&tm); + } + + messages.push_back({item.child("title").text().as_string(), + item.child("guid").text().as_string(), + item.child("description").text().as_string(), + timestamp}); + } + + return (RSSChannel){channel_name, channel_url, std::nullopt, messages}; + } +} \ No newline at end of file -- cgit v1.2.3