diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fdda219 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +indent_style = tab +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[package.json] +indent_style = space +indent_size = 2 + +[*.yml] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffb419d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +.DS_Store +.tmp +tmp +dist +npm-debug.log* +package-lock.json +yarn.lock \ No newline at end of file diff --git a/CONTRIBUTOR_LICENSE_AGREEMENT.md b/CONTRIBUTOR_LICENSE_AGREEMENT.md new file mode 100644 index 0000000..104e263 --- /dev/null +++ b/CONTRIBUTOR_LICENSE_AGREEMENT.md @@ -0,0 +1,5 @@ +# n8n Contributor License Agreement + +I give n8n permission to license my contributions on any terms they like. I am giving them this license in order to make it possible for them to accept my contributions into their project. + +***As far as the law allows, my contributions come as is, without any warranty or condition, and I will not be liable to anyone for any damages related to this software or this license, under any kind of legal claim.*** diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..b3aadc2 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,230 @@ +“Commons Clause” License Condition v1.0 + +The Software is provided to you by the Licensor under the +License, as defined below, subject to the following condition. + +Without limiting other conditions in the License, the grant +of rights under the License will not include, and the License +does not grant to you, the right to Sell the Software. + +For purposes of the foregoing, “Sell” means practicing any or +all of the rights granted to you under the License to provide +to third parties, for a fee or other consideration (including +without limitation fees for hosting or consulting/ support +services related to the Software), a product or service whose +value derives, entirely or substantially, from the functionality +of the Software. Any license notice or attribution required by +the License must also include this Commons Clause License +Condition notice. + +Software: n8n + +License: Apache 2.0 + +Licensor: Jan Oberhauser + + +--------------------------------------------------------------------- + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..400e48c --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ + +# ⭕ n8n-nodes-twitter-dynamic-auth + + + +![n8n.io - Workflow Automation](https://n8n.io/_nuxt/img/n8n-logo-press.9d4dfc1.png) + + +>**Twitter node with dynamic auth credentials for n8n.** + +Current node of twitter inside n8n has a credential method that let's us to connect directly a twitter account from n8n. In this case we don't want to connect and implement twitter operation from a single account but from different user accounts. To implement this we have the `access_token` and `access_secret` for each user and we can send request to twitter API against these users. + +This custom node has now 4 fields for authentication credentials: + + ◉ Consumer Key + ◉ Consumer Secret + ◉ Access Token + ◉ Access Secret + +These field values can be dynamically set to send request for dynamic users. + +## How to run and install + +>Run & Test Locally + +Once you've downloaded/cloned the repo, you need to build the code and publish the package locally to test it. Run the following commands: + + +### Install dependencies + npm install + +### Build the code + npm run build + +### "Publish" the package locally + npm link + +NOTE: If you get permission errors, run the command as a root user with sudo, for example sudo npm link. + +## n8n Guide +[Creating n8n-nodes-module](https://docs.n8n.io/integrations/creating-nodes/code/create-n8n-nodes-module/) + +## License + +[Apache 2.0 with Commons Clause](https://github.com/n8n-io/n8n/blob/master/packages/nodes-base/LICENSE.md) \ No newline at end of file diff --git a/credentials/TwitterOAuth1DynamicApi.credentials.ts b/credentials/TwitterOAuth1DynamicApi.credentials.ts new file mode 100644 index 0000000..ee14a0e --- /dev/null +++ b/credentials/TwitterOAuth1DynamicApi.credentials.ts @@ -0,0 +1,46 @@ +import { + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class TwitterOAuth1DynamicApi implements ICredentialType { + name = 'twitterOAuth1DynamicApi'; + displayName = 'Twitter OAuth Dynamic API'; + documentationUrl = 'twitter'; + properties: INodeProperties[] = [ + { + displayName: 'Consumer Key', + name: 'consumerKey', + type: 'string', + default: '', + required: true, + }, + { + displayName: 'Consumer Secret', + name: 'consumerSecret', + type: 'string', + default: '', + required: true, + }, + { + displayName: 'Access Token', + name: 'accessToken', + type: 'string', + default: '', + required: true, + }, + { + displayName: 'Access Secret', + name: 'accessSecret', + type: 'string', + default: '', + required: true, + }, + { + displayName: 'Signature Method', + name: 'signatureMethod', + type: 'hidden', + default: 'HMAC-SHA1', + }, + ]; +} diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..546bb43 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,11 @@ +const { src, dest } = require('gulp'); + +function copyIcons() { + src('nodes/**/*.{png,svg}') + .pipe(dest('dist/nodes')) + + return src('credentials/**/*.{png,svg}') + .pipe(dest('dist/credentials')); +} + +exports.default = copyIcons; \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..e69de29 diff --git a/nodes/TwitterDynamicAuth/DirectMessageDescription.ts b/nodes/TwitterDynamicAuth/DirectMessageDescription.ts new file mode 100644 index 0000000..46ba74f --- /dev/null +++ b/nodes/TwitterDynamicAuth/DirectMessageDescription.ts @@ -0,0 +1,98 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const directMessageOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'directMessage', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a direct message', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +]; + +export const directMessageFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* directMessage:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'User ID', + name: 'userId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'directMessage', + ], + }, + }, + description: 'The ID of the user who should receive the direct message', + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + required: true, + default: '', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'directMessage', + ], + }, + }, + description: 'The text of your Direct Message. URL encode as necessary. Max length of 10,000 characters.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'directMessage', + ], + }, + }, + options: [ + { + displayName: 'Attachment', + name: 'attachment', + type: 'string', + default: 'data', + description: 'Name of the binary property which contain data that should be added to the direct message as attachment', + }, + ], + }, +]; diff --git a/nodes/TwitterDynamicAuth/GenericFunctions.ts b/nodes/TwitterDynamicAuth/GenericFunctions.ts new file mode 100644 index 0000000..cfed448 --- /dev/null +++ b/nodes/TwitterDynamicAuth/GenericFunctions.ts @@ -0,0 +1,182 @@ +import { + OptionsWithUrl, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IBinaryKeyData, + IDataObject, + INodeExecutionData, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; +import { requestOAuth1Dynamic } from '../requestHelper/requestOAuth1Dynamic'; + +export async function twitterApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IHookFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + let options: OptionsWithUrl = { + method, + body, + qs, + url: uri || `https://api.twitter.com/1.1${resource}`, + json: true, + }; + try { + if (Object.keys(option).length !== 0) { + options = Object.assign({}, options, option); + } + if (Object.keys(body).length === 0) { + delete options.body; + } + if (Object.keys(qs).length === 0) { + delete options.qs; + } + //@ts-ignore + return await requestOAuth1Dynamic.call(this, 'twitterOAuth1DynamicApi', options); + } catch (error: any) { + throw new NodeApiError(this.getNode(), error); + } +} + +export async function twitterApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, propertyName: string, method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + + query.count = 100; + + do { + responseData = await twitterApiRequest.call(this, method, endpoint, body, query); + query.since_id = responseData.search_metadata.max_id; + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + responseData.search_metadata && + responseData.search_metadata.next_results + ); + + return returnData; +} + +export function chunks(buffer: Buffer, chunkSize: number) { + const result = []; + const len = buffer.length; + let i = 0; + + while (i < len) { + result.push(buffer.slice(i, i += chunkSize)); + } + + return result; +} + +export async function uploadAttachments(this: IExecuteFunctions, binaryProperties: string[], items: INodeExecutionData[], i: number) { + + const uploadUri = 'https://upload.twitter.com/1.1/media/upload.json'; + + const media: IDataObject[] = []; + + for (const binaryPropertyName of binaryProperties) { + + const binaryData = items[i].binary as IBinaryKeyData; + + if (binaryData === undefined) { + throw new NodeOperationError(this.getNode(), 'No binary data set. So file can not be written!'); + } + + if (!binaryData[binaryPropertyName]) { + continue; + } + + let attachmentBody = {}; + let response: IDataObject = {}; + + const dataBuffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); + const isAnimatedWebp = (dataBuffer.toString().indexOf('ANMF') !== -1); + + const isImage = binaryData[binaryPropertyName].mimeType.includes('image'); + + if (isImage && isAnimatedWebp) { + throw new NodeOperationError(this.getNode(), 'Animated .webp images are not supported use .gif instead'); + } + + if (isImage) { + + const attachmentBody = { + media_data: binaryData[binaryPropertyName].data, + }; + + response = await twitterApiRequest.call(this, 'POST', '', {}, {}, uploadUri, { form: attachmentBody }); + + media.push(response); + + } else { + + // https://developer.twitter.com/en/docs/media/upload-media/api-reference/post-media-upload-init + + const dataBuffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); + + attachmentBody = { + command: 'INIT', + total_bytes: dataBuffer.byteLength, + media_type: binaryData[binaryPropertyName].mimeType, + }; + + response = await twitterApiRequest.call(this, 'POST', '', {}, {}, uploadUri, { form: attachmentBody }); + + const mediaId = response.media_id_string; + + // break the data on 5mb chunks (max size that can be uploaded at once) + + const binaryParts = chunks(dataBuffer, 5242880); + + let index = 0; + + for (const binaryPart of binaryParts) { + + //https://developer.twitter.com/en/docs/media/upload-media/api-reference/post-media-upload-append + + attachmentBody = { + name: binaryData[binaryPropertyName].fileName, + command: 'APPEND', + media_id: mediaId, + media_data: Buffer.from(binaryPart).toString('base64'), + segment_index: index, + }; + + response = await twitterApiRequest.call(this, 'POST', '', {}, {}, uploadUri, { form: attachmentBody }); + + index++; + } + + //https://developer.twitter.com/en/docs/media/upload-media/api-reference/post-media-upload-finalize + + attachmentBody = { + command: 'FINALIZE', + media_id: mediaId, + }; + + response = await twitterApiRequest.call(this, 'POST', '', {}, {}, uploadUri, { form: attachmentBody }); + + // data has not been uploaded yet, so wait for it to be ready + if (response.processing_info) { + const { check_after_secs } = (response.processing_info as IDataObject); + await new Promise((resolve, reject) => { + setTimeout(() => { + // @ts-ignore + resolve(); + }, (check_after_secs as number) * 1000); + }); + } + + media.push(response); + } + + return media; + } +} diff --git a/nodes/TwitterDynamicAuth/TweetDescription.ts b/nodes/TwitterDynamicAuth/TweetDescription.ts new file mode 100644 index 0000000..a53d417 --- /dev/null +++ b/nodes/TwitterDynamicAuth/TweetDescription.ts @@ -0,0 +1,480 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const tweetOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'tweet', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create or reply a tweet', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a tweet', + }, + { + name: 'Search', + value: 'search', + description: 'Search tweets', + }, + { + name: 'Like', + value: 'like', + description: 'Like a tweet', + }, + { + name: 'Retweet', + value: 'retweet', + description: 'Retweet a tweet', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +]; + +export const tweetFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* tweet:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Text', + name: 'text', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + required: true, + default: '', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'tweet', + ], + }, + }, + description: 'The text of the status update. URL encode as necessary. t.co link wrapping will affect character counts.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'tweet', + ], + }, + }, + options: [ + { + displayName: 'Attachments', + name: 'attachments', + type: 'string', + default: 'data', + description: 'Name of the binary properties which contain data which should be added to tweet as attachment. Multiple ones can be comma-separated.', + }, + { + displayName: 'Display Coordinates', + name: 'displayCoordinates', + type: 'boolean', + default: false, + description: 'Whether or not to put a pin on the exact coordinates a Tweet has been sent from', + }, + { + displayName: 'In Reply to Tweet', + name: 'inReplyToStatusId', + type: 'string', + default: '', + description: 'The ID of an existing status that the update is in reply to', + }, + { + displayName: 'Location', + name: 'locationFieldsUi', + type: 'fixedCollection', + placeholder: 'Add Location', + default: {}, + description: 'Subscriber location information.n', + options: [ + { + name: 'locationFieldsValues', + displayName: 'Location', + values: [ + { + displayName: 'Latitude', + name: 'latitude', + type: 'string', + required: true, + description: 'The location latitude', + default: '', + }, + { + displayName: 'Longitude', + name: 'longitude', + type: 'string', + required: true, + description: 'The location longitude', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Possibly Sensitive', + name: 'possiblySensitive', + type: 'boolean', + default: false, + description: 'If you upload Tweet media that might be considered sensitive content such as nudity, or medical procedures, you must set this value to true', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* tweet:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Tweet ID', + name: 'tweetId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + operation: [ + 'delete', + ], + resource: [ + 'tweet', + ], + }, + }, + description: 'The ID of the tweet to delete', + }, + + /* -------------------------------------------------------------------------- */ + /* tweet:search */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Search Text', + name: 'searchText', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + required: true, + default: '', + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'tweet', + ], + }, + }, + description: 'A UTF-8, URL-encoded search query of 500 characters maximum, including operators. Queries may additionally be limited by complexity. Check the searching examples here.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'tweet', + ], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'tweet', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + }, + default: 50, + description: 'Max number of results to return', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'tweet', + ], + }, + }, + options: [ + { + displayName: 'Include Entities', + name: 'includeEntities', + type: 'boolean', + default: false, + description: 'The entities node will not be included when set to false', + }, + { + displayName: 'Language', + name: 'lang', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getLanguages', + }, + default: '', + description: 'Restricts tweets to the given language, given by an ISO 639-1 code. Language detection is best-effort.', + }, + { + displayName: 'Location', + name: 'locationFieldsUi', + type: 'fixedCollection', + placeholder: 'Add Location', + default: {}, + description: 'Subscriber location information.n', + options: [ + { + name: 'locationFieldsValues', + displayName: 'Location', + values: [ + { + displayName: 'Latitude', + name: 'latitude', + type: 'string', + required: true, + description: 'The location latitude', + default: '', + }, + { + displayName: 'Longitude', + name: 'longitude', + type: 'string', + required: true, + description: 'The location longitude', + default: '', + }, + { + displayName: 'Radius', + name: 'radius', + type: 'options', + options: [ + { + name: 'Milles', + value: 'mi', + }, + { + name: 'Kilometers', + value: 'km', + }, + ], + required: true, + description: 'Returns tweets by users located within a given radius of the given latitude/longitude', + default: '', + }, + { + displayName: 'Distance', + name: 'distance', + type: 'number', + typeOptions: { + minValue: 0, + }, + required: true, + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Result Type', + name: 'resultType', + type: 'options', + options: [ + { + name: 'Mixed', + value: 'mixed', + description: 'Include both popular and real time results in the response', + }, + { + name: 'Recent', + value: 'recent', + description: 'Return only the most recent results in the response', + }, + { + name: 'Popular', + value: 'popular', + description: 'Return only the most popular results in the response', + }, + ], + default: 'mixed', + description: 'Specifies what type of search results you would prefer to receive', + }, + { + displayName: 'Tweet Mode', + name: 'tweetMode', + type: 'options', + options: [ + { + name: 'Compatibility', + value: 'compat', + }, + { + name: 'Extended', + value: 'extended', + }, + ], + default: 'compat', + description: 'When the extended mode is selected, the response contains the entire untruncated text of the Tweet', + }, + { + displayName: 'Until', + name: 'until', + type: 'dateTime', + default: '', + description: 'Returns tweets created before the given date', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* tweet:like */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Tweet ID', + name: 'tweetId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + operation: [ + 'like', + ], + resource: [ + 'tweet', + ], + }, + }, + description: 'The ID of the tweet', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'like', + ], + resource: [ + 'tweet', + ], + }, + }, + options: [ + { + displayName: 'Include Entities', + name: 'includeEntities', + type: 'boolean', + default: false, + description: 'The entities will be omitted when set to false', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* tweet:retweet */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Tweet ID', + name: 'tweetId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + operation: [ + 'retweet', + ], + resource: [ + 'tweet', + ], + }, + }, + description: 'The ID of the tweet', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'retweet', + ], + resource: [ + 'tweet', + ], + }, + }, + options: [ + { + displayName: 'Trim User', + name: 'trimUser', + type: 'boolean', + default: false, + description: 'When set to either true, each tweet returned in a timeline will include a user object including only the status authors numerical ID', + }, + ], + }, +]; diff --git a/nodes/TwitterDynamicAuth/TweetInterface.ts b/nodes/TwitterDynamicAuth/TweetInterface.ts new file mode 100644 index 0000000..fee6046 --- /dev/null +++ b/nodes/TwitterDynamicAuth/TweetInterface.ts @@ -0,0 +1,10 @@ +export interface ITweet { + auto_populate_reply_metadata?: boolean; + display_coordinates?: boolean; + lat?: number; + long?: number; + media_ids?: string; + possibly_sensitive?: boolean; + status: string; + in_reply_to_status_id?: string; +} diff --git a/nodes/TwitterDynamicAuth/Twitter.node.json b/nodes/TwitterDynamicAuth/Twitter.node.json new file mode 100644 index 0000000..159fac4 --- /dev/null +++ b/nodes/TwitterDynamicAuth/Twitter.node.json @@ -0,0 +1,37 @@ +{ + "node": "n8n-nodes-base.twitterDynamicAuth", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Marketing & Content" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/twitter" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.twitter/" + } + ], + "generic": [ + { + "label": "6 e-commerce workflows to power up your Shopify s", + "icon": "store", + "url": "https://n8n.io/blog/no-code-ecommerce-workflow-automations/" + }, + { + "label": "Automate your data processing pipeline in 9 steps", + "icon": "⚙️", + "url": "https://n8n.io/blog/automate-your-data-processing-pipeline-in-9-steps-with-n8n/" + }, + { + "label": "5 workflow automations for Mattermost that we love at n8n", + "icon": "🤖", + "url": "https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/" + } + ] + } +} \ No newline at end of file diff --git a/nodes/TwitterDynamicAuth/TwitterDynamicAuth.node.ts b/nodes/TwitterDynamicAuth/TwitterDynamicAuth.node.ts new file mode 100644 index 0000000..4171332 --- /dev/null +++ b/nodes/TwitterDynamicAuth/TwitterDynamicAuth.node.ts @@ -0,0 +1,293 @@ + +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + directMessageFields, + directMessageOperations, +} from './DirectMessageDescription'; + +import { + tweetFields, + tweetOperations, +} from './TweetDescription'; + +import { + twitterApiRequest, + twitterApiRequestAllItems, + uploadAttachments, +} from './GenericFunctions'; + +import { + ITweet, +} from './TweetInterface'; + +const ISO6391 = require('iso-639-1'); + +export class TwitterDynamicAuth implements INodeType { + description: INodeTypeDescription = { + displayName: 'TwitterDynamicAuth', + name: 'TwitterDynamicAuth', + icon: 'file:twitter.svg', + group: ['input', 'output'], + version: 1, + description: 'Consume Twitter API using dynamic auth', + subtitle: '={{$parameter["operation"] + ":" + $parameter["resource"]}}', + defaults: { + name: 'TwitterDynamicAuth', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'twitterOAuth1DynamicApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Direct Message', + value: 'directMessage', + }, + { + name: 'Tweet', + value: 'tweet', + }, + ], + default: 'tweet', + description: 'The resource to operate on.', + }, + // DIRECT MESSAGE + ...directMessageOperations, + ...directMessageFields, + // TWEET + ...tweetOperations, + ...tweetFields, + ], + }; + + methods = { + loadOptions: { + // Get all the available languages to display them to user so that he can + // select them easily + async getLanguages(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const languages = ISO6391.getAllNames(); + for (const language of languages) { + const languageName = language; + const languageId = ISO6391.getCode(language); + returnData.push({ + name: languageName, + value: languageId, + }); + } + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = items.length; + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + for (let i = 0; i < length; i++) { + try { + if (resource === 'directMessage') { + //https://developer.twitter.com/en/docs/twitter-api/v1/direct-messages/sending-and-receiving/api-reference/new-event + if (operation === 'create') { + const userId = this.getNodeParameter('userId', i) as string; + const text = this.getNodeParameter('text', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const body: IDataObject = { + type: 'message_create', + message_create: { + target: { + recipient_id: userId, + }, + message_data: { + text, + attachment: {}, + }, + }, + }; + + if (additionalFields.attachment) { + const attachment = additionalFields.attachment as string; + + const attachmentProperties: string[] = attachment.split(',').map((propertyName) => { + return propertyName.trim(); + }); + + const medias = await uploadAttachments.call(this, attachmentProperties, items, i); + //@ts-ignore + body.message_create.message_data.attachment = { type: 'media', media: { id: medias[0].media_id_string } }; + } else { + //@ts-ignore + delete body.message_create.message_data.attachment; + } + + responseData = await twitterApiRequest.call(this, 'POST', '/direct_messages/events/new.json', { event: body }); + + responseData = responseData.event; + } + } + if (resource === 'tweet') { + // https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-update + if (operation === 'create') { + const text = this.getNodeParameter('text', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const body: ITweet = { + status: text, + }; + + if (additionalFields.inReplyToStatusId) { + body.in_reply_to_status_id = additionalFields.inReplyToStatusId as string; + body.auto_populate_reply_metadata = true; + } + + if (additionalFields.attachments) { + + const attachments = additionalFields.attachments as string; + + const attachmentProperties: string[] = attachments.split(',').map((propertyName) => { + return propertyName.trim(); + }); + + const medias = await uploadAttachments.call(this, attachmentProperties, items, i); + + body.media_ids = (medias as IDataObject[]).map((media: IDataObject) => media.media_id_string).join(','); + } + + if (additionalFields.possiblySensitive) { + body.possibly_sensitive = additionalFields.possiblySensitive as boolean; + } + + if (additionalFields.displayCoordinates) { + body.display_coordinates = additionalFields.displayCoordinates as boolean; + } + + if (additionalFields.locationFieldsUi) { + const locationUi = additionalFields.locationFieldsUi as IDataObject; + if (locationUi.locationFieldsValues) { + const values = locationUi.locationFieldsValues as IDataObject; + body.lat = parseFloat(values.latitude as string); + body.long = parseFloat(values.longitude as string); + } + } + + responseData = await twitterApiRequest.call(this, 'POST', '/statuses/update.json', {}, body as unknown as IDataObject); + } + // https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-destroy-id + if (operation === 'delete') { + const tweetId = this.getNodeParameter('tweetId', i) as string; + + responseData = await twitterApiRequest.call(this, 'POST', `/statuses/destroy/${tweetId}.json`, {}, {}); + } + // https://developer.twitter.com/en/docs/tweets/search/api-reference/get-search-tweets + if (operation === 'search') { + const q = this.getNodeParameter('searchText', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const qs: IDataObject = { + q, + }; + + if (additionalFields.includeEntities) { + qs.include_entities = additionalFields.includeEntities as boolean; + } + + if (additionalFields.resultType) { + qs.response_type = additionalFields.resultType as string; + } + + if (additionalFields.until) { + qs.until = additionalFields.until as string; + } + + if (additionalFields.lang) { + qs.lang = additionalFields.lang as string; + } + + if (additionalFields.locationFieldsUi) { + const locationUi = additionalFields.locationFieldsUi as IDataObject; + if (locationUi.locationFieldsValues) { + const values = locationUi.locationFieldsValues as IDataObject; + qs.geocode = `${values.latitude as string},${values.longitude as string},${values.distance}${values.radius}`; + } + } + + qs.tweet_mode = additionalFields.tweetMode || 'compat'; + + if (returnAll) { + responseData = await twitterApiRequestAllItems.call(this, 'statuses', 'GET', '/search/tweets.json', {}, qs); + } else { + qs.count = this.getNodeParameter('limit', 0) as number; + responseData = await twitterApiRequest.call(this, 'GET', '/search/tweets.json', {}, qs); + responseData = responseData.statuses; + } + } + //https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-favorites-create + if (operation === 'like') { + const tweetId = this.getNodeParameter('tweetId', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const qs: IDataObject = { + id: tweetId, + }; + + if (additionalFields.includeEntities) { + qs.include_entities = additionalFields.includeEntities as boolean; + } + + responseData = await twitterApiRequest.call(this, 'POST', '/favorites/create.json', {}, qs); + } + //https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-retweet-id + if (operation === 'retweet') { + const tweetId = this.getNodeParameter('tweetId', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const qs: IDataObject = { + id: tweetId, + }; + + if (additionalFields.trimUser) { + qs.trim_user = additionalFields.trimUser as boolean; + } + + responseData = await twitterApiRequest.call(this, 'POST', `/statuses/retweet/${tweetId}.json`, {}, qs); + } + } + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else if (responseData !== undefined) { + returnData.push(responseData as IDataObject); + } + } catch (error: any) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/nodes/TwitterDynamicAuth/twitter.svg b/nodes/TwitterDynamicAuth/twitter.svg new file mode 100644 index 0000000..59c4d9b --- /dev/null +++ b/nodes/TwitterDynamicAuth/twitter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nodes/requestHelper/requestOAuth1Dynamic.ts b/nodes/requestHelper/requestOAuth1Dynamic.ts new file mode 100644 index 0000000..7857bf7 --- /dev/null +++ b/nodes/requestHelper/requestOAuth1Dynamic.ts @@ -0,0 +1,70 @@ +import { IResponseError } from "n8n-core"; +import { IAllExecuteFunctions, IHttpRequestOptions } from "n8n-workflow"; +import { OptionsWithUri, OptionsWithUrl } from "request-promise-native"; +import requestPromise = require("request-promise-native"); +import clientOAuth1, { Token } from 'oauth-1.0a'; +import { createHmac } from 'crypto'; + +export async function requestOAuth1Dynamic( + this: IAllExecuteFunctions, + credentialsType: string, + requestOptions: + | OptionsWithUrl + | OptionsWithUri + | requestPromise.RequestPromiseOptions + | IHttpRequestOptions, + isN8nRequest = false, +) { + const credentials = await this.getCredentials(credentialsType); + + if (credentials === undefined) { + throw new Error('No credentials were returned!'); + } + + // if (credentials.oauthTokenData === undefined) { + // throw new Error('OAuth credentials not connected!'); + // } + + const oauth = new clientOAuth1({ + consumer: { + key: credentials.consumerKey as string, + secret: credentials.consumerSecret as string, + }, + signature_method: credentials.signatureMethod as string, + hash_function(base: any, key: any) { + const algorithm = credentials.signatureMethod === 'HMAC-SHA1' ? 'sha1' : 'sha256'; + return createHmac(algorithm, key).update(base).digest('base64'); + }, + }); + + // const oauthTokenData = credentials.oauthTokenData as IDataObject; + + const token: Token = { + key: credentials.accessToken as string, + secret: credentials.accessSecret as string, + }; + + // @ts-ignore + requestOptions.data = { ...requestOptions.qs, ...requestOptions.form }; + + // Fixes issue that OAuth1 library only works with "url" property and not with "uri" + // @ts-ignore + if (requestOptions.uri && !requestOptions.url) { + // @ts-ignore + requestOptions.url = requestOptions.uri; + // @ts-ignore + delete requestOptions.uri; + } + + // @ts-ignore + requestOptions.headers = oauth.toHeader(oauth.authorize(requestOptions, token)); + + if (isN8nRequest) { + return this.helpers.httpRequest(requestOptions as IHttpRequestOptions); + } + + return this.helpers.request!(requestOptions).catch(async (error: IResponseError) => { + // Unknown error so simply throw it + throw error; + }); +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..0cfa454 --- /dev/null +++ b/package.json @@ -0,0 +1,70 @@ +{ + "name": "n8n-nodes-twitter-dynamic-auth", + "version": "0.1.0", + "description": "Twitter node with dynamic auth credentials for n8n.", + "license": "SEE LICENSE IN LICENSE.md", + "homepage": "https://n8n.io", + "author": { + "name": "MD Rayhan Talukder", + "email": "trrayhan@gmail.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/rayhantr/n8n-nodes-twitter-dynamic-auth" + }, + "main": "index.js", + "scripts": { + "dev": "npm run watch", + "build": "tsc && gulp", + "lint": "tslint -p tsconfig.json -c tslint.json", + "lintfix": "tslint --fix -p tsconfig.json -c tslint.json", + "nodelinter": "nodelinter", + "watch": "tsc --watch", + "test": "jest" + }, + "files": [ + "dist" + ], + "n8n": { + "credentials": [ + "dist/credentials/TwitterOAuth1DynamicApi.credentials.js" + ], + "nodes": [ + "dist/nodes/TwitterDynamicAuth/TwitterDynamicAuth.node.js" + ] + }, + "devDependencies": { + "@types/express": "^4.17.6", + "@types/jest": "^26.0.13", + "@types/node": "^14.17.27", + "@types/request-promise-native": "~1.0.15", + "gulp": "^4.0.0", + "jest": "^26.4.2", + "n8n-workflow": "~0.83.0", + "nodelinter": "^0.1.9", + "ts-jest": "^26.3.0", + "tslint": "^6.1.2", + "typescript": "~4.3.5" + }, + "dependencies": { + "iso-639-1": "^2.1.13", + "n8n-core": "~0.101.0" + }, + "jest": { + "transform": { + "^.+\\.tsx?$": "ts-jest" + }, + "testURL": "http://localhost/", + "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", + "testPathIgnorePatterns": [ + "/dist/", + "/node_modules/" + ], + "moduleFileExtensions": [ + "ts", + "tsx", + "js", + "json" + ] + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..45d8d8f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "lib": [ + "es2017", + "es2019.array" + ], + "types": [ + "node", + "jest" + ], + "module": "commonjs", + "noImplicitAny": true, + "removeComments": true, + "strictNullChecks": true, + "strict": true, + "preserveConstEnums": true, + "resolveJsonModule": true, + "declaration": true, + "outDir": "./dist/", + "target": "es2017", + "sourceMap": true, + "esModuleInterop": true + }, + "include": [ + "credentials/**/*", + "src/**/*", + "nodes/**/*", + "nodes/**/*.json", + "test/**/*" + ], + "exclude": [ + "**/*.spec.ts" + ] +} \ No newline at end of file diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..859893d --- /dev/null +++ b/tslint.json @@ -0,0 +1,124 @@ +{ + "linterOptions": { + "exclude": [ + "node_modules/**/*" + ] + }, + "defaultSeverity": "error", + "jsRules": {}, + "rules": { + "array-type": [ + true, + "array-simple" + ], + "arrow-return-shorthand": true, + "ban": [ + true, + { + "name": "Array", + "message": "tsstyle#array-constructor" + } + ], + "ban-types": [ + true, + [ + "Object", + "Use {} instead." + ], + [ + "String", + "Use 'string' instead." + ], + [ + "Number", + "Use 'number' instead." + ], + [ + "Boolean", + "Use 'boolean' instead." + ] + ], + "class-name": true, + "curly": [ + true, + "ignore-same-line" + ], + "forin": true, + "jsdoc-format": true, + "label-position": true, + "indent": [true, "tabs", 2], + "member-access": [ + true, + "no-public" + ], + "new-parens": true, + "no-angle-bracket-type-assertion": true, + "no-any": true, + "no-arg": true, + "no-conditional-assignment": true, + "no-construct": true, + "no-debugger": true, + "no-default-export": true, + "no-duplicate-variable": true, + "no-inferrable-types": true, + "ordered-imports": [true, { + "import-sources-order": "any", + "named-imports-order": "case-insensitive" + }], + "no-namespace": [ + true, + "allow-declarations" + ], + "no-reference": true, + "no-string-throw": true, + "no-unused-expression": true, + "no-var-keyword": true, + "object-literal-shorthand": true, + "only-arrow-functions": [ + true, + "allow-declarations", + "allow-named-functions" + ], + "prefer-const": true, + "radix": true, + "semicolon": [ + true, + "always", + "ignore-bound-class-methods" + ], + "switch-default": true, + "trailing-comma": [ + true, + { + "multiline": { + "objects": "always", + "arrays": "always", + "functions": "always", + "typeLiterals": "ignore" + }, + "esSpecCompliant": true + } + ], + "triple-equals": [ + true, + "allow-null-check" + ], + "use-isnan": true, + "quotemark": [ + true, + "single" + ], + "quotes": [ + "error", + "single" + ], + "variable-name": [ + true, + "check-format", + "ban-keywords", + "allow-leading-underscore", + "allow-trailing-underscore" + ] + }, + "rulesDirectory": [] +} \ No newline at end of file