From eb2f14d06df87599692998f1df758fcd29c6c8ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 4 Feb 2021 05:37:03 -0300 Subject: [PATCH] :sparkles: Add Reddit Node (#1345) * Set up initial scaffolding and auth * Add grant type to credentials * Add Account operations and OAuth2 request * Add post submission functionality * Refactor resources into resource descriptions * Refactor request for no auth URL * Refactor submission description for consistency * Add listing resource * Refactor My Account resource into details * Add request for all items * Add listings for specific subreddits * Fix minor linting details * Add subreddit resource * Add All-Reddit and Subreddit resource descriptions * Adjust display options for credentials * Add subreddit search functionality * Clean up auth parameter * Add user resource with GET endpoint * Add user description * Add submission search and commenting functionality * Clean up logging and comments * Fix minor details * Fix casing in properties * Add dividers to execute() method * Refactor per feedback * Remove unused description * Add punctuation to property descriptions * Fix resources indentation * Add resource dividers * Remove deprecated sidebar option * Make subreddit:get responses consistent * Remove returnAll and limit from subreddit:get * Flatten user:get response for about * Rename comment target property * Remove best property from post:getAll * Enrich subreddit search by keyword operation * Remove unneeded scopes * Add endpoint documentation * Add scaffolding for post comment * Add all operations for postComment resource * Add all operations for post resource * Refactor subreddit:getAll * Fix postComment:getAll * Flatten responses for profile:get * :zap: Improvements * Fix response traversal for postComment:add * Flatten response for postComment:reply * Fix subreddit:getAll with keyword search * Fix pagination to enforce limit * Wrap unauthenticated API call in try-catch block * Add 404 error for empty array responses * Revert "Fix pagination to enforce limit" This reverts commit 72548d952378a2f899517522b5ba4950a940c6e4. * Turn user:get (gilded) into listing * :zap: Small improvement * :zap: Improve Reddit-Node Co-authored-by: ricardo Co-authored-by: Jan Oberhauser --- .../RedditOAuth2Api.credentials.ts | 69 +++ .../nodes/Reddit/GenericFunctions.ts | 145 ++++++ .../nodes/Reddit/PostCommentDescription.ts | 231 ++++++++++ .../nodes/Reddit/PostDescription.ts | 351 +++++++++++++++ .../nodes/Reddit/ProfileDescription.ts | 80 ++++ .../nodes-base/nodes/Reddit/Reddit.node.ts | 425 ++++++++++++++++++ .../nodes/Reddit/SubredditDescription.ts | 162 +++++++ .../nodes/Reddit/UserDescription.ts | 140 ++++++ packages/nodes-base/nodes/Reddit/reddit.svg | 1 + packages/nodes-base/package.json | 2 + 10 files changed, 1606 insertions(+) create mode 100644 packages/nodes-base/credentials/RedditOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/nodes/Reddit/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Reddit/PostCommentDescription.ts create mode 100644 packages/nodes-base/nodes/Reddit/PostDescription.ts create mode 100644 packages/nodes-base/nodes/Reddit/ProfileDescription.ts create mode 100644 packages/nodes-base/nodes/Reddit/Reddit.node.ts create mode 100644 packages/nodes-base/nodes/Reddit/SubredditDescription.ts create mode 100644 packages/nodes-base/nodes/Reddit/UserDescription.ts create mode 100644 packages/nodes-base/nodes/Reddit/reddit.svg diff --git a/packages/nodes-base/credentials/RedditOAuth2Api.credentials.ts b/packages/nodes-base/credentials/RedditOAuth2Api.credentials.ts new file mode 100644 index 0000000000..fe7def1a7a --- /dev/null +++ b/packages/nodes-base/credentials/RedditOAuth2Api.credentials.ts @@ -0,0 +1,69 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +const scopes = [ + 'identity', + 'edit', + 'history', + 'mysubreddits', + 'read', + 'save', + 'submit', +]; + +// https://github.com/reddit-archive/reddit/wiki/OAuth2 + +export class RedditOAuth2Api implements ICredentialType { + name = 'redditOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Reddit OAuth2 API'; + documentationUrl = 'reddit'; + properties = [ + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: 'response_type=code', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: 'grant_type=authorization_code', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: 'duration=permanent', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://www.reddit.com/api/v1/authorize', + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://www.reddit.com/api/v1/access_token', + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: scopes.join(' '), + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'header', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Reddit/GenericFunctions.ts b/packages/nodes-base/nodes/Reddit/GenericFunctions.ts new file mode 100644 index 0000000000..32aa3d899f --- /dev/null +++ b/packages/nodes-base/nodes/Reddit/GenericFunctions.ts @@ -0,0 +1,145 @@ +import { + IExecuteFunctions, + IHookFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +import { + OptionsWithUri, +} from 'request'; + +/** + * Make an authenticated or unauthenticated API request to Reddit. + */ +export async function redditApiRequest( + this: IHookFunctions | IExecuteFunctions, + method: string, + endpoint: string, + qs: IDataObject, +): Promise { // tslint:disable-line:no-any + + const resource = this.getNodeParameter('resource', 0) as string; + + const authRequired = ['profile', 'post', 'postComment'].includes(resource); + + qs.api_type = 'json'; + + const options: OptionsWithUri = { + headers: { + 'user-agent': 'n8n', + }, + method, + uri: authRequired ? `https://oauth.reddit.com/${endpoint}` : `https://www.reddit.com/${endpoint}`, + qs, + json: true, + }; + + if (!Object.keys(qs).length) { + delete options.qs; + } + + if (authRequired) { + let response; + + try { + response = await this.helpers.requestOAuth2.call(this, 'redditOAuth2Api', options); + } catch (error) { + if (error.response.body && error.response.body.message) { + const message = error.response.body.message; + throw new Error(`Reddit error response [${error.statusCode}]: ${message}`); + } + } + + if ((response.errors && response.errors.length !== 0) || (response.json && response.json.errors && response.json.errors.length !== 0)) { + const errors = response?.errors || response?.json?.errors; + const errorMessage = errors.map((error: []) => error.join('-')); + + throw new Error(`Reddit error response [400]: ${errorMessage.join('|')}`); + } + + return response; + + } else { + + try { + return await this.helpers.request.call(this, options); + } catch (error) { + const errorMessage = error?.response?.body?.message; + if (errorMessage) { + throw new Error(`Reddit error response [${error.statusCode}]: ${errorMessage}`); + } + throw error; + } + } +} + +/** + * Make an unauthenticated API request to Reddit and return all results. + */ +export async function redditApiRequestAllItems( + this: IHookFunctions | IExecuteFunctions, + method: string, + endpoint: string, + qs: IDataObject, +): Promise { // tslint:disable-line:no-any + + let responseData; + const returnData: IDataObject[] = []; + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + const returnAll = this.getNodeParameter('returnAll', 0, false) as boolean; + + qs.limit = 100; + + do { + responseData = await redditApiRequest.call(this, method, endpoint, qs); + if (!Array.isArray(responseData)) { + qs.after = responseData.data.after; + } + + if (endpoint === 'api/search_subreddits.json') { + responseData.subreddits.forEach((child: any) => returnData.push(child)); // tslint:disable-line:no-any + } else if (resource === 'postComment' && operation === 'getAll') { + responseData[1].data.children.forEach((child: any) => returnData.push(child.data)); // tslint:disable-line:no-any + } else { + responseData.data.children.forEach((child: any) => returnData.push(child.data)); // tslint:disable-line:no-any + } + if (qs.limit && returnData.length >= qs.limit && returnAll === false) { + return returnData; + } + + } while (responseData.data && responseData.data.after); + + return returnData; +} + +/** + * Handles a large Reddit listing by returning all items or up to a limit. + */ +export async function handleListing( + this: IExecuteFunctions, + i: number, + endpoint: string, + qs: IDataObject = {}, + requestMethod: 'GET' | 'POST' = 'GET', +): Promise { // tslint:disable-line:no-any + + let responseData; + + const returnAll = this.getNodeParameter('returnAll', i); + + if (returnAll) { + responseData = await redditApiRequestAllItems.call(this, requestMethod, endpoint, qs); + } else { + const limit = this.getNodeParameter('limit', i); + qs.limit = limit; + responseData = await redditApiRequestAllItems.call(this, requestMethod, endpoint, qs); + responseData = responseData.slice(0, limit); + } + + return responseData; +} diff --git a/packages/nodes-base/nodes/Reddit/PostCommentDescription.ts b/packages/nodes-base/nodes/Reddit/PostCommentDescription.ts new file mode 100644 index 0000000000..ec2880df7b --- /dev/null +++ b/packages/nodes-base/nodes/Reddit/PostCommentDescription.ts @@ -0,0 +1,231 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const postCommentOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + default: 'create', + description: 'Operation to perform', + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a top-level comment in a post', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all comments in a post', + }, + { + name: 'Delete', + value: 'delete', + description: 'Remove a comment from a post', + }, + { + name: 'Reply', + value: 'reply', + description: 'Write a reply to a comment in a post', + }, + ], + displayOptions: { + show: { + resource: [ + 'postComment', + ], + }, + }, + }, +] as INodeProperties[]; + +export const postCommentFields = [ + // ---------------------------------- + // postComment: create + // ---------------------------------- + { + displayName: 'Post ID', + name: 'postId', + type: 'string', + required: true, + default: '', + description: 'ID of the post to write the comment to. Found in the post URL:
/r/[subreddit_name]/comments/[post_id]/[post_title]', + placeholder: 'l0me7x', + displayOptions: { + show: { + resource: [ + 'postComment', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Comment Text', + name: 'commentText', + type: 'string', + required: true, + default: '', + description: 'Text of the comment. Markdown supported.', + displayOptions: { + show: { + resource: [ + 'postComment', + ], + operation: [ + 'create', + ], + }, + }, + }, + + // ---------------------------------- + // postComment: getAll + // ---------------------------------- + { + displayName: 'Subreddit', + name: 'subreddit', + type: 'string', + required: true, + default: '', + description: 'The name of subreddit where the post is.', + displayOptions: { + show: { + resource: [ + 'postComment', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Post ID', + name: 'postId', + type: 'string', + required: true, + default: '', + description: 'ID of the post to get all comments from. Found in the post URL:
/r/[subreddit_name]/comments/[post_id]/[post_title]', + placeholder: 'l0me7x', + displayOptions: { + show: { + resource: [ + 'postComment', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Return all results.', + displayOptions: { + show: { + resource: [ + 'postComment', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 100, + description: 'The number of results to return.', + typeOptions: { + minValue: 1, + maxValue: 100, + }, + displayOptions: { + show: { + resource: [ + 'postComment', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + + // ---------------------------------- + // postComment: delete + // ---------------------------------- + { + displayName: 'Comment ID', + name: 'commentId', + type: 'string', + required: true, + default: '', + description: 'ID of the comment to remove. Found in the comment URL:
/r/[subreddit_name]/comments/[post_id]/[post_title]/[comment_id]', + placeholder: 'gla7fmt', + displayOptions: { + show: { + resource: [ + 'postComment', + ], + operation: [ + 'delete', + ], + }, + }, + }, + + // ---------------------------------- + // postComment: reply + // ---------------------------------- + { + displayName: 'Comment ID', + name: 'commentId', + type: 'string', + required: true, + default: '', + description: 'ID of the comment to reply to. To be found in the comment URL:
www.reddit.com/r/[subreddit_name]/comments/[post_id]/[post_title]/[comment_id]', + placeholder: 'gl9iroa', + displayOptions: { + show: { + resource: [ + 'postComment', + ], + operation: [ + 'reply', + ], + }, + }, + }, + { + displayName: 'Reply Text', + name: 'replyText', + type: 'string', + required: true, + default: '', + description: 'Text of the reply. Markdown supported.', + displayOptions: { + show: { + resource: [ + 'postComment', + ], + operation: [ + 'reply', + ], + }, + }, + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Reddit/PostDescription.ts b/packages/nodes-base/nodes/Reddit/PostDescription.ts new file mode 100644 index 0000000000..cce6a0f582 --- /dev/null +++ b/packages/nodes-base/nodes/Reddit/PostDescription.ts @@ -0,0 +1,351 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const postOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + default: 'create', + description: 'Operation to perform', + options: [ + { + name: 'Create', + value: 'create', + description: 'Submit a post to a subreddit', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a post from a subreddit', + }, + { + name: 'Get', + value: 'get', + description: 'Get a post from a subreddit', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all posts from a subreddit', + }, + ], + displayOptions: { + show: { + resource: [ + 'post', + ], + }, + }, + }, +] as INodeProperties[]; + +export const postFields = [ + // ---------------------------------- + // post: create + // ---------------------------------- + { + displayName: 'Subreddit', + name: 'subreddit', + type: 'string', + required: true, + default: '', + description: 'Subreddit to create the post in.', + displayOptions: { + show: { + resource: [ + 'post', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Kind', + name: 'kind', + type: 'options', + options: [ + { + name: 'Text Post', + value: 'self', + }, + { + name: 'Link Post', + value: 'link', + }, + { + name: 'Image Post', + value: 'image', + }, + ], + default: 'self', + description: 'The kind of the post to create.', + displayOptions: { + show: { + resource: [ + 'post', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + required: true, + default: '', + description: 'Title of the post, up to 300 characters long.', + displayOptions: { + show: { + resource: [ + 'post', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'URL', + name: 'url', + type: 'string', + required: true, + default: '', + description: 'URL of the post.', + displayOptions: { + show: { + resource: [ + 'post', + ], + operation: [ + 'create', + ], + kind: [ + 'link', + 'image', + ], + }, + }, + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + required: true, + default: '', + description: 'Text of the post. Markdown supported.', + displayOptions: { + show: { + resource: [ + 'post', + ], + operation: [ + 'create', + ], + kind: [ + 'self', + ], + }, + }, + }, + { + displayName: 'Resubmit', + name: 'resubmit', + type: 'boolean', + default: false, + description: 'If toggled on, the URL will be posted even if
it was already posted to the subreddit before.
Otherwise, the re-posting will trigger an error.', + displayOptions: { + show: { + resource: [ + 'post', + ], + operation: [ + 'create', + ], + kind: [ + 'link', + 'image', + ], + }, + }, + }, + + // ---------------------------------- + // post: delete + // ---------------------------------- + { + displayName: 'Post ID', + name: 'postId', + type: 'string', + required: true, + default: '', + description: 'ID of the post to delete. Found in the post URL:
/r/[subreddit_name]/comments/[post_id]/[post_title]', + placeholder: 'gla7fmt', + displayOptions: { + show: { + resource: [ + 'post', + ], + operation: [ + 'delete', + ], + }, + }, + }, + + // ---------------------------------- + // post: get + // ---------------------------------- + { + displayName: 'Subreddit', + name: 'subreddit', + type: 'string', + required: true, + default: '', + description: 'The name of subreddit to retrieve the post from.', + displayOptions: { + show: { + resource: [ + 'post', + ], + operation: [ + 'get', + ], + }, + }, + }, + { + displayName: 'Post ID', + name: 'postId', + type: 'string', + required: true, + default: '', + description: 'ID of the post to retrieve. Found in the post URL:
/r/[subreddit_name]/comments/[post_id]/[post_title]', + placeholder: 'l0me7x', + displayOptions: { + show: { + resource: [ + 'post', + ], + operation: [ + 'get', + ], + }, + }, + }, + + // ---------------------------------- + // post: getAll + // ---------------------------------- + { + displayName: 'Subreddit', + name: 'subreddit', + type: 'string', + required: true, + default: '', + description: 'The name of subreddit to retrieve the posts from.', + displayOptions: { + show: { + resource: [ + 'post', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Return all results.', + displayOptions: { + show: { + resource: [ + 'post', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 100, + description: 'The number of results to return.', + typeOptions: { + minValue: 1, + maxValue: 100, + }, + displayOptions: { + show: { + resource: [ + 'post', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + displayOptions: { + show: { + resource: [ + 'post', + ], + operation: [ + 'getAll', + ], + }, + }, + default: {}, + placeholder: 'Add Field', + options: [ + { + displayName: 'Category', + name: 'category', + type: 'options', + required: true, + default: 'top', + description: 'Category of the posts to retrieve.', + options: [ + { + name: 'Top Posts', + value: 'top', + }, + { + name: 'Hot Posts', + value: 'hot', + }, + { + name: 'New Posts', + value: 'new', + }, + { + name: 'Rising Posts', + value: 'rising', + }, + ], + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Reddit/ProfileDescription.ts b/packages/nodes-base/nodes/Reddit/ProfileDescription.ts new file mode 100644 index 0000000000..1ff0730603 --- /dev/null +++ b/packages/nodes-base/nodes/Reddit/ProfileDescription.ts @@ -0,0 +1,80 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const profileOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'profile', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + }, + ], + default: 'get', + description: 'Operation to perform', + }, +] as INodeProperties[]; + + +export const profileFields = [ + { + displayName: 'Details', + name: 'details', + type: 'options', + required: true, + default: 'identity', + description: 'Details of my account to retrieve.', + options: [ + { + name: 'Identity', + value: 'identity', + description: 'Return the identity of the logged-in user', + }, + { + name: 'Blocked Users', + value: 'blockedUsers', + description: 'Return the blocked users of the logged-in user', + }, + { + name: 'Friends', + value: 'friends', + description: 'Return the friends of the logged-in user', + }, + { + name: 'Karma', + value: 'karma', + description: 'Return the subreddit karma for the logged-in user', + }, + { + name: 'Preferences', + value: 'prefs', + description: 'Return the settings preferences of the logged-in user', + }, + { + name: 'Trophies', + value: 'trophies', + description: 'Return the trophies of the logged-in user', + }, + ], + displayOptions: { + show: { + resource: [ + 'profile', + ], + operation: [ + 'get', + ], + }, + }, + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Reddit/Reddit.node.ts b/packages/nodes-base/nodes/Reddit/Reddit.node.ts new file mode 100644 index 0000000000..29d41a39d3 --- /dev/null +++ b/packages/nodes-base/nodes/Reddit/Reddit.node.ts @@ -0,0 +1,425 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + handleListing, + redditApiRequest, +} from './GenericFunctions'; + +import { + postCommentFields, + postCommentOperations, +} from './PostCommentDescription'; + +import { + postFields, + postOperations, +} from './PostDescription'; + +import { + profileFields, + profileOperations, +} from './ProfileDescription'; + +import { + subredditFields, + subredditOperations, +} from './SubredditDescription'; + +import { + userFields, + userOperations, +} from './UserDescription'; + +export class Reddit implements INodeType { + description: INodeTypeDescription = { + displayName: 'Reddit', + name: 'reddit', + icon: 'file:reddit.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume the Reddit API', + defaults: { + name: 'Reddit', + color: '#ff5700', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'redditOAuth2Api', + required: true, + displayOptions: { + show: { + resource: [ + 'postComment', + 'post', + 'profile', + ], + }, + }, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Post', + value: 'post', + }, + { + name: 'Post Comment', + value: 'postComment', + }, + { + name: 'Profile', + value: 'profile', + }, + { + name: 'Subreddit', + value: 'subreddit', + }, + { + name: 'User', + value: 'user', + }, + ], + default: 'post', + description: 'Resource to consume', + }, + ...postCommentOperations, + ...postCommentFields, + ...profileOperations, + ...profileFields, + ...subredditOperations, + ...subredditFields, + ...postOperations, + ...postFields, + ...userOperations, + ...userFields, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + let responseData; + const returnData: IDataObject[] = []; + + for (let i = 0; i < items.length; i++) { + + // ********************************************************************* + // post + // ********************************************************************* + + if (resource === 'post') { + + if (operation === 'create') { + // ---------------------------------- + // post: create + // ---------------------------------- + + // https://www.reddit.com/dev/api/#POST_api_submit + + const qs: IDataObject = { + title: this.getNodeParameter('title', i), + sr: this.getNodeParameter('subreddit', i), + kind: this.getNodeParameter('kind', i), + }; + + qs.kind === 'self' + ? qs.text = this.getNodeParameter('text', i) + : qs.url = this.getNodeParameter('url', i); + + if (qs.url) { + qs.resubmit = this.getNodeParameter('resubmit', i); + } + + responseData = await redditApiRequest.call(this, 'POST', 'api/submit', qs); + + responseData = responseData.json.data; + + } else if (operation === 'delete') { + // ---------------------------------- + // post: delete + // ---------------------------------- + + // https://www.reddit.com/dev/api/#POST_api_del + + const postTypePrefix = 't3_'; + + const qs: IDataObject = { + id: postTypePrefix + this.getNodeParameter('postId', i), + }; + + await redditApiRequest.call(this, 'POST', 'api/del', qs); + + responseData = { success: true }; + + } else if (operation === 'get') { + // ---------------------------------- + // post: get + // ---------------------------------- + + const subreddit = this.getNodeParameter('subreddit', i); + const postId = this.getNodeParameter('postId', i) as string; + const endpoint = `r/${subreddit}/comments/${postId}.json`; + + responseData = await redditApiRequest.call(this, 'GET', endpoint, {}); + responseData = responseData[0].data.children[0].data; + + } else if (operation === 'getAll') { + // ---------------------------------- + // post: getAll + // ---------------------------------- + + // https://www.reddit.com/dev/api/#GET_hot + // https://www.reddit.com/dev/api/#GET_new + // https://www.reddit.com/dev/api/#GET_rising + // https://www.reddit.com/dev/api/#GET_{sort} + + const subreddit = this.getNodeParameter('subreddit', i); + let endpoint = `r/${subreddit}.json`; + + const { category } = this.getNodeParameter('filters', i) as { category: string }; + if (category) { + endpoint = `r/${subreddit}/${category}.json`; + } + + responseData = await handleListing.call(this, i, endpoint); + + } + + } else if (resource === 'postComment') { + + // ********************************************************************* + // postComment + // ********************************************************************* + + if (operation === 'create') { + // ---------------------------------- + // postComment: create + // ---------------------------------- + + // https://www.reddit.com/dev/api/#POST_api_comment + + const postTypePrefix = 't3_'; + + const qs: IDataObject = { + text: this.getNodeParameter('commentText', i), + thing_id: postTypePrefix + this.getNodeParameter('postId', i), + }; + + responseData = await redditApiRequest.call(this, 'POST', 'api/comment', qs); + responseData = responseData.json.data.things[0].data; + + } else if (operation === 'getAll') { + // ---------------------------------- + // postComment: getAll + // ---------------------------------- + + // https://www.reddit.com/r/{subrreddit}/comments/{postId}.json + + const subreddit = this.getNodeParameter('subreddit', i); + const postId = this.getNodeParameter('postId', i) as string; + const endpoint = `r/${subreddit}/comments/${postId}.json`; + + responseData = await handleListing.call(this, i, endpoint); + + } else if (operation === 'delete') { + // ---------------------------------- + // postComment: delete + // ---------------------------------- + + // https://www.reddit.com/dev/api/#POST_api_del + + const commentTypePrefix = 't1_'; + + const qs: IDataObject = { + id: commentTypePrefix + this.getNodeParameter('commentId', i), + }; + + await redditApiRequest.call(this, 'POST', 'api/del', qs); + + responseData = { success: true }; + + } else if (operation === 'reply') { + // ---------------------------------- + // postComment: reply + // ---------------------------------- + + // https://www.reddit.com/dev/api/#POST_api_comment + + const commentTypePrefix = 't1_'; + + const qs: IDataObject = { + text: this.getNodeParameter('replyText', i), + thing_id: commentTypePrefix + this.getNodeParameter('commentId', i), + }; + + responseData = await redditApiRequest.call(this, 'POST', 'api/comment', qs); + responseData = responseData.json.data.things[0].data; + } + + } else if (resource === 'profile') { + // ********************************************************************* + // pprofile + // ********************************************************************* + + if (operation === 'get') { + // ---------------------------------- + // profile: get + // ---------------------------------- + + // https://www.reddit.com/dev/api/#GET_api_v1_me + // https://www.reddit.com/dev/api/#GET_api_v1_me_karma + // https://www.reddit.com/dev/api/#GET_api_v1_me_prefs + // https://www.reddit.com/dev/api/#GET_api_v1_me_trophies + // https://www.reddit.com/dev/api/#GET_prefs_{where} + + const endpoints: { [key: string]: string } = { + identity: 'me', + blockedUsers: 'me/blocked', + friends: 'me/friends', + karma: 'me/karma', + prefs: 'me/prefs', + trophies: 'me/trophies', + }; + + const details = this.getNodeParameter('details', i) as string; + const endpoint = `api/v1/${endpoints[details]}`; + responseData = await redditApiRequest.call(this, 'GET', endpoint, {}); + + if (details === 'identity') { + responseData = responseData.features; + } else if (details === 'friends') { + responseData = responseData.data.children; + if (!responseData.length) { + throw new Error('Reddit error response [404]: Not Found'); + } + } else if (details === 'karma') { + responseData = responseData.data; + if (!responseData.length) { + throw new Error('Reddit error response [404]: Not Found'); + } + } else if (details === 'trophies') { + responseData = responseData.data.trophies.map((trophy: IDataObject) => trophy.data); + } + } + + } else if (resource === 'subreddit') { + + // ********************************************************************* + // subreddit + // ********************************************************************* + + if (operation === 'get') { + // ---------------------------------- + // subreddit: get + // ---------------------------------- + + // https://www.reddit.com/dev/api/#GET_r_{subreddit}_about + // https://www.reddit.com/dev/api/#GET_r_{subreddit}_about_rules + + const subreddit = this.getNodeParameter('subreddit', i); + const content = this.getNodeParameter('content', i) as string; + const endpoint = `r/${subreddit}/about/${content}.json`; + + responseData = await redditApiRequest.call(this, 'GET', endpoint, {}); + + if (content === 'rules') { + responseData = responseData.rules; + } else if (content === 'about') { + responseData = responseData.data; + } + + } else if (operation === 'getAll') { + // ---------------------------------- + // subreddit: getAll + // ---------------------------------- + + // https://www.reddit.com/dev/api/#GET_api_trending_subreddits + // https://www.reddit.com/dev/api/#POST_api_search_subreddits + // https://www.reddit.com/r/subreddits.json + + const filters = this.getNodeParameter('filters', i) as IDataObject; + + if (filters.trending) { + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + const endpoint = 'api/trending_subreddits.json'; + responseData = await redditApiRequest.call(this, 'GET', endpoint, {}); + responseData = responseData.subreddit_names.map((name: string) => ({ name })); + if (returnAll === false) { + const limit = this.getNodeParameter('limit', 0) as number; + responseData = responseData.splice(0, limit); + } + + } else if (filters.keyword) { + const qs: IDataObject = {}; + qs.query = filters.keyword; + + const endpoint = 'api/search_subreddits.json'; + responseData = await redditApiRequest.call(this, 'POST', endpoint, qs); + + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + + if (returnAll === false) { + const limit = this.getNodeParameter('limit', 0) as number; + responseData = responseData.subreddits.splice(0, limit); + } + } else { + const endpoint = 'r/subreddits.json'; + responseData = await handleListing.call(this, i, endpoint); + } + } + + } else if (resource === 'user') { + // ********************************************************************* + // user + // ********************************************************************* + + if (operation === 'get') { + + // ---------------------------------- + // user: get + // ---------------------------------- + + // https://www.reddit.com/dev/api/#GET_user_{username}_{where} + + const username = this.getNodeParameter('username', i) as string; + const details = this.getNodeParameter('details', i) as string; + const endpoint = `user/${username}/${details}.json`; + + responseData = details === 'about' + ? await redditApiRequest.call(this, 'GET', endpoint, {}) + : await handleListing.call(this, i, endpoint); + + if (details === 'about') { + responseData = responseData.data; + } + } + } + + Array.isArray(responseData) + ? returnData.push(...responseData) + : returnData.push(responseData); + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Reddit/SubredditDescription.ts b/packages/nodes-base/nodes/Reddit/SubredditDescription.ts new file mode 100644 index 0000000000..c7c2c7626f --- /dev/null +++ b/packages/nodes-base/nodes/Reddit/SubredditDescription.ts @@ -0,0 +1,162 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const subredditOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + default: 'get', + description: 'Operation to perform', + options: [ + { + name: 'Get', + value: 'get', + description: 'Retrieve background information about a subreddit.', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve information about subreddits from all of Reddit.', + }, + ], + displayOptions: { + show: { + resource: [ + 'subreddit', + ], + }, + }, + }, +] as INodeProperties[]; + +export const subredditFields = [ + // ---------------------------------- + // subreddit: get + // ---------------------------------- + { + displayName: 'Content', + name: 'content', + type: 'options', + required: true, + default: 'about', + description: 'Subreddit content to retrieve.', + options: [ + { + name: 'About', + value: 'about', + }, + { + name: 'Rules', + value: 'rules', + }, + ], + displayOptions: { + show: { + resource: [ + 'subreddit', + ], + operation: [ + 'get', + ], + }, + }, + }, + { + displayName: 'Subreddit', + name: 'subreddit', + type: 'string', + required: true, + default: '', + description: 'The name of subreddit to retrieve the content from.', + displayOptions: { + show: { + resource: [ + 'subreddit', + ], + operation: [ + 'get', + ], + }, + }, + }, + + // ---------------------------------- + // subreddit: getAll + // ---------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Return all results.', + displayOptions: { + show: { + resource: [ + 'subreddit', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 100, + description: 'The number of results to return.', + typeOptions: { + minValue: 1, + maxValue: 100, + }, + displayOptions: { + show: { + resource: [ + 'subreddit', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Field', + default: {}, + options: [ + { + displayName: 'Keyword', + name: 'keyword', + type: 'string', + default: '', + description: 'The keyword for the subreddit search.', + }, + { + displayName: 'Trending', + name: 'trending', + type: 'boolean', + default: false, + description: 'Currently trending subreddits in all of Reddit.', + }, + ], + displayOptions: { + show: { + resource: [ + 'subreddit', + ], + operation: [ + 'getAll', + ], + }, + }, + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Reddit/UserDescription.ts b/packages/nodes-base/nodes/Reddit/UserDescription.ts new file mode 100644 index 0000000000..e181c3c6d7 --- /dev/null +++ b/packages/nodes-base/nodes/Reddit/UserDescription.ts @@ -0,0 +1,140 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const userOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'user', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + }, + ], + default: 'get', + description: 'Operation to perform', + }, +] as INodeProperties[]; + +export const userFields = [ + { + displayName: 'Username', + name: 'username', + type: 'string', + required: true, + default: '', + description: 'Reddit ID of the user to retrieve.', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'get', + ], + }, + }, + }, + { + displayName: 'Details', + name: 'details', + type: 'options', + required: true, + default: 'about', + description: 'Details of the user to retrieve.', + options: [ + { + name: 'About', + value: 'about', + }, + { + name: 'Comments', + value: 'comments', + }, + { + name: 'Gilded', + value: 'gilded', + }, + { + name: 'Overview', + value: 'overview', + }, + { + name: 'Submitted', + value: 'submitted', + }, + ], + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'get', + ], + }, + }, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Return all results.', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'get', + ], + details: [ + 'overview', + 'submitted', + 'comments', + 'gilded', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 100, + description: 'The number of results to return.', + typeOptions: { + minValue: 1, + maxValue: 100, + }, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'get', + ], + details: [ + 'comments', + 'gilded', + 'overview', + 'submitted', + ], + returnAll: [ + false, + ], + }, + }, + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Reddit/reddit.svg b/packages/nodes-base/nodes/Reddit/reddit.svg new file mode 100644 index 0000000000..507bbb9ed7 --- /dev/null +++ b/packages/nodes-base/nodes/Reddit/reddit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 9356146ba8..2ae7d0f5e9 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -177,6 +177,7 @@ "dist/credentials/QuestDb.credentials.js", "dist/credentials/QuickBaseApi.credentials.js", "dist/credentials/RabbitMQ.credentials.js", + "dist/credentials/RedditOAuth2Api.credentials.js", "dist/credentials/Redis.credentials.js", "dist/credentials/RocketchatApi.credentials.js", "dist/credentials/RundeckApi.credentials.js", @@ -425,6 +426,7 @@ "dist/nodes/ReadBinaryFile.node.js", "dist/nodes/ReadBinaryFiles.node.js", "dist/nodes/ReadPdf.node.js", + "dist/nodes/Reddit/Reddit.node.js", "dist/nodes/Redis/Redis.node.js", "dist/nodes/RenameKeys.node.js", "dist/nodes/Rocketchat/Rocketchat.node.js",