diff --git a/README.md b/README.md
index 1809190..2f0b43c 100644
--- a/README.md
+++ b/README.md
@@ -6,11 +6,13 @@ Feed bot for Stoat
-A syndication feed bot for [Stoat](https://stoat.chat) that supports RSS, Atom, and JSON feeds. The bot automatically fetches feeds and posts new items.
+A syndication feed bot for [Stoat](https://stoat.chat) that supports RSS, Atom, JSON, and podcast feeds. The bot automatically fetches feeds and posts new items.
## Features
-- [x] Support for RSS, Atom, and JSON feeds
+- [x] Support for RSS, Atom, JSON, and podcast feeds
+- [x] Per-feed filtering of items by field content or existence
+- [x] Per-feed post templates with variables
- [x] Automatic feed checking
- [x] Manual forced feed checking
- [x] Duplicate detection to prevent spam
diff --git a/bun.lock b/bun.lock
index 0de72d6..0462d12 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,18 +5,66 @@
"": {
"name": "autofeeds",
"dependencies": {
- "json-with-bigint": "^3.5.10",
- "mysql2": "^3.23.2",
+ "json-with-bigint": "^3.5.11",
+ "mysql2": "^3.23.3",
"node-cron": "^4.6.0",
"rss-parser": "^3.13.0",
},
+ "devDependencies": {
+ "@types/bun": "^1.3.14",
+ "typescript": "^7.0.2",
+ },
},
},
"packages": {
+ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
+
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
+ "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
+
+ "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
+
+ "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
+
+ "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
+
+ "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
+
+ "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
+
+ "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
+
+ "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
+
+ "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
+
+ "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
+
+ "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
+
+ "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
+
+ "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
+
+ "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
+
+ "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
+
+ "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
+
+ "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
+
+ "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
+
+ "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
+
+ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
+
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
+ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
+
"entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
@@ -45,6 +93,8 @@
"sql-escaper": ["sql-escaper@1.5.1", "", {}, "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg=="],
+ "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
+
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
diff --git a/init.sql b/init.sql
index 0bd1177..8c41f69 100644
--- a/init.sql
+++ b/init.sql
@@ -8,7 +8,8 @@ CREATE TABLE IF NOT EXISTS feeds (
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,
+ feed_type ENUM('rss', 'atom', 'json', 'podcast') NOT NULL,
+ template TEXT NULL,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_feed_channel (url, channel_id),
@@ -17,6 +18,18 @@ CREATE TABLE IF NOT EXISTS feeds (
INDEX idx_last_updated (last_updated)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+CREATE TABLE IF NOT EXISTS feed_filters (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ feed_id INT NOT NULL,
+ field_name VARCHAR(32) NOT NULL,
+ operator VARCHAR(16) NOT NULL,
+ filter_value VARCHAR(256),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE,
+ UNIQUE KEY unique_rule (feed_id, field_name, operator, filter_value),
+ INDEX idx_feed_id (feed_id)
+) 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,
diff --git a/lib/commands.js b/lib/commands.js
deleted file mode 100644
index b8753f9..0000000
--- a/lib/commands.js
+++ /dev/null
@@ -1,310 +0,0 @@
-"use strict";
-
-const { verboseLog } = require("./utils");
-
-async function isUserModerator(msg) {
- try {
- if (!msg.server || !msg.authorId) return false;
- if (msg.server.ownerId === msg.authorId) return true;
-
- const member = await msg.server.fetchMember(msg.authorId);
- if (!member) return false;
-
- return member.hasPermission("ManageServer") || member.hasPermission("ManageChannel");
- } catch (error) {
- console.error("Error checking moderator status:", error);
- return false;
- }
-}
-
-async function safeReply(ctx, msg, content) {
- try {
- await msg.reply(content);
- } catch (error) {
- verboseLog(`Failed to reply in channel ${msg.channelId}, notifying in DM`);
- try {
- const dmChannel = await ctx.stoat.openDM(msg.authorId);
- await dmChannel.sendMessage(
- `⚠️ AutoFeeds acted on your command in <#${msg.channelId}> and tried to reply but couldn't due to not having the required permissions.\nYou can consult the setup documentation here: https://automod.vale.rocks/docs/autofeeds/setup`,
- );
- } catch (dmError) {
- console.error("Failed to send message failure DM to user:", dmError);
- }
- }
-}
-
-const { detectFeedType, initialiseFeed, checkFeed } = require("./feeds");
-
-async function handleAddFeed(ctx, msg, args) {
- if (!(await isUserModerator(msg))) {
- await safeReply(ctx, msg, "❌ You lack the permissions required to add feeds.");
- return;
- }
-
- if (args.length < 2) {
- await safeReply(ctx, msg, `Usage: \`@${ctx.stoat.botUser?.username || "AutoFeeds"} add \``);
- return;
- }
-
- const url = args[1];
- const channelId = msg.channelId;
- const serverId = msg.channel?.server?.id;
-
- if (!serverId) {
- await safeReply(ctx, msg, "This command can only be used in server channels.");
- return;
- }
-
- if (ctx.feeds.has(`${url}-${channelId}`)) {
- await safeReply(ctx, msg, "⚠️ This feed is already configured for this channel.");
- return;
- }
-
- try {
- const detectResult = await detectFeedType(url);
-
- if (detectResult?.error) {
- if (detectResult.error === 402) {
- await safeReply(ctx, msg, "❌ Cannot add feed: Payment Required (HTTP 402). The feed publisher requires payment or authentication to access this feed.");
- } else if (detectResult.error === 429) {
- await safeReply(ctx, msg, "❌ Cannot add feed: Too Many Requests (HTTP 429). The feed publisher is rate-limiting requests.");
- } else if (detectResult.error === 404) {
- await safeReply(ctx, msg, "❌ Cannot add feed: Not Found (HTTP 404). The feed URL does not exist.");
- } else {
- await safeReply(ctx, msg, `❌ Invalid feed URL or unsupported format (HTTP ${detectResult.error}).`);
- }
- return;
- }
-
- const feedType = detectResult.type;
-
- if (feedType === "expired_json") {
- await safeReply(ctx, msg, "Cannot add feed: This JSON feed has been marked as 'expired' by its publisher, meaning it will no longer be updated.");
- return;
- }
-
- if (!feedType) {
- await safeReply(ctx, msg, "Invalid feed URL or unsupported feed format.");
- return;
- }
-
- await ctx.db.execute("INSERT IGNORE INTO feeds (url, channel_id, server_id, feed_type) VALUES (?, ?, ?, ?)", [url, channelId, serverId, feedType]);
-
- const feed = { url, channel_id: channelId, server_id: serverId, feed_type: feedType };
- ctx.feeds.set(`${url}-${channelId}`, feed);
-
- await safeReply(ctx, msg, `✅ Added ${feedType.toUpperCase()} feed: ${url}`);
- await ctx.setBotStatus();
-
- await initialiseFeed(ctx, feed);
- } catch (error) {
- console.error("Error adding feed:", error);
- if (error.code === "ER_DUP_ENTRY") {
- await safeReply(ctx, msg, "⚠️ This feed is already added to this channel.");
- } else {
- await safeReply(ctx, msg, "❌ Failed to add feed. Please check the URL and try again.");
- }
- }
-}
-
-async function handleRemoveFeed(ctx, msg, args) {
- if (!(await isUserModerator(msg))) {
- await safeReply(ctx, msg, "❌ You lack the permissions required to remove feeds.");
- return;
- }
-
- if (args.length < 2) {
- await safeReply(ctx, msg, `Usage: \`@${ctx.stoat.botUser?.username || "AutoFeeds"} remove \``);
- return;
- }
-
- const url = args[1];
- const channelId = msg.channelId;
-
- try {
- const [result] = await ctx.db.execute("DELETE FROM feeds WHERE url = ? AND channel_id = ?", [url, channelId]);
-
- if (result.affectedRows > 0) {
- ctx.feeds.delete(`${url}-${channelId}`);
- await safeReply(ctx, msg, "✅ Feed removed successfully.");
- await ctx.setBotStatus();
- } else {
- await safeReply(ctx, msg, "Feed not found in this channel.");
- }
- } catch (error) {
- console.error("Error removing feed:", error);
- await safeReply(ctx, msg, "Failed to remove feed.");
- }
-}
-
-async function handleListFeeds(ctx, msg) {
- const channelId = msg.channelId;
-
- try {
- const [rows] = await ctx.db.execute("SELECT url, feed_type, last_updated FROM feeds WHERE channel_id = ?", [channelId]);
-
- if (rows.length === 0) {
- await safeReply(ctx, msg, "No feeds configured for this channel.");
- return;
- }
-
- let response = "📡 **Configured Feeds:**\n";
- rows.forEach((feed, index) => {
- const updated = feed.last_updated
- ? (() => {
- const ts = Math.floor(new Date(feed.last_updated).getTime() / 1000);
- return ` ()`;
- })()
- : "Never";
- response += `${index + 1}. [${feed.feed_type.toUpperCase()}] ${feed.url}\n`;
- response += ` Last checked: ${updated}`;
- if (index < rows.length - 1) response += "\n";
- });
-
- await safeReply(ctx, msg, response);
- } catch (error) {
- console.error("Error listing feeds:", error);
- await safeReply(ctx, msg, "Failed to list feeds.");
- }
-}
-
-async function handleCheckFeed(ctx, msg, args) {
- if (args.length < 2) {
- await safeReply(ctx, msg, `Usage: \`@${ctx.stoat.botUser?.username || "AutoFeeds"} check \``);
- return;
- }
-
- const url = args[1];
- const feedKey = `${url}-${msg.channelId}`;
- const feed = ctx.feeds.get(feedKey);
-
- if (!feed) {
- await safeReply(ctx, msg, "Feed not found in this channel.");
- return;
- }
-
- await safeReply(ctx, msg, "⏳ Checking feed…");
-
- const result = await checkFeed(ctx, feed);
-
- if (result?.error) {
- await safeReply(ctx, msg, "❌ Error checking feed.");
- return;
- }
-
- await safeReply(ctx, msg, `Feed checked. ${result.newItemsCount} new items found.`);
-}
-
-async function handlePing(ctx, msg) {
- const now = Date.now();
- const wsPing = ctx.stoat.wsPing;
- const wsDisplay = wsPing < 0 ? "`Reconnecting/Syncing…`" : `\`${wsPing}ms\``;
-
- try {
- const replyMsg = await msg.reply("⌛ Measuring...");
-
- const messagePing = Math.round(Date.now() - now);
- const uptime = process.uptime();
- const d = Math.floor(uptime / 86400);
- const h = Math.floor((uptime % 86400) / 3600);
- const m = Math.floor((uptime % 3600) / 60);
- const s = Math.floor(uptime % 60);
-
- let uptimeStr = "";
- if (d > 0) uptimeStr += `${d}d `;
- if (h > 0) uptimeStr += `${h}h `;
- if (m > 0) uptimeStr += `${m}m `;
- if (uptime < 300) uptimeStr += `${s}s`;
-
- const content = ["## Ping Pong!", `WebSocket: ${wsDisplay}`, `Message: \`${messagePing}ms\``, `Uptime: \`${uptimeStr.trim() || "0s"}\``].join("\n");
-
- await replyMsg.edit({ content });
- } catch (error) {
- console.error("Ping error:", error);
- }
-}
-
-async function handleHelp(ctx, msg) {
- const botName = ctx.stoat.botUser?.username || "AutoFeeds";
- const help = [
- "## AutoFeeds Help",
- "",
- "Visit [the documentation]() for usage information and [the AutoMod server](https://stt.gg/automod) for 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} ping\` - Check bot latency and uptime`,
- `\`@${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 20 minutes or as specified by the feed.",
- ].join("\n");
-
- await safeReply(ctx, msg, help);
-}
-
-function setupCommands(ctx) {
- const { stoat } = ctx;
-
- stoat.onMessage((event) => {
- const msg = stoat.makeMessageContext(event);
-
- (async () => {
- try {
- if (msg.author?.bot || !msg.content) return;
-
- const botId = stoat.botUser?._id;
- if (!botId) return;
-
- const mention = `<@${botId}>`;
- if (!msg.content.startsWith(mention)) return;
-
- const args = msg.content.slice(mention.length).trim().split(/\s+/);
- const command = args[0]?.toLowerCase();
-
- if (!command) {
- await handleHelp(ctx, msg);
- return;
- }
-
- switch (command) {
- case "add":
- await handleAddFeed(ctx, msg, args);
- break;
- case "remove":
- await handleRemoveFeed(ctx, msg, args);
- break;
- case "list":
- await handleListFeeds(ctx, msg);
- break;
- case "check":
- await handleCheckFeed(ctx, msg, args);
- break;
- case "ping":
- await handlePing(ctx, msg);
- break;
- case "help":
- await handleHelp(ctx, msg);
- break;
- default:
- await safeReply(ctx, msg, `That isn't a command. You can see the documentation with \`@${stoat.botUser?.username || "AutoFeeds"} help\`.`);
- }
- } catch (error) {
- if (error.type === "MissingPermission") {
- await safeReply(ctx, msg, "⚠️ AutoFeeds couldn't reply in this channel due to missing permissions.");
- } else {
- console.error("Command error:", error);
- await safeReply(ctx, msg, "❌ An error occurred while processing your command.");
- }
- }
- })();
- });
-}
-
-module.exports = { setupCommands, isUserModerator, safeReply };
diff --git a/lib/commands.ts b/lib/commands.ts
new file mode 100644
index 0000000..210bdd6
--- /dev/null
+++ b/lib/commands.ts
@@ -0,0 +1,592 @@
+"use strict";
+
+import type { Ctx, FeedType } from "./types";
+import type { MessageContext } from "./stoat";
+import { verboseLog, stripUrlBrackets, formatUrlForMarkdown } from "./utils";
+import { detectFeedType, initialiseFeed, checkFeed, FILTER_FIELDS, FILTER_OPERATORS } from "./feeds";
+import { TEMPLATE_VAR_FIELDS, unknownTemplateVars } from "./templates";
+
+const PODCAST_ONLY_FIELDS = ["type", "episode", "season", "duration"];
+const GENERAL_FILTER_FIELDS = Object.keys(FILTER_FIELDS).filter((field) => !PODCAST_ONLY_FIELDS.includes(field));
+const GENERAL_TEMPLATE_VARS = Object.keys(TEMPLATE_VAR_FIELDS).filter((name) => !PODCAST_ONLY_FIELDS.includes(name));
+
+async function isUserModerator(msg: MessageContext): Promise {
+ try {
+ if (!msg.server || !msg.authorId) return false;
+ if (msg.server.ownerId === msg.authorId) return true;
+
+ const member = await msg.server.fetchMember(msg.authorId);
+ if (!member) return false;
+
+ return member.hasPermission("ManageServer") || member.hasPermission("ManageChannel");
+ } catch (error) {
+ console.error("Error checking moderator status:", error);
+ return false;
+ }
+}
+
+async function safeReply(ctx: Ctx, msg: MessageContext, content: string): Promise {
+ try {
+ await msg.reply(content);
+ } catch (error) {
+ verboseLog(`Failed to reply in channel ${msg.channelId}, notifying in DM`);
+ try {
+ const dmChannel = await ctx.stoat.openDM(msg.authorId);
+ await dmChannel.sendMessage(
+ `⚠️ AutoFeeds acted on your command in <#${msg.channelId}> and tried to reply but couldn't due to not having the required permissions.\nYou can consult the setup documentation here: `,
+ );
+ } catch (dmError) {
+ console.error("Failed to send message failure DM to user:", dmError);
+ }
+ }
+}
+
+async function handleAddFeed(ctx: Ctx, msg: MessageContext, args: string[]): Promise {
+ if (!(await isUserModerator(msg))) {
+ await safeReply(ctx, msg, "❌ You lack the permissions required to add feeds.");
+ return;
+ }
+
+ if (args.length < 2) {
+ await safeReply(ctx, msg, `Usage: \`@${ctx.stoat.botUser?.username || "AutoFeeds"} add \``);
+ return;
+ }
+
+ const url = stripUrlBrackets(args[1]);
+ const channelId = msg.channelId;
+ const serverId = msg.channel?.server?.id;
+
+ if (!serverId) {
+ await safeReply(ctx, msg, "This command can only be used in server channels.");
+ return;
+ }
+
+ if (ctx.feeds.has(`${url}-${channelId}`)) {
+ await safeReply(ctx, msg, "⚠️ This feed is already configured for this channel.");
+ return;
+ }
+
+ try {
+ const detectResult = await detectFeedType(url);
+
+ if (detectResult?.error) {
+ if (detectResult.error === 402) {
+ await safeReply(ctx, msg, "❌ Cannot add feed: Payment Required (HTTP 402). The feed publisher requires payment or authentication to access this feed.");
+ } else if (detectResult.error === 429) {
+ await safeReply(ctx, msg, "❌ Cannot add feed: Too Many Requests (HTTP 429). The feed publisher is rate-limiting requests.");
+ } else if (detectResult.error === 404) {
+ await safeReply(ctx, msg, "❌ Cannot add feed: Not Found (HTTP 404). The feed URL does not exist.");
+ } else {
+ await safeReply(ctx, msg, `❌ Invalid feed URL or unsupported format (HTTP ${detectResult.error}).`);
+ }
+ return;
+ }
+
+ const feedType = detectResult.type;
+
+ if (feedType === "expired_json") {
+ await safeReply(ctx, msg, "Cannot add feed: This JSON feed has been marked as 'expired' by its publisher, meaning it will no longer be updated.");
+ return;
+ }
+
+ if (!feedType) {
+ await safeReply(ctx, msg, "Invalid feed URL or unsupported feed format.");
+ return;
+ }
+
+ await ctx.db.execute("INSERT IGNORE INTO feeds (url, channel_id, server_id, feed_type) VALUES (?, ?, ?, ?)", [url, channelId, serverId, feedType]);
+
+ const feed = { url, channel_id: channelId, server_id: serverId, feed_type: feedType as FeedType };
+ ctx.feeds.set(`${url}-${channelId}`, feed);
+
+ await safeReply(ctx, msg, `✅ Added ${feedType.toUpperCase()} feed: ${formatUrlForMarkdown(url)}`);
+ await ctx.setBotStatus();
+
+ await initialiseFeed(ctx, feed);
+ } catch (error) {
+ console.error("Error adding feed:", error);
+ if ((error as { code?: string }).code === "ER_DUP_ENTRY") {
+ await safeReply(ctx, msg, "⚠️ This feed is already added to this channel.");
+ } else {
+ await safeReply(ctx, msg, "❌ Failed to add feed. Please check the URL and try again.");
+ }
+ }
+}
+
+async function handleRemoveFeed(ctx: Ctx, msg: MessageContext, args: string[]): Promise {
+ if (!(await isUserModerator(msg))) {
+ await safeReply(ctx, msg, "❌ You lack the permissions required to remove feeds.");
+ return;
+ }
+
+ if (args.length < 2) {
+ await safeReply(ctx, msg, `Usage: \`@${ctx.stoat.botUser?.username || "AutoFeeds"} remove \``);
+ return;
+ }
+
+ const url = stripUrlBrackets(args[1]);
+ const channelId = msg.channelId;
+
+ try {
+ const [result] = (await ctx.db.execute("DELETE FROM feeds WHERE url = ? AND channel_id = ?", [url, channelId])) as [{ affectedRows: number }, unknown];
+
+ if (result.affectedRows > 0) {
+ ctx.feeds.delete(`${url}-${channelId}`);
+ await safeReply(ctx, msg, "✅ Feed removed successfully.");
+ await ctx.setBotStatus();
+ } else {
+ await safeReply(ctx, msg, "Feed not found in this channel.");
+ }
+ } catch (error) {
+ console.error("Error removing feed:", error);
+ await safeReply(ctx, msg, "Failed to remove feed.");
+ }
+}
+
+async function handleListFeeds(ctx: Ctx, msg: MessageContext): Promise {
+ const channelId = msg.channelId;
+
+ try {
+ const [rows] = (await ctx.db.execute("SELECT url, feed_type, last_updated, template FROM feeds WHERE channel_id = ?", [channelId])) as [
+ { url: string; feed_type: FeedType; last_updated: Date | null; template: string | null }[],
+ unknown,
+ ];
+
+ if (rows.length === 0) {
+ await safeReply(ctx, msg, "No feeds configured for this channel.");
+ return;
+ }
+
+ let response = "📡 **Configured Feeds:**\n";
+ rows.forEach((feed, index) => {
+ const updated = feed.last_updated
+ ? (() => {
+ const ts = Math.floor(new Date(feed.last_updated).getTime() / 1000);
+ return ` ()`;
+ })()
+ : "Never";
+ response += `${index + 1}. [${feed.feed_type.toUpperCase()}] ${formatUrlForMarkdown(feed.url)}${feed.template ? " 📝" : ""}\n`;
+ response += ` Last checked: ${updated}`;
+ if (index < rows.length - 1) response += "\n";
+ });
+
+ await safeReply(ctx, msg, response);
+ } catch (error) {
+ console.error("Error listing feeds:", error);
+ await safeReply(ctx, msg, "Failed to list feeds.");
+ }
+}
+
+async function handleCheckFeed(ctx: Ctx, msg: MessageContext, args: string[]): Promise {
+ if (args.length < 2) {
+ await safeReply(ctx, msg, `Usage: \`@${ctx.stoat.botUser?.username || "AutoFeeds"} check \``);
+ return;
+ }
+
+ const url = stripUrlBrackets(args[1]);
+ const feedKey = `${url}-${msg.channelId}`;
+ const feed = ctx.feeds.get(feedKey);
+
+ if (!feed) {
+ await safeReply(ctx, msg, "Feed not found in this channel.");
+ return;
+ }
+
+ await safeReply(ctx, msg, "⏳ Checking feed…");
+
+ const result = await checkFeed(ctx, feed);
+
+ if (result?.error) {
+ await safeReply(ctx, msg, "❌ Error checking feed.");
+ return;
+ }
+
+ await safeReply(ctx, msg, `Feed checked. ${result.newItemsCount} new items found.`);
+}
+
+async function handleFilter(ctx: Ctx, msg: MessageContext, args: string[]): Promise {
+ const sub = args[1]?.toLowerCase();
+ if (sub === "list") return handleFilterList(ctx, msg, args);
+ if (sub === "remove") return handleFilterRemove(ctx, msg, args);
+ return handleFilterAdd(ctx, msg, args);
+}
+
+async function handleFilterAdd(ctx: Ctx, msg: MessageContext, args: string[]): Promise {
+ if (!(await isUserModerator(msg))) {
+ await safeReply(ctx, msg, "❌ You lack the permissions required to manage filters.");
+ return;
+ }
+
+ if (args.length < 4) {
+ await safeReply(ctx, msg, `Usage: \`@${ctx.stoat.botUser?.username || "AutoFeeds"} filter [value]\``);
+ return;
+ }
+
+ const url = stripUrlBrackets(args[1]);
+ const field = args[2].toLowerCase();
+ const operator = args[3].toLowerCase();
+ const value = args.slice(4).join(" ");
+
+ if (!FILTER_FIELDS[field]) {
+ await safeReply(ctx, msg, `❌ Unknown field. Valid fields: ${Object.keys(FILTER_FIELDS).join(", ")}.`);
+ return;
+ }
+
+ if (!FILTER_OPERATORS.includes(operator)) {
+ await safeReply(ctx, msg, `❌ Unknown operator. Valid operators: ${FILTER_OPERATORS.join(", ")}.`);
+ return;
+ }
+
+ if ((operator === "contains" || operator === "equals") && !value) {
+ await safeReply(ctx, msg, `❌ The \`${operator}\` operator requires a value.`);
+ return;
+ }
+
+ const [feedRows] = (await ctx.db.execute("SELECT id, feed_type FROM feeds WHERE url = ? AND channel_id = ?", [url, msg.channelId])) as [{ id: number; feed_type: FeedType }[], unknown];
+ if (feedRows.length === 0) {
+ await safeReply(ctx, msg, "Feed not found in this channel.");
+ return;
+ }
+
+ if (PODCAST_ONLY_FIELDS.includes(field) && feedRows[0].feed_type !== "podcast") {
+ await safeReply(ctx, msg, `❌ The \`${field}\` field is podcast-only, but this feed is ${feedRows[0].feed_type.toUpperCase()}.`);
+ return;
+ }
+
+ const filterValue = value || null;
+ const [existing] = (await ctx.db.execute("SELECT id FROM feed_filters WHERE feed_id = ? AND field_name = ? AND operator = ? AND (filter_value = ? OR (filter_value IS NULL AND ? IS NULL))", [
+ feedRows[0].id,
+ field,
+ operator,
+ filterValue,
+ filterValue,
+ ])) as [{ id: number }[], unknown];
+ if (existing.length > 0) {
+ await safeReply(ctx, msg, "⚠️ This filter rule already exists for this feed.");
+ return;
+ }
+
+ await ctx.db.execute("INSERT INTO feed_filters (feed_id, field_name, operator, filter_value) VALUES (?, ?, ?, ?)", [feedRows[0].id, field, operator, filterValue]);
+
+ await safeReply(ctx, msg, `✅ Added filter: \`${field} ${operator}${value ? ` "${value}"` : ""}\` to ${formatUrlForMarkdown(url)}`);
+}
+
+async function handleFilterRemove(ctx: Ctx, msg: MessageContext, args: string[]): Promise {
+ if (!(await isUserModerator(msg))) {
+ await safeReply(ctx, msg, "❌ You lack the permissions required to manage filters.");
+ return;
+ }
+
+ if (args.length < 4) {
+ await safeReply(ctx, msg, `Usage: \`@${ctx.stoat.botUser?.username || "AutoFeeds"} filter remove \``);
+ return;
+ }
+
+ const url = stripUrlBrackets(args[2]);
+ if (!/^\d+$/.test(args[3])) {
+ await safeReply(ctx, msg, "❌ Invalid rule ID. Use `filter list ` to see rule IDs.");
+ return;
+ }
+ const ruleId = parseInt(args[3], 10);
+
+ const [result] = (await ctx.db.execute("DELETE f FROM feed_filters f INNER JOIN feeds ON feeds.id = f.feed_id WHERE f.id = ? AND feeds.url = ? AND feeds.channel_id = ?", [
+ ruleId,
+ url,
+ msg.channelId,
+ ])) as [{ affectedRows: number }, unknown];
+
+ if (result.affectedRows > 0) {
+ await safeReply(ctx, msg, "✅ Filter rule removed.");
+ } else {
+ await safeReply(ctx, msg, "Filter rule not found for this feed in this channel.");
+ }
+}
+
+async function handleFilterList(ctx: Ctx, msg: MessageContext, args: string[]): Promise {
+ if (args.length < 3) {
+ await safeReply(ctx, msg, `Usage: \`@${ctx.stoat.botUser?.username || "AutoFeeds"} filter list \``);
+ return;
+ }
+
+ const url = stripUrlBrackets(args[2]);
+
+ const [feedRows] = (await ctx.db.execute("SELECT id FROM feeds WHERE url = ? AND channel_id = ?", [url, msg.channelId])) as [{ id: number }[], unknown];
+ if (feedRows.length === 0) {
+ await safeReply(ctx, msg, "Feed not found in this channel.");
+ return;
+ }
+
+ const [rules] = (await ctx.db.execute("SELECT id, field_name, operator, filter_value FROM feed_filters WHERE feed_id = ? ORDER BY id", [feedRows[0].id])) as [
+ { id: number; field_name: string; operator: string; filter_value: string | null }[],
+ unknown,
+ ];
+
+ if (rules.length === 0) {
+ await safeReply(ctx, msg, `No filter rules configured for ${formatUrlForMarkdown(url)}. Add one with \`filter\`.`);
+ return;
+ }
+
+ let response = `📡 **Filters for ${formatUrlForMarkdown(url)}:**\n`;
+ rules.forEach((rule, index) => {
+ response += `${index + 1}. \`${rule.field_name} ${rule.operator}${rule.filter_value ? ` "${rule.filter_value}"` : ""}\` (ID ${rule.id})`;
+ if (index < rules.length - 1) response += "\n";
+ });
+ response += `\nRemove with \`@${ctx.stoat.botUser?.username || "AutoFeeds"} filter remove \`.`;
+
+ await safeReply(ctx, msg, response);
+}
+
+async function handleTemplate(ctx: Ctx, msg: MessageContext, args: string[]): Promise {
+ if (!(await isUserModerator(msg))) {
+ await safeReply(ctx, msg, "❌ You lack the permissions required to manage templates.");
+ return;
+ }
+
+ if (args.length < 2) {
+ await safeReply(ctx, msg, `Usage: \`@${ctx.stoat.botUser?.username || "AutoFeeds"} template [template text | reset]\``);
+ return;
+ }
+
+ const url = stripUrlBrackets(args[1]);
+ const templateText = args.slice(2).join(" ");
+
+ const feedKey = `${url}-${msg.channelId}`;
+ const feed = ctx.feeds.get(feedKey);
+ if (!feed) {
+ await safeReply(ctx, msg, "Feed not found in this channel.");
+ return;
+ }
+
+ if (!templateText) {
+ if (feed.template) {
+ await safeReply(ctx, msg, `📝 **Template for ${formatUrlForMarkdown(url)}:**\n${feed.template}\n\nSet a new one with \`template \` or reset with \`template reset\`.`);
+ } else {
+ await safeReply(ctx, msg, `📝 No custom template set for ${formatUrlForMarkdown(url)} - the default format is used.`);
+ }
+ return;
+ }
+
+ if (templateText.toLowerCase() === "reset") {
+ await ctx.db.execute("UPDATE feeds SET template = NULL WHERE url = ? AND channel_id = ?", [url, msg.channelId]);
+ delete feed.template;
+ await safeReply(ctx, msg, "✅ Template reset to the default format.");
+ return;
+ }
+
+ const template = templateText.replace(/\\n/g, "\n");
+ if (template.length > 2000) {
+ await safeReply(ctx, msg, "❌ Template too long (max 2000 characters).");
+ return;
+ }
+
+ if (feed.feed_type !== "podcast") {
+ const podcastOnlyVars = PODCAST_ONLY_FIELDS.filter((v) => new RegExp(`\\{${v}\\}`).test(template));
+ if (podcastOnlyVars.length > 0) {
+ await safeReply(
+ ctx,
+ msg,
+ `❌ ${podcastOnlyVars.map((v) => `{${v}}`).join(", ")} ${podcastOnlyVars.length > 1 ? "are" : "is a"} podcast-only variable${podcastOnlyVars.length > 1 ? "s" : ""}, but this feed is ${feed.feed_type.toUpperCase()}.`,
+ );
+ return;
+ }
+ }
+
+ const unknown = unknownTemplateVars(template);
+
+ await ctx.db.execute("UPDATE feeds SET template = ? WHERE url = ? AND channel_id = ?", [template, url, msg.channelId]);
+ feed.template = template;
+
+ let response = `✅ Template set for ${formatUrlForMarkdown(url)}.`;
+ if (unknown.length > 0) {
+ response += `\n⚠️ Unknown variable${unknown.length > 1 ? "s" : ""} left as-is: ${unknown.map((v) => `{${v}}`).join(", ")}`;
+ }
+ await safeReply(ctx, msg, response);
+}
+
+async function handlePing(ctx: Ctx, msg: MessageContext): Promise {
+ const now = Date.now();
+ const wsPing = ctx.stoat.wsPing;
+ const wsDisplay = wsPing < 0 ? "`Reconnecting/Syncing…`" : `\`${wsPing}ms\``;
+
+ try {
+ const replyMsg = (await msg.reply("⌛ Measuring...")) as { edit: (data: object) => Promise };
+
+ const messagePing = Math.round(Date.now() - now);
+ const uptime = process.uptime();
+ const d = Math.floor(uptime / 86400);
+ const h = Math.floor((uptime % 86400) / 3600);
+ const m = Math.floor((uptime % 3600) / 60);
+ const s = Math.floor(uptime % 60);
+
+ let uptimeStr = "";
+ if (d > 0) uptimeStr += `${d}d `;
+ if (h > 0) uptimeStr += `${h}h `;
+ if (m > 0) uptimeStr += `${m}m `;
+ if (uptime < 300) uptimeStr += `${s}s`;
+
+ const content = ["## Ping Pong!", `WebSocket: ${wsDisplay}`, `Message: \`${messagePing}ms\``, `Uptime: \`${uptimeStr.trim() || "0s"}\``].join("\n");
+
+ await replyMsg.edit({ content });
+ } catch (error) {
+ console.error("Ping error:", error);
+ }
+}
+
+function helpMain(ctx: Ctx): string {
+ const botName = ctx.stoat.botUser?.username || "AutoFeeds";
+ return [
+ "## AutoFeeds Help",
+ `AutoFeeds posts new items from RSS, Atom, JSON, and podcast feeds to your channel. Type \`@${botName} help [category]\` to view commands within a category. Visit [the documentation]() for usage information and [the AutoMod server](https://stt.gg/automod) for help.`,
+ "",
+ `- \`@${botName} add \` - Add a 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 feed for new items`,
+ "",
+ `For more commands and features, see \`@${botName} help filters\`, \`@${botName} help templates\`, and \`@${botName} help feeds\`.`,
+ ].join("\n");
+}
+
+function helpFilters(ctx: Ctx): string {
+ const botName = ctx.stoat.botUser?.username || "AutoFeeds";
+ return [
+ "## Filtering",
+ "Block feed items from being posted based on field content or existence. Rules are set per feed.",
+ "",
+ `**Add a rule**: \`@${botName} filter [value]\``,
+ "",
+ `**Fields**: \`${GENERAL_FILTER_FIELDS.join("`, `")}\``,
+ `**Operators**: \`${FILTER_OPERATORS.join("`, `")}\``,
+ "",
+ "**Examples**",
+ `- \`@${botName} filter title contains sponsor\``,
+ `- \`@${botName} filter type equals trailer\``,
+ `- \`@${botName} filter audio missing\``,
+ "",
+ `**Podcast-only fields**: \`${PODCAST_ONLY_FIELDS.join("`, `")}\``,
+ "",
+ `Remove rules with \`@${botName} filter remove \` and list them with \`@${botName} filter list \`.`,
+ ].join("\n");
+}
+
+function helpTemplates(ctx: Ctx): string {
+ const botName = ctx.stoat.botUser?.username || "AutoFeeds";
+ return [
+ "## Templates",
+ "Customise how feed items are posted using variables. Templates are set per feed.",
+ "",
+ `**Usage**: \`@${botName} template [template text | reset]\``,
+ "",
+ `**Variables**: \`${["pubTime", "pubTimeUnix", ...GENERAL_TEMPLATE_VARS].map((v) => `{${v}}`).join("`, `")}\``,
+ `**Podcast-only variables**: \`${PODCAST_ONLY_FIELDS.map((v) => `{${v}}`).join("`, `")}\``,
+ "",
+ "- `{pubTime}` accepts an optional format: `{pubTime:YYYY-MM-DD HH:mm}` (tokens `YYYY`, `MM`, `DD`, `HH`, `mm`, `ss`, UTC)",
+ "- Use `\\n` for newlines",
+ "- Unknown variables are left as-is",
+ ].join("\n");
+}
+
+function helpFeeds(): string {
+ return [
+ "## Supported Feed Types",
+ "- **RSS 2.0** and **Atom 1.0** - XML syndication feeds",
+ "- **JSON Feed 1.0/1.1** - JSON-based feeds",
+ "- **Podcasts** - RSS feeds with iTunes/podcast namespaces",
+ "",
+ "Podcast feeds post all episode types by default. Block trailers or bonus episodes with `filter type equals trailer`.",
+ "Feeds are automatically checked every 20 minutes or as specified by the feed.",
+ ].join("\n");
+}
+
+async function handleHelp(ctx: Ctx, msg: MessageContext, args?: string[]): Promise {
+ const topic = args?.[1]?.toLowerCase();
+
+ let help: string;
+ switch (topic) {
+ case "filters":
+ case "filter":
+ help = helpFilters(ctx);
+ break;
+ case "templates":
+ case "template":
+ help = helpTemplates(ctx);
+ break;
+ case "feeds":
+ case "feed":
+ help = helpFeeds();
+ break;
+ case undefined:
+ help = helpMain(ctx);
+ break;
+ default:
+ help = `❌ Unknown help topic \`${topic}\`. Topics: \`filters\`, \`templates\`, \`feeds\`.`;
+ }
+
+ await safeReply(ctx, msg, help);
+}
+
+function setupCommands(ctx: Ctx): void {
+ const { stoat } = ctx;
+
+ stoat.onMessage((event) => {
+ const msg = stoat.makeMessageContext(event);
+
+ (async () => {
+ try {
+ if (msg.author?.bot || !msg.content) return;
+
+ const botId = stoat.botUser?._id;
+ if (!botId) return;
+
+ const mention = `<@${botId}>`;
+ if (!msg.content.startsWith(mention)) return;
+
+ const args = msg.content.slice(mention.length).trim().split(/\s+/);
+ const command = args[0]?.toLowerCase();
+
+ if (!command) {
+ await handleHelp(ctx, msg);
+ return;
+ }
+
+ switch (command) {
+ case "add":
+ await handleAddFeed(ctx, msg, args);
+ break;
+ case "remove":
+ await handleRemoveFeed(ctx, msg, args);
+ break;
+ case "list":
+ await handleListFeeds(ctx, msg);
+ break;
+ case "check":
+ await handleCheckFeed(ctx, msg, args);
+ break;
+ case "filter":
+ await handleFilter(ctx, msg, args);
+ break;
+ case "template":
+ await handleTemplate(ctx, msg, args);
+ break;
+ case "ping":
+ await handlePing(ctx, msg);
+ break;
+ case "help":
+ await handleHelp(ctx, msg, args);
+ break;
+ default:
+ await safeReply(ctx, msg, `That isn't a command. You can see the documentation with \`@${stoat.botUser?.username || "AutoFeeds"} help\`.`);
+ }
+ } catch (error) {
+ if ((error as { type?: string }).type === "MissingPermission") {
+ await safeReply(ctx, msg, "⚠️ AutoFeeds couldn't reply in this channel due to missing permissions.");
+ } else {
+ console.error("Command error:", error);
+ await safeReply(ctx, msg, "❌ An error occurred while processing your command.");
+ }
+ }
+ })();
+ });
+}
+
+export { setupCommands, isUserModerator, safeReply };
diff --git a/lib/database.js b/lib/database.js
deleted file mode 100644
index 9edbe9b..0000000
--- a/lib/database.js
+++ /dev/null
@@ -1,63 +0,0 @@
-"use strict";
-
-const mysql = require("mysql2/promise");
-const { verboseLog } = require("./utils");
-
-async function initDatabase() {
- const maxRetries = 30;
-
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
- let pool = null;
- try {
- pool = mysql.createPool({
- 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",
- waitForConnections: true,
- connectionLimit: 10,
- queueLimit: 0,
- enableKeepAlive: true,
- keepAliveInitialDelay: 10000,
- });
-
- await pool.execute("SELECT 1");
- verboseLog("Database pool established successfully");
- return pool;
- } catch (error) {
- if (pool) {
- try {
- await pool.end();
- } catch (_) {
- /* best effort */
- }
- }
-
- verboseLog(`Database connection attempt ${attempt}/${maxRetries} failed:`, error.message);
-
- await new Promise((resolve) => setTimeout(resolve, 2000));
-
- if (attempt === maxRetries) throw error;
- }
- }
-}
-
-async function loadFeeds(db) {
- const feeds = new Map();
-
- try {
- const [rows] = await db.execute("SELECT * FROM feeds");
- for (const feed of rows) {
- feeds.set(`${feed.url}-${feed.channel_id}`, feed);
- }
- verboseLog(`Loaded ${rows.length} feeds from database`);
- } catch (error) {
- console.error("Error loading feeds:", error);
- }
-
- return feeds;
-}
-
-module.exports = { initDatabase, loadFeeds };
diff --git a/lib/database.ts b/lib/database.ts
new file mode 100644
index 0000000..b82322b
--- /dev/null
+++ b/lib/database.ts
@@ -0,0 +1,108 @@
+"use strict";
+
+import mysql from "mysql2/promise";
+import type { Pool } from "mysql2/promise";
+import type { Feed } from "./types";
+import { verboseLog } from "./utils";
+
+async function initDatabase(): Promise {
+ const maxRetries = 30;
+
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
+ let pool: Pool | null = null;
+ try {
+ pool = mysql.createPool({
+ host: process.env["DB_HOST"] || "localhost",
+ port: Number(process.env["DB_PORT"]) || 3306,
+ user: process.env["DB_USER"] || "feeduser",
+ password: process.env["DB_PASS"] || "",
+ database: process.env["DB_NAME"] || "autofeeds",
+ charset: "utf8mb4",
+ waitForConnections: true,
+ connectionLimit: 10,
+ queueLimit: 0,
+ enableKeepAlive: true,
+ keepAliveInitialDelay: 10000,
+ });
+
+ await pool.execute("SELECT 1");
+ verboseLog("Database pool established successfully");
+ await runMigrations(pool);
+ return pool;
+ } catch (error) {
+ if (pool) {
+ try {
+ await pool.end();
+ } catch (_) {
+ /* best effort */
+ }
+ }
+
+ verboseLog(`Database connection attempt ${attempt}/${maxRetries} failed:`, (error as Error).message);
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ if (attempt === maxRetries) throw error;
+ }
+ }
+
+ throw new Error("Database initialisation failed");
+}
+
+async function runMigrations(db: Pool): Promise {
+ try {
+ const [tables] = await db.execute("SHOW TABLES LIKE 'feeds'");
+ if ((tables as unknown[]).length === 0) {
+ console.warn("The 'feeds' table does not exist - skipping migrations. Ensure init.sql has been applied.");
+ return;
+ }
+
+ const [typeRows] = await db.execute("SHOW COLUMNS FROM feeds LIKE 'feed_type'");
+ const currentType = (typeRows as { Type?: string }[])[0]?.Type || "";
+ if (!currentType.includes("podcast")) {
+ await db.execute("ALTER TABLE feeds MODIFY COLUMN feed_type ENUM('rss', 'atom', 'json', 'podcast') NOT NULL");
+ verboseLog("Migrated feeds.feed_type to include 'podcast'");
+ }
+
+ await db.execute(
+ `CREATE TABLE IF NOT EXISTS feed_filters (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ feed_id INT NOT NULL,
+ field_name VARCHAR(32) NOT NULL,
+ operator VARCHAR(16) NOT NULL,
+ filter_value VARCHAR(256),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE,
+ UNIQUE KEY unique_rule (feed_id, field_name, operator, filter_value),
+ INDEX idx_feed_id (feed_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
+ );
+
+ const [templateCols] = await db.execute("SHOW COLUMNS FROM feeds LIKE 'template'");
+ if ((templateCols as unknown[]).length === 0) {
+ await db.execute("ALTER TABLE feeds ADD COLUMN template TEXT NULL AFTER feed_type");
+ verboseLog("Migrated feeds to add template column");
+ }
+ } catch (error) {
+ console.error("Error running database migrations:", error);
+ throw error;
+ }
+}
+
+async function loadFeeds(db: Pool): Promise