diff --git a/.github/workflows/feedbot.yml b/.github/workflows/feedbot.yml index 2afff40..9ffd94f 100644 --- a/.github/workflows/feedbot.yml +++ b/.github/workflows/feedbot.yml @@ -17,8 +17,8 @@ jobs: uses: actions/cache@v2 with: path: ./slackfeedbot-cache - key: feed-cache-e-${{ steps.generate-key.outputs.cache-key }} - restore-keys: feed-cache-e- + key: feed-cache-i-${{ steps.generate-key.outputs.cache-key }} + restore-keys: feed-cache-i- - name: LAT uses: 'selfagency/slackfeedbot@dev' with: diff --git a/README.md b/README.md index 38888da..0c65edf 100644 --- a/README.md +++ b/README.md @@ -14,17 +14,21 @@ Push RSS feed updates to Slack via GitHub Actions 4. Create a new workflow in your desired repository (e.g. `.github/workflows/slackfeedbot.yml`) and drop in the follwing, where: - - `rss` is an RSS feed URL. - - `slack_webhook` is the URL of your Slack webhook (this can and probably + - `rss`: An RSS feed URL. + - `slack_webhook`: The URL of your Slack webhook (this can and probably should be a repository or organization secret). - - `cache_dir` is the folder in which you want to cache RSS data to prevent + - `cache_dir`: The folder in which you want to cache RSS data to prevent publishing duplicates (e.g., `./slackfeedbot-cache`), or alternately... - - `interval` is the number of minutes between runs of the parent workflow, as + - `interval`: The number of minutes between runs of the parent workflow, as specified in the `cron` section of the `schedule` workflow trigger (may publish duplicates due to post pinning). - - `unfurl` tells Slack to show the [Open Graph](https://ogp.me/) preview. If - set to `false` the title, date, short description, and a link to the feed item - will be posted. Defaults to `false` because it's kind of flaky. + - `unfurl`: Tells Slack to show the [Open Graph](https://ogp.me/) preview. + Defaults to `false` because it's kind of flaky. Not customizable. Use + the below settings for customizd display. + - `show_desc`: Whether to show the post description. Defaults to `true`. + - `show_img`: Whether to show the post image. Defaults to `true`. + - `show_date`: Whether to show the post date. Defaults to `true`. + - `show_link`: Whether to show the Read more link, linking back to the post. Defaults to `true`. ## Examples @@ -54,7 +58,7 @@ jobs: key: feed-cache-${{ steps.generate-key.outputs.cache-key }} restore-keys: feed-cache- - name: NYT - uses: 'selfagency/slackfeedbot@v1.2.6' + uses: 'selfagency/slackfeedbot@v1.2.7' with: rss: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml' slack_webhook: ${{ secrets.SLACK_WEBHOOK }} @@ -75,45 +79,13 @@ jobs: runs-on: ubuntu-latest steps: - name: NYT - uses: 'selfagency/slackfeedbot@v1.2.6' + uses: 'selfagency/slackfeedbot@v1.2.7' with: rss: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml' slack_webhook: ${{ secrets.SLACK_WEBHOOK }} interval: 15 ``` -### Unfurl URLs - -``` -name: FeedBot -on: - schedule: - - cron: '*/15 * * * *' -jobs: - rss-to-slack: - runs-on: ubuntu-latest - steps: - - name: Generate cache key - uses: actions/github-script@v6 - id: generate-key - with: - script: | - core.setOutput('cache-key', new Date().valueOf()) - - name: Retrieve cache - uses: actions/cache@v2 - with: - path: ./slackfeedbot-cache - key: feed-cache-${{ steps.generate-key.outputs.cache-key }} - restore-keys: feed-cache- - - name: NYT - uses: 'selfagency/slackfeedbot@v1.2.6' - with: - rss: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml' - slack_webhook: ${{ secrets.SLACK_WEBHOOK }} - cache_dir: './slackfeedbot-cache' - unfurl: true -``` - ### Multiple feeds ``` @@ -138,13 +110,13 @@ jobs: key: feed-cache-${{ steps.generate-key.outputs.cache-key }} restore-keys: feed-cache- - name: LAT - uses: 'selfagency/slackfeedbot@v1.2.6' + uses: 'selfagency/slackfeedbot@v1.2.7' with: rss: 'https://www.latimes.com/rss2.0.xml' slack_webhook: ${{ secrets.SLACK_WEBHOOK }} cache_dir: './slackfeedbot-cache' - name: WaPo - uses: 'selfagency/slackfeedbot@v1.2.6' + uses: 'selfagency/slackfeedbot@v1.2.7' with: rss: 'https://feeds.washingtonpost.com/rss/homepage' slack_webhook: ${{ secrets.SLACK_WEBHOOK }} diff --git a/action.yml b/action.yml index 6a0d79f..c8bebf0 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,15 @@ inputs: interval: description: 'Minutes between workflow runs' required: false + show_date: + description: 'Show date in output' + required: false + show_link: + description: 'Show link in output' + required: false + show_description: + description: 'Show description in output' + required: false unfurl: description: 'Unfurl links' required: false diff --git a/dist/index.cjs b/dist/index.cjs index e819813..7d83873 100644 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -20070,6 +20070,178 @@ var require_showdown = __commonJS({ } }); +// node_modules/striptags/src/striptags.js +var require_striptags = __commonJS({ + "node_modules/striptags/src/striptags.js"(exports, module2) { + "use strict"; + (function(global2) { + if (typeof Symbol2 !== "function") { + var Symbol2 = function(name) { + return name; + }; + Symbol2.nonNative = true; + } + const STATE_PLAINTEXT = Symbol2("plaintext"); + const STATE_HTML = Symbol2("html"); + const STATE_COMMENT = Symbol2("comment"); + const ALLOWED_TAGS_REGEX = /<(\w*)>/g; + const NORMALIZE_TAG_REGEX = /<\/?([^\s\/>]+)/; + function striptags2(html, allowable_tags, tag_replacement) { + html = html || ""; + allowable_tags = allowable_tags || []; + tag_replacement = tag_replacement || ""; + let context = init_context(allowable_tags, tag_replacement); + return striptags_internal(html, context); + } + function init_striptags_stream(allowable_tags, tag_replacement) { + allowable_tags = allowable_tags || []; + tag_replacement = tag_replacement || ""; + let context = init_context(allowable_tags, tag_replacement); + return function striptags_stream(html) { + return striptags_internal(html || "", context); + }; + } + striptags2.init_streaming_mode = init_striptags_stream; + function init_context(allowable_tags, tag_replacement) { + allowable_tags = parse_allowable_tags(allowable_tags); + return { + allowable_tags, + tag_replacement, + state: STATE_PLAINTEXT, + tag_buffer: "", + depth: 0, + in_quote_char: "" + }; + } + function striptags_internal(html, context) { + if (typeof html != "string") { + throw new TypeError("'html' parameter must be a string"); + } + let allowable_tags = context.allowable_tags; + let tag_replacement = context.tag_replacement; + let state = context.state; + let tag_buffer = context.tag_buffer; + let depth = context.depth; + let in_quote_char = context.in_quote_char; + let output = ""; + for (let idx = 0, length = html.length; idx < length; idx++) { + let char = html[idx]; + if (state === STATE_PLAINTEXT) { + switch (char) { + case "<": + state = STATE_HTML; + tag_buffer += char; + break; + default: + output += char; + break; + } + } else if (state === STATE_HTML) { + switch (char) { + case "<": + if (in_quote_char) { + break; + } + depth++; + break; + case ">": + if (in_quote_char) { + break; + } + if (depth) { + depth--; + break; + } + in_quote_char = ""; + state = STATE_PLAINTEXT; + tag_buffer += ">"; + if (allowable_tags.has(normalize_tag(tag_buffer))) { + output += tag_buffer; + } else { + output += tag_replacement; + } + tag_buffer = ""; + break; + case '"': + case "'": + if (char === in_quote_char) { + in_quote_char = ""; + } else { + in_quote_char = in_quote_char || char; + } + tag_buffer += char; + break; + case "-": + if (tag_buffer === "": + if (tag_buffer.slice(-2) == "--") { + state = STATE_PLAINTEXT; + } + tag_buffer = ""; + break; + default: + tag_buffer += char; + break; + } + } + } + context.state = state; + context.tag_buffer = tag_buffer; + context.depth = depth; + context.in_quote_char = in_quote_char; + return output; + } + function parse_allowable_tags(allowable_tags) { + let tag_set = /* @__PURE__ */ new Set(); + if (typeof allowable_tags === "string") { + let match; + while (match = ALLOWED_TAGS_REGEX.exec(allowable_tags)) { + tag_set.add(match[1]); + } + } else if (!Symbol2.nonNative && typeof allowable_tags[Symbol2.iterator] === "function") { + tag_set = new Set(allowable_tags); + } else if (typeof allowable_tags.forEach === "function") { + allowable_tags.forEach(tag_set.add, tag_set); + } + return tag_set; + } + function normalize_tag(tag_buffer) { + let match = NORMALIZE_TAG_REGEX.exec(tag_buffer); + return match ? match[1].toLowerCase() : null; + } + if (typeof define === "function" && define.amd) { + define(function module_factory() { + return striptags2; + }); + } else if (typeof module2 === "object" && module2.exports) { + module2.exports = striptags2; + } else { + global2.striptags = striptags2; + } + })(exports); + } +}); + // node_modules/web-streams-polyfill/dist/ponyfill.es2018.js var require_ponyfill_es2018 = __commonJS({ "node_modules/web-streams-polyfill/dist/ponyfill.es2018.js"(exports, module2) { @@ -24479,7 +24651,7 @@ var checkCache = async (rss, cached) => { if (!cacheHit) output.push(item); } - import_core.default.debug(`Found ${output.length} new items`); + import_core.default.debug(`Found ${output.length} uncached items`); return output; } else { import_core.default.debug("Nothing to check"); @@ -24524,11 +24696,13 @@ var getFeed = async (rssFeed, cacheDir, interval) => { import_core2.default.debug(`Retrieving previously cached entries\u2026`); try { cached = await readCache(rssFeed, cacheDir); - toSend = await checkCache(rss, cached); + toSend = (await checkCache(rss, cached)).filter((item) => { + return (0, import_dayjs.default)(item.created).isAfter((0, import_dayjs.default)().subtract(1, "hour")); + }); } catch (err) { import_core2.default.debug(err.message); toSend = rss.items.filter((item) => { - return (0, import_dayjs.default)(item.created).isAfter((0, import_dayjs.default)().subtract(60, "minute")); + return (0, import_dayjs.default)(item.created).isAfter((0, import_dayjs.default)().subtract(1, "hour")); }); } } else if (interval) { @@ -24545,6 +24719,7 @@ var getFeed = async (rssFeed, cacheDir, interval) => { // src/lib/payload.ts var import_core4 = __toESM(require_core(), 1); +var import_dayjs2 = __toESM(require_dayjs_min(), 1); var import_html_to_text = __toESM(require_html_to_text2(), 1); // node_modules/linkedom/esm/shared/symbols.js @@ -28755,6 +28930,7 @@ setPrototypeOf(Document3, Document).prototype = Document.prototype; // src/lib/payload.ts var import_showdown = __toESM(require_showdown(), 1); +var import_striptags = __toESM(require_striptags(), 1); // src/lib/feedimg.ts var import_core3 = __toESM(require_core(), 1); @@ -29945,39 +30121,63 @@ var getFeedImg = async (rssFeed) => { // src/lib/payload.ts var converter = new import_showdown.default.Converter(); var html2txt = (0, import_html_to_text.compile)({ - wordwrap: 120 + wordwrap: 255 }); -var genPayload = async (filtered, unfiltered, rssFeed, unfurl) => { +var genPayload = async (filtered, unfiltered, rssFeed, unfurl, showDesc, showImg, showDate, showLink) => { try { - const blocks = filtered.map((item) => { + const blocks = []; + filtered.forEach((item) => { + var _a; let text = ""; if (!unfurl) { - if (item.title) - text += `*${html2txt(item.title)}* -`; if (item.description) { const { document: document2 } = parseHTML("
"); - let desc = item.description; - if (/>.+</.test(item.description)) { - desc = item.description.replace(/>/g, ">").replace(/</g, "<").replace(/\n/g, "").replace(//g, "\n").replace(/\\\\-/g, "-"); - } + const desc = (0, import_striptags.default)(item.description.replace(/>/g, ">").replace(/</g, "<"), ["p", "strong", "b", "em", "i", "a", "ul", "ol", "li"], " "); const markdown = converter.makeMarkdown(desc, document2); - text += `${html2txt(markdown).replace(/[Rr]ead more/g, "\u2026")} -`; + text += `${markdown.replace(/\\-/g, "-").replace(/\\\|/g, "|")}`; } - if (item.link) - text += `<${item.link}|Read more>`; - } else { - if (item.title) - text += `<${item.link}|${html2txt(item.title + item.created)}>`; } - return { - type: "section", - text: { - type: "mrkdwn", - text + if (item == null ? void 0 : item.title) { + blocks.push({ + type: "header", + text: { type: "plain_text", text: html2txt(item == null ? void 0 : item.title) } + }); + } + if (unfurl) { + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: `<${item == null ? void 0 : item.link}|Read more>` + } + }); + } else { + const fields = []; + if (showLink) { + fields.push({ + type: "mrkdwn", + text: `<${item == null ? void 0 : item.link}|Read more>` + }); } - }; + blocks.push({ + type: "section", + fields, + accessory: showImg && item.image ? { + type: "image", + image_url: item.image + } : void 0, + text: showDesc && !text.trim().toLowerCase().startsWith("read more") ? { + type: "mrkdwn", + text + } : void 0 + }); + if (showDate) { + blocks.push({ + type: "context", + elements: [{ type: "mrkdwn", text: `Published ${(_a = (0, import_dayjs2.default)(item == null ? void 0 : item.created)) == null ? void 0 : _a.format("MMM D @ h:mma")} UTC` }] + }); + } + } }); const payload = { as_user: false, @@ -30013,18 +30213,32 @@ var slack = async (payload, webhook) => { var import_core6 = __toESM(require_core(), 1); var validate = () => { import_core6.default.debug(`Validating inputs\u2026`); - if (!import_core6.default.getInput("rss") || !import_core6.default.getInput("rss").startsWith("http")) { + import_core6.default.debug(`Inputs: ${JSON.stringify({ + slackWebhook: import_core6.default.getInput("slack_webhook"), + rssFeed: import_core6.default.getInput("rss"), + cacheDir: import_core6.default.getInput("cache_dir"), + interval: import_core6.default.getInput("interval"), + unfurl: import_core6.default.getInput("unfurl"), + showDesc: import_core6.default.getInput("show_desc"), + showLink: import_core6.default.getInput("show_link"), + showDate: import_core6.default.getInput("show_date"), + showImg: import_core6.default.getInput("show_img") + })}`); + if (import_core6.default.getInput("rss").length === 0 || !import_core6.default.getInput("rss").startsWith("http")) { throw new Error("No feed or invalid feed specified"); } - if (!import_core6.default.getInput("slack_webhook") || !import_core6.default.getInput("slack_webhook").startsWith("https")) { + if (import_core6.default.getInput("slack_webhook").length === 0 || !import_core6.default.getInput("slack_webhook").startsWith("https")) { throw new Error("No Slack webhook or invalid webhook specified"); } - if (!import_core6.default.getInput("interval") && !import_core6.default.getInput("cache_dir")) { + if (import_core6.default.getInput("interval").length === 0 && import_core6.default.getInput("cache_dir").length === 0) { throw new Error("No interval or cache folder specified"); } - if (import_core6.default.getInput("interval") && parseInt(import_core6.default.getInput("interval")).toString() === "NaN") { + if (import_core6.default.getInput("interval").length > 0 && parseInt(import_core6.default.getInput("interval")).toString() === "NaN") { throw new Error("Invalid interval specified"); } + if (import_core6.default.getInput("unfurl").length > 0 && import_core6.default.getBooleanInput("unfurl") && (import_core6.default.getInput("show_desc").length > 0 || import_core6.default.getInput("show_link").length > 0 || import_core6.default.getInput("show_date").length > 0 || import_core6.default.getInput("show_img").length > 0)) { + throw new Error("Unfurled links cannot be styled with `show` options"); + } }; // src/action.ts @@ -30034,16 +30248,25 @@ var run = async () => { const slackWebhook = import_core7.default.getInput("slack_webhook"); const rssFeed = import_core7.default.getInput("rss"); const cacheDir = import_core7.default.getInput("cache_dir"); - const interval = import_core7.default.getInput("interval") ? parseInt(import_core7.default.getInput("interval")) : void 0; - let unfurl = false; - try { - unfurl = import_core7.default.getBooleanInput("unfurl"); - } catch (err) { - import_core7.default.debug(err.message); - } + const interval = import_core7.default.getInput("interval").length > 0 ? parseInt(import_core7.default.getInput("interval")) : void 0; + const unfurl = import_core7.default.getInput("unfurl").length > 0 ? import_core7.default.getBooleanInput("unfurl") : false; + const showDesc = import_core7.default.getInput("show_desc").length > 0 ? import_core7.default.getBooleanInput("show_desc") : true; + const showLink = import_core7.default.getInput("show_link").length > 0 ? import_core7.default.getBooleanInput("show_link") : true; + const showDate = import_core7.default.getInput("show_date").length > 0 ? import_core7.default.getBooleanInput("show_date") : true; + const showImg = import_core7.default.getInput("show_img").length > 0 ? import_core7.default.getBooleanInput("show_img") : true; + import_core7.default.debug(`Processed inputs: ${JSON.stringify({ + slackWebhook, + rssFeed, + cacheDir, + interval, + unfurl, + showDesc, + showLink, + showDate + })}`); const { filtered, unfiltered, cached } = await getFeed(rssFeed, cacheDir, interval); if (filtered.length) { - const payload = await genPayload(filtered, unfiltered, rssFeed, unfurl); + const payload = await genPayload(filtered, unfiltered, rssFeed, unfurl, showDesc, showImg, showDate, showLink); await slack(payload, slackWebhook); if (cacheDir) await writeCache((unfiltered == null ? void 0 : unfiltered.title) || "", rssFeed, cacheDir, filtered, cached); diff --git a/package-lock.json b/package-lock.json index 2f9cb18..6835eb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "slackfeedbot", - "version": "1.2.6", + "version": "1.2.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "slackfeedbot", - "version": "1.2.6", + "version": "1.2.7", "license": "MIT", "dependencies": { "@actions/core": "^1.6.0", @@ -16,7 +16,8 @@ "node-fetch": "^3.2.3", "object-sha": "^2.0.6", "rss-to-json": "^2.0.2", - "showdown": "^2.0.3" + "showdown": "^2.0.3", + "striptags": "^3.2.0" }, "devDependencies": { "@types/html-to-text": "^8.1.0", @@ -4228,6 +4229,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/striptags": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/striptags/-/striptags-3.2.0.tgz", + "integrity": "sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==" + }, "node_modules/strnum": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", @@ -7385,6 +7391,11 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, + "striptags": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/striptags/-/striptags-3.2.0.tgz", + "integrity": "sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==" + }, "strnum": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", diff --git a/package.json b/package.json index 503f159..0e6497b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "slackfeedbot", - "version": "1.2.6", + "version": "1.2.7", "description": "Push RSS feed updates to Slack via GitHub Actions", "repository": { "type": "git", @@ -32,7 +32,8 @@ "node-fetch": "^3.2.3", "object-sha": "^2.0.6", "rss-to-json": "^2.0.2", - "showdown": "^2.0.3" + "showdown": "^2.0.3", + "striptags": "^3.2.0" }, "devDependencies": { "@types/html-to-text": "^8.1.0", diff --git a/src/action.ts b/src/action.ts index 3adc897..5af1513 100644 --- a/src/action.ts +++ b/src/action.ts @@ -7,32 +7,44 @@ import { validate } from './lib/validate'; const run = async () => { try { - // validate inputs + // Validate inputs validate(); - // parse inputs + // Parse inputs const slackWebhook = core.getInput('slack_webhook'); const rssFeed = core.getInput('rss'); const cacheDir = core.getInput('cache_dir'); - const interval = core.getInput('interval') ? parseInt(core.getInput('interval')) : undefined; - let unfurl = false; - try { - unfurl = core.getBooleanInput('unfurl'); - } catch (err) { - core.debug((err).message); - } + const interval = core.getInput('interval').length > 0 ? parseInt(core.getInput('interval')) : undefined; + const unfurl = core.getInput('unfurl').length > 0 ? core.getBooleanInput('unfurl') : false; + const showDesc = core.getInput('show_desc').length > 0 ? core.getBooleanInput('show_desc') : true; + const showLink = core.getInput('show_link').length > 0 ? core.getBooleanInput('show_link') : true; + const showDate = core.getInput('show_date').length > 0 ? core.getBooleanInput('show_date') : true; + const showImg = core.getInput('show_img').length > 0 ? core.getBooleanInput('show_img') : true; + + core.debug( + `Processed inputs: ${JSON.stringify({ + slackWebhook, + rssFeed, + cacheDir, + interval, + unfurl, + showDesc, + showLink, + showDate + })}` + ); - // get rss feed items + // Get RSS feed items const { filtered, unfiltered, cached } = await getFeed(rssFeed, cacheDir, interval); if (filtered.length) { - // generate payload - const payload = await genPayload(filtered, unfiltered, rssFeed, unfurl); + // Generate payload + const payload = await genPayload(filtered, unfiltered, rssFeed, unfurl, showDesc, showImg, showDate, showLink); - // send payload to slack + // Send payload to Slack await slack(payload, slackWebhook); - // cache data + // Save cache data if (cacheDir) await writeCache(unfiltered?.title || '', rssFeed, cacheDir, filtered, cached); } else { core.info(`No new items found`); diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 61a6547..4d9cf25 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -8,11 +8,12 @@ const read = promisify(fs.readFile); const write = promisify(fs.writeFile); const md = promisify(fs.mkdir); +// Defines a CacheRecord, which is used to check if an item has already been published class CacheRecord { [index: string]: string | number | undefined; - feedTitle?: string; - title?: string; - date?: string | number; + feedTitle?: string; // title of the rss feed + title?: string; // title of the post + date?: string | number; // publish date of the post constructor(feedTitle?: string, title?: string, date?: number | string) { this.feedTitle = feedTitle; @@ -21,15 +22,19 @@ class CacheRecord { } } +// For hashing the CacheRecord const hash = (str: string): string => { return createHash('sha256').update(str).digest('hex'); }; +// Generates a hashable string from the CacheRecord const cacheSlug = (item: CacheRecord): string => { const { feedTitle, title, created } = item; + let slug = ''; slug += feedTitle?.toLowerCase().replace(/[^a-z0-9]/g, '-'); slug += title?.toLowerCase().replace(/[^a-z0-9]/g, '-'); + if (created) { const date = new Date(created); slug += `${slug}-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; @@ -37,6 +42,7 @@ const cacheSlug = (item: CacheRecord): string => { return slug; }; +// Reads the cache file and returns it as an array of hashes const readCache = async (rssFeed: string, cacheDir: string): Promise => { try { core.debug(`Retrieving previously published entries…`); @@ -53,15 +59,19 @@ const readCache = async (rssFeed: string, cacheDir: string): Promise = } }; +// Checks if an item has already been published by comparing hashed CacheRecords const checkCache = async (rss: RssFeed, cached: string[]): Promise => { try { if (rss?.items) { const output = []; + // For each item in the RSS feed for (const item of rss.items) { let cacheHit = false; + // Compare the item to the cached items for (const published in cached) { const record = new CacheRecord(rss.title, item.title, item.created); + if (cached[published] === hash(cacheSlug(record))) { cacheHit = true; core.debug(`Cache hit for ${item.title}`); @@ -71,7 +81,7 @@ const checkCache = async (rss: RssFeed, cached: string[]): Promise => { const url = new URL(rssFeed); const host = url.hostname diff --git a/src/lib/getfeed.ts b/src/lib/getfeed.ts index df90695..d7f2dc0 100644 --- a/src/lib/getfeed.ts +++ b/src/lib/getfeed.ts @@ -4,31 +4,39 @@ import { parse } from 'rss-to-json'; import { RssFeed, RssFeedItem } from '../types.d'; import { checkCache, readCache } from './cache'; +// Gets the feed, checks the cache (or time since last run), and returns new items const getFeed = async ( rssFeed: string, cacheDir: string | undefined, interval: number | undefined ): Promise<{ filtered: RssFeedItem[]; unfiltered: RssFeed; cached: string[] }> => { core.debug(`Retrieving ${rssFeed}…`); + const rss: RssFeed = await parse(rssFeed, {}); core.debug(`Feed has ${rss?.items?.length} items`); if (rss?.items?.length) { let toSend: RssFeedItem[] = []; let cached: string[] = []; + if (cacheDir) { core.debug(`Retrieving previously cached entries…`); + try { cached = await readCache(rssFeed, cacheDir); - toSend = await checkCache(rss, cached); + toSend = (await checkCache(rss, cached)).filter(item => { + return dayjs(item.created).isAfter(dayjs().subtract(1, 'hour')); + }); } catch (err) { core.debug((err).message); + toSend = rss.items.filter(item => { - return dayjs(item.created).isAfter(dayjs().subtract(60, 'minute')); + return dayjs(item.created).isAfter(dayjs().subtract(1, 'hour')); }); } } else if (interval) { core.debug(`Selecting items posted in the last ${interval} minutes…`); + toSend = rss.items.filter(item => { return dayjs(item.created).isAfter(dayjs().subtract(interval, 'minute')); }); diff --git a/src/lib/payload.ts b/src/lib/payload.ts index 10de442..a921145 100644 --- a/src/lib/payload.ts +++ b/src/lib/payload.ts @@ -1,54 +1,98 @@ import core from '@actions/core'; +import dayjs from 'dayjs'; import { compile } from 'html-to-text'; import { parseHTML } from 'linkedom'; import showdown from 'showdown'; +import striptags from 'striptags'; import type { Block, Payload, RssFeed, RssFeedItem } from '../types.d'; import { getFeedImg } from './feedimg'; const converter = new showdown.Converter(); const html2txt = compile({ - wordwrap: 120 + wordwrap: 255 }); +// Generates the payload to publish to Slack const genPayload = async ( filtered: RssFeedItem[], unfiltered: RssFeed, rssFeed: string, - unfurl: boolean + unfurl: boolean, + showDesc: boolean, + showImg: boolean, + showDate: boolean, + showLink: boolean ): Promise => { try { - const blocks: Block[] = filtered.map(item => { + const blocks: Block[] = []; + filtered.forEach(item => { let text = ''; if (!unfurl) { - if (item.title) text += `*${html2txt(item.title)}*\n`; if (item.description) { // core.debug(`Item description: ${item.description}`); const { document } = parseHTML('
'); - let desc = item.description; - if (/>.+</.test(item.description)) { - desc = item.description - .replace(/>/g, '>') - .replace(/</g, '<') - .replace(/\n/g, '') - .replace(//g, '\n') - .replace(/\\\\-/g, '-'); - } + const desc = striptags( + item.description.replace(/>/g, '>').replace(/</g, '<'), + ['p', 'strong', 'b', 'em', 'i', 'a', 'ul', 'ol', 'li'], + ' ' + ); const markdown = converter.makeMarkdown(desc, document); - text += `${html2txt(markdown).replace(/[Rr]ead more/g, '…')}\n`; + text += `${markdown.replace(/\\-/g, '-').replace(/\\\|/g, '|')}`; } - if (item.link) text += `<${item.link}|Read more>`; - } else { - if (item.title) text += `<${item.link}|${html2txt(item.title + item.created)}>`; } - return { - type: 'section', - text: { - type: 'mrkdwn', - text + if (item?.title) { + blocks.push({ + type: 'header', + text: { type: 'plain_text', text: html2txt(item?.title) } + }); + } + + if (unfurl) { + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `<${item?.link}|Read more>` + } + }); + } else { + const fields = []; + + if (showLink) { + fields.push({ + type: 'mrkdwn', + text: `<${item?.link}|Read more>` + }); } - }; + + blocks.push({ + type: 'section', + fields, + accessory: + showImg && item.image + ? { + type: 'image', + image_url: item.image + } + : undefined, + text: + showDesc && !text.trim().toLowerCase().startsWith('read more') + ? { + type: 'mrkdwn', + text + } + : undefined + }); + + if (showDate) { + blocks.push({ + type: 'context', + elements: [{ type: 'mrkdwn', text: `Published ${dayjs(item?.created)?.format('MMM D @ h:mma')} UTC` }] + }); + } + } }); const payload = { diff --git a/src/lib/slack.ts b/src/lib/slack.ts index 7fc0446..e553a26 100644 --- a/src/lib/slack.ts +++ b/src/lib/slack.ts @@ -2,6 +2,7 @@ import core from '@actions/core'; import fetch from 'node-fetch'; import { Payload } from '../types'; +// Publishes messages to Slack const slack = async (payload: Payload, webhook: string) => { const res = await fetch(webhook, { method: 'POST', diff --git a/src/lib/validate.ts b/src/lib/validate.ts index d5043c8..7b10889 100644 --- a/src/lib/validate.ts +++ b/src/lib/validate.ts @@ -1,23 +1,49 @@ import core from '@actions/core'; +// Validate inputs const validate = (): void => { core.debug(`Validating inputs…`); - if (!core.getInput('rss') || !core.getInput('rss').startsWith('http')) { + core.debug( + `Inputs: ${JSON.stringify({ + slackWebhook: core.getInput('slack_webhook'), + rssFeed: core.getInput('rss'), + cacheDir: core.getInput('cache_dir'), + interval: core.getInput('interval'), + unfurl: core.getInput('unfurl'), + showDesc: core.getInput('show_desc'), + showLink: core.getInput('show_link'), + showDate: core.getInput('show_date'), + showImg: core.getInput('show_img') + })}` + ); + + if (core.getInput('rss').length === 0 || !core.getInput('rss').startsWith('http')) { throw new Error('No feed or invalid feed specified'); } - if (!core.getInput('slack_webhook') || !core.getInput('slack_webhook').startsWith('https')) { + if (core.getInput('slack_webhook').length === 0 || !core.getInput('slack_webhook').startsWith('https')) { throw new Error('No Slack webhook or invalid webhook specified'); } - if (!core.getInput('interval') && !core.getInput('cache_dir')) { + if (core.getInput('interval').length === 0 && core.getInput('cache_dir').length === 0) { throw new Error('No interval or cache folder specified'); } - if (core.getInput('interval') && parseInt(core.getInput('interval')).toString() === 'NaN') { + if (core.getInput('interval').length > 0 && parseInt(core.getInput('interval')).toString() === 'NaN') { throw new Error('Invalid interval specified'); } + + if ( + core.getInput('unfurl').length > 0 && + core.getBooleanInput('unfurl') && + (core.getInput('show_desc').length > 0 || + core.getInput('show_link').length > 0 || + core.getInput('show_date').length > 0 || + core.getInput('show_img').length > 0) + ) { + throw new Error('Unfurled links cannot be styled with `show` options'); + } }; export { validate }; diff --git a/src/types.d.ts b/src/types.d.ts index 7411d76..8ce221f 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -30,15 +30,16 @@ interface Icons { icons?: Icon[]; } +/* eslint-disable camelcase */ interface Block { type?: string; - text?: { - type?: string; - text?: string; - }; + text?: string | Block; + fields?: Block[]; + image_url?: string; + accessory?: Block; + elements?: Block[]; } -/* eslint-disable camelcase */ interface Payload { as_user?: boolean; username?: string;