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> { + const feeds = new Map(); + + try { + const [rows] = await db.execute("SELECT * FROM feeds"); + for (const feed of rows as Feed[]) { + feeds.set(`${feed.url}-${feed.channel_id}`, feed); + } + verboseLog(`Loaded ${feeds.size} feeds from database`); + } catch (error) { + console.error("Error loading feeds:", error); + } + + return feeds; +} + +export { initDatabase, loadFeeds }; diff --git a/lib/feeds.js b/lib/feeds.ts similarity index 51% rename from lib/feeds.js rename to lib/feeds.ts index e541931..cfbdaaa 100644 --- a/lib/feeds.js +++ b/lib/feeds.ts @@ -1,8 +1,26 @@ "use strict"; -const { verboseLog } = require("./utils"); +import type Parser from "rss-parser"; +import type { Ctx, Feed, FeedItem, FilterRule } from "./types"; +import { verboseLog } from "./utils"; +import { renderTemplate } from "./templates"; + +class FeedGoneError extends Error { + feedGone = true; + status: number; + + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} -async function detectFeedType(url) { +function looksLikePodcast(text: string): boolean { + if (/xmlns:itunes|xmlns:podcast|<\/?itunes:|<\/?podcast:/i.test(text)) return true; + return /]*type=["']audio\//i.test(text); +} + +async function detectFeedType(url: string): Promise<{ type?: string | null; error?: string | number }> { if (!url || typeof url !== "string" || url.trim() === "") { console.error("detectFeedType called with invalid URL:", url); return { error: "invalid_url" }; @@ -26,7 +44,7 @@ async function detectFeedType(url) { // JSON Feed if (contentType.includes("application/json") || text.trim().startsWith("{")) { try { - const json = JSON.parse(text); + const json = JSON.parse(text) as { version?: string; expired?: boolean }; if (json.version && json.version.startsWith("https://jsonfeed.org/version/")) { return json.expired === true ? { type: "expired_json" } : { type: "json" }; } @@ -37,7 +55,7 @@ async function detectFeedType(url) { // XML feeds if (text.includes(" 300 ? "…" : ""}**`]; + const parts: string[] = [`**${title}${item.title && item.title.length > 300 ? "…" : ""}**`]; if (item.description) { const description = item.description.substring(0, 200); parts.push(`${description}${description.length === 200 ? "…" : ""}`); } + if (item.isPodcast) { + const meta: string[] = []; + if (item.episodeType && String(item.episodeType).toLowerCase() !== "full") meta.push(String(item.episodeType)); + if (item.seasonNumber) meta.push(`Season ${item.seasonNumber}`); + if (item.episodeNumber) meta.push(`Episode ${item.episodeNumber}`); + if (item.duration) meta.push(item.duration); + if (meta.length) parts.push(`🎙️ ${meta.join(" · ")}`); + } + if (item.link) parts.push(`🔗: ${item.link}`); + if (item.isPodcast && item.audioUrl) parts.push(`🎧: ${item.audioUrl}`); return parts.join("\n"); } -async function parseXmlFeed(parser, feed) { +/** + * Normalise an itunes:duration value ("1234", "12:34", or "1:12:34") to a display-friendly h:mm:ss / m:ss string. Returns null for invalid values. + */ +function formatItunesDuration(raw: unknown): string | null { + if (raw === null || raw === undefined) return null; + + const str = String(raw).trim(); + if (!str) return null; + + let seconds: number; + if (/^\d+(\.\d+)?$/.test(str)) { + seconds = Math.round(parseFloat(str)); + } else { + const parts = str.split(":").map((p) => parseInt(p, 10)); + if (parts.length === 0 || parts.length > 3 || parts.some((p) => isNaN(p))) return null; + seconds = parts.length === 3 ? parts[0] * 3600 + parts[1] * 60 + parts[2] : parts.length === 2 ? parts[0] * 60 + parts[1] : parts[0]; + } + + if (!seconds || seconds <= 0) return null; + + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`; +} + +const FILTER_FIELDS: Record = { + title: "title", + body: "description", + description: "description", + link: "link", + audio: "audioUrl", + duration: "duration", + episode: "episodeNumber", + season: "seasonNumber", + type: "episodeType", +}; + +const FILTER_OPERATORS = ["contains", "equals", "missing", "present"]; + +function shouldBlockItem(item: FeedItem, rules: FilterRule[]): boolean { + return rules.some((rule) => ruleMatchesItem(rule, item)); +} + +function ruleMatchesItem(rule: FilterRule, item: FeedItem): boolean { + const field = FILTER_FIELDS[String(rule.field_name || "").toLowerCase()]; + if (!field) return false; + + const raw = item[field]; + const value = raw === null || raw === undefined ? "" : String(raw); + + switch (String(rule.operator || "").toLowerCase()) { + case "contains": { + const needle = String(rule.filter_value || "").toLowerCase(); + return needle !== "" && value !== "" && value.toLowerCase().includes(needle); + } + case "equals": + return value.toLowerCase() === String(rule.filter_value || "").toLowerCase(); + case "missing": + return value === ""; + case "present": + return value !== ""; + default: + return false; + } +} + +async function parseXmlFeed(parser: Parser, feed: Feed): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); try { - const headers = { + const headers: Record = { "User-Agent": "AutoFeeds/1.0", "Accept": "application/rss+xml, application/xml, application/atom+xml, application/rdf+xml;q=0.9, application/xml;q=0.8, text/xml;q=0.8, text/html;q=0.7, unknown/unknown;q=0.1, application/unknown;q=0.1, */*;q=0.1", @@ -128,12 +227,7 @@ async function parseXmlFeed(parser, feed) { const response = await fetch(feed.url, { headers, signal: controller.signal }); if (response.status === 304) return []; - if (response.status === 402) { - const err = new Error(`Feed ${feed.url} returned HTTP 402`); - err.feedGone = true; - err.status = 402; - throw err; - } + if (response.status === 402) throw new FeedGoneError(`Feed ${feed.url} returned HTTP 402`, 402); if (response.status === 429) return []; if (!response.ok) throw new Error(`HTTP Error ${response.status}: ${response.statusText}`); @@ -157,63 +251,101 @@ async function parseXmlFeed(parser, feed) { if (!feed.nextCheckTime || ttlLimit > feed.nextCheckTime) feed.nextCheckTime = ttlLimit; } - return feedData.items.map((item) => ({ - id: item.guid || item.link || item.title, - title: item.title, - link: item.link, - description: item.contentSnippet || item.content, - published: safeFeedDate(item.pubDate, item.isoDate), - })); + const feedTitle = feedData.title || null; + const feedLink = feedData.link || null; + + return feedData.items.map((item: any): FeedItem => { + const enclosure = item.enclosure || null; + const mapped: FeedItem = { + id: item.guid || item.link || item.title, + title: item.title, + link: item.link, + description: item.contentSnippet || item.content, + bodyHtml: item.content || null, + published: safeFeedDate(item.pubDate, item.isoDate), + author: item.creator || item.author || item.itunesAuthor || null, + categories: Array.isArray(item.categories) ? item.categories : [], + audioUrl: enclosure?.url || null, + audioType: enclosure?.type || null, + audioLength: enclosure?.length ?? null, + feedTitle, + feedLink, + }; + + if (feed.feed_type === "podcast") { + mapped.isPodcast = true; + mapped.episodeType = item.itunesEpisodeType || null; + mapped.episodeNumber = item.itunesEpisode ?? item.podcastEpisode ?? null; + mapped.seasonNumber = item.itunesSeason ?? item.podcastSeason ?? null; + mapped.duration = formatItunesDuration(item.itunesDuration); + } + + return mapped; + }); } catch (error) { - console.error(`Error parsing XML feed ${feed.url}:`, error.message); + console.error(`Error parsing XML feed ${feed.url}:`, (error as Error).message); return []; } finally { clearTimeout(timeout); } } -async function parseJsonFeed(feed) { +async function parseJsonFeed(feed: Feed): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); try { - const headers = { "User-Agent": "AutoFeeds/1.0" }; + const headers: Record = { "User-Agent": "AutoFeeds/1.0" }; if (feed.etag) headers["If-None-Match"] = feed.etag; if (feed.lastModified) headers["If-Modified-Since"] = feed.lastModified; const response = await fetch(feed.url, { headers, signal: controller.signal }); if (response.status === 304) return []; - if (response.status === 402) { - const err = new Error(`Feed ${feed.url} returned HTTP 402`); - err.feedGone = true; - err.status = 402; - throw err; - } + if (response.status === 402) throw new FeedGoneError(`Feed ${feed.url} returned HTTP 402`, 402); if (response.status === 429) return []; if (!response.ok) throw new Error(`HTTP Error ${response.status}`); - const feedData = await response.json(); + const feedData = (await response.json()) as any; // Only update cache headers after a successful parse processCacheHeaders(feed, response); - return (feedData.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: safeFeedDate(item.date_published, item.date_modified), - })); + const feedTitle = feedData.title || null; + const feedLink = feedData.home_page_url || feedData.feed_url || null; + + return (feedData.items || []).map((item: any): FeedItem => { + const mapped: FeedItem = { + id: item.id || item.url || item.title, + title: item.title, + link: item.url || item.external_url, + description: item.summary || item.content_text, + bodyHtml: item.content_html || null, + published: safeFeedDate(item.date_published, item.date_modified), + author: item.author?.name || item.author || null, + categories: Array.isArray(item.tags) ? item.tags : [], + feedTitle, + feedLink, + }; + + const audioAttachment = (item.attachments || []).find((a: any) => String(a.mime_type || "").startsWith("audio/")); + if (audioAttachment) { + mapped.audioUrl = audioAttachment.url; + mapped.audioType = audioAttachment.mime_type || null; + mapped.audioLength = audioAttachment.size_in_bytes ?? null; + } + + return mapped; + }); } catch (error) { - console.error(`Error parsing JSON feed ${feed.url}:`, error.message); + console.error(`Error parsing JSON feed ${feed.url}:`, (error as Error).message); return []; } finally { clearTimeout(timeout); } } -async function checkAllFeeds(ctx) { +async function checkAllFeeds(ctx: Ctx): Promise { const { feeds } = ctx; verboseLog(`Checking ${feeds.size} feeds...`); @@ -229,8 +361,8 @@ async function checkAllFeeds(ctx) { } } -async function _processFeedItems(ctx, feed, shouldPost) { - let items = []; +async function _processFeedItems(ctx: Ctx, feed: Feed, shouldPost: boolean): Promise<{ newItemsCount: number } | undefined> { + let items: FeedItem[] = []; if (feed.feed_type === "json") { items = await parseJsonFeed(feed); @@ -240,22 +372,28 @@ async function _processFeedItems(ctx, feed, shouldPost) { if (items.length === 0) return shouldPost ? { newItemsCount: 0 } : undefined; - const [feedRows] = await ctx.db.execute("SELECT id FROM feeds WHERE url = ? AND channel_id = ?", [feed.url, feed.channel_id]); + const [feedRows] = (await ctx.db.execute("SELECT id FROM feeds WHERE url = ? AND channel_id = ?", [feed.url, feed.channel_id])) as [{ id: number }[], unknown]; if (feedRows.length === 0) return shouldPost ? { newItemsCount: 0 } : undefined; const feedId = feedRows[0].id; + const [filterRows] = (await ctx.db.execute("SELECT id, field_name, operator, filter_value FROM feed_filters WHERE feed_id = ?", [feedId])) as [FilterRule[], unknown]; + let newItemsCount = 0; for (const item of items.slice(0, 5)) { try { - const [insertResult] = await ctx.db.execute("INSERT IGNORE INTO feed_items (feed_id, item_id, title, link, published_at) VALUES (?, ?, ?, ?, ?)", [ + // Blocked items are still recorded as seen so removing a filter + // later doesn't re-post old items. + const blocked = shouldBlockItem(item, filterRows); + + const [insertResult] = (await ctx.db.execute("INSERT IGNORE INTO feed_items (feed_id, item_id, title, link, published_at) VALUES (?, ?, ?, ?, ?)", [ feedId, item.id ?? null, item.title ?? null, item.link ?? null, item.published ?? null, - ]); + ])) as [{ affectedRows: number }, unknown]; - if (insertResult.affectedRows > 0 && shouldPost) { + if (insertResult.affectedRows > 0 && shouldPost && !blocked) { await postFeedItem(ctx, feed, item); newItemsCount++; } @@ -271,7 +409,7 @@ async function _processFeedItems(ctx, feed, shouldPost) { return shouldPost ? { newItemsCount } : undefined; } -async function _removeFeedAndNotify(ctx, feed, status) { +async function _removeFeedAndNotify(ctx: Ctx, feed: Feed, status: number): Promise { const reason = "now requires payment"; console.log(`Feed ${feed.url} ${reason} — removing`); @@ -290,11 +428,11 @@ async function _removeFeedAndNotify(ctx, feed, status) { } } -async function checkFeed(ctx, feed) { +async function checkFeed(ctx: Ctx, feed: Feed): Promise<{ newItemsCount: number; error?: boolean }> { try { - return await _processFeedItems(ctx, feed, true); + return (await _processFeedItems(ctx, feed, true)) ?? { newItemsCount: 0 }; } catch (error) { - if (error.feedGone) { + if (error instanceof FeedGoneError) { await _removeFeedAndNotify(ctx, feed, error.status); return { newItemsCount: 0, error: true }; } @@ -303,7 +441,7 @@ async function checkFeed(ctx, feed) { } } -async function initialiseFeed(ctx, feed) { +async function initialiseFeed(ctx: Ctx, feed: Feed): Promise { try { await _processFeedItems(ctx, feed, false); } catch (error) { @@ -311,7 +449,7 @@ async function initialiseFeed(ctx, feed) { } } -async function postFeedItem(ctx, feed, item) { +async function postFeedItem(ctx: Ctx, feed: Feed, item: FeedItem): Promise { try { const channelData = await ctx.stoat.getChannel(feed.channel_id); if (!channelData) { @@ -322,14 +460,14 @@ async function postFeedItem(ctx, feed, item) { return; } - await ctx.stoat.sendMessage(channelData._id, formatFeedMessage(item)); + await ctx.stoat.sendMessage(channelData._id, formatFeedMessage(item, feed)); } catch (error) { console.error("Error posting feed item:", error); await notifyServerOwner(ctx, feed); } } -async function notifyServerOwner(ctx, feed) { +async function notifyServerOwner(ctx: Ctx, feed: Feed): Promise { const cacheKey = `${feed.server_id}-${feed.channel_id}`; if (ctx.notifiedOwners.has(cacheKey)) return; @@ -349,8 +487,9 @@ async function notifyServerOwner(ctx, feed) { } } -module.exports = { +export { detectFeedType, + looksLikePodcast, shouldSkipFeedCheck, processCacheHeaders, parseXmlFeed, @@ -359,6 +498,10 @@ module.exports = { checkFeed, initialiseFeed, formatFeedMessage, + formatItunesDuration, + shouldBlockItem, + FILTER_FIELDS, + FILTER_OPERATORS, postFeedItem, notifyServerOwner, }; diff --git a/lib/stoat.js b/lib/stoat.ts similarity index 63% rename from lib/stoat.js rename to lib/stoat.ts index 403ebb4..f886345 100644 --- a/lib/stoat.js +++ b/lib/stoat.ts @@ -1,37 +1,74 @@ "use strict"; -const { JSONParse } = require("json-with-bigint"); +import { JSONParse } from "json-with-bigint"; const API_BASE = process.env["STOAT_API_URL"] || "https://api.stoat.chat/0.8"; // Permission bit masks used by fetchMember's hasPermission wrapper -const PERM = { ManageChannel: 1n, ManageServer: 2n }; +const PERM: Record = { ManageChannel: 1n, ManageServer: 2n }; + +export interface BotUser { + _id: string; + username?: string; + bot?: boolean; +} + +export interface ChannelData { + _id: string; + channel_type?: string; + recipients?: string[]; + server?: string; +} + +export interface ServerData { + _id: string; + owner?: string; +} + +export interface MemberContext { + hasPermission(...perms: string[]): boolean; +} + +export interface MessageContext { + id: string; + authorId: string; + channelId: string; + content: string; + author: BotUser | null; + channel: { id: string; type: string | null; server: { id: string } | null } | null; + server: { id: string; ownerId: string; fetchMember: (userId: string) => Promise } | null; + reply: (data: string | object) => Promise; + edit: (data: object) => Promise; +} + +class StoatApiError extends Error { + status?: number; + type?: string; +} class StoatClient { - constructor() { - this.botUser = null; - this.wsPing = -1; + botUser: BotUser | null = null; + wsPing = -1; - this._token = null; - this._ws = null; - this._wsUrl = null; - this._heartbeatTimer = null; - this._pongTimer = null; + private _token: string | null = null; + private _ws: WebSocket | null = null; + private _wsUrl: string | null = null; + private _heartbeatTimer: ReturnType | null = null; + private _pongTimer: ReturnType | null = null; - // Caches populated from the Ready event / API calls - this._users = new Map(); - this._servers = new Map(); - this._channels = new Map(); - this._members = new Map(); // "serverId:userId" → data + // Caches populated from the Ready event / API calls + private _users = new Map(); + private _servers = new Map(); + private _channels = new Map(); + private _members = new Map(); // "serverId:userId" → data - // Callbacks - this._onMessage = null; + // Callbacks + private _onMessage: ((event: any) => void) | null = null; - // Track intentional disconnects so onclose doesn't kill the process - this._intentionalClose = false; - } + // Track intentional disconnects so onclose doesn't kill the process + private _intentionalClose = false; - async _request(method, path, { body, extraHeaders, params } = {}) { + async _request(method: string, path: string, { body, extraHeaders, params }: { body?: unknown; extraHeaders?: Record; params?: Record } = {}): Promise { const url = params ? (() => { const u = new URL(API_BASE + path); @@ -42,7 +79,7 @@ class StoatClient { })() : API_BASE + path; - const fetchOpts = { + const fetchOpts: RequestInit = { method, headers: { ...(body !== undefined ? { "Content-Type": "application/json" } : {}), @@ -56,10 +93,10 @@ class StoatClient { if (!res.ok) { const text = await res.text(); - const err = new Error(`${method} ${path} → ${res.status}`); + const err = new StoatApiError(`${method} ${path} → ${res.status}`); err.status = res.status; try { - const p = JSON.parse(text); + const p = JSON.parse(text) as { type?: string }; if (p.type) err.type = p.type; } catch (_) { /* ignore non-JSON error bodies */ @@ -70,30 +107,30 @@ class StoatClient { return JSONParse(await res.text()); } - _authHeaders() { - return { "x-bot-token": this._token }; + _authHeaders(): Record { + return { "x-bot-token": this._token ?? "" }; } - async get(path, params) { + async get(path: string, params?: Record): Promise { return this._request("GET", path, { params }); } - async post(path, body, extraHeaders) { + async post(path: string, body?: unknown, extraHeaders?: Record): Promise { return this._request("POST", path, { body, extraHeaders }); } - async patch(path, body) { + async patch(path: string, body?: unknown): Promise { return this._request("PATCH", path, { body }); } - async sendMessage(channelId, data) { + async sendMessage(channelId: string, data: string | object): Promise { const body = typeof data === "string" ? { content: data } : data; return this.post(`/channels/${channelId}/messages`, body, { "Idempotency-Key": crypto.randomUUID(), }); } - async replyToMessage(event, data, mention) { + async replyToMessage(event: { channel: string; _id: string }, data: string | object, mention?: boolean): Promise<{ id: string; edit: (d: object) => Promise }> { const body = typeof data === "string" ? { content: data } : data; const result = await this.post(`/channels/${event.channel}/messages`, { ...body, replies: [{ id: event._id, mention: mention ?? true }] }, { "Idempotency-Key": crypto.randomUUID() }); if (!result) { @@ -105,7 +142,7 @@ class StoatClient { }; } - makeMessageContext(event) { + makeMessageContext(event: any): MessageContext { const channelData = this._channels.get(event.channel); // Derive the server ID from the channel cache, or fall back to the member data embedded in the Message event (so commands in channels created after the Ready event still work). const serverId = channelData?.server || event.member?._id?.server; @@ -119,7 +156,7 @@ class StoatClient { author: this._users.get(event.author) ?? null, channel: channelData - ? { id: channelData._id, type: channelData.channel_type, server: serverData ? { id: serverData._id } : null } + ? { id: channelData._id, type: channelData.channel_type ?? null, server: serverData ? { id: serverData._id } : null } : serverId ? { id: event.channel, type: null, server: { id: serverId } } : null, @@ -128,7 +165,7 @@ class StoatClient { ? { id: serverData._id, ownerId: serverData.owner, - fetchMember: (userId) => this.fetchMember(serverData._id, userId), + fetchMember: (userId: string) => this.fetchMember(serverData._id, userId), } : null, @@ -137,7 +174,7 @@ class StoatClient { }; } - async openDM(userId) { + async openDM(userId: string): Promise<{ id: string; sendMessage: (data: string | object) => Promise }> { // Look for an existing DM in the cache for (const ch of this._channels.values()) { if (ch.channel_type === "DirectMessage" && ch.recipients?.includes(userId)) { @@ -154,10 +191,10 @@ class StoatClient { /** * Public accessor — returns cached channel data or fetches + caches it. */ - async getChannel(channelId) { - let data = this._channels.get(channelId); + async getChannel(channelId: string): Promise { + let data = this._channels.get(channelId) as ChannelData | undefined; if (!data) { - data = await this.get(`/channels/${channelId}`); + data = (await this.get(`/channels/${channelId}`)) as ChannelData | undefined; if (data) this._channels.set(data._id, data); } return data ?? null; @@ -166,16 +203,16 @@ class StoatClient { /** * Public accessor — returns cached server data or fetches + caches it. */ - async getServer(serverId) { - let data = this._servers.get(serverId); + async getServer(serverId: string): Promise { + let data = this._servers.get(serverId) as ServerData | undefined; if (!data) { - data = await this.get(`/servers/${serverId}`); + data = (await this.get(`/servers/${serverId}`)) as ServerData | undefined; if (data) this._servers.set(data._id, data); } return data ?? null; } - async fetchMember(serverId, userId) { + async fetchMember(serverId: string, userId: string | { id: string }): Promise { const id = typeof userId === "string" ? userId : userId.id; const key = serverId + ":" + id; @@ -189,7 +226,7 @@ class StoatClient { const serverData = this._servers.get(serverId); return { - hasPermission(...perms) { + hasPermission(...perms: string[]): boolean { const needed = perms.reduce((a, n) => { const bit = PERM[n]; if (bit === undefined) { @@ -203,7 +240,7 @@ class StoatClient { let perm = typeof serverData?.default_permissions === "bigint" ? serverData.default_permissions : BigInt(serverData?.default_permissions ?? 0); if (m.roles && serverData?.roles) { - for (const roleId of m.roles) { + for (const roleId of m.roles as string[]) { const role = serverData.roles[roleId]; if (role?.permissions) { const a = typeof role.permissions.a === "bigint" ? role.permissions.a : BigInt(role.permissions.a ?? 0); @@ -218,44 +255,45 @@ class StoatClient { }; } - async connect(token) { + async connect(token: string): Promise { this._token = token; await this._fetchWsUrl(); return this._connectWS(); } - async _fetchWsUrl() { + async _fetchWsUrl(): Promise { const res = await fetch(API_BASE); if (!res.ok) throw new Error(`API root returned ${res.status}`); - const data = JSONParse(await res.text()); - const ws = data.ws ?? data.gateway ?? data.ws_url ?? data.websocket_url; + const data = JSONParse(await res.text()) as Record; + const ws = data["ws"] ?? data["gateway"] ?? data["ws_url"] ?? data["websocket_url"]; if (typeof ws !== "string") throw new Error("API root missing WebSocket URL"); this._wsUrl = ws; } - disconnect() { + disconnect(): void { this._intentionalClose = true; this._cleanupWS(); } - onMessage(fn) { + onMessage(fn: (event: any) => void): void { this._onMessage = fn; } - _connectWS() { + _connectWS(): Promise { this._cleanupWS(); this._intentionalClose = false; + if (!this._wsUrl) return Promise.reject(new Error("WebSocket URL not set")); return new Promise((resolve, reject) => { - const url = new URL(this._wsUrl); + const url = new URL(this._wsUrl as string); url.searchParams.set("version", "1"); url.searchParams.set("format", "json"); - url.searchParams.set("token", this._token); + url.searchParams.set("token", this._token ?? ""); const ws = new WebSocket(url.toString()); this._ws = ws; - const connectTimer = setTimeout(() => { + const connectTimer: ReturnType = setTimeout(() => { ws.close(); reject(new Error("WebSocket connection timeout")); }, 10_000); @@ -272,7 +310,7 @@ class StoatClient { ws.onmessage = (event) => { clearTimeout(connectTimer); - const raw = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); + const raw = typeof event === "string" ? event : typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as ArrayBuffer); this._handleWSEvent(JSONParse(raw), resolve); }; @@ -292,9 +330,9 @@ class StoatClient { }); } - _cleanupWS() { - clearInterval(this._heartbeatTimer); - clearTimeout(this._pongTimer); + _cleanupWS(): void { + clearInterval(this._heartbeatTimer ?? undefined); + clearTimeout(this._pongTimer ?? undefined); this._heartbeatTimer = null; this._pongTimer = null; if (this._ws) { @@ -307,7 +345,7 @@ class StoatClient { } } - _handleWSEvent(event, readyResolve) { + _handleWSEvent(event: any, readyResolve?: () => void): void { switch (event.type) { case "Ping": if (this._ws && this._ws.readyState === WebSocket.OPEN) { @@ -316,7 +354,7 @@ class StoatClient { break; case "Pong": - clearTimeout(this._pongTimer); + clearTimeout(this._pongTimer ?? undefined); this.wsPing = +new Date() - event.data; break; @@ -370,4 +408,4 @@ class StoatClient { } } -module.exports = { StoatClient, API_BASE }; +export { StoatClient, StoatApiError, API_BASE }; diff --git a/lib/templates.ts b/lib/templates.ts new file mode 100644 index 0000000..4f10b5e --- /dev/null +++ b/lib/templates.ts @@ -0,0 +1,81 @@ +"use strict"; + +import type { FeedItem } from "./types"; + +/** + * Maps template variable names to standardised item fields. The field names are produced by parseXmlFeed/parseJsonFeed so the same variables work across RSS, Atom, JSON, and podcast feeds. + */ +const TEMPLATE_VAR_FIELDS: Record = { + title: "title", + body: "description", + bodyHtml: "bodyHtml", + link: "link", + id: "id", + author: "author", + categories: "categories", + audio: "audioUrl", + audioType: "audioType", + audioLength: "audioLength", + duration: "duration", + season: "seasonNumber", + episode: "episodeNumber", + type: "episodeType", + feedTitle: "feedTitle", + feedLink: "feedLink", +}; + +const DEFAULT_PUB_TIME_FORMAT = "YYYY-MM-DD HH:mm"; + +function isKnownTemplateVar(name: string): boolean { + return name === "pubTime" || name === "pubTimeUnix" || Object.hasOwn(TEMPLATE_VAR_FIELDS, name); +} + +/** + * Format a date using strftime-like tokens: YYYY, MM, DD, HH, mm, ss (UTC). + */ +function formatTemplateDate(date: unknown, fmt?: string): string { + const d = date instanceof Date && !isNaN(date.getTime()) ? date : new Date(); + const pad = (n: number): string => String(n).padStart(2, "0"); + const tokens: Record = { + YYYY: String(d.getUTCFullYear()), + MM: pad(d.getUTCMonth() + 1), + DD: pad(d.getUTCDate()), + HH: pad(d.getUTCHours()), + mm: pad(d.getUTCMinutes()), + ss: pad(d.getUTCSeconds()), + }; + return String(fmt || DEFAULT_PUB_TIME_FORMAT).replace(/YYYY|MM|DD|HH|mm|ss/g, (m) => tokens[m] ?? m); +} + +/** + * Render a template, replacing {var} placeholders with values from the item. + * {pubTime} accepts an optional format: {pubTime:YYYY-MM-DD}. Unknown variables are left as-is so typos are visible in the output. + */ +function renderTemplate(template: string, item: FeedItem): string | null { + if (template === "") return null; + + return template.replace(/\{([a-zA-Z]+)(?::([^}]+))?\}/g, (match, name: string, fmt?: string) => { + if (name === "pubTime") return formatTemplateDate(item.published, fmt); + if (name === "pubTimeUnix") { + const d = item.published instanceof Date && !isNaN(item.published.getTime()) ? item.published : null; + return d ? String(Math.floor(d.getTime() / 1000)) : ""; + } + + const key = TEMPLATE_VAR_FIELDS[name]; + if (!key) return match; + + const value = item[key]; + if (value === null || value === undefined || value === "") return ""; + return Array.isArray(value) ? value.join(", ") : String(value); + }); +} + +/** + * Return the names of unknown variables used in a template ([] if all known). + */ +function unknownTemplateVars(template: string): string[] { + const names = [...template.matchAll(/\{([a-zA-Z]+)(?::[^}]+)?\}/g)].map((m) => m[1]); + return [...new Set(names.filter((name) => name !== undefined && !isKnownTemplateVar(name)))]; +} + +export { TEMPLATE_VAR_FIELDS, DEFAULT_PUB_TIME_FORMAT, isKnownTemplateVar, formatTemplateDate, renderTemplate, unknownTemplateVars }; diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..b20fd33 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,59 @@ +import type { Pool } from "mysql2/promise"; +import type Parser from "rss-parser"; +import type { StoatClient } from "./stoat"; + +export type FeedType = "rss" | "atom" | "json" | "podcast"; + +export interface Feed { + id?: number; + url: string; + channel_id: string; + server_id: string; + feed_type: FeedType; + template?: string | null; + etag?: string | null; + lastModified?: string | null; + nextCheckTime?: number; + ttl?: number; + skipHours?: number[]; + skipDays?: string[]; + last_updated?: Date; + created_at?: Date; +} + +export interface FeedItem { + id?: string | null; + title?: string | null; + link?: string | null; + description?: string | null; + bodyHtml?: string | null; + published?: Date; + author?: string | null; + categories: string[]; + audioUrl?: string | null; + audioType?: string | null; + audioLength?: string | number | null; + isPodcast?: boolean; + episodeType?: string | null; + episodeNumber?: string | number | null; + seasonNumber?: string | number | null; + duration?: string | null; + feedTitle?: string | null; + feedLink?: string | null; +} + +export interface FilterRule { + id: number; + field_name: string; + operator: string; + filter_value: string | null; +} + +export interface Ctx { + stoat: StoatClient; + db: Pool; + feeds: Map; + parser: Parser; + notifiedOwners: Set; + setBotStatus: () => Promise; +} diff --git a/lib/utils.js b/lib/utils.js deleted file mode 100644 index 431be81..0000000 --- a/lib/utils.js +++ /dev/null @@ -1,9 +0,0 @@ -"use strict"; - -const VERBOSE = process.env.VERBOSE_LOGGING === "true" || process.env.VERBOSE_LOGGING === "1"; - -function verboseLog(...args) { - if (VERBOSE) console.log(...args); -} - -module.exports = { VERBOSE, verboseLog }; diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..9d8a5b4 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,29 @@ +"use strict"; + +const VERBOSE = process.env["VERBOSE_LOGGING"] === "true" || process.env["VERBOSE_LOGGING"] === "1"; + +function verboseLog(...args: unknown[]): void { + if (VERBOSE) console.log(...args); +} + +/** + * Normalise a URL given in a command. Strips surrounding angle brackets + */ +function stripUrlBrackets(url: string): string { + let cleaned = url.trim(); + if (cleaned.startsWith("<") && cleaned.endsWith(">")) { + cleaned = cleaned.slice(1, -1).trim(); + } + return cleaned; +} + +/** + * Wrap a URL in angle brackets so markdown renders it as a plain link. + * Used for bot replies; posted feed items use bare links so embeds render. + */ +function formatUrlForMarkdown(url: string): string { + if (url === "") return url; + return `<${url}>`; +} + +export { VERBOSE, verboseLog, stripUrlBrackets, formatUrlForMarkdown }; diff --git a/main.js b/main.js deleted file mode 100644 index 8b0c148..0000000 --- a/main.js +++ /dev/null @@ -1,125 +0,0 @@ -"use strict"; - -const { StoatClient } = require("./lib/stoat"); -const { initDatabase, loadFeeds } = require("./lib/database"); -const { setupCommands } = require("./lib/commands"); -const { checkAllFeeds } = require("./lib/feeds"); -const cron = require("node-cron"); -const Parser = require("rss-parser"); - -class AutoFeeds { - constructor() { - this.stoat = new StoatClient(); - this.parser = new Parser({ - customFields: { - feed: ["language", "ttl", "skipHours", "skipDays"], - item: ["guid", "pubDate", "published", "updated"], - }, - }); - this.db = null; - this.feeds = new Map(); - this.notifiedOwners = new Set(); - - this._setupErrorHandlers(); - } - - _setupErrorHandlers() { - process.on("uncaughtException", (error) => { - console.error("Uncaught Exception:", error); - process.exit(1); - }); - - process.on("unhandledRejection", (reason, promise) => { - console.error("Unhandled Rejection at:", promise, "reason:", reason); - process.exit(1); - }); - } - - async init() { - try { - this.db = await initDatabase(); - this.feeds = await loadFeeds(this.db); - - const maxRetries = 5; - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - await this.stoat.connect(process.env["BOT_TOKEN"]); - break; - } catch (error) { - console.error(`Bot connection attempt ${attempt}/${maxRetries} failed:`, error); - if (attempt === maxRetries) throw error; - console.log("Retrying bot connection in 5 seconds..."); - await new Promise((resolve) => setTimeout(resolve, 5000)); - } - } - - await this._setBotStatus(); - - const ctx = { - stoat: this.stoat, - db: this.db, - feeds: this.feeds, - parser: this.parser, - notifiedOwners: this.notifiedOwners, - setBotStatus: () => this._setBotStatus(), - }; - - cron.schedule("*/20 * * * *", () => checkAllFeeds(ctx)); - setupCommands(ctx); - } catch (error) { - console.error("Failed to initialise bot:", error); - process.exit(1); - } - } - - async _setBotStatus() { - const botName = this.stoat.botUser?.username || "AutoFeeds"; - const statusText = `@${botName} help | Handling ${this.feeds.size} feeds`; - - try { - if (!this.stoat.botUser) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - - await this.stoat.patch("/users/@me", { - status: { text: statusText, presence: "Online" }, - }); - - console.log(`Bot status updated: ${statusText}`); - } catch (error) { - console.error("Failed to update bot status:", error.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((error) => { - console.error("Failed to start bot:", error); - process.exit(1); -}); - -async function shutdown() { - console.log("Shutting down bot gracefully..."); - bot.stoat.disconnect(); - if (bot.db) { - try { - await bot.db.end(); - console.log("Database connection pool closed."); - } catch (error) { - console.error("Error closing database connection pool:", error); - } - } - process.exit(0); -} - -process.on("SIGINT", shutdown); -process.on("SIGTERM", shutdown); diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..f683b79 --- /dev/null +++ b/main.ts @@ -0,0 +1,159 @@ +"use strict"; + +import { StoatClient } from "./lib/stoat"; +import { initDatabase, loadFeeds } from "./lib/database"; +import { setupCommands } from "./lib/commands"; +import { checkAllFeeds } from "./lib/feeds"; +import { schedule } from "node-cron"; +import Parser from "rss-parser"; +import type { Pool } from "mysql2/promise"; +import type { Ctx, Feed } from "./lib/types"; + +const STATUS_UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000; + +class AutoFeeds { + stoat = new StoatClient(); + parser = new Parser({ + customFields: { + feed: ["language", "ttl", "skipHours", "skipDays"], + item: [ + "guid", + "pubDate", + "published", + "updated", + ["itunes:duration", "itunesDuration"], + ["itunes:episode", "itunesEpisode"], + ["itunes:season", "itunesSeason"], + ["itunes:episodeType", "itunesEpisodeType"], + ["itunes:author", "itunesAuthor"], + ["podcast:guid", "podcastGuid"], + ["podcast:episode", "podcastEpisode"], + ["podcast:season", "podcastSeason"], + ], + }, + }); + db: Pool | null = null; + feeds = new Map(); + notifiedOwners = new Set(); + private _lastStatusUpdate = 0; + + constructor() { + this._setupErrorHandlers(); + } + + _setupErrorHandlers(): void { + process.on("uncaughtException", (error) => { + console.error("Uncaught Exception:", error); + process.exit(1); + }); + + process.on("unhandledRejection", (reason, promise) => { + console.error("Unhandled Rejection at:", promise, "reason:", reason); + process.exit(1); + }); + } + + async init(): Promise { + try { + this.db = await initDatabase(); + this.feeds = await loadFeeds(this.db); + + const maxRetries = 5; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await this.stoat.connect(process.env["BOT_TOKEN"] ?? ""); + break; + } catch (error) { + console.error(`Bot connection attempt ${attempt}/${maxRetries} failed:`, error); + if (attempt === maxRetries) throw error; + console.log("Retrying bot connection in 5 seconds..."); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + } + + await this._setBotStatus(); + + const ctx: Ctx = { + stoat: this.stoat, + db: this.db, + feeds: this.feeds, + parser: this.parser, + notifiedOwners: this.notifiedOwners, + setBotStatus: () => this._setBotStatus(), + }; + + schedule("*/20 * * * *", () => checkAllFeeds(ctx)); + setupCommands(ctx); + } catch (error) { + console.error("Failed to initialise bot:", error); + process.exit(1); + } + } + + async _setBotStatus(): Promise { + if (Date.now() - this._lastStatusUpdate < STATUS_UPDATE_INTERVAL_MS) return; + + const botName = this.stoat.botUser?.username || "AutoFeeds"; + const statusText = `@${botName} help | Handling ${formatFeedCount(this.feeds.size)} feeds`; + + try { + if (!this.stoat.botUser) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + this._lastStatusUpdate = Date.now(); + await this.stoat.patch("/users/@me", { + status: { text: statusText, presence: "Online" }, + }); + + console.log(`Bot status updated: ${statusText}`); + } catch (error) { + console.error("Failed to update bot status:", (error as Error).message); + } + } +} + +/** + * Format a feed count using general figures: 42, 1K+, 15K+, 1M+. + */ +function formatFeedCount(count: number): string { + if (count >= 1_000_000) return `${Math.floor(count / 1_000_000)}M+`; + if (count >= 1000) return `${Math.floor(count / 1000)}K+`; + return `${count}`; +} + +if (import.meta.main) { + 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((error) => { + console.error("Failed to start bot:", error); + process.exit(1); + }); + + async function shutdown(): Promise { + console.log("Shutting down bot gracefully..."); + bot.stoat.disconnect(); + if (bot.db) { + try { + await bot.db.end(); + console.log("Database connection pool closed."); + } catch (error) { + console.error("Error closing database connection pool:", error); + } + } + process.exit(0); + } + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +export { AutoFeeds, formatFeedCount }; diff --git a/package.json b/package.json index 5d9c129..0bf86ed 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "autofeeds", "version": "1.0.0", - "description": "A feed bot for Stoat that supports RSS, Atom, and JSON feeds.", - "main": "main.js", + "description": "A feed bot for Stoat that supports RSS, Atom, JSON, and podcast feeds.", + "main": "main.ts", "scripts": { - "start": "bun main.js", - "dev": "bun main.js" + "start": "bun main.ts", + "dev": "bun main.ts", + "typecheck": "tsc --noEmit" }, "dependencies": { "json-with-bigint": "^3.5.11", @@ -18,7 +19,12 @@ "rss", "atom", "json-feed", + "podcast", "bot", "feeds" - ] + ], + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^7.0.2" + } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d95b6c2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "lib": ["ESNext"], + "types": ["bun"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["main.ts", "lib/**/*.ts"] +}