From 42721dba80077fb796086a2bf0ecce256bf3a50f Mon Sep 17 00:00:00 2001 From: Giulio Andreini Date: Wed, 28 Jun 2023 12:19:25 +0200 Subject: [PATCH] feat(Twitter Node): Node overhaul (#4788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * First node set up. * Progress: all Resources and Operations updated * Upsates to all resources. * Updated tooltip for Tweet > Search > Tweet fields. * Upodate to resource locator items in User > Search. * Added e.g. to placeholders and minor copy tweaks. * Fixed Operations sorting. * Added a couple of operations back. * Removed 'Authorized API Call'. * Remove authorization header when empty * Import pkce * Add OAuth2 with new grant type to Twitter * Add pkce logic auto assign authorization code if pkce not defined * Add pkce to ui and interfaces * Fix scopes for Oauth2 twitter * Deubg + pass it through header * Add debug console, add airtable cred * Remove all console.logs, make PKCE in th body only when it exists * Remove invalid character ~ * Remove more console.logs * remove body inside query * Remove useless grantype check * Hide oauth2 twitter waiting for overhaul * Remove redundant header removal * Remove more console.logs * Add V2 twitter * Add V1 * Fix description V1, V2 * Fix description for V1 * Add Oauth2 request * Add user lookup * Add search username by ID * Search tweet feat * Wip create tweet * Generic function for returning ID * Add like and retweet feat * Add delete tweet feat * Fix Location tweets * Fix type * Feat List add members * Add scopes for dm and list * Add direct message feature * Improve response data * Fix regex * Add unit test to Twitter v2 * Fix unit test * Remove console.logs * Remove more console.logs * Handle @ in username * Minor copy tweaks. * Add return all logic * Add error for api permission error * Update message api error * Add error for date error * Add notice for TwitterOAuth2 api link * Fix display names location * fix List RLC * Fix like endpoint * Fix error message check * fix(core): Fix OAuth2 callback for grantType=clientCredentials * Improve fix for callback * update pnpm * Fix iso time for end time * sync oauth2Credential * remove unused codeVerifer in Server.ts * Add location and attachments notice * Add notice to oauth1 * Improve notice for twitter * moved credentials notice to TwitterOAuth1Api.credentials.ts --------- Co-authored-by: agobrech Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ Co-authored-by: Marcus --- packages/@n8n/client-oauth2/src/CodeFlow.ts | 1 - .../@n8n/client-oauth2/src/CredentialsFlow.ts | 1 - .../TwitterOAuth1Api.credentials.ts | 7 + .../TwitterOAuth2Api.credentials.ts | 73 +++ .../nodes/Twitter/Twitter.node.json | 3 +- .../nodes-base/nodes/Twitter/Twitter.node.ts | 348 +------------ .../{ => V1}/DirectMessageDescription.ts | 0 .../Twitter/{ => V1}/GenericFunctions.ts | 0 .../Twitter/{ => V1}/TweetDescription.ts | 0 .../nodes/Twitter/{ => V1}/TweetInterface.ts | 0 .../nodes/Twitter/V1/TwitterV1.node.ts | 337 ++++++++++++ .../Twitter/V2/DirectMessageDescription.ts | 103 ++++ .../nodes/Twitter/V2/GenericFunctions.ts | 125 +++++ .../nodes/Twitter/V2/ListDescription.ts | 94 ++++ .../nodes/Twitter/V2/TweetDescription.ts | 479 ++++++++++++++++++ .../nodes/Twitter/V2/TweetInterface.ts | 25 + .../nodes/Twitter/V2/TwitterV2.node.ts | 365 +++++++++++++ .../nodes/Twitter/V2/UserDescription.ts | 78 +++ .../nodes/Twitter/test/Twitter.test.ts | 88 ++++ .../test/Workflow_Twitter_UnitTest.json | 284 +++++++++++ packages/nodes-base/package.json | 1 + .../test/nodes/FakeCredentialsMap.ts | 19 + 22 files changed, 2100 insertions(+), 331 deletions(-) create mode 100644 packages/nodes-base/credentials/TwitterOAuth2Api.credentials.ts rename packages/nodes-base/nodes/Twitter/{ => V1}/DirectMessageDescription.ts (100%) rename packages/nodes-base/nodes/Twitter/{ => V1}/GenericFunctions.ts (100%) rename packages/nodes-base/nodes/Twitter/{ => V1}/TweetDescription.ts (100%) rename packages/nodes-base/nodes/Twitter/{ => V1}/TweetInterface.ts (100%) create mode 100644 packages/nodes-base/nodes/Twitter/V1/TwitterV1.node.ts create mode 100644 packages/nodes-base/nodes/Twitter/V2/DirectMessageDescription.ts create mode 100644 packages/nodes-base/nodes/Twitter/V2/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Twitter/V2/ListDescription.ts create mode 100644 packages/nodes-base/nodes/Twitter/V2/TweetDescription.ts create mode 100644 packages/nodes-base/nodes/Twitter/V2/TweetInterface.ts create mode 100644 packages/nodes-base/nodes/Twitter/V2/TwitterV2.node.ts create mode 100644 packages/nodes-base/nodes/Twitter/V2/UserDescription.ts create mode 100644 packages/nodes-base/nodes/Twitter/test/Twitter.test.ts create mode 100644 packages/nodes-base/nodes/Twitter/test/Workflow_Twitter_UnitTest.json diff --git a/packages/@n8n/client-oauth2/src/CodeFlow.ts b/packages/@n8n/client-oauth2/src/CodeFlow.ts index fceb71cdd5..7d3b842329 100644 --- a/packages/@n8n/client-oauth2/src/CodeFlow.ts +++ b/packages/@n8n/client-oauth2/src/CodeFlow.ts @@ -57,7 +57,6 @@ export class CodeFlow { opts?: Partial, ): Promise { const options = { ...this.client.options, ...opts }; - expects(options, 'clientId', 'accessTokenUri'); const url = uri instanceof URL ? uri : new URL(uri, DEFAULT_URL_BASE); diff --git a/packages/@n8n/client-oauth2/src/CredentialsFlow.ts b/packages/@n8n/client-oauth2/src/CredentialsFlow.ts index 1b6eb70e8e..d83450a412 100644 --- a/packages/@n8n/client-oauth2/src/CredentialsFlow.ts +++ b/packages/@n8n/client-oauth2/src/CredentialsFlow.ts @@ -21,7 +21,6 @@ export class CredentialsFlow { */ async getToken(opts?: Partial): Promise { const options = { ...this.client.options, ...opts }; - expects(options, 'clientId', 'clientSecret', 'accessTokenUri'); const body: CredentialsFlowBody = { diff --git a/packages/nodes-base/credentials/TwitterOAuth1Api.credentials.ts b/packages/nodes-base/credentials/TwitterOAuth1Api.credentials.ts index 5a576c89c7..63c50e001b 100644 --- a/packages/nodes-base/credentials/TwitterOAuth1Api.credentials.ts +++ b/packages/nodes-base/credentials/TwitterOAuth1Api.credentials.ts @@ -34,5 +34,12 @@ export class TwitterOAuth1Api implements ICredentialType { type: 'hidden', default: 'HMAC-SHA1', }, + { + displayName: + 'Some operations requires a Basic or a Pro API for more informations see Twitter Api Doc', + name: 'apiPermissioms', + type: 'notice', + default: '', + }, ]; } diff --git a/packages/nodes-base/credentials/TwitterOAuth2Api.credentials.ts b/packages/nodes-base/credentials/TwitterOAuth2Api.credentials.ts new file mode 100644 index 0000000000..e948daad39 --- /dev/null +++ b/packages/nodes-base/credentials/TwitterOAuth2Api.credentials.ts @@ -0,0 +1,73 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +const scopes = [ + 'tweet.read', + 'users.read', + 'tweet.write', + 'tweet.moderate.write', + 'users.read', + 'follows.read', + 'follows.write', + 'offline.access', + 'like.read', + 'like.write', + 'dm.write', + 'dm.read', + 'list.read', + 'list.write', +]; +export class TwitterOAuth2Api implements ICredentialType { + name = 'twitterOAuth2Api'; + + extends = ['oAuth2Api']; + + displayName = 'Twitter OAuth2 API'; + + documentationUrl = 'twitter'; + + properties: INodeProperties[] = [ + { + displayName: + 'Some operations requires a Basic or a Pro API for more informations see Twitter Api Doc', + name: 'apiPermissioms', + type: 'notice', + default: '', + }, + { + displayName: 'Grant Type', + name: 'grantType', + type: 'hidden', + default: 'pkce', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: 'https://twitter.com/i/oauth2/authorize', + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: 'https://api.twitter.com/2/oauth2/token', + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: `${scopes.join(' ')}`, + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden', + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden', + default: 'header', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Twitter/Twitter.node.json b/packages/nodes-base/nodes/Twitter/Twitter.node.json index 7282e9b2ce..9d8fb0b276 100644 --- a/packages/nodes-base/nodes/Twitter/Twitter.node.json +++ b/packages/nodes-base/nodes/Twitter/Twitter.node.json @@ -31,5 +31,6 @@ "url": "https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/" } ] - } + }, + "alias": ["Tweet"] } diff --git a/packages/nodes-base/nodes/Twitter/Twitter.node.ts b/packages/nodes-base/nodes/Twitter/Twitter.node.ts index 4f49f7a414..3e35c6a0f5 100644 --- a/packages/nodes-base/nodes/Twitter/Twitter.node.ts +++ b/packages/nodes-base/nodes/Twitter/Twitter.node.ts @@ -1,335 +1,27 @@ -import type { - IDataObject, - IExecuteFunctions, - ILoadOptionsFunctions, - INodeExecutionData, - INodePropertyOptions, - INodeType, - INodeTypeDescription, - JsonObject, -} from 'n8n-workflow'; +import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow'; +import { VersionedNodeType } from 'n8n-workflow'; -import { directMessageFields, directMessageOperations } from './DirectMessageDescription'; +import { TwitterV1 } from './V1/TwitterV1.node'; -import { tweetFields, tweetOperations } from './TweetDescription'; +import { TwitterV2 } from './V2/TwitterV2.node'; -import { - twitterApiRequest, - twitterApiRequestAllItems, - uploadAttachments, -} from './GenericFunctions'; +export class Twitter extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Twitter', + name: 'twitter', + icon: 'file:twitter.svg', + group: ['output'], + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Twitter API', + defaultVersion: 2, + }; -import type { ITweet, ITweetCreate } from './TweetInterface'; + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new TwitterV1(baseDescription), + 2: new TwitterV2(baseDescription), + }; -import ISO6391 from 'iso-639-1'; - -export class Twitter implements INodeType { - description: INodeTypeDescription = { - displayName: 'Twitter', - name: 'twitter', - icon: 'file:twitter.svg', - group: ['input', 'output'], - version: 1, - description: 'Consume Twitter API', - subtitle: '={{$parameter["operation"] + ":" + $parameter["resource"]}}', - defaults: { - name: 'Twitter', - }, - inputs: ['main'], - outputs: ['main'], - credentials: [ - { - name: 'twitterOAuth1Api', - required: true, - }, - ], - properties: [ - { - displayName: 'Resource', - name: 'resource', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Direct Message', - value: 'directMessage', - }, - { - name: 'Tweet', - value: 'tweet', - }, - ], - default: 'tweet', - }, - // DIRECT MESSAGE - ...directMessageOperations, - ...directMessageFields, - // TWEET - ...tweetOperations, - ...tweetFields, - ], - }; - - methods = { - loadOptions: { - // Get all the available languages to display them to user so that they 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: INodeExecutionData[] = []; - const length = items.length; - let responseData; - const resource = this.getNodeParameter('resource', 0); - const operation = this.getNodeParameter('operation', 0); - 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); - const body: ITweetCreate = { - 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, i); - body.message_create.message_data.attachment = { - type: 'media', - //@ts-ignore - media: { id: medias[0].media_id_string }, - }; - } else { - 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); - 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, 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); - const additionalFields = this.getNodeParameter('additionalFields', i); - 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); - 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); - - 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); - - 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, - ); - } - } - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject[]), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } catch (error) { - if (this.continueOnFail()) { - const executionErrorData = { - json: { - error: (error as JsonObject).message, - }, - }; - returnData.push(executionErrorData); - continue; - } - throw error; - } - } - - return this.prepareOutputData(returnData); + super(nodeVersions, baseDescription); } } diff --git a/packages/nodes-base/nodes/Twitter/DirectMessageDescription.ts b/packages/nodes-base/nodes/Twitter/V1/DirectMessageDescription.ts similarity index 100% rename from packages/nodes-base/nodes/Twitter/DirectMessageDescription.ts rename to packages/nodes-base/nodes/Twitter/V1/DirectMessageDescription.ts diff --git a/packages/nodes-base/nodes/Twitter/GenericFunctions.ts b/packages/nodes-base/nodes/Twitter/V1/GenericFunctions.ts similarity index 100% rename from packages/nodes-base/nodes/Twitter/GenericFunctions.ts rename to packages/nodes-base/nodes/Twitter/V1/GenericFunctions.ts diff --git a/packages/nodes-base/nodes/Twitter/TweetDescription.ts b/packages/nodes-base/nodes/Twitter/V1/TweetDescription.ts similarity index 100% rename from packages/nodes-base/nodes/Twitter/TweetDescription.ts rename to packages/nodes-base/nodes/Twitter/V1/TweetDescription.ts diff --git a/packages/nodes-base/nodes/Twitter/TweetInterface.ts b/packages/nodes-base/nodes/Twitter/V1/TweetInterface.ts similarity index 100% rename from packages/nodes-base/nodes/Twitter/TweetInterface.ts rename to packages/nodes-base/nodes/Twitter/V1/TweetInterface.ts diff --git a/packages/nodes-base/nodes/Twitter/V1/TwitterV1.node.ts b/packages/nodes-base/nodes/Twitter/V1/TwitterV1.node.ts new file mode 100644 index 0000000000..ecb034d23a --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V1/TwitterV1.node.ts @@ -0,0 +1,337 @@ +import type { + IDataObject, + IExecuteFunctions, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, + JsonObject, +} from 'n8n-workflow'; + +import { directMessageFields, directMessageOperations } from './DirectMessageDescription'; + +import { tweetFields, tweetOperations } from './TweetDescription'; + +import { + twitterApiRequest, + twitterApiRequestAllItems, + uploadAttachments, +} from './GenericFunctions'; + +import type { ITweet, ITweetCreate } from './TweetInterface'; + +import ISO6391 from 'iso-639-1'; + +export class TwitterV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDecription: INodeTypeBaseDescription) { + this.description = { + ...baseDecription, + version: 1, + description: 'Consume Twitter API', + subtitle: '={{$parameter["operation"] + ":" + $parameter["resource"]}}', + defaults: { + name: 'Twitter', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'twitterOAuth1Api', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Direct Message', + value: 'directMessage', + }, + { + name: 'Tweet', + value: 'tweet', + }, + ], + default: 'tweet', + }, + // DIRECT MESSAGE + ...directMessageOperations, + ...directMessageFields, + // TWEET + ...tweetOperations, + ...tweetFields, + ], + }; + } + + methods = { + loadOptions: { + // Get all the available languages to display them to user so that they 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: INodeExecutionData[] = []; + const length = items.length; + let responseData; + const resource = this.getNodeParameter('resource', 0); + const operation = this.getNodeParameter('operation', 0); + 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); + const body: ITweetCreate = { + 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, i); + body.message_create.message_data.attachment = { + type: 'media', + //@ts-ignore + media: { id: medias[0].media_id_string }, + }; + } else { + 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); + 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, 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); + const additionalFields = this.getNodeParameter('additionalFields', i); + 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); + 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); + + 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); + + 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, + ); + } + } + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject[]), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + const executionErrorData = { + json: { + error: (error as JsonObject).message, + }, + }; + returnData.push(executionErrorData); + continue; + } + throw error; + } + } + + return this.prepareOutputData(returnData); + } +} diff --git a/packages/nodes-base/nodes/Twitter/V2/DirectMessageDescription.ts b/packages/nodes-base/nodes/Twitter/V2/DirectMessageDescription.ts new file mode 100644 index 0000000000..2e98afede1 --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V2/DirectMessageDescription.ts @@ -0,0 +1,103 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const directMessageOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['directMessage'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Send a direct message to a user', + action: 'Create Direct Message', + }, + ], + default: 'create', + }, +]; + +export const directMessageFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* directMessage:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'User', + name: 'user', + type: 'resourceLocator', + default: { mode: 'username', value: '' }, + required: true, + description: 'The user you want to send the message to', + displayOptions: { + show: { + operation: ['create'], + resource: ['directMessage'], + }, + }, + modes: [ + { + displayName: 'By Username', + name: 'username', + type: 'string', + validation: [], + placeholder: 'e.g. n8n', + url: '', + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1068479892537384960', + url: '', + }, + ], + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + required: true, + default: '', + typeOptions: { + rows: 2, + }, + displayOptions: { + show: { + operation: ['create'], + resource: ['directMessage'], + }, + }, + description: + 'The text of the direct message. URL encoding is required. 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 ID', + name: 'attachments', + type: 'string', + default: '', + placeholder: '1664279886239010824', + description: 'The attachment ID to associate with the message', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Twitter/V2/GenericFunctions.ts b/packages/nodes-base/nodes/Twitter/V2/GenericFunctions.ts new file mode 100644 index 0000000000..2064e05a82 --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V2/GenericFunctions.ts @@ -0,0 +1,125 @@ +import type { OptionsWithUrl } from 'request'; + +import type { + IDataObject, + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, + INodeParameterResourceLocator, + JsonObject, +} from 'n8n-workflow'; +import { NodeApiError, NodeOperationError } from 'n8n-workflow'; + +export async function twitterApiRequest( + this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IHookFunctions, + method: string, + resource: string, + body: IDataObject = {}, + qs: IDataObject = {}, + fullOutput?: boolean, + uri?: string, + option: IDataObject = {}, +) { + let options: OptionsWithUrl = { + method, + body, + qs, + url: uri || `https://api.twitter.com/2${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; + } + if (fullOutput) { + return await this.helpers.requestOAuth2.call(this, 'twitterOAuth2Api', options); + } else { + const { data } = await this.helpers.requestOAuth2.call(this, 'twitterOAuth2Api', options); + return data; + } + } catch (error) { + if (error.error?.required_enrollment === 'Appropriate Level of API Access') { + throw new NodeOperationError( + this.getNode(), + 'The operation requires Twitter Api to be either Basic or Pro.', + ); + } else if (error.errors && error.error?.errors[0].message.includes('must be ')) { + throw new NodeOperationError(this.getNode(), error.error.errors[0].message as string); + } + throw new NodeApiError(this.getNode(), error as JsonObject); + } +} + +export async function twitterApiRequestAllItems( + this: IExecuteFunctions | ILoadOptionsFunctions, + propertyName: string, + method: string, + endpoint: string, + body: IDataObject = {}, + query: IDataObject = {}, +) { + const returnData: IDataObject[] = []; + + let responseData; + + query.max_results = 10; + + do { + responseData = await twitterApiRequest.call(this, method, endpoint, body, query, true); + query.next_token = responseData.meta.next_token as string; + returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]); + } while (responseData.meta.next_token); + + return returnData; +} + +export function returnId(tweetId: INodeParameterResourceLocator) { + if (tweetId.mode === 'id') { + return tweetId.value as string; + } else if (tweetId.mode === 'url') { + const value = tweetId.value as string; + const tweetIdMatch = value.includes('lists') + ? value.match(/^https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/list(s)?\/(\d+)$/) + : value.match(/^https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(es)?\/(\d+)$/); + + return tweetIdMatch?.[3] as string; + } else { + throw new Error(`The mode ${tweetId.mode} is not valid!`); + } +} + +export async function returnIdFromUsername( + this: IExecuteFunctions, + usernameRlc: INodeParameterResourceLocator, +) { + usernameRlc.value = (usernameRlc.value as string).includes('@') + ? (usernameRlc.value as string).replace('@', '') + : usernameRlc.value; + if ( + usernameRlc.mode === 'username' || + (usernameRlc.mode === 'name' && this.getNode().parameters.list !== undefined) + ) { + const user = (await twitterApiRequest.call( + this, + 'GET', + `/users/by/username/${usernameRlc.value}`, + {}, + )) as { id: string }; + return user.id; + } else if (this.getNode().parameters.list === undefined) { + const list = (await twitterApiRequest.call( + this, + 'GET', + `/list/by/name/${usernameRlc.value}`, + {}, + )) as { id: string }; + return list.id; + } else throw new Error(`The username mode ${usernameRlc.mode} is not valid!`); +} diff --git a/packages/nodes-base/nodes/Twitter/V2/ListDescription.ts b/packages/nodes-base/nodes/Twitter/V2/ListDescription.ts new file mode 100644 index 0000000000..430e1bcaa9 --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V2/ListDescription.ts @@ -0,0 +1,94 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const listOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['list'], + }, + }, + options: [ + { + name: 'Add Member', + value: 'add', + description: 'Add a member to a list', + action: 'Add Member to List', + }, + ], + default: 'add', + }, +]; + +export const listFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* list:add */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'List', + name: 'list', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + required: true, + description: 'The list you want to add the user to', + displayOptions: { + show: { + operation: ['add'], + resource: ['list'], + }, + }, + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 99923132', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/i/lists/99923132', + url: '', + }, + ], + }, + { + displayName: 'User', + name: 'user', + type: 'resourceLocator', + default: { mode: 'username', value: '' }, + required: true, + description: 'The user you want to add to the list', + displayOptions: { + show: { + operation: ['add'], + resource: ['list'], + }, + }, + modes: [ + { + displayName: 'By Username', + name: 'username', + type: 'string', + validation: [], + placeholder: 'e.g. n8n', + url: '', + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1068479892537384960', + url: '', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Twitter/V2/TweetDescription.ts b/packages/nodes-base/nodes/Twitter/V2/TweetDescription.ts new file mode 100644 index 0000000000..b7fb9006ef --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V2/TweetDescription.ts @@ -0,0 +1,479 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const tweetOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['tweet'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create, quote, or reply to a tweet', + action: 'Create Tweet', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a tweet', + action: 'Delete Tweet', + }, + { + name: 'Like', + value: 'like', + description: 'Like a tweet', + action: 'Like Tweet', + }, + { + name: 'Retweet', + value: 'retweet', + description: 'Retweet a tweet', + action: 'Retweet Tweet', + }, + { + name: 'Search', + value: 'search', + description: 'Search for tweets from the last seven days', + action: 'Search Tweets', + }, + ], + default: 'create', + }, +]; + +export const tweetFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* tweet:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Text', + name: 'text', + type: 'string', + typeOptions: { + rows: 2, + }, + default: '', + required: true, + displayOptions: { + show: { + operation: ['create'], + resource: ['tweet'], + }, + }, + description: + 'The text of the status update. URLs must be encoded. Links wrapped with the t.co shortener will affect character count', + }, + { + displayName: 'Options', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: ['create'], + resource: ['tweet'], + }, + }, + options: [ + { + displayName: 'Location ID', + name: 'location', + type: 'string', + placeholder: '4e696bef7e24d378', + default: '', + description: 'Location information for the tweet', + }, + { + displayName: 'Media ID', + name: 'attachments', + type: 'string', + default: '', + placeholder: '1664279886239010824', + description: 'The attachment ID to associate with the message', + }, + { + displayName: 'Quote a Tweet', + name: 'inQuoteToStatusId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + description: 'The tweet being quoted', + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1187836157394112513', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513', + url: '', + }, + ], + }, + { + displayName: 'Reply to Tweet', + name: 'inReplyToStatusId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + // required: true, + description: 'The tweet being replied to', + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1187836157394112513', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513', + url: '', + }, + ], + }, + ], + }, + { + displayName: 'Locations are not supported due to Twitter V2 API limitations', + name: 'noticeLocation', + type: 'notice', + displayOptions: { + show: { + '/additionalFields.location': [''], + }, + }, + default: '', + }, + { + displayName: 'Attachements are not supported due to Twitter V2 API limitations', + name: 'noticeAttachments', + type: 'notice', + displayOptions: { + show: { + '/additionalFields.attachments': [''], + }, + }, + default: '', + }, + + /* -------------------------------------------------------------------------- */ + /* tweet:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Tweet', + name: 'tweetDeleteId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + required: true, + description: 'The tweet to delete', + displayOptions: { + show: { + resource: ['tweet'], + operation: ['delete'], + }, + }, + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1187836157394112513', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513', + url: '', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* tweet:like */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Tweet', + name: 'tweetId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + required: true, + description: 'The tweet to like', + displayOptions: { + show: { + operation: ['like'], + resource: ['tweet'], + }, + }, + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1187836157394112513', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513', + url: '', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* tweet:search */ + /* -------------------------------------------------------------------------- */ + { + // displayName: 'Search Text', + displayName: 'Search Term', + name: 'searchText', + type: 'string', + required: true, + default: '', + placeholder: 'e.g. automation', + 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', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: ['tweet'], + operation: ['search'], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'Max number of results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: ['tweet'], + operation: ['search'], + returnAll: [false], + }, + }, + }, + { + displayName: 'Options', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: ['search'], + resource: ['tweet'], + }, + }, + options: [ + { + displayName: 'Sort Order', + name: 'sortOrder', + type: 'options', + options: [ + { + name: 'Recent', + value: 'recency', + }, + { + name: 'Relevant', + value: 'relevancy', + }, + ], + // required: true, + description: 'The order in which to return results', + default: 'recency', + }, + { + displayName: 'After', + name: 'startTime', + type: 'dateTime', + default: '', + description: + "Tweets before this date will not be returned. This date must be within the last 7 days if you don't have Academic Research access.", + }, + { + displayName: 'Before', + name: 'endTime', + type: 'dateTime', + default: '', + description: + "Tweets after this date will not be returned. This date must be within the last 7 days if you don't have Academic Research access.", + }, + { + displayName: 'Tweet Fields', + name: 'tweetFieldsObject', + type: 'multiOptions', + // eslint-disable-next-line n8n-nodes-base/node-param-multi-options-type-unsorted-items + options: [ + { + name: 'Attachments', + value: 'attachments', + }, + { + name: 'Author ID', + value: 'author_id', + }, + { + name: 'Context Annotations', + value: 'context_annotations', + }, + { + name: 'Conversation ID', + value: 'conversation_id', + }, + { + name: 'Created At', + value: 'created_at', + }, + { + name: 'Edit Controls', + value: 'edit_controls', + }, + { + name: 'Entities', + value: 'entities', + }, + { + name: 'Geo', + value: 'geo', + }, + { + name: 'ID', + value: 'id', + }, + { + name: 'In Reply To User ID', + value: 'in_reply_to_user_id', + }, + { + name: 'Lang', + value: 'lang', + }, + { + name: 'Non Public Metrics', + value: 'non_public_metrics', + }, + { + name: 'Public Metrics', + value: 'public_metrics', + }, + { + name: 'Organic Metrics', + value: 'organic_metrics', + }, + { + name: 'Promoted Metrics', + value: 'promoted_metrics', + }, + { + name: 'Possibly Sensitive', + value: 'possibly_sensitive', + }, + { + name: 'Referenced Tweets', + value: 'referenced_tweets', + }, + { + name: 'Reply Settings', + value: 'reply_settings', + }, + { + name: 'Source', + value: 'source', + }, + { + name: 'Text', + value: 'text', + }, + { + name: 'Withheld', + value: 'withheld', + }, + ], + default: [], + description: + 'The fields to add to each returned tweet object. Default fields are: ID, text, edit_history_tweet_ids.', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* tweet:retweet */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Tweet', + name: 'tweetId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + required: true, + description: 'The tweet to retweet', + displayOptions: { + show: { + operation: ['retweet'], + resource: ['tweet'], + }, + }, + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1187836157394112513', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513', + url: '', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Twitter/V2/TweetInterface.ts b/packages/nodes-base/nodes/Twitter/V2/TweetInterface.ts new file mode 100644 index 0000000000..05fb67e260 --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V2/TweetInterface.ts @@ -0,0 +1,25 @@ +import type { IDataObject } from 'n8n-workflow'; + +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; +} + +export interface ITweetCreate { + type: 'message_create'; + message_create: { + target: { + recipient_id: string; + }; + message_data: { + text: string; + attachment?: IDataObject; + }; + }; +} diff --git a/packages/nodes-base/nodes/Twitter/V2/TwitterV2.node.ts b/packages/nodes-base/nodes/Twitter/V2/TwitterV2.node.ts new file mode 100644 index 0000000000..10fd2e42a2 --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V2/TwitterV2.node.ts @@ -0,0 +1,365 @@ +import type { + IDataObject, + IExecuteFunctions, + ILoadOptionsFunctions, + INodeExecutionData, + INodeParameterResourceLocator, + INodePropertyOptions, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, + JsonObject, +} from 'n8n-workflow'; + +import { directMessageOperations, directMessageFields } from './DirectMessageDescription'; +import { listOperations, listFields } from './ListDescription'; +import { tweetFields, tweetOperations } from './TweetDescription'; +import { userOperations, userFields } from './UserDescription'; + +import ISO6391 from 'iso-639-1'; +import { + returnId, + returnIdFromUsername, + twitterApiRequest, + twitterApiRequestAllItems, +} from './GenericFunctions'; +import { DateTime } from 'luxon'; + +export class TwitterV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + version: 2, + description: + 'Post, like, and search tweets, send messages, search users, and add users to lists', + subtitle: '={{$parameter["operation"] + ":" + $parameter["resource"]}}', + defaults: { + name: 'Twitter', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'twitterOAuth2Api', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Direct Message', + value: 'directMessage', + description: 'Send a direct message to a user', + }, + { + name: 'List', + value: 'list', + description: 'Add a user to a list', + }, + { + name: 'Tweet', + value: 'tweet', + description: 'Create, like, search, or delete a tweet', + }, + { + name: 'User', + value: 'user', + description: 'Search users by username', + }, + ], + default: 'tweet', + }, + // DIRECT MESSAGE + ...directMessageOperations, + ...directMessageFields, + // LIST + ...listOperations, + ...listFields, + // TWEET + ...tweetOperations, + ...tweetFields, + // USER + ...userOperations, + ...userFields, + ], + }; + } + + methods = { + loadOptions: { + // Get all the available languages to display them to user so that they 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: INodeExecutionData[] = []; + const length = items.length; + let responseData; + const resource = this.getNodeParameter('resource', 0); + const operation = this.getNodeParameter('operation', 0); + for (let i = 0; i < length; i++) { + try { + if (resource === 'user') { + if (operation === 'searchUser') { + const me = this.getNodeParameter('me', i, false) as boolean; + if (me) { + responseData = await twitterApiRequest.call(this, 'GET', '/users/me', {}); + } else { + const userRlc = this.getNodeParameter( + 'user', + i, + undefined, + {}, + ) as INodeParameterResourceLocator; + if (userRlc.mode === 'username') { + userRlc.value = (userRlc.value as string).includes('@') + ? (userRlc.value as string).replace('@', '') + : userRlc.value; + responseData = await twitterApiRequest.call( + this, + 'GET', + `/users/by/username/${userRlc.value}`, + {}, + ); + } else if (userRlc.mode === 'id') { + responseData = await twitterApiRequest.call( + this, + 'GET', + `/users/${userRlc.value}`, + {}, + ); + } + } + } + } + if (resource === 'tweet') { + if (operation === 'search') { + const searchText = this.getNodeParameter('searchText', i, '', {}); + const returnAll = this.getNodeParameter('returnAll', i); + const { sortOrder, startTime, endTime, tweetFieldsObject } = this.getNodeParameter( + 'additionalFields', + i, + {}, + ) as { + sortOrder: string; + startTime: string; + endTime: string; + tweetFieldsObject: string[]; + }; + const qs: IDataObject = { + query: searchText, + }; + if (endTime) { + const endTimeISO = DateTime.fromISO(endTime).toISO(); + qs.end_time = endTimeISO; + } + if (sortOrder) { + qs.sort_order = sortOrder; + } + if (startTime) { + const startTimeISO8601 = DateTime.fromISO(startTime).toISO(); + qs.start_time = startTimeISO8601; + } + if (tweetFieldsObject) { + if (tweetFieldsObject.length > 0) { + qs['tweet.fields'] = tweetFieldsObject.join(','); + } + } + if (returnAll) { + responseData = await twitterApiRequestAllItems.call( + this, + 'data', + 'GET', + '/tweets/search/recent', + {}, + qs, + ); + } else { + const limit = this.getNodeParameter('limit', i); + qs.max_results = limit; + responseData = await twitterApiRequest.call( + this, + 'GET', + '/tweets/search/recent', + {}, + qs, + ); + } + } + if (operation === 'create') { + const text = this.getNodeParameter('text', i, '', {}); + const { location, attachments, inQuoteToStatusId, inReplyToStatusId } = + this.getNodeParameter('additionalFields', i, {}) as { + location: string; + attachments: string; + inQuoteToStatusId: INodeParameterResourceLocator; + inReplyToStatusId: INodeParameterResourceLocator; + }; + const body: IDataObject = { + text, + }; + if (location) { + body.geo = { place_id: location }; + } + if (attachments) { + body.media = { media_ids: [attachments] }; + } + if (inQuoteToStatusId) { + body.quote_tweet_id = returnId(inQuoteToStatusId); + } + if (inReplyToStatusId) { + const inReplyToStatusIdValue = { in_reply_to_tweet_id: returnId(inReplyToStatusId) }; + body.reply = inReplyToStatusIdValue; + } + responseData = await twitterApiRequest.call(this, 'POST', '/tweets', body); + } + if (operation === 'delete') { + const tweetRLC = this.getNodeParameter( + 'tweetDeleteId', + i, + '', + {}, + ) as INodeParameterResourceLocator; + const tweetId = returnId(tweetRLC); + responseData = await twitterApiRequest.call(this, 'DELETE', `/tweets/${tweetId}`, {}); + } + if (operation === 'like') { + const tweetRLC = this.getNodeParameter( + 'tweetId', + i, + '', + {}, + ) as INodeParameterResourceLocator; + const tweetId = returnId(tweetRLC); + const body: IDataObject = { + tweet_id: tweetId, + }; + const user = (await twitterApiRequest.call(this, 'GET', '/users/me', {})) as { + id: string; + }; + responseData = await twitterApiRequest.call( + this, + 'POST', + `/users/${user.id}/likes`, + body, + ); + } + if (operation === 'retweet') { + const tweetRLC = this.getNodeParameter( + 'tweetId', + i, + '', + {}, + ) as INodeParameterResourceLocator; + const tweetId = returnId(tweetRLC); + const body: IDataObject = { + tweet_id: tweetId, + }; + const user = (await twitterApiRequest.call(this, 'GET', '/users/me', {})) as { + id: string; + }; + responseData = await twitterApiRequest.call( + this, + 'POST', + `/users/${user.id}/retweets`, + body, + ); + } + } + if (resource === 'list') { + if (operation === 'add') { + const userRlc = this.getNodeParameter( + 'user', + i, + '', + {}, + ) as INodeParameterResourceLocator; + const userId = + userRlc.mode !== 'username' + ? returnId(userRlc) + : await returnIdFromUsername.call(this, userRlc); + const listRlc = this.getNodeParameter( + 'list', + i, + '', + {}, + ) as INodeParameterResourceLocator; + const listId = returnId(listRlc); + responseData = await twitterApiRequest.call(this, 'POST', `/lists/${listId}/members`, { + user_id: userId, + }); + } + } + if (resource === 'directMessage') { + if (operation === 'create') { + const userRlc = this.getNodeParameter( + 'user', + i, + '', + {}, + ) as INodeParameterResourceLocator; + const user = await returnIdFromUsername.call(this, userRlc); + const text = this.getNodeParameter('text', i, '', {}); + const { attachments } = this.getNodeParameter('additionalFields', i, {}, {}) as { + attachments: number; + }; + const body: IDataObject = { + text, + }; + + if (attachments) { + body.attachments = [{ media_id: attachments }]; + } + + responseData = await twitterApiRequest.call( + this, + 'POST', + `/dm_conversations/with/${user}/messages`, + body, + ); + } + } + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject[]), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + const executionErrorData = { + json: { + error: (error as JsonObject).message, + }, + }; + returnData.push(executionErrorData); + continue; + } + throw error; + } + } + + return this.prepareOutputData(returnData); + } +} diff --git a/packages/nodes-base/nodes/Twitter/V2/UserDescription.ts b/packages/nodes-base/nodes/Twitter/V2/UserDescription.ts new file mode 100644 index 0000000000..9f7c9d6ff8 --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V2/UserDescription.ts @@ -0,0 +1,78 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const userOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + { + name: 'Get', + value: 'searchUser', + description: 'Retrieve a user by username', + action: 'Get User', + }, + ], + default: 'searchUser', + }, +]; + +export const userFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* user:searchUser */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'User', + name: 'user', + type: 'resourceLocator', + default: { mode: 'username', value: '' }, + required: true, + description: 'The user you want to search', + displayOptions: { + show: { + operation: ['searchUser'], + resource: ['user'], + }, + hide: { + me: [true], + }, + }, + modes: [ + { + displayName: 'By Username', + name: 'username', + type: 'string', + validation: [], + placeholder: 'e.g. n8n', + url: '', + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1068479892537384960', + url: '', + }, + ], + }, + { + displayName: 'Me', + name: 'me', + type: 'boolean', + displayOptions: { + show: { + operation: ['searchUser'], + resource: ['user'], + }, + }, + default: false, + description: 'Whether you want to search the authenticated user', + }, +]; diff --git a/packages/nodes-base/nodes/Twitter/test/Twitter.test.ts b/packages/nodes-base/nodes/Twitter/test/Twitter.test.ts new file mode 100644 index 0000000000..e9e1e09bae --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/test/Twitter.test.ts @@ -0,0 +1,88 @@ +import { getWorkflowFilenames, testWorkflows } from '../../../test/nodes/Helpers'; + +import nock from 'nock'; + +const searchResult = { + data: [ + { + edit_history_tweet_ids: ['1666357334740811776'], + id: '1666357334740811776', + text: 'RT @business: Extreme heat is happening earlier than usual this year in Asia. That’s posing a grave risk to agriculture and industrial acti…', + }, + { + edit_history_tweet_ids: ['1666357331276230656'], + id: '1666357331276230656', + text: + '@ROBVME @sheepsleepdeep @mattxiv Bit like Bloomberg, but then for an incredible average beer brand, to which grown ass adults have some deeply emotional attachment to and going through a teenage break-up with, because a tiktok.\n' + + 'Got it.', + }, + { + edit_history_tweet_ids: ['1666357319381180417'], + id: '1666357319381180417', + text: "The global economy is set for a weak recovery from the shocks of Covid and Russia’s war in Ukraine, dogged by persistent inflation and central banks' restrictive policies, the OECD warns https://t.co/HPtelXu8iR https://t.co/rziWHhr8Np", + }, + { + edit_history_tweet_ids: ['1666357315946303488'], + id: '1666357315946303488', + text: + 'RT @lukedepulford: Love this so much. Variations of “Glory to Hong Kong” are THE WHOLE TOP TEN of the most downloaded song on iTunes.\n' + + '\n' + + '✊\n' + + '\n' + + 'h…', + }, + { + edit_history_tweet_ids: ['1666357265320869891'], + id: '1666357265320869891', + text: 'RT @business: The SEC said it’s seeking to freeze https://t.co/35sr7lifRX’s assets and protect customer funds, including through the repatr…', + }, + { + edit_history_tweet_ids: ['1666357244760555520'], + id: '1666357244760555520', + text: 'RT @BloombergJapan: オプション市場で日経平均先高観強まる、3万4000円に備える買い急増 https://t.co/mIcdkgokYj', + }, + { + edit_history_tweet_ids: ['1666357239710359552'], + id: '1666357239710359552', + text: "Twitter'a mı girdim bloomberg mi anlamadım,dolar euro altın..maşallahları var,tl mi onun anası sikilmiş.", + }, + { + edit_history_tweet_ids: ['1666357235340165120'], + id: '1666357235340165120', + text: 'RT @business: These charts show why Germany needs mass migration https://t.co/rvZixuwwnu', + }, + { + edit_history_tweet_ids: ['1666357210409213952'], + id: '1666357210409213952', + text: 'RT @elonmusk: @MattWalshBlog View count is actually understated, as it does not include anything from our API, for example tweets you see i…', + }, + { + edit_history_tweet_ids: ['1666357208983166976'], + id: '1666357208983166976', + text: 'RT @coinbureau: There we go. Without proving in a court of law that these tokens are "securities" the SEC may be able to restrict access to…', + }, + ], +}; + +const meResult = { + data: { id: '1285192200213626880', name: 'Integration-n8n', username: 'IntegrationN8n' }, +}; +describe('Test Twitter Request Node', () => { + beforeAll(() => { + const baseUrl = 'https://api.twitter.com/2'; + nock.disableNetConnect(); + //GET + nock(baseUrl).get('/users/me').reply(200, meResult); + + nock(baseUrl) + .get('/tweets/search/recent?query=bloomberg&max_results=10') + .reply(200, searchResult); + }); + + afterEach(() => { + nock.restore(); + }); + + const workflows = getWorkflowFilenames(__dirname); + testWorkflows(workflows); +}); diff --git a/packages/nodes-base/nodes/Twitter/test/Workflow_Twitter_UnitTest.json b/packages/nodes-base/nodes/Twitter/test/Workflow_Twitter_UnitTest.json new file mode 100644 index 0000000000..54548cf88e --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/test/Workflow_Twitter_UnitTest.json @@ -0,0 +1,284 @@ +{ + "name": "node-1-twitter-node-overhaul", + "nodes": [ + { + "parameters": {}, + "id": "91cdc3d3-9cf7-4fe0-b74c-b17e0c8b404d", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-420, 80] + }, + { + "parameters": { + "resource": "user", + "me": true + }, + "id": "4ee34d07-db6c-413b-95f3-932182770044", + "name": "Twitter1", + "type": "n8n-nodes-base.twitter", + "typeVersion": 2, + "position": [-140, 260], + "credentials": { + "twitterOAuth2Api": { + "id": "121", + "name": "Twitter OAuth account 2" + } + }, + "disabled": false + }, + { + "parameters": { + "operation": "delete", + "tweetDeleteId": { + "__rl": true, + "value": "={{ $('Twitter').item.json.id }}", + "mode": "id" + } + }, + "id": "ceca5fb1-f4b7-4dbe-9505-61793bffd87a", + "name": "Twitter3", + "type": "n8n-nodes-base.twitter", + "typeVersion": 2, + "position": [240, 80], + "credentials": { + "twitterOAuth2Api": { + "id": "121", + "name": "Twitter OAuth account 2" + } + }, + "disabled": true + }, + { + "parameters": { + "operation": "retweet", + "tweetId": { + "__rl": true, + "value": "={{ $json.id }}", + "mode": "id" + }, + "additionalFields": {} + }, + "id": "ce49286a-04bb-4f59-bae5-ef64a1bca2b0", + "name": "Twitter2", + "type": "n8n-nodes-base.twitter", + "typeVersion": 2, + "position": [40, 80], + "credentials": { + "twitterOAuth2Api": { + "id": "121", + "name": "Twitter OAuth account 2" + } + }, + "disabled": true + }, + { + "parameters": { + "text": "test5", + "additionalFields": {} + }, + "id": "370e20de-dca6-48ab-aa24-47fb326bad77", + "name": "Twitter", + "type": "n8n-nodes-base.twitter", + "typeVersion": 2, + "position": [-140, 80], + "credentials": { + "twitterOAuth2Api": { + "id": "121", + "name": "Twitter OAuth account 2" + } + }, + "disabled": true + }, + { + "parameters": { + "resource": "list", + "list": { + "__rl": true, + "value": "https://twitter.com/i/lists/1663852298521419776", + "mode": "url" + }, + "user": { + "__rl": true, + "value": "n8n_io", + "mode": "username" + } + }, + "id": "ea1c13af-80b8-41d4-8591-e16b8e7be84b", + "name": "Twitter7", + "type": "n8n-nodes-base.twitter", + "typeVersion": 2, + "position": [-140, 640], + "credentials": { + "twitterOAuth2Api": { + "id": "121", + "name": "Twitter OAuth account 2" + } + }, + "disabled": true + }, + { + "parameters": { + "operation": "search", + "searchText": "bloomberg", + "limit": 10, + "additionalFields": {} + }, + "id": "216b3061-a52b-4d64-9ed8-9cc6940f6efa", + "name": "Twitter5", + "type": "n8n-nodes-base.twitter", + "typeVersion": 2, + "position": [-140, 440], + "credentials": { + "twitterOAuth2Api": { + "id": "121", + "name": "Twitter OAuth account 2" + } + } + } + ], + "pinData": { + "Twitter1": [ + { + "json": { + "id": "1285192200213626880", + "name": "Integration-n8n", + "username": "IntegrationN8n" + } + } + ], + "Twitter5": [ + { + "json": { + "edit_history_tweet_ids": ["1666357334740811776"], + "id": "1666357334740811776", + "text": "RT @business: Extreme heat is happening earlier than usual this year in Asia. That’s posing a grave risk to agriculture and industrial acti…" + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357331276230656"], + "id": "1666357331276230656", + "text": "@ROBVME @sheepsleepdeep @mattxiv Bit like Bloomberg, but then for an incredible average beer brand, to which grown ass adults have some deeply emotional attachment to and going through a teenage break-up with, because a tiktok.\nGot it." + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357319381180417"], + "id": "1666357319381180417", + "text": "The global economy is set for a weak recovery from the shocks of Covid and Russia’s war in Ukraine, dogged by persistent inflation and central banks' restrictive policies, the OECD warns https://t.co/HPtelXu8iR https://t.co/rziWHhr8Np" + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357315946303488"], + "id": "1666357315946303488", + "text": "RT @lukedepulford: Love this so much. Variations of “Glory to Hong Kong” are THE WHOLE TOP TEN of the most downloaded song on iTunes.\n\n✊\n\nh…" + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357265320869891"], + "id": "1666357265320869891", + "text": "RT @business: The SEC said it’s seeking to freeze https://t.co/35sr7lifRX’s assets and protect customer funds, including through the repatr…" + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357244760555520"], + "id": "1666357244760555520", + "text": "RT @BloombergJapan: オプション市場で日経平均先高観強まる、3万4000円に備える買い急増 https://t.co/mIcdkgokYj" + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357239710359552"], + "id": "1666357239710359552", + "text": "Twitter'a mı girdim bloomberg mi anlamadım,dolar euro altın..maşallahları var,tl mi onun anası sikilmiş." + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357235340165120"], + "id": "1666357235340165120", + "text": "RT @business: These charts show why Germany needs mass migration https://t.co/rvZixuwwnu" + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357210409213952"], + "id": "1666357210409213952", + "text": "RT @elonmusk: @MattWalshBlog View count is actually understated, as it does not include anything from our API, for example tweets you see i…" + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357208983166976"], + "id": "1666357208983166976", + "text": "RT @coinbureau: There we go. Without proving in a court of law that these tokens are \"securities\" the SEC may be able to restrict access to…" + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Twitter1", + "type": "main", + "index": 0 + }, + { + "node": "Twitter", + "type": "main", + "index": 0 + }, + { + "node": "Twitter7", + "type": "main", + "index": 0 + }, + { + "node": "Twitter5", + "type": "main", + "index": 0 + } + ] + ] + }, + "Twitter1": { + "main": [[]] + }, + "Twitter2": { + "main": [ + [ + { + "node": "Twitter3", + "type": "main", + "index": 0 + } + ] + ] + }, + "Twitter": { + "main": [ + [ + { + "node": "Twitter2", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "bc985960-5bfd-4b56-b290-ab839b3c0c30", + "id": "66", + "meta": { + "instanceId": "8e9416f42a954d0a370d988ac3c0f916f44074a6e45189164b1a8559394a7516" + }, + "tags": [] +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index e260be8b82..cf100c387f 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -318,6 +318,7 @@ "dist/credentials/TwilioApi.credentials.js", "dist/credentials/TwistOAuth2Api.credentials.js", "dist/credentials/TwitterOAuth1Api.credentials.js", + "dist/credentials/TwitterOAuth2Api.credentials.js", "dist/credentials/TypeformApi.credentials.js", "dist/credentials/TypeformOAuth2Api.credentials.js", "dist/credentials/UnleashedSoftwareApi.credentials.js", diff --git a/packages/nodes-base/test/nodes/FakeCredentialsMap.ts b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts index 83c82b3dbb..3b13075233 100644 --- a/packages/nodes-base/test/nodes/FakeCredentialsMap.ts +++ b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts @@ -26,4 +26,23 @@ export const FAKE_CREDENTIALS_DATA = { accessKeyId: 'key', secretAccessKey: 'secret', }, + twitterOAuth2Api: { + grantType: 'pkce', + authUrl: 'https://twitter.com/i/oauth2/authorize', + accessTokenUrl: 'https://api.twitter.com/2/oauth2/token', + clientId: 'CLIENTID', + clientSecret: 'CLIENTSECRET', + scope: + 'tweet.read users.read tweet.write tweet.moderate.write users.read follows.read follows.write offline.access like.read like.write dm.write dm.read list.read list.write', + authQueryParameters: '', + authentication: 'header', + oauthTokenData: { + token_type: 'bearer', + expires_in: 7200, + access_token: 'ACCESSTOKEN', + scope: + 'tweet.moderate.write follows.read offline.access list.write dm.read list.read tweet.write like.write like.read users.read dm.write tweet.read follows.write', + refresh_token: 'REFRESHTOKEN', + }, + }, } as const;