This commit is contained in:
François-Guillaume Ribreau
2025-05-04 23:38:17 +02:00
commit 130d6b8216
15 changed files with 1226 additions and 0 deletions

View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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",
}
`);
});
});

View 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
View 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
View 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",
],
}