From cc97d20f7ab36440ff66aad13f603583154ec73b Mon Sep 17 00:00:00 2001 From: Declan Chidlow Date: Mon, 26 May 2025 09:21:01 +0800 Subject: [PATCH] Slop out the first version of this bot --- .env.example | 16 ++ .gitignore | 21 ++ Dockerfile | 18 ++ README.md | 33 ++- SELF-HOSTING.md | 57 +++++ bot.js | 508 ++++++++++++++++++++++++++++++++++++++++++++ bun.lock | 90 ++++++++ compose.yml.example | 51 +++++ init.sql | 33 +++ package.json | 25 +++ 10 files changed, 841 insertions(+), 11 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 SELF-HOSTING.md create mode 100644 bot.js create mode 100644 bun.lock create mode 100644 compose.yml.example create mode 100644 init.sql create mode 100644 package.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b8a9b40 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Copy this file to `.env.example` before editing + +# TODO: Base URL of the Revolt API to connect to. +# Defaults to https://api.revolt.chat/0.8 +REVOLT_API_URL= + +# Your bot account's token. +BOT_TOKEN= + +# Database details. +DB_NAME=autofeeds +DB_USER=feeduser + +# Set secure password for your database. Keep in mind that you can't easily change these after initialising. +DB_PASS=db_password +DB_ROOT_PASS=db_root_password diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e28e51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# IDE / editor specific ignores +.idea/ +.vscode/ + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# JavaScript +node_modules + +# Docker +compose.yml + +# Misc +dist +/db +/private diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cf471e6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM oven/bun:latest +WORKDIR /app + +COPY package.json ./ +COPY bun.lock ./ + +RUN bun install --frozen-lockfile --production +COPY . . + +RUN addgroup --gid 1001 nodejs && \ + adduser --uid 1001 --ingroup nodejs --system botuser +RUN chown -R botuser:nodejs /app +USER botuser + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD bun -e "console.log('Bot is running')" || exit 1 + +CMD ["bun", "start"] diff --git a/README.md b/README.md index e0315ab..634210a 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,31 @@

- TemplateRepo + AutoFeeds - [![Stars](https://img.shields.io/github/stars/DeclanChidlow/TemplateRepo?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/TemplateRepo/stargazers) - [![Forks](https://img.shields.io/github/forks/DeclanChidlow/TemplateRepo?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/TemplateRepo/network/members) - [![Pull Requests](https://img.shields.io/github/issues-pr/DeclanChidlow/TemplateRepo?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/TemplateRepo/pulls) - [![Issues](https://img.shields.io/github/issues/DeclanChidlow/TemplateRepo?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/TemplateRepo/issues) - [![Contributors](https://img.shields.io/github/contributors/DeclanChidlow/TemplateRepo?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/TemplateRepo/graphs/contributors) - [![Licence](https://img.shields.io/github/license/DeclanChidlow/TemplateRepo?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/TemplateRepo/blob/main/LICENCE) + [![Stars](https://img.shields.io/github/stars/DeclanChidlow/AutoFeeds?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoFeeds/stargazers) + [![Forks](https://img.shields.io/github/forks/DeclanChidlow/AutoFeeds?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoFeeds/network/members) + [![Pull Requests](https://img.shields.io/github/issues-pr/DeclanChidlow/AutoFeeds?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoFeeds/pulls) + [![Issues](https://img.shields.io/github/issues/DeclanChidlow/AutoFeeds?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoFeeds/issues) + [![Contributors](https://img.shields.io/github/contributors/DeclanChidlow/AutoFeeds?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoFeeds/graphs/contributors) + [![Licence](https://img.shields.io/github/license/DeclanChidlow/AutoFeeds?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoFeeds/blob/main/LICENCE)

-Brief description of repository +Feed bot for Revolt

-Larger description of repository. +A syndication feed bot for [Revolt](https://revolt.chat) that supports RSS, Atom, and JSON feeds. The bot automatically fetches feeds and posts new items. -## Badge Setup +## Features -Search and replace all instances of "DeclanChidlow/TemplateRepo" with your required details. In Neovim this can be done with `:%s/DeclanChidlow/your_username/g` then `:%s/TemplateRepo/your_repo_name/g`. +[X] Support for RSS, Atom, and JSON feeds +[X] Automatic feed checking +[X] Manual forced feed checking +[X] Duplicate detection to prevent spam + +## Documentation and Support + +All of AutoFeed's commands and features are documented in the help command, triggered by `@AutoFeeds help`. If you still have questions or need help, you can join the [support server on Revolt](https://rvlt.gg/automod). The Revolt server is also a good way to stay in the loop on updates, changes, and development. + +## Invite to Your Server + +If you're on the official Revolt instance at [revolt.chat](https://revolt.chat) then you can [add AutoFeeds to your server](). If you're on a third-party instance, then you'll need to host it yourself. Instructions to do so are available in [SELF-HOSTING.md]. diff --git a/SELF-HOSTING.md b/SELF-HOSTING.md new file mode 100644 index 0000000..8876fa0 --- /dev/null +++ b/SELF-HOSTING.md @@ -0,0 +1,57 @@ +# Self-Hosting + +If you are hosting your own instance, or wish to run a modified version of the bot, you can self-host it. + +> [!IMPORTANT] +> Due to limited time and resources, I can _not_ offer free self-hosting support. If you’d like paid assistance, or to have AutoFeeds hosted on your behalf in a paid capacity, please [contact Vale](https://vale.rocks/contact). + +> [!IMPORTANT] +> This is a technical guide intended for people who wish to setup their own Revolt instance. +> It is _not_ for the average end user. If you wish to add AutoFeeds to your server, you can [invite it](). + +## Prerequisites + +Ensure you have the following: + +- [Git](https://git-scm.com) +- [Docker](https://www.docker.com) +- A capable server + +## Installation + +1. **Clone the AutoFeeds repository** \ + `git clone https://github.com/DeclanChidlow/AutoFeeds` + +2. **Enter the AutoFeeds directory** \ + `cd AutoFeeds` + +3. **Setup configurations** \ + Copy the example configuration files to create the actual configuration files: \ + `cp compose.yml.example compose.yml` \ + `cp .env.example .env` + + You will need to edit `.env` to populate it with the relevant details. While the default `compose.yml` will work out of the box in most situations, you might need to tweak it depending on your specific configuration. + +4. **Build AutoFeeds** \ + `docker compose build` + +5. **Run it with Docker** \ + Start AutoFeeds with `docker compose up` and consult the logs. Assuming it runs without issues and nothing seems amiss, you can stop it, and restart it to run in the background (detached mode) with `docker compose up -d`. + +## Updating + +1. **Ensure you are in the AutoFeeds directory** \ + `cd AutoFeeds` + +2. **Stop AutoFeeds** \ + `docker compose stop` + +3. **Pull the latest changes** \ + This will copy the latest changes from GitHub to your local machine. \ + `git pull` + +4. **Build AutoFeeds** \ + `docker compose build` + +5. **Start AutoFeeds** \ + Start AutoFeeds with `docker compose up` and consult the logs. Assuming it runs without issues and nothing seems amiss, you can stop it, and restart it to run in the background (detached mode) with `docker compose up -d`. diff --git a/bot.js b/bot.js new file mode 100644 index 0000000..b0495bf --- /dev/null +++ b/bot.js @@ -0,0 +1,508 @@ +const { Client } = require("revolt.js"); +const mysql = require("mysql2/promise"); +const Parser = require("rss-parser"); +const fetch = require("node-fetch"); +const cron = require("node-cron"); + +class AutoFeeds { + constructor() { + this.client = new Client(); + this.parser = new Parser({ + customFields: { + feed: ["language", "ttl"], + item: ["guid", "pubDate", "published", "updated"], + }, + }); + this.db = null; + this.feeds = new Map(); + } + + async init() { + try { + await this.initDatabase(); + await this.loadFeeds(); + await this.client.loginBot(process.env["BOT_TOKEN"]); + console.log("Bot connected to Revolt!"); + + cron.schedule("*/15 * * * *", () => { + this.checkAllFeeds(); + }); + + this.setupCommands(); + } catch (error) { + console.error("Failed to initialise bot:", error); + process.exit(1); + } + } + + async initDatabase() { + const maxRetries = 30; + let retries = 0; + + while (retries < maxRetries) { + try { + this.db = await mysql.createConnection({ + host: process.env["DB_HOST"] || "localhost", + port: process.env["DB_PORT"] || 3306, + user: process.env["DB_USER"] || "feeduser", + password: process.env["DB_PASS"] || "", + database: process.env["DB_NAME"] || "autofeeds", + charset: "utf8mb4", + }); + + // Test the connection + await this.db.execute("SELECT 1"); + break; + } catch (error) { + retries++; + console.log(`Database connection attempt ${retries}/${maxRetries} failed, retrying in 2 seconds...`); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + if (retries === maxRetries) { + throw error; + } + } + } + + await this.db.execute(` + CREATE TABLE IF NOT EXISTS feeds ( + id INT AUTO_INCREMENT PRIMARY KEY, + url VARCHAR(512) NOT NULL, + channel_id VARCHAR(26) NOT NULL, + server_id VARCHAR(26) NOT NULL, + feed_type ENUM('rss', 'atom', 'json') NOT NULL, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_feed_channel (url, channel_id) + ) + `); + + await this.db.execute(` + CREATE TABLE IF NOT EXISTS feed_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + feed_id INT NOT NULL, + item_id VARCHAR(512) NOT NULL, + title VARCHAR(512), + link VARCHAR(512), + published_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE, + UNIQUE KEY unique_item (feed_id, item_id) + ) + `); + + console.log("Database initialised successfully"); + } + + async loadFeeds() { + const [rows] = await this.db.execute("SELECT * FROM feeds"); + for (const feed of rows) { + this.feeds.set(`${feed.url}-${feed.channel_id}`, feed); + } + console.log(`Loaded ${rows.length} feeds from database`); + } + + async isUserModerator(message) { + try { + if (message.server.ownerId === message.authorId) { + return true; + } + + return false; + } catch (error) { + console.error("Error checking moderator status:", error); + return false; + } + } + + setupCommands() { + this.client.on("messageCreate", async (message) => { + if (message.author?.bot || !message.content) return; + + const botId = this.client.user.id; + const mention = `<@${botId}>`; + + if (!message.content.startsWith(mention)) return; + + const args = message.content.slice(mention.length).trim().split(" "); + const command = args[0]?.toLowerCase(); + + if (!command) { + await this.handleHelp(message); + return; + } + + try { + switch (command) { + case "add": + await this.handleAddFeed(message, args); + break; + case "remove": + await this.handleRemoveFeed(message, args); + break; + case "list": + await this.handleListFeeds(message); + break; + case "check": + await this.handleCheckFeed(message, args); + break; + case "help": + await this.handleHelp(message); + break; + default: + await message.reply(`That isn't a command. You can see the doumentation with \`@${this.client.user?.username} help\`.`); + break; + } + } catch (error) { + console.error("Command error:", error); + await message.reply("An error occurred while processing your command."); + } + }); + } + + async handleAddFeed(message, args) { + if (!(await this.isUserModerator(message))) { + await message.reply("Only moderators may add feeds."); + return; + } + + if (args.length < 2) { + await message.reply(`Usage: \`@${this.client.user?.username} add \``); + return; + } + + const url = args[1]; + const channelId = message.channelId; + const serverId = message.channel?.server.id; + + if (!serverId) { + await message.reply("This command can only be used in server channels."); + return; + } + + try { + const feedType = await this.detectFeedType(url); + if (!feedType) { + await message.reply("Invalid feed URL or unsupported feed format."); + return; + } + + await this.db.execute("INSERT IGNORE INTO feeds (url, channel_id, server_id, feed_type) VALUES (?, ?, ?, ?)", [url, channelId, serverId, feedType]); + + // Add to memory + const feed = { url, channel_id: channelId, server_id: serverId, feed_type: feedType }; + this.feeds.set(`${url}-${channelId}`, feed); + + await message.reply(`✅ Added ${feedType.toUpperCase()} feed: ${url}`); + + // Initialise feed without posting items + await this.initialiseFeed(feed); + } catch (error) { + console.error("Error adding feed:", error); + if (error.code === "ER_DUP_ENTRY") { + await message.reply("This feed is already added to this channel."); + } else { + await message.reply("Failed to add feed. Please check the URL and try again."); + } + } + } + + async handleRemoveFeed(message, args) { + if (!(await this.isUserModerator(message))) { + await message.reply("Only moderators may remove feeds."); + return; + } + + if (args.length < 2) { + await message.reply(`Usage: \`@${this.client.user?.username} remove \``); + return; + } + + const url = args[1]; + const channelId = message.channelId; + + try { + const [result] = await this.db.execute("DELETE FROM feeds WHERE url = ? AND channel_id = ?", [url, channelId]); + + if (result.affectedRows > 0) { + this.feeds.delete(`${url}-${channelId}`); + await message.reply("✅ Feed removed successfully."); + } else { + await message.reply("Feed not found in this channel."); + } + } catch (error) { + console.error("Error removing feed:", error); + await message.reply("Failed to remove feed."); + } + } + + async handleListFeeds(message) { + const channelId = message.channelId; + + try { + const [rows] = await this.db.execute("SELECT url, feed_type, last_updated FROM feeds WHERE channel_id = ?", [channelId]); + + if (rows.length === 0) { + await message.reply("No feeds configured for this channel."); + return; + } + + let response = "📡 **Configured Feeds:**\n"; + rows.forEach((feed, index) => { + response += `${index + 1}. [${feed.feed_type.toUpperCase()}] ${feed.url}\n`; + response += ` Last updated: ${feed.last_updated || "Never"}\n`; + }); + + await message.reply(response); + } catch (error) { + console.error("Error listing feeds:", error); + await message.reply("Failed to list feeds."); + } + } + + async handleCheckFeed(message, args) { + if (args.length < 2) { + await message.reply(`Usage: \`@${this.client.user?.username} check \``); + return; + } + + const url = args[1]; + const channelId = message.channelId; + const feedKey = `${url}-${channelId}`; + const feed = this.feeds.get(feedKey); + + if (!feed) { + await message.reply("Feed not found in this channel."); + return; + } + + await message.reply("Checking feed..."); + await this.checkFeed(feed, false); + } + + async handleHelp(message) { + const botName = this.client.user?.username; + const help = `## AutoFeeds Help + + \`@${botName} add \` - Add an RSS/Atom/JSON feed to this channel + \`@${botName} remove \` - Remove a feed from this channel + \`@${botName} list\` - List all feeds in this channel + \`@${botName} check \` - Manually check a specific feed for new items + \`@${botName} help\` - Show this help message + + **Supported Feed Types:** + - RSS 2.0 + - Atom 1.0 + - JSON Feed 1.0/1.1 + + Feeds are automatically checked every 15 minutes.`; + + await message.reply(help); + } + + async detectFeedType(url) { + try { + const response = await fetch(url, { + headers: { "User-Agent": "AutoFeeds/1.0" }, + timeout: 10000, + }); + + if (!response.ok) return null; + + const contentType = response.headers.get("content-type") || ""; + const text = await response.text(); + + if (contentType.includes("application/json") || text.trim().startsWith("{")) { + try { + const json = JSON.parse(text); + if (json.version && json.version.startsWith("https://jsonfeed.org/version/")) { + return "json"; + } + } catch (e) {} + } + + if (text.includes(" setTimeout(resolve, 1000)); + } catch (error) { + console.error(`Error checking feed ${feed.url}:`, error); + } + } + } + + async checkFeed(feed, isManual = false) { + try { + let items = []; + + if (feed.feed_type === "json") { + items = await this.parseJsonFeed(feed.url); + } else { + items = await this.parseXmlFeed(feed.url); + } + + if (items.length === 0) return; + + const [feedRows] = await this.db.execute("SELECT id FROM feeds WHERE url = ? AND channel_id = ?", [feed.url, feed.channel_id]); + + if (feedRows.length === 0) return; + const feedId = feedRows[0].id; + + // Process items (newest first) + let newItemsCount = 0; + + for (const item of items.slice(0, 5)) { + // Limit to 5 most recent items + try { + // Try to insert the item. This will fail silently if it already exists + const [insertResult] = await this.db.execute("INSERT IGNORE INTO feed_items (feed_id, item_id, title, link, published_at) VALUES (?, ?, ?, ?, ?)", [ + feedId, + item.id, + item.title, + item.link, + item.published, + ]); + + // Only post if this is a new item (removed the isManual condition) + if (insertResult.affectedRows > 0) { + await this.postFeedItem(feed, item); + newItemsCount++; + } + } catch (error) { + console.error("Error processing feed item:", error); + } + } + + // Update last_updated timestamp + await this.db.execute("UPDATE feeds SET last_updated = CURRENT_TIMESTAMP WHERE id = ?", [feedId]); + + if (newItemsCount > 0) { + console.log(`Posted ${newItemsCount} new items from ${feed.url}`); + } + } catch (error) { + console.error(`Error checking feed ${feed.url}:`, error); + } + } + + async initialiseFeed(feed) { + try { + let items = []; + + if (feed.feed_type === "json") { + items = await this.parseJsonFeed(feed.url); + } else { + items = await this.parseXmlFeed(feed.url); + } + + if (items.length === 0) return; + + const [feedRows] = await this.db.execute("SELECT id FROM feeds WHERE url = ? AND channel_id = ?", [feed.url, feed.channel_id]); + + if (feedRows.length === 0) return; + const feedId = feedRows[0].id; + + // Store existing items without posting them + for (const item of items.slice(0, 5)) { + try { + await this.db.execute("INSERT IGNORE INTO feed_items (feed_id, item_id, title, link, published_at) VALUES (?, ?, ?, ?, ?)", [feedId, item.id, item.title, item.link, item.published]); + } catch (error) { + console.error("Error storing feed item:", error); + } + } + + // Update last_updated timestamp + await this.db.execute("UPDATE feeds SET last_updated = CURRENT_TIMESTAMP WHERE id = ?", [feedId]); + } catch (error) { + console.error(`Error initialising feed ${feed.url}:`, error); + } + } + + async parseXmlFeed(url) { + const feed = await this.parser.parseURL(url); + + return feed.items.map((item) => ({ + id: item.guid || item.link || item.title, + title: item.title, + link: item.link, + description: item.contentSnippet || item.content, + published: new Date(item.pubDate || item.isoDate || Date.now()), + })); + } + + async parseJsonFeed(url) { + const response = await fetch(url, { + headers: { "User-Agent": "AutoFeeds/1.0" }, + }); + + const feed = await response.json(); + + return (feed.items || []).map((item) => ({ + id: item.id || item.url || item.title, + title: item.title, + link: item.url || item.external_url, + description: item.summary || item.content_text, + published: new Date(item.date_published || item.date_modified || Date.now()), + })); + } + + async postFeedItem(feed, item) { + try { + const channel = this.client.channels.get(feed.channel_id); + if (!channel) return; + + const message = this.formatFeedMessage(item); + await channel.sendMessage(message); + } catch (error) { + console.error("Error posting feed item:", error); + } + } + + formatFeedMessage(item) { + let message = `**${item.title}**\n`; + + if (item.description) { + const description = item.description.substring(0, 200); + message += `${description}${description.length === 200 ? "..." : ""}\n\n`; + } + + if (item.link) { + message += `🔗: ${item.link}`; + } + + return message; + } +} + +const requiredEnvVars = ["BOT_TOKEN"]; +const missingEnvVars = requiredEnvVars.filter((envVar) => !process.env[envVar]); + +if (missingEnvVars.length > 0) { + console.error("Missing required environment variables:", missingEnvVars.join(", ")); + console.error("Please create a .env file with the required variables."); + process.exit(1); +} + +const bot = new AutoFeeds(); +bot.init().catch(console.error); + +process.on("SIGINT", async () => { + console.log("Shutting down bot..."); + if (bot.db) await bot.db.end(); + process.exit(0); +}); diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..b3a4194 --- /dev/null +++ b/bun.lock @@ -0,0 +1,90 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "revolt-feed-bot", + "dependencies": { + "mysql2": "^3.14.1", + "node-cron": "^4.0.7", + "node-fetch": "^3.3.2", + "revolt.js": "^7.2.0", + "rss-parser": "^3.13.0", + }, + }, + }, + "packages": { + "@solid-primitives/map": ["@solid-primitives/map@0.7.1", "", { "dependencies": { "@solid-primitives/trigger": "^1.2.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-0Fo5gToY+XPZVQLwzm8dc/3vVLdYLboUJD+kyoW+V64YPHs0qPDEekJuRUV7Mp9uiyHnD1DArfy1CrHAezTDZQ=="], + + "@solid-primitives/set": ["@solid-primitives/set@0.7.1", "", { "dependencies": { "@solid-primitives/trigger": "^1.2.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-mB5SfKHnyTlVZGLhh5BDivDemKWtMePNyUX3FIiBisTf7mkJZVFdgiKLO0l+QaOlRKM5+VsRT1pThL5grl2sig=="], + + "@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.1", "", { "dependencies": { "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-pvNmddYs5AYUpiH373F7wbQOlcc10SSNHY8kUiu4UHoDlv4jhSnlNXzbFkmt33hq4ODKdN5gVm00jCnAJ+wm8Q=="], + + "@solid-primitives/utils": ["@solid-primitives/utils@6.3.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-4/Z59nnwu4MPR//zWZmZm2yftx24jMqQ8CSd/JobL26TPfbn4Ph8GKNVJfGJWShg1QB98qObJSskqizbTvcLLA=="], + + "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.6", "", {}, "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA=="], + + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + + "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + + "lru.min": ["lru.min@1.1.2", "", {}, "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg=="], + + "mysql2": ["mysql2@3.14.1", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.6.3", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-7ytuPQJjQB8TNAYX/H2yhL+iQOnIBjAMam361R7UAL0lOVXWjtdrmoL9HYKqKoLp/8UUTRcvo1QPvK9KL7wA8w=="], + + "named-placeholders": ["named-placeholders@1.1.3", "", { "dependencies": { "lru-cache": "^7.14.1" } }, "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w=="], + + "node-cron": ["node-cron@4.0.7", "", {}, "sha512-A37UUDpxRT/kWanELr/oMayCWQFk9Zx9BEUoXrAKuKwKzH4XuAX+vMixMBPkgZBkADgJwXv91w5cMRTNSVP/mA=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "revolt-api": ["revolt-api@0.8.5-1", "", {}, "sha512-gMxs7X4O686w8fOsLIEmhID+gnjcPc3DuGRx9kxTG+lRouZgcL0eYs/BowcJo73/R6C4u4hedElzctzRj7sNig=="], + + "revolt.js": ["revolt.js@7.2.0", "", { "dependencies": { "@solid-primitives/map": "^0.7.1", "@solid-primitives/set": "^0.7.1", "@vladfrangu/async_event_emitter": "^2.4.6", "revolt-api": "0.8.5-1", "solid-js": "^1.9.6", "ulid": "^2.4.0" } }, "sha512-LCPUnsvxlrc2o/e4uLO0wyvlkzhSxNNOS9sttUE1X04sc8wziuhJ9JaXKR/8VztQ7XSGjBAJWFbTfVuYR0/aNA=="], + + "rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "^2.0.3", "xml2js": "^0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], + + "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], + + "seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="], + + "seroval-plugins": ["seroval-plugins@1.3.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ=="], + + "solid-js": ["solid-js@1.9.7", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw=="], + + "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], + + "ulid": ["ulid@2.4.0", "", { "bin": { "ulid": "bin/cli.js" } }, "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + } +} diff --git a/compose.yml.example b/compose.yml.example new file mode 100644 index 0000000..b3cec6a --- /dev/null +++ b/compose.yml.example @@ -0,0 +1,51 @@ +services: + mariadb: + image: mariadb:11.4.7 + container_name: autofeeds-db + restart: unless-stopped + environment: + - MYSQL_DATABASE=${DB_NAME} + - MYSQL_USER=${DB_USER} + - MYSQL_PASSWORD=${DB_PASS} + - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASS} + volumes: + - ./db:/var/lib/mysql + - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro + networks: + - bot-network + ports: + - "3306:3306" + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + start_period: 10s + interval: 10s + timeout: 5s + retries: 3 + + autofeeds-bot: + build: . + container_name: autofeeds-bot + restart: unless-stopped + environment: + - BOT_TOKEN + - DB_HOST=mariadb + - DB_PORT=3306 + - DB_NAME + - DB_USER + - DB_PASS + - NODE_ENV=production + networks: + - bot-network + depends_on: + mariadb: + condition: service_healthy + healthcheck: + test: ["CMD", "bun", "-e", "process.exit(0)"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +networks: + bot-network: + driver: bridge diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..0bd1177 --- /dev/null +++ b/init.sql @@ -0,0 +1,33 @@ +-- Initialise database with required tables +-- This runs automatically when the MariaDB container starts for the first time + +USE autofeeds; + +CREATE TABLE IF NOT EXISTS feeds ( + id INT AUTO_INCREMENT PRIMARY KEY, + url VARCHAR(512) NOT NULL, + channel_id VARCHAR(26) NOT NULL, + server_id VARCHAR(26) NOT NULL, + feed_type ENUM('rss', 'atom', 'json') NOT NULL, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_feed_channel (url, channel_id), + INDEX idx_channel_id (channel_id), + INDEX idx_server_id (server_id), + INDEX idx_last_updated (last_updated) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS feed_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + feed_id INT NOT NULL, + item_id VARCHAR(512) NOT NULL, + title VARCHAR(512), + link VARCHAR(512), + published_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE, + UNIQUE KEY unique_item (feed_id, item_id), + INDEX idx_feed_id (feed_id), + INDEX idx_published_at (published_at), + INDEX idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/package.json b/package.json new file mode 100644 index 0000000..518fdeb --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "autofeeds", + "version": "1.0.0", + "description": "A feed bot for Revolt that supports RSS, Atom, and JSON feeds.", + "main": "bot.js", + "scripts": { + "start": "bun bot.js", + "dev": "bun bot.js" + }, + "dependencies": { + "revolt.js": "^7.2.0", + "mysql2": "^3.14.1", + "rss-parser": "^3.13.0", + "node-fetch": "^3.3.2", + "node-cron": "^4.0.7" + }, + "keywords": [ + "revolt", + "rss", + "atom", + "json-feed", + "bot", + "feeds" + ] +} -- 2.51.2