├── index.js ├── .nvmrc ├── .github ├── copilot-instructions.md ├── images │ ├── screenshot_20241128_174932.png │ ├── use_case_rss_trigger_overview.png │ └── use_case_rss_trigger_node_details.png ├── dependabot.yml ├── workflows │ ├── build.yml │ ├── test.yml │ ├── npm-publish.yml │ └── n8n-integration-test.yml └── git-commit-instructions.md ├── .npmignore ├── .gitignore ├── .vscode └── extensions.json ├── .editorconfig ├── .eslintrc.prepublish.js ├── jest.config.js ├── nodes └── Bluesky │ ├── V2 │ ├── resources.ts │ ├── __tests__ │ │ ├── postOperations.test.ts │ │ ├── userOperations.test.ts │ │ └── feedOperations.test.ts │ ├── userOperations.ts │ ├── feedOperations.ts │ ├── languages.ts │ ├── BlueskyV2.node.ts │ └── postOperations.ts │ ├── bluesky.svg │ ├── Bluesky.node.ts │ └── V1 │ ├── BlueskyV1.node.ts │ └── languages.ts ├── tsconfig.json ├── LICENSE.md ├── .prettierrc.js ├── README.md ├── credentials └── BlueskyApi.credentials.ts ├── .eslintrc.js ├── AGENTS.md ├── .junie └── guidelines.md ├── package.json ├── tslint.json └── CODE_OF_CONDUCT.md /index.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.14.0 2 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | ../AGENTS.md -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.tsbuildinfo 3 | -------------------------------------------------------------------------------- /.github/images/screenshot_20241128_174932.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muench-dev/n8n-nodes-bluesky/HEAD/.github/images/screenshot_20241128_174932.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | .DS_Store 4 | .tmp 5 | tmp 6 | dist 7 | npm-debug.log* 8 | yarn.lock 9 | .vscode/launch.json 10 | .qodo 11 | -------------------------------------------------------------------------------- /.github/images/use_case_rss_trigger_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muench-dev/n8n-nodes-bluesky/HEAD/.github/images/use_case_rss_trigger_overview.png -------------------------------------------------------------------------------- /.github/images/use_case_rss_trigger_node_details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muench-dev/n8n-nodes-bluesky/HEAD/.github/images/use_case_rss_trigger_node_details.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "EditorConfig.EditorConfig", 5 | "esbenp.prettier-vscode", 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "npm" 7 | directory: "/" # Location of package manifests 8 | schedule: 9 | interval: "weekly" 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [package.json] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | 18 | [*.yml] 19 | indent_style = space 20 | indent_size = 2 21 | -------------------------------------------------------------------------------- /.eslintrc.prepublish.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@types/eslint').ESLint.ConfigData} 3 | */ 4 | module.exports = { 5 | extends: "./.eslintrc.js", 6 | 7 | overrides: [ 8 | { 9 | files: ['package.json'], 10 | plugins: ['eslint-plugin-n8n-nodes-base'], 11 | rules: { 12 | 'n8n-nodes-base/community-package-json-name-still-default': 'error', 13 | }, 14 | }, 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['nodes/**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)', 'nodes/**/*TestNode.node.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': ['ts-jest', { 7 | tsconfig: 'tsconfig.json', 8 | }] 9 | }, 10 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 11 | }; 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Test and Build 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: pnpm/action-setup@v4 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 20 15 | cache: 'pnpm' 16 | - name: Install dependencies 17 | run: pnpm install 18 | - name: Build 19 | run: pnpm run build 20 | -------------------------------------------------------------------------------- /nodes/Bluesky/V2/resources.ts: -------------------------------------------------------------------------------- 1 | import { INodeProperties } from 'n8n-workflow'; 2 | 3 | export const resourcesProperty: INodeProperties = { 4 | displayName: 'Resource', 5 | name: 'resource', 6 | type: 'options', 7 | noDataExpression: true, 8 | options: [ 9 | { 10 | name: 'User', 11 | value: 'user', 12 | }, 13 | { 14 | name: 'Feed', 15 | value: 'feed', 16 | }, 17 | { 18 | name: 'Post', 19 | value: 'post', 20 | }, 21 | ], 22 | default: 'post', 23 | }; 24 | -------------------------------------------------------------------------------- /.github/git-commit-instructions.md: -------------------------------------------------------------------------------- 1 | # Commit Message Guidelines 2 | 3 | This project follows the [Conventional Commit Standard](https://www.conventionalcommits.org/): 4 | - Use `feat:`, `fix:`, `chore:`, etc. in commit messages. 5 | - Example: `feat: add support for new Bluesky post operation` 6 | 7 | ## Contribution 8 | 9 | - Ensure all tests pass before submitting a PR. 10 | - Follow the project structure and naming conventions. 11 | - Use Conventional Commits for all commit messages. 12 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test and Lint Files 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: pnpm/action-setup@v4 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 20 15 | cache: 'pnpm' 16 | - name: Install dependencies 17 | run: pnpm install 18 | - name: Lint 19 | run: pnpm run lint 20 | - name: Test 21 | run: pnpm run test 22 | 23 | -------------------------------------------------------------------------------- /nodes/Bluesky/bluesky.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "target": "es2019", 7 | "lib": ["es2019", "es2020", "es2022.error"], 8 | "removeComments": true, 9 | "useUnknownInCatchVariables": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "noImplicitAny": true, 12 | "noImplicitReturns": true, 13 | "noUnusedLocals": true, 14 | "strictNullChecks": true, 15 | "preserveConstEnums": true, 16 | "esModuleInterop": true, 17 | "resolveJsonModule": true, 18 | "incremental": true, 19 | "declaration": true, 20 | "sourceMap": true, 21 | "skipLibCheck": true, 22 | "outDir": "./dist/", 23 | }, 24 | "include": [ 25 | "credentials/**/*", 26 | "nodes/**/*", 27 | "nodes/**/*.json", 28 | "nodes/**/__tests__/**/*", 29 | "package.json", 30 | ], 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2022 n8n 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | /** 3 | * https://prettier.io/docs/en/options.html#semicolons 4 | */ 5 | semi: true, 6 | 7 | /** 8 | * https://prettier.io/docs/en/options.html#trailing-commas 9 | */ 10 | trailingComma: 'all', 11 | 12 | /** 13 | * https://prettier.io/docs/en/options.html#bracket-spacing 14 | */ 15 | bracketSpacing: true, 16 | 17 | /** 18 | * https://prettier.io/docs/en/options.html#tabs 19 | */ 20 | useTabs: true, 21 | 22 | /** 23 | * https://prettier.io/docs/en/options.html#tab-width 24 | */ 25 | tabWidth: 2, 26 | 27 | /** 28 | * https://prettier.io/docs/en/options.html#arrow-function-parentheses 29 | */ 30 | arrowParens: 'always', 31 | 32 | /** 33 | * https://prettier.io/docs/en/options.html#quotes 34 | */ 35 | singleQuote: true, 36 | 37 | /** 38 | * https://prettier.io/docs/en/options.html#quote-props 39 | */ 40 | quoteProps: 'as-needed', 41 | 42 | /** 43 | * https://prettier.io/docs/en/options.html#end-of-line 44 | */ 45 | endOfLine: 'lf', 46 | 47 | /** 48 | * https://prettier.io/docs/en/options.html#print-width 49 | */ 50 | printWidth: 100, 51 | }; 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Banner image](https://user-images.githubusercontent.com/10284570/173569848-c624317f-42b1-45a6-ab09-f0ea3c247648.png) 2 | 3 | # n8n-nodes-bluesky 4 | 5 | This repository contains the code for the n8n nodes that interact with the [Bluesky API](https://docs.bsky.app/docs/category/http-reference). 6 | 7 | ## Installation 8 | 9 | ```bash 10 | pnpm install @muench-dev/n8n-nodes-bluesky 11 | ``` 12 | 13 | In n8n community edition, you can install the nodes in the settings page. 14 | 15 | ## Features 16 | 17 | - User 18 | - Block User 19 | - Get Profile 20 | - Mute User 21 | - Un-mute User 22 | - Feed 23 | - Get Author Feed 24 | - Get Timeline of current user 25 | - Post 26 | - Create Post 27 | - Like 28 | - Unlike 29 | - Repost 30 | - Delete Repost 31 | 32 | ## Screenshots 33 | 34 | ![images](.github/images/screenshot_20241128_174932.png) 35 | 36 | ## Use Cases 37 | 38 | ### RSS Feed to Bluesky 39 | 40 | You can use the RSS Trigger node to get the latest posts from an RSS feed and then use the Create Post node to post them to Bluesky. 41 | 42 | ![images](.github/images/use_case_rss_trigger_overview.png) 43 | 44 | Use Open Graph Tags to get the image and description of the post. 45 | 46 | ![images](.github/images/use_case_rss_trigger_node_details.png) 47 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: pnpm/action-setup@v4 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: 20 19 | - name: Install dependencies 20 | run: pnpm install 21 | - name: Build 22 | run: pnpm run build 23 | #- run: pnpm ci 24 | #- run: pnpm test 25 | 26 | publish-npm: 27 | needs: build 28 | runs-on: ubuntu-latest 29 | permissions: 30 | id-token: write 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: pnpm/action-setup@v4 34 | - uses: actions/setup-node@v3 35 | with: 36 | node-version: 20 37 | registry-url: https://registry.npmjs.org/ 38 | - name: Install dependencies 39 | run: pnpm install 40 | - name: Build 41 | run: pnpm run build 42 | #- run: npm ci && npm run build 43 | - name: Publish 44 | shell: bash 45 | run: pnpm publish --access public --no-git-checks 46 | env: 47 | NPM_CONFIG_PROVENANCE: true 48 | -------------------------------------------------------------------------------- /credentials/BlueskyApi.credentials.ts: -------------------------------------------------------------------------------- 1 | import { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow'; 2 | import { Icon } from 'n8n-workflow'; 3 | 4 | export class BlueskyApi implements ICredentialType { 5 | displayName = 'Bluesky API'; 6 | name = 'blueskyApi'; 7 | documentationUrl = 'https://bsky.app/settings/app-passwords'; 8 | icon = 'node:@muench-dev/n8n-nodes-bluesky.bluesky' as Icon; 9 | 10 | properties: INodeProperties[] = [ 11 | { 12 | displayName: 'Identifier (Handle)', 13 | name: 'identifier', 14 | description: 'The handle of the user account', 15 | type: 'string', 16 | default: '', 17 | required: true, 18 | }, 19 | { 20 | displayName: 'App Password', 21 | name: 'appPassword', 22 | description: 'The password for the app', 23 | type: 'string', 24 | typeOptions: { password: true }, 25 | default: '', 26 | required: true, 27 | }, 28 | { 29 | displayName: 'Service URL', 30 | name: 'serviceUrl', 31 | description: 'The URL of the atp service', 32 | type: 'string', 33 | default: 'https://bsky.social', 34 | required: true, 35 | }, 36 | ]; 37 | 38 | test: ICredentialTestRequest = { 39 | request: { 40 | baseURL: '={{$credentials.serviceUrl}}', 41 | url: '/xrpc/com.atproto.server.createSession', 42 | method: 'POST', 43 | json: true, 44 | body: { 45 | identifier: '={{$credentials.identifier}}', 46 | password: '={{$credentials.appPassword}}', 47 | }, 48 | }, 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@types/eslint').ESLint.ConfigData} 3 | */ 4 | module.exports = { 5 | root: true, 6 | 7 | env: { 8 | browser: true, 9 | es6: true, 10 | node: true, 11 | }, 12 | 13 | parser: '@typescript-eslint/parser', 14 | 15 | parserOptions: { 16 | project: ['./tsconfig.json'], 17 | sourceType: 'module', 18 | extraFileExtensions: ['.json'], 19 | }, 20 | 21 | // ignorePatterns: ['.eslintrc.js', '**/*.js', '**/node_modules/**', '**/dist/**'], // Commented out for now 22 | 23 | overrides: [ 24 | { 25 | files: ['package.json'], 26 | plugins: ['eslint-plugin-n8n-nodes-base'], 27 | extends: ['plugin:n8n-nodes-base/community'], 28 | rules: { 29 | 'n8n-nodes-base/community-package-json-name-still-default': 'off', 30 | }, 31 | }, 32 | { 33 | files: ['./credentials/**/*.ts'], 34 | plugins: ['eslint-plugin-n8n-nodes-base'], 35 | extends: ['plugin:n8n-nodes-base/credentials'], 36 | rules: { 37 | 'n8n-nodes-base/cred-class-field-documentation-url-missing': 'off', 38 | 'n8n-nodes-base/cred-class-field-documentation-url-miscased': 'off', 39 | }, 40 | }, 41 | { 42 | files: ['./nodes/**/*.ts'], 43 | plugins: ['eslint-plugin-n8n-nodes-base'], 44 | extends: ['plugin:n8n-nodes-base/nodes'], 45 | rules: { 46 | 'n8n-nodes-base/node-execute-block-missing-continue-on-fail': 'off', 47 | 'n8n-nodes-base/node-resource-description-filename-against-convention': 'off', 48 | 'n8n-nodes-base/node-param-fixed-collection-type-unsorted-items': 'off', 49 | }, 50 | }, 51 | ], 52 | }; 53 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # n8n Bluesky Node 2 | 3 | ## Project Overview 4 | This project implements custom nodes for the [n8n](https://n8n.io) workflow automation tool, specifically for integrating with the Bluesky API. 5 | 6 | ## Project Structure 7 | - `credentials/` — Contains credential classes for authenticating with external services (e.g., `BlueskyApi.credentials.ts`). 8 | - `nodes/Bluesky/` — Main directory for Bluesky node implementations. 9 | - `Bluesky.node.ts` — Entry point for the Bluesky node. 10 | - `bluesky.svg` — Node icon. 11 | - `V1/` — Version 1 of the Bluesky node. 12 | - `BlueskyV1.node.ts`, `languages.ts` — Node logic and language support. 13 | - `V2/` — Version 2 of the Bluesky node. 14 | - `BlueskyV2.node.ts`, `BlueskyTestNode.node.ts`, `feedOperations.ts`, `postOperations.ts`, `userOperations.ts`, `resources.ts`, `languages.ts` — Node logic, operations, and resources. 15 | - `__tests__/` — Contains test files for V2 operations. 16 | - `index.js` — Main entry point for the package. 17 | - `package.json` — Project metadata and dependencies. 18 | - `tsconfig.json` — TypeScript configuration. 19 | - `tslint.json` — Linting rules. 20 | - `jest.config.js` — Jest configuration for testing. 21 | 22 | ## Tools Used 23 | - **TypeScript** — Main language for node and credential implementations. 24 | - **Jest** — Testing framework for unit and integration tests (see `nodes/Bluesky/V2/__tests__/`). 25 | - **TSLint** — Linting for TypeScript code. 26 | - **pnpm** — Package manager (see `pnpm-lock.yaml`). 27 | 28 | ## Running Tests 29 | 30 | To run all tests: 31 | 32 | ```sh 33 | pnpm install 34 | pnpm test 35 | ``` 36 | 37 | Run a specific test file: 38 | 39 | ```sh 40 | pnpm test nodes/Bluesky/V2/__tests__/feedOperations.test.ts 41 | ``` 42 | -------------------------------------------------------------------------------- /nodes/Bluesky/Bluesky.node.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationError, 3 | INodeType, 4 | INodeTypeBaseDescription, 5 | IVersionedNodeType, 6 | } from 'n8n-workflow'; 7 | import { LoggerProxy as Logger, VersionedNodeType } from 'n8n-workflow'; 8 | 9 | import { BlueskyV1 } from './V1/BlueskyV1.node'; 10 | import { BlueskyV2 } from './V2/BlueskyV2.node'; 11 | 12 | export class Bluesky extends VersionedNodeType { 13 | constructor() { 14 | const baseDescription: INodeTypeBaseDescription = { 15 | displayName: 'Bluesky', 16 | name: 'bluesky', 17 | icon: 'file:bluesky.svg', 18 | group: ['transform'], 19 | subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', 20 | description: 'Interact with the Bluesky social platform', 21 | defaultVersion: 2, 22 | }; 23 | 24 | const nodeVersions: IVersionedNodeType['nodeVersions'] = { 25 | 1: new BlueskyV1(baseDescription), 26 | 2: new BlueskyV2(baseDescription), 27 | }; 28 | 29 | super(nodeVersions, baseDescription); 30 | } 31 | 32 | override getNodeType(version?: number): INodeType { 33 | if (version === undefined) { 34 | return super.getNodeType(); 35 | } 36 | 37 | const requestedNode = this.nodeVersions[version]; 38 | if (requestedNode) { 39 | return requestedNode; 40 | } 41 | 42 | const fallbackVersion = this.getLatestVersion(); 43 | Logger.warn('Requested Bluesky node version is not available, falling back to latest version', { 44 | nodeName: this.description.name, 45 | requestedVersion: version, 46 | fallbackVersion, 47 | }); 48 | 49 | const fallbackNode = this.nodeVersions[fallbackVersion]; 50 | if (!fallbackNode) { 51 | throw new ApplicationError('Bluesky node has no available versions to fall back to.'); 52 | } 53 | 54 | // Cache the fallback under the requested version so subsequent lookups reuse it without logging 55 | this.nodeVersions[version] = fallbackNode; 56 | 57 | return fallbackNode; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.junie/guidelines.md: -------------------------------------------------------------------------------- 1 | # n8n Bluesky Node 2 | 3 | ## Project Overview 4 | This project implements custom nodes for the [n8n](https://n8n.io) workflow automation tool, specifically for integrating with the Bluesky API. 5 | 6 | ## Project Structure 7 | - `credentials/` — Contains credential classes for authenticating with external services (e.g., `BlueskyApi.credentials.ts`). 8 | - `nodes/Bluesky/` — Main directory for Bluesky node implementations. 9 | - `Bluesky.node.ts` — Entry point for the Bluesky node. 10 | - `bluesky.svg` — Node icon. 11 | - `V1/` — Version 1 of the Bluesky node. 12 | - `BlueskyV1.node.ts`, `languages.ts` — Node logic and language support. 13 | - `V2/` — Version 2 of the Bluesky node. 14 | - `BlueskyV2.node.ts`, `BlueskyTestNode.node.ts`, `feedOperations.ts`, `postOperations.ts`, `userOperations.ts`, `resources.ts`, `languages.ts` — Node logic, operations, and resources. 15 | - `__tests__/` — Contains test files for V2 operations. 16 | - `index.js` — Main entry point for the package. 17 | - `package.json` — Project metadata and dependencies. 18 | - `tsconfig.json` — TypeScript configuration. 19 | - `tslint.json` — Linting rules. 20 | - `jest.config.js` — Jest configuration for testing. 21 | 22 | ## Tools Used 23 | - **TypeScript** — Main language for node and credential implementations. 24 | - **Jest** — Testing framework for unit and integration tests (see `nodes/Bluesky/V2/__tests__/`). 25 | - **TSLint** — Linting for TypeScript code. 26 | - **pnpm** — Package manager (see `pnpm-lock.yaml`). 27 | 28 | ## Running Tests 29 | 30 | To run all tests: 31 | 32 | ```sh 33 | pnpm install 34 | pnpm test 35 | ``` 36 | 37 | Run a specific test file: 38 | 39 | ```sh 40 | pnpm test nodes/Bluesky/V2/__tests__/feedOperations.test.ts 41 | ``` 42 | 43 | 44 | ## Commit Message Guidelines 45 | This project follows the [Conventional Commit Standard](https://www.conventionalcommits.org/): 46 | - Use `feat:`, `fix:`, `chore:`, etc. in commit messages. 47 | - Example: `feat: add support for new Bluesky post operation` 48 | 49 | ## Contribution 50 | - Ensure all tests pass before submitting a PR. 51 | - Follow the project structure and naming conventions. 52 | - Use Conventional Commits for all commit messages. 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@muench-dev/n8n-nodes-bluesky", 3 | "version": "3.1.1", 4 | "description": "BlueSky API nodes for n8n", 5 | "keywords": [ 6 | "n8n-community-node-package" 7 | ], 8 | "license": "MIT", 9 | "homepage": "https://github.com/muench-dev/n8n-nodes-bluesky#readme", 10 | "author": { 11 | "name": "Christian Münch", 12 | "email": "christian@muench.dev" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/muench-dev/n8n-nodes-bluesky.git" 17 | }, 18 | "engines": { 19 | "node": ">=18.10", 20 | "pnpm": ">=9.1" 21 | }, 22 | "packageManager": "pnpm@9.1.4", 23 | "main": "index.js", 24 | "scripts": { 25 | "preinstall": "npx only-allow pnpm", 26 | "clean": "rimraf dist/", 27 | "build": "npm run clean && tsc && npm run build:images", 28 | "build:images": "copyfiles nodes/**/*.svg nodes/**/*.png dist/", 29 | "dev": "tsc --watch", 30 | "format": "prettier nodes credentials --write", 31 | "lint": "eslint nodes credentials package.json", 32 | "lintfix": "eslint nodes credentials package.json --fix", 33 | "test": "jest", 34 | "prepublishOnly": "pnpm build && pnpm lint -c .eslintrc.prepublish.js nodes credentials package.json" 35 | }, 36 | "files": [ 37 | "dist" 38 | ], 39 | "n8n": { 40 | "n8nNodesApiVersion": 1, 41 | "credentials": [ 42 | "dist/credentials/BlueskyApi.credentials.js" 43 | ], 44 | "nodes": [ 45 | "dist/nodes/Bluesky/Bluesky.node.js" 46 | ] 47 | }, 48 | "devDependencies": { 49 | "@types/jest": "^30.0.0", 50 | "@types/node": "^24.6.2", 51 | "@typescript-eslint/parser": "^8.16.0", 52 | "copyfiles": "^2.4.1", 53 | "eslint": "^8.56.0", 54 | "eslint-plugin-n8n-nodes-base": "^1.16.1", 55 | "jest": "^30.0.2", 56 | "prettier": "^3.3.2", 57 | "rimraf": "^6.0.1", 58 | "ts-jest": "^29.3.4", 59 | "typescript": "^5.5.3" 60 | }, 61 | "peerDependencies": { 62 | "n8n-workflow": "*" 63 | }, 64 | "dependencies": { 65 | "@atproto/api": "^0.17.3", 66 | "open-graph-scraper": "^6.8.2", 67 | "sharp": "^0.34.3", 68 | "zod": "^4.1.12" 69 | }, 70 | "pnpm": { 71 | "overrides": { 72 | "form-data@>=4.0.0 <4.0.4": ">=4.0.4", 73 | "axios@<1.12.0": ">=1.12.0" 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "linterOptions": { 3 | "exclude": [ 4 | "node_modules/**/*" 5 | ] 6 | }, 7 | "defaultSeverity": "error", 8 | "jsRules": {}, 9 | "rules": { 10 | "array-type": [ 11 | true, 12 | "array-simple" 13 | ], 14 | "arrow-return-shorthand": true, 15 | "ban": [ 16 | true, 17 | { 18 | "name": "Array", 19 | "message": "tsstyle#array-constructor" 20 | } 21 | ], 22 | "ban-types": [ 23 | true, 24 | [ 25 | "Object", 26 | "Use {} instead." 27 | ], 28 | [ 29 | "String", 30 | "Use 'string' instead." 31 | ], 32 | [ 33 | "Number", 34 | "Use 'number' instead." 35 | ], 36 | [ 37 | "Boolean", 38 | "Use 'boolean' instead." 39 | ] 40 | ], 41 | "class-name": true, 42 | "curly": [ 43 | true, 44 | "ignore-same-line" 45 | ], 46 | "forin": true, 47 | "jsdoc-format": true, 48 | "label-position": true, 49 | "indent": [ 50 | true, 51 | "tabs", 52 | 2 53 | ], 54 | "member-access": [ 55 | true, 56 | "no-public" 57 | ], 58 | "new-parens": true, 59 | "no-angle-bracket-type-assertion": true, 60 | "no-any": true, 61 | "no-arg": true, 62 | "no-conditional-assignment": true, 63 | "no-construct": true, 64 | "no-debugger": true, 65 | "no-default-export": true, 66 | "no-duplicate-variable": true, 67 | "no-inferrable-types": true, 68 | "ordered-imports": [ 69 | true, 70 | { 71 | "import-sources-order": "any", 72 | "named-imports-order": "case-insensitive" 73 | } 74 | ], 75 | "no-namespace": [ 76 | true, 77 | "allow-declarations" 78 | ], 79 | "no-reference": true, 80 | "no-string-throw": true, 81 | "no-unused-expression": true, 82 | "no-var-keyword": true, 83 | "object-literal-shorthand": true, 84 | "only-arrow-functions": [ 85 | true, 86 | "allow-declarations", 87 | "allow-named-functions" 88 | ], 89 | "prefer-const": true, 90 | "radix": true, 91 | "semicolon": [ 92 | true, 93 | "always", 94 | "ignore-bound-class-methods" 95 | ], 96 | "switch-default": true, 97 | "trailing-comma": [ 98 | true, 99 | { 100 | "multiline": { 101 | "objects": "always", 102 | "arrays": "always", 103 | "functions": "always", 104 | "typeLiterals": "ignore" 105 | }, 106 | "esSpecCompliant": true 107 | } 108 | ], 109 | "triple-equals": [ 110 | true, 111 | "allow-null-check" 112 | ], 113 | "use-isnan": true, 114 | "quotes": [ 115 | "error", 116 | "single" 117 | ], 118 | "variable-name": [ 119 | true, 120 | "check-format", 121 | "ban-keywords", 122 | "allow-leading-underscore", 123 | "allow-trailing-underscore" 124 | ] 125 | }, 126 | "rulesDirectory": [] 127 | } 128 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at jan@n8n.io. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /nodes/Bluesky/V2/__tests__/postOperations.test.ts: -------------------------------------------------------------------------------- 1 | import { postOperation } from '../postOperations'; 2 | import ogs from 'open-graph-scraper'; 3 | import { AtpAgent } from '@atproto/api'; 4 | 5 | jest.mock('open-graph-scraper'); 6 | jest.mock('@atproto/api'); 7 | 8 | describe('postOperation', () => { 9 | let mockAgent: jest.Mocked; 10 | 11 | beforeEach(() => { 12 | // Reset mocks before each test 13 | (ogs as jest.Mock).mockReset(); 14 | (jest.fn() as any).mockReset?.(); 15 | 16 | // Mock AtpAgent and its methods 17 | mockAgent = { 18 | post: jest.fn().mockResolvedValue({ uri: 'test-uri', cid: 'test-cid' }), 19 | uploadBlob: jest.fn().mockResolvedValue({ data: { blob: 'test-blob' } }), 20 | } as any; // Using 'any' to simplify mock structure for methods not directly used by postOperation's core logic being tested 21 | }); 22 | 23 | it('should set description to empty string if ogDescription is missing when fetching Open Graph tags', async () => { 24 | (ogs as jest.Mock).mockResolvedValue({ 25 | error: false, 26 | result: { 27 | ogTitle: 'Test Title', 28 | // ogDescription is intentionally missing 29 | ogImage: [{ url: 'http://example.com/image.png' }], 30 | success: true, 31 | }, 32 | }); 33 | 34 | // Mock fetch for image data 35 | global.fetch = jest.fn().mockResolvedValue({ 36 | ok: true, 37 | statusText: 'OK', 38 | arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)), 39 | } as any); 40 | 41 | const postText = 'Test post'; 42 | const langs = ['en']; 43 | const websiteCard = { 44 | uri: 'http://example.com', 45 | fetchOpenGraphTags: true, 46 | title: '', // Title will be overridden by OG data 47 | description: 'Initial Description', // This should be overridden 48 | thumbnailBinary: undefined, 49 | }; 50 | 51 | await postOperation(mockAgent, postText, langs, websiteCard); 52 | 53 | expect(mockAgent.post).toHaveBeenCalledTimes(1); 54 | const postData = mockAgent.post.mock.calls[0][0]; 55 | expect(postData).toEqual({ 56 | langs: ['en'], 57 | embed: { 58 | $type: 'app.bsky.embed.external', 59 | external: { 60 | uri: 'http://example.com', 61 | title: 'Test Title', 62 | description: '', 63 | thumb: 'test-blob', 64 | }, 65 | }, 66 | text: "Test post" 67 | }); 68 | // Ensure image upload used explicit encoding for website card 69 | expect(mockAgent.uploadBlob).toHaveBeenCalledWith(expect.any(Buffer), { encoding: 'image/jpeg' }); 70 | }); 71 | 72 | it('should handle empty websiteCard.description', async () => { 73 | const postText = 'Test post'; 74 | const langs = ['en']; 75 | const websiteCard = { 76 | uri: 'http://example.com', 77 | fetchOpenGraphTags: false, 78 | title: 'Test Title', 79 | description: '', 80 | thumbnailBinary: undefined, 81 | }; 82 | 83 | await postOperation(mockAgent, postText, langs, websiteCard); 84 | 85 | expect(mockAgent.post).toHaveBeenCalledTimes(1); 86 | const postData = mockAgent.post.mock.calls[0][0]; 87 | expect(postData).toEqual({ 88 | embed: { 89 | $type: 'app.bsky.embed.external', 90 | external: { 91 | uri: 'http://example.com', 92 | title: 'Test Title', 93 | description: '', 94 | thumb: undefined, 95 | }, 96 | }, 97 | facets: undefined, 98 | text: postText, 99 | langs: ['en'], 100 | }); 101 | }); 102 | 103 | it('should support ogImage as a string', async () => { 104 | (ogs as jest.Mock).mockResolvedValue({ 105 | error: false, 106 | result: { 107 | ogTitle: 'Title From OG', 108 | ogDescription: 'Desc From OG', 109 | ogImage: 'http://example.com/og-image.jpg', 110 | }, 111 | }); 112 | 113 | global.fetch = jest.fn().mockResolvedValue({ 114 | ok: true, 115 | arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(16)), 116 | } as any); 117 | 118 | await postOperation( 119 | mockAgent, 120 | 'Post with OG image string', 121 | ['en'], 122 | { uri: 'http://example.com', fetchOpenGraphTags: true, title: '', description: '', thumbnailBinary: undefined } 123 | ); 124 | 125 | // Called once for OG thumbnail with encoding 126 | expect(mockAgent.uploadBlob).toHaveBeenCalledWith(expect.any(Buffer), { encoding: 'image/jpeg' }); 127 | expect(mockAgent.post).toHaveBeenCalledTimes(1); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /nodes/Bluesky/V1/BlueskyV1.node.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | INodeExecutionData, 3 | IExecuteFunctions, 4 | INodeType, 5 | INodeTypeDescription, 6 | INodeTypeBaseDescription, 7 | } from 'n8n-workflow'; 8 | 9 | import { NodeConnectionType } from 'n8n-workflow'; 10 | 11 | import { 12 | AppBskyFeedGetAuthorFeed, 13 | AppBskyFeedPost, 14 | AtpAgent, 15 | CredentialSession, 16 | RichText, 17 | } from '@atproto/api'; 18 | import { getLanguageOptions } from './languages'; 19 | import { FeedViewPost } from '@atproto/api/dist/client/types/app/bsky/feed/defs'; 20 | 21 | export class BlueskyV1 implements INodeType { 22 | description: INodeTypeDescription; 23 | 24 | constructor(baseDescription: INodeTypeBaseDescription) { 25 | this.description = { 26 | ...baseDescription, 27 | version: 1, 28 | subtitle: '', 29 | defaults: { 30 | name: 'Bluesky', 31 | }, 32 | inputs: [NodeConnectionType.Main], 33 | outputs: [NodeConnectionType.Main], 34 | credentials: [ 35 | { 36 | name: 'blueskyApi', 37 | required: true, 38 | }, 39 | ], 40 | properties: [ 41 | { 42 | displayName: 'Operation', 43 | name: 'operation', 44 | type: 'options', 45 | noDataExpression: true, 46 | options: [ 47 | { 48 | name: 'Get Author Feed', 49 | value: 'getAuthorFeed', 50 | description: 'Retrieve user feed', 51 | action: 'Retrieve user feed', 52 | }, 53 | { 54 | name: 'Create a Post', 55 | value: 'post', 56 | description: 'Create a new post', 57 | action: 'Post a status update to bluesky', 58 | }, 59 | ], 60 | default: 'post', 61 | }, 62 | { 63 | displayName: 'Post Text', 64 | name: 'postText', 65 | type: 'string', 66 | default: '', 67 | displayOptions: { 68 | show: { 69 | operation: ['post'], 70 | }, 71 | }, 72 | }, 73 | { 74 | displayName: 'Language Names or IDs', 75 | name: 'langs', 76 | type: 'multiOptions', 77 | description: 78 | 'Choose from the list of supported languages. Choose from the list, or specify IDs using an expression.', 79 | options: getLanguageOptions(), 80 | default: ['en'], 81 | displayOptions: { 82 | show: { 83 | operation: ['post'], 84 | }, 85 | }, 86 | }, 87 | ], 88 | }; 89 | } 90 | 91 | async execute(this: IExecuteFunctions): Promise { 92 | const items = this.getInputData(); 93 | const returnData: INodeExecutionData[] = []; 94 | 95 | // Load credentials 96 | const credentials = (await this.getCredentials('blueskyApi')) as { 97 | identifier: string; 98 | appPassword: string; 99 | serviceUrl: string; 100 | }; 101 | 102 | const operation = this.getNodeParameter('operation', 0) as string; 103 | const serviceUrl = new URL(credentials.serviceUrl.replace(/\/+$/, '')); // Ensure no trailing slash 104 | 105 | const session = new CredentialSession(serviceUrl); 106 | const agent = new AtpAgent(session); 107 | await agent.login({ 108 | identifier: credentials.identifier, 109 | password: credentials.appPassword, 110 | }); 111 | 112 | for (let i = 0; i < items.length; i++) { 113 | if (operation === 'getAuthorFeed') { 114 | const authorFeedResponse: AppBskyFeedGetAuthorFeed.Response = await agent.getAuthorFeed({ 115 | actor: credentials.identifier, 116 | limit: 10, 117 | }); 118 | 119 | authorFeedResponse.data.feed.forEach((feedPost: FeedViewPost) => { 120 | returnData.push({ 121 | json: { 122 | post: feedPost.post, 123 | reply: feedPost.reply, 124 | reason: feedPost.reason, 125 | feedContext: feedPost.feedContext, 126 | }, 127 | }); 128 | }); 129 | } 130 | 131 | if (operation === 'post') { 132 | let rt = new RichText({ 133 | text: this.getNodeParameter('postText', i) as string, 134 | }); 135 | 136 | await rt.detectFacets(agent); 137 | 138 | let postData = { 139 | text: rt.text, 140 | langs: this.getNodeParameter('langs', i) as string[], 141 | facets: rt.facets, 142 | } as AppBskyFeedPost.Record & Omit; 143 | 144 | const postResponse: { uri: string; cid: string } = await agent.post(postData); 145 | 146 | returnData.push({ 147 | json: { 148 | uri: postResponse.uri, 149 | cid: postResponse.cid, 150 | }, 151 | }); 152 | } 153 | } 154 | 155 | return [returnData]; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /.github/workflows/n8n-integration-test.yml: -------------------------------------------------------------------------------- 1 | name: n8n Integration Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | # Allow manual trigger for testing 8 | workflow_dispatch: 9 | 10 | jobs: 11 | n8n-integration-test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | # Test against multiple n8n versions to ensure compatibility 16 | n8n-version: ['latest', '1.82.0'] 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup pnpm 23 | uses: pnpm/action-setup@v4 24 | 25 | - name: Setup Node.js 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: 20 29 | cache: 'pnpm' 30 | 31 | - name: Install dependencies and build package 32 | run: | 33 | pnpm install 34 | pnpm build 35 | 36 | - name: Pack the package for installation 37 | run: | 38 | pnpm pack 39 | # Move the packed tarball to a predictable location 40 | mv *.tgz muench-dev-n8n-nodes-bluesky.tgz 41 | 42 | - name: Setup vanilla n8n installation 43 | run: | 44 | # Create a temporary directory for n8n installation 45 | mkdir -p /tmp/n8n-test 46 | cd /tmp/n8n-test 47 | 48 | # Initialize a new npm project 49 | npm init -y 50 | 51 | # Install n8n 52 | npm install n8n@${{ matrix.n8n-version }} 53 | 54 | # Install our Bluesky nodes package from the packed tarball 55 | npm install ${{ github.workspace }}/muench-dev-n8n-nodes-bluesky.tgz 56 | 57 | - name: Verify package installation 58 | run: | 59 | set -euo pipefail 60 | cd /tmp/n8n-test 61 | npm list --depth=0 @muench-dev/n8n-nodes-bluesky 62 | node <<'NODE' 63 | const pkg = require('@muench-dev/n8n-nodes-bluesky/package.json'); 64 | console.log(`Installed ${pkg.name} v${pkg.version}`); 65 | NODE 66 | 67 | - name: Test n8n startup with package 68 | timeout-minutes: 5 69 | run: | 70 | set -euo pipefail 71 | cd /tmp/n8n-test 72 | export N8N_DISABLE_UI=true 73 | export N8N_SKIP_WEBHOOK_DEREGISTRATION_SHUTDOWN=true 74 | export N8N_LOG_LEVEL=error 75 | export N8N_PORT=5678 76 | export N8N_LISTEN_ADDRESS=127.0.0.1 77 | 78 | timeout 90s npm exec -- n8n start > n8n-startup.log 2>&1 & 79 | N8N_PID=$! 80 | 81 | echo "⏳ Waiting to ensure n8n remains running..." 82 | sleep 15 83 | 84 | if ! kill -0 "$N8N_PID" 2>/dev/null; then 85 | echo "❌ n8n process exited during startup" 86 | cat n8n-startup.log 87 | exit 1 88 | fi 89 | 90 | echo "✅ n8n stayed up during the initial smoke check" 91 | kill "$N8N_PID" 92 | wait "$N8N_PID" 2>/dev/null || true 93 | 94 | - name: Test API endpoint availability 95 | timeout-minutes: 3 96 | run: | 97 | set -euo pipefail 98 | cd /tmp/n8n-test 99 | # Set environment variables for n8n 100 | export N8N_DISABLE_UI=true 101 | export N8N_SKIP_WEBHOOK_DEREGISTRATION_SHUTDOWN=true 102 | export N8N_LOG_LEVEL=error 103 | export N8N_PORT=5678 104 | export N8N_LISTEN_ADDRESS=127.0.0.1 105 | 106 | # Start n8n in the background 107 | timeout 60s npm exec -- n8n start > n8n-api-test.log 2>&1 & 108 | N8N_PID=$! 109 | 110 | ready=false 111 | 112 | # Wait for n8n to be ready 113 | echo "⏳ Waiting for n8n to be ready..." 114 | for i in {1..30}; do 115 | if curl -f -s http://127.0.0.1:5678/healthz > /dev/null 2>&1; then 116 | echo "✅ n8n API is responding" 117 | ready=true 118 | break 119 | fi 120 | if ! kill -0 $N8N_PID 2>/dev/null; then 121 | echo "❌ n8n process died during startup" 122 | cat n8n-api-test.log 123 | exit 1 124 | fi 125 | sleep 2 126 | done 127 | 128 | if [ "$ready" != "true" ]; then 129 | echo "❌ n8n API health check failed" 130 | cat n8n-api-test.log 131 | exit 1 132 | fi 133 | 134 | # Clean shutdown 135 | kill $N8N_PID 136 | wait $N8N_PID 2>/dev/null || true 137 | 138 | - name: Upload test artifacts on failure 139 | if: failure() 140 | uses: actions/upload-artifact@v4 141 | with: 142 | name: n8n-test-logs-${{ matrix.n8n-version }} 143 | path: | 144 | /tmp/n8n-test/*.log 145 | /tmp/n8n-test/package.json 146 | /tmp/n8n-test/package-lock.json 147 | retention-days: 7 148 | -------------------------------------------------------------------------------- /nodes/Bluesky/V2/userOperations.ts: -------------------------------------------------------------------------------- 1 | import { INodeExecutionData, INodeProperties, IDataObject } from 'n8n-workflow'; 2 | import { 3 | AppBskyActorGetProfile, 4 | AppBskyGraphMuteActor, 5 | AppBskyGraphUnmuteActor, 6 | AtpAgent, 7 | AtUri, 8 | } from '@atproto/api'; 9 | 10 | export const userProperties: INodeProperties[] = [ 11 | { 12 | displayName: 'Operation', 13 | name: 'operation', 14 | type: 'options', 15 | noDataExpression: true, 16 | displayOptions: { 17 | show: { 18 | resource: ['user'], 19 | }, 20 | }, 21 | options: [ 22 | { 23 | name: 'Block User', 24 | value: 'block', 25 | description: 26 | 'Blocking a user prevents interaction and hides the user from the client experience', 27 | action: 'Block a user', 28 | }, 29 | { 30 | name: 'Get Profile', 31 | value: 'getProfile', 32 | description: 'Get detailed profile view of an actor', 33 | action: 'Get detailed profile view of an actor', 34 | }, 35 | { 36 | name: 'Mute User', 37 | value: 'mute', 38 | description: 'Muting a user hides their posts from your feeds', 39 | action: 'Mute a user', 40 | }, 41 | { 42 | name: 'Un-Block User', 43 | value: 'unblock', 44 | description: 'Unblocking a user restores interaction and shows the user in the client experience', 45 | action: 'Unblock a user', 46 | }, 47 | { 48 | name: 'Un-Mute User', 49 | value: 'unmute', 50 | description: 'Muting a user hides their posts from your feeds', 51 | action: 'Unmute a user', 52 | }, 53 | ], 54 | default: 'getProfile', 55 | }, 56 | { 57 | displayName: 'Did', 58 | name: 'did', 59 | type: 'string', 60 | default: '', 61 | required: true, 62 | description: 'The DID of the user', 63 | hint: 'The getProfile operation can be used to get the DID of a user', 64 | displayOptions: { 65 | show: { 66 | resource: ['user'], 67 | operation: ['mute', 'unmute', 'block'], 68 | }, 69 | }, 70 | }, 71 | { 72 | displayName: 'Actor', 73 | name: 'actor', 74 | type: 'string', 75 | default: '', 76 | required: true, 77 | description: 'Handle or DID of account to fetch profile of', 78 | displayOptions: { 79 | show: { 80 | resource: ['user'], 81 | operation: ['getProfile'], 82 | }, 83 | }, 84 | }, 85 | { 86 | displayName: 'Uri', 87 | name: 'uri', 88 | type: 'string', 89 | description: 'The URI of the user', 90 | default: '', 91 | required: true, 92 | displayOptions: { 93 | show: { 94 | resource: ['user'], 95 | operation: ['unblock'], 96 | }, 97 | }, 98 | }, 99 | ]; 100 | 101 | export async function muteOperation(agent: AtpAgent, did: string): Promise { 102 | const returnData: INodeExecutionData[] = []; 103 | const muteResponse: AppBskyGraphMuteActor.Response = await agent.mute(did); 104 | 105 | returnData.push({ 106 | json: { 107 | did: did, 108 | success: muteResponse.success !== undefined ? muteResponse.success : true, 109 | } as IDataObject, 110 | } as INodeExecutionData); 111 | 112 | return returnData; 113 | } 114 | 115 | export async function unmuteOperation(agent: AtpAgent, did: string): Promise { 116 | const returnData: INodeExecutionData[] = []; 117 | const unmuteResponse: AppBskyGraphUnmuteActor.Response = await agent.unmute(did); 118 | 119 | returnData.push({ 120 | json: { 121 | did: did, 122 | success: unmuteResponse.success !== undefined ? unmuteResponse.success : true, 123 | } as IDataObject, 124 | } as INodeExecutionData); 125 | 126 | return returnData; 127 | } 128 | 129 | export async function getProfileOperation( 130 | agent: AtpAgent, 131 | actor: string, 132 | ): Promise { 133 | const returnData: INodeExecutionData[] = []; 134 | const profileResponse: AppBskyActorGetProfile.Response = await agent.getProfile({ 135 | actor: actor, 136 | }); 137 | 138 | returnData.push({ 139 | json: profileResponse.data as unknown as IDataObject, 140 | } as INodeExecutionData); 141 | 142 | return returnData; 143 | } 144 | 145 | export async function blockOperation(agent: AtpAgent, did: string): Promise { 146 | const returnData: INodeExecutionData[] = []; 147 | 148 | const { uri } = await agent.app.bsky.graph.block.create( 149 | { repo: agent.session!.did }, // owner DID 150 | { 151 | subject: did, // DID of the user to block 152 | createdAt: new Date().toISOString(), 153 | }, 154 | ); 155 | 156 | returnData.push({ 157 | json: { 158 | uri, 159 | }, 160 | } as INodeExecutionData); 161 | 162 | return returnData; 163 | } 164 | 165 | export async function unblockOperation( 166 | agent: AtpAgent, 167 | uri: string, 168 | ): Promise { 169 | const returnData: INodeExecutionData[] = []; 170 | const { rkey } = new AtUri(uri); 171 | 172 | await agent.app.bsky.graph.block.delete({ 173 | repo: agent.session!.did, // Assuming block records are in the user's own repo 174 | rkey, 175 | }); 176 | 177 | returnData.push({ 178 | json: { 179 | uri, 180 | }, 181 | } as INodeExecutionData); 182 | 183 | return returnData; 184 | } 185 | -------------------------------------------------------------------------------- /nodes/Bluesky/V2/__tests__/userOperations.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | muteOperation, 3 | unmuteOperation, 4 | getProfileOperation, 5 | blockOperation, 6 | unblockOperation 7 | } from '../userOperations'; 8 | import { 9 | AtpAgent, 10 | AtUri 11 | } from '@atproto/api'; 12 | 13 | // Mock the @atproto/api module 14 | jest.mock('@atproto/api'); 15 | 16 | describe('userOperations', () => { 17 | let mockAgent: jest.Mocked; 18 | 19 | beforeEach(() => { 20 | // Reset mocks before each test 21 | jest.clearAllMocks(); 22 | 23 | // Mock AtpAgent and its methods 24 | mockAgent = { 25 | mute: jest.fn().mockResolvedValue({}), 26 | unmute: jest.fn().mockResolvedValue({}), 27 | getProfile: jest.fn().mockResolvedValue({ 28 | data: { 29 | did: 'did:plc:testuser', 30 | handle: 'test.bsky.social', 31 | displayName: 'Test User', 32 | } 33 | }), 34 | app: { 35 | bsky: { 36 | graph: { 37 | block: { 38 | create: jest.fn().mockResolvedValue({ uri: 'test-block-uri' }), 39 | delete: jest.fn().mockResolvedValue({}) 40 | } 41 | } 42 | } 43 | }, 44 | session: { 45 | did: 'did:plc:loggedinuser' 46 | } 47 | } as any; 48 | }); 49 | 50 | describe('muteOperation', () => { 51 | it('should call agent.mute with the correct DID', async () => { 52 | const did = 'did:plc:testuser'; 53 | const result = await muteOperation(mockAgent, did); 54 | 55 | expect(mockAgent.mute).toHaveBeenCalledWith(did); 56 | expect(result).toHaveLength(1); 57 | expect(result[0].json).toEqual({ did, success: true }); 58 | }); 59 | 60 | it('should return the mute response in the result', async () => { 61 | const mockResponse = { success: true }; 62 | (mockAgent.mute as jest.Mock).mockResolvedValue(mockResponse); 63 | 64 | const did = 'did:plc:testuser'; 65 | const result = await muteOperation(mockAgent, did); 66 | 67 | expect(result[0].json).toEqual({ did, success: true }); 68 | }); 69 | }); 70 | 71 | describe('unmuteOperation', () => { 72 | it('should call agent.unmute with the correct DID', async () => { 73 | const did = 'did:plc:testuser'; 74 | const result = await unmuteOperation(mockAgent, did); 75 | 76 | expect(mockAgent.unmute).toHaveBeenCalledWith(did); 77 | expect(result).toHaveLength(1); 78 | expect(result[0].json).toEqual({ did, success: true }); 79 | }); 80 | 81 | it('should return the unmute response in the result', async () => { 82 | const mockResponse = { success: true }; 83 | (mockAgent.unmute as jest.Mock).mockResolvedValue(mockResponse); 84 | 85 | const did = 'did:plc:testuser'; 86 | const result = await unmuteOperation(mockAgent, did); 87 | 88 | expect(result[0].json).toEqual({ did, success: true }); 89 | }); 90 | }); 91 | 92 | describe('getProfileOperation', () => { 93 | it('should call agent.getProfile with the correct actor', async () => { 94 | const actor = 'test.bsky.social'; 95 | const result = await getProfileOperation(mockAgent, actor); 96 | 97 | expect(mockAgent.getProfile).toHaveBeenCalledWith({ actor }); 98 | expect(result).toHaveLength(1); 99 | expect(result[0].json).toEqual({ 100 | did: 'did:plc:testuser', 101 | handle: 'test.bsky.social', 102 | displayName: 'Test User', 103 | }); 104 | }); 105 | 106 | it('should return the profile data in the result', async () => { 107 | const mockProfileData = { 108 | did: 'did:plc:testuser', 109 | handle: 'test.bsky.social', 110 | displayName: 'Custom Name', 111 | description: 'Test description' 112 | }; 113 | 114 | (mockAgent.getProfile as jest.Mock).mockResolvedValue({ 115 | data: mockProfileData 116 | }); 117 | 118 | const actor = 'test.bsky.social'; 119 | const result = await getProfileOperation(mockAgent, actor); 120 | 121 | expect(result[0].json).toEqual(mockProfileData); 122 | }); 123 | }); 124 | 125 | describe('blockOperation', () => { 126 | it('should call agent.app.bsky.graph.block.create with the correct parameters', async () => { 127 | const did = 'did:plc:testuser'; 128 | const result = await blockOperation(mockAgent, did); 129 | 130 | expect(mockAgent.app.bsky.graph.block.create).toHaveBeenCalledWith( 131 | { repo: 'did:plc:loggedinuser' }, 132 | { 133 | subject: did, 134 | createdAt: expect.any(String) 135 | } 136 | ); 137 | expect(result).toHaveLength(1); 138 | expect(result[0].json).toEqual({ uri: 'test-block-uri' }); 139 | }); 140 | }); 141 | 142 | describe('unblockOperation', () => { 143 | it('should call agent.app.bsky.graph.block.delete with the correct parameters', async () => { 144 | // Mock AtUri constructor 145 | ((AtUri as unknown) as jest.Mock).mockImplementation((uri) => { 146 | return { 147 | uri, 148 | rkey: 'test-rkey' 149 | }; 150 | }); 151 | 152 | const uri = 'at://did:plc:loggedinuser/app.bsky.graph.block/test-rkey'; 153 | const result = await unblockOperation(mockAgent, uri); 154 | 155 | expect(mockAgent.app.bsky.graph.block.delete).toHaveBeenCalledWith({ 156 | repo: 'did:plc:loggedinuser', 157 | rkey: 'test-rkey' 158 | }); 159 | expect(result).toHaveLength(1); 160 | expect(result[0].json).toEqual({ uri }); 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /nodes/Bluesky/V2/feedOperations.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AppBskyFeedDefs, 3 | AppBskyFeedGetAuthorFeed, 4 | AppBskyFeedGetTimeline, 5 | AtpAgent, 6 | ComAtprotoLabelDefs, 7 | AppBskyActorDefs, 8 | } from '@atproto/api'; 9 | import { INodeExecutionData, INodeProperties, IDataObject } from 'n8n-workflow'; 10 | 11 | export interface OutputPost { 12 | uri: string; 13 | cid: string; 14 | author: { 15 | did: string; 16 | handle: string; 17 | displayName?: string; 18 | avatar?: string; 19 | viewer?: AppBskyActorDefs.ViewerState; 20 | labels?: ComAtprotoLabelDefs.Label[]; 21 | }; 22 | record: { 23 | text?: string; 24 | createdAt: string; 25 | [key: string]: any; 26 | }; 27 | embed?: AppBskyFeedDefs.PostView['embed']; 28 | replyCount?: number; 29 | repostCount?: number; 30 | likeCount?: number; 31 | indexedAt: string; 32 | viewer?: AppBskyFeedDefs.ViewerState; 33 | labels?: ComAtprotoLabelDefs.Label[]; 34 | replyParent?: { 35 | uri: string; 36 | cid: string; 37 | authorDid?: string; 38 | } | null; 39 | repostedBy?: { 40 | did: string; 41 | handle: string; 42 | displayName?: string; 43 | avatar?: string; 44 | viewer?: AppBskyActorDefs.ViewerState; 45 | labels?: ComAtprotoLabelDefs.Label[]; 46 | }; 47 | feedContext?: string | { text: string; facets?: any[] }; 48 | [key: string]: any; 49 | } 50 | 51 | export function mapFeedViewPostToOutputPost( 52 | feedViewPost: AppBskyFeedDefs.FeedViewPost, 53 | ): OutputPost { 54 | const post = feedViewPost.post; 55 | 56 | const outputPost: OutputPost = { 57 | uri: post.uri, 58 | cid: post.cid, 59 | author: { 60 | did: post.author.did, 61 | handle: post.author.handle, 62 | displayName: post.author.displayName, 63 | avatar: post.author.avatar, 64 | viewer: post.author.viewer, 65 | labels: post.author.labels, 66 | }, 67 | record: { 68 | text: (post.record as any)?.text, 69 | createdAt: (post.record as any)?.createdAt, 70 | ...(post.record as any), 71 | }, 72 | embed: post.embed, 73 | replyCount: post.replyCount, 74 | repostCount: post.repostCount, 75 | likeCount: post.likeCount, 76 | indexedAt: post.indexedAt, 77 | viewer: post.viewer, 78 | labels: post.labels, 79 | replyParent: null, 80 | }; 81 | 82 | if (feedViewPost.reply) { 83 | const parent = feedViewPost.reply.parent; 84 | if (AppBskyFeedDefs.isPostView(parent)) { 85 | outputPost.replyParent = { 86 | uri: parent.uri, 87 | cid: parent.cid, 88 | authorDid: parent.author.did, 89 | }; 90 | } else if (AppBskyFeedDefs.isNotFoundPost(parent)) { 91 | outputPost.replyParent = { uri: parent.uri, cid: 'not_found_cid', authorDid: 'not_found_author' }; 92 | } else if (AppBskyFeedDefs.isBlockedPost(parent)) { 93 | outputPost.replyParent = { uri: parent.uri, cid: 'blocked_cid', authorDid: 'blocked_author' }; 94 | } 95 | } 96 | 97 | if (feedViewPost.reason && AppBskyFeedDefs.isReasonRepost(feedViewPost.reason)) { 98 | const reposter = feedViewPost.reason.by; 99 | outputPost.repostedBy = { 100 | did: reposter.did, 101 | handle: reposter.handle, 102 | displayName: reposter.displayName, 103 | avatar: reposter.avatar, 104 | viewer: reposter.viewer, 105 | labels: reposter.labels, 106 | }; 107 | outputPost.feedContext = `Reposted by @${reposter.handle}`; 108 | } 109 | 110 | if (feedViewPost.feedContext) { 111 | if (typeof outputPost.feedContext === 'string' && typeof feedViewPost.feedContext === 'string') { 112 | outputPost.feedContext = `${outputPost.feedContext}; ${feedViewPost.feedContext}`; 113 | } else if (!outputPost.feedContext) { 114 | outputPost.feedContext = feedViewPost.feedContext as string | { text: string; facets?: any[] }; 115 | } 116 | } 117 | return outputPost; 118 | } 119 | 120 | export async function _getAuthorFeedInternal( 121 | agent: AtpAgent, 122 | params: AppBskyFeedGetAuthorFeed.QueryParams, 123 | ): Promise { 124 | const response = await agent.getAuthorFeed(params); 125 | if (!response.success) { 126 | // After confirming !response.success, check for error properties before accessing 127 | if ('error' in response && 'message' in response) { 128 | throw new Error(`Failed to fetch author feed: ${response.error} - ${response.message}`); 129 | } else { 130 | // Fallback if structure is not as expected for a failure 131 | throw new Error('Failed to fetch author feed due to an unknown error structure.'); 132 | } 133 | } 134 | return response.data.feed.map((item) => mapFeedViewPostToOutputPost(item)); 135 | } 136 | 137 | export async function _getTimelineInternal( 138 | agent: AtpAgent, 139 | params: AppBskyFeedGetTimeline.QueryParams, 140 | ): Promise { 141 | const response = await agent.getTimeline(params); 142 | if (!response.success) { 143 | // After confirming !response.success, check for error properties before accessing 144 | if ('error' in response && 'message' in response) { 145 | throw new Error(`Failed to fetch timeline: ${response.error} - ${response.message}`); 146 | } else { 147 | // Fallback if structure is not as expected for a failure 148 | throw new Error('Failed to fetch timeline due to an unknown error structure.'); 149 | } 150 | } 151 | return response.data.feed.map((item) => mapFeedViewPostToOutputPost(item)); 152 | } 153 | 154 | export const feedProperties: INodeProperties[] = [ 155 | { 156 | displayName: 'Operation', 157 | name: 'operation', 158 | type: 'options', 159 | noDataExpression: true, 160 | displayOptions: { show: { resource: ['feed'] } }, 161 | options: [ 162 | { name: 'Get Author Feed', value: 'getAuthorFeed', description: 'Author feeds return posts by a single user', action: 'Retrieve feed with posts by a single user' }, 163 | { name: 'Timeline', value: 'getTimeline', description: 'The default chronological feed of posts from users the authenticated user follows', action: 'Retrieve user timeline' }, 164 | ], 165 | default: 'getAuthorFeed', 166 | }, 167 | { 168 | displayName: 'Actor', 169 | name: 'actor', 170 | type: 'string', 171 | default: '', 172 | required: true, 173 | description: "The DID of the author whose posts you'd like to fetch", 174 | hint: 'The user getProfile operation can be used to get the DID of a user', 175 | displayOptions: { show: { resource: ['feed'], operation: ['getAuthorFeed'] } }, 176 | }, 177 | { 178 | displayName: 'Limit', 179 | name: 'limit', 180 | type: 'number', 181 | typeOptions: { minValue: 1 }, 182 | default: 50, 183 | required: true, 184 | description: 'Max number of results to return', 185 | displayOptions: { show: { resource: ['feed'], operation: ['getAuthorFeed', 'getTimeline'] } }, 186 | }, 187 | ]; 188 | 189 | export async function getAuthorFeed( 190 | agent: AtpAgent, 191 | actor: string, 192 | limit: number, 193 | ): Promise { 194 | const outputPosts = await _getAuthorFeedInternal(agent, { actor: actor, limit: limit }); 195 | return outputPosts.map((post) => ({ json: post as IDataObject, pairedItem: { item: 0 } })); 196 | } 197 | 198 | export async function getTimeline( 199 | agent: AtpAgent, 200 | limit: number, 201 | ): Promise { 202 | const outputPosts = await _getTimelineInternal(agent, { limit: limit }); 203 | return outputPosts.map((post) => ({ json: post as IDataObject, pairedItem: { item: 0 } })); 204 | } 205 | -------------------------------------------------------------------------------- /nodes/Bluesky/V1/languages.ts: -------------------------------------------------------------------------------- 1 | import { INodePropertyOptions } from 'n8n-workflow'; 2 | 3 | const languageCodes: any = { 4 | af: 'Afrikaans', 5 | 'af-ZA': 'Afrikaans (South Africa)', 6 | ar: 'Arabic', 7 | 'ar-AE': 'Arabic (U.A.E.)', 8 | 'ar-BH': 'Arabic (Bahrain)', 9 | 'ar-DZ': 'Arabic (Algeria)', 10 | 'ar-EG': 'Arabic (Egypt)', 11 | 'ar-IQ': 'Arabic (Iraq)', 12 | 'ar-JO': 'Arabic (Jordan)', 13 | 'ar-KW': 'Arabic (Kuwait)', 14 | 'ar-LB': 'Arabic (Lebanon)', 15 | 'ar-LY': 'Arabic (Libya)', 16 | 'ar-MA': 'Arabic (Morocco)', 17 | 'ar-OM': 'Arabic (Oman)', 18 | 'ar-QA': 'Arabic (Qatar)', 19 | 'ar-SA': 'Arabic (Saudi Arabia)', 20 | 'ar-SY': 'Arabic (Syria)', 21 | 'ar-TN': 'Arabic (Tunisia)', 22 | 'ar-YE': 'Arabic (Yemen)', 23 | az: 'Azeri (Latin)', 24 | 'az-AZ': 'Azeri (Latin) (Azerbaijan)', 25 | 'az-AZ-Cyrillic': 'Azeri (Cyrillic) (Azerbaijan)', 26 | be: 'Belarusian', 27 | 'be-BY': 'Belarusian (Belarus)', 28 | bg: 'Bulgarian', 29 | 'bg-BG': 'Bulgarian (Bulgaria)', 30 | 'bs-BA': 'Bosnian (Bosnia and Herzegovina)', 31 | ca: 'Catalan', 32 | 'ca-ES': 'Catalan (Spain)', 33 | cs: 'Czech', 34 | 'cs-CZ': 'Czech (Czech Republic)', 35 | cy: 'Welsh', 36 | 'cy-GB': 'Welsh (United Kingdom)', 37 | da: 'Danish', 38 | 'da-DK': 'Danish (Denmark)', 39 | de: 'German', 40 | 'de-AT': 'German (Austria)', 41 | 'de-CH': 'German (Switzerland)', 42 | 'de-DE': 'German (Germany)', 43 | 'de-LI': 'German (Liechtenstein)', 44 | 'de-LU': 'German (Luxembourg)', 45 | dv: 'Divehi', 46 | 'dv-MV': 'Divehi (Maldives)', 47 | el: 'Greek', 48 | 'el-GR': 'Greek (Greece)', 49 | en: 'English', 50 | 'en-AU': 'English (Australia)', 51 | 'en-BZ': 'English (Belize)', 52 | 'en-CA': 'English (Canada)', 53 | 'en-CB': 'English (Caribbean)', 54 | 'en-GB': 'English (United Kingdom)', 55 | 'en-IE': 'English (Ireland)', 56 | 'en-JM': 'English (Jamaica)', 57 | 'en-NZ': 'English (New Zealand)', 58 | 'en-PH': 'English (Republic of the Philippines)', 59 | 'en-TT': 'English (Trinidad and Tobago)', 60 | 'en-US': 'English (United States)', 61 | 'en-ZA': 'English (South Africa)', 62 | 'en-ZW': 'English (Zimbabwe)', 63 | eo: 'Esperanto', 64 | es: 'Spanish', 65 | 'es-AR': 'Spanish (Argentina)', 66 | 'es-BO': 'Spanish (Bolivia)', 67 | 'es-CL': 'Spanish (Chile)', 68 | 'es-CO': 'Spanish (Colombia)', 69 | 'es-CR': 'Spanish (Costa Rica)', 70 | 'es-DO': 'Spanish (Dominican Republic)', 71 | 'es-EC': 'Spanish (Ecuador)', 72 | 'es-ES': 'Spanish (Spain)', 73 | 'es-GT': 'Spanish (Guatemala)', 74 | 'es-HN': 'Spanish (Honduras)', 75 | 'es-MX': 'Spanish (Mexico)', 76 | 'es-NI': 'Spanish (Nicaragua)', 77 | 'es-PA': 'Spanish (Panama)', 78 | 'es-PE': 'Spanish (Peru)', 79 | 'es-PR': 'Spanish (Puerto Rico)', 80 | 'es-PY': 'Spanish (Paraguay)', 81 | 'es-SV': 'Spanish (El Salvador)', 82 | 'es-UY': 'Spanish (Uruguay)', 83 | 'es-VE': 'Spanish (Venezuela)', 84 | et: 'Estonian', 85 | 'et-EE': 'Estonian (Estonia)', 86 | eu: 'Basque', 87 | 'eu-ES': 'Basque (Spain)', 88 | fa: 'Farsi', 89 | 'fa-IR': 'Farsi (Iran)', 90 | fi: 'Finnish', 91 | 'fi-FI': 'Finnish (Finland)', 92 | fo: 'Faroese', 93 | 'fo-FO': 'Faroese (Faroe Islands)', 94 | fr: 'French', 95 | 'fr-BE': 'French (Belgium)', 96 | 'fr-CA': 'French (Canada)', 97 | 'fr-CH': 'French (Switzerland)', 98 | 'fr-FR': 'French (France)', 99 | 'fr-LU': 'French (Luxembourg)', 100 | 'fr-MC': 'French (Principality of Monaco)', 101 | gl: 'Galician', 102 | 'gl-ES': 'Galician (Spain)', 103 | gu: 'Gujarati', 104 | 'gu-IN': 'Gujarati (India)', 105 | he: 'Hebrew', 106 | 'he-IL': 'Hebrew (Israel)', 107 | hi: 'Hindi', 108 | 'hi-IN': 'Hindi (India)', 109 | hr: 'Croatian', 110 | 'hr-BA': 'Croatian (Bosnia and Herzegovina)', 111 | 'hr-HR': 'Croatian (Croatia)', 112 | hu: 'Hungarian', 113 | 'hu-HU': 'Hungarian (Hungary)', 114 | hy: 'Armenian', 115 | 'hy-AM': 'Armenian (Armenia)', 116 | id: 'Indonesian', 117 | 'id-ID': 'Indonesian (Indonesia)', 118 | is: 'Icelandic', 119 | 'is-IS': 'Icelandic (Iceland)', 120 | it: 'Italian', 121 | 'it-CH': 'Italian (Switzerland)', 122 | 'it-IT': 'Italian (Italy)', 123 | ja: 'Japanese', 124 | 'ja-JP': 'Japanese (Japan)', 125 | ka: 'Georgian', 126 | 'ka-GE': 'Georgian (Georgia)', 127 | kk: 'Kazakh', 128 | 'kk-KZ': 'Kazakh (Kazakhstan)', 129 | kn: 'Kannada', 130 | 'kn-IN': 'Kannada (India)', 131 | ko: 'Korean', 132 | 'ko-KR': 'Korean (Korea)', 133 | kok: 'Konkani', 134 | 'kok-IN': 'Konkani (India)', 135 | ky: 'Kyrgyz', 136 | 'ky-KG': 'Kyrgyz (Kyrgyzstan)', 137 | lt: 'Lithuanian', 138 | 'lt-LT': 'Lithuanian (Lithuania)', 139 | lv: 'Latvian', 140 | 'lv-LV': 'Latvian (Latvia)', 141 | mi: 'Maori', 142 | 'mi-NZ': 'Maori (New Zealand)', 143 | mk: 'FYRO Macedonian', 144 | 'mk-MK': 'FYRO Macedonian (Former Yugoslav Republic of Macedonia)', 145 | mn: 'Mongolian', 146 | 'mn-MN': 'Mongolian (Mongolia)', 147 | mr: 'Marathi', 148 | 'mr-IN': 'Marathi (India)', 149 | ms: 'Malay', 150 | 'ms-BN': 'Malay (Brunei Darussalam)', 151 | 'ms-MY': 'Malay (Malaysia)', 152 | mt: 'Maltese', 153 | 'mt-MT': 'Maltese (Malta)', 154 | nb: 'Norwegian (Bokmål)', 155 | 'nb-NO': 'Norwegian (Bokmål) (Norway)', 156 | nl: 'Dutch', 157 | 'nl-BE': 'Dutch (Belgium)', 158 | 'nl-NL': 'Dutch (Netherlands)', 159 | 'nn-NO': 'Norwegian (Nynorsk) (Norway)', 160 | ns: 'Northern Sotho', 161 | 'ns-ZA': 'Northern Sotho (South Africa)', 162 | pa: 'Punjabi', 163 | 'pa-IN': 'Punjabi (India)', 164 | pl: 'Polish', 165 | 'pl-PL': 'Polish (Poland)', 166 | ps: 'Pashto', 167 | 'ps-AR': 'Pashto (Afghanistan)', 168 | pt: 'Portuguese', 169 | 'pt-BR': 'Portuguese (Brazil)', 170 | 'pt-PT': 'Portuguese (Portugal)', 171 | qu: 'Quechua', 172 | 'qu-BO': 'Quechua (Bolivia)', 173 | 'qu-EC': 'Quechua (Ecuador)', 174 | 'qu-PE': 'Quechua (Peru)', 175 | ro: 'Romanian', 176 | 'ro-RO': 'Romanian (Romania)', 177 | ru: 'Russian', 178 | 'ru-RU': 'Russian (Russia)', 179 | sa: 'Sanskrit', 180 | 'sa-IN': 'Sanskrit (India)', 181 | se: 'Sami (Northern)', 182 | 'se-FI': 'Sami (Northern) (Finland)', 183 | 'se-FI-Inari': 'Sami (Inari) (Finland)', 184 | 'se-NO': 'Sami (Northern) (Norway)', 185 | 'se-SE': 'Sami (Northern) (Sweden)', 186 | sk: 'Slovak', 187 | 'sk-SK': 'Slovak (Slovakia)', 188 | sl: 'Slovenian', 189 | 'sl-SI': 'Slovenian (Slovenia)', 190 | sq: 'Albanian', 191 | 'sq-AL': 'Albanian (Albania)', 192 | 'sr-BA': 'Serbian (Latin) (Bosnia and Herzegovina)', 193 | 'sr-SP': 'Serbian (Latin) (Serbia and Montenegro)', 194 | sv: 'Swedish', 195 | 'sv-FI': 'Swedish (Finland)', 196 | 'sv-SE': 'Swedish (Sweden)', 197 | sw: 'Swahili', 198 | 'sw-KE': 'Swahili (Kenya)', 199 | syr: 'Syriac', 200 | 'syr-SY': 'Syriac (Syria)', 201 | ta: 'Tamil', 202 | 'ta-IN': 'Tamil (India)', 203 | te: 'Telugu', 204 | 'te-IN': 'Telugu (India)', 205 | th: 'Thai', 206 | 'th-TH': 'Thai (Thailand)', 207 | tl: 'Tagalog', 208 | 'tl-PH': 'Tagalog (Philippines)', 209 | tn: 'Tswana', 210 | 'tn-ZA': 'Tswana (South Africa)', 211 | tr: 'Turkish', 212 | 'tr-TR': 'Turkish (Turkey)', 213 | tt: 'Tatar', 214 | 'tt-RU': 'Tatar (Russia)', 215 | ts: 'Tsonga', 216 | uk: 'Ukrainian', 217 | 'uk-UA': 'Ukrainian (Ukraine)', 218 | ur: 'Urdu', 219 | 'ur-PK': 'Urdu (Pakistan)', 220 | uz: 'Uzbek (Latin)', 221 | 'uz-UZ': 'Uzbek (Latin) (Uzbekistan)', 222 | 'uz-UZ-Cyrillic': 'Uzbek (Cyrillic) (Uzbekistan)', 223 | vi: 'Vietnamese', 224 | 'vi-VN': 'Vietnamese (Viet Nam)', 225 | xh: 'Xhosa', 226 | 'xh-ZA': 'Xhosa (South Africa)', 227 | zh: 'Chinese', 228 | 'zh-CN': 'Chinese (Simplified)', 229 | 'zh-HK': 'Chinese (Hong Kong)', 230 | 'zh-MO': 'Chinese (Macau)', 231 | 'zh-SG': 'Chinese (Singapore)', 232 | 'zh-TW': 'Chinese (Traditional)', 233 | zu: 'Zulu', 234 | 'zu-ZA': 'Zulu (South Africa)', 235 | }; 236 | 237 | export function getLanguageOptions(): INodePropertyOptions[] { 238 | let options = []; 239 | 240 | for (const code in languageCodes) { 241 | options.push({ 242 | name: languageCodes[code] as string, 243 | value: code as string, 244 | } as INodePropertyOptions); 245 | } 246 | 247 | return options; 248 | } 249 | -------------------------------------------------------------------------------- /nodes/Bluesky/V2/languages.ts: -------------------------------------------------------------------------------- 1 | import { INodePropertyOptions } from 'n8n-workflow'; 2 | 3 | const languageCodes: any = { 4 | af: 'Afrikaans', 5 | 'af-ZA': 'Afrikaans (South Africa)', 6 | ar: 'Arabic', 7 | 'ar-AE': 'Arabic (U.A.E.)', 8 | 'ar-BH': 'Arabic (Bahrain)', 9 | 'ar-DZ': 'Arabic (Algeria)', 10 | 'ar-EG': 'Arabic (Egypt)', 11 | 'ar-IQ': 'Arabic (Iraq)', 12 | 'ar-JO': 'Arabic (Jordan)', 13 | 'ar-KW': 'Arabic (Kuwait)', 14 | 'ar-LB': 'Arabic (Lebanon)', 15 | 'ar-LY': 'Arabic (Libya)', 16 | 'ar-MA': 'Arabic (Morocco)', 17 | 'ar-OM': 'Arabic (Oman)', 18 | 'ar-QA': 'Arabic (Qatar)', 19 | 'ar-SA': 'Arabic (Saudi Arabia)', 20 | 'ar-SY': 'Arabic (Syria)', 21 | 'ar-TN': 'Arabic (Tunisia)', 22 | 'ar-YE': 'Arabic (Yemen)', 23 | az: 'Azeri (Latin)', 24 | 'az-AZ': 'Azeri (Latin) (Azerbaijan)', 25 | 'az-AZ-Cyrillic': 'Azeri (Cyrillic) (Azerbaijan)', 26 | be: 'Belarusian', 27 | 'be-BY': 'Belarusian (Belarus)', 28 | bg: 'Bulgarian', 29 | 'bg-BG': 'Bulgarian (Bulgaria)', 30 | 'bs-BA': 'Bosnian (Bosnia and Herzegovina)', 31 | ca: 'Catalan', 32 | 'ca-ES': 'Catalan (Spain)', 33 | cs: 'Czech', 34 | 'cs-CZ': 'Czech (Czech Republic)', 35 | cy: 'Welsh', 36 | 'cy-GB': 'Welsh (United Kingdom)', 37 | da: 'Danish', 38 | 'da-DK': 'Danish (Denmark)', 39 | de: 'German', 40 | 'de-AT': 'German (Austria)', 41 | 'de-CH': 'German (Switzerland)', 42 | 'de-DE': 'German (Germany)', 43 | 'de-LI': 'German (Liechtenstein)', 44 | 'de-LU': 'German (Luxembourg)', 45 | dv: 'Divehi', 46 | 'dv-MV': 'Divehi (Maldives)', 47 | el: 'Greek', 48 | 'el-GR': 'Greek (Greece)', 49 | en: 'English', 50 | 'en-AU': 'English (Australia)', 51 | 'en-BZ': 'English (Belize)', 52 | 'en-CA': 'English (Canada)', 53 | 'en-CB': 'English (Caribbean)', 54 | 'en-GB': 'English (United Kingdom)', 55 | 'en-IE': 'English (Ireland)', 56 | 'en-JM': 'English (Jamaica)', 57 | 'en-NZ': 'English (New Zealand)', 58 | 'en-PH': 'English (Republic of the Philippines)', 59 | 'en-TT': 'English (Trinidad and Tobago)', 60 | 'en-US': 'English (United States)', 61 | 'en-ZA': 'English (South Africa)', 62 | 'en-ZW': 'English (Zimbabwe)', 63 | eo: 'Esperanto', 64 | es: 'Spanish', 65 | 'es-AR': 'Spanish (Argentina)', 66 | 'es-BO': 'Spanish (Bolivia)', 67 | 'es-CL': 'Spanish (Chile)', 68 | 'es-CO': 'Spanish (Colombia)', 69 | 'es-CR': 'Spanish (Costa Rica)', 70 | 'es-DO': 'Spanish (Dominican Republic)', 71 | 'es-EC': 'Spanish (Ecuador)', 72 | 'es-ES': 'Spanish (Spain)', 73 | 'es-GT': 'Spanish (Guatemala)', 74 | 'es-HN': 'Spanish (Honduras)', 75 | 'es-MX': 'Spanish (Mexico)', 76 | 'es-NI': 'Spanish (Nicaragua)', 77 | 'es-PA': 'Spanish (Panama)', 78 | 'es-PE': 'Spanish (Peru)', 79 | 'es-PR': 'Spanish (Puerto Rico)', 80 | 'es-PY': 'Spanish (Paraguay)', 81 | 'es-SV': 'Spanish (El Salvador)', 82 | 'es-UY': 'Spanish (Uruguay)', 83 | 'es-VE': 'Spanish (Venezuela)', 84 | et: 'Estonian', 85 | 'et-EE': 'Estonian (Estonia)', 86 | eu: 'Basque', 87 | 'eu-ES': 'Basque (Spain)', 88 | fa: 'Farsi', 89 | 'fa-IR': 'Farsi (Iran)', 90 | fi: 'Finnish', 91 | 'fi-FI': 'Finnish (Finland)', 92 | fo: 'Faroese', 93 | 'fo-FO': 'Faroese (Faroe Islands)', 94 | fr: 'French', 95 | 'fr-BE': 'French (Belgium)', 96 | 'fr-CA': 'French (Canada)', 97 | 'fr-CH': 'French (Switzerland)', 98 | 'fr-FR': 'French (France)', 99 | 'fr-LU': 'French (Luxembourg)', 100 | 'fr-MC': 'French (Principality of Monaco)', 101 | gl: 'Galician', 102 | 'gl-ES': 'Galician (Spain)', 103 | gu: 'Gujarati', 104 | 'gu-IN': 'Gujarati (India)', 105 | he: 'Hebrew', 106 | 'he-IL': 'Hebrew (Israel)', 107 | hi: 'Hindi', 108 | 'hi-IN': 'Hindi (India)', 109 | hr: 'Croatian', 110 | 'hr-BA': 'Croatian (Bosnia and Herzegovina)', 111 | 'hr-HR': 'Croatian (Croatia)', 112 | hu: 'Hungarian', 113 | 'hu-HU': 'Hungarian (Hungary)', 114 | hy: 'Armenian', 115 | 'hy-AM': 'Armenian (Armenia)', 116 | id: 'Indonesian', 117 | 'id-ID': 'Indonesian (Indonesia)', 118 | is: 'Icelandic', 119 | 'is-IS': 'Icelandic (Iceland)', 120 | it: 'Italian', 121 | 'it-CH': 'Italian (Switzerland)', 122 | 'it-IT': 'Italian (Italy)', 123 | ja: 'Japanese', 124 | 'ja-JP': 'Japanese (Japan)', 125 | ka: 'Georgian', 126 | 'ka-GE': 'Georgian (Georgia)', 127 | kk: 'Kazakh', 128 | 'kk-KZ': 'Kazakh (Kazakhstan)', 129 | kn: 'Kannada', 130 | 'kn-IN': 'Kannada (India)', 131 | ko: 'Korean', 132 | 'ko-KR': 'Korean (Korea)', 133 | kok: 'Konkani', 134 | 'kok-IN': 'Konkani (India)', 135 | ky: 'Kyrgyz', 136 | 'ky-KG': 'Kyrgyz (Kyrgyzstan)', 137 | lt: 'Lithuanian', 138 | 'lt-LT': 'Lithuanian (Lithuania)', 139 | lv: 'Latvian', 140 | 'lv-LV': 'Latvian (Latvia)', 141 | mi: 'Maori', 142 | 'mi-NZ': 'Maori (New Zealand)', 143 | mk: 'FYRO Macedonian', 144 | 'mk-MK': 'FYRO Macedonian (Former Yugoslav Republic of Macedonia)', 145 | mn: 'Mongolian', 146 | 'mn-MN': 'Mongolian (Mongolia)', 147 | mr: 'Marathi', 148 | 'mr-IN': 'Marathi (India)', 149 | ms: 'Malay', 150 | 'ms-BN': 'Malay (Brunei Darussalam)', 151 | 'ms-MY': 'Malay (Malaysia)', 152 | mt: 'Maltese', 153 | 'mt-MT': 'Maltese (Malta)', 154 | nb: 'Norwegian (Bokmål)', 155 | 'nb-NO': 'Norwegian (Bokmål) (Norway)', 156 | nl: 'Dutch', 157 | 'nl-BE': 'Dutch (Belgium)', 158 | 'nl-NL': 'Dutch (Netherlands)', 159 | 'nn-NO': 'Norwegian (Nynorsk) (Norway)', 160 | ns: 'Northern Sotho', 161 | 'ns-ZA': 'Northern Sotho (South Africa)', 162 | pa: 'Punjabi', 163 | 'pa-IN': 'Punjabi (India)', 164 | pl: 'Polish', 165 | 'pl-PL': 'Polish (Poland)', 166 | ps: 'Pashto', 167 | 'ps-AR': 'Pashto (Afghanistan)', 168 | pt: 'Portuguese', 169 | 'pt-BR': 'Portuguese (Brazil)', 170 | 'pt-PT': 'Portuguese (Portugal)', 171 | qu: 'Quechua', 172 | 'qu-BO': 'Quechua (Bolivia)', 173 | 'qu-EC': 'Quechua (Ecuador)', 174 | 'qu-PE': 'Quechua (Peru)', 175 | ro: 'Romanian', 176 | 'ro-RO': 'Romanian (Romania)', 177 | ru: 'Russian', 178 | 'ru-RU': 'Russian (Russia)', 179 | sa: 'Sanskrit', 180 | 'sa-IN': 'Sanskrit (India)', 181 | se: 'Sami (Northern)', 182 | 'se-FI': 'Sami (Northern) (Finland)', 183 | 'se-FI-Inari': 'Sami (Inari) (Finland)', 184 | 'se-NO': 'Sami (Northern) (Norway)', 185 | 'se-SE': 'Sami (Northern) (Sweden)', 186 | sk: 'Slovak', 187 | 'sk-SK': 'Slovak (Slovakia)', 188 | sl: 'Slovenian', 189 | 'sl-SI': 'Slovenian (Slovenia)', 190 | sq: 'Albanian', 191 | 'sq-AL': 'Albanian (Albania)', 192 | 'sr-BA': 'Serbian (Latin) (Bosnia and Herzegovina)', 193 | 'sr-SP': 'Serbian (Latin) (Serbia and Montenegro)', 194 | sv: 'Swedish', 195 | 'sv-FI': 'Swedish (Finland)', 196 | 'sv-SE': 'Swedish (Sweden)', 197 | sw: 'Swahili', 198 | 'sw-KE': 'Swahili (Kenya)', 199 | syr: 'Syriac', 200 | 'syr-SY': 'Syriac (Syria)', 201 | ta: 'Tamil', 202 | 'ta-IN': 'Tamil (India)', 203 | te: 'Telugu', 204 | 'te-IN': 'Telugu (India)', 205 | th: 'Thai', 206 | 'th-TH': 'Thai (Thailand)', 207 | tl: 'Tagalog', 208 | 'tl-PH': 'Tagalog (Philippines)', 209 | tn: 'Tswana', 210 | 'tn-ZA': 'Tswana (South Africa)', 211 | tr: 'Turkish', 212 | 'tr-TR': 'Turkish (Turkey)', 213 | tt: 'Tatar', 214 | 'tt-RU': 'Tatar (Russia)', 215 | ts: 'Tsonga', 216 | uk: 'Ukrainian', 217 | 'uk-UA': 'Ukrainian (Ukraine)', 218 | ur: 'Urdu', 219 | 'ur-PK': 'Urdu (Pakistan)', 220 | uz: 'Uzbek (Latin)', 221 | 'uz-UZ': 'Uzbek (Latin) (Uzbekistan)', 222 | 'uz-UZ-Cyrillic': 'Uzbek (Cyrillic) (Uzbekistan)', 223 | vi: 'Vietnamese', 224 | 'vi-VN': 'Vietnamese (Viet Nam)', 225 | xh: 'Xhosa', 226 | 'xh-ZA': 'Xhosa (South Africa)', 227 | zh: 'Chinese', 228 | 'zh-CN': 'Chinese (Simplified)', 229 | 'zh-HK': 'Chinese (Hong Kong)', 230 | 'zh-MO': 'Chinese (Macau)', 231 | 'zh-SG': 'Chinese (Singapore)', 232 | 'zh-TW': 'Chinese (Traditional)', 233 | zu: 'Zulu', 234 | 'zu-ZA': 'Zulu (South Africa)', 235 | }; 236 | 237 | export function getLanguageOptions(): INodePropertyOptions[] { 238 | let options = []; 239 | 240 | for (const code in languageCodes) { 241 | options.push({ 242 | name: languageCodes[code] as string, 243 | value: code as string, 244 | } as INodePropertyOptions); 245 | } 246 | 247 | return options; 248 | } 249 | -------------------------------------------------------------------------------- /nodes/Bluesky/V2/__tests__/feedOperations.test.ts: -------------------------------------------------------------------------------- 1 | import { AtpAgent, AppBskyFeedGetAuthorFeed,AppBskyFeedGetTimeline,AppBskyFeedDefs,AppBskyActorDefs } from '@atproto/api'; 2 | import { _getAuthorFeedInternal, _getTimelineInternal } from '../feedOperations'; 3 | 4 | // Mock the entire @atproto/api module 5 | jest.mock('@atproto/api'); 6 | 7 | // Restore actual implementations for type guards 8 | const { AppBskyFeedDefs: ActualAppBskyFeedDefs } = jest.requireActual('@atproto/api'); 9 | 10 | // After jest.mock, all exports are jest.fn(). We need to make them use the actual implementation. 11 | // Cast to JestMockedFunction for type safety with mockImplementation. 12 | (AppBskyFeedDefs.isPostView as jest.MockedFunction) 13 | .mockImplementation(ActualAppBskyFeedDefs.isPostView); 14 | (AppBskyFeedDefs.isNotFoundPost as jest.MockedFunction) 15 | .mockImplementation(ActualAppBskyFeedDefs.isNotFoundPost); 16 | (AppBskyFeedDefs.isBlockedPost as jest.MockedFunction) 17 | .mockImplementation(ActualAppBskyFeedDefs.isBlockedPost); 18 | (AppBskyFeedDefs.isReasonRepost as jest.MockedFunction) 19 | .mockImplementation(ActualAppBskyFeedDefs.isReasonRepost); 20 | 21 | 22 | const mockGetAuthorFeed = jest.fn(); 23 | const mockGetTimeline = jest.fn(); 24 | 25 | const MockedAtpAgent = AtpAgent as jest.MockedClass; 26 | 27 | MockedAtpAgent.mockImplementation(() => { 28 | return { 29 | getAuthorFeed: mockGetAuthorFeed, 30 | getTimeline: mockGetTimeline, 31 | } as any; 32 | }); 33 | 34 | 35 | describe('FeedOperations', () => { 36 | let agent: jest.Mocked; 37 | 38 | beforeEach(() => { 39 | agent = new MockedAtpAgent({ service: 'https://bsky.social' }) as jest.Mocked; 40 | (agent.getAuthorFeed as jest.Mock).mockImplementation(mockGetAuthorFeed); 41 | (agent.getTimeline as jest.Mock).mockImplementation(mockGetTimeline); 42 | 43 | mockGetAuthorFeed.mockClear(); 44 | mockGetTimeline.mockClear(); 45 | 46 | MockedAtpAgent.mockClear(); 47 | MockedAtpAgent.mockImplementation(() => { 48 | return { 49 | getAuthorFeed: mockGetAuthorFeed, 50 | getTimeline: mockGetTimeline, 51 | } as any; 52 | }); 53 | }); 54 | 55 | const sampleAuthor = (did: string, handleSuffix: string = 'test'): AppBskyActorDefs.ProfileViewBasic => ({ 56 | $type: 'app.bsky.actor.defs#profileViewBasic', 57 | did: did, 58 | handle: `${handleSuffix}.bsky.social`, 59 | displayName: `User ${did.slice(-4)}`, 60 | avatar: 'https://example.com/avatar.jpg', 61 | viewer: {}, 62 | labels: [], 63 | }); 64 | 65 | const samplePostRecord = (text: string): { $type: 'app.bsky.feed.post', text: string, createdAt: string } => ({ 66 | $type: 'app.bsky.feed.post', 67 | text: text, 68 | createdAt: new Date().toISOString(), 69 | }); 70 | 71 | const samplePostView = (id: string, text: string, actorDid: string = 'did:plc:testactor'): AppBskyFeedDefs.PostView => ({ 72 | $type: 'app.bsky.feed.defs#postView', 73 | uri: `at://${actorDid}/app.bsky.feed.post/${id}`, 74 | cid: `cid${id}`, 75 | author: sampleAuthor(actorDid, id), 76 | record: samplePostRecord(text) as unknown as AppBskyFeedDefs.PostView['record'], 77 | indexedAt: new Date().toISOString(), 78 | viewer: {}, 79 | labels: [], 80 | likeCount: 0, 81 | repostCount: 0, 82 | replyCount: 0, 83 | }); 84 | 85 | const createReasonRepost = (reposterDid: string, indexedAt: string): AppBskyFeedDefs.ReasonRepost => ({ 86 | $type: 'app.bsky.feed.defs#reasonRepost', 87 | by: sampleAuthor(reposterDid, 'reposter'), 88 | indexedAt: indexedAt, 89 | }); 90 | 91 | 92 | describe('_getAuthorFeedInternal', () => { 93 | const actor = 'did:plc:testactor'; 94 | const limit = 10; 95 | 96 | beforeEach(() => { 97 | mockGetAuthorFeed.mockClear(); 98 | }); 99 | 100 | it('should return mapped feed items for a successful response', async () => { 101 | const mockFeedItems: AppBskyFeedDefs.FeedViewPost[] = [ 102 | { post: samplePostView('post1', 'Hello World', actor) }, 103 | { post: samplePostView('post2', 'Another Post', actor) }, 104 | ]; 105 | const mockApiResponse: AppBskyFeedGetAuthorFeed.Response = { 106 | success: true, data: { feed: mockFeedItems, cursor: 'cursor123' }, headers: {}, 107 | }; 108 | mockGetAuthorFeed.mockResolvedValue(mockApiResponse); 109 | const result = await _getAuthorFeedInternal(agent, { actor, limit }); 110 | expect(mockGetAuthorFeed).toHaveBeenCalledWith({ actor, limit }); 111 | expect(result).toHaveLength(2); 112 | }); 113 | 114 | it('should return an empty array for an empty feed response', async () => { 115 | const mockApiResponse: AppBskyFeedGetAuthorFeed.Response = { 116 | success: true, data: { feed: [] }, headers: {}, 117 | }; 118 | mockGetAuthorFeed.mockResolvedValue(mockApiResponse); 119 | const result = await _getAuthorFeedInternal(agent, { actor, limit }); 120 | expect(result).toEqual([]); 121 | }); 122 | 123 | it('should correctly map all potential fields in a feed item for getAuthorFeed', async () => { 124 | const detailedPost = samplePostView('postDetailed', 'Detailed post content', actor); 125 | const parentPostSimple = samplePostView('parentPost', 'Parent content', 'did:plc:parentactor'); 126 | const rootPostSimple = samplePostView('rootPost', 'Root content', 'did:plc:rootactor'); 127 | 128 | const replyRef: AppBskyFeedDefs.ReplyRef = { 129 | root: rootPostSimple as any as AppBskyFeedDefs.ReplyRef['root'], 130 | parent: parentPostSimple as any as AppBskyFeedDefs.ReplyRef['parent'], 131 | }; 132 | const reasonRepost = createReasonRepost('did:plc:reposter', new Date().toISOString()); 133 | 134 | const mockFeedItem: AppBskyFeedDefs.FeedViewPost = { 135 | post: detailedPost, 136 | reply: replyRef, 137 | reason: reasonRepost as any as AppBskyFeedDefs.FeedViewPost['reason'], 138 | }; 139 | const mockApiResponse: AppBskyFeedGetAuthorFeed.Response = { success: true, data: { feed: [mockFeedItem] }, headers: {} }; 140 | mockGetAuthorFeed.mockResolvedValue(mockApiResponse); 141 | 142 | const result = await _getAuthorFeedInternal(agent, { actor, limit }); 143 | expect(result).toHaveLength(1); 144 | const output = result[0]; 145 | 146 | expect(output.uri).toBe(detailedPost.uri); 147 | expect(output.record.text).toBe('Detailed post content'); 148 | expect(output.replyParent?.uri).toBe(parentPostSimple.uri); 149 | expect(output.replyParent?.cid).toBe(parentPostSimple.cid); 150 | expect(output.repostedBy?.did).toBe(reasonRepost.by.did); 151 | }); 152 | 153 | it('should throw an error if API call fails for getAuthorFeed', async () => { 154 | mockGetAuthorFeed.mockResolvedValue({ success: false, error: 'Unauthorized', message: 'Auth required' }); 155 | await expect(_getAuthorFeedInternal(agent, { actor, limit })).rejects.toThrow('Failed to fetch author feed: Unauthorized - Auth required'); 156 | }); 157 | }); 158 | 159 | describe('_getTimelineInternal', () => { 160 | const algorithm = 'reverse-chronological'; 161 | const limit = 15; 162 | 163 | beforeEach(() => { 164 | mockGetTimeline.mockClear(); 165 | }); 166 | 167 | 168 | it('should return mapped feed items for a successful timeline response', async () => { 169 | const mockFeedItems: AppBskyFeedDefs.FeedViewPost[] = [ 170 | { post: samplePostView('timelinePost1', 'Timeline Hello') }, 171 | { post: samplePostView('timelinePost2', 'Timeline World') }, 172 | ]; 173 | const mockApiResponse: AppBskyFeedGetTimeline.Response = { 174 | success: true, data: { feed: mockFeedItems, cursor: 'timelineCursor456' }, headers: {}, 175 | }; 176 | mockGetTimeline.mockResolvedValue(mockApiResponse); 177 | const result = await _getTimelineInternal(agent, { algorithm, limit }); 178 | expect(mockGetTimeline).toHaveBeenCalledWith({ algorithm, limit }); 179 | expect(result).toHaveLength(2); 180 | }); 181 | 182 | it('should return an empty array for an empty timeline response', async () => { 183 | const mockApiResponse: AppBskyFeedGetTimeline.Response = { 184 | success: true, data: { feed: [] }, headers: {}, 185 | }; 186 | mockGetTimeline.mockResolvedValue(mockApiResponse); 187 | const result = await _getTimelineInternal(agent, { limit }); 188 | expect(result).toEqual([]); 189 | }); 190 | 191 | it('should correctly map all potential fields in a timeline item', async () => { 192 | const actorDidForTimeline = 'did:plc:timelineposter'; 193 | const detailedPost = samplePostView('postDetailedTimeline', 'Detailed timeline post', actorDidForTimeline); 194 | const parentPostSimple = samplePostView('parentPostTimeline', 'Parent content', 'did:plc:parentactorTimeline'); 195 | const rootPostSimple = samplePostView('rootPostTimeline', 'Root content', 'did:plc:rootactorTimeline'); 196 | 197 | const replyRef: AppBskyFeedDefs.ReplyRef = { 198 | root: rootPostSimple as any as AppBskyFeedDefs.ReplyRef['root'], 199 | parent: parentPostSimple as any as AppBskyFeedDefs.ReplyRef['parent'], 200 | }; 201 | const reasonRepost = createReasonRepost('did:plc:timelinereposter', new Date().toISOString()); 202 | 203 | const mockFeedItem: AppBskyFeedDefs.FeedViewPost = { 204 | post: detailedPost, 205 | reply: replyRef, 206 | reason: reasonRepost as any as AppBskyFeedDefs.FeedViewPost['reason'], 207 | }; 208 | const mockApiResponse: AppBskyFeedGetTimeline.Response = { success: true, data: { feed: [mockFeedItem] }, headers: {} }; 209 | mockGetTimeline.mockResolvedValue(mockApiResponse); 210 | 211 | const result = await _getTimelineInternal(agent, { limit }); 212 | expect(result).toHaveLength(1); 213 | const output = result[0]; 214 | expect(output.uri).toBe(detailedPost.uri); 215 | expect(output.record.text).toBe('Detailed timeline post'); 216 | expect(output.replyParent?.uri).toBe(parentPostSimple.uri); 217 | expect(output.repostedBy?.did).toBe(reasonRepost.by.did); 218 | }); 219 | 220 | it('should throw an error if API call fails for getTimeline', async () => { 221 | mockGetTimeline.mockResolvedValue({ success: false, error: 'Server Error', message: 'Internal issue' }); 222 | await expect(_getTimelineInternal(agent, { limit })).rejects.toThrow('Failed to fetch timeline: Server Error - Internal issue'); 223 | }); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /nodes/Bluesky/V2/BlueskyV2.node.ts: -------------------------------------------------------------------------------- 1 | import { 2 | INodeExecutionData, 3 | IExecuteFunctions, 4 | INodeType, 5 | INodeTypeDescription, 6 | INodeTypeBaseDescription, JsonObject, NodeApiError, 7 | LoggerProxy as Logger, 8 | } from 'n8n-workflow'; 9 | 10 | import { NodeConnectionType } from 'n8n-workflow'; 11 | 12 | import { AtpAgent, CredentialSession } from '@atproto/api'; 13 | 14 | import { resourcesProperty } from './resources'; 15 | 16 | // Operations 17 | import { 18 | deleteLikeOperation, 19 | deletePostOperation, 20 | likeOperation, 21 | postOperation, 22 | deleteRepostOperation, 23 | postProperties, 24 | repostOperation, 25 | } from './postOperations'; 26 | import { 27 | getProfileOperation, 28 | muteOperation, 29 | userProperties, 30 | unmuteOperation, 31 | blockOperation, 32 | unblockOperation, 33 | } from './userOperations'; 34 | import { getAuthorFeed, feedProperties, getTimeline } from './feedOperations'; 35 | 36 | export class BlueskyV2 implements INodeType { 37 | description: INodeTypeDescription; 38 | 39 | constructor(baseDescription: INodeTypeBaseDescription) { 40 | this.description = { 41 | ...baseDescription, 42 | version: 2, 43 | defaults: { 44 | name: 'Bluesky', 45 | }, 46 | inputs: [NodeConnectionType.Main], 47 | outputs: [NodeConnectionType.Main], 48 | credentials: [ 49 | { 50 | name: 'blueskyApi', 51 | required: true, 52 | }, 53 | ], 54 | properties: [resourcesProperty, ...userProperties, ...postProperties, ...feedProperties], 55 | }; 56 | } 57 | 58 | async execute(this: IExecuteFunctions): Promise { 59 | const items = this.getInputData(); 60 | const returnData: INodeExecutionData[] = []; 61 | 62 | // Load credentials 63 | const credentials = (await this.getCredentials('blueskyApi')) as { 64 | identifier: string; 65 | appPassword: string; 66 | serviceUrl: string; 67 | }; 68 | 69 | const operation = this.getNodeParameter('operation', 0) as string; 70 | const serviceUrl = new URL(credentials.serviceUrl.replace(/\/+$/, '')); // Ensure no trailing slash 71 | 72 | const node = this.getNode(); 73 | const nodeMeta = { nodeName: node.name, nodeType: node.type, nodeId: node.id, operation }; 74 | 75 | Logger.info('Initializing Bluesky session', { ...nodeMeta, serviceOrigin: serviceUrl.origin }); 76 | 77 | const session = new CredentialSession(serviceUrl); 78 | const agent = new AtpAgent(session); 79 | await agent.login({ 80 | identifier: credentials.identifier, 81 | password: credentials.appPassword, 82 | }); 83 | 84 | Logger.info('Authenticated with Bluesky', { ...nodeMeta }); 85 | 86 | for (let i = 0; i < items.length; i++) { 87 | const itemMeta = { ...nodeMeta, itemIndex: i }; 88 | 89 | try { 90 | switch (operation) { 91 | /** 92 | * Post operations 93 | */ 94 | 95 | case 'post': { 96 | const postText = this.getNodeParameter('postText', i) as string; 97 | const langs = this.getNodeParameter('langs', i) as string[]; 98 | 99 | /** 100 | * Handle website card details if provided 101 | */ 102 | let websiteCardRaw = this.getNodeParameter('websiteCard', i, {}); 103 | let websiteCard: any = websiteCardRaw; 104 | if ( 105 | websiteCardRaw && 106 | typeof websiteCardRaw === 'object' && 107 | 'details' in websiteCardRaw 108 | ) { 109 | if (Array.isArray((websiteCardRaw as any).details) && (websiteCardRaw as any).details.length > 0) { 110 | websiteCard = (websiteCardRaw as any).details[0]; 111 | } else if (typeof (websiteCardRaw as any).details === 'object') { 112 | websiteCard = (websiteCardRaw as any).details; 113 | } 114 | } 115 | 116 | // Load thumbnail binary only when explicitly provided and OG tags are not fetched 117 | let thumbnailBinary: Buffer | undefined; 118 | if (websiteCard.thumbnailBinaryProperty && websiteCard.fetchOpenGraphTags === false) { 119 | thumbnailBinary = await this.helpers.getBinaryDataBuffer(i, websiteCard.thumbnailBinaryProperty); 120 | } 121 | 122 | // Validate URL; ignore invalid/relative URLs 123 | let websiteCardUri = websiteCard.uri; 124 | try { 125 | if (websiteCardUri) new URL(websiteCardUri); 126 | } catch { 127 | websiteCardUri = undefined; 128 | } 129 | 130 | // Construct websiteCardPayload analogously to imagePayload 131 | let websiteCardPayload: 132 | | { 133 | uri: string | undefined; 134 | title: string | undefined; 135 | description: string | undefined; 136 | thumbnailBinary: Buffer | undefined; 137 | fetchOpenGraphTags: boolean | undefined; 138 | } | undefined; 139 | 140 | if ( 141 | websiteCardUri || 142 | websiteCard.title || 143 | websiteCard.description || 144 | thumbnailBinary || 145 | websiteCard.fetchOpenGraphTags !== undefined 146 | ) { 147 | websiteCardPayload = { 148 | uri: websiteCardUri ?? undefined, 149 | title: websiteCard.title ?? undefined, 150 | description: websiteCard.description ?? undefined, 151 | thumbnailBinary: thumbnailBinary ?? undefined, 152 | fetchOpenGraphTags: websiteCard.fetchOpenGraphTags ?? undefined, 153 | }; 154 | } 155 | 156 | // 🔁 was: console.debug(...) 157 | Logger.debug('Prepared websiteCard payload', { 158 | ...itemMeta, 159 | hasWebsiteCard: Boolean(websiteCardPayload), 160 | hasThumbnailBinary: Boolean(thumbnailBinary), 161 | websiteCardUri: websiteCardPayload?.uri, 162 | }); 163 | 164 | // Handle optional image parameter (supports both flattened and .details shapes) 165 | const imageParamRaw = this.getNodeParameter('image', i, {}) as any; 166 | const imageParam = (imageParamRaw && imageParamRaw.details) ? imageParamRaw.details : imageParamRaw; 167 | let imagePayload: { 168 | alt?: string; 169 | mimeType?: string; 170 | binary?: Buffer; 171 | width?: number; 172 | height?: number; 173 | } | undefined; 174 | 175 | if (imageParam && (imageParam.binary || imageParam.alt || imageParam.mimeType)) { 176 | let imageBuffer: Buffer | undefined; 177 | if (imageParam.binary) { 178 | imageBuffer = await this.helpers.getBinaryDataBuffer(i, imageParam.binary as string); 179 | } 180 | imagePayload = { 181 | alt: imageParam.alt, 182 | mimeType: imageParam.mimeType, 183 | binary: imageBuffer, 184 | width: imageParam.width, 185 | height: imageParam.height, 186 | }; 187 | } 188 | 189 | Logger.info('Posting to Bluesky', { 190 | ...itemMeta, 191 | hasImage: Boolean(imagePayload?.binary), 192 | hasAltText: Boolean(imagePayload?.alt), 193 | langs, 194 | postTextPreview: postText?.slice(0, 60), 195 | }); 196 | 197 | const postData = await postOperation( 198 | agent, 199 | postText, 200 | langs, 201 | websiteCardPayload, 202 | imagePayload 203 | ); 204 | 205 | Logger.debug('Post operation completed', { ...itemMeta, itemsReturned: postData.length }); 206 | 207 | returnData.push(...postData); 208 | break; 209 | } 210 | 211 | case 'deletePost': { 212 | const uriDeletePost = this.getNodeParameter('uri', i) as string; 213 | Logger.info('Deleting post', { ...itemMeta, uri: uriDeletePost }); 214 | const deletePostData = await deletePostOperation(agent, uriDeletePost); 215 | returnData.push(...deletePostData); 216 | break; 217 | } 218 | 219 | case 'like': { 220 | const uriLike = this.getNodeParameter('uri', i) as string; 221 | const cidLike = this.getNodeParameter('cid', i) as string; 222 | Logger.debug('Liking post', { ...itemMeta, uri: uriLike, cidPresent: Boolean(cidLike) }); 223 | const likeData = await likeOperation(agent, uriLike, cidLike); 224 | returnData.push(...likeData); 225 | break; 226 | } 227 | 228 | case 'deleteLike': { 229 | const uriDeleteLike = this.getNodeParameter('uri', i) as string; 230 | Logger.debug('Deleting like', { ...itemMeta, uri: uriDeleteLike }); 231 | const deleteLikeData = await deleteLikeOperation(agent, uriDeleteLike); 232 | returnData.push(...deleteLikeData); 233 | break; 234 | } 235 | 236 | case 'repost': { 237 | const uriRepost = this.getNodeParameter('uri', i) as string; 238 | const cidRepost = this.getNodeParameter('cid', i) as string; 239 | Logger.debug('Reposting', { ...itemMeta, uri: uriRepost, cidPresent: Boolean(cidRepost) }); 240 | const repostData = await repostOperation(agent, uriRepost, cidRepost); 241 | returnData.push(...repostData); 242 | break; 243 | } 244 | 245 | case 'deleteRepost': { 246 | const uriDeleteRepost = this.getNodeParameter('uri', i) as string; 247 | Logger.debug('Deleting repost', { ...itemMeta, uri: uriDeleteRepost }); 248 | const deleteRepostData = await deleteRepostOperation(agent, uriDeleteRepost); 249 | returnData.push(...deleteRepostData); 250 | break; 251 | } 252 | 253 | /** 254 | * Feed operations 255 | */ 256 | 257 | case 'getAuthorFeed': { 258 | const authorFeedActor = this.getNodeParameter('actor', i) as string; 259 | const authorFeedPostLimit = this.getNodeParameter('limit', i) as number; 260 | Logger.debug('Fetching author feed', { 261 | ...itemMeta, 262 | actor: authorFeedActor, 263 | limit: authorFeedPostLimit, 264 | }); 265 | const feedData = await getAuthorFeed(agent, authorFeedActor, authorFeedPostLimit); 266 | returnData.push(...feedData); 267 | break; 268 | } 269 | 270 | case 'getTimeline': { 271 | const timelinePostLimit = this.getNodeParameter('limit', i) as number; 272 | Logger.debug('Fetching timeline', { ...itemMeta, limit: timelinePostLimit }); 273 | const timelineData = await getTimeline(agent, timelinePostLimit); 274 | returnData.push(...timelineData); 275 | break; 276 | } 277 | 278 | /** 279 | * User operations 280 | */ 281 | 282 | case 'getProfile': { 283 | const actor = this.getNodeParameter('actor', i) as string; 284 | Logger.debug('Getting profile', { ...itemMeta, actor }); 285 | const profileData = await getProfileOperation(agent, actor); 286 | returnData.push(...profileData); 287 | break; 288 | } 289 | 290 | case 'mute': { 291 | const didMute = this.getNodeParameter('did', i) as string; 292 | Logger.info('Muting user', { ...itemMeta, did: didMute }); 293 | const muteData = await muteOperation(agent, didMute); 294 | returnData.push(...muteData); 295 | break; 296 | } 297 | 298 | case 'unmute': { 299 | const didUnmute = this.getNodeParameter('did', i) as string; 300 | Logger.info('Unmuting user', { ...itemMeta, did: didUnmute }); 301 | const unmuteData = await unmuteOperation(agent, didUnmute); 302 | returnData.push(...unmuteData); 303 | break; 304 | } 305 | 306 | case 'block': { 307 | const didBlock = this.getNodeParameter('did', i) as string; 308 | Logger.warn('Blocking user', { ...itemMeta, did: didBlock }); 309 | const blockData = await blockOperation(agent, didBlock); 310 | returnData.push(...blockData); 311 | break; 312 | } 313 | 314 | case 'unblock': { 315 | const uriUnblock = this.getNodeParameter('uri', i) as string; 316 | Logger.warn('Unblocking user', { ...itemMeta, uri: uriUnblock }); 317 | const unblockData = await unblockOperation(agent, uriUnblock); 318 | returnData.push(...unblockData); 319 | break; 320 | } 321 | 322 | default: 323 | Logger.warn('Unknown operation requested', itemMeta); 324 | } 325 | } catch (error) { 326 | // log the error with context 327 | Logger.error('Operation failed', { ...itemMeta, error: (error as Error)?.message }); 328 | 329 | if (this.continueOnFail()) { 330 | returnData.push({ 331 | json: { error: (error as Error).message }, 332 | pairedItem: [{ item: i }], 333 | }); 334 | continue; 335 | } 336 | throw new NodeApiError(this.getNode(), error as JsonObject); 337 | } 338 | } 339 | 340 | Logger.info('Node execution finished', { ...nodeMeta, itemsProcessed: items.length, itemsReturned: returnData.length }); 341 | 342 | return [returnData]; 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /nodes/Bluesky/V2/postOperations.ts: -------------------------------------------------------------------------------- 1 | import { AtpAgent, RichText } from '@atproto/api'; 2 | import sharp from 'sharp'; 3 | import { 4 | INodeExecutionData, 5 | INodeProperties, 6 | LoggerProxy as Logger, 7 | } from 'n8n-workflow'; 8 | import { getLanguageOptions } from './languages'; 9 | import ogs from 'open-graph-scraper'; 10 | 11 | const IMAGE_SIZE_LIMIT = 976.56 * 1024; // 976.56KB in bytes 12 | const MAX_IMAGE_WIDTH = 1000; 13 | 14 | export const postProperties: INodeProperties[] = [ 15 | { 16 | displayName: 'Operation', 17 | default: 'post', 18 | displayOptions: { 19 | show: { 20 | resource: ['post'], 21 | }, 22 | }, 23 | name: 'operation', 24 | noDataExpression: true, 25 | options: [ 26 | { 27 | name: 'Create a Post', 28 | value: 'post', 29 | action: 'Create a post', 30 | }, 31 | { 32 | name: 'Delete a Post', 33 | value: 'deletePost', 34 | action: 'Delete a post', 35 | }, 36 | { 37 | name: 'Delete Repost', 38 | value: 'deleteRepost', 39 | action: 'Delete a repost', 40 | }, 41 | { 42 | name: 'Like a Post', 43 | value: 'like', 44 | action: 'Like a post', 45 | }, 46 | { 47 | name: 'Repost a Post', 48 | value: 'repost', 49 | action: 'Repost a post', 50 | }, 51 | { 52 | name: 'Unline a Post', 53 | value: 'deleteLike', 54 | action: 'Unlike a post', 55 | }, 56 | ], 57 | type: 'options', 58 | }, 59 | { 60 | displayName: 'Post Text', 61 | name: 'postText', 62 | type: 'string', 63 | default: '', 64 | displayOptions: { 65 | show: { 66 | resource: ['post'], 67 | operation: ['post'], 68 | }, 69 | }, 70 | }, 71 | { 72 | displayName: 'Language Names or IDs', 73 | name: 'langs', 74 | type: 'multiOptions', 75 | description: 76 | 'Choose from the list of supported languages. Choose from the list, or specify IDs using an expression.', 77 | options: getLanguageOptions(), 78 | default: ['en'], 79 | displayOptions: { 80 | show: { 81 | resource: ['post'], 82 | operation: ['post'], 83 | }, 84 | }, 85 | }, 86 | { 87 | displayName: 'Uri', 88 | name: 'uri', 89 | type: 'string', 90 | description: 'The URI of the post', 91 | default: '', 92 | required: true, 93 | displayOptions: { 94 | show: { 95 | resource: ['post'], 96 | operation: ['deletePost', 'like', 'deleteLike', 'repost'], 97 | }, 98 | }, 99 | }, 100 | { 101 | displayName: 'Cid', 102 | name: 'cid', 103 | type: 'string', 104 | description: 'The CID of the post', 105 | default: '', 106 | required: true, 107 | displayOptions: { 108 | show: { 109 | resource: ['post'], 110 | operation: ['like', 'repost'], 111 | }, 112 | }, 113 | }, 114 | { 115 | displayName: 'Website Card', 116 | name: 'websiteCard', 117 | type: 'fixedCollection', 118 | default: {}, 119 | placeholder: 'Add Website Card', 120 | options: [ 121 | { 122 | displayName: 'Details', 123 | name: 'details', 124 | values: [ 125 | { 126 | displayName: 'URI', 127 | name: 'uri', 128 | type: 'string', 129 | default: '', 130 | required: true, 131 | }, 132 | { 133 | displayName: 'Fetch Open Graph Tags', 134 | name: 'fetchOpenGraphTags', 135 | type: 'boolean', 136 | description: 'Whether to fetch open graph tags from the website', 137 | hint: 'If enabled, the node will fetch the open graph tags from the website URL provided and use them to create a website card', 138 | default: false, 139 | }, 140 | { 141 | displayName: 'Title', 142 | name: 'title', 143 | type: 'string', 144 | default: '', 145 | required: true, 146 | displayOptions: { 147 | show: { 148 | fetchOpenGraphTags: [false], 149 | }, 150 | } 151 | }, 152 | { 153 | displayName: 'Description', 154 | name: 'description', 155 | type: 'string', 156 | default: '', 157 | displayOptions: { 158 | show: { 159 | fetchOpenGraphTags: [false], 160 | }, 161 | } 162 | }, 163 | { 164 | displayName: 'Binary Property', 165 | name: 'thumbnailBinaryProperty', 166 | type: 'string', 167 | default: '', 168 | description: 'Name of the binary property containing the thumbnail image', 169 | displayOptions: { 170 | show: { 171 | fetchOpenGraphTags: [false], 172 | }, 173 | } 174 | }, 175 | ], 176 | }, 177 | ], 178 | displayOptions: { 179 | show: { 180 | resource: ['post'], 181 | operation: ['post'], 182 | }, 183 | }, 184 | }, 185 | { 186 | displayName: 'Image', 187 | name: 'image', 188 | type: 'fixedCollection', 189 | default: {}, 190 | placeholder: 'Add Image', 191 | options: [ 192 | { 193 | displayName: 'Details', 194 | name: 'details', 195 | values: [ 196 | { 197 | displayName: 'ALT', 198 | name: 'alt', 199 | type: 'string', 200 | default: '', 201 | required: true, 202 | }, 203 | { 204 | displayName: 'mimeType', 205 | name: 'mimeType', 206 | type: 'string', 207 | default: '', 208 | required: true, 209 | }, 210 | { 211 | displayName: 'Width', 212 | name: 'width', 213 | type: 'number', 214 | default: 400, 215 | }, 216 | { 217 | displayName: 'Height', 218 | name: 'height', 219 | type: 'number', 220 | default: 300, 221 | }, 222 | { 223 | displayName: 'Binary Property', 224 | name: 'binary', 225 | type: 'string', 226 | default: 'data', 227 | description: 'Name of the binary property containing the image', 228 | }, 229 | ], 230 | }, 231 | ], 232 | displayOptions: { 233 | show: { 234 | resource: ['post'], 235 | operation: ['post'], 236 | }, 237 | }, 238 | }, 239 | ]; 240 | 241 | /** 242 | * Resize an image if it exceeds the specified size or width. 243 | * 244 | * @link https://github.com/lovell/sharp/issues/1667 245 | * 246 | * @param imageBuffer 247 | * @param maxWidth 248 | * @param maxSizeBytes 249 | */ 250 | async function resizeImageIfNeeded(imageBuffer: Buffer, maxWidth: number, maxSizeBytes: number): Promise { 251 | let quality = 90; 252 | let buffer = imageBuffer; 253 | const minQuality = 40; 254 | const drop = 5; 255 | 256 | while (buffer.length > maxSizeBytes && quality >= minQuality) { 257 | try { 258 | buffer = await sharp(imageBuffer) 259 | .resize({ width: maxWidth, withoutEnlargement: true, fit: 'inside' }) 260 | .jpeg({ quality }) 261 | .toBuffer(); 262 | } catch (error: any) { 263 | Logger.warn(`Failed to resize image at quality ${quality}: ${error.message}. Returning original image.`); 264 | break; 265 | } 266 | if (buffer.length <= maxSizeBytes) { 267 | return buffer; 268 | } 269 | quality -= drop; 270 | } 271 | if (buffer.length > maxSizeBytes) { 272 | Logger.warn(`Image could not be resized below ${maxSizeBytes} bytes. Returning best effort.`); 273 | } 274 | return buffer; 275 | } 276 | 277 | export async function postOperation( 278 | agent: AtpAgent, 279 | postText: string, 280 | langs: string[], 281 | websiteCard?: { 282 | thumbnailBinary: Buffer | undefined; 283 | description: string | undefined; 284 | title: string | undefined; 285 | uri: string | undefined; 286 | fetchOpenGraphTags: boolean | undefined; 287 | }, 288 | image?: { 289 | alt?: string; 290 | mimeType?: string; 291 | binary?: Buffer; 292 | width?: number; 293 | height?: number; 294 | }, 295 | ): Promise { 296 | const returnData: INodeExecutionData[] = []; 297 | 298 | let rt = new RichText({ text: postText }); 299 | try { 300 | await rt.detectFacets(agent); 301 | } catch (facetsErr: any) { 302 | Logger.error(`Failed to detect facets in post text: ${facetsErr?.message || facetsErr}`); 303 | // Continue without facets if detection fails 304 | } 305 | 306 | let postData: any = { 307 | text: rt.text || postText, 308 | langs: langs, 309 | facets: rt.facets, 310 | }; 311 | 312 | if (image) { 313 | Logger.debug('Processing image node property'); 314 | let imageBlob = undefined; 315 | if (image.binary) { 316 | const resizedImageBuffer = await resizeImageIfNeeded(image.binary, MAX_IMAGE_WIDTH, IMAGE_SIZE_LIMIT); 317 | const uploadResponse = await agent.uploadBlob(resizedImageBuffer, { 318 | encoding: image.mimeType && image.mimeType.trim() !== '' ? image.mimeType : 'image/jpeg', 319 | }); 320 | imageBlob = uploadResponse.data.blob; 321 | const imageEntry: any = { 322 | alt: image.alt, 323 | image: imageBlob, 324 | }; 325 | if (typeof image.width === 'number' && typeof image.height === 'number' && image.width > 0 && image.height > 0) { 326 | imageEntry.aspectRatio = { width: image.width, height: image.height }; 327 | } 328 | postData.embed = { 329 | $type: 'app.bsky.embed.images', 330 | images: [imageEntry], 331 | }; 332 | } 333 | } 334 | 335 | // If an image embed is present, prefer it over a website card. Only build website card embed if no image embed was set. 336 | if (!postData.embed && websiteCard?.uri) { 337 | Logger.debug('Processing websiteCard node property'); 338 | 339 | // Validate URL before proceeding 340 | try { 341 | new URL(websiteCard.uri); 342 | } catch (error) { 343 | throw new Error(`Invalid URL provided: ${websiteCard.uri}`); 344 | } 345 | 346 | let thumbBlob = undefined; 347 | 348 | if (websiteCard.fetchOpenGraphTags === true) { 349 | try { 350 | const ogsResponse = await ogs({ url: websiteCard.uri }); 351 | if (ogsResponse.error) { 352 | Logger.error(`Error fetching Open Graph tags: ${ogsResponse.error}`); 353 | if (!websiteCard.title) { 354 | websiteCard.title = websiteCard.uri || 'Untitled'; 355 | } 356 | } else { 357 | Logger.info('Open Graph response', { ogsResponse }); 358 | // Extract image URL from various ogImage shapes 359 | const ogImage = (ogsResponse.result as any).ogImage; 360 | let imageUrl: string | undefined; 361 | if (typeof ogImage === 'string') { 362 | imageUrl = ogImage; 363 | } else if (Array.isArray(ogImage) && ogImage.length > 0) { 364 | const first = ogImage[0]; 365 | imageUrl = typeof first === 'string' ? first : first?.url; 366 | } else if (ogImage && typeof ogImage === 'object' && 'url' in ogImage) { 367 | imageUrl = (ogImage as any).url; 368 | } 369 | if (imageUrl) { 370 | try { 371 | Logger.info('Fetching image from Open Graph tags', { imageUrl }); 372 | const imageDataResponse = await fetch(imageUrl); 373 | if (imageDataResponse.ok) { 374 | const thumbBlobArrayBuffer = await imageDataResponse.arrayBuffer(); 375 | let thumbBuffer = Buffer.from(thumbBlobArrayBuffer); 376 | thumbBuffer = await resizeImageIfNeeded(thumbBuffer, MAX_IMAGE_WIDTH, IMAGE_SIZE_LIMIT); 377 | const { data } = await agent.uploadBlob(thumbBuffer, { encoding: 'image/jpeg' }); 378 | thumbBlob = data.blob; 379 | } 380 | } catch (imageErr: any) { 381 | Logger.error(`Failed to fetch or process image from Open Graph tags: ${imageErr?.message || imageErr}`); 382 | // Proceed without thumbnail 383 | } 384 | } 385 | if (ogsResponse.result.ogTitle) { 386 | websiteCard.title = ogsResponse.result.ogTitle; 387 | } else if (!websiteCard.title) { 388 | websiteCard.title = websiteCard.uri || 'Untitled'; 389 | } 390 | if (ogsResponse.result.ogDescription) { 391 | websiteCard.description = ogsResponse.result.ogDescription; 392 | } else { 393 | websiteCard.description = ''; 394 | } 395 | } 396 | } catch (err: any) { 397 | Logger.error(`Failed to fetch Open Graph tags for URL '${websiteCard.uri}': ${err?.message || err}`); 398 | // Do not throw; continue without OG enhancements 399 | } 400 | } else if (websiteCard.thumbnailBinary) { 401 | // Only upload image if provided and not fetching OG tags 402 | 403 | Logger.debug('Processing websiteCard.thumbnailBinary node property'); 404 | 405 | try { 406 | websiteCard.thumbnailBinary = await resizeImageIfNeeded(websiteCard.thumbnailBinary, MAX_IMAGE_WIDTH, IMAGE_SIZE_LIMIT); 407 | const uploadResponse = await agent.uploadBlob(websiteCard.thumbnailBinary, { encoding: 'image/jpeg' }); 408 | thumbBlob = uploadResponse.data.blob; 409 | } catch (thumbErr: any) { 410 | Logger.error(`Failed to process or upload thumbnail: ${thumbErr?.message || thumbErr}`); 411 | // Don't throw here, continue with the post without the thumbnail 412 | } 413 | } 414 | 415 | // Define the thumbnail for the embed 416 | if (websiteCard.fetchOpenGraphTags === false) { 417 | // handle thumbnailBinary 418 | if (websiteCard.thumbnailBinary && !thumbBlob) { 419 | try { 420 | websiteCard.thumbnailBinary = await resizeImageIfNeeded(websiteCard.thumbnailBinary, MAX_IMAGE_WIDTH, IMAGE_SIZE_LIMIT); 421 | const uploadResponse = await agent.uploadBlob(websiteCard.thumbnailBinary, { encoding: 'image/jpeg' }); 422 | thumbBlob = uploadResponse.data.blob; 423 | } catch (thumbErr: any) { 424 | Logger.error(`Failed to process or upload thumbnail: ${thumbErr?.message || thumbErr}`); 425 | } 426 | } 427 | } 428 | 429 | // Always include thumb, even if undefined 430 | const externalEmbed: any = { 431 | uri: websiteCard.uri, 432 | title: websiteCard.title, 433 | description: websiteCard.description, 434 | }; 435 | 436 | if (thumbBlob) { 437 | externalEmbed.thumb = thumbBlob; 438 | } 439 | 440 | postData.embed = { 441 | $type: 'app.bsky.embed.external', 442 | external: externalEmbed, 443 | }; 444 | } 445 | 446 | const postResponse: { uri: string; cid: string } = await agent.post(postData); 447 | 448 | returnData.push({ 449 | json: { 450 | uri: postResponse.uri, 451 | cid: postResponse.cid, 452 | }, 453 | }); 454 | 455 | return returnData; 456 | } 457 | 458 | export async function deletePostOperation(agent: AtpAgent, uri: string): Promise { 459 | const returnData: INodeExecutionData[] = []; 460 | await agent.deletePost(uri) 461 | 462 | returnData.push({ 463 | json: { 464 | uri: uri, 465 | }, 466 | }); 467 | 468 | return returnData; 469 | } 470 | 471 | export async function likeOperation( 472 | agent: AtpAgent, 473 | uri: string, 474 | cid: string, 475 | ): Promise { 476 | const returnData: INodeExecutionData[] = []; 477 | 478 | // https://docs.bsky.app/docs/tutorials/like-repost#liking-a-post 479 | const likeResponse: { uri: string; cid: string } = await agent.like(uri, cid); 480 | 481 | returnData.push({ 482 | json: { 483 | uri: likeResponse.uri, 484 | cid: likeResponse.cid, 485 | }, 486 | }); 487 | 488 | return returnData; 489 | } 490 | 491 | export async function deleteLikeOperation( 492 | agent: AtpAgent, 493 | uri: string, 494 | ): Promise { 495 | const returnData: INodeExecutionData[] = []; 496 | 497 | // no response from deleteLike 498 | // https://docs.bsky.app/docs/tutorials/like-repost#unliking-a-post 499 | await agent.deleteLike(uri); 500 | 501 | returnData.push({ 502 | json: { 503 | uri: uri, 504 | }, 505 | }); 506 | 507 | return returnData; 508 | } 509 | 510 | export async function repostOperation( 511 | agent: AtpAgent, 512 | uri: string, 513 | cid: string, 514 | ): Promise { 515 | const returnData: INodeExecutionData[] = []; 516 | 517 | // https://docs.bsky.app/docs/tutorials/like-repost#quote-reposting 518 | const repostResult: { uri: string; cid: string } = await agent.repost(uri, cid); 519 | 520 | returnData.push({ 521 | json: { 522 | uri: repostResult.uri, 523 | cid: repostResult.cid, 524 | }, 525 | }); 526 | 527 | return returnData; 528 | } 529 | 530 | export async function deleteRepostOperation( 531 | agent: AtpAgent, 532 | uri: string, 533 | ): Promise { 534 | const returnData: INodeExecutionData[] = []; 535 | 536 | // no response from deleteRepost 537 | await agent.deleteRepost(uri); 538 | 539 | returnData.push({ 540 | json: { 541 | uri: uri, 542 | }, 543 | }); 544 | 545 | return returnData; 546 | } 547 | --------------------------------------------------------------------------------