mirror of
https://github.com/aljazceru/n8n-nodes-signal-cli.git
synced 2025-12-17 06:14:20 +01:00
🤖🌏
This commit is contained in:
10
.clinerules/testing-preferences.md
Normal file
10
.clinerules/testing-preferences.md
Normal file
@@ -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.
|
||||
52
.eslintrc.js
Normal file
52
.eslintrc.js
Normal file
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
16
.eslintrc.prepublish.js
Normal file
16
.eslintrc.prepublish.js
Normal file
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -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
|
||||
16
.npmignore
Normal file
16
.npmignore
Normal file
@@ -0,0 +1,16 @@
|
||||
node_modules
|
||||
*.ts
|
||||
*.tsbuildinfo
|
||||
*.map
|
||||
coverage
|
||||
.nyc_output
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
*.log
|
||||
*.tmp
|
||||
test
|
||||
tests
|
||||
*.tgz
|
||||
.envrc
|
||||
.env
|
||||
102
README.md
Normal file
102
README.md
Normal file
@@ -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
|
||||
26
credentials/SignalCliApi.credentials.ts
Normal file
26
credentials/SignalCliApi.credentials.ts
Normal file
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
16
gulpfile.js
Normal file
16
gulpfile.js
Normal file
@@ -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));
|
||||
}
|
||||
9
index.js
Normal file
9
index.js
Normal file
@@ -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,
|
||||
},
|
||||
};
|
||||
7
jest.config.js
Normal file
7
jest.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||
prettierPath: require.resolve('prettier'),
|
||||
testTimeout: 20000,
|
||||
};
|
||||
554
nodes/Signal/Signal.node.ts
Normal file
554
nodes/Signal/Signal.node.ts
Normal file
@@ -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<INodeExecutionData[][]> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
202
nodes/Signal/Signal.test.ts
Normal file
202
nodes/Signal/Signal.test.ts
Normal file
@@ -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",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
96
nodes/SignalTrigger/SignalTrigger.node.ts
Normal file
96
nodes/SignalTrigger/SignalTrigger.node.ts
Normal file
@@ -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<ITriggerResponse> {
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
71
package.json
Normal file
71
package.json
Normal file
@@ -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 <npm@fgribreau.com> (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"
|
||||
}
|
||||
}
|
||||
32
tsconfig.json
Normal file
32
tsconfig.json
Normal file
@@ -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",
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user