n8n-nodes-signal-cli-rest-api 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -109,7 +109,7 @@ services:
109
109
 
110
110
  1. Clone this repository:
111
111
  ```bash
112
- git clone https://github.com/your-username/n8n-nodes-signal.git
112
+ git clone https://github.com/zblaze/n8n-nodes-signal.git
113
113
  cd n8n-nodes-signal
114
114
  ```
115
115
 
@@ -0,0 +1,9 @@
1
+ import { ICredentialType, INodeProperties, ICredentialTestRequest, Icon } from 'n8n-workflow';
2
+ export declare class SignalApi implements ICredentialType {
3
+ name: string;
4
+ displayName: string;
5
+ icon: Icon;
6
+ documentationUrl: string;
7
+ properties: INodeProperties[];
8
+ test: ICredentialTestRequest;
9
+ }
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SignalApi = void 0;
4
+ class SignalApi {
5
+ constructor() {
6
+ this.name = 'signalApi';
7
+ this.displayName = 'Signal API';
8
+ this.icon = 'file:signal.svg';
9
+ this.documentationUrl = 'https://github.com/bbernhard/signal-cli-rest-api';
10
+ this.properties = [
11
+ {
12
+ displayName: 'API URL',
13
+ name: 'apiUrl',
14
+ type: 'string',
15
+ default: 'http://localhost:8080',
16
+ placeholder: 'http://your-truenas-ip:8085',
17
+ description: 'The URL of your signal-cli-rest-api instance',
18
+ required: true,
19
+ },
20
+ {
21
+ displayName: 'API Token',
22
+ name: 'apiToken',
23
+ type: 'string',
24
+ typeOptions: { password: true },
25
+ default: '',
26
+ description: 'Optional API token for authentication (set in docker-compose)',
27
+ },
28
+ {
29
+ displayName: 'Phone Number',
30
+ name: 'phoneNumber',
31
+ type: 'string',
32
+ default: '',
33
+ placeholder: '+1234567890',
34
+ description: 'Phone number registered with Signal (with country code)',
35
+ required: true,
36
+ },
37
+ ];
38
+ this.test = {
39
+ request: {
40
+ method: 'GET',
41
+ url: '={{$credentials.apiUrl}}/v1/about',
42
+ headers: {
43
+ // The Authorization header uses n8n expression syntax for runtime interpolation
44
+ ...(typeof '{{ $credentials.apiToken }}' === 'string' && '{{ $credentials.apiToken }}'
45
+ ? { Authorization: 'Bearer {{ $credentials.apiToken }}' }
46
+ : {}),
47
+ },
48
+ timeout: 5000,
49
+ }
50
+ };
51
+ }
52
+ }
53
+ exports.SignalApi = SignalApi;
@@ -0,0 +1,5 @@
1
+ import { IExecuteFunctions, INodeExecutionData, INodeType, INodeTypeDescription } from 'n8n-workflow';
2
+ export declare class Signal implements INodeType {
3
+ description: INodeTypeDescription;
4
+ execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]>;
5
+ }
@@ -0,0 +1,317 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Signal = void 0;
4
+ const messages_1 = require("./messages");
5
+ const groups_1 = require("./groups");
6
+ const contacts_1 = require("./contacts");
7
+ class Signal {
8
+ constructor() {
9
+ this.description = {
10
+ displayName: 'Signal',
11
+ name: 'signal',
12
+ icon: 'file:signal.svg',
13
+ group: ['output'],
14
+ version: 1,
15
+ description: 'Interact with Signal via signal-cli-rest-api',
16
+ defaults: {
17
+ name: 'Signal',
18
+ },
19
+ inputs: ['main'],
20
+ outputs: ['main'],
21
+ credentials: [
22
+ {
23
+ name: 'signalApi',
24
+ required: true,
25
+ },
26
+ ],
27
+ properties: [
28
+ {
29
+ displayName: 'Operation',
30
+ name: 'operation',
31
+ type: 'options',
32
+ noDataExpression: true,
33
+ default: '',
34
+ options: [
35
+ {
36
+ name: 'Messages: Send Message',
37
+ value: 'sendMessage',
38
+ description: 'Send a text message to a contact or group',
39
+ action: 'Send a text message',
40
+ },
41
+ {
42
+ name: 'Messages: Send Attachment',
43
+ value: 'sendAttachment',
44
+ description: 'Send a file or image to a contact or group',
45
+ action: 'Send an attachment',
46
+ },
47
+ {
48
+ name: 'Messages: Send Reaction',
49
+ value: 'sendReaction',
50
+ description: 'Send a reaction (emoji) to a message',
51
+ action: 'Send a reaction',
52
+ },
53
+ {
54
+ name: 'Messages: Remove Reaction',
55
+ value: 'removeReaction',
56
+ description: 'Remove a reaction from a message',
57
+ action: 'Remove a reaction',
58
+ },
59
+ {
60
+ name: 'Contacts: Get Contacts',
61
+ value: 'getContacts',
62
+ description: 'Get the list of contacts for the account',
63
+ action: 'Get contacts',
64
+ },
65
+ {
66
+ name: 'Groups: Get Groups',
67
+ value: 'getGroups',
68
+ description: 'Get the list of groups for the account',
69
+ action: 'Get groups',
70
+ },
71
+ {
72
+ name: 'Groups: Create Group',
73
+ value: 'createGroup',
74
+ description: 'Create a new Signal group',
75
+ action: 'Create a group',
76
+ },
77
+ {
78
+ name: 'Groups: Update Group',
79
+ value: 'updateGroup',
80
+ description: 'Update a Signal group’s name or members',
81
+ action: 'Update a group',
82
+ },
83
+ ],
84
+ },
85
+ {
86
+ displayName: 'Recipient',
87
+ name: 'recipient',
88
+ type: 'string',
89
+ default: '',
90
+ placeholder: '+1234567890 or groupId',
91
+ description: 'Phone number or group ID to send the message, attachment, or reaction to',
92
+ required: true,
93
+ displayOptions: {
94
+ show: {
95
+ operation: ['sendMessage', 'sendAttachment', 'sendReaction', 'removeReaction'],
96
+ },
97
+ },
98
+ },
99
+ {
100
+ displayName: 'Message',
101
+ name: 'message',
102
+ type: 'string',
103
+ default: '',
104
+ description: 'The text message to send (optional for attachments)',
105
+ displayOptions: {
106
+ show: {
107
+ operation: ['sendMessage', 'sendAttachment'],
108
+ },
109
+ },
110
+ },
111
+ {
112
+ displayName: 'Attachment URL',
113
+ name: 'attachmentUrl',
114
+ type: 'string',
115
+ default: '',
116
+ placeholder: 'https://example.com/image.jpg',
117
+ description: 'URL of the file or image to send (e.g., PNG, JPG, PDF, MP3 for voice notes)',
118
+ required: true,
119
+ displayOptions: {
120
+ show: {
121
+ operation: ['sendAttachment'],
122
+ },
123
+ },
124
+ },
125
+ {
126
+ displayName: 'Group ID',
127
+ name: 'groupId',
128
+ type: 'string',
129
+ default: '',
130
+ placeholder: 'group.XXXXXXXXXXXXXXXXXXXXXXXXXX==',
131
+ description: 'ID of the group to update',
132
+ required: true,
133
+ displayOptions: {
134
+ show: {
135
+ operation: ['updateGroup'],
136
+ },
137
+ },
138
+ },
139
+ {
140
+ displayName: 'Group Name',
141
+ name: 'groupName',
142
+ type: 'string',
143
+ default: '',
144
+ description: 'Name of the new or updated group',
145
+ required: false,
146
+ displayOptions: {
147
+ show: {
148
+ operation: ['createGroup', 'updateGroup'],
149
+ },
150
+ },
151
+ },
152
+ {
153
+ displayName: 'Group Members',
154
+ name: 'groupMembers',
155
+ type: 'string',
156
+ default: '',
157
+ placeholder: '+1234567890,+0987654321',
158
+ description: 'Comma-separated list of phone numbers to add to the group',
159
+ required: false,
160
+ displayOptions: {
161
+ show: {
162
+ operation: ['createGroup', 'updateGroup'],
163
+ },
164
+ },
165
+ },
166
+ {
167
+ displayName: 'Emoji',
168
+ name: 'emoji',
169
+ type: 'options',
170
+ default: '👍',
171
+ description: 'Emoji to send as a reaction (select or enter custom emoji)',
172
+ required: true,
173
+ typeOptions: {
174
+ allowCustom: true,
175
+ },
176
+ options: [
177
+ {
178
+ name: 'Thumbs Up',
179
+ value: '👍',
180
+ },
181
+ {
182
+ name: 'Heart',
183
+ value: '❤️',
184
+ },
185
+ {
186
+ name: 'Smile',
187
+ value: '😄',
188
+ },
189
+ {
190
+ name: 'Sad',
191
+ value: '😢',
192
+ },
193
+ {
194
+ name: 'Angry',
195
+ value: '😣',
196
+ },
197
+ {
198
+ name: 'Star',
199
+ value: '⭐',
200
+ },
201
+ {
202
+ name: 'Fire',
203
+ value: '🔥',
204
+ },
205
+ {
206
+ name: 'Plus',
207
+ value: '➕',
208
+ },
209
+ {
210
+ name: 'Minus',
211
+ value: '➖',
212
+ },
213
+ {
214
+ name: 'Handshake',
215
+ value: '🤝',
216
+ },
217
+ ],
218
+ displayOptions: {
219
+ show: {
220
+ operation: ['sendReaction'],
221
+ },
222
+ },
223
+ },
224
+ {
225
+ displayName: 'Target Author',
226
+ name: 'targetAuthor',
227
+ type: 'string',
228
+ default: '',
229
+ placeholder: '+1234567890',
230
+ description: 'Phone number of the message author to react to',
231
+ required: true,
232
+ displayOptions: {
233
+ show: {
234
+ operation: ['sendReaction', 'removeReaction'],
235
+ },
236
+ },
237
+ },
238
+ {
239
+ displayName: 'Target Message Timestamp',
240
+ name: 'targetSentTimestamp',
241
+ type: 'number',
242
+ default: 0,
243
+ description: 'Timestamp of the message to react to (in milliseconds)',
244
+ required: true,
245
+ displayOptions: {
246
+ show: {
247
+ operation: ['sendReaction', 'removeReaction'],
248
+ },
249
+ },
250
+ },
251
+ {
252
+ displayName: 'Timeout (seconds)',
253
+ name: 'timeout',
254
+ type: 'number',
255
+ default: 60,
256
+ description: 'Request timeout in seconds (set higher for Get Groups, e.g., 300)',
257
+ displayOptions: {
258
+ show: {
259
+ operation: ['sendMessage', 'sendAttachment', 'sendReaction', 'removeReaction', 'getContacts', 'getGroups', 'createGroup', 'updateGroup'],
260
+ },
261
+ },
262
+ typeOptions: {
263
+ minValue: 1,
264
+ maxValue: 600,
265
+ },
266
+ hint: 'Increase for slow operations like Get Groups (recommended: 300 for Get Groups)',
267
+ },
268
+ ],
269
+ };
270
+ }
271
+ async execute() {
272
+ const items = this.getInputData();
273
+ const returnData = [];
274
+ const operation = this.getNodeParameter('operation', 0);
275
+ const credentials = await this.getCredentials('signalApi');
276
+ const apiUrl = credentials.apiUrl;
277
+ const apiToken = credentials.apiToken;
278
+ const phoneNumber = credentials.phoneNumber;
279
+ for (let i = 0; i < items.length; i++) {
280
+ const timeout = this.getNodeParameter('timeout', i, operation === 'getGroups' ? 300 : 60) * 1000;
281
+ const params = {
282
+ recipient: this.getNodeParameter('recipient', i, ''),
283
+ message: this.getNodeParameter('message', i, ''),
284
+ attachmentUrl: this.getNodeParameter('attachmentUrl', i, ''),
285
+ groupId: this.getNodeParameter('groupId', i, ''),
286
+ groupName: this.getNodeParameter('groupName', i, ''),
287
+ groupMembers: this.getNodeParameter('groupMembers', i, ''),
288
+ emoji: this.getNodeParameter('emoji', i, ''),
289
+ targetAuthor: this.getNodeParameter('targetAuthor', i, ''),
290
+ targetSentTimestamp: this.getNodeParameter('targetSentTimestamp', i, 0),
291
+ timeout,
292
+ apiUrl,
293
+ apiToken,
294
+ phoneNumber,
295
+ };
296
+ try {
297
+ if (['sendMessage', 'sendAttachment', 'sendReaction', 'removeReaction'].includes(operation)) {
298
+ const result = await messages_1.executeMessagesOperation.call(this, operation, i, params);
299
+ returnData.push(result);
300
+ }
301
+ else if (['getGroups', 'createGroup', 'updateGroup'].includes(operation)) {
302
+ const result = await groups_1.executeGroupsOperation.call(this, operation, i, params);
303
+ returnData.push(result);
304
+ }
305
+ else if (operation === 'getContacts') {
306
+ const result = await contacts_1.executeContactsOperation.call(this, operation, i, params);
307
+ returnData.push(result);
308
+ }
309
+ }
310
+ catch (error) {
311
+ throw error;
312
+ }
313
+ }
314
+ return [returnData];
315
+ }
316
+ }
317
+ exports.Signal = Signal;
@@ -0,0 +1,5 @@
1
+ import { INodeType, INodeTypeDescription, ITriggerFunctions, ITriggerResponse } from 'n8n-workflow';
2
+ export declare class SignalTrigger implements INodeType {
3
+ description: INodeTypeDescription;
4
+ trigger(this: ITriggerFunctions): Promise<ITriggerResponse>;
5
+ }
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SignalTrigger = void 0;
4
+ const ws_1 = require("ws");
5
+ class SignalTrigger {
6
+ constructor() {
7
+ this.description = {
8
+ displayName: 'Signal Trigger',
9
+ name: 'signalTrigger',
10
+ icon: 'file:signal.svg',
11
+ group: ['trigger'],
12
+ version: 1,
13
+ description: 'Triggers on new Signal messages via signal-cli-rest-api WebSocket',
14
+ defaults: {
15
+ name: 'Signal Trigger',
16
+ },
17
+ inputs: [],
18
+ outputs: ['main'],
19
+ credentials: [
20
+ {
21
+ name: 'signalApi',
22
+ required: true,
23
+ },
24
+ ],
25
+ properties: [
26
+ {
27
+ displayName: 'Reconnect Delay (seconds)',
28
+ name: 'reconnectDelay',
29
+ type: 'number',
30
+ default: 5,
31
+ description: 'Delay before reconnecting on close (in seconds)',
32
+ typeOptions: {
33
+ minValue: 1,
34
+ maxValue: 60,
35
+ },
36
+ },
37
+ {
38
+ displayName: 'Only With Text',
39
+ name: 'onlyWithText',
40
+ type: 'boolean',
41
+ default: true,
42
+ description: 'Retrieve only messages with text content',
43
+ },
44
+ {
45
+ displayName: 'Only With Attachments',
46
+ name: 'onlyWithAttachments',
47
+ type: 'boolean',
48
+ default: false,
49
+ description: 'Retrieve only messages with attachments',
50
+ },
51
+ {
52
+ displayName: 'Only With Reactions',
53
+ name: 'onlyWithReactions',
54
+ type: 'boolean',
55
+ default: false,
56
+ description: 'Retrieve only messages with reactions',
57
+ },
58
+ ],
59
+ };
60
+ }
61
+ async trigger() {
62
+ const credentials = await this.getCredentials('signalApi');
63
+ const apiUrl = credentials.apiUrl;
64
+ const apiToken = credentials.apiToken;
65
+ const phoneNumber = credentials.phoneNumber;
66
+ const reconnectDelay = this.getNodeParameter('reconnectDelay', 0) * 1000;
67
+ const onlyWithText = this.getNodeParameter('onlyWithText', 0);
68
+ const onlyWithAttachments = this.getNodeParameter('onlyWithAttachments', 0);
69
+ const onlyWithReactions = this.getNodeParameter('onlyWithReactions', 0);
70
+ const wsUrl = `${apiUrl.replace('http', 'ws')}/v1/receive/${phoneNumber}`;
71
+ const processedMessages = new Set();
72
+ const maxMessages = 1000;
73
+ const connectWebSocket = () => {
74
+ const ws = new ws_1.WebSocket(wsUrl, {
75
+ headers: apiToken ? { Authorization: `Bearer ${apiToken}` } : {},
76
+ });
77
+ ws.on('message', (data) => {
78
+ var _a, _b, _c, _d, _e;
79
+ try {
80
+ const message = JSON.parse(data.toString());
81
+ this.logger.debug(`SignalTrigger: Received message: ${JSON.stringify(message, null, 2)}`);
82
+ const timestamp = ((_a = message.envelope) === null || _a === void 0 ? void 0 : _a.timestamp) || 0;
83
+ if (processedMessages.has(timestamp)) {
84
+ this.logger.debug(`SignalTrigger: Skipping duplicate message with timestamp ${timestamp}`);
85
+ return;
86
+ }
87
+ if (processedMessages.size >= maxMessages) {
88
+ processedMessages.clear();
89
+ }
90
+ processedMessages.add(timestamp);
91
+ const dataMsg = ((_b = message.envelope) === null || _b === void 0 ? void 0 : _b.dataMessage) || ((_d = (_c = message.envelope) === null || _c === void 0 ? void 0 : _c.syncMessage) === null || _d === void 0 ? void 0 : _d.sentMessage) || {};
92
+ const processedMessage = {
93
+ messageText: dataMsg.message || '',
94
+ attachments: dataMsg.attachments || [],
95
+ reactions: dataMsg.reactions || [],
96
+ sourceNumber: ((_e = message.envelope) === null || _e === void 0 ? void 0 : _e.sourceNumber) || '',
97
+ timestamp: timestamp,
98
+ account: message.account || '',
99
+ };
100
+ // Ігнорувати події без вмісту
101
+ if (!processedMessage.messageText &&
102
+ processedMessage.attachments.length === 0 &&
103
+ processedMessage.reactions.length === 0) {
104
+ this.logger.debug(`SignalTrigger: Skipping empty message with timestamp ${timestamp}`);
105
+ return;
106
+ }
107
+ // Фільтрація за параметрами
108
+ if ((onlyWithText && !processedMessage.messageText) ||
109
+ (onlyWithAttachments && processedMessage.attachments.length === 0) ||
110
+ (onlyWithReactions && processedMessage.reactions.length === 0)) {
111
+ this.logger.debug(`SignalTrigger: Skipping filtered message with timestamp ${timestamp}`);
112
+ return;
113
+ }
114
+ const returnData = {
115
+ json: processedMessage,
116
+ };
117
+ this.emit([this.helpers.returnJsonArray([returnData])]);
118
+ this.logger.debug(`SignalTrigger: Emitted message with timestamp ${timestamp}`);
119
+ }
120
+ catch (error) {
121
+ this.logger.error('SignalTrigger: Error parsing message', { error });
122
+ }
123
+ });
124
+ ws.on('error', (error) => {
125
+ this.logger.error('SignalTrigger: WebSocket error', { error });
126
+ setTimeout(connectWebSocket, reconnectDelay);
127
+ });
128
+ ws.on('close', (code, reason) => {
129
+ this.logger.debug(`SignalTrigger: WebSocket closed with code ${code}, reason: ${reason.toString()}`);
130
+ setTimeout(connectWebSocket, reconnectDelay);
131
+ });
132
+ return ws;
133
+ };
134
+ const ws = connectWebSocket();
135
+ return new Promise((resolve, reject) => {
136
+ ws.on('open', () => {
137
+ this.logger.debug(`SignalTrigger: Connected to ${wsUrl}`);
138
+ resolve({
139
+ closeFunction: async () => {
140
+ ws.close();
141
+ this.logger.debug('SignalTrigger: WebSocket closed');
142
+ },
143
+ });
144
+ });
145
+ ws.on('error', (error) => {
146
+ this.logger.error('SignalTrigger: WebSocket connection failed', { error });
147
+ setTimeout(connectWebSocket, reconnectDelay);
148
+ });
149
+ });
150
+ }
151
+ }
152
+ exports.SignalTrigger = SignalTrigger;
@@ -0,0 +1,9 @@
1
+ import { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
2
+ interface OperationParams {
3
+ timeout: number;
4
+ apiUrl: string;
5
+ apiToken: string;
6
+ phoneNumber: string;
7
+ }
8
+ export declare function executeContactsOperation(this: IExecuteFunctions, operation: string, itemIndex: number, params: OperationParams): Promise<INodeExecutionData>;
9
+ export {};
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.executeContactsOperation = void 0;
7
+ const n8n_workflow_1 = require("n8n-workflow");
8
+ const axios_1 = __importDefault(require("axios"));
9
+ async function executeContactsOperation(operation, itemIndex, params) {
10
+ var _a, _b, _c, _d;
11
+ const { timeout, apiUrl, apiToken, phoneNumber } = params;
12
+ const axiosConfig = {
13
+ headers: apiToken ? { Authorization: `Bearer ${apiToken}` } : {},
14
+ timeout,
15
+ };
16
+ const retryRequest = async (request, retries = 2, delay = 5000) => {
17
+ for (let attempt = 1; attempt <= retries; attempt++) {
18
+ try {
19
+ return await request();
20
+ }
21
+ catch (error) {
22
+ if (attempt === retries)
23
+ throw error;
24
+ await new Promise(resolve => setTimeout(resolve, delay));
25
+ }
26
+ }
27
+ };
28
+ try {
29
+ if (operation === 'getContacts') {
30
+ const response = await retryRequest(() => axios_1.default.get(`${apiUrl}/v1/contacts/${phoneNumber}`, axiosConfig));
31
+ return { json: response.data, pairedItem: { item: itemIndex } };
32
+ }
33
+ throw new n8n_workflow_1.NodeApiError(this.getNode(), { message: 'Unknown operation' });
34
+ }
35
+ catch (error) {
36
+ const axiosError = error;
37
+ throw new n8n_workflow_1.NodeApiError(this.getNode(), {
38
+ message: axiosError.message,
39
+ description: (((_b = (_a = axiosError.response) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.error) || axiosError.message),
40
+ httpCode: ((_d = (_c = axiosError.response) === null || _c === void 0 ? void 0 : _c.status) === null || _d === void 0 ? void 0 : _d.toString()) || 'unknown',
41
+ }, { itemIndex });
42
+ }
43
+ }
44
+ exports.executeContactsOperation = executeContactsOperation;
@@ -0,0 +1,12 @@
1
+ import { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
2
+ interface OperationParams {
3
+ groupName?: string;
4
+ groupMembers?: string;
5
+ groupId?: string;
6
+ timeout: number;
7
+ apiUrl: string;
8
+ apiToken: string;
9
+ phoneNumber: string;
10
+ }
11
+ export declare function executeGroupsOperation(this: IExecuteFunctions, operation: string, itemIndex: number, params: OperationParams): Promise<INodeExecutionData>;
12
+ export {};
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.executeGroupsOperation = void 0;
7
+ const n8n_workflow_1 = require("n8n-workflow");
8
+ const axios_1 = __importDefault(require("axios"));
9
+ async function executeGroupsOperation(operation, itemIndex, params) {
10
+ var _a, _b, _c, _d;
11
+ const { groupName, groupMembers, groupId, timeout, apiUrl, apiToken, phoneNumber } = params;
12
+ const axiosConfig = {
13
+ headers: apiToken ? { Authorization: `Bearer ${apiToken}` } : {},
14
+ timeout,
15
+ };
16
+ const retryRequest = async (request, retries = 2, delay = 5000) => {
17
+ for (let attempt = 1; attempt <= retries; attempt++) {
18
+ try {
19
+ return await request();
20
+ }
21
+ catch (error) {
22
+ if (attempt === retries)
23
+ throw error;
24
+ await new Promise(resolve => setTimeout(resolve, delay));
25
+ }
26
+ }
27
+ };
28
+ try {
29
+ if (operation === 'getGroups') {
30
+ const response = await retryRequest(() => axios_1.default.get(`${apiUrl}/v1/groups/${phoneNumber}`, axiosConfig));
31
+ return { json: response.data, pairedItem: { item: itemIndex } };
32
+ }
33
+ else if (operation === 'createGroup') {
34
+ const members = (groupMembers === null || groupMembers === void 0 ? void 0 : groupMembers.split(',').map(member => member.trim())) || [];
35
+ const response = await retryRequest(() => axios_1.default.post(`${apiUrl}/v1/groups/${phoneNumber}`, {
36
+ name: groupName,
37
+ members,
38
+ }, axiosConfig));
39
+ return { json: response.data, pairedItem: { item: itemIndex } };
40
+ }
41
+ else if (operation === 'updateGroup') {
42
+ const body = {};
43
+ if (groupName)
44
+ body.name = groupName;
45
+ if (groupMembers)
46
+ body.members = groupMembers.split(',').map(member => member.trim());
47
+ const response = await retryRequest(() => axios_1.default.put(`${apiUrl}/v1/groups/${phoneNumber}/${groupId}`, body, axiosConfig));
48
+ return { json: response.data, pairedItem: { item: itemIndex } };
49
+ }
50
+ throw new n8n_workflow_1.NodeApiError(this.getNode(), { message: 'Unknown operation' });
51
+ }
52
+ catch (error) {
53
+ const axiosError = error;
54
+ throw new n8n_workflow_1.NodeApiError(this.getNode(), {
55
+ message: axiosError.message,
56
+ description: (((_b = (_a = axiosError.response) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.error) || axiosError.message),
57
+ httpCode: ((_d = (_c = axiosError.response) === null || _c === void 0 ? void 0 : _c.status) === null || _d === void 0 ? void 0 : _d.toString()) || 'unknown',
58
+ }, { itemIndex });
59
+ }
60
+ }
61
+ exports.executeGroupsOperation = executeGroupsOperation;
@@ -0,0 +1,15 @@
1
+ import { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
2
+ interface OperationParams {
3
+ recipient?: string;
4
+ message?: string;
5
+ attachmentUrl?: string;
6
+ emoji?: string;
7
+ targetAuthor?: string;
8
+ targetSentTimestamp?: number;
9
+ timeout: number;
10
+ apiUrl: string;
11
+ apiToken: string;
12
+ phoneNumber: string;
13
+ }
14
+ export declare function executeMessagesOperation(this: IExecuteFunctions, operation: string, itemIndex: number, params: OperationParams): Promise<INodeExecutionData>;
15
+ export {};
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.executeMessagesOperation = void 0;
7
+ const n8n_workflow_1 = require("n8n-workflow");
8
+ const axios_1 = __importDefault(require("axios"));
9
+ async function executeMessagesOperation(operation, itemIndex, params) {
10
+ var _a, _b, _c, _d;
11
+ const { recipient, message, attachmentUrl, emoji, targetAuthor, targetSentTimestamp, timeout, apiUrl, apiToken, phoneNumber } = params;
12
+ const axiosConfig = {
13
+ headers: apiToken ? { Authorization: `Bearer ${apiToken}` } : {},
14
+ timeout,
15
+ };
16
+ const retryRequest = async (request, retries = 2, delay = 5000) => {
17
+ for (let attempt = 1; attempt <= retries; attempt++) {
18
+ try {
19
+ return await request();
20
+ }
21
+ catch (error) {
22
+ if (attempt === retries)
23
+ throw error;
24
+ await new Promise(resolve => setTimeout(resolve, delay));
25
+ }
26
+ }
27
+ };
28
+ try {
29
+ if (operation === 'sendMessage') {
30
+ const response = await retryRequest(() => axios_1.default.post(`${apiUrl}/v1/send`, {
31
+ message,
32
+ number: phoneNumber,
33
+ recipients: [recipient],
34
+ }, axiosConfig));
35
+ return { json: response.data, pairedItem: { item: itemIndex } };
36
+ }
37
+ else if (operation === 'sendAttachment') {
38
+ const response = await retryRequest(() => axios_1.default.post(`${apiUrl}/v1/send`, {
39
+ message,
40
+ number: phoneNumber,
41
+ recipients: [recipient],
42
+ attachments: [attachmentUrl],
43
+ }, axiosConfig));
44
+ return { json: response.data, pairedItem: { item: itemIndex } };
45
+ }
46
+ else if (operation === 'sendReaction') {
47
+ const response = await retryRequest(() => axios_1.default.post(`${apiUrl}/v1/reactions/${phoneNumber}`, {
48
+ reaction: emoji,
49
+ recipient,
50
+ target_author: targetAuthor,
51
+ timestamp: targetSentTimestamp,
52
+ }, axiosConfig));
53
+ return { json: response.data, pairedItem: { item: itemIndex } };
54
+ }
55
+ else if (operation === 'removeReaction') {
56
+ const response = await retryRequest(() => axios_1.default.delete(`${apiUrl}/v1/reactions/${phoneNumber}`, {
57
+ ...axiosConfig,
58
+ data: {
59
+ recipient,
60
+ target_author: targetAuthor,
61
+ timestamp: targetSentTimestamp,
62
+ },
63
+ }));
64
+ return { json: response.data || { status: 'Reaction removed' }, pairedItem: { item: itemIndex } };
65
+ }
66
+ throw new n8n_workflow_1.NodeApiError(this.getNode(), { message: 'Unknown operation' });
67
+ }
68
+ catch (error) {
69
+ const axiosError = error;
70
+ throw new n8n_workflow_1.NodeApiError(this.getNode(), {
71
+ message: axiosError.message,
72
+ description: (((_b = (_a = axiosError.response) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.error) || axiosError.message),
73
+ httpCode: ((_d = (_c = axiosError.response) === null || _c === void 0 ? void 0 : _c.status) === null || _d === void 0 ? void 0 : _d.toString()) || 'unknown',
74
+ }, { itemIndex });
75
+ }
76
+ }
77
+ exports.executeMessagesOperation = executeMessagesOperation;
package/package.json CHANGED
@@ -1,17 +1,24 @@
1
1
  {
2
2
  "name": "n8n-nodes-signal-cli-rest-api",
3
- "version": "0.2.1",
4
- "description": "Signal Node for n8n",
3
+ "version": "0.2.2",
4
+ "description": "Signal Node for n8n using signal-cli-rest-api",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/ZBlaZe/n8n-nodes-signal.git"
8
8
  },
9
- "keywords": ["n8n-community", "signal", "messaging"],
9
+ "keywords": ["n8n-community-node-package", "signal", "messaging", "n8n", "automation"],
10
10
  "license": "MIT",
11
11
  "main": "index.js",
12
+ "files": [
13
+ "dist/**/*",
14
+ "package.json",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
12
18
  "scripts": {
13
19
  "build": "tsc && copy nodes\\Signal\\signal.svg dist\\nodes\\Signal\\signal.svg && copy credentials\\signal.svg dist\\credentials\\signal.svg",
14
- "dev": "tsc --watch"
20
+ "dev": "tsc --watch",
21
+ "prepublishOnly": "npm run build"
15
22
  },
16
23
  "n8n": {
17
24
  "n8nNodesApiVersion": 1,
@@ -19,18 +26,22 @@
19
26
  "dist/nodes/Signal/Signal.node.js",
20
27
  "dist/nodes/Signal/SignalTrigger.node.js"
21
28
  ],
22
- "credentials": ["./dist/credentials/SignalApi.credentials.js"]
29
+ "credentials": ["dist/credentials/SignalApi.credentials.js"]
23
30
  },
24
31
  "dependencies": {
25
- "axios": "1.7.2",
26
- "n8n-core": "1.111.0",
27
- "n8n-workflow": "1.109.0",
28
- "@types/node": "20.12.12",
32
+ "axios": "^1.7.2",
29
33
  "ws": "^8.18.0"
30
34
  },
35
+ "peerDependencies": {
36
+ "n8n-workflow": ">=1.109.0"
37
+ },
31
38
  "devDependencies": {
32
- "typescript": "5.4.5",
33
- "ts-node": "10.9.2",
39
+ "n8n-workflow": "1.109.0",
40
+ "typescript": "^5.4.5",
41
+ "@types/node": "^20.12.12",
34
42
  "@types/ws": "^8.5.12"
43
+ },
44
+ "engines": {
45
+ "node": ">=18.0.0"
35
46
  }
36
47
  }
package/.editorconfig DELETED
@@ -1,20 +0,0 @@
1
- root = true
2
-
3
- [*]
4
- charset = utf-8
5
- indent_style = tab
6
- indent_size = 2
7
- end_of_line = lf
8
- insert_final_newline = true
9
- trim_trailing_whitespace = true
10
-
11
- [package.json]
12
- indent_style = space
13
- indent_size = 2
14
-
15
- [*.md]
16
- trim_trailing_whitespace = false
17
-
18
- [*.yml]
19
- indent_style = space
20
- indent_size = 2
package/.eslintrc.js DELETED
@@ -1,146 +0,0 @@
1
- /**
2
- * @type {import('@types/eslint').ESLint.ConfigData}
3
- */
4
- module.exports = {
5
- root: true,
6
-
7
- env: {
8
- browser: true,
9
- es6: true,
10
- node: true,
11
- },
12
-
13
- parser: '@typescript-eslint/parser',
14
-
15
- parserOptions: {
16
- project: ['./tsconfig.json'],
17
- sourceType: 'module',
18
- extraFileExtensions: ['.json'],
19
- },
20
-
21
- ignorePatterns: ['.eslintrc.js', '**/*.js', '**/node_modules/**', '**/dist/**'],
22
-
23
- overrides: [
24
- {
25
- files: ['package.json'],
26
- plugins: ['eslint-plugin-n8n-nodes-base'],
27
- extends: ['plugin:n8n-nodes-base/community'],
28
- rules: {
29
- 'n8n-nodes-base/community-package-json-name-still-default': 'off',
30
- },
31
- },
32
- {
33
- files: ['./credentials/**/*.ts'],
34
- plugins: ['eslint-plugin-n8n-nodes-base'],
35
- extends: ['plugin:n8n-nodes-base/credentials'],
36
- rules: {
37
- 'n8n-nodes-base/cred-class-field-authenticate-type-assertion': 'error',
38
- 'n8n-nodes-base/cred-class-field-display-name-missing-oauth2': 'error',
39
- 'n8n-nodes-base/cred-class-field-display-name-miscased': 'error',
40
- 'n8n-nodes-base/cred-class-field-documentation-url-missing': 'error',
41
- 'n8n-nodes-base/cred-class-field-documentation-url-miscased': 'off',
42
- 'n8n-nodes-base/cred-class-field-name-missing-oauth2': 'error',
43
- 'n8n-nodes-base/cred-class-field-name-unsuffixed': 'error',
44
- 'n8n-nodes-base/cred-class-field-name-uppercase-first-char': 'error',
45
- 'n8n-nodes-base/cred-class-field-properties-assertion': 'error',
46
- 'n8n-nodes-base/cred-class-field-type-options-password-missing': 'error',
47
- 'n8n-nodes-base/cred-class-name-missing-oauth2-suffix': 'error',
48
- 'n8n-nodes-base/cred-class-name-unsuffixed': 'error',
49
- 'n8n-nodes-base/cred-filename-against-convention': 'error',
50
- },
51
- },
52
- {
53
- files: ['./nodes/**/*.ts'],
54
- plugins: ['eslint-plugin-n8n-nodes-base'],
55
- extends: ['plugin:n8n-nodes-base/nodes'],
56
- rules: {
57
- 'n8n-nodes-base/node-class-description-credentials-name-unsuffixed': 'error',
58
- 'n8n-nodes-base/node-class-description-display-name-unsuffixed-trigger-node': 'error',
59
- 'n8n-nodes-base/node-class-description-empty-string': 'error',
60
- 'n8n-nodes-base/node-class-description-icon-not-svg': 'error',
61
- 'n8n-nodes-base/node-class-description-inputs-wrong-regular-node': 'off',
62
- 'n8n-nodes-base/node-class-description-inputs-wrong-trigger-node': 'error',
63
- 'n8n-nodes-base/node-class-description-missing-subtitle': 'error',
64
- 'n8n-nodes-base/node-class-description-non-core-color-present': 'error',
65
- 'n8n-nodes-base/node-class-description-name-miscased': 'error',
66
- 'n8n-nodes-base/node-class-description-name-unsuffixed-trigger-node': 'error',
67
- 'n8n-nodes-base/node-class-description-outputs-wrong': 'off',
68
- 'n8n-nodes-base/node-dirname-against-convention': 'error',
69
- 'n8n-nodes-base/node-execute-block-double-assertion-for-items': 'error',
70
- 'n8n-nodes-base/node-execute-block-wrong-error-thrown': 'error',
71
- 'n8n-nodes-base/node-filename-against-convention': 'error',
72
- 'n8n-nodes-base/node-param-array-type-assertion': 'error',
73
- 'n8n-nodes-base/node-param-color-type-unused': 'error',
74
- 'n8n-nodes-base/node-param-default-missing': 'error',
75
- 'n8n-nodes-base/node-param-default-wrong-for-boolean': 'error',
76
- 'n8n-nodes-base/node-param-default-wrong-for-collection': 'error',
77
- 'n8n-nodes-base/node-param-default-wrong-for-fixed-collection': 'error',
78
- 'n8n-nodes-base/node-param-default-wrong-for-fixed-collection': 'error',
79
- 'n8n-nodes-base/node-param-default-wrong-for-multi-options': 'error',
80
- 'n8n-nodes-base/node-param-default-wrong-for-number': 'error',
81
- 'n8n-nodes-base/node-param-default-wrong-for-simplify': 'error',
82
- 'n8n-nodes-base/node-param-default-wrong-for-string': 'error',
83
- 'n8n-nodes-base/node-param-description-boolean-without-whether': 'error',
84
- 'n8n-nodes-base/node-param-description-comma-separated-hyphen': 'error',
85
- 'n8n-nodes-base/node-param-description-empty-string': 'error',
86
- 'n8n-nodes-base/node-param-description-excess-final-period': 'error',
87
- 'n8n-nodes-base/node-param-description-excess-inner-whitespace': 'error',
88
- 'n8n-nodes-base/node-param-description-identical-to-display-name': 'error',
89
- 'n8n-nodes-base/node-param-description-line-break-html-tag': 'error',
90
- 'n8n-nodes-base/node-param-description-lowercase-first-char': 'error',
91
- 'n8n-nodes-base/node-param-description-miscased-id': 'error',
92
- 'n8n-nodes-base/node-param-description-miscased-json': 'error',
93
- 'n8n-nodes-base/node-param-description-miscased-url': 'error',
94
- 'n8n-nodes-base/node-param-description-missing-final-period': 'error',
95
- 'n8n-nodes-base/node-param-description-missing-for-ignore-ssl-issues': 'error',
96
- 'n8n-nodes-base/node-param-description-missing-for-return-all': 'error',
97
- 'n8n-nodes-base/node-param-description-missing-for-simplify': 'error',
98
- 'n8n-nodes-base/node-param-description-missing-from-dynamic-multi-options': 'error',
99
- 'n8n-nodes-base/node-param-description-missing-from-dynamic-options': 'error',
100
- 'n8n-nodes-base/node-param-description-missing-from-limit': 'error',
101
- 'n8n-nodes-base/node-param-description-unencoded-angle-brackets': 'error',
102
- 'n8n-nodes-base/node-param-description-unneeded-backticks': 'error',
103
- 'n8n-nodes-base/node-param-description-untrimmed': 'error',
104
- 'n8n-nodes-base/node-param-description-url-missing-protocol': 'error',
105
- 'n8n-nodes-base/node-param-description-weak': 'error',
106
- 'n8n-nodes-base/node-param-description-wrong-for-dynamic-multi-options': 'error',
107
- 'n8n-nodes-base/node-param-description-wrong-for-dynamic-options': 'error',
108
- 'n8n-nodes-base/node-param-description-wrong-for-ignore-ssl-issues': 'error',
109
- 'n8n-nodes-base/node-param-description-wrong-for-limit': 'error',
110
- 'n8n-nodes-base/node-param-description-wrong-for-return-all': 'error',
111
- 'n8n-nodes-base/node-param-description-wrong-for-simplify': 'error',
112
- 'n8n-nodes-base/node-param-description-wrong-for-upsert': 'error',
113
- 'n8n-nodes-base/node-param-display-name-excess-inner-whitespace': 'error',
114
- 'n8n-nodes-base/node-param-display-name-miscased-id': 'error',
115
- 'n8n-nodes-base/node-param-display-name-miscased': 'error',
116
- 'n8n-nodes-base/node-param-display-name-not-first-position': 'error',
117
- 'n8n-nodes-base/node-param-display-name-untrimmed': 'error',
118
- 'n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options': 'error',
119
- 'n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options': 'error',
120
- 'n8n-nodes-base/node-param-display-name-wrong-for-simplify': 'error',
121
- 'n8n-nodes-base/node-param-display-name-wrong-for-update-fields': 'error',
122
- 'n8n-nodes-base/node-param-min-value-wrong-for-limit': 'error',
123
- 'n8n-nodes-base/node-param-multi-options-type-unsorted-items': 'error',
124
- 'n8n-nodes-base/node-param-name-untrimmed': 'error',
125
- 'n8n-nodes-base/node-param-operation-option-action-wrong-for-get-many': 'error',
126
- 'n8n-nodes-base/node-param-operation-option-description-wrong-for-get-many': 'error',
127
- 'n8n-nodes-base/node-param-operation-option-without-action': 'error',
128
- 'n8n-nodes-base/node-param-operation-without-no-data-expression': 'error',
129
- 'n8n-nodes-base/node-param-option-description-identical-to-name': 'error',
130
- 'n8n-nodes-base/node-param-option-name-containing-star': 'error',
131
- 'n8n-nodes-base/node-param-option-name-duplicate': 'error',
132
- 'n8n-nodes-base/node-param-option-name-wrong-for-get-many': 'error',
133
- 'n8n-nodes-base/node-param-option-name-wrong-for-upsert': 'error',
134
- 'n8n-nodes-base/node-param-option-value-duplicate': 'error',
135
- 'n8n-nodes-base/node-param-options-type-unsorted-items': 'error',
136
- 'n8n-nodes-base/node-param-placeholder-miscased-id': 'error',
137
- 'n8n-nodes-base/node-param-placeholder-missing-email': 'error',
138
- 'n8n-nodes-base/node-param-required-false': 'error',
139
- 'n8n-nodes-base/node-param-resource-with-plural-option': 'error',
140
- 'n8n-nodes-base/node-param-resource-without-no-data-expression': 'error',
141
- 'n8n-nodes-base/node-param-type-options-missing-from-limit': 'error',
142
- 'n8n-nodes-base/node-param-type-options-password-missing': 'error',
143
- },
144
- },
145
- ],
146
- };
@@ -1,16 +0,0 @@
1
- /**
2
- * @type {import('@types/eslint').ESLint.ConfigData}
3
- */
4
- module.exports = {
5
- extends: "./.eslintrc.js",
6
-
7
- overrides: [
8
- {
9
- files: ['package.json'],
10
- plugins: ['eslint-plugin-n8n-nodes-base'],
11
- rules: {
12
- 'n8n-nodes-base/community-package-json-name-still-default': 'error',
13
- },
14
- },
15
- ],
16
- };
package/.prettierrc.js DELETED
@@ -1,51 +0,0 @@
1
- module.exports = {
2
- /**
3
- * https://prettier.io/docs/en/options.html#semicolons
4
- */
5
- semi: true,
6
-
7
- /**
8
- * https://prettier.io/docs/en/options.html#trailing-commas
9
- */
10
- trailingComma: 'all',
11
-
12
- /**
13
- * https://prettier.io/docs/en/options.html#bracket-spacing
14
- */
15
- bracketSpacing: true,
16
-
17
- /**
18
- * https://prettier.io/docs/en/options.html#tabs
19
- */
20
- useTabs: true,
21
-
22
- /**
23
- * https://prettier.io/docs/en/options.html#tab-width
24
- */
25
- tabWidth: 2,
26
-
27
- /**
28
- * https://prettier.io/docs/en/options.html#arrow-function-parentheses
29
- */
30
- arrowParens: 'always',
31
-
32
- /**
33
- * https://prettier.io/docs/en/options.html#quotes
34
- */
35
- singleQuote: true,
36
-
37
- /**
38
- * https://prettier.io/docs/en/options.html#quote-props
39
- */
40
- quoteProps: 'as-needed',
41
-
42
- /**
43
- * https://prettier.io/docs/en/options.html#end-of-line
44
- */
45
- endOfLine: 'lf',
46
-
47
- /**
48
- * https://prettier.io/docs/en/options.html#print-width
49
- */
50
- printWidth: 100,
51
- };
@@ -1,76 +0,0 @@
1
- # Contributor Covenant Code of Conduct
2
-
3
- ## Our Pledge
4
-
5
- In the interest of fostering an open and welcoming environment, we as
6
- contributors and maintainers pledge to making participation in our project and
7
- our community a harassment-free experience for everyone, regardless of age, body
8
- size, disability, ethnicity, sex characteristics, gender identity and expression,
9
- level of experience, education, socio-economic status, nationality, personal
10
- appearance, race, religion, or sexual identity and orientation.
11
-
12
- ## Our Standards
13
-
14
- Examples of behavior that contributes to creating a positive environment
15
- include:
16
-
17
- * Using welcoming and inclusive language
18
- * Being respectful of differing viewpoints and experiences
19
- * Gracefully accepting constructive criticism
20
- * Focusing on what is best for the community
21
- * Showing empathy towards other community members
22
-
23
- Examples of unacceptable behavior by participants include:
24
-
25
- * The use of sexualized language or imagery and unwelcome sexual attention or
26
- advances
27
- * Trolling, insulting/derogatory comments, and personal or political attacks
28
- * Public or private harassment
29
- * Publishing others' private information, such as a physical or electronic
30
- address, without explicit permission
31
- * Other conduct which could reasonably be considered inappropriate in a
32
- professional setting
33
-
34
- ## Our Responsibilities
35
-
36
- Project maintainers are responsible for clarifying the standards of acceptable
37
- behavior and are expected to take appropriate and fair corrective action in
38
- response to any instances of unacceptable behavior.
39
-
40
- Project maintainers have the right and responsibility to remove, edit, or
41
- reject comments, commits, code, wiki edits, issues, and other contributions
42
- that are not aligned to this Code of Conduct, or to ban temporarily or
43
- permanently any contributor for other behaviors that they deem inappropriate,
44
- threatening, offensive, or harmful.
45
-
46
- ## Scope
47
-
48
- This Code of Conduct applies both within project spaces and in public spaces
49
- when an individual is representing the project or its community. Examples of
50
- representing a project or community include using an official project e-mail
51
- address, posting via an official social media account, or acting as an appointed
52
- representative at an online or offline event. Representation of a project may be
53
- further defined and clarified by project maintainers.
54
-
55
- ## Enforcement
56
-
57
- Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
- reported by contacting the project team at jan@n8n.io. All
59
- complaints will be reviewed and investigated and will result in a response that
60
- is deemed necessary and appropriate to the circumstances. The project team is
61
- obligated to maintain confidentiality with regard to the reporter of an incident.
62
- Further details of specific enforcement policies may be posted separately.
63
-
64
- Project maintainers who do not follow or enforce the Code of Conduct in good
65
- faith may face temporary or permanent repercussions as determined by other
66
- members of the project's leadership.
67
-
68
- ## Attribution
69
-
70
- This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
- available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72
-
73
- [homepage]: https://www.contributor-covenant.org
74
-
75
- For answers to common questions about this code of conduct, see
76
- https://www.contributor-covenant.org/faq
File without changes
File without changes