├── 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 |
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 | 
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 | 
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 | 
43 |
44 | Use Open Graph Tags to get the image and description of the post.
45 |
46 | 
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 |
--------------------------------------------------------------------------------