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
- [](https://github.com/DeclanChidlow/TemplateRepo/stargazers)
- [](https://github.com/DeclanChidlow/TemplateRepo/network/members)
- [](https://github.com/DeclanChidlow/TemplateRepo/pulls)
- [](https://github.com/DeclanChidlow/TemplateRepo/issues)
- [](https://github.com/DeclanChidlow/TemplateRepo/graphs/contributors)
- [](https://github.com/DeclanChidlow/TemplateRepo/blob/main/LICENCE)
+ [](https://github.com/DeclanChidlow/AutoFeeds/stargazers)
+ [](https://github.com/DeclanChidlow/AutoFeeds/network/members)
+ [](https://github.com/DeclanChidlow/AutoFeeds/pulls)
+ [](https://github.com/DeclanChidlow/AutoFeeds/issues)
+ [](https://github.com/DeclanChidlow/AutoFeeds/graphs/contributors)
+ [](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"
+ ]
+}