From 130d6b82161a29497a2a9c63cdc108c808de83a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franc=CC=A7ois-Guillaume=20Ribreau?= Date: Sun, 4 May 2025 23:38:17 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=F0=9F=8C=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .clinerules/testing-preferences.md | 10 + .eslintrc.js | 52 ++ .eslintrc.prepublish.js | 16 + .gitignore | 17 + .npmignore | 16 + README.md | 102 ++++ credentials/SignalCliApi.credentials.ts | 26 + gulpfile.js | 16 + index.js | 9 + jest.config.js | 7 + nodes/Signal/Signal.node.ts | 554 ++++++++++++++++++++++ nodes/Signal/Signal.test.ts | 202 ++++++++ nodes/SignalTrigger/SignalTrigger.node.ts | 96 ++++ package.json | 71 +++ tsconfig.json | 32 ++ 15 files changed, 1226 insertions(+) create mode 100644 .clinerules/testing-preferences.md create mode 100644 .eslintrc.js create mode 100644 .eslintrc.prepublish.js create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 README.md create mode 100644 credentials/SignalCliApi.credentials.ts create mode 100644 gulpfile.js create mode 100644 index.js create mode 100644 jest.config.js create mode 100644 nodes/Signal/Signal.node.ts create mode 100644 nodes/Signal/Signal.test.ts create mode 100644 nodes/SignalTrigger/SignalTrigger.node.ts create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.clinerules/testing-preferences.md b/.clinerules/testing-preferences.md new file mode 100644 index 0000000..63b7c2b --- /dev/null +++ b/.clinerules/testing-preferences.md @@ -0,0 +1,10 @@ +## Brief overview + This set of guidelines outlines the testing preferences for the project, focusing on the avoidance of mocks and specific testing practices. + +## Testing strategies + - Never use mocks and jest.mock in tests. + - Prefer black-box tests over white-box tests when possible. + +## Testing best practices + - Ensure each test is comprehensive and covers all necessary scenarios. + - Keep tests concise and focused on specific functionality. diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..b3c4c73 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,52 @@ +/** + * @type {import('@types/eslint').ESLint.ConfigData} + */ +module.exports = { + root: true, + + env: { + browser: true, + es6: true, + node: true, + }, + + parser: '@typescript-eslint/parser', + + parserOptions: { + project: ['./tsconfig.json'], + sourceType: 'module', + extraFileExtensions: ['.json'], + }, + + ignorePatterns: ['.eslintrc.js', '**/*.js', '**/node_modules/**', '**/dist/**'], + + overrides: [ + { + files: ['package.json'], + plugins: ['eslint-plugin-n8n-nodes-base'], + extends: ['plugin:n8n-nodes-base/community'], + rules: { + 'n8n-nodes-base/community-package-json-name-still-default': 'off', + }, + }, + { + files: ['./credentials/**/*.ts'], + plugins: ['eslint-plugin-n8n-nodes-base'], + extends: ['plugin:n8n-nodes-base/credentials'], + rules: { + 'n8n-nodes-base/cred-class-field-documentation-url-missing': 'off', + 'n8n-nodes-base/cred-class-field-documentation-url-miscased': 'off', + }, + }, + { + files: ['./nodes/**/*.ts'], + plugins: ['eslint-plugin-n8n-nodes-base'], + extends: ['plugin:n8n-nodes-base/nodes'], + rules: { + 'n8n-nodes-base/node-execute-block-missing-continue-on-fail': 'off', + 'n8n-nodes-base/node-resource-description-filename-against-convention': 'off', + 'n8n-nodes-base/node-param-fixed-collection-type-unsorted-items': 'off', + }, + }, + ], +}; diff --git a/.eslintrc.prepublish.js b/.eslintrc.prepublish.js new file mode 100644 index 0000000..2d319f0 --- /dev/null +++ b/.eslintrc.prepublish.js @@ -0,0 +1,16 @@ +/** + * @type {import('@types/eslint').ESLint.ConfigData} + */ +module.exports = { + extends: "./.eslintrc.js", + + overrides: [ + { + files: ['package.json'], + plugins: ['eslint-plugin-n8n-nodes-base'], + rules: { + 'n8n-nodes-base/community-package-json-name-still-default': 'error', + }, + }, + ], +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e34e41c --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +node_modules +*.tsbuildinfo +*.map +coverage +.nyc_output +.vscode +.idea +.DS_Store +*.log +*.tmp +test +tests +dist +pnpm-lock.yaml +*.tgz +.envrc +.env diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..9eef0aa --- /dev/null +++ b/.npmignore @@ -0,0 +1,16 @@ +node_modules +*.ts +*.tsbuildinfo +*.map +coverage +.nyc_output +.vscode +.idea +.DS_Store +*.log +*.tmp +test +tests +*.tgz +.envrc +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..153410d --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# πŸ“¦ n8n-nodes-signal-cli + +This repository contains n8n nodes for interacting with Signal CLI. It includes a trigger node for receiving messages and an action node for various Signal operations. + +## πŸ“š Table of Contents +1. [πŸ“‹ Prerequisites](#-prerequisites) +2. [πŸš€ Usage](#-usage) +3. [πŸ“₯ Installation](#-installation) +4. [πŸ€– Nodes](#-nodes) +5. [πŸ’» Development](#-development) +6. [πŸš€ Release](#-release) +7. [🀝 Contributing](#-contributing) +8. [⚠️ Known Limitations](#-known-limitations) +9. [πŸ“„ License](#-license) + +## πŸ“‹ Prerequisites + +* Node.js (>=18.10) and pnpm (>=9.1) +* n8n installed globally using `pnpm install n8n -g` +* Signal CLI set up and running in daemon mode with HTTP JSON-RPC endpoint exposed (`--http`) + + +## πŸ“₯ Installation + +1. Clone this repository. +2. Run `pnpm install` to install dependencies. +3. Run `pnpm build` to build the nodes. +4. Copy the `dist` folder and `package.json` to your n8n custom nodes directory (usually `~/.n8n/custom/nodes/n8n-nodes-signal-cli`). + +## πŸ€– Nodes + +### πŸ”” SignalTrigger + +* Triggers when a new message is received via Signal CLI. +* Requires Signal CLI API credential. +* Parameters: + * `account`: Signal account to listen for incoming messages. + +### πŸ“± Signal + +* Interact with Signal CLI API for various operations. +* Requires Signal CLI API credential. +* Supports the following resources and operations: + * **Message**: + * Send: Send a message to a recipient or group. + * Parameters: `account`, `recipient`, `message` + * **Group**: + * Create: Create a new group. + * Parameters: `account`, `name`, `members` + * List: List all groups. + * Parameters: `account` + * **Contact**: + * Update: Update a contact's name. + * Parameters: `account`, `recipient`, `name` + * List: List all contacts. + * Parameters: `account` + * **Reaction**: + * Send: Send a reaction to a message. + * Parameters: `account`, `recipient`, `reaction`, `targetAuthor`, `timestamp` + * Remove: Remove a reaction from a message. + * Parameters: `account`, `recipient`, `reaction`, `targetAuthor`, `timestamp` + * **Receipt**: + * Send: Send a receipt (read or viewed) for a message. + * Parameters: `account`, `recipient`, `receiptType`, `timestamp` + +## πŸ’» Development + +* Run `pnpm dev` to start the TypeScript compiler in watch mode. +* Run `pnpm lint` to check for linting errors. +* Run `pnpm test` to run integration tests. + +Before running the tests, set the `ENDPOINT` environment variable to the Signal CLI API URL (e.g., "http://127.0.0.1:8085"). + +For example, you can run the following command in your terminal: + +```bash +export ENDPOINT="http://127.0.0.1:8085" # signal-cli endpoint +export ACCOUNT_NUMBER="Β±33620382719" # your phone number in international format +``` + + +## πŸš€ Release + +* Run `pnpm release` to release a new version of the package. + +## 🀝 Contributing + +Contributions are welcome! Please follow these steps to contribute: +1. Fork this repository. +2. Create a new branch for your feature or bug fix. +3. Submit a pull request with a clear description of your changes. +4. Ensure that your code adheres to the existing coding standards and passes all tests. + +## ⚠️ Known Limitations + +* This implementation relies on the Signal CLI API, which may have its own limitations and constraints. +* Ensure that the Signal CLI is properly configured and running before using these nodes. +* Some operations may require specific permissions or settings within Signal CLI. + +## πŸ“„ License + +MIT diff --git a/credentials/SignalCliApi.credentials.ts b/credentials/SignalCliApi.credentials.ts new file mode 100644 index 0000000..02acb35 --- /dev/null +++ b/credentials/SignalCliApi.credentials.ts @@ -0,0 +1,26 @@ +import { + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class SignalCliApi implements ICredentialType { + name = 'signalCliApi'; + displayName = 'Signal CLI API'; + properties: INodeProperties[] = [ + { + displayName: 'URL', + name: 'url', + type: 'string', + default: process.env.ENDPOINT || '', + placeholder: 'http://localhost:8085', + required: true, + }, + { + displayName: 'Account', + name: 'account', + type: 'string', + default: '', + required: true, + }, + ]; +} diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..edb92fe --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,16 @@ +const path = require('path'); +const { task, src, dest } = require('gulp'); + +task('build:icons', copyIcons); + +function copyIcons() { + const nodeSource = path.resolve('nodes', '**', '*.{png,svg}'); + const nodeDestination = path.resolve('dist', 'nodes'); + + src(nodeSource).pipe(dest(nodeDestination)); + + const credSource = path.resolve('credentials', '**', '*.{png,svg}'); + const credDestination = path.resolve('dist', 'credentials'); + + return src(credSource).pipe(dest(credDestination)); +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..8e37425 --- /dev/null +++ b/index.js @@ -0,0 +1,9 @@ +module.exports = { + nodes: { + Signal: require('./dist/nodes/Signal/Signal.node').Signal, + SignalTrigger: require('./dist/nodes/SignalTrigger/SignalTrigger.node').SignalTrigger, + }, + credentials: { + SignalCliApi: require('./dist/credentials/signalCliApi.credentials').SignalCliApi, + }, +}; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..3ab01b9 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + prettierPath: require.resolve('prettier'), + testTimeout: 20000, +}; diff --git a/nodes/Signal/Signal.node.ts b/nodes/Signal/Signal.node.ts new file mode 100644 index 0000000..27590bc --- /dev/null +++ b/nodes/Signal/Signal.node.ts @@ -0,0 +1,554 @@ +import { + IExecuteFunctions, + INodeExecutionData, + INodeType, + INodeTypeDescription, + NodeOperationError, + NodeConnectionType, +} from 'n8n-workflow'; +import axios from 'axios'; +import { v4 as uuidv4 } from 'uuid'; +import Debug from 'debug'; + +const debug = Debug('n8n:signal'); + +export class Signal implements INodeType { + description: INodeTypeDescription = { + displayName: 'Signal', + name: 'signal', + group: ['output'], + version: 1, + description: 'Interact with Signal CLI API', + defaults: { + name: 'Signal', + }, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + credentials: [ + { + name: 'signalCliApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Message', + value: 'message', + }, + { + name: 'Group', + value: 'group', + }, + { + name: 'Contact', + value: 'contact', + }, + { + name: 'Reaction', + value: 'reaction', + }, + { + name: 'Receipt', + value: 'receipt', + }, + ], + default: 'message', + }, + // Message properties + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['message'], + }, + }, + options: [ + { + name: 'Send', + value: 'send', + }, + ], + default: 'send', + }, + { + displayName: 'Account', + name: 'account', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['message'], + }, + }, + }, + { + displayName: 'Recipient', + name: 'recipient', + type: 'string', + default: '', + required: true, + description: 'Phone number or group ID of the recipient', + displayOptions: { + show: { + resource: ['message'], + operation: ['send'], + }, + }, + }, + { + displayName: 'Message', + name: 'message', + type: 'string', + default: '', + required: true, + description: 'The message to be sent', + displayOptions: { + show: { + resource: ['message'], + operation: ['send'], + }, + }, + }, + // Group properties + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['group'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + }, + { + name: 'List', + value: 'list', + }, + ], + default: 'create', + }, + { + displayName: 'Account', + name: 'account', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['group'], + }, + }, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + required: true, + description: 'The name of the group', + displayOptions: { + show: { + resource: ['group'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Members', + name: 'members', + type: 'string', + default: '', + required: true, + description: 'Comma-separated list of members to add to the group', + displayOptions: { + show: { + resource: ['group'], + operation: ['create'], + }, + }, + }, + // Contact properties + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['contact'], + }, + }, + options: [ + { + name: 'Update', + value: 'update', + }, + { + name: 'List', + value: 'list', + }, + ], + default: 'update', + }, + { + displayName: 'Account', + name: 'account', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['contact'], + }, + }, + }, + { + displayName: 'Recipient', + name: 'recipient', + type: 'string', + default: '', + required: true, + description: 'Phone number of the contact', + displayOptions: { + show: { + resource: ['contact'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'The name of the contact', + displayOptions: { + show: { + resource: ['contact'], + operation: ['update'], + }, + }, + }, + // Reaction properties + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['reaction'], + }, + }, + options: [ + { + name: 'Send', + value: 'send', + }, + { + name: 'Remove', + value: 'remove', + }, + ], + default: 'send', + }, + { + displayName: 'Account', + name: 'account', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['reaction'], + }, + }, + }, + { + displayName: 'Recipient', + name: 'recipient', + type: 'string', + default: '', + required: true, + description: 'Phone number or group ID of the recipient', + displayOptions: { + show: { + resource: ['reaction'], + operation: ['send', 'remove'], + }, + }, + }, + { + displayName: 'Reaction', + name: 'reaction', + type: 'string', + default: '', + required: true, + description: 'The reaction to be sent', + displayOptions: { + show: { + resource: ['reaction'], + operation: ['send', 'remove'], + }, + }, + }, + { + displayName: 'Target Author', + name: 'targetAuthor', + type: 'string', + default: '', + required: true, + description: 'The author of the message being reacted to', + displayOptions: { + show: { + resource: ['reaction'], + operation: ['send', 'remove'], + }, + }, + }, + { + displayName: 'Timestamp', + name: 'timestamp', + type: 'number', + default: 0, + required: true, + description: 'The timestamp of the message being reacted to', + displayOptions: { + show: { + resource: ['reaction'], + operation: ['send', 'remove'], + }, + }, + }, + // Receipt properties + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['receipt'], + }, + }, + options: [ + { + name: 'Send', + value: 'send', + }, + ], + default: 'send', + }, + { + displayName: 'Account', + name: 'account', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['receipt'], + }, + }, + }, + { + displayName: 'Recipient', + name: 'recipient', + type: 'string', + default: '', + required: true, + description: 'Phone number or group ID of the recipient', + displayOptions: { + show: { + resource: ['receipt'], + operation: ['send'], + }, + }, + }, + { + displayName: 'Receipt Type', + name: 'receiptType', + type: 'options', + options: [ + { + name: 'Read', + value: 'read', + }, + { + name: 'Viewed', + value: 'viewed', + }, + ], + default: 'read', + required: true, + displayOptions: { + show: { + resource: ['receipt'], + operation: ['send'], + }, + }, + }, + { + displayName: 'Timestamp', + name: 'timestamp', + type: 'number', + default: 0, + required: true, + description: 'The timestamp of the message being receipted', + displayOptions: { + show: { + resource: ['receipt'], + operation: ['send'], + }, + }, + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + const credentials = await this.getCredentials('signalCliApi'); + + if (!credentials.url) { + throw new NodeOperationError(this.getNode(), 'Signal CLI API URL is not set in credentials'); + } + + const url = `${credentials.url}/api/v1/rpc`; + + try { + let response; + debug('Signal Node: Executing with resource=%s, operation=%s', resource, operation); + if (resource === 'message' && operation === 'send') { + const account = this.getNodeParameter('account', 0) as string; + const recipient = this.getNodeParameter('recipient', 0) as string; + const message = this.getNodeParameter('message', 0) as string; + + const requestBody = { + jsonrpc: '2.0', + method: 'send', + params: { account, recipient, message }, + id: uuidv4(), + }; + debug('Signal Node: Sending message with requestBody=%o', requestBody); + response = await axios.post(`${url}`, requestBody); + } else if (resource === 'group' && operation === 'create') { + const account = this.getNodeParameter('account', 0) as string; + const name = this.getNodeParameter('name', 0) as string; + const members = (this.getNodeParameter('members', 0) as string).split(','); + + const requestBody = { + jsonrpc: '2.0', + method: 'updateGroup', + params: { + account, + name, + members, + }, + id: uuidv4(), + }; + debug('Signal Node: Creating group with requestBody=%o', requestBody); + response = await axios.post(`${url}`, requestBody); + } else if (resource === 'group' && operation === 'list') { + const account = this.getNodeParameter('account', 0) as string; + + const requestBody = { + jsonrpc: '2.0', + method: 'listGroups', + params: { account }, + id: uuidv4(), + }; + + response = await axios.post(`${url}`, requestBody); + } else if (resource === 'contact' && operation === 'update') { + const account = this.getNodeParameter('account', 0) as string; + const recipient = this.getNodeParameter('recipient', 0) as string; + const name = this.getNodeParameter('name', 0) as string; + + const requestBody = { + jsonrpc: '2.0', + method: 'updateContact', + params: { account, recipient, name }, + id: uuidv4(), + }; + + response = await axios.post(`${url}`, requestBody); + } else if (resource === 'contact' && operation === 'list') { + const account = this.getNodeParameter('account', 0) as string; + + const requestBody = { + jsonrpc: '2.0', + method: 'listContacts', + params: { account }, + id: uuidv4(), + }; + + response = await axios.post(`${url}`, requestBody); + } else if (resource === 'reaction' && operation === 'send') { + const account = this.getNodeParameter('account', 0) as string; + const recipient = this.getNodeParameter('recipient', 0) as string; + const reaction = this.getNodeParameter('reaction', 0) as string; + const targetAuthor = this.getNodeParameter('targetAuthor', 0) as string; + const timestamp = this.getNodeParameter('timestamp', 0) as number; + + const requestBody = { + jsonrpc: '2.0', + method: 'sendReaction', + params: { account, recipient, reaction, targetAuthor, timestamp }, + id: uuidv4(), + }; + + response = await axios.post(`${url}`, requestBody); + } else if (resource === 'reaction' && operation === 'remove') { + const account = this.getNodeParameter('account', 0) as string; + const recipient = this.getNodeParameter('recipient', 0) as string; + const reaction = this.getNodeParameter('reaction', 0) as string; + const targetAuthor = this.getNodeParameter('targetAuthor', 0) as string; + const timestamp = this.getNodeParameter('timestamp', 0) as number; + + const requestBody = { + jsonrpc: '2.0', + method: 'sendReaction', + params: { account, recipient, reaction, targetAuthor, timestamp, remove: true }, + id: uuidv4(), + }; + + response = await axios.post(`${url}`, requestBody); + } else if (resource === 'receipt' && operation === 'send') { + const account = this.getNodeParameter('account', 0) as string; + const recipient = this.getNodeParameter('recipient', 0) as string; + const receiptType = this.getNodeParameter('receiptType', 0) as string; + const timestamp = this.getNodeParameter('timestamp', 0) as number; + + const requestBody = { + jsonrpc: '2.0', + method: 'sendReceipt', + params: { account, recipient, receiptType, timestamp }, + id: uuidv4(), + }; + + response = await axios.post(`${url}`, requestBody); + } + + debug('Signal Node: Response', response?.data); + const item: INodeExecutionData = { + json: response?.data, + }; + return [[item]]; + } catch (error) { + throw new NodeOperationError(this.getNode(), 'Error interacting with Signal API', { + itemIndex: 0, + }); + } + } +} diff --git a/nodes/Signal/Signal.test.ts b/nodes/Signal/Signal.test.ts new file mode 100644 index 0000000..d8775e9 --- /dev/null +++ b/nodes/Signal/Signal.test.ts @@ -0,0 +1,202 @@ +import { Signal } from "./Signal.node"; +import type { IExecuteFunctions } from "n8n-workflow"; +import omit from "omit-deep"; + +jest.mock("uuid", () => ({ v4: () => "n8n" })); + +describe("Signal Node", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should send a message", async () => { + const signal = new Signal(); + const executeFunctions = { + getCredentials: async () => ({ + url: process.env.ENDPOINT, + account: process.env.ACCOUNT_NUMBER, + }), + getNodeParameter: (paramName: string): string => { + if (paramName === "account") + return process.env.ACCOUNT_NUMBER as string; + if (paramName === "recipient") + return process.env.ACCOUNT_NUMBER as string; + if (paramName === "message") return "Hello, world!"; + if (paramName === "resource") return "message"; + if (paramName === "operation") return "send"; + throw new Error(`Unexpected parameter name: ${paramName}`); + }, + helpers: {}, + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as any, + getExecutionId: jest.fn(), + getNode: jest.fn(), + continueOnFail: jest.fn(), + getInputData: jest.fn(), + getWorkflowStaticData: jest.fn(), + getRestApiUrl: jest.fn(), + getTimezone: jest.fn(), + getWorkflow: jest.fn(), + } as unknown as IExecuteFunctions; + + const result = await signal.execute.call(executeFunctions); + + expect( + omit(result[0][0].json, [ + "timestamp", + "result.results.0.recipientAddress.uuid", + "result.results.0.recipientAddress.number", + ]) + ).toMatchInlineSnapshot(` + { + "id": "n8n", + "jsonrpc": "2.0", + "result": { + "results": [ + { + "recipientAddress": {}, + "type": "SUCCESS", + }, + ], + }, + } + `); + }); + + it("should create a group", async () => { + const signal = new Signal(); + const executeFunctions = { + getCredentials: async () => ({ + url: process.env.ENDPOINT, + account: process.env.ACCOUNT_NUMBER, + }), + getNodeParameter: (paramName: string): string => { + if (paramName === "account") + return process.env.ACCOUNT_NUMBER as string; + if (paramName === "name") return "Test Group"; + if (paramName === "members") return `${process.env.ACCOUNT_NUMBER}`; + if (paramName === "resource") return "group"; + if (paramName === "operation") return "create"; + throw new Error(`Unexpected parameter name: ${paramName}`); + }, + helpers: {}, + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as any, + getExecutionId: jest.fn(), + getNode: jest.fn(), + continueOnFail: jest.fn(), + getInputData: jest.fn(), + getWorkflowStaticData: jest.fn(), + getRestApiUrl: jest.fn(), + getTimezone: jest.fn(), + getWorkflow: jest.fn(), + } as unknown as IExecuteFunctions; + + const result = await signal.execute.call(executeFunctions); + + expect(omit(result[0][0].json, ["timestamp", "result.groupId"])) + .toMatchInlineSnapshot(` + { + "id": "n8n", + "jsonrpc": "2.0", + "result": { + "results": [], + }, + } + `); + }); + + it("should list groups", async () => { + const signal = new Signal(); + const executeFunctions = { + getCredentials: async () => ({ + url: process.env.ENDPOINT, + account: process.env.ACCOUNT_NUMBER, + }), + getNodeParameter: (paramName: string): string => { + if (paramName === "account") + return process.env.ACCOUNT_NUMBER as string; + if (paramName === "resource") return "group"; + if (paramName === "operation") return "list"; + throw new Error(`Unexpected parameter name: ${paramName}`); + }, + helpers: {}, + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as any, + getExecutionId: jest.fn(), + getNode: jest.fn(), + continueOnFail: jest.fn(), + getInputData: jest.fn(), + getWorkflowStaticData: jest.fn(), + getRestApiUrl: jest.fn(), + getTimezone: jest.fn(), + getWorkflow: jest.fn(), + } as unknown as IExecuteFunctions; + + const result = await signal.execute.call(executeFunctions); + + expect((result?.[0]?.[0]?.json?.result as any[]).length).toBeGreaterThan(0); + expect(omit(result[0][0].json, ["timestamp", "result"])) + .toMatchInlineSnapshot(` + { + "id": "n8n", + "jsonrpc": "2.0", + } + `); + }); + + it("should list contacts", async () => { + const signal = new Signal(); + const executeFunctions = { + getCredentials: async () => ({ + url: process.env.ENDPOINT, + account: process.env.ACCOUNT_NUMBER, + }), + getNodeParameter: (paramName: string): string => { + if (paramName === "account") + return process.env.ACCOUNT_NUMBER as string; + if (paramName === "resource") return "contact"; + if (paramName === "operation") return "list"; + throw new Error(`Unexpected parameter name: ${paramName}`); + }, + helpers: {}, + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as any, + getExecutionId: jest.fn(), + getNode: jest.fn(), + continueOnFail: jest.fn(), + getInputData: jest.fn(), + getWorkflowStaticData: jest.fn(), + getRestApiUrl: jest.fn(), + getTimezone: jest.fn(), + getWorkflow: jest.fn(), + } as unknown as IExecuteFunctions; + + const result = await signal.execute.call(executeFunctions); + + expect((result?.[0]?.[0]?.json?.result as any[]).length).toBeGreaterThan(0); + expect(omit(result?.[0]?.[0]?.json, ["timestamp", "result"])) + .toMatchInlineSnapshot(` + { + "id": "n8n", + "jsonrpc": "2.0", + } + `); + }); +}); diff --git a/nodes/SignalTrigger/SignalTrigger.node.ts b/nodes/SignalTrigger/SignalTrigger.node.ts new file mode 100644 index 0000000..248f596 --- /dev/null +++ b/nodes/SignalTrigger/SignalTrigger.node.ts @@ -0,0 +1,96 @@ + +import { + ITriggerFunctions, + INodeExecutionData, + INodeType, + INodeTypeDescription, + NodeApiError, + ITriggerResponse, + NodeConnectionType, +} from 'n8n-workflow'; +import { EventSource } from 'eventsource'; +import debug from 'debug'; + +const signalTriggerDebug = debug('n8n:nodes:signal-trigger'); + +export class SignalTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Signal Trigger', + name: 'signalTrigger', + group: ['trigger'], + version: 1, + description: 'Triggers when a new message is received', + defaults: { + name: 'Signal Trigger', + }, + inputs: [], + outputs: [NodeConnectionType.Main], + credentials: [ + { + name: 'signalCliApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Account', + name: 'account', + type: 'string', + default: '', + required: true, + }, + ], + }; + + async trigger(this: ITriggerFunctions): Promise { + const credentials = await this.getCredentials('signalCliApi'); + if (!credentials.url) { + throw new NodeApiError(this.getNode(), { message: 'Signal CLI API URL is not set in credentials' }); + } + const url = `${credentials.url}/api/v1/events`; + + const eventSource = new EventSource(url); + + eventSource.onmessage = (event) => { + signalTriggerDebug('Received event: %o', event); + try { + const data = JSON.parse(event.data); + const message = data.dataMessage?.message; + if (message) { + const item: INodeExecutionData = { + json: { message }, + }; + this.emit([this.helpers.returnJsonArray([item])]); + } + } catch (error) { + this.logger.error('Error parsing message from Signal API', { error }); + } + }; + + + + return new Promise((resolve, reject) => { + eventSource.onerror = (err) => { + this.logger.error('EventSource error'); + reject(err); + }; + + eventSource.onopen = () => { + signalTriggerDebug('Connected to %s', url); + + eventSource.onerror = (err) => { + this.logger.error('EventSource error', {error: errΒ }); + }; + + resolve({ + closeFunction: async () => { + eventSource.close(); + }, + }); + } + }); + + + + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..087f480 --- /dev/null +++ b/package.json @@ -0,0 +1,71 @@ +{ + "name": "n8n-nodes-signal-cli", + "version": "0.1.0", + "description": "n8n nodes for Signal CLI", + "keywords": [ + "n8n-community-node-package" + ], + "license": "MIT", + "homepage": "", + "author": "Francois-Guillaume Ribreau (https://fgribreau.com)", + "repository": { + "type": "git", + "url": "https://github.com/fgribreau/n8n-nodes-signal-cli" + }, + "engines": { + "node": ">=18.10", + "pnpm": ">=9.1" + }, + "packageManager": "pnpm@9.1.4", + "main": "index.js", + "scripts": { + "build": "tsc && gulp build:icons", + "dev": "tsc --watch", + "format": "prettier nodes credentials --write", + "lint": "eslint nodes/**/*.ts credentials/**/*.ts package.json", + "lintfix": "eslint nodes/**/*.ts credentials/**/*.ts package.json --fix", + "prepublishOnly": "pnpm build && pnpm lint -c .eslintrc.prepublish.js nodes/**/*.ts credentials/**/*.ts package.json", + "release": "pnpm test && pnpm build && np --no-yarn --any-branch", + "test": "jest" + }, + "files": [ + "dist" + ], + "n8n": { + "n8nNodesApiVersion": 1, + "credentials": [ + "dist/credentials/signalCliApi.credentials.js" + ], + "nodes": [ + "dist/nodes/Signal/Signal.node.js", + "dist/nodes/SignalTrigger/SignalTrigger.node.js" + ] + }, + "devDependencies": { + "@types/axios": "^0.14.4", + "@types/debug": "^4.1.12", + "@types/jest": "^29.5.14", + "@types/node": "^22.15.3", + "@types/omit-deep": "^0.3.2", + "@typescript-eslint/parser": "^7.15.0", + "eslint": "^8.56.0", + "eslint-plugin-n8n-nodes-base": "^1.16.1", + "gulp": "^5.0.0", + "gulp-typescript": "^6.0.0-alpha.1", + "jest": "^29.7.0", + "np": "^10.2.0", + "prettier": "^2.8.8", + "ts-jest": "^29.3.2", + "typescript": "^5.8.3" + }, + "peerDependencies": { + "n8n-workflow": "*" + }, + "dependencies": { + "axios": "^1.9.0", + "debug": "^4.4.0", + "eventsource": "^3.0.6", + "omit-deep": "^0.3.0", + "uuid": "^11.1.0" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..625fb27 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "strict": true, + "module": "commonjs", + "moduleResolution": "node", + "target": "es2019", + "lib": ["es2019", "es2020", "es2022.error"], + "removeComments": true, + "useUnknownInCatchVariables": false, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "strictNullChecks": true, + "preserveConstEnums": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "incremental": true, + "declaration": true, + "sourceMap": true, + "skipLibCheck": true, + "outDir": "./dist/", + "types": ["node", "jest"] + }, + "exclude": ["node_modules/axios"], + "include": [ + "credentials/**/*", + "nodes/**/*", + "nodes/**/*.json", + "package.json", + ], +}