├── .nvmrc ├── .prettierignore ├── .gitattributes ├── .eslintignore ├── src ├── helpers │ ├── stripHTML.ts │ ├── extractTags.ts │ ├── isFeedItemNewer.ts │ ├── removeUndefinedProps.ts │ ├── convertArrayToObject.ts │ ├── formatPostContent.ts │ ├── getExtraEntryFields.ts │ └── postToSocialMedia.ts ├── index.ts ├── logger.ts ├── services │ ├── slack.ts │ ├── discord.ts │ ├── mastodon.ts │ ├── twitter.ts │ ├── bluesky.ts │ └── mastodon-metadata.ts ├── cache.ts ├── feed.ts ├── action.ts ├── config.ts └── types.ts ├── .prettierrc.json ├── jest.config.js ├── .github └── workflows │ └── publish.yml ├── tests ├── helpers │ ├── stripHTML.test.ts │ ├── formatPostContent.test.ts │ ├── removeUndefinedProps.test.ts │ ├── __snapshots__ │ │ └── getExtraEntryFields.test.ts.snap │ ├── extractTags.test.ts │ ├── convertArrayToObject.test.ts │ ├── isFeedItemNewer.test.ts │ ├── getExtraEntryFields.test.ts │ └── postToSocialMedia.test.ts ├── cache.test.ts ├── services │ ├── slack.test.ts │ ├── discord.test.ts │ ├── bluesky.test.ts │ ├── mastodon.test.ts │ ├── twitter.test.ts │ └── mastodon-metadata.test.ts ├── action.test.ts └── feed.test.ts ├── tsconfig.json ├── LICENSE ├── package.json ├── .gitignore ├── .eslintrc.json ├── action.yml └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/** -diff linguist-generated=true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | jest.config.js 5 | tests -------------------------------------------------------------------------------- /src/helpers/stripHTML.ts: -------------------------------------------------------------------------------- 1 | export const stripHTML = (content: string) => 2 | content.replace(/(<([^>]+)>)/gi, ''); 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore next */ 2 | import { runAction } from './action'; 3 | /* istanbul ignore next */ 4 | runAction(); 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "semi": true, 6 | "singleQuote": true 7 | } 8 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { info, warning, notice, debug } from '@actions/core'; 2 | 3 | export const logger = { 4 | info: (msg: string) => info(msg), 5 | warning: (msg: string) => warning(msg), 6 | notice: (msg: string) => notice(msg), 7 | debug: (msg: string) => debug(msg), 8 | }; 9 | -------------------------------------------------------------------------------- /src/helpers/extractTags.ts: -------------------------------------------------------------------------------- 1 | export const extractTags = (postFormat: string) => { 2 | const regex = /{([A-Za-z0-9:.]+)}/g; 3 | const matches = postFormat.match(regex); 4 | if (matches) { 5 | return matches.map((match) => match.slice(1, -1)); 6 | } else { 7 | return []; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | clearMocks: true, 6 | moduleFileExtensions: ['js', 'ts'], 7 | testMatch: ['**/*.test.ts'], 8 | transform: { 9 | '^.+\\.ts$': 'ts-jest', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/helpers/isFeedItemNewer.ts: -------------------------------------------------------------------------------- 1 | import { FeedItem } from '../types'; 2 | 3 | export const isFeedItemNewer = ({ 4 | feedItem, 5 | cachedItem, 6 | }: { 7 | feedItem?: FeedItem; 8 | cachedItem?: FeedItem; 9 | }) => { 10 | if (feedItem && cachedItem) { 11 | return feedItem.published > cachedItem.published; 12 | } 13 | 14 | return false; 15 | }; 16 | -------------------------------------------------------------------------------- /src/helpers/removeUndefinedProps.ts: -------------------------------------------------------------------------------- 1 | type GenericObject = { 2 | [key: string]: any; 3 | }; 4 | 5 | export const removeUndefinedProps = (obj: T) => { 6 | const result = {} as T; 7 | 8 | for (let prop in obj) { 9 | if (obj[prop] !== undefined) { 10 | result[prop] = obj[prop]; 11 | } 12 | } 13 | 14 | return result; 15 | }; 16 | -------------------------------------------------------------------------------- /src/helpers/convertArrayToObject.ts: -------------------------------------------------------------------------------- 1 | export const convertArrayToObject = ( 2 | arr: { [key: string]: string }[] 3 | ): { [key: string]: string } => { 4 | const result: { [key: string]: string } = {}; 5 | 6 | for (const item of arr) { 7 | const key = Object.keys(item)[0]; 8 | const value = Object.values(item)[0]; 9 | result[key] = value; 10 | } 11 | 12 | return result; 13 | }; 14 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published, edited] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | with: 14 | ref: ${{ github.event.release.tag_name }} 15 | - name: Install deps and build 16 | run: npm ci && npm run build 17 | - uses: JasonEtco/build-and-tag-action@v2 18 | env: 19 | GITHUB_TOKEN: ${{ github.token }} 20 | -------------------------------------------------------------------------------- /tests/helpers/stripHTML.test.ts: -------------------------------------------------------------------------------- 1 | import { stripHTML } from '../../src/helpers/stripHTML'; 2 | 3 | describe('stripHTML', () => { 4 | it('removes HTML tags from the content', () => { 5 | const content = '

Hello, World!

'; 6 | const result = stripHTML(content); 7 | expect(result).toBe('Hello, World!'); 8 | }); 9 | 10 | it('returns the original content if there are no HTML tags', () => { 11 | const content = 'Hello, World!'; 12 | const result = stripHTML(content); 13 | expect(result).toBe(content); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/helpers/formatPostContent.ts: -------------------------------------------------------------------------------- 1 | import { FeedItem } from '../types'; 2 | import { extractTags } from './extractTags'; 3 | 4 | export const formatPostContent = (content: FeedItem, postFormat: string) => { 5 | const itemTags = extractTags(postFormat); 6 | 7 | return itemTags.reduce((acc, itemTag) => { 8 | const nestedTag = itemTag.split('.'); 9 | 10 | if (nestedTag.length === 2) { 11 | return acc.replace( 12 | `{${itemTag}}`, 13 | (content[nestedTag[0]] as Record)[ 14 | nestedTag[1] 15 | ] as string 16 | ); 17 | } 18 | 19 | return acc.replace(`{${itemTag}}`, content[itemTag] as string); 20 | }, postFormat); 21 | }; 22 | -------------------------------------------------------------------------------- /tests/helpers/formatPostContent.test.ts: -------------------------------------------------------------------------------- 1 | import { formatPostContent } from '../../src/helpers/formatPostContent'; 2 | import { FeedItem } from '../../src/types'; 3 | 4 | describe('formatPostContent', () => { 5 | const content = { 6 | title: 'Sample Title', 7 | enclosure: { 8 | url: 'https://example.org', 9 | }, 10 | 'itunes:image': 'https://example.org/image.jpg', 11 | } as unknown as FeedItem; 12 | 13 | test('should format post content correctly', () => { 14 | const postFormat = 15 | 'Title: {title} / Image: {itunes:image} / URL: {enclosure.url}'; 16 | const expected = 17 | 'Title: Sample Title / Image: https://example.org/image.jpg / URL: https://example.org'; 18 | const formattedContent = formatPostContent(content, postFormat); 19 | expect(formattedContent).toEqual(expected); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /tests/helpers/removeUndefinedProps.test.ts: -------------------------------------------------------------------------------- 1 | import { removeUndefinedProps } from '../../src/helpers/removeUndefinedProps'; 2 | 3 | describe('removeUndefinedProps', () => { 4 | it('should remove properties with undefined values', () => { 5 | const obj = { 6 | id: 'https://example.org', 7 | title: 'Sample title', 8 | link: 'https://example.org', 9 | published: 1680651000000, 10 | description: 'Sample description', 11 | enclosure: undefined, 12 | category: undefined, 13 | 'itunes:image': undefined, 14 | }; 15 | 16 | const expectedObj = { 17 | id: 'https://example.org', 18 | title: 'Sample title', 19 | link: 'https://example.org', 20 | published: 1680651000000, 21 | description: 'Sample description', 22 | }; 23 | 24 | const result = removeUndefinedProps(obj); 25 | 26 | expect(result).toEqual(expectedObj); 27 | expect(result).not.toBe(obj); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 4 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 5 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 6 | "outDir": "./lib" /* Redirect output structure to the directory. */, 7 | "strict": true /* Enable all strict type-checking options. */, 8 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 9 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 10 | }, 11 | "exclude": ["node_modules", "**/*.test.ts"], 12 | "typeRoots": ["src/@types", "node_modules/@types"] 13 | } 14 | -------------------------------------------------------------------------------- /tests/helpers/__snapshots__/getExtraEntryFields.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getExtraEntryFields returns expected extra fields when feedEntry has all required properties 1`] = ` 4 | { 5 | "category": "Technology", 6 | "enclosure": { 7 | "length": 1024, 8 | "type": "audio/mpeg", 9 | "url": "https://example.com/audio.mp3", 10 | }, 11 | "itunes:duration": "00:30:00", 12 | "itunes:episode": 1, 13 | "itunes:episodeType": "full", 14 | "itunes:explicit": false, 15 | "itunes:keywords": "keyword1,keyword2", 16 | "itunes:subtitle": "Episode subtitle", 17 | "pubDate": "2023-07-19", 18 | } 19 | `; 20 | 21 | exports[`getExtraEntryFields returns expected extra fields when feedEntry has incomplete properties 1`] = `{}`; 22 | 23 | exports[`getExtraEntryFields returns expected extra fields when feedEntry has missing properties 1`] = ` 24 | { 25 | "category": {}, 26 | "enclosure": {}, 27 | } 28 | `; 29 | 30 | exports[`getExtraEntryFields returns expected extra fields when feedEntry has undefined properties 1`] = `{}`; 31 | -------------------------------------------------------------------------------- /tests/helpers/extractTags.test.ts: -------------------------------------------------------------------------------- 1 | import { extractTags } from '../../src/helpers/extractTags'; 2 | 3 | describe('extractTags', () => { 4 | it('should extract tags that contain valid characters', () => { 5 | const postFormat = 6 | 'This is a {validTag1} and {validTag2:example} with {validTag3.0} tags.'; 7 | const expectedTags = ['validTag1', 'validTag2:example', 'validTag3.0']; 8 | 9 | const extractedTags = extractTags(postFormat); 10 | 11 | expect(extractedTags).toEqual(expectedTags); 12 | }); 13 | 14 | it('should return an empty array when no valid tags are found', () => { 15 | const postFormat = 'This is a test without any valid tags.'; 16 | 17 | const extractedTags = extractTags(postFormat); 18 | 19 | expect(extractedTags).toEqual([]); 20 | }); 21 | 22 | it('should ignore tags with invalid characters', () => { 23 | const postFormat = 24 | 'This is a {validTag} and {invalidTag@$%} with {anotherValidTag} tags.'; 25 | const expectedTags = ['validTag', 'anotherValidTag']; 26 | 27 | const extractedTags = extractTags(postFormat); 28 | 29 | expect(extractedTags).toEqual(expectedTags); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /tests/helpers/convertArrayToObject.test.ts: -------------------------------------------------------------------------------- 1 | import { convertArrayToObject } from '../../src/helpers/convertArrayToObject'; 2 | 3 | describe('convertArrayToObject', () => { 4 | it('should convert the array to an object', () => { 5 | const array = [ 6 | { mastodon: 'skipped' }, 7 | { mastodonMetadata: 'skipped' }, 8 | { twitter: 'skipped' }, 9 | { discord: 'skipped' }, 10 | { slack: 'skipped' }, 11 | ] as { 12 | [key: string]: string; 13 | }[]; 14 | 15 | const result = convertArrayToObject(array); 16 | 17 | expect(result).toEqual({ 18 | mastodon: 'skipped', 19 | mastodonMetadata: 'skipped', 20 | twitter: 'skipped', 21 | discord: 'skipped', 22 | slack: 'skipped', 23 | }); 24 | }); 25 | 26 | it('should return an empty object when given an empty array', () => { 27 | const array: { [key: string]: string }[] = []; 28 | const result = convertArrayToObject(array); 29 | 30 | expect(result).toEqual({}); 31 | }); 32 | 33 | it('should handle duplicate keys by overwriting the previous value', () => { 34 | const array = [{ key: 'value1' }, { key: 'value2' }, { key: 'value3' }]; 35 | 36 | const result = convertArrayToObject(array); 37 | 38 | expect(result).toEqual({ key: 'value3' }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /tests/helpers/isFeedItemNewer.test.ts: -------------------------------------------------------------------------------- 1 | import { isFeedItemNewer } from '../../src/helpers/isFeedItemNewer'; 2 | import { FeedItem } from '../../src/types'; 3 | 4 | describe('isFeedItemNewer', () => { 5 | it('returns true if the feed item is newer than the cached item', () => { 6 | const feedItem = { 7 | published: new Date('2023-06-11'), 8 | } as unknown as FeedItem; 9 | const cachedItem = { 10 | published: new Date('2023-06-10'), 11 | } as unknown as FeedItem; 12 | const result = isFeedItemNewer({ feedItem, cachedItem }); 13 | expect(result).toBe(true); 14 | }); 15 | 16 | it('returns false if the feed item is older than or equal to the cached item', () => { 17 | const feedItem = { 18 | published: new Date('2023-06-10'), 19 | } as unknown as FeedItem; 20 | const cachedItem = { 21 | published: new Date('2023-06-11'), 22 | } as unknown as FeedItem; 23 | const result = isFeedItemNewer({ feedItem, cachedItem }); 24 | expect(result).toBe(false); 25 | }); 26 | 27 | it('returns false if either the feed item or the cached item is not provided', () => { 28 | const feedItem = { 29 | published: new Date('2023-06-10'), 30 | } as unknown as FeedItem; 31 | const result1 = isFeedItemNewer({ feedItem }); 32 | const result2 = isFeedItemNewer({}); 33 | expect(result1).toBe(false); 34 | expect(result2).toBe(false); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/services/slack.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { config } from '../config'; 3 | import { PostSubmitStatus, SocialMediaService, SocialService } from '../types'; 4 | import { logger } from '../logger'; 5 | 6 | export class Slack implements SocialMediaService { 7 | private readonly webhookUrl: string; 8 | 9 | constructor(webhookUrl: string) { 10 | this.webhookUrl = webhookUrl; 11 | } 12 | 13 | private validateConfig() { 14 | return this.webhookUrl.length > 0; 15 | } 16 | 17 | async post(content: string) { 18 | if (!config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.slack) { 19 | logger.warning('Posting to Slack is disabled. Skipping...'); 20 | return PostSubmitStatus.disabled; 21 | } 22 | 23 | if (!this.validateConfig()) { 24 | logger.warning(`Slack configuration incomplete. Skipping...`); 25 | return PostSubmitStatus.notConfigured; 26 | } 27 | 28 | logger.info('Posting to Slack...'); 29 | 30 | try { 31 | await axios.post(this.webhookUrl, { text: content }); 32 | 33 | return PostSubmitStatus.updated; 34 | } catch (error) { 35 | if (error instanceof Error) logger.warning(error.message); 36 | return PostSubmitStatus.errored; 37 | } 38 | } 39 | } 40 | 41 | export const postToSlack = async (content: string) => { 42 | const { webhookUrl } = config.SOCIAL_MEDIA[SocialService.slack]; 43 | return new Slack(webhookUrl).post(content); 44 | }; 45 | -------------------------------------------------------------------------------- /src/services/discord.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { config } from '../config'; 3 | import { PostSubmitStatus, SocialMediaService, SocialService } from '../types'; 4 | import { logger } from '../logger'; 5 | 6 | export class Discord implements SocialMediaService { 7 | private readonly webhookUrl: string; 8 | 9 | constructor(webhookUrl: string) { 10 | this.webhookUrl = webhookUrl; 11 | } 12 | 13 | private validateConfig() { 14 | return this.webhookUrl.length > 0; 15 | } 16 | 17 | async post(content: string) { 18 | if (!config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.discord) { 19 | logger.warning('Posting to Discord is disabled. Skipping...'); 20 | return PostSubmitStatus.disabled; 21 | } 22 | 23 | if (!this.validateConfig()) { 24 | logger.warning(`Discord configuration incomplete. Skipping...`); 25 | return PostSubmitStatus.notConfigured; 26 | } 27 | 28 | logger.info('Posting to Discord...'); 29 | 30 | try { 31 | await axios.post(this.webhookUrl, { content }); 32 | 33 | return PostSubmitStatus.updated; 34 | } catch (error) { 35 | if (error instanceof Error) logger.warning(error.message); 36 | return PostSubmitStatus.errored; 37 | } 38 | } 39 | } 40 | 41 | export const postToDiscord = async (content: string) => { 42 | const { webhookUrl } = config.SOCIAL_MEDIA[SocialService.discord]; 43 | return new Discord(webhookUrl).post(content); 44 | }; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-action-feed-to-social-media", 3 | "version": "2.4.2", 4 | "private": true, 5 | "description": "Post latest RSS / Atom feed item to multiple social platforms", 6 | "main": "dist/index.js", 7 | "scripts": { 8 | "start": "npx ncc run ./src/index.ts", 9 | "build": "npx ncc build ./src/index.ts", 10 | "watch": "tsc --watch", 11 | "format": "prettier --write '**/*.ts'", 12 | "format-check": "prettier --check '**/*.ts'", 13 | "lint": "eslint src/**/*.ts", 14 | "test": "jest", 15 | "test:cov": "jest --coverage --collectCoverageFrom='src/**/*.ts'", 16 | "all": "npm run format && npm run lint && npm test && npm run build && npm run package" 17 | }, 18 | "devDependencies": { 19 | "@actions/core": "^1.10.0", 20 | "@actions/io": "^1.1.3", 21 | "@extractus/feed-extractor": "6.2.4", 22 | "@types/jest": "^29.5.3", 23 | "@types/node": "^20.4.2", 24 | "@typescript-eslint/parser": "^6.1.0", 25 | "@vercel/ncc": "^0.36.1", 26 | "axios": "^1.4.0", 27 | "concurrently": "^8.2.0", 28 | "discord.js": "^14.11.0", 29 | "easy-bsky-bot-sdk": "^0.1.2", 30 | "eslint": "^8.45.0", 31 | "eslint-plugin-github": "^4.9.2", 32 | "eslint-plugin-jest": "^27.2.3", 33 | "jest": "^29.6.1", 34 | "js-yaml": "^4.1.0", 35 | "masto": "^5.11.4", 36 | "prettier": "^3.0.0", 37 | "rimraf": "^5.0.1", 38 | "ts-jest": "^29.1.1", 39 | "twitter-api-v2": "^1.15.0", 40 | "typescript": "^5.1.6" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/helpers/getExtraEntryFields.test.ts: -------------------------------------------------------------------------------- 1 | import { getExtraEntryFields } from '../../src/helpers/getExtraEntryFields'; 2 | 3 | describe('getExtraEntryFields', () => { 4 | test('returns expected extra fields when feedEntry has all required properties', () => { 5 | const feedEntry = { 6 | enclosure: { 7 | '@_url': 'https://example.com/audio.mp3', 8 | '@_type': 'audio/mpeg', 9 | '@_length': 1024, 10 | }, 11 | category: { 12 | '@_text': 'Technology', 13 | }, 14 | pubDate: '2023-07-19', 15 | 'itunes:subtitle': 'Episode subtitle', 16 | 'itunes:image': 'https://example.com/image.jpg', 17 | 'itunes:explicit': false, 18 | 'itunes:keywords': 'keyword1,keyword2', 19 | 'itunes:episodeType': 'full', 20 | 'itunes:duration': '00:30:00', 21 | 'itunes:episode': 1, 22 | }; 23 | 24 | const result = getExtraEntryFields(feedEntry); 25 | expect(result).toMatchSnapshot(); 26 | }); 27 | 28 | test('returns expected extra fields when feedEntry has missing properties', () => { 29 | const feedEntry = { 30 | enclosure: { 31 | url: 'https://example.com/audio.mp3', 32 | }, 33 | category: {}, 34 | }; 35 | 36 | const result = getExtraEntryFields(feedEntry); 37 | expect(result).toMatchSnapshot(); 38 | }); 39 | 40 | test('returns expected extra fields when feedEntry has undefined properties', () => { 41 | const feedEntry = { 42 | enclosure: undefined, 43 | category: undefined, 44 | }; 45 | 46 | const result = getExtraEntryFields(feedEntry); 47 | expect(result).toMatchSnapshot(); 48 | }); 49 | 50 | test('returns expected extra fields when feedEntry has incomplete properties', () => { 51 | const feedEntry = {}; 52 | 53 | const result = getExtraEntryFields(feedEntry); 54 | expect(result).toMatchSnapshot(); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/helpers/getExtraEntryFields.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Enclosure, 3 | ExtraEntryField, 4 | ExtraEntryProperty, 5 | FeedItem, 6 | } from '../types'; 7 | import { removeUndefinedProps } from './removeUndefinedProps'; 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | export const getExtraEntryFields = (feedEntry: Record) => { 11 | const extraFields: Partial = { 12 | [ExtraEntryField.enclosure]: feedEntry[ExtraEntryField.enclosure] 13 | ? removeUndefinedProps({ 14 | url: 15 | feedEntry[ExtraEntryField.enclosure][ExtraEntryProperty.url] || 16 | undefined, 17 | type: 18 | feedEntry[ExtraEntryField.enclosure][ExtraEntryProperty.type] || 19 | undefined, 20 | length: 21 | feedEntry[ExtraEntryField.enclosure][ExtraEntryProperty.length] || 22 | undefined, 23 | }) 24 | : undefined, 25 | [ExtraEntryField.category]: feedEntry[ExtraEntryField.category]?.[ 26 | ExtraEntryProperty.text 27 | ] 28 | ? feedEntry[ExtraEntryField.category][ExtraEntryProperty.text] 29 | : feedEntry[ExtraEntryField.category], 30 | [ExtraEntryField.pubDate]: feedEntry[ExtraEntryField.pubDate] || undefined, 31 | [ExtraEntryField.itunesSubtitle]: feedEntry[ExtraEntryField.itunesSubtitle], 32 | [ExtraEntryField.itunesImage]: 33 | feedEntry[ExtraEntryField.itunesImage]?.[ExtraEntryProperty.href], 34 | [ExtraEntryField.itunesExplicit]: feedEntry[ExtraEntryField.itunesExplicit], 35 | [ExtraEntryField.itunesKeywords]: feedEntry[ExtraEntryField.itunesKeywords], 36 | [ExtraEntryField.itunesEpisodeType]: 37 | feedEntry[ExtraEntryField.itunesEpisodeType], 38 | [ExtraEntryField.itunesDuration]: feedEntry[ExtraEntryField.itunesDuration], 39 | [ExtraEntryField.itunesEpisode]: feedEntry[ExtraEntryField.itunesEpisode], 40 | }; 41 | 42 | return removeUndefinedProps(extraFields); 43 | }; 44 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { mkdirP } from '@actions/io'; 3 | import { existsSync, readFileSync, writeFileSync } from 'fs'; 4 | import { config } from './config'; 5 | import { logger } from './logger'; 6 | 7 | export class Cache { 8 | name: string; 9 | private cacheDir: string; 10 | private cacheFilePath: string; 11 | 12 | constructor(name: string) { 13 | logger.info(`Establishing cache: ${name}`); 14 | 15 | this.name = name; 16 | this.cacheDir = path.join(process.cwd(), config.CACHE_DIRECTORY); 17 | this.cacheFilePath = path.join(this.cacheDir, this.name); 18 | } 19 | 20 | private cacheExists() { 21 | return existsSync(this.cacheFilePath); 22 | } 23 | 24 | private cacheDirExists() { 25 | return existsSync(this.cacheDir); 26 | } 27 | 28 | private async createCacheDir() { 29 | return mkdirP(this.cacheDir); 30 | } 31 | 32 | private readCache() { 33 | return readFileSync(this.cacheFilePath, { 34 | encoding: 'utf8', 35 | flag: 'r', 36 | }); 37 | } 38 | 39 | private writeToCache(content: string) { 40 | writeFileSync(this.cacheFilePath, content, { 41 | encoding: 'utf8', 42 | }); 43 | } 44 | 45 | get() { 46 | if (this.cacheExists()) { 47 | logger.info(`Reading cache: ${this.name}`); 48 | 49 | const content = this.readCache(); 50 | 51 | logger.debug(`Retrieved cached item title: ${JSON.stringify(content)}`); 52 | 53 | return JSON.parse(content) as T; 54 | } 55 | logger.warning(`Cache for ${this.name} is empty`); 56 | return undefined; 57 | } 58 | 59 | async set(content: Record) { 60 | if (!this.cacheDirExists()) { 61 | logger.info(`Cache directory doesn't exist. Creating...`); 62 | await this.createCacheDir(); 63 | } 64 | 65 | logger.info(`Saving to cache: ${this.name}`); 66 | 67 | this.writeToCache(JSON.stringify(content)); 68 | } 69 | } 70 | 71 | export const createCache = (fileName: string) => new Cache(fileName); 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | 93 | # OS metadata 94 | .DS_Store 95 | Thumbs.db 96 | 97 | # Ignore built ts files 98 | __tests__/runner/* 99 | lib/**/* 100 | dist -------------------------------------------------------------------------------- /src/services/mastodon.ts: -------------------------------------------------------------------------------- 1 | import { login } from 'masto'; 2 | import { config } from '../config'; 3 | import { 4 | MastodonPostVisibilitySetting, 5 | MastodonSettings, 6 | PostSubmitStatus, 7 | PostedStatusUrl, 8 | SocialMediaService, 9 | SocialService, 10 | } from '../types'; 11 | import { logger } from '../logger'; 12 | 13 | export class Mastodon implements SocialMediaService { 14 | private readonly instance: string; 15 | private readonly accessToken: string; 16 | private readonly postVisibility: MastodonPostVisibilitySetting; 17 | 18 | constructor(params: MastodonSettings) { 19 | this.accessToken = params.accessToken; 20 | this.instance = params.instance; 21 | this.postVisibility = params.postVisibility; 22 | } 23 | 24 | private validateConfig() { 25 | return this.accessToken.length > 0 && this.instance.length > 0; 26 | } 27 | 28 | private async getClient() { 29 | return await login({ 30 | url: this.instance, 31 | accessToken: this.accessToken, 32 | }); 33 | } 34 | 35 | async post(content: string): Promise { 36 | if (!config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.mastodon) { 37 | logger.warning('Posting to Mastodon is disabled. Skipping...'); 38 | return PostSubmitStatus.disabled; 39 | } 40 | 41 | if (!content) { 42 | logger.warning('Post content is empty. Skipping...'); 43 | return PostSubmitStatus.skipped; 44 | } 45 | 46 | if (!this.validateConfig()) { 47 | logger.warning(`Mastodon configuration incomplete. Skipping...`); 48 | return PostSubmitStatus.notConfigured; 49 | } 50 | 51 | logger.info('Posting to Mastodon...'); 52 | 53 | try { 54 | const client = await this.getClient(); 55 | 56 | const postedStatus = await client.v1.statuses.create({ 57 | status: content, 58 | visibility: this.postVisibility, 59 | }); 60 | 61 | return postedStatus.url as PostedStatusUrl; 62 | } catch (error) { 63 | if (error instanceof Error) logger.notice(error.message); 64 | return PostSubmitStatus.errored; 65 | } 66 | } 67 | } 68 | 69 | export const postToMastodon = async (content: string) => { 70 | const settings = config.SOCIAL_MEDIA[SocialService.mastodon]; 71 | return new Mastodon(settings).post(content); 72 | }; 73 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest", "@typescript-eslint"], 3 | "extends": ["plugin:github/recommended"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 9, 7 | "sourceType": "module", 8 | "project": "./tsconfig.json" 9 | }, 10 | "rules": { 11 | "filenames/match-regex": "off", 12 | "no-shadow": "off", 13 | "import/named": "off", 14 | "i18n-text/no-en": "off", 15 | "eslint-comments/no-use": "off", 16 | "import/no-namespace": "off", 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": "error", 19 | "@typescript-eslint/explicit-member-accessibility": [ 20 | "error", 21 | { "accessibility": "no-public" } 22 | ], 23 | "@typescript-eslint/no-require-imports": "error", 24 | "@typescript-eslint/array-type": "error", 25 | "@typescript-eslint/await-thenable": "error", 26 | "@typescript-eslint/ban-ts-comment": "error", 27 | "camelcase": "off", 28 | "@typescript-eslint/consistent-type-assertions": "error", 29 | "@typescript-eslint/func-call-spacing": ["error", "never"], 30 | "@typescript-eslint/no-array-constructor": "error", 31 | "@typescript-eslint/no-empty-interface": "error", 32 | "@typescript-eslint/no-explicit-any": "error", 33 | "@typescript-eslint/no-extraneous-class": "error", 34 | "@typescript-eslint/no-for-in-array": "error", 35 | "@typescript-eslint/no-inferrable-types": "error", 36 | "@typescript-eslint/no-misused-new": "error", 37 | "@typescript-eslint/no-namespace": "error", 38 | "@typescript-eslint/no-non-null-assertion": "warn", 39 | "@typescript-eslint/no-unnecessary-qualifier": "error", 40 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 41 | "@typescript-eslint/no-useless-constructor": "error", 42 | "@typescript-eslint/no-var-requires": "error", 43 | "@typescript-eslint/prefer-for-of": "warn", 44 | "@typescript-eslint/prefer-function-type": "warn", 45 | "@typescript-eslint/prefer-includes": "error", 46 | "@typescript-eslint/prefer-string-starts-ends-with": "error", 47 | "@typescript-eslint/require-array-sort-compare": "error", 48 | "@typescript-eslint/restrict-plus-operands": "error", 49 | "@typescript-eslint/type-annotation-spacing": "error", 50 | "@typescript-eslint/unbound-method": "error" 51 | }, 52 | "env": { 53 | "node": true, 54 | "es6": true, 55 | "jest/globals": true 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/helpers/postToSocialMedia.ts: -------------------------------------------------------------------------------- 1 | import { FeedItem, SocialService } from '../types'; 2 | import { updateMastodonMetadata } from '../services/mastodon-metadata'; 3 | import { postToDiscord } from '../services/discord'; 4 | import { postToSlack } from '../services/slack'; 5 | import { postToTwitter } from '../services/twitter'; 6 | import { postToMastodon } from '../services/mastodon'; 7 | import { postToBluesky } from '../services/bluesky'; 8 | import { config } from '../config'; 9 | import { formatPostContent } from './formatPostContent'; 10 | import { logger } from '../logger'; 11 | 12 | export const postToSocialMedia = (params: { 13 | type: SocialService; 14 | content: FeedItem; 15 | }) => { 16 | const { content, type } = params; 17 | const { link } = params.content; 18 | const { POST_FORMAT, SOCIAL_MEDIA } = config; 19 | 20 | if (!link) { 21 | logger.notice( 22 | 'Post link is empty. If you enabled Mastodon metadata update, it will not be triggered.' 23 | ); 24 | } 25 | 26 | switch (type) { 27 | case SocialService.mastodon: { 28 | const mastodonPost = formatPostContent( 29 | content, 30 | SOCIAL_MEDIA.mastodon.postFormat || POST_FORMAT 31 | ); 32 | return postToMastodon(mastodonPost); 33 | } 34 | 35 | case SocialService.mastodonMetadata: 36 | return updateMastodonMetadata(link || ''); 37 | 38 | case SocialService.twitter: { 39 | const twitterPost = formatPostContent( 40 | content, 41 | SOCIAL_MEDIA.twitter.postFormat || POST_FORMAT 42 | ); 43 | return postToTwitter(twitterPost); 44 | } 45 | 46 | case SocialService.discord: { 47 | const post = formatPostContent( 48 | content, 49 | SOCIAL_MEDIA.discord.postFormat || POST_FORMAT 50 | ); 51 | return postToDiscord(post); 52 | } 53 | 54 | case SocialService.slack: { 55 | const slackPost = formatPostContent( 56 | content, 57 | SOCIAL_MEDIA.slack.postFormat || POST_FORMAT 58 | ); 59 | return postToSlack(slackPost); 60 | } 61 | 62 | case SocialService.bluesky: { 63 | const blueskyPost = formatPostContent( 64 | content, 65 | SOCIAL_MEDIA.bluesky.postFormat || POST_FORMAT 66 | ); 67 | return postToBluesky(blueskyPost); 68 | } 69 | 70 | default: 71 | throw new Error( 72 | `Unknown social media type: '${type}'. Available types: ${Object.keys( 73 | SocialService 74 | )}` 75 | ); 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /src/services/twitter.ts: -------------------------------------------------------------------------------- 1 | import { TwitterApi } from 'twitter-api-v2'; 2 | import { config } from '../config'; 3 | import { 4 | PostSubmitStatus, 5 | SocialMediaService, 6 | SocialService, 7 | TwitterSettings, 8 | } from '../types'; 9 | import { logger } from '../logger'; 10 | 11 | export class Twitter implements SocialMediaService { 12 | private readonly apiKey: string; 13 | private readonly apiKeySecret: string; 14 | private readonly accessToken: string; 15 | private readonly accessTokenSecret: string; 16 | 17 | constructor(params: TwitterSettings) { 18 | this.apiKey = params.apiKey; 19 | this.apiKeySecret = params.apiKeySecret; 20 | this.accessToken = params.accessToken; 21 | this.accessTokenSecret = params.accessTokenSecret; 22 | } 23 | 24 | private validateConfig() { 25 | return ( 26 | this.apiKey.length > 0 && 27 | this.apiKeySecret.length > 0 && 28 | this.accessToken.length > 0 && 29 | this.accessTokenSecret.length > 0 30 | ); 31 | } 32 | 33 | private async getClient() { 34 | return new TwitterApi({ 35 | appKey: this.apiKey, 36 | appSecret: this.apiKeySecret, 37 | accessToken: this.accessToken, 38 | accessSecret: this.accessTokenSecret, 39 | }); 40 | } 41 | 42 | async post(content: string) { 43 | if (!config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.twitter) { 44 | logger.warning('Posting to Twitter is disabled. Skipping...'); 45 | return PostSubmitStatus.disabled; 46 | } 47 | 48 | if (!content) { 49 | logger.warning('Post content is empty. Skipping...'); 50 | return PostSubmitStatus.skipped; 51 | } 52 | 53 | if (!this.validateConfig()) { 54 | logger.warning(`Twitter configuration incomplete. Skipping...`); 55 | return PostSubmitStatus.notConfigured; 56 | } 57 | 58 | logger.info('Posting to Twitter...'); 59 | 60 | try { 61 | const client = await this.getClient(); 62 | 63 | const postedStatus = await client.v2.tweet(content); 64 | 65 | const twitterStatusUrl = 'https://twitter.com/twitter/status'; 66 | const tweetId = postedStatus.data.id; 67 | 68 | return `${twitterStatusUrl}/${tweetId}`; 69 | } catch (error) { 70 | if (error instanceof Error) logger.warning(error.message); 71 | return PostSubmitStatus.errored; 72 | } 73 | } 74 | } 75 | 76 | export const postToTwitter = async (content: string) => { 77 | const settings = config.SOCIAL_MEDIA[SocialService.twitter]; 78 | return new Twitter(settings).post(content); 79 | }; 80 | -------------------------------------------------------------------------------- /src/services/bluesky.ts: -------------------------------------------------------------------------------- 1 | import { BskyBot } from 'easy-bsky-bot-sdk'; 2 | import { config } from '../config'; 3 | import { 4 | BlueskySettings, 5 | PostSubmitStatus, 6 | SocialMediaService, 7 | SocialService, 8 | } from '../types'; 9 | import { logger } from '../logger'; 10 | 11 | export class Bluesky implements SocialMediaService { 12 | private readonly service: string; 13 | private readonly handle: string; 14 | private readonly appPassword: string; 15 | private readonly ownerHandle: string; 16 | private readonly ownerContact: string; 17 | 18 | constructor(params: BlueskySettings) { 19 | this.service = params.service || 'https://bsky.social'; 20 | this.handle = params.handle; 21 | this.appPassword = params.appPassword; 22 | this.ownerHandle = params.ownerHandle; 23 | this.ownerContact = params.ownerContact; 24 | } 25 | 26 | private validateConfig() { 27 | return ( 28 | this.handle.length > 0 && 29 | this.handle.includes('.') && 30 | this.appPassword.length > 0 && 31 | this.ownerHandle.length > 0 && 32 | this.ownerContact.length > 0 33 | ); 34 | } 35 | 36 | private async getClient() { 37 | BskyBot.setOwner({ 38 | handle: this.ownerHandle, 39 | contact: this.ownerContact, 40 | }); 41 | 42 | const bot = new BskyBot({ 43 | service: this.service, 44 | handle: this.handle, 45 | useNonBotHandle: true, 46 | }); 47 | 48 | await bot.login(this.appPassword); 49 | 50 | return bot; 51 | } 52 | 53 | async post(content: string) { 54 | if (!config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.bluesky) { 55 | logger.warning('Posting to Bluesky is disabled. Skipping...'); 56 | return PostSubmitStatus.disabled; 57 | } 58 | 59 | if (!this.validateConfig()) { 60 | logger.warning(`Bluesky configuration incomplete. Skipping...`); 61 | return PostSubmitStatus.notConfigured; 62 | } 63 | 64 | logger.info('Posting to Bluesky...'); 65 | 66 | try { 67 | const client = await this.getClient(); 68 | 69 | await client.post({ 70 | text: content, 71 | }); 72 | 73 | await client.kill(); 74 | 75 | return PostSubmitStatus.updated; 76 | } catch (error) { 77 | if (error instanceof Error) logger.warning(error.message); 78 | return PostSubmitStatus.errored; 79 | } 80 | } 81 | } 82 | 83 | export const postToBluesky = async (content: string) => { 84 | const settings = config.SOCIAL_MEDIA[SocialService.bluesky]; 85 | return new Bluesky(settings).post(content); 86 | }; 87 | -------------------------------------------------------------------------------- /tests/cache.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { mkdirP } from '@actions/io'; 3 | import { Cache, createCache } from '../src/cache'; 4 | 5 | jest.mock('@actions/io', () => ({ 6 | readFile: jest.fn(), 7 | mkdirP: jest.fn(), 8 | })); 9 | 10 | jest.mock('@actions/core', () => ({ 11 | info: () => jest.fn(), 12 | debug: () => jest.fn(), 13 | warning: () => jest.fn(), 14 | getInput: () => jest.fn(), 15 | getBooleanInput: () => jest.fn(), 16 | })); 17 | 18 | jest.mock('fs'); 19 | 20 | jest.mock('path', () => ({ 21 | join: (name: string) => name, 22 | })); 23 | 24 | describe('Cache', () => { 25 | beforeEach(() => { 26 | jest.resetAllMocks(); 27 | }); 28 | 29 | describe('constructor', () => { 30 | it('should initialize cache properties correctly', () => { 31 | const testCache = new Cache('test'); 32 | expect(testCache.name).toBe('test'); 33 | }); 34 | }); 35 | 36 | describe('get', () => { 37 | it('should create cache file when it does not exist', async () => { 38 | const CACHE_FILE = 'test-file.json'; 39 | const TEST_CONTENT = { test: 'expected test content' }; 40 | 41 | const existsSyncFn = jest.spyOn(fs, 'existsSync'); 42 | existsSyncFn.mockReturnValue(false); 43 | 44 | await new Cache(CACHE_FILE).set(TEST_CONTENT); 45 | 46 | expect(mkdirP).toHaveBeenCalled(); 47 | }); 48 | 49 | it('should return cached content when cache exists', async () => { 50 | const CACHE_FILE = 'test-file.json'; 51 | const EXPECTED_CONTENT = { id: 'expected test content' }; 52 | 53 | const testCache = new Cache(CACHE_FILE); 54 | 55 | const existsSyncFn = jest.spyOn(fs, 'existsSync'); 56 | const readFileSyncFn = jest.spyOn(fs, 'readFileSync'); 57 | 58 | existsSyncFn.mockReturnValue(true); 59 | readFileSyncFn.mockReturnValue(JSON.stringify(EXPECTED_CONTENT)); 60 | 61 | await testCache.set(EXPECTED_CONTENT); 62 | 63 | const result = testCache.get(); 64 | 65 | expect(result).toStrictEqual(EXPECTED_CONTENT); 66 | }); 67 | 68 | it('should return undefined when no content is cached', () => { 69 | const fileName = 'test-file.json'; 70 | const testCache = new Cache(fileName); 71 | 72 | const result = testCache.get(); 73 | 74 | expect(result).toBeUndefined(); 75 | }); 76 | }); 77 | }); 78 | 79 | describe('createCache', () => { 80 | it('should create a new Cache instance with the provided name', () => { 81 | const fileName = 'test-file.json'; 82 | const cache = createCache(fileName); 83 | 84 | expect(cache).toBeInstanceOf(Cache); 85 | expect(cache.name).toBe(fileName); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/feed.ts: -------------------------------------------------------------------------------- 1 | import { extract, ReaderOptions } from '@extractus/feed-extractor'; 2 | import { FeedItem, NewestItemStrategy } from './types'; 3 | import { getExtraEntryFields } from './helpers/getExtraEntryFields'; 4 | import { logger } from './logger'; 5 | 6 | export class Feed { 7 | url: string; 8 | strategy: NewestItemStrategy; 9 | 10 | private items: FeedItem[] | undefined; 11 | 12 | constructor(url: string, strategy = NewestItemStrategy.latestDate) { 13 | logger.info(`Setting up feed: ${url}`); 14 | logger.info(`Using newest item strategy: ${strategy}`); 15 | 16 | this.url = url; 17 | this.strategy = strategy; 18 | this.items = undefined; 19 | } 20 | 21 | private async extract() { 22 | logger.debug(`Extracting feed: ${this.url}`); 23 | 24 | const parserOptions = { 25 | descriptionMaxLen: 999999, 26 | xmlParserOptions: { 27 | ignoreAttributes: false, 28 | allowBooleanAttributes: true, 29 | // attributeNamePrefix is hardcoded to be '@_' 30 | }, 31 | getExtraEntryFields, 32 | } as ReaderOptions; 33 | 34 | this.items = (await extract(this.url, parserOptions)) 35 | .entries as unknown as FeedItem[]; 36 | return this.items; 37 | } 38 | 39 | private async getItems() { 40 | if (!this.items) { 41 | await this.extract(); 42 | } 43 | 44 | /* istanbul ignore next */ 45 | return this.items?.map((entry) => ({ 46 | ...entry, 47 | published: entry.published ? new Date(entry.published).getTime() : 0, 48 | })); 49 | } 50 | 51 | private getItemByStrategy = ( 52 | items: FeedItem[], 53 | strategy: NewestItemStrategy 54 | ) => { 55 | switch (strategy) { 56 | case NewestItemStrategy.first: 57 | return items[0]; 58 | case NewestItemStrategy.last: 59 | return items[items.length - 1]; 60 | case NewestItemStrategy.latestDate: 61 | return items.sort( 62 | (first: FeedItem, second: FeedItem) => 63 | second.published - first.published 64 | )[0]; 65 | default: 66 | throw new Error( 67 | `Unknown newestItemStrategy '${this.strategy}'. Available options: '${NewestItemStrategy.first}', '${NewestItemStrategy.last}' or '${NewestItemStrategy.latestDate}'` 68 | ); 69 | } 70 | }; 71 | 72 | async getLatestItem() { 73 | logger.info(`Obtaining latest feed item...`); 74 | 75 | const items = await this.getItems(); 76 | 77 | if (items) { 78 | const latestItem = this.getItemByStrategy(items, this.strategy); 79 | logger.debug(`Newest item: ${JSON.stringify(latestItem)}`); 80 | return latestItem; 81 | } 82 | 83 | logger.warning('Feed is empty!'); 84 | return undefined; 85 | } 86 | } 87 | 88 | export const fetchLatestFeedItem = ( 89 | url: string, 90 | strategy: NewestItemStrategy 91 | ) => new Feed(url, strategy).getLatestItem(); 92 | -------------------------------------------------------------------------------- /src/action.ts: -------------------------------------------------------------------------------- 1 | import { setFailed, setOutput } from '@actions/core'; 2 | import { config } from './config'; 3 | import { createCache } from './cache'; 4 | import { fetchLatestFeedItem } from './feed'; 5 | import { 6 | ActionOutput, 7 | FeedItem, 8 | PostSubmitStatus, 9 | SocialService, 10 | } from './types'; 11 | import { convertArrayToObject } from './helpers/convertArrayToObject'; 12 | import { isFeedItemNewer } from './helpers/isFeedItemNewer'; 13 | import { postToSocialMedia } from './helpers/postToSocialMedia'; 14 | import { logger } from './logger'; 15 | 16 | export const runAction = async () => { 17 | try { 18 | const { FEED_URL, LATEST_ITEM_STRATEGY, CACHE_FILE_NAME, SOCIAL_MEDIA } = 19 | config; 20 | 21 | const servicesToUpdate = Object.keys( 22 | SOCIAL_MEDIA.SERVICES_TO_UPDATE 23 | ) as SocialService[]; 24 | 25 | const feedItem = await fetchLatestFeedItem(FEED_URL, LATEST_ITEM_STRATEGY); 26 | 27 | logger.debug(JSON.stringify(feedItem)); 28 | 29 | if (!feedItem) { 30 | logger.warning('No feed item to fetch!'); 31 | 32 | const skippedStatuses = servicesToUpdate.map((service) => ({ 33 | [service]: PostSubmitStatus.skipped, 34 | })); 35 | 36 | const outputObject = convertArrayToObject(skippedStatuses); 37 | 38 | setOutput(ActionOutput.updateStatus, JSON.stringify(outputObject)); 39 | 40 | return; 41 | } 42 | 43 | const cache = createCache(CACHE_FILE_NAME); 44 | 45 | const cachedItem = cache.get(); 46 | 47 | if (cachedItem) { 48 | logger.debug('Cached item:'); 49 | logger.debug(JSON.stringify(cachedItem)); 50 | } else { 51 | logger.debug('No cached item found!'); 52 | } 53 | 54 | const shouldPost = isFeedItemNewer({ feedItem, cachedItem }); 55 | 56 | if (shouldPost) { 57 | logger.info('New feed item detected. Attempting to post it...'); 58 | 59 | const postStatuses = servicesToUpdate.map(async (service) => ({ 60 | [service]: await postToSocialMedia({ 61 | type: service, 62 | content: feedItem, 63 | }), 64 | })); 65 | 66 | const allSocialsUpdates = await Promise.all(postStatuses); 67 | const outputObject = convertArrayToObject(allSocialsUpdates); 68 | 69 | logger.info(`Updating cache with new feed item...`); 70 | await cache.set(feedItem); 71 | 72 | setOutput(ActionOutput.updateStatus, JSON.stringify(outputObject)); 73 | 74 | return; 75 | } 76 | 77 | const skippedStatuses = servicesToUpdate.map((service) => ({ 78 | [service]: PostSubmitStatus.skipped, 79 | })); 80 | 81 | if (!cachedItem) { 82 | logger.info(`Populating empty cache with fetched feed item...`); 83 | await cache.set(feedItem); 84 | } else { 85 | logger.info('No new feed item detected. Exiting...'); 86 | } 87 | 88 | const finalObject = convertArrayToObject(skippedStatuses); 89 | 90 | setOutput(ActionOutput.updateStatus, JSON.stringify(finalObject)); 91 | 92 | return; 93 | } catch (error) { 94 | if (error instanceof Error) setFailed(error.message); 95 | return; 96 | } 97 | }; 98 | -------------------------------------------------------------------------------- /tests/services/slack.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Slack, postToSlack } from '../../src/services/slack'; 3 | import { config } from '../../src/config'; 4 | import { PostSubmitStatus, SocialService } from '../../src/types'; 5 | 6 | jest.mock('axios'); 7 | jest.mock('../../src/config'); 8 | jest.mock('../../src/logger'); 9 | 10 | jest.mock('@actions/core', () => ({ 11 | info: () => jest.fn(), 12 | debug: () => jest.fn(), 13 | warning: () => jest.fn(), 14 | getInput: (key: string) => { 15 | const MOCKED_CONFIG = { 16 | feedUrl: 'https://test-feed-url/', 17 | } as { 18 | [key: string]: string; 19 | }; 20 | 21 | return MOCKED_CONFIG[key]; 22 | }, 23 | getBooleanInput: () => jest.fn(), 24 | })); 25 | 26 | jest.mock('path', () => ({ 27 | join: (name: string) => name, 28 | })); 29 | 30 | describe('Slack', () => { 31 | describe('post', () => { 32 | it('should return "disabled" if posting to Slack is disabled', async () => { 33 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.slack = false; 34 | 35 | const slack = new Slack('webhook-url'); 36 | const result = await slack.post('content'); 37 | 38 | expect(result).toBe(PostSubmitStatus.disabled); 39 | }); 40 | 41 | it('should return "notConfigured" if Slack configuration is incomplete', async () => { 42 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.slack = true; 43 | 44 | const slack = new Slack(''); 45 | const result = await slack.post('content'); 46 | 47 | expect(result).toBe(PostSubmitStatus.notConfigured); 48 | }); 49 | 50 | it('should return "updated" if the post is successfully submitted', async () => { 51 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.slack = true; 52 | 53 | const slack = new Slack('webhook-url'); 54 | (axios.post as any).mockImplementationOnce(() => Promise.resolve({})); 55 | 56 | const result = await slack.post('content'); 57 | 58 | expect(result).toBe(PostSubmitStatus.updated); 59 | }); 60 | 61 | it('should return "errored" if an error occurs during the post', async () => { 62 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.slack = true; 63 | 64 | const slack = new Slack('webhook-url'); 65 | const error = new Error('Post failed'); 66 | (axios.post as any).mockImplementationOnce(() => Promise.reject(error)); 67 | 68 | const result = await slack.post('content'); 69 | 70 | expect(result).toBe(PostSubmitStatus.errored); 71 | 3; 72 | }); 73 | }); 74 | }); 75 | 76 | describe('postToSlack', () => { 77 | it('should call Slack class with the correct parameters', async () => { 78 | const slackInstance = { 79 | post: jest 80 | .fn() 81 | .mockImplementation(() => Promise.resolve(PostSubmitStatus.updated)), 82 | }; 83 | const SlackMock = jest.fn().mockImplementation(() => slackInstance); 84 | jest.mock('../../src/services/slack', () => ({ Slack: SlackMock })); 85 | 86 | config.SOCIAL_MEDIA[SocialService.slack] = { 87 | webhookUrl: 'webhook-url', 88 | } as (typeof config.SOCIAL_MEDIA)[SocialService.slack]; 89 | 90 | const result = await postToSlack('content'); 91 | 92 | expect(result).toBe(PostSubmitStatus.updated); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /tests/services/discord.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Discord, postToDiscord } from '../../src/services/discord'; 3 | import { config } from '../../src/config'; 4 | import { PostSubmitStatus, SocialService } from '../../src/types'; 5 | 6 | jest.mock('axios'); 7 | jest.mock('../../src/config'); 8 | jest.mock('../../src/logger'); 9 | 10 | jest.mock('@actions/core', () => ({ 11 | info: () => jest.fn(), 12 | debug: () => jest.fn(), 13 | warning: () => jest.fn(), 14 | getInput: (key: string) => { 15 | const MOCKED_CONFIG = { 16 | feedUrl: 'https://test-feed-url/', 17 | } as { 18 | [key: string]: string; 19 | }; 20 | 21 | return MOCKED_CONFIG[key]; 22 | }, 23 | getBooleanInput: () => jest.fn(), 24 | })); 25 | 26 | jest.mock('path', () => ({ 27 | join: (name: string) => name, 28 | })); 29 | 30 | describe('Discord', () => { 31 | describe('post', () => { 32 | it('should return "disabled" if posting to Discord is disabled', async () => { 33 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.discord = false; 34 | 35 | const discord = new Discord('webhook-url'); 36 | const result = await discord.post('content'); 37 | 38 | expect(result).toBe(PostSubmitStatus.disabled); 39 | }); 40 | 41 | it('should return "notConfigured" if Discord configuration is incomplete', async () => { 42 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.discord = true; 43 | 44 | const discord = new Discord(''); 45 | const result = await discord.post('content'); 46 | 47 | expect(result).toBe(PostSubmitStatus.notConfigured); 48 | }); 49 | 50 | it('should return "updated" if the post is successfully submitted', async () => { 51 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.discord = true; 52 | 53 | const discord = new Discord('webhook-url'); 54 | (axios.post as any).mockImplementationOnce(() => Promise.resolve({})); 55 | 56 | const result = await discord.post('content'); 57 | 58 | expect(result).toBe(PostSubmitStatus.updated); 59 | }); 60 | 61 | it('should return "errored" if an error occurs during the post', async () => { 62 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.discord = true; 63 | 64 | const discord = new Discord('webhook-url'); 65 | const error = new Error('Post failed'); 66 | (axios.post as any).mockImplementationOnce(() => Promise.reject(error)); 67 | 68 | const result = await discord.post('content'); 69 | 70 | expect(result).toBe(PostSubmitStatus.errored); 71 | 3; 72 | }); 73 | }); 74 | }); 75 | 76 | describe('postToDiscord', () => { 77 | it('should call Discord class with the correct parameters', async () => { 78 | const discordInstance = { 79 | post: jest 80 | .fn() 81 | .mockImplementation(() => Promise.resolve(PostSubmitStatus.updated)), 82 | }; 83 | const DiscordMock = jest.fn().mockImplementation(() => discordInstance); 84 | jest.mock('../../src/services/discord', () => ({ Discord: DiscordMock })); 85 | 86 | config.SOCIAL_MEDIA[SocialService.discord] = { 87 | webhookUrl: 'webhook-url', 88 | } as (typeof config.SOCIAL_MEDIA)[SocialService.discord]; 89 | 90 | const result = await postToDiscord('content'); 91 | 92 | expect(result).toBe(PostSubmitStatus.updated); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /tests/action.test.ts: -------------------------------------------------------------------------------- 1 | import { runAction } from '../src/action'; 2 | import { setFailed, setOutput } from '@actions/core'; 3 | import { createCache } from '../src/cache'; 4 | import { fetchLatestFeedItem } from '../src/feed'; 5 | import { isFeedItemNewer } from '../src/helpers/isFeedItemNewer'; 6 | import { postToSocialMedia } from '../src/helpers/postToSocialMedia'; 7 | import { FeedItem } from '../src/types'; 8 | 9 | jest.mock('@actions/core'); 10 | jest.mock('../src/config'); 11 | jest.mock('../src/cache'); 12 | jest.mock('../src/feed'); 13 | jest.mock('../src/logger'); 14 | jest.mock('../src/helpers/isFeedItemNewer'); 15 | jest.mock('../src/helpers/postToSocialMedia'); 16 | 17 | jest.mock('path', () => ({ 18 | join: (name: string) => name, 19 | })); 20 | 21 | describe('runAction', () => { 22 | it('should skip when no feed item is fetched', async () => { 23 | const mockedSetOutput = setOutput as jest.Mock; 24 | ( 25 | fetchLatestFeedItem as jest.MockedFunction 26 | ).mockResolvedValueOnce(undefined); 27 | 28 | await runAction(); 29 | 30 | expect(mockedSetOutput).toHaveBeenCalled(); 31 | }); 32 | 33 | it('should post to social media and update cache when a new feed item is detected', async () => { 34 | const mockedSetOutput = setOutput as jest.Mock; 35 | 36 | const mockedCachedItem = { title: 'Cached Feed Item' } as FeedItem; 37 | const mockedFeedItem = { title: 'New Feed Item' } as FeedItem; 38 | 39 | ( 40 | fetchLatestFeedItem as jest.MockedFunction 41 | ).mockResolvedValueOnce(mockedFeedItem); 42 | (createCache as any).mockReturnValueOnce({ 43 | get: jest.fn().mockReturnValue(mockedCachedItem), 44 | set: jest.fn(), 45 | }); 46 | (isFeedItemNewer as any).mockReturnValueOnce(true); 47 | (postToSocialMedia as any).mockResolvedValueOnce('success'); 48 | 49 | await runAction(); 50 | 51 | expect(postToSocialMedia).toHaveBeenCalled(); 52 | expect(mockedSetOutput).toHaveBeenCalled(); 53 | 54 | expect(createCache).toHaveBeenCalled(); 55 | expect(mockedSetOutput).toHaveBeenCalled(); 56 | }); 57 | 58 | it('should populate empty cache when no cached item exists', async () => { 59 | const mockedSetOutput = setOutput as jest.Mock; 60 | 61 | const mockedFeedItem = { title: 'New Feed Item' }; 62 | 63 | (fetchLatestFeedItem as any).mockResolvedValueOnce(mockedFeedItem); 64 | (createCache as any).mockReturnValueOnce({ 65 | get: jest.fn().mockReturnValue(null), 66 | set: jest.fn(), 67 | }); 68 | 69 | await runAction(); 70 | 71 | expect(createCache).toHaveBeenCalled(); 72 | 73 | expect(mockedSetOutput).toHaveBeenCalled(); 74 | }); 75 | 76 | it('should exit when no new feed item is detected', async () => { 77 | const mockedSetOutput = setOutput as jest.Mock; 78 | 79 | const mockedFeedItem = { title: 'New Feed Item' }; 80 | 81 | (fetchLatestFeedItem as any).mockResolvedValueOnce(mockedFeedItem); 82 | (createCache as any).mockReturnValueOnce({ 83 | get: jest.fn().mockReturnValue(mockedFeedItem), 84 | set: jest.fn(), 85 | }); 86 | 87 | await runAction(); 88 | }); 89 | 90 | it('should handle error and set failed status', async () => { 91 | const mockedSetFailed = setFailed as jest.Mock; 92 | 93 | const mockedError = new Error('Some error'); 94 | 95 | (fetchLatestFeedItem as any).mockRejectedValueOnce(mockedError); 96 | 97 | await runAction(); 98 | 99 | expect(mockedSetFailed).toHaveBeenCalledWith('Some error'); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/services/mastodon-metadata.ts: -------------------------------------------------------------------------------- 1 | import { login } from 'masto'; 2 | import { config } from '../config'; 3 | import { 4 | MastodonMetadataSettings, 5 | PostSubmitStatus, 6 | SocialService, 7 | } from '../types'; 8 | import { stripHTML } from '../helpers/stripHTML'; 9 | import { logger } from '../logger'; 10 | 11 | export class MastodonMetadata { 12 | private readonly instance: string; 13 | private readonly accessToken: string; 14 | private readonly fieldIndex: number; 15 | 16 | constructor(params: MastodonMetadataSettings) { 17 | this.accessToken = params.accessToken; 18 | this.instance = params.instance; 19 | this.fieldIndex = params.fieldIndex; 20 | } 21 | 22 | private validateConfig() { 23 | return this.accessToken.length > 0 && this.instance.length > 0; 24 | } 25 | 26 | private async getClient() { 27 | return await login({ 28 | url: this.instance, 29 | accessToken: this.accessToken, 30 | }); 31 | } 32 | 33 | private updateMetadataFields({ 34 | accountMetadataFields, 35 | content, 36 | }: { 37 | accountMetadataFields: { 38 | name: string; 39 | value: string; 40 | }[]; 41 | content: string; 42 | }) { 43 | const updatedMetadataFields = accountMetadataFields.map( 44 | ({ name, value }) => ({ 45 | name, 46 | value: stripHTML(value), 47 | }) 48 | ); 49 | 50 | updatedMetadataFields[this.fieldIndex] = { 51 | name: accountMetadataFields[this.fieldIndex].name, 52 | value: content, 53 | }; 54 | 55 | return updatedMetadataFields; 56 | } 57 | 58 | async update(content: string) { 59 | if (!content) { 60 | logger.warning('Post content is empty. Skipping...'); 61 | return PostSubmitStatus.skipped; 62 | } 63 | 64 | if (!config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.mastodonMetadata) { 65 | logger.warning('Updating Mastodon metadata is disabled. Skipping...'); 66 | return PostSubmitStatus.disabled; 67 | } 68 | 69 | if (!this.validateConfig()) { 70 | logger.warning( 71 | `Updating Mastodon metadata configuration incomplete. Skipping...` 72 | ); 73 | return PostSubmitStatus.notConfigured; 74 | } 75 | 76 | try { 77 | logger.info('Updating Mastodon metadata...'); 78 | 79 | const client = await this.getClient(); 80 | const accountCredentials = await client.v1.accounts.verifyCredentials(); 81 | 82 | const accountMetadataFields = accountCredentials.fields; 83 | 84 | if (!accountMetadataFields || accountMetadataFields.length === 0) { 85 | logger.warning('Profile metadata is empty. Skipping...'); 86 | return PostSubmitStatus.skipped; 87 | } 88 | 89 | if (accountMetadataFields[this.fieldIndex] === undefined) { 90 | logger.warning( 91 | `Profile metadata field on index ${this.fieldIndex} does not exist. Skipping...` 92 | ); 93 | return PostSubmitStatus.skipped; 94 | } 95 | 96 | const updatedMetadataFields = this.updateMetadataFields({ 97 | accountMetadataFields, 98 | content, 99 | }); 100 | 101 | await client.v1.accounts.updateCredentials({ 102 | fieldsAttributes: updatedMetadataFields, 103 | }); 104 | 105 | return PostSubmitStatus.updated; 106 | } catch (error) { 107 | if (error instanceof Error) logger.notice(error.message); 108 | return PostSubmitStatus.errored; 109 | } 110 | } 111 | } 112 | 113 | export const updateMastodonMetadata = async (content: string) => { 114 | const settings = config.SOCIAL_MEDIA[SocialService.mastodonMetadata]; 115 | return new MastodonMetadata(settings).update(content); 116 | }; 117 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { getInput, getBooleanInput } from '@actions/core'; 2 | import { 3 | ActionInput, 4 | MastodonPostVisibilitySetting, 5 | NewestItemStrategy, 6 | SocialService, 7 | } from './types'; 8 | 9 | const cacheDirectory = getInput(ActionInput.cacheDirectory); 10 | const cacheFileName = getInput(ActionInput.cacheFileName); 11 | 12 | const feedSettings = { 13 | FEED_URL: getInput(ActionInput.feedUrl, { required: true }), 14 | LATEST_ITEM_STRATEGY: getInput( 15 | ActionInput.newestItemStrategy 16 | ) as NewestItemStrategy, 17 | }; 18 | 19 | const cacheSettings = { 20 | CACHE_DIRECTORY: cacheDirectory, 21 | CACHE_FILE_NAME: cacheFileName, 22 | }; 23 | 24 | const postSettings = { 25 | POST_FORMAT: getInput(ActionInput.postFormat, { 26 | trimWhitespace: false, 27 | }), 28 | }; 29 | 30 | const servicesToUpdate = { 31 | [SocialService.mastodon]: getBooleanInput(ActionInput.mastodonEnable), 32 | [SocialService.mastodonMetadata]: getBooleanInput( 33 | ActionInput.mastodonMetadataEnable 34 | ), 35 | [SocialService.twitter]: getBooleanInput(ActionInput.twitterEnable), 36 | [SocialService.discord]: getBooleanInput(ActionInput.discordEnable), 37 | [SocialService.slack]: getBooleanInput(ActionInput.slackEnable), 38 | [SocialService.bluesky]: getBooleanInput(ActionInput.blueskyEnable), 39 | }; 40 | 41 | const mastodonSettings = { 42 | postFormat: getInput(ActionInput.mastodonPostFormat, { 43 | trimWhitespace: false, 44 | }), 45 | instance: getInput(ActionInput.mastodonInstance), 46 | accessToken: getInput(ActionInput.mastodonAccessToken), 47 | postVisibility: getInput( 48 | ActionInput.mastodonPostVisibility 49 | ) as MastodonPostVisibilitySetting, 50 | }; 51 | 52 | const mastodonMetadataSettings = { 53 | instance: 54 | getInput(ActionInput.mastodonMetadataInstance) || 55 | getInput(ActionInput.mastodonInstance), 56 | accessToken: 57 | getInput(ActionInput.mastodonMetadataAccessToken) || 58 | getInput(ActionInput.mastodonAccessToken), 59 | fieldIndex: parseInt(getInput(ActionInput.mastodonMetadataFieldIndex)), 60 | }; 61 | 62 | const twitterSettings = { 63 | postFormat: getInput(ActionInput.twitterPostFormat, { 64 | trimWhitespace: false, 65 | }), 66 | apiKey: getInput(ActionInput.twitterApiKey), 67 | apiKeySecret: getInput(ActionInput.twitterApiKeySecret), 68 | accessToken: getInput(ActionInput.twitterAccessToken), 69 | accessTokenSecret: getInput(ActionInput.twitterAccessTokenSecret), 70 | }; 71 | 72 | const discordSettings = { 73 | postFormat: getInput(ActionInput.discordPostFormat, { 74 | trimWhitespace: false, 75 | }), 76 | webhookUrl: getInput(ActionInput.discordWebhookUrl), 77 | }; 78 | 79 | const slackSettings = { 80 | postFormat: getInput(ActionInput.slackPostFormat, { 81 | trimWhitespace: false, 82 | }), 83 | webhookUrl: getInput(ActionInput.slackWebhookUrl), 84 | }; 85 | 86 | const blueskySettings = { 87 | postFormat: getInput(ActionInput.blueskyPostFormat, { 88 | trimWhitespace: false, 89 | }), 90 | service: getInput(ActionInput.blueskyService), 91 | handle: getInput(ActionInput.blueskyHandle), 92 | appPassword: getInput(ActionInput.blueskyAppPassword), 93 | ownerHandle: getInput(ActionInput.blueskyOwnerHandle), 94 | ownerContact: getInput(ActionInput.blueskyOwnerContact), 95 | }; 96 | 97 | export const config = { 98 | ...feedSettings, 99 | ...cacheSettings, 100 | ...postSettings, 101 | SOCIAL_MEDIA: { 102 | SERVICES_TO_UPDATE: servicesToUpdate, 103 | [SocialService.mastodon]: mastodonSettings, 104 | [SocialService.mastodonMetadata]: mastodonMetadataSettings, 105 | [SocialService.twitter]: twitterSettings, 106 | [SocialService.discord]: discordSettings, 107 | [SocialService.slack]: slackSettings, 108 | [SocialService.bluesky]: blueskySettings, 109 | }, 110 | }; 111 | -------------------------------------------------------------------------------- /tests/services/bluesky.test.ts: -------------------------------------------------------------------------------- 1 | import { Bluesky, postToBluesky } from '../../src/services/bluesky'; 2 | import { config } from '../../src/config'; 3 | import { PostSubmitStatus } from '../../src/types'; 4 | 5 | jest.mock('../../src/config'); 6 | 7 | jest.mock('../../src/logger', () => ({ 8 | logger: { 9 | info: jest.fn(), 10 | warning: jest.fn(), 11 | }, 12 | })); 13 | 14 | jest.mock('easy-bsky-bot-sdk'); 15 | 16 | jest.mock('@actions/core', () => ({ 17 | info: () => jest.fn(), 18 | debug: () => jest.fn(), 19 | warning: () => jest.fn(), 20 | notice: () => jest.fn(), 21 | getInput: (key: string) => { 22 | const MOCKED_CONFIG = { 23 | feedUrl: 'https://test-feed-url/', 24 | } as { 25 | [key: string]: string; 26 | }; 27 | 28 | return MOCKED_CONFIG[key]; 29 | }, 30 | getBooleanInput: () => jest.fn(), 31 | })); 32 | 33 | describe('Bluesky', () => { 34 | beforeEach(() => { 35 | jest.clearAllMocks(); 36 | }); 37 | 38 | it('should create a Bluesky instance with valid configuration', () => { 39 | const bluesky = new Bluesky({ 40 | handle: 'valid.handle', 41 | appPassword: 'valid.password', 42 | ownerHandle: 'valid.ownerHandle', 43 | ownerContact: 'valid.ownerContact', 44 | }); 45 | 46 | expect(bluesky).toBeInstanceOf(Bluesky); 47 | }); 48 | 49 | it('should post to Bluesky and return updated status', async () => { 50 | const bluesky = new Bluesky({ 51 | handle: 'valid.handle', 52 | appPassword: 'valid.password', 53 | ownerHandle: 'valid.ownerHandle', 54 | ownerContact: 'valid.ownerContact', 55 | }); 56 | 57 | const status = await bluesky.post('Test content'); 58 | 59 | expect(status).toBe(PostSubmitStatus.updated); 60 | }); 61 | 62 | it('should disable posting to Bluesky if posting to Bluesky is disabled', async () => { 63 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.bluesky = false; 64 | 65 | const bluesky = new Bluesky({ 66 | handle: 'valid.handle', 67 | appPassword: 'valid.password', 68 | ownerHandle: 'valid.ownerHandle', 69 | ownerContact: 'valid.ownerContact', 70 | }); 71 | 72 | const status = await bluesky.post('Test content'); 73 | 74 | expect(status).toBe(PostSubmitStatus.disabled); 75 | }); 76 | 77 | it('should return notConfigured when Bluesky configuration is incomplete', async () => { 78 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.bluesky = true; 79 | 80 | const bluesky = new Bluesky({ 81 | handle: '', 82 | appPassword: '', 83 | ownerHandle: '', 84 | ownerContact: '', 85 | }); 86 | 87 | const status = await bluesky.post('Test content'); 88 | 89 | expect(status).toBe(PostSubmitStatus.notConfigured); 90 | }); 91 | 92 | it('should return errored when an error occurs while posting', async () => { 93 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.bluesky = true; 94 | 95 | const bluesky = new Bluesky({ 96 | handle: 'valid.handle', 97 | appPassword: 'valid.password', 98 | ownerHandle: 'valid.ownerHandle', 99 | ownerContact: 'valid.ownerContact', 100 | }); 101 | 102 | (bluesky as any).getClient = jest.fn(() => { 103 | throw new Error('Test error'); 104 | }); 105 | 106 | const status = await bluesky.post('Test content'); 107 | 108 | expect(status).toBe(PostSubmitStatus.errored); 109 | }); 110 | }); 111 | 112 | describe('postToBluesky', () => { 113 | it('should create an instance of Bluesky and call post', async () => { 114 | const mockUpdate = jest 115 | .spyOn(Bluesky.prototype as any, 'post') 116 | .mockResolvedValue(PostSubmitStatus.updated); 117 | 118 | const content = 'content'; 119 | const result = await postToBluesky(content); 120 | 121 | expect(result).toBe(PostSubmitStatus.updated); 122 | expect(mockUpdate).toHaveBeenCalledWith(content); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /tests/services/mastodon.test.ts: -------------------------------------------------------------------------------- 1 | import { login } from 'masto'; 2 | import { Mastodon, postToMastodon } from '../../src/services/mastodon'; 3 | import { 4 | MastodonPostVisibilitySetting, 5 | PostSubmitStatus, 6 | } from '../../src/types'; 7 | import { config } from '../../src/config'; 8 | 9 | jest.mock('masto'); 10 | 11 | jest.mock('@actions/core', () => ({ 12 | info: () => jest.fn(), 13 | debug: () => jest.fn(), 14 | warning: () => jest.fn(), 15 | notice: () => jest.fn(), 16 | getInput: (key: string) => { 17 | const MOCKED_CONFIG = { 18 | feedUrl: 'https://test-feed-url/', 19 | } as { 20 | [key: string]: string; 21 | }; 22 | 23 | return MOCKED_CONFIG[key]; 24 | }, 25 | getBooleanInput: () => jest.fn(), 26 | })); 27 | 28 | jest.mock('path', () => ({ 29 | join: (name: string) => name, 30 | })); 31 | 32 | describe('Mastodon', () => { 33 | describe('post', () => { 34 | it('should return skipped if content looks empty', async () => { 35 | const instance = new Mastodon({ 36 | accessToken: 'token', 37 | instance: 'instance', 38 | postVisibility: MastodonPostVisibilitySetting.public, 39 | }); 40 | const result = await instance.post(''); 41 | 42 | expect(result).toBe(PostSubmitStatus.skipped); 43 | }); 44 | 45 | it('should return disabled if posting to Mastodon is disabled', async () => { 46 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.mastodon = false; 47 | 48 | const instance = new Mastodon({ 49 | accessToken: 'token', 50 | instance: 'instance', 51 | postVisibility: MastodonPostVisibilitySetting.public, 52 | }); 53 | const result = await instance.post(''); 54 | 55 | expect(result).toBe(PostSubmitStatus.disabled); 56 | }); 57 | 58 | it('should return notConfigured if Mastodon configuration is incomplete', async () => { 59 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.mastodon = true; 60 | 61 | const instance = new Mastodon({ 62 | accessToken: '', 63 | instance: '', 64 | postVisibility: MastodonPostVisibilitySetting.public, 65 | }); 66 | 67 | const result = await instance.post('Test content'); 68 | 69 | expect(result).toBe(PostSubmitStatus.notConfigured); 70 | }); 71 | 72 | it('should return PostSubmitStatus.errored if an error occurs', async () => { 73 | (login as any).mockRejectedValue(new Error('API error')); 74 | 75 | const instance = new Mastodon({ 76 | accessToken: 'token', 77 | instance: 'instance', 78 | postVisibility: MastodonPostVisibilitySetting.public, 79 | }); 80 | const result = await instance.post('content'); 81 | 82 | expect(result).toBe(PostSubmitStatus.errored); 83 | }); 84 | 85 | it('should post content to Mastodon and return the posted status URL', async () => { 86 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.mastodon = true; 87 | 88 | const createStatusMock = jest.fn().mockImplementation(() => ({ 89 | url: 'https://mastodon.instance/status/123', 90 | })); 91 | const loginMock = (jest.requireMock('masto') as any).login; 92 | loginMock.mockResolvedValue({ 93 | v1: { statuses: { create: createStatusMock } }, 94 | }); 95 | 96 | const instance = new Mastodon({ 97 | accessToken: 'token', 98 | instance: 'instance', 99 | postVisibility: MastodonPostVisibilitySetting.public, 100 | }); 101 | const result = await instance.post('Test content'); 102 | 103 | expect(result).toBe('https://mastodon.instance/status/123'); 104 | }); 105 | }); 106 | }); 107 | 108 | describe('postToMastodon', () => { 109 | it('should create an instance of Mastodon and call post', async () => { 110 | const mockUpdate = jest 111 | .spyOn(Mastodon.prototype as any, 'post') 112 | .mockResolvedValue(PostSubmitStatus.updated); 113 | 114 | const content = 'content'; 115 | const result = await postToMastodon(content); 116 | 117 | expect(result).toBe(PostSubmitStatus.updated); 118 | expect(mockUpdate).toHaveBeenCalledWith(content); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /tests/services/twitter.test.ts: -------------------------------------------------------------------------------- 1 | import { Twitter, postToTwitter } from '../../src/services/twitter'; 2 | import { PostSubmitStatus } from '../../src/types'; 3 | import { config } from '../../src/config'; 4 | import { TwitterApi } from 'twitter-api-v2'; 5 | 6 | jest.mock('twitter-api-v2'); 7 | 8 | jest.mock('@actions/core', () => ({ 9 | info: () => jest.fn(), 10 | debug: () => jest.fn(), 11 | warning: () => jest.fn(), 12 | notice: () => jest.fn(), 13 | getInput: (key: string) => { 14 | const MOCKED_CONFIG = { 15 | feedUrl: 'https://test-feed-url/', 16 | } as { 17 | [key: string]: string; 18 | }; 19 | 20 | return MOCKED_CONFIG[key]; 21 | }, 22 | getBooleanInput: () => jest.fn(), 23 | })); 24 | 25 | jest.mock('path', () => ({ 26 | join: (name: string) => name, 27 | })); 28 | 29 | describe('Twitter', () => { 30 | describe('post', () => { 31 | it('should return skipped if content looks empty', async () => { 32 | const instance = new Twitter({ 33 | apiKey: 'test-api-key', 34 | apiKeySecret: 'test-api-key-secret', 35 | accessToken: 'test-access-token', 36 | accessTokenSecret: 'test-access-token-secret', 37 | }); 38 | 39 | const result = await instance.post(''); 40 | 41 | expect(result).toBe(PostSubmitStatus.skipped); 42 | }); 43 | 44 | it('should return disabled if posting to Mastodon is disabled', async () => { 45 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.twitter = false; 46 | 47 | const instance = new Twitter({ 48 | apiKey: 'test-api-key', 49 | apiKeySecret: 'test-api-key-secret', 50 | accessToken: 'test-access-token', 51 | accessTokenSecret: 'test-access-token-secret', 52 | }); 53 | 54 | const result = await instance.post('test-content'); 55 | 56 | expect(result).toBe(PostSubmitStatus.disabled); 57 | }); 58 | 59 | it('should return notConfigured if Twitter configuration is incomplete', async () => { 60 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.twitter = true; 61 | 62 | const instance = new Twitter({ 63 | apiKey: '', 64 | apiKeySecret: '', 65 | accessToken: '', 66 | accessTokenSecret: '', 67 | }); 68 | 69 | const result = await instance.post('Test content'); 70 | 71 | expect(result).toBe(PostSubmitStatus.notConfigured); 72 | }); 73 | 74 | it('should return PostSubmitStatus.errored if an error occurs', async () => { 75 | (TwitterApi as any).mockRejectedValue(new Error('API error')); 76 | 77 | const instance = new Twitter({ 78 | apiKey: 'test-api-key', 79 | apiKeySecret: 'test-api-key-secret', 80 | accessToken: 'test-access-token', 81 | accessTokenSecret: 'test-access-token-secret', 82 | }); 83 | 84 | const result = await instance.post('content'); 85 | 86 | expect(result).toBe(PostSubmitStatus.errored); 87 | }); 88 | 89 | it('should post content to Twitter and return the posted status URL', async () => { 90 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.twitter = true; 91 | 92 | const tweetMock = jest.fn().mockImplementation(() => ({ 93 | data: { 94 | id: '123', 95 | }, 96 | })); 97 | const twitterApiMock = (jest.requireMock('twitter-api-v2') as any) 98 | .TwitterApi; 99 | twitterApiMock.mockResolvedValue({ 100 | v2: { tweet: tweetMock }, 101 | }); 102 | 103 | const instance = new Twitter({ 104 | apiKey: 'test-api-key', 105 | apiKeySecret: 'test-api-key-secret', 106 | accessToken: 'test-access-token', 107 | accessTokenSecret: 'test-access-token-secret', 108 | }); 109 | 110 | const result = await instance.post('Test content'); 111 | 112 | expect(result).toBe('https://twitter.com/twitter/status/123'); 113 | }); 114 | }); 115 | }); 116 | 117 | describe('postToTwitter', () => { 118 | it('should create an instance of Twitter and call post', async () => { 119 | const mockUpdate = jest 120 | .spyOn(Twitter.prototype as any, 'post') 121 | .mockResolvedValue(PostSubmitStatus.updated); 122 | 123 | const content = 'content'; 124 | const result = await postToTwitter(content); 125 | 126 | expect(result).toBe(PostSubmitStatus.updated); 127 | expect(mockUpdate).toHaveBeenCalledWith(content); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /tests/helpers/postToSocialMedia.test.ts: -------------------------------------------------------------------------------- 1 | import { postToSocialMedia } from '../../src/helpers/postToSocialMedia'; 2 | import { postToTwitter } from '../../src/services/twitter'; 3 | import { postToMastodon } from '../../src/services/mastodon'; 4 | import { updateMastodonMetadata } from '../../src/services/mastodon-metadata'; 5 | import { postToDiscord } from '../../src/services/discord'; 6 | import { postToSlack } from '../../src/services/slack'; 7 | import { postToBluesky } from '../../src/services/bluesky'; 8 | import { FeedItem, SocialService } from '../../src/types'; 9 | 10 | jest.mock('../../src/services/twitter'); 11 | jest.mock('../../src/services/mastodon'); 12 | jest.mock('../../src/services/mastodon-metadata'); 13 | jest.mock('../../src/services/discord'); 14 | jest.mock('../../src/services/slack'); 15 | jest.mock('../../src/services/bluesky'); 16 | 17 | jest.mock('@actions/core', () => ({ 18 | info: () => jest.fn(), 19 | debug: () => jest.fn(), 20 | warning: () => jest.fn(), 21 | notice: () => jest.fn(), 22 | getInput: (key: string) => { 23 | const MOCKED_CONFIG = { 24 | feedUrl: 'https://test-feed-url/', 25 | postFormat: '{title} {link}', 26 | } as { 27 | [key: string]: string; 28 | }; 29 | 30 | return MOCKED_CONFIG[key]; 31 | }, 32 | getBooleanInput: () => jest.fn(), 33 | })); 34 | 35 | jest.mock('path', () => ({ 36 | join: (name: string) => name, 37 | })); 38 | 39 | describe('postToSocialMedia', () => { 40 | const mockContent = { 41 | title: 'Test Post', 42 | link: 'https://example.com/test-post', 43 | }; 44 | 45 | it('posts to Twitter when the social media type is Twitter', () => { 46 | const params = { 47 | type: SocialService.twitter, 48 | content: mockContent as FeedItem, 49 | }; 50 | postToSocialMedia(params); 51 | expect(postToTwitter).toHaveBeenCalledWith( 52 | expect.stringContaining('Test Post') 53 | ); 54 | }); 55 | 56 | it('posts to Mastodon when the social media type is Mastodon', () => { 57 | const params = { 58 | type: SocialService.mastodon, 59 | content: mockContent as FeedItem, 60 | }; 61 | postToSocialMedia(params); 62 | expect(postToMastodon).toHaveBeenCalledWith( 63 | expect.stringContaining('Test Post') 64 | ); 65 | }); 66 | 67 | it('updates Mastodon metadata when the social media type is MastodonMetadata', () => { 68 | const params = { 69 | type: SocialService.mastodonMetadata, 70 | content: mockContent as FeedItem, 71 | }; 72 | postToSocialMedia(params); 73 | expect(updateMastodonMetadata).toHaveBeenCalledWith( 74 | 'https://example.com/test-post' 75 | ); 76 | }); 77 | 78 | it('skips updating Mastodon metadata if link is empty', () => { 79 | const params = { 80 | type: SocialService.mastodonMetadata, 81 | content: { 82 | title: 'Test Post', 83 | link: '', 84 | } as FeedItem, 85 | }; 86 | postToSocialMedia(params); 87 | expect(updateMastodonMetadata).toHaveBeenCalledWith(''); 88 | }); 89 | 90 | it('posts to Discord when the social media type is Discord', () => { 91 | const params = { 92 | type: SocialService.discord, 93 | content: mockContent as FeedItem, 94 | }; 95 | postToSocialMedia(params); 96 | expect(postToDiscord).toHaveBeenCalledWith( 97 | expect.stringContaining('Test Post') 98 | ); 99 | }); 100 | 101 | it('posts to Slack when the social media type is Slack', () => { 102 | const params = { 103 | type: SocialService.slack, 104 | content: mockContent as FeedItem, 105 | }; 106 | postToSocialMedia(params); 107 | expect(postToSlack).toHaveBeenCalledWith( 108 | expect.stringContaining('Test Post') 109 | ); 110 | }); 111 | 112 | it('posts to Bluesky when the social media type is Bluesky', () => { 113 | const params = { 114 | type: SocialService.bluesky, 115 | content: mockContent as FeedItem, 116 | }; 117 | postToSocialMedia(params); 118 | expect(postToBluesky).toHaveBeenCalledWith( 119 | expect.stringContaining('Test Post') 120 | ); 121 | }); 122 | 123 | it('throws an error for an unknown social media type', () => { 124 | const params = { 125 | type: 'unknown' as SocialService, 126 | content: mockContent as FeedItem, 127 | }; 128 | expect(() => postToSocialMedia(params)).toThrow(); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { FeedEntry } from '@extractus/feed-extractor'; 2 | 3 | export enum ActionInput { 4 | // General 5 | feedUrl = 'feedUrl', 6 | newestItemStrategy = 'newestItemStrategy', 7 | cacheDirectory = 'cacheDirectory', 8 | cacheFileName = 'cacheFileName', 9 | postFormat = 'postFormat', 10 | titlePrefix = 'titlePrefix', 11 | linkPrefix = 'linkPrefix', 12 | postSeparator = 'postSeparator', 13 | postFooter = 'postFooter', 14 | // Mastodon 15 | mastodonType = 'mastodonType', 16 | mastodonEnable = 'mastodonEnable', 17 | mastodonPostFormat = 'mastodonPostFormat', 18 | mastodonInstance = 'mastodonInstance', 19 | mastodonAccessToken = 'mastodonAccessToken', 20 | mastodonPostVisibility = 'mastodonPostVisibility', 21 | // Mastodon metadata 22 | mastodonMetadataEnable = 'mastodonMetadataEnable', 23 | mastodonMetadataInstance = 'mastodonMetadataInstance', 24 | mastodonMetadataAccessToken = 'mastodonMetadataAccessToken', 25 | mastodonMetadataFieldIndex = 'mastodonMetadataFieldIndex', 26 | // Twitter 27 | twitterEnable = 'twitterEnable', 28 | twitterPostFormat = 'twitterPostFormat', 29 | twitterApiKey = 'twitterApiKey', 30 | twitterApiKeySecret = 'twitterApiKeySecret', 31 | twitterAccessToken = 'twitterAccessToken', 32 | twitterAccessTokenSecret = 'twitterAccessTokenSecret', 33 | // Discord 34 | discordEnable = 'discordEnable', 35 | discordPostFormat = 'discordPostFormat', 36 | discordWebhookUrl = 'discordWebhookUrl', 37 | // Slack 38 | slackEnable = 'slackEnable', 39 | slackPostFormat = 'slackPostFormat', 40 | slackWebhookUrl = 'slackWebhookUrl', 41 | // Bluesky 42 | blueskyEnable = 'blueskyEnable', 43 | blueskyPostFormat = 'blueskyPostFormat', 44 | blueskyService = 'blueskyService', 45 | blueskyHandle = 'blueskyHandle', 46 | blueskyAppPassword = 'blueskyAppPassword', 47 | blueskyOwnerHandle = 'blueskyOwnerHandle', 48 | blueskyOwnerContact = 'blueskyOwnerContact', 49 | } 50 | 51 | export enum ActionOutput { 52 | updateStatus = 'updateStatus', 53 | } 54 | 55 | export enum SocialService { 56 | mastodon = 'mastodon', 57 | mastodonMetadata = 'mastodonMetadata', 58 | twitter = 'twitter', 59 | discord = 'discord', 60 | slack = 'slack', 61 | bluesky = 'bluesky', 62 | } 63 | 64 | export enum NewestItemStrategy { 65 | latestDate = 'latestDate', 66 | first = 'first', 67 | last = 'last', 68 | } 69 | 70 | export type TimestampInMiliseconds = number; 71 | 72 | export enum ExtraEntryField { 73 | category = 'category', 74 | pubDate = 'pubDate', 75 | enclosure = 'enclosure', 76 | itunesSubtitle = 'itunes:subtitle', 77 | itunesImage = 'itunes:image', 78 | itunesExplicit = 'itunes:explicit', 79 | itunesKeywords = 'itunes:keywords', 80 | itunesEpisodeType = 'itunes:episodeType', 81 | itunesDuration = 'itunes:duration', 82 | itunesEpisode = 'itunes:episode', 83 | } 84 | 85 | export enum ExtraEntryProperty { 86 | text = '@_text', 87 | href = '@_href', 88 | url = '@_url', 89 | type = '@_type', 90 | length = '@_length', 91 | } 92 | 93 | export type Enclosure = { 94 | url?: string; 95 | length?: string; 96 | type?: string; 97 | }; 98 | 99 | export type FeedItem = Omit & { 100 | [key: string]: unknown; 101 | published: TimestampInMiliseconds; 102 | [ExtraEntryField.category]?: string; 103 | [ExtraEntryField.pubDate]?: string; 104 | [ExtraEntryField.enclosure]?: Enclosure; 105 | [ExtraEntryField.itunesSubtitle]?: string; 106 | [ExtraEntryField.itunesImage]?: string; 107 | [ExtraEntryField.itunesExplicit]?: string; 108 | [ExtraEntryField.itunesKeywords]?: string; 109 | [ExtraEntryField.itunesEpisodeType]?: string; 110 | [ExtraEntryField.itunesDuration]?: string; 111 | [ExtraEntryField.itunesEpisode]?: number; 112 | }; 113 | 114 | export type PostedStatusUrl = string; 115 | 116 | export enum PostSubmitStatus { 117 | disabled = 'disabled', 118 | notConfigured = 'notConfigured', 119 | updated = 'updated', 120 | errored = 'errored', 121 | skipped = 'skipped', 122 | } 123 | 124 | export abstract class SocialMediaService { 125 | abstract post(content: string): Promise; 126 | } 127 | 128 | export enum MastodonPostVisibilitySetting { 129 | public = 'public', 130 | unlisted = 'unlisted', 131 | private = 'private', 132 | direct = 'direct', 133 | } 134 | 135 | export interface MastodonSettings { 136 | accessToken: string; 137 | instance: string; 138 | postVisibility: MastodonPostVisibilitySetting; 139 | } 140 | 141 | export type MastodonMetadataSettings = Omit< 142 | MastodonSettings, 143 | 'postVisibility' 144 | > & { 145 | fieldIndex: number; 146 | }; 147 | 148 | export interface TwitterSettings { 149 | apiKey: string; 150 | apiKeySecret: string; 151 | accessToken: string; 152 | accessTokenSecret: string; 153 | } 154 | 155 | export interface DiscordSettings { 156 | webhookUrl: string; 157 | } 158 | 159 | export interface SlackSettings { 160 | webhookUrl: string; 161 | } 162 | 163 | export interface BlueskySettings { 164 | service?: string; 165 | handle: string; 166 | appPassword: string; 167 | ownerHandle: string; 168 | ownerContact: string; 169 | } 170 | -------------------------------------------------------------------------------- /tests/feed.test.ts: -------------------------------------------------------------------------------- 1 | import { Feed, fetchLatestFeedItem } from '../src/feed'; 2 | import { FeedItem, NewestItemStrategy } from '../src/types'; 3 | import { extract } from '@extractus/feed-extractor'; 4 | 5 | jest.mock('../src/logger', () => ({ 6 | logger: { 7 | info: jest.fn(), 8 | debug: jest.fn(), 9 | warning: jest.fn(), 10 | }, 11 | })); 12 | 13 | jest.mock('@extractus/feed-extractor', () => ({ 14 | extract: () => undefined, 15 | })); 16 | 17 | describe('Feed', () => { 18 | describe('getLatestItem', () => { 19 | it('should use "latestDate" strategy when none is provided', async () => { 20 | const url = 'https://example.com/feed'; 21 | 22 | const items = [ 23 | { title: 'Item 1', published: 100 }, 24 | { title: 'Item 2', published: 200 }, 25 | { title: 'Item 3', published: 50 }, 26 | ]; 27 | const feed = new Feed(url); 28 | 29 | jest 30 | .spyOn(Feed.prototype as any, 'getItems') 31 | .mockResolvedValueOnce(items); 32 | 33 | const result = await feed.getLatestItem(); 34 | 35 | expect(result).toEqual({ title: 'Item 2', published: 200 }); 36 | }); 37 | 38 | it('should throw when unknown strategy is provided', async () => { 39 | const url = 'https://example.com/feed'; 40 | 41 | const items = [ 42 | { title: 'Item 1', published: 100 }, 43 | { title: 'Item 2', published: 200 }, 44 | { title: 'Item 3', published: 50 }, 45 | ]; 46 | const feed = new Feed(url, 'wrongStrategy' as NewestItemStrategy); 47 | 48 | jest 49 | .spyOn(Feed.prototype as any, 'getItems') 50 | .mockResolvedValueOnce(items); 51 | 52 | await expect(feed.getLatestItem()).rejects.toThrow(); 53 | }); 54 | 55 | it('should return undefined when feed contains no items', async () => { 56 | const url = 'https://example.com/feed'; 57 | const strategy = NewestItemStrategy.first; 58 | const items = [] as FeedItem[]; 59 | 60 | (extract as any) = () => ({ 61 | entries: items, 62 | }); 63 | 64 | const feed = new Feed(url, strategy); 65 | 66 | const result = await feed.getLatestItem(); 67 | 68 | expect(result).toBe(undefined); 69 | }); 70 | 71 | it('should return undefined when feed is empty', async () => { 72 | const url = 'https://example.com/feed'; 73 | const strategy = NewestItemStrategy.first; 74 | const items = [] as FeedItem[]; 75 | 76 | (extract as any) = () => ({}); 77 | 78 | const feed = new Feed(url, strategy); 79 | 80 | const result = await feed.getLatestItem(); 81 | 82 | expect(result).toBe(undefined); 83 | }); 84 | 85 | it('should return the first item when strategy is "first"', async () => { 86 | const url = 'https://example.com/feed'; 87 | const strategy = NewestItemStrategy.first; 88 | const items = [ 89 | { title: 'Item 1', published: 100 }, 90 | { title: 'Item 2', published: 200 }, 91 | { title: 'Item 3', published: 50 }, 92 | ] as FeedItem[]; 93 | const feed = new Feed(url, strategy); 94 | 95 | jest 96 | .spyOn(Feed.prototype as any, 'getItems') 97 | .mockResolvedValueOnce(items); 98 | 99 | const result = await feed.getLatestItem(); 100 | 101 | expect(result).toBe(items[0]); 102 | }); 103 | 104 | it('should return the last item when strategy is "last"', async () => { 105 | const url = 'https://example.com/feed'; 106 | const strategy = NewestItemStrategy.last; 107 | const items = [ 108 | { title: 'Item 1', published: 100 }, 109 | { title: 'Item 2', published: 200 }, 110 | { title: 'Item 3', published: 50 }, 111 | ]; 112 | const feed = new Feed(url, strategy); 113 | 114 | jest 115 | .spyOn(Feed.prototype as any, 'getItems') 116 | .mockResolvedValueOnce(items); 117 | 118 | const result = await feed.getLatestItem(); 119 | 120 | expect(result).toBe(items[2]); 121 | }); 122 | 123 | it('should return the item with the latest date when strategy is "latestDate"', async () => { 124 | const url = 'https://example.com/feed'; 125 | const strategy = NewestItemStrategy.latestDate; 126 | const items = [ 127 | { title: 'Item 1', published: 100 }, 128 | { title: 'Item 2', published: 200 }, 129 | { title: 'Item 3', published: 50 }, 130 | ]; 131 | const feed = new Feed(url, strategy); 132 | 133 | jest 134 | .spyOn(Feed.prototype as any, 'getItems') 135 | .mockResolvedValueOnce(items); 136 | 137 | const result = await feed.getLatestItem(); 138 | 139 | expect(result).toEqual({ title: 'Item 2', published: 200 }); 140 | }); 141 | 142 | it('should return undefined and log a warning if feed items are empty', async () => { 143 | const url = 'https://example.com/feed'; 144 | const strategy = NewestItemStrategy.latestDate; 145 | const items = [] as FeedItem[]; 146 | const feed = new Feed(url, strategy); 147 | 148 | jest 149 | .spyOn(Feed.prototype as any, 'getItems') 150 | .mockResolvedValueOnce(items); 151 | 152 | const result = await feed.getLatestItem(); 153 | 154 | expect(result).toBeUndefined(); 155 | }); 156 | }); 157 | }); 158 | 159 | describe('fetchLatestFeedItem', () => { 160 | it('should create a Feed instance with the provided URL and strategy, and call getLatestItem', async () => { 161 | const url = 'https://example.com/feed'; 162 | const strategy = NewestItemStrategy.latestDate; 163 | const items = [ 164 | { 165 | title: 'Latest Item 1', 166 | published: 20, 167 | }, 168 | { 169 | title: 'Older item 2', 170 | published: 10, 171 | }, 172 | ]; 173 | 174 | jest.spyOn(Feed.prototype as any, 'getItems').mockResolvedValueOnce(items); 175 | jest.spyOn(Feed.prototype as any, 'getLatestItem'); 176 | 177 | await fetchLatestFeedItem(url, strategy); 178 | 179 | expect(Feed.prototype.getLatestItem).toHaveBeenCalledTimes(1); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /tests/services/mastodon-metadata.test.ts: -------------------------------------------------------------------------------- 1 | import { login } from 'masto'; 2 | import { 3 | MastodonMetadata, 4 | updateMastodonMetadata, 5 | } from '../../src/services/mastodon-metadata'; 6 | import { config } from '../../src/config'; 7 | import { PostSubmitStatus } from '../../src/types'; 8 | 9 | jest.mock('masto'); 10 | 11 | jest.mock('@actions/core', () => ({ 12 | info: () => jest.fn(), 13 | debug: () => jest.fn(), 14 | warning: () => jest.fn(), 15 | notice: () => jest.fn(), 16 | getInput: (key: string) => { 17 | const MOCKED_CONFIG = { 18 | feedUrl: 'https://test-feed-url/', 19 | } as { 20 | [key: string]: string; 21 | }; 22 | 23 | return MOCKED_CONFIG[key]; 24 | }, 25 | getBooleanInput: () => jest.fn(), 26 | })); 27 | 28 | jest.mock('path', () => ({ 29 | join: (name: string) => name, 30 | })); 31 | 32 | describe('MastodonMetadata', () => { 33 | describe('update', () => { 34 | it('should return skipped if content looks empty', async () => { 35 | const instance = new MastodonMetadata({ 36 | accessToken: 'token', 37 | instance: 'instance', 38 | fieldIndex: 0, 39 | }); 40 | const result = await instance.update(''); 41 | 42 | expect(result).toBe(PostSubmitStatus.skipped); 43 | }); 44 | 45 | it('should return disabled if updating Mastodon metadata is disabled', async () => { 46 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.mastodonMetadata = false; 47 | 48 | const instance = new MastodonMetadata({ 49 | accessToken: 'token', 50 | instance: 'instance', 51 | fieldIndex: 0, 52 | }); 53 | const result = await instance.update('Test content'); 54 | 55 | expect(result).toBe(PostSubmitStatus.disabled); 56 | }); 57 | 58 | it('should return notConfigured if Mastodon metadata configuration is incomplete', async () => { 59 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.mastodonMetadata = true; 60 | 61 | const instance = new MastodonMetadata({ 62 | accessToken: '', 63 | instance: '', 64 | fieldIndex: 0, 65 | }); 66 | const result = await instance.update('Test content'); 67 | 68 | expect(result).toBe(PostSubmitStatus.notConfigured); 69 | }); 70 | 71 | it('should return PostSubmitStatus.skipped if existing profile metadata is empty', async () => { 72 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.mastodonMetadata = true; 73 | 74 | const mockClient = { 75 | v1: { 76 | accounts: { 77 | verifyCredentials: jest.fn().mockImplementation(() => ({ 78 | fields: [], 79 | })), 80 | updateCredentials: jest.fn(), 81 | }, 82 | }, 83 | }; 84 | (login as any).mockResolvedValue(mockClient); 85 | 86 | const instance = new MastodonMetadata({ 87 | accessToken: 'token', 88 | instance: 'instance', 89 | fieldIndex: 0, 90 | }); 91 | const result = await instance.update('content'); 92 | 93 | expect(result).toBe(PostSubmitStatus.skipped); 94 | }); 95 | 96 | it('should return PostSubmitStatus.skipped if field index is out of range of existing profile metadata', async () => { 97 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.mastodonMetadata = true; 98 | 99 | const mockClient = { 100 | v1: { 101 | accounts: { 102 | verifyCredentials: jest.fn().mockImplementation(() => ({ 103 | fields: [ 104 | { 105 | name: 'test', 106 | value: 'some-value', 107 | }, 108 | ], 109 | })), 110 | updateCredentials: jest.fn(), 111 | }, 112 | }, 113 | }; 114 | (login as any).mockResolvedValue(mockClient); 115 | 116 | const instance = new MastodonMetadata({ 117 | accessToken: 'token', 118 | instance: 'instance', 119 | fieldIndex: 3, 120 | }); 121 | const result = await instance.update('content'); 122 | 123 | expect(result).toBe(PostSubmitStatus.skipped); 124 | }); 125 | 126 | it('should return PostSubmitStatus.errored if an error occurs', async () => { 127 | (login as any).mockRejectedValue(new Error('API error')); 128 | 129 | const instance = new MastodonMetadata({ 130 | accessToken: 'token', 131 | instance: 'instance', 132 | fieldIndex: 0, 133 | }); 134 | const result = await instance.update('content'); 135 | 136 | expect(result).toBe(PostSubmitStatus.errored); 137 | }); 138 | 139 | it('should update Mastodon metadata and return PostSubmitStatus.updated', async () => { 140 | config.SOCIAL_MEDIA.SERVICES_TO_UPDATE.mastodonMetadata = true; 141 | 142 | const mockClient = { 143 | v1: { 144 | accounts: { 145 | verifyCredentials: jest.fn().mockImplementation(() => ({ 146 | fields: [ 147 | { 148 | name: 'test', 149 | value: 'some content', 150 | }, 151 | ], 152 | })), 153 | updateCredentials: jest.fn(), 154 | }, 155 | }, 156 | }; 157 | (login as any).mockResolvedValue(mockClient); 158 | 159 | const instance = new MastodonMetadata({ 160 | accessToken: 'token', 161 | instance: 'instance', 162 | fieldIndex: 0, 163 | }); 164 | const result = await instance.update('content'); 165 | 166 | expect(result).toBe(PostSubmitStatus.updated); 167 | }); 168 | }); 169 | }); 170 | 171 | describe('updateMastodonMetadata', () => { 172 | it('should create an instance of MastodonMetadata and call update', async () => { 173 | const mockUpdate = jest 174 | .spyOn(MastodonMetadata.prototype as any, 'update') 175 | .mockResolvedValue(PostSubmitStatus.updated); 176 | 177 | const content = 'content'; 178 | const result = await updateMastodonMetadata(content); 179 | 180 | expect(result).toBe(PostSubmitStatus.updated); 181 | expect(mockUpdate).toHaveBeenCalledWith(content); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Feed To Social Media' 2 | description: 'Post to social media whenever new item is found in a RSS/Atom feed' 3 | author: 'Łukasz Wójcik' 4 | 5 | branding: 6 | icon: 'rss' 7 | color: 'orange' 8 | 9 | inputs: 10 | # General settings 11 | feedUrl: 12 | description: 'URL of the feed to fetch' 13 | required: true 14 | newestItemStrategy: 15 | description: 'Which item in the feed item array should be considered newest?' 16 | default: 'latestDate' 17 | cacheDirectory: 18 | description: 'Path to the directory where cache files are stored' 19 | default: 'cache' 20 | cacheFileName: 21 | description: 'Name of the file where cache data is stored' 22 | default: 'feed-to-social-media.json' 23 | postFormat: 24 | description: 'Format of the post. See README for available tags' 25 | default: '{title} {link}' 26 | # Mastodon settings 27 | mastodonEnable: 28 | description: 'Enable posting to Mastodon?' 29 | default: 'false' 30 | mastodonPostFormat: 31 | description: 'Format of the Mastodon post. Overrides postFormat if used' 32 | mastodonInstance: 33 | description: 'URL of the Mastodon instance to which the status should be sent' 34 | mastodonAccessToken: 35 | description: 'Access token for the Mastodon API' 36 | mastodonPostVisibility: 37 | description: 'Visibility setting for Mastodon posts' 38 | default: 'public' 39 | # Mastodon profile metadata settings 40 | mastodonMetadataEnable: 41 | description: 'Enable updating Mastodon profile metadata?' 42 | default: 'false' 43 | mastodonMetadataInstance: 44 | description: 'URL of the Mastodon instance where profile metadata should be updated' 45 | mastodonMetadataAccessToken: 46 | description: 'Access token for the Mastodon API on the instance where profile metadata should be updated' 47 | mastodonMetadataFieldIndex: 48 | description: 'Index of the existing profile metadata field that should be updated (starting at 0)' 49 | # Twitter settings 50 | twitterEnable: 51 | description: 'Enable posting to Twitter?' 52 | default: 'false' 53 | twitterPostFormat: 54 | description: 'Format of the Twitter status. Overrides postFormat if used' 55 | twitterApiKey: 56 | description: 'Twitter API key' 57 | twitterApiKeySecret: 58 | description: 'Twitter API key secret' 59 | twitterAccessToken: 60 | description: 'Twitter API access token' 61 | twitterAccessTokenSecret: 62 | description: 'Twitter API access token secret' 63 | # Discord settings 64 | discordEnable: 65 | description: 'Enable posting to Discord?' 66 | default: 'false' 67 | discordPostFormat: 68 | description: 'Format of the Discord message. Overrides postFormat if used' 69 | discordWebhookUrl: 70 | description: 'Webhook URL to use for posting messages to Discord' 71 | # Slack settings 72 | slackEnable: 73 | description: 'Enable posting to Slack?' 74 | default: 'false' 75 | slackPostFormat: 76 | description: 'Format of the Slack message. Overrides postFormat if used' 77 | slackWebhookUrl: 78 | description: 'Webhook URL to use for posting messages to Slack' 79 | # Bluesky settings 80 | blueskyEnable: 81 | description: 'Enable posting to Bluesky?' 82 | default: 'false' 83 | blueskyPostFormat: 84 | description: 'Format of the Bluesky message. Overrides postFormat if used' 85 | blueskyService: 86 | description: 'Bluesky server URL' 87 | default: 'https://bsky.social' 88 | blueskyHandle: 89 | description: 'Bluesky handle to post to (e.g. myaccount.bsky.social)' 90 | blueskyAppPassword: 91 | description: 'Bluesky app password' 92 | blueskyOwnerHandle: 93 | description: 'Your handle as a bot owner. This info will go in the bot user-agent string, so it will be visible to the server you connect (not displayed publicly)' 94 | blueskyOwnerContact: 95 | description: 'Your contact info as a bot owner. This info will go in the bot user-agent string, so it will be visible to the server you connect (not displayed publicly)' 96 | outputs: 97 | updateStatus: 98 | description: 'Stringified object with update status' 99 | value: ${{ steps.feed2sm.outputs.updateStatus }} 100 | 101 | runs: 102 | using: 'composite' 103 | steps: 104 | - name: Checkout repo 105 | uses: actions/checkout@v4 106 | 107 | - name: Feed to social media 108 | run: node ${{ github.action_path }}/dist/index.js 109 | shell: bash 110 | env: 111 | INPUT_FEEDURL: ${{ inputs.feedUrl }} 112 | INPUT_NEWESTITEMSTRATEGY: ${{ inputs.newestItemStrategy }} 113 | INPUT_CACHEDIRECTORY: ${{ inputs.cacheDirectory }} 114 | INPUT_CACHEFILENAME: ${{ inputs.cacheFileName }} 115 | INPUT_POSTFORMAT: ${{ inputs.postFormat }} 116 | INPUT_MASTODONENABLE: ${{ inputs.mastodonEnable }} 117 | INPUT_MASTODONPOSTFORMAT: ${{ inputs.mastodonPostFormat }} 118 | INPUT_MASTODONINSTANCE: ${{ inputs.mastodonInstance }} 119 | INPUT_MASTODONACCESSTOKEN: ${{ inputs.mastodonAccessToken }} 120 | INPUT_MASTODONPOSTVISIBILITY: ${{ inputs.mastodonPostVisibility }} 121 | INPUT_MASTODONMETADATAENABLE: ${{ inputs.mastodonMetadataEnable }} 122 | INPUT_MASTODONMETADATAINSTANCE: ${{ inputs.mastodonMetadataInstance }} 123 | INPUT_MASTODONMETADATAACCESSTOKEN: ${{ inputs.mastodonMetadataAccessToken }} 124 | INPUT_MASTODONMETADATAFIELDINDEX: ${{ inputs.mastodonMetadataFieldIndex }} 125 | INPUT_TWITTERENABLE: ${{ inputs.twitterEnable }} 126 | INPUT_TWITTERPOSTFORMAT: ${{ inputs.twitterPostFormat }} 127 | INPUT_TWITTERAPIKEY: ${{ inputs.twitterApiKey }} 128 | INPUT_TWITTERAPIKEYSECRET: ${{ inputs.twitterApiKeySecret }} 129 | INPUT_TWITTERACCESSTOKEN: ${{ inputs.twitterAccessToken }} 130 | INPUT_TWITTERACCESSTOKENSECRET: ${{ inputs.twitterAccessTokenSecret }} 131 | INPUT_DISCORDENABLE: ${{ inputs.discordEnable }} 132 | INPUT_DISCORDPOSTFORMAT: ${{ inputs.discordPostFormat }} 133 | INPUT_DISCORDWEBHOOKURL: ${{ inputs.discordWebhookUrl }} 134 | INPUT_SLACKENABLE: ${{ inputs.slackEnable }} 135 | INPUT_SLACKPOSTFORMAT: ${{ inputs.slackPostFormat }} 136 | INPUT_SLACKWEBHOOKURL: ${{ inputs.slackWebhookUrl }} 137 | INPUT_BLUESKYENABLE: ${{ inputs.blueskyEnable }} 138 | INPUT_BLUESKYPOSTFORMAT: ${{ inputs.blueskyPostFormat }} 139 | INPUT_BLUESKYHANDLE: ${{ inputs.blueskyHandle }} 140 | INPUT_BLUESKYAPPPASSWORD: ${{ inputs.blueskyAppPassword }} 141 | INPUT_BLUESKYOWNERHANDLE: ${{ inputs.blueskyOwnerHandle }} 142 | INPUT_BLUESKYOWNERCONTACT: ${{ inputs.blueskyOwnerContact }} 143 | 144 | - name: Pull any changes from Git 145 | shell: bash 146 | run: git pull 147 | 148 | - name: Commit and push cache changes 149 | shell: bash 150 | run: | 151 | cd . 152 | git config user.name "github-actions[bot]" 153 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 154 | git add . 155 | git diff --cached --exit-code || git commit -m "Update feed item cache" --author="${{ github.actor }} <${{ github.actor }}@users.noreply.github.com>" 156 | git push origin HEAD 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Action: Feed to Social Media 2 | 3 | > This project is no longer maintained or updated and it will be unpublished from GitHub Marketplace around 28 October 2024. Please update your workflows accordingly. In case of breaking changes, feel free to fork the repo and publish your own updated version. 4 | 5 | This GitHub Action selects latest item from RSS / Atom feed and posts it to one or more social media sites. 6 | 7 | Supported platforms: 8 | 9 | - Mastodon (posting to account + updating profile metadata) 10 | - Twitter 11 | - Slack 12 | - Discord 13 | - Bluesky 14 | 15 | This GitHub Action is heavily inspired by [Any Feed to Mastodon GitHub Action](https://github.com/nhoizey/github-action-feed-to-mastodon) by [Nicolas Hoizey](https://github.com/nhoizey/github-action-feed-to-mastodon). However, there are significant scope and use case diffences between the two: 16 | 17 | - this Action employs naively straightforward algorithm for determining whether new items should be posted or not. You can choose between 3 different strategies (top of the item array, bottom of the item array, latest publication date). 18 | 19 | - only one feed item is posted each time. This Action isn't suitable for fast-moving feeds that burst multiple new items on each check and that's intentional. 20 | 21 | - published posts can use custom formatting. Example: `New article: {article} {link} #someHashtag` 22 | 23 | ## Usage 24 | 25 | Below is a action workflow that uses all available options: 26 | 27 | ```yaml 28 | name: Feed to social media 29 | on: 30 | workflow_dispatch: 31 | 32 | jobs: 33 | Feed2SocialMedia: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Feed to social media 37 | uses: lwojcik/github-action-feed-to-social-media@v2 38 | with: 39 | feedUrl: 'https://offbeatbits.com/excerpts.xml' 40 | newestItemStrategy: 'latestDate' 41 | postFormat: "New post: {title}\n\n{link}" 42 | # Mastodon settings 43 | mastodonEnable: true 44 | mastodonPostFormat: "New post: {title}\n\n{link}" 45 | mastodonInstance: 'https://mas.to' 46 | mastodonAccessToken: 'MASTODON_ACCESS_TOKEN' 47 | mastodonPostVisibility: 'unlisted' 48 | # Mastodon metadata settings 49 | mastodonMetadataEnable: true 50 | mastodonMetadataInstance: 'https://mas.to' 51 | mastodonMetadataAccessToken: 'MASTODON_METADATA_ACCESS_TOKEN' 52 | mastodonMetadataFieldIndex: 0 53 | # Twitter settings 54 | twitterEnable: true 55 | twitterPostFormat: "New post: {title}\n\n{link}" 56 | twitterApiKey: 'TWITTER_API_KEY' 57 | twitterApiKeySecret: 'TWITTER_API_SECRET' 58 | twitterAccessToken: 'TWITTER_ACCESS_TOKEN' 59 | twitterAccessTokenSecret: 'TWITTER_ACCESS_TOKEN_SECRET' 60 | # Discord settings 61 | discordEnable: true 62 | discordPostFormat: "New post: {title}\n\n{link}" 63 | discordWebhookUrl: 'DISCORD_WEBHOOK_URL' 64 | # Slack settings 65 | slackEnable: true 66 | slackPostFormat: "New post: {title}\n\n{link}" 67 | slackWebhookUrl: 'DISCORD_WEBHOOK_URL' 68 | # Bluesky settings 69 | blueskyEnable: true 70 | blueskyPostFormat: "New post: {title}\n\n{link}" 71 | blueskyHandle: 'user.bsky.social' 72 | blueskyAppPassword: 'APP_PASSWORD' 73 | blueskyOwnerHandle: 'owner.bsky.social' 74 | blueskyOwnerContact: 'owner@example.org' 75 | ``` 76 | 77 | Before running the Action, make sure it has write permissions in your repository so that it can create and update feed cache. Go to your repository settings, select `Actions` > `General`, look for `Workflow permissions` section and make sure you have `Read and write` permissions selected. 78 | 79 | Internally, the Action uses [actions/checkout](https://github.com/actions/checkout/) and basic Git features (committing and pushing changes to the repo it's run on) to restore and update the feed cache. This allows the Action to avoid posting duplicates. 80 | 81 | The Action won't post anything if cache is empty (i.e. on first run or when you delete cache from the directory). 82 | 83 | To store sensitive information (e.g. access tokens) use [Encrypted Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) so that you can keep your action workflow public in a safe way: 84 | 85 | ```yaml 86 | - name: Feed to social media 87 | uses: lwojcik/github-action-feed-to-social-media@v2 88 | with: 89 | feedUrl: 'https://example.org/your-feed-url.xml' 90 | # Mastodon settings 91 | mastodonEnable: true 92 | mastodonInstance: 'https://your-mastodon-instance-url.example.org' 93 | mastodonAccessToken: ${{ secrets.MASTODON_ACCESS_TOKEN }} 94 | ``` 95 | 96 | ## Settings (aka GitHub Action Inputs) 97 | 98 | ### Required minimum settings 99 | 100 | - `feedUrl` (required) - URL of RSS / ATOM feed. Example: `https://offbeatbits.com/excerpts.xml` 101 | - `newestItemStrategy` - the way newest item is determined. Defaults to `latestDate`. The following strategies are available: 102 | - `latestDate` - item with the latest publication date regardless of its position in the entry array 103 | - `first` - first item in the feed in order of appearance 104 | - `last` - last item in the feed in order of appearance 105 | - `postFormat` - format string for new posts used across all channels. Example: `New article: {article} \n\n {link} \n\n #someHashtag`. Default setting: `{title} {link}` 106 | 107 | ### Post formatting 108 | 109 | You use either of the following tags for your custom post format (as long as your feed items contain those properties): 110 | 111 | - `{title}` 112 | - `{link}` 113 | - `{description}` 114 | - `{category}` 115 | - `{pubDate}` 116 | - `{enclosure.url}` 117 | - `{enclosure.type}` 118 | - `{enclosure.length}` 119 | - `{itunes:subtitle}` 120 | - `{itunes:image}` 121 | - `{itunes:explicit}` 122 | - `{itunes:keywords}` 123 | - `{itunes:episodeType}` 124 | - `{itunes:duration}` 125 | - `{itunes:episode}` 126 | 127 | Found a missing property I should add? [Submit an issue!](https://github.com/lwojcik/github-action-feed-to-social-media/issues/new) 128 | 129 | ### Per-channel post formats 130 | 131 | All channels (except Mastodon metadata) can use custom post format that overrides global `postFormat` setting. Thanks to that, it is possible to make better use of platform-specific features or increased character limits. 132 | 133 | For example, it is reasonable to use hashtags on Mastodon and Twitter: `New article: {title} {link} #some #hashtags` 134 | 135 | but skip them on Discord or Slack: `New article: {title} {link}` 136 | 137 | or alter the message altogether on channels that offer generous character limit: `New article: {title} {link} {description}` 138 | 139 | See the sections below on how to use this. 140 | 141 | ### Mastodon settings 142 | 143 | Posting to Mastodon is done by [masto](https://www.npmjs.com/package/masto) library. 144 | 145 | To use this feature, create a new application in `Development` section of your profile settings and note down your access token. 146 | 147 | - `mastodonEnable` - enables / disables posting to Mastodon. Default: `false` 148 | - `mastodonPostFormat` - custom Mastodon post format. Optional. If used, it overrides `customPostFormat` setting 149 | - `mastodonInstance` - instance URL. Example: `https://mastodon.social` 150 | - `mastodonAccessToken` - access token for your Mastodon API app 151 | - `mastodonPostVisibility` - visibility setting for your posts. Defaults to `public`. Available options: 152 | - `public` - visible for all 153 | - `unlisted` - visible for all but opted-out of discovery features like local / federated feeds 154 | - `private` - followers only 155 | - `direct` - visible only to the posting account and mentioned people 156 | 157 | ### Mastodon metadata settings 158 | 159 | Updating Mastodon profile metadata is done by [masto](https://www.npmjs.com/package/masto) library. 160 | 161 | Setting this up enables you to update your account's profile metadata with a link to your latest RSS item. 162 | 163 | **Note:** if you set `mastodonMetadataEnable` to `true` and leave other settings in this section empty, the Action will update profile metadata on the same account as the one used for posting - **you don't have to provide the same data twice**. 164 | 165 | Provide an instance and access token in this section only if you want to update profile metadata on a different profile than the one you post to. 166 | 167 | The Action will only update the value of an _existing_ metadata entry and it will _not_ create a new one if it fails to find it. Before running the Action for the first time, make sure the metadata field you want to update already exists. 168 | 169 | - `mastodonMetadataEnable` - enables / disables updating Mastodon accouns metadata. Default: `false` 170 | - `mastodonMetadataInstance` - Mastodon instance. Example: `https://mastodon.social` 171 | - `mastodonMetadataAccessToken` - access token for your Mastodon API app 172 | - `mastodonMetadataFieldIndex` - which metadata field should be updated? `0` is the first item 173 | 174 | _Shoutout to [Sammy](https://github.com/theresnotime) and their [Mastodon Field Changer](https://github.com/theresnotime/mastodon-field-changer) for inspiring me to research this feature. Thanks!_ 175 | 176 | ### Twitter settings 177 | 178 | Posting to Twitter is done by [twitter-api-v2](https://www.npmjs.com/package/twitter-api-v2) library. Authentication is done via Oauth 1.0a protocol. 179 | 180 | To post an update to a Twitter account you need to set up an app through [Twitter Developer Portal](https://developer.twitter.com/) and obtain the following set of credentials: 181 | 182 | - API key 183 | - API secret 184 | - access token 185 | - access token secret 186 | 187 | The Action was tested and confirmed to work against free tier of Twitter API v2 in June 2023. 188 | 189 | - `twitterEnable` - enables / disables posting to Twitter. Default: `false` 190 | - `twitterPostFormat` - custom Twitter post format. Optional. If used, it overrides `customPostFormat` setting 191 | - `twitterApiKey` - Twitter API key 192 | - `twitterApiKeySecret` - Twitter API key secret 193 | - `twiiterAccessToken` - Twitter access token for your app 194 | - `twitterAccessTokenSecret` - Twitter access token secret for your app 195 | 196 | ### Discord settings 197 | 198 | Posting to Discord is done by a custom wrapper around [Discord webhook mechanism](https://discord.com/developers/docs/resources/webhook). 199 | 200 | To obtain a webhook URL follow the steps below: 201 | 202 | 1. Click on **Server settings** on the server you want to add a webhook for 203 | 2. Select `Integrations`. 204 | 3. Click **Create webhook**. 205 | 4. Adjust available webhook settings. When done, click **Copy Webhook URL** - that's the URL you have to provide for the Action to post on your channel. 206 | 207 | - `discordEnable` - enables / disables posting to Discord. Default: `false` 208 | - `discordPostFormat` - custom Discord message format. Optional. If used, it overrides `customPostFormat` setting 209 | - `discordWebhookUrl` - webhook URL to use for posting. Example: `https://discordapp.com/api/webhooks/123456` 210 | 211 | ### Slack settings 212 | 213 | Posting to Slack is done by a custom wrapper around [Slack Incoming Webhooks mechanism](https://api.slack.com/messaging/webhooks). 214 | 215 | To obtain a webhook URL follow the steps below: 216 | 217 | 1. Log in to the workspace of your choice, then open [Apps page](https://api.slack.com/apps). 218 | 2. Click **Create an App** and select **From scratch**. 219 | 3. Fill in the app name and select correct workspace to integrate with. 220 | 4. Click **Create an app**. 221 | 5. On the page you see, click **Incoming Webhooks**. Click the toggle next to **Activate Incoming Webhooks** to enable it. 222 | 6. Click **Add New Webhook to Workspace**. 223 | 7. In the permission window, select a channel for the Action to post to and click **Allow**. 224 | 8. You'll be redirected back to the Incoming Webhooks page. Copy the webhook URL from the table - that's the URL you provide to the Action. 225 | 226 | - `slackEnable` - enables / disables posting to Slack. Default: `false` 227 | - `slackPostFormat` - custom Discord message format. Optional. If used, it overrides `customPostFormat` setting 228 | - `slackWebhookUrl` - webhook URL to use for posting. Example: `https://hooks.slack.com/services/123/456` 229 | 230 | ### Bluesky settings 231 | 232 | Posting to Bluesky is done by [easy-bsky-bot-sdk](https://www.npmjs.com/package/easy-bsky-bot-sdk). 233 | 234 | - `blueskyEnable` - enables / disables posting to Bluesky. Default: `false` 235 | - `blueskyService` - Bluesky server to communicate with. Default: `bsky.social` 236 | - `blueskyHandle` - Bluesky account used to post 237 | - `blueskyAppPassword` - Bluesky app password. Obtain it from `Settings > App passwords` 238 | - `blueskyOwnerHandle` - your handle as an owner of the posting account. Together with `blueskyOwnerContact`, this information is used in bot's user-agent string, so it will be visible to the server owner (but it won't be displayed publicly) 239 | - `blueskyOwnerContact` - your contact information as an owner of the posting account 240 | 241 | ## Output 242 | 243 | The Action sets the output in a following format of a stringified JSON object: 244 | 245 | ```js 246 | { 247 | [SocialService]: PostSubmitStatus, 248 | } 249 | ``` 250 | 251 | `SocialService` is the name of a social media service that has been updated. The following values are possible: 252 | 253 | - `mastodon` 254 | - `mastodonMetadata` 255 | - `twitter` 256 | - `discord` 257 | - `slack` 258 | 259 | See also [SocialService enum](https://github.com/lwojcik/github-action-feed-to-social-media/blob/main/src/types.ts#L39). 260 | 261 | `PostSubmitStatus` is the status of the last update. The following values are possible: 262 | 263 | - **URL string** - new item was detected and the update was posted successfuly - the URL points to a published status (for services that expose status URLs, like Twitter and Mastodon) 264 | - `disabled` - posting to the selected service is disabled by workflow configuration 265 | - `notConfigured` - posting to the selected service is enabled, but configuration is incomplete (e.g. missing access key) 266 | - `updated` - new item was detected and the update was posted successfully (for services that don't expose post URLs, e.g. Mastodon metadata or Discord webooks), 267 | - `errored` - new item was detected and the update was posted, but it resulted in an error, 268 | - `skipped` - no new item was detected or posting was skipped for a different reason - see the log for information. 269 | 270 | See also [PostSubmitStatus enum](https://github.com/lwojcik/github-action-feed-to-social-media/blob/main/src/types.ts#L61). 271 | 272 | Sample status: 273 | 274 | ```json 275 | { 276 | "mastodon": "https://mastodon.social/url-to-status", 277 | "mastodonMetadata": "updated", 278 | "twitter": "https://twitter.com/twitter/status/123456", 279 | "discord": "updated", 280 | "slack": "updated" 281 | } 282 | ``` 283 | --------------------------------------------------------------------------------