diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index f0d0d01751..d500d56f88 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -219,12 +219,17 @@ export function requestOAuth1(this: IAllExecuteFunctions, credentialsType: strin //@ts-ignore url: requestOptions.url, method: requestOptions.method, - data: requestOptions.body, + data: { ...requestOptions.qs, ...requestOptions.body }, json: requestOptions.json, }; - //@ts-ignore - newRequestOptions.form = oauth.authorize(newRequestOptions as RequestOptions, token); + if (Object.keys(requestOptions.qs).length !== 0) { + //@ts-ignore + newRequestOptions.qs = oauth.authorize(newRequestOptions as RequestOptions, token); + } else { + //@ts-ignore + newRequestOptions.form = oauth.authorize(newRequestOptions as RequestOptions, token); + } return this.helpers.request!(newRequestOptions) .catch(async (error: IResponseError) => { diff --git a/packages/nodes-base/nodes/Github/GenericFunctions.ts b/packages/nodes-base/nodes/Github/GenericFunctions.ts index 592367019e..de20d34062 100644 --- a/packages/nodes-base/nodes/Github/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Github/GenericFunctions.ts @@ -50,7 +50,7 @@ export async function githubApiRequest(this: IHookFunctions | IExecuteFunctions, const baseUrl = credentials!.server || 'https://api.github.com'; options.uri = `${baseUrl}${endpoint}`; - + //@ts-ignore return await this.helpers.requestOAuth2.call(this, 'githubOAuth2Api', options); } } catch (error) { diff --git a/packages/nodes-base/nodes/HttpRequest.node.ts b/packages/nodes-base/nodes/HttpRequest.node.ts index 499575a728..417d80eeea 100644 --- a/packages/nodes-base/nodes/HttpRequest.node.ts +++ b/packages/nodes-base/nodes/HttpRequest.node.ts @@ -801,6 +801,7 @@ export class HttpRequest implements INodeType { // Now that the options are all set make the actual http request if (oAuth2Api !== undefined) { + //@ts-ignore response = await this.helpers.requestOAuth2.call(this, 'oAuth2Api', requestOptions); } else { response = await this.helpers.request(requestOptions); diff --git a/packages/nodes-base/nodes/OAuth.node.ts b/packages/nodes-base/nodes/OAuth.node.ts index 685b07cc40..3966571cd1 100644 --- a/packages/nodes-base/nodes/OAuth.node.ts +++ b/packages/nodes-base/nodes/OAuth.node.ts @@ -137,7 +137,7 @@ export class OAuth implements INodeType { uri: url, json: true, }; - + //@ts-ignore const responseData = await this.helpers.requestOAuth2.call(this, 'oAuth2Api', requestOptions); return [this.helpers.returnJsonArray(responseData)]; } else { diff --git a/packages/nodes-base/nodes/Twitter/GenericFunctions.ts b/packages/nodes-base/nodes/Twitter/GenericFunctions.ts index d13ea1c548..1b793047c3 100644 --- a/packages/nodes-base/nodes/Twitter/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Twitter/GenericFunctions.ts @@ -42,3 +42,35 @@ export async function twitterApiRequest(this: IExecuteFunctions | IExecuteSingle throw 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; +} + + diff --git a/packages/nodes-base/nodes/Twitter/TweetDescription.ts b/packages/nodes-base/nodes/Twitter/TweetDescription.ts index 6434843bea..edd12bbf5e 100644 --- a/packages/nodes-base/nodes/Twitter/TweetDescription.ts +++ b/packages/nodes-base/nodes/Twitter/TweetDescription.ts @@ -20,6 +20,11 @@ export const tweetOperations = [ value: 'create', description: 'Create a new tweet', }, + { + name: 'Search', + value: 'search', + description: 'Search tweets', + }, ], default: 'create', description: 'The operation to perform.', @@ -70,54 +75,10 @@ export const tweetFields = [ options: [ { displayName: 'Attachments', - name: 'attachmentsUi', - placeholder: 'Add Attachments', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - options: [ - { - name: 'attachment', - displayName: 'Attachment', - values: [ - { - displayName: 'Binary Property', - name: 'binaryPropertyName', - type: 'string', - default: 'data', - description: 'Name of the binary properties which contain data which should be added to tweet as attachment', - }, - { - displayName: 'Category', - name: 'category', - type: 'options', - options: [ - { - name: 'Amplify Video', - value: 'amplifyVideo', - }, - { - name: 'Gif', - value: 'tweetGif', - }, - { - name: 'Image', - value: 'tweetImage', - }, - { - name: 'Video', - value: 'tweetVideo', - }, - ], - default: '', - description: 'The category that represents how the media will be used', - }, - ], - }, - ], - default: '', - description: 'Array of supported attachments to add to the message.', + 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', @@ -167,4 +128,197 @@ export const tweetFields = [ }, ] }, +/* -------------------------------------------------------------------------- */ +/* 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: 'If all results should be returned 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: 'How many 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: 'Lang', + 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: 'Until', + name: 'until', + type: 'dateTime', + default: '', + description: 'Returns tweets created before the given date', + }, + ], + }, ] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Twitter/Twitter.node.ts b/packages/nodes-base/nodes/Twitter/Twitter.node.ts index 09b1dcde93..60f95e3798 100644 --- a/packages/nodes-base/nodes/Twitter/Twitter.node.ts +++ b/packages/nodes-base/nodes/Twitter/Twitter.node.ts @@ -1,6 +1,8 @@ import { IExecuteFunctions, + ILoadOptionsFunctions, + BINARY_ENCODING, } from 'n8n-core'; import { @@ -9,6 +11,7 @@ import { INodeExecutionData, INodeType, INodeTypeDescription, + INodePropertyOptions, } from 'n8n-workflow'; import { @@ -17,16 +20,16 @@ import { } from './TweetDescription'; import { + chunks, twitterApiRequest, + twitterApiRequestAllItems, } from './GenericFunctions'; import { ITweet, } from './TweetInterface'; -import { - snakeCase, -} from 'change-case'; +const ISO6391 = require('iso-639-1'); export class Twitter implements INodeType { description: INodeTypeDescription = { @@ -69,6 +72,26 @@ export class Twitter implements INodeType { ], }; + 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[] = []; @@ -86,33 +109,96 @@ export class Twitter implements INodeType { status: text, }; - if (additionalFields.attachmentsUi) { + if (additionalFields.attachments) { const mediaIds = []; - const attachmentsUi = additionalFields.attachmentsUi as IDataObject; + const attachments = additionalFields.attachments as string; const uploadUri = 'https://upload.twitter.com/1.1/media/upload.json'; - if (attachmentsUi.attachment) { - const attachtments = attachmentsUi.attachment as IDataObject[]; - for (const attachment of attachtments) { + const attachmentProperties: string[] = attachments.split(',').map((propertyName) => { + return propertyName.trim(); + }); - const binaryData = items[i].binary as IBinaryKeyData; - const binaryPropertyName = attachment.binaryPropertyName as string; + for (const binaryPropertyName of attachmentProperties) { - if (binaryData === undefined) { - throw new Error('No binary data set. So file can not be written!'); - } + const binaryData = items[i].binary as IBinaryKeyData; - if (!binaryData[binaryPropertyName]) { - continue; - } + if (binaryData === undefined) { + throw new Error('No binary data set. So file can not be written!'); + } + + if (!binaryData[binaryPropertyName]) { + continue; + } + + let attachmentBody = {}; + let response: IDataObject = {}; + + if (binaryData[binaryPropertyName].mimeType.includes('image')) { const attachmentBody = { media_data: binaryData[binaryPropertyName].data, - media_category: snakeCase(attachment.category as string).toUpperCase(), }; - const response = await twitterApiRequest.call(this, 'POST', '', attachmentBody, {}, uploadUri); - mediaIds.push(response.media_id_string); + + response = await twitterApiRequest.call(this, 'POST', '', attachmentBody, {}, uploadUri); + + } else { + + // https://developer.twitter.com/en/docs/media/upload-media/api-reference/post-media-upload-init + + attachmentBody = { + command: 'INIT', + total_bytes: Buffer.from(binaryData[binaryPropertyName].data, BINARY_ENCODING).byteLength, + media_type: binaryData[binaryPropertyName].mimeType, + }; + + response = await twitterApiRequest.call(this, 'POST', '', attachmentBody, {}, uploadUri); + + const mediaId = response.media_id_string; + + // break the data on 5mb chunks (max size that can be uploaded at once) + + const binaryParts = chunks(Buffer.from(binaryData[binaryPropertyName].data, BINARY_ENCODING), 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', '', attachmentBody, {}, uploadUri); + + 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', '', attachmentBody, {}, uploadUri); + + // 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(() => { + resolve(); + }, (check_after_secs as number) * 1000); + }); + } } + + mediaIds.push(response.media_id_string); } body.media_ids = mediaIds.join(','); @@ -133,6 +219,47 @@ export class Twitter implements INodeType { responseData = await twitterApiRequest.call(this, 'POST', '/statuses/update.json', body); } + // 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}`; + } + } + + 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; + } + } } if (Array.isArray(responseData)) { returnData.push.apply(returnData, responseData as IDataObject[]); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index a898058634..3f28db9dec 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -321,6 +321,7 @@ "gm": "^1.23.1", "googleapis": "~50.0.0", "imap-simple": "^4.3.0", + "iso-639-1": "^2.1.3", "jsonwebtoken": "^8.5.1", "lodash.get": "^4.4.2", "lodash.set": "^4.3.2",