diff --git a/backend/src/api/integration/helpers/discourseCreateOrUpdate.ts b/backend/src/api/integration/helpers/discourseCreateOrUpdate.ts new file mode 100644 index 0000000000..28f8476e73 --- /dev/null +++ b/backend/src/api/integration/helpers/discourseCreateOrUpdate.ts @@ -0,0 +1,9 @@ +import Permissions from '../../../security/permissions' +import IntegrationService from '../../../services/integrationService' +import PermissionChecker from '../../../services/user/permissionChecker' + +export default async (req, res) => { + new PermissionChecker(req).validateHas(Permissions.values.tenantEdit) + const payload = await new IntegrationService(req).discourseConnectOrUpdate(req.body) + await req.responseHandler.success(req, res, payload) +} diff --git a/backend/src/api/integration/helpers/discourseTestWebhook.ts b/backend/src/api/integration/helpers/discourseTestWebhook.ts new file mode 100644 index 0000000000..ca52d73255 --- /dev/null +++ b/backend/src/api/integration/helpers/discourseTestWebhook.ts @@ -0,0 +1,15 @@ +import Permissions from '../../../security/permissions' +import PermissionChecker from '../../../services/user/permissionChecker' +import IncomingWebhookRepository from '../../../database/repositories/incomingWebhookRepository' +import SequelizeRepository from '../../../database/repositories/sequelizeRepository' + +export default async (req, res) => { + new PermissionChecker(req).validateHas(Permissions.values.tenantEdit) + + const options = await SequelizeRepository.getDefaultIRepositoryOptions() + const repo = new IncomingWebhookRepository(options) + + const isWebhooksReceived = await repo.checkWebhooksExistForIntegration(req.body.integrationId) + + await req.responseHandler.success(req, res, { isWebhooksReceived }) +} diff --git a/backend/src/api/integration/helpers/discourseValidator.ts b/backend/src/api/integration/helpers/discourseValidator.ts new file mode 100644 index 0000000000..14e3b1878e --- /dev/null +++ b/backend/src/api/integration/helpers/discourseValidator.ts @@ -0,0 +1,30 @@ +import axios from 'axios' +import Error400 from '../../../errors/Error400' +import Permissions from '../../../security/permissions' +import PermissionChecker from '../../../services/user/permissionChecker' + +export default async (req, res) => { + new PermissionChecker(req).validateHasAny([ + Permissions.values.integrationCreate, + Permissions.values.integrationEdit, + ]) + + const { apiKey, apiUsername, forumHostname } = req.body + + if (apiKey && apiUsername && forumHostname) { + try { + const result = await axios.get(`${forumHostname}/admin/users/list/active.json`, { + headers: { + 'Api-Key': apiKey, + 'Api-Username': apiUsername, + }, + }) + if (result.status === 200 && result.data && result.data.length > 0) { + return req.responseHandler.success(req, res, result.data) + } + } catch (e) { + return req.responseHandler.error(req, res, new Error400(req.language)) + } + } + return req.responseHandler.error(req, res, new Error400(req.language)) +} diff --git a/backend/src/api/integration/index.ts b/backend/src/api/integration/index.ts index 05f297c670..0dfe3cf085 100644 --- a/backend/src/api/integration/index.ts +++ b/backend/src/api/integration/index.ts @@ -60,6 +60,21 @@ export default (app) => { safeWrap(require('./helpers/stackOverflowVolume').default), ) + app.post( + '/tenant/:tenantId/discourse-connect', + safeWrap(require('./helpers/discourseCreateOrUpdate').default), + ) + + app.post( + '/tenant/:tenantId/discourse-validate', + safeWrap(require('./helpers/discourseValidator').default), + ) + + app.post( + '/tenant/:tenantId/discourse-test-webhook', + safeWrap(require('./helpers/discourseTestWebhook').default), + ) + if (TWITTER_CONFIG.clientId) { /** * Using the passport.authenticate this endpoint forces a diff --git a/backend/src/api/webhooks/discourse.ts b/backend/src/api/webhooks/discourse.ts new file mode 100644 index 0000000000..8ea2dd9731 --- /dev/null +++ b/backend/src/api/webhooks/discourse.ts @@ -0,0 +1,89 @@ +import IntegrationRepository from '../../database/repositories/integrationRepository' +import TenantRepository from '../../database/repositories/tenantRepository' +import SequelizeRepository from '../../database/repositories/sequelizeRepository' +import IncomingWebhookRepository from '../../database/repositories/incomingWebhookRepository' +import { WebhookType } from '../../types/webhooks' +import { sendNodeWorkerMessage } from '../../serverless/utils/nodeWorkerSQS' +import { NodeWorkerProcessWebhookMessage } from '../../types/mq/nodeWorkerProcessWebhookMessage' +import { verifyWebhookSignature } from '../../utils/crypto' +import { PlatformType } from '../../types/integrationEnums' + +export default async (req, res) => { + const signature = req.headers['x-discourse-event-signature'] + const eventId = req.headers['x-discourse-event-id'] + const eventType = req.headers['x-discourse-event-type'] + const event = req.headers['x-discourse-event'] + const data = req.body + + const options = await SequelizeRepository.getDefaultIRepositoryOptions() + const tenant = await TenantRepository.findById(req.params.tenantId, options) + const optionsWithTenant = await SequelizeRepository.getDefaultIRepositoryOptions(null, tenant) + const integration = (await IntegrationRepository.findByPlatform( + PlatformType.DISCOURSE, + optionsWithTenant, + )) as any + + if (integration) { + try { + if (!signature) { + req.log.error({ signature }, 'Discourse Webhook signature header missing!') + await req.responseHandler.success( + req, + res, + 'Discourse Webhook signature header missing!', + 200, + ) + return + } + + if ( + !verifyWebhookSignature(JSON.stringify(data), integration.settings.webhookSecret, signature) + ) { + req.log.error({ signature }, 'Discourse Webhook signature verification failed!') + await req.responseHandler.success( + req, + res, + 'Discourse Webhook signature verification failed!', + 200, + ) + return + } + } catch (error) { + req.log.error({ signature, error }, 'Internal error when verifying discourse webhook') + await req.responseHandler.success( + req, + res, + 'Internal error when verifying discourse webhook', + 200, + ) + return + } + + req.log.info({ integrationId: integration.id }, 'Incoming Discourse Webhook!') + const options = await SequelizeRepository.getDefaultIRepositoryOptions() + const repo = new IncomingWebhookRepository(options) + + const result = await repo.create({ + tenantId: integration.tenantId, + integrationId: integration.id, + type: WebhookType.DISCOURSE, + payload: { + signature, + eventId, + eventType, + event, + data, + }, + }) + + await sendNodeWorkerMessage( + integration.tenantId, + new NodeWorkerProcessWebhookMessage(integration.tenantId, result.id), + ) + + await req.responseHandler.success(req, res, {}, 204) + } else { + req.log.error({ tenant }, 'No integration found for incoming Discourse Webhook!') + await req.responseHandler.success(req, res, {}, 200) + } +} diff --git a/backend/src/api/webhooks/index.ts b/backend/src/api/webhooks/index.ts index 98012bbc49..bc7ace07c7 100644 --- a/backend/src/api/webhooks/index.ts +++ b/backend/src/api/webhooks/index.ts @@ -4,4 +4,5 @@ export default (app) => { app.post(`/github`, safeWrap(require('./github').default)) app.post(`/stripe`, safeWrap(require('./stripe').default)) app.post(`/sendgrid`, safeWrap(require('./sendgrid').default)) + app.post(`/discourse/:tenantId`, safeWrap(require('./discourse').default)) } diff --git a/backend/src/database/attributes/member/discourse.ts b/backend/src/database/attributes/member/discourse.ts new file mode 100644 index 0000000000..79af834a05 --- /dev/null +++ b/backend/src/database/attributes/member/discourse.ts @@ -0,0 +1,27 @@ +import { Attribute } from '../attribute' +import { AttributeType } from '../types' +import { MemberAttributes, MemberAttributeName } from './enums' + +export const DiscourseMemberAttributes: Attribute[] = [ + { + name: MemberAttributes[MemberAttributeName.URL].name, + label: MemberAttributes[MemberAttributeName.URL].label, + type: AttributeType.URL, + canDelete: false, + show: true, + }, + { + name: MemberAttributes[MemberAttributeName.BIO].name, + label: MemberAttributes[MemberAttributeName.BIO].label, + type: AttributeType.STRING, + canDelete: false, + show: true, + }, + { + name: MemberAttributes[MemberAttributeName.AVATAR_URL].name, + label: MemberAttributes[MemberAttributeName.AVATAR_URL].label, + type: AttributeType.URL, + canDelete: false, + show: false, + }, +] diff --git a/backend/src/database/repositories/incomingWebhookRepository.ts b/backend/src/database/repositories/incomingWebhookRepository.ts index 6521c0353f..2dfb8ee828 100644 --- a/backend/src/database/repositories/incomingWebhookRepository.ts +++ b/backend/src/database/repositories/incomingWebhookRepository.ts @@ -232,4 +232,30 @@ export default class IncomingWebhookRepository extends RepositoryBase< type: QueryTypes.DELETE, }) } + + async checkWebhooksExistForIntegration(integrationId: string): Promise { + interface QueryResult { + count: number + } + + const transaction = this.transaction + + const results: QueryResult[] = await this.seq.query( + ` + select count(*)::int as count + from "incomingWebhooks" + where "integrationId" = :integrationId + limit 1 + `, + { + replacements: { + integrationId, + }, + type: QueryTypes.SELECT, + transaction, + }, + ) + + return results.length > 0 && results[0].count > 0 + } } diff --git a/backend/src/i18n/en.ts b/backend/src/i18n/en.ts index 829c8baf65..da0264513e 100644 --- a/backend/src/i18n/en.ts +++ b/backend/src/i18n/en.ts @@ -216,6 +216,7 @@ const en = { discord: 'Discord', slack: 'Slack', hackernews: 'Hacker News', + discourse: 'Discourse', }, }, automation: { diff --git a/backend/src/serverless/integrations/grid/discordGrid.ts b/backend/src/serverless/integrations/grid/discordGrid.ts index d35026e221..7b6906b46a 100644 --- a/backend/src/serverless/integrations/grid/discordGrid.ts +++ b/backend/src/serverless/integrations/grid/discordGrid.ts @@ -10,4 +10,9 @@ export class DiscordGrid { score: 6, isContribution: true, } + + static topic: gridEntry = { + score: 8, + isContribution: true, + } } diff --git a/backend/src/serverless/integrations/grid/discourseGrid.ts b/backend/src/serverless/integrations/grid/discourseGrid.ts new file mode 100644 index 0000000000..f2234a77af --- /dev/null +++ b/backend/src/serverless/integrations/grid/discourseGrid.ts @@ -0,0 +1,23 @@ +import { gridEntry } from './grid' + +export class DiscourseGrid { + static create_topic: gridEntry = { + score: 8, + isContribution: true, + } + + static message_in_topic: gridEntry = { + score: 6, + isContribution: true, + } + + static join: gridEntry = { + score: 3, + isContribution: false, + } + + static like: gridEntry = { + score: 1, + isContribution: false, + } +} diff --git a/backend/src/serverless/integrations/services/integrationProcessor.ts b/backend/src/serverless/integrations/services/integrationProcessor.ts index 7b7c0f181c..c01a0852a5 100644 --- a/backend/src/serverless/integrations/services/integrationProcessor.ts +++ b/backend/src/serverless/integrations/services/integrationProcessor.ts @@ -19,6 +19,7 @@ import { SlackIntegrationService } from './integrations/slackIntegrationService' import { StackOverlflowIntegrationService } from './integrations/stackOverflowIntegrationService' import { TwitterIntegrationService } from './integrations/twitterIntegrationService' import { TwitterReachIntegrationService } from './integrations/twitterReachIntegrationService' +import { DiscourseIntegrationService } from './integrations/discourseIntegrationService' import { IntegrationServiceBase } from './integrationServiceBase' import { IntegrationTickProcessor } from './integrationTickProcessor' import { WebhookProcessor } from './webhookProcessor' @@ -45,6 +46,7 @@ export class IntegrationProcessor extends LoggingBase { new SlackIntegrationService(), new GithubIntegrationService(), new StackOverlflowIntegrationService(), + new DiscourseIntegrationService(), ] // add premium integrations diff --git a/backend/src/serverless/integrations/services/integrations/discourseIntegrationService.ts b/backend/src/serverless/integrations/services/integrations/discourseIntegrationService.ts new file mode 100644 index 0000000000..e9550d4ae5 --- /dev/null +++ b/backend/src/serverless/integrations/services/integrations/discourseIntegrationService.ts @@ -0,0 +1,590 @@ +import sanitizeHtml from 'sanitize-html' +import he from 'he' +import moment from 'moment/moment' +import { IntegrationServiceBase } from '../integrationServiceBase' +import { IntegrationType, PlatformType } from '../../../../types/integrationEnums' +import { + IIntegrationStream, + IPendingStream, + IProcessStreamResults, + IStepContext, + IProcessWebhookResults, +} from '../../../../types/integration/stepResult' +import { + DiscourseConnectionParams, + DiscourseCategoryResponse, + DiscourseTopicResponse, + DiscourseTopicsInput, + DiscoursePostsInput, + DiscoursePostsFromTopicResponse, + DiscoursePostsByIdsInput, + DiscoursePostsByIdsResponse, + DiscourseWebhookPost, + DiscourseWebhookUser, + DiscourseUserResponse, + DiscourseWebhookNotification, +} from '../../types/discourseTypes' +import { getDiscourseCategories } from '../../usecases/discourse/getCategories' +import { getDiscourseTopics } from '../../usecases/discourse/getTopics' +import { getDiscoursePostsFromTopic } from '../../usecases/discourse/getPostsFromTopic' +import { getDiscoursePostsByIds } from '../../usecases/discourse/getPostsByIds' +import { getDiscourseUserByUsername } from '../../usecases/discourse/getUser' +import { AddActivitiesSingle, Member } from '../../types/messageTypes' +import Operations from '../../../dbOperations/operations' +import { DiscourseMemberAttributes } from '../../../../database/attributes/member/discourse' +import MemberAttributeSettingsService from '../../../../services/memberAttributeSettingsService' +import { MemberAttributeName } from '../../../../database/attributes/member/enums' +import { DiscourseActivityType } from '../../../../types/activityTypes' +import { DiscourseGrid } from '../../grid/discourseGrid' +import type { PlatformIdentities } from '../../types/messageTypes' + +const BOT_USERNAMES = ['system', 'discobot'] + +const usernameIsBot = (username: string): boolean => BOT_USERNAMES.includes(username) + +enum DiscourseStreamType { + CATEGORIES = 'categories', + TOPICS_FROM_CATEGORY = 'topicsFromCategory', + POSTS_FROM_TOPIC = 'postsFromTopic', + POSTS_BY_IDS = 'postsByIds', +} + +enum DiscourseWebhookType { + POST_CREATED = 'post_created', + USER_CREATED = 'user_created', + LIKED_A_POST = 'notification_created', +} + +interface DiscourseProcessResult { + type: DiscourseStreamType + data: + | DiscourseCategoryResponse + | DiscourseTopicResponse + | DiscoursePostsFromTopicResponse + | DiscoursePostsByIdsResponse +} + +/* eslint class-methods-use-this: 0 */ + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +/* eslint-disable no-case-declarations */ + +export class DiscourseIntegrationService extends IntegrationServiceBase { + constructor() { + // disable polling - new data will come trhough webhooks + super(IntegrationType.DISCOURSE, -1) + } + + async createMemberAttributes(context: IStepContext): Promise { + const service = new MemberAttributeSettingsService(context.repoContext) + await service.createPredefined(DiscourseMemberAttributes) + } + + /** + * Set up the pipeline data that will be needed throughout the processing. + * @param context context passed along worker messages + */ + async preprocess(context: IStepContext): Promise { + const settings = context.integration.settings + context.pipelineData = { + apiKey: settings.apiKey, + apiUsername: settings.apiUsername, + forumHostname: settings.forumHostname, + } + } + + async getStreams(context: IStepContext): Promise { + return [ + { + value: DiscourseStreamType.CATEGORIES, + metadata: { + page: '', + }, + }, + ] + } + + async processStream( + stream: IIntegrationStream, + context: IStepContext, + ): Promise { + const streamType = stream.value as DiscourseStreamType + let result: DiscourseProcessResult + switch (streamType) { + case DiscourseStreamType.CATEGORIES: + result = await DiscourseIntegrationService.processCategoriesStream(stream, context) + break + case DiscourseStreamType.TOPICS_FROM_CATEGORY: + result = await DiscourseIntegrationService.processTopicsFromCategoryStream(stream, context) + break + case DiscourseStreamType.POSTS_FROM_TOPIC: + result = await DiscourseIntegrationService.processPostsFromTopicStream(stream, context) + break + case DiscourseStreamType.POSTS_BY_IDS: + result = await DiscourseIntegrationService.processPostsByIds(stream, context) + break + default: + throw new Error(`Unknown stream type: ${streamType}`) + } + + const newStreams: IPendingStream[] = [] + const nextPageStream: IPendingStream | undefined = undefined + const activities: AddActivitiesSingle[] = [] + + // another switch statement to handle the different types of results, helps with type safety + switch (result.type) { + case DiscourseStreamType.CATEGORIES: + const data = result.data as DiscourseCategoryResponse + data.category_list.categories.forEach((category) => { + newStreams.push({ + value: DiscourseStreamType.TOPICS_FROM_CATEGORY, + metadata: { + category_id: category.id, + category_slug: category.slug, + page: 0, + }, + }) + }) + break + case DiscourseStreamType.TOPICS_FROM_CATEGORY: + const data2 = result.data as DiscourseTopicResponse + data2.topic_list.topics.forEach((topic) => { + newStreams.push({ + value: DiscourseStreamType.POSTS_FROM_TOPIC, + metadata: { + topicId: topic.id, + topic_slug: topic.slug, + page: 0, + }, + }) + }) + break + case DiscourseStreamType.POSTS_FROM_TOPIC: + const data3 = result.data as DiscoursePostsFromTopicResponse + const batchSize = 100 + const postBatches: number[][] = [] + + data3.post_stream.stream.forEach((postId, index) => { + if (index % batchSize === 0) { + postBatches.push([]) + } + postBatches[postBatches.length - 1].push(postId) + }) + + postBatches.forEach((postBatch, index) => { + newStreams.push({ + value: DiscourseStreamType.POSTS_BY_IDS, + metadata: { + topicId: data3.id, + topicSlug: data3.slug, + topicTitle: data3.title, + postIds: postBatch, + lastIdInPreviousBatch: + index === 0 ? undefined : postBatches[index - 1][postBatches[index - 1].length - 1], + }, + }) + }) + + break + case DiscourseStreamType.POSTS_BY_IDS: + // no new streams to launch + // just add the activities + const data4 = result.data as DiscoursePostsByIdsResponse + const { topicId, lastIdInPreviousBatch } = stream.metadata + const posts = data4.post_stream.posts + for (const post of posts) { + if (usernameIsBot(post.username)) { + /* eslint-disable no-continue */ + continue + } + const user = await getDiscourseUserByUsername( + { + forumHostname: context.pipelineData.forumHostname, + apiKey: context.pipelineData.apiKey, + apiUsername: context.pipelineData.apiUsername, + }, + { username: post.username }, + context.logger, + ) + + const member = DiscourseIntegrationService.parseUserIntoMember( + user, + context.pipelineData.forumHostname, + context, + ) + + const activity: AddActivitiesSingle = { + member, + username: member.username[PlatformType.DISCOURSE].username, + platform: PlatformType.DISCOURSE, + tenant: context.integration.tenantId, + sourceId: `${topicId}-${post.post_number}`, + sourceParentId: post.post_number === 1 ? null : `${topicId}-${post.post_number - 1}`, + type: + post.post_number === 1 + ? DiscourseActivityType.CREATE_TOPIC + : DiscourseActivityType.MESSAGE_IN_TOPIC, + timestamp: moment(post.created_at).utc().toDate(), + body: sanitizeHtml(he.decode(post.cooked)), + title: post.post_number === 1 ? stream.metadata.topicTitle : null, + url: `${context.pipelineData.forumHostname}/t/${stream.metadata.topicSlug}/${topicId}/${post.post_number}`, + channel: stream.metadata.topicTitle, + score: + post.post_number === 1 + ? DiscourseGrid[DiscourseActivityType.CREATE_TOPIC].score + : DiscourseGrid[DiscourseActivityType.MESSAGE_IN_TOPIC].score, + isContribution: + post.post_number === 1 + ? DiscourseGrid[DiscourseActivityType.CREATE_TOPIC].isContribution + : DiscourseGrid[DiscourseActivityType.MESSAGE_IN_TOPIC].isContribution, + } + activities.push(activity) + } + break + default: + throw new Error(`Unknown stream type: ${streamType}`) + } + + return { + operations: [ + { + type: Operations.UPSERT_ACTIVITIES_WITH_MEMBERS, + records: activities, + }, + ], + newStreams, + nextPageStream, + } + } + + static async processCategoriesStream( + stream: IIntegrationStream, + context: IStepContext, + ): Promise { + const { forumHostname, apiKey, apiUsername } = context.pipelineData + const params: DiscourseConnectionParams = { + forumHostname, + apiKey, + apiUsername, + } + const discourseCategories = await getDiscourseCategories(params, context.logger) + return { + type: DiscourseStreamType.CATEGORIES, + data: discourseCategories, + } + } + + static async processTopicsFromCategoryStream( + stream: IIntegrationStream, + context: IStepContext, + ): Promise { + const { forumHostname, apiKey, apiUsername } = context.pipelineData + const params: DiscourseConnectionParams = { + forumHostname, + apiKey, + apiUsername, + } + + const input: DiscourseTopicsInput = { + category_id: stream.metadata.category_id, + category_slug: stream.metadata.category_slug, + page: stream.metadata.page, + } + + const discourseTopics = await getDiscourseTopics(params, input, context.logger) + return { + type: DiscourseStreamType.TOPICS_FROM_CATEGORY, + data: discourseTopics, + } + } + + static async processPostsFromTopicStream( + stream: IIntegrationStream, + context: IStepContext, + ): Promise { + const { forumHostname, apiKey, apiUsername } = context.pipelineData + const params: DiscourseConnectionParams = { + forumHostname, + apiKey, + apiUsername, + } + + const input: DiscoursePostsInput = { + topic_slug: stream.metadata.topic_slug, + topic_id: stream.metadata.topicId, + page: stream.metadata.page, + } + + const discourseTopics = await getDiscoursePostsFromTopic(params, input, context.logger) + return { + type: DiscourseStreamType.POSTS_FROM_TOPIC, + data: discourseTopics, + } + } + + static async processPostsByIds( + stream: IIntegrationStream, + context: IStepContext, + ): Promise { + const { forumHostname, apiKey, apiUsername } = context.pipelineData + const params: DiscourseConnectionParams = { + forumHostname, + apiKey, + apiUsername, + } + + const input: DiscoursePostsByIdsInput = { + topic_id: stream.metadata.topicId, + post_ids: stream.metadata.postIds, + } + + const discoursePosts = await getDiscoursePostsByIds(params, input, context.logger) + return { + type: DiscourseStreamType.POSTS_BY_IDS, + data: discoursePosts, + } + } + + async processWebhook(webhook: any, context: IStepContext): Promise { + const { event, data } = webhook.payload + + switch (event) { + case DiscourseWebhookType.POST_CREATED: + return this.processPostCreatedWebhook(data as DiscourseWebhookPost, context) + case DiscourseWebhookType.USER_CREATED: + return this.processUserCreatedWebhook(data as DiscourseWebhookUser, context) + case DiscourseWebhookType.LIKED_A_POST: + const localData = data as DiscourseWebhookNotification + if (localData.notification.notification_type === 5) { + return this.processLikedAPostWebhook(localData, context) + } + break + default: + context.logger.warn( + { + event, + data, + }, + 'No record created for event!', + ) + + return { + operations: [], + } + } + + return { + operations: [], + } + } + + async processPostCreatedWebhook( + data: DiscourseWebhookPost, + context: IStepContext, + ): Promise { + const post = data.post + if (usernameIsBot(post.username)) { + return { + operations: [], + } + } + const user = await getDiscourseUserByUsername( + { + forumHostname: context.integration.settings.forumHostname, + apiKey: context.integration.settings.apiKey, + apiUsername: context.integration.settings.apiUsername, + }, + { username: post.username }, + context.logger, + ) + + const member = DiscourseIntegrationService.parseUserIntoMember( + user, + context.integration.settings.forumHostname, + context, + ) + + const activity: AddActivitiesSingle = { + member, + username: member.username[PlatformType.DISCOURSE].username, + platform: PlatformType.DISCOURSE, + tenant: context.integration.tenantId, + sourceId: `${post.topic_id}-${post.post_number}`, + sourceParentId: post.post_number === 1 ? null : `${post.topic_id}-${post.post_number - 1}`, + type: + post.post_number === 1 + ? DiscourseActivityType.CREATE_TOPIC + : DiscourseActivityType.MESSAGE_IN_TOPIC, + timestamp: moment(post.created_at).utc().toDate(), + body: sanitizeHtml(he.decode(post.cooked)), + title: post.post_number === 1 ? post.topic_title : null, + url: `${context.integration.settings.forumHostname}/t/${post.topic_slug}/${post.topic_id}/${post.post_number}`, + channel: post.topic_title, + score: + post.post_number === 1 + ? DiscourseGrid[DiscourseActivityType.CREATE_TOPIC].score + : DiscourseGrid[DiscourseActivityType.MESSAGE_IN_TOPIC].score, + isContribution: + post.post_number === 1 + ? DiscourseGrid[DiscourseActivityType.CREATE_TOPIC].isContribution + : DiscourseGrid[DiscourseActivityType.MESSAGE_IN_TOPIC].isContribution, + } + + return { + operations: [ + { + type: Operations.UPSERT_ACTIVITIES_WITH_MEMBERS, + records: [activity], + }, + ], + } + } + + async processUserCreatedWebhook( + data: DiscourseWebhookUser, + context: IStepContext, + ): Promise { + const user = data.user + if (usernameIsBot(user.username)) { + return { + operations: [], + } + } + const member = DiscourseIntegrationService.parseUserIntoMember( + { + user: user as any, + user_badges: [], + badges: [], + badge_types: [], + users: [], + }, + context.integration.settings.forumHostname, + context, + ) + + const activity: AddActivitiesSingle = { + member, + username: member.username[PlatformType.DISCOURSE].username, + platform: PlatformType.DISCOURSE, + tenant: context.integration.tenantId, + sourceId: `${user.id}`, + type: DiscourseActivityType.JOIN, + timestamp: moment(user.created_at).utc().toDate(), + body: null, + title: null, + url: `${context.integration.settings.forumHostname}/u/${user.username}`, + channel: null, + score: DiscourseGrid[DiscourseActivityType.JOIN].score, + isContribution: DiscourseGrid[DiscourseActivityType.JOIN].isContribution, + } + + return { + operations: [ + { + type: Operations.UPSERT_ACTIVITIES_WITH_MEMBERS, + records: [activity], + }, + ], + } + } + + async processLikedAPostWebhook( + data: DiscourseWebhookNotification, + context: IStepContext, + ): Promise { + const notification = data.notification + const username = notification.data.username + ? notification.data.username + : notification.data.original_username + const channel = notification.fancy_title + ? notification.fancy_title + : notification.data.topic_title + + if (usernameIsBot(username)) { + return { + operations: [], + } + } + + const user = await getDiscourseUserByUsername( + { + forumHostname: context.integration.settings.forumHostname, + apiKey: context.integration.settings.apiKey, + apiUsername: context.integration.settings.apiUsername, + }, + { username }, + context.logger, + ) + + const member = DiscourseIntegrationService.parseUserIntoMember( + user, + context.integration.settings.forumHostname, + context, + ) + + const activity: AddActivitiesSingle = { + member, + username: member.username[PlatformType.DISCOURSE].username, + platform: PlatformType.DISCOURSE, + tenant: context.integration.tenantId, + sourceId: `${notification.id}`, + type: DiscourseActivityType.LIKE, + timestamp: moment(notification.created_at).utc().toDate(), + body: null, + title: null, + channel, + score: DiscourseGrid[DiscourseActivityType.LIKE].score, + isContribution: DiscourseGrid[DiscourseActivityType.LIKE].isContribution, + attributes: { + topicURL: `${context.integration.settings.forumHostname}/t/${notification.slug}/${notification.topic_id}`, + }, + } + + return { + operations: [ + { + type: Operations.UPSERT_ACTIVITIES_WITH_MEMBERS, + records: [activity], + }, + ], + } + } + + static parseUserIntoMember( + user: DiscourseUserResponse, + forumHostname: string, + context: IStepContext, + ): Member { + return { + username: { + [PlatformType.DISCOURSE]: { + username: user.user.username, + integrationId: context.integration.id, + }, + } as PlatformIdentities, + displayName: user.user.name, + attributes: { + [MemberAttributeName.URL]: { + [PlatformType.DISCOURSE]: `${forumHostname}/u/${user.user.username}`, + }, + [MemberAttributeName.WEBSITE_URL]: { + [PlatformType.DISCOURSE]: user.user.website || '', + }, + [MemberAttributeName.LOCATION]: { + [PlatformType.DISCOURSE]: user.user.location || '', + }, + [MemberAttributeName.BIO]: { + [PlatformType.DISCOURSE]: user.user.bio_cooked + ? sanitizeHtml(he.decode(user.user.bio_cooked)) + : '', + }, + [MemberAttributeName.AVATAR_URL]: { + [PlatformType.DISCOURSE]: + `${forumHostname}${user.user.avatar_template.replace('{size}', '200')}` || '', + }, + }, + emails: user.user.email ? [user.user.email] : [], + } + } +} diff --git a/backend/src/serverless/integrations/types/discourseTypes.ts b/backend/src/serverless/integrations/types/discourseTypes.ts new file mode 100644 index 0000000000..9560bec28f --- /dev/null +++ b/backend/src/serverless/integrations/types/discourseTypes.ts @@ -0,0 +1,481 @@ +export interface DiscourseConnectionParams { + apiKey: string + apiUsername: string + forumHostname: string +} + +export interface DiscourseCategoryResponse { + category_list: { + can_create_category: boolean + can_create_topic: boolean + categories: DiscourseCategory[] + } +} + +export interface DiscourseTopicsInput { + category_slug: string + category_id: number + page: number +} + +export interface DiscoursePostsInput { + topic_slug: string + topic_id: number + page: number +} + +export interface DisourseUserByUsernameInput { + username: string +} + +export interface DiscoursePostsByIdsInput { + topic_id: number + post_ids: number[] +} + +export interface DiscoursePostsByIdsResponse { + post_stream: { + posts: Post[] + } + id: number // this is the id of the topic +} + +export interface DiscourseWebhookPost { + post: Post +} + +export interface DiscourseWebhookNotification { + notification: { + id: number + user_id: number + notification_type: number + read: boolean + created_at: string + post_number: number + topic_id: number + slug: string + fancy_title?: string + data: { + group_id: number + group_name: string + inbox_count: number + username?: string + topic_title?: string + original_post_id?: number + original_post_type?: number + original_username?: string + revision_number: any + display_username?: string + } + } +} + +export interface DiscourseWebhookUser { + user: { + id: number + username: string + name: string + avatar_template: string + email: string + secondary_emails: any + last_posted_at: any + last_seen_at: string + created_at: string + muted: boolean + trust_level: number + moderator: boolean + admin: boolean + title: any + badge_count: number + time_read: number + recent_time_read: number + primary_group_id: any + primary_group_name: any + primary_group_flair_url: any + primary_group_flair_bg_color: any + primary_group_flair_color: any + featured_topic: any + staged: boolean + pending_count: number + profile_view_count: number + second_factor_enabled: boolean + can_upload_profile_header: boolean + can_upload_user_card_background: boolean + post_count: number + locale: any + muted_category_ids: any + regular_category_ids: any + watched_tags: any + watching_first_post_tags: any + tracked_tags: any + muted_tags: any + tracked_category_ids: any + watched_category_ids: any + watched_first_post_category_ids: any + system_avatar_template: string + muted_usernames: any + ignored_usernames: any + allowed_pm_usernames: any + mailing_list_posts_per_day: number + featured_user_badge_ids: any + invited_by: any + groups: any + user_option: any + } +} + +interface CreatedByLastPoster { + id: number + username: string + name: string + avatar_template: string +} + +export interface DiscourseWebhookTopic { + id: number + title: string + fancy_title: string + posts_count: number + created_at: string + views: number + reply_count: number + like_count: number + last_posted_at: string + visible: boolean + closed: boolean + archived: boolean + archetype: string + slug: string + category_id: number + word_count: number + deleted_at: null + user_id: number + featured_link: string + pinned_globally: boolean + pinned_at: string + pinned_until: string + unpinned: string + pinned: boolean + highest_post_number: number + deleted_by: any + has_deleted: boolean + bookmarked: boolean + participant_count: number + thumbnails: any + created_by: CreatedByLastPoster + last_poster: CreatedByLastPoster +} + +export interface DiscourseCategory { + id: number + name: string + color: string + text_color: string + slug: string + topic_count: number + post_count: number + position: number + description: string + description_text: string + description_excerpt: string + topic_url: string + read_restricted: boolean + permission: number + notification_level: number + can_edit: boolean + topic_template: string + has_children: boolean + sort_order: string + sort_ascending: string + show_subcategory_list: boolean + num_featured_topics: number + default_view: string + subcategory_list_style: string + default_top_period: string + default_list_filter: string + minimum_required_tags: number + navigate_to_first_post_after_read: boolean + topics_day: number + topics_week: number + topics_month: number + topics_year: number + topics_all_time: number + is_uncategorized: boolean + subcategory_ids: (number | null)[] + subcategory_list: any[] + uploaded_logo: string + uploaded_logo_dark: string + uploaded_background: string +} + +export interface DiscourseTopicResponse { + users: User[] + primary_groups: any[] + topic_list: { + can_create_topic: boolean + per_page: number + top_tags: any[] + topics: Topic[] + } +} + +export interface DiscourseUserResponse { + user_badges: any[] + badges: any[] + badge_types: any[] + users: FullUser[] + user: FullUser +} + +export interface FullUser { + id: number + username: string + name: string + avatar_template: string + email?: string + bio_cooked?: string + bio_excerpt?: string + bio_raw?: string + location?: string + website?: string + secondary_emails: string[] | null[] + active: boolean + admin: boolean + moderator: boolean + last_seen_at: string + last_emailed_at: string + created_at: string + last_seen_age: number + last_emailed_age: number + created_at_age: number + trust_level: number + manual_locked_trust_level: string | null + flag_level: number + title: string | null + time_read: number + staged: boolean + days_visited: number + posts_read_count: number + topics_entered: number + post_count: number +} + +interface User { + id: number + username: string + name: string + avatar_template: string +} + +interface Topic { + id: number + title: string + fancy_title: string + slug: string + posts_count: number + reply_count: number + highest_post_number: number + image_url: string + created_at: string + last_posted_at: string + bumped: boolean + bumped_at: string + archetype: string + unseen: boolean + pinned: boolean + unpinned: string | null + excerpt: string + visible: boolean + closed: boolean + archived: boolean + bookmarked: string | null + liked: string | null + views: number + like_count: number + has_summary: boolean + last_poster_username: string + category_id: number + pinned_globally: boolean + featured_link: string + posters: Poster[] +} + +interface Poster { + extras: string | null + description: string + user_id: number + primary_group_id: string | null +} + +export interface DiscoursePostsFromTopicResponse { + post_stream: { + posts: Post[] + stream: number[] + } + timeline_lookup: any[] + suggested_topics: SuggestedTopic[] + tags: any[] + tags_descriptions: {} + id: number + title: string + fancy_title: string + posts_count: number + created_at: string + views: number + reply_count: number + like_count: number + last_posted_at: string + visible: boolean + closed: boolean + archived: boolean + has_summary: boolean + archetype: string + slug: string + category_id: number + word_count: number + deleted_at: string + user_id: number + featured_link: string + pinned_globally: boolean + pinned_at: string + pinned_until: string + image_url: string + slow_mode_seconds: number + draft: string + draft_key: string + draft_sequence: number + unpinned: string | null + pinned: boolean + current_post_number: number + highest_post_number: number + deleted_by: string | null + has_deleted: boolean + actions_summary: ActionSummary[] + chunk_size: number + bookmarked: boolean + bookmarks: any[] + topic_timer: string | null + message_bus_last_id: number + participant_count: number + show_read_indicator: boolean + thumbnails: string | null + slow_mode_enabled_until: string | null + details: TopicDetails +} + +interface Post { + id: number // this id is unique accrss all posts + name: string + username: string + avatar_template: string + created_at: string + cooked: string + post_number: number // this shows the order of the post in the topic, 1 is the first post + post_type: number + updated_at: string + reply_count: number + reply_to_post_number: string | null + quote_count: number + incoming_link_count: number + reads: number + readers_count: number + score: number + yours: boolean + topic_id: number + topic_slug: string + topic_title?: string + display_username: string + primary_group_name: string + flair_name: string + flair_url: string + flair_bg_color: string + flair_color: string + version: number + can_edit: boolean + can_delete: boolean + can_recover: boolean + can_wiki: boolean + link_counts: LinkCount[] + read: boolean + user_title: string + bookmarked: boolean + actions_summary: ActionSummary[] + moderator: boolean + admin: boolean + staff: boolean + user_id: number + hidden: boolean + trust_level: number + deleted_at: string | null + user_deleted: boolean + edit_reason: string | null + can_view_edit_history: boolean + wiki: boolean + reviewable_id: number + reviewable_score_count: number + reviewable_score_pending_count: number +} + +interface LinkCount { + url: string + internal: boolean + reflection: boolean + title: string + clicks: number +} + +interface ActionSummary { + id: number + can_act: boolean + count?: number + hidden?: boolean +} + +interface SuggestedTopic extends Omit { + tags: any[] + posters: SuggestedTopicPoster[] +} + +interface SuggestedTopicPoster { + extras: string | null + description: string + user: User +} + +interface TopicDetails { + can_edit: boolean + notification_level: number + can_move_posts: boolean + can_delete: boolean + can_remove_allowed_users: boolean + can_create_post: boolean + can_reply_as_new_topic: boolean + can_invite_to: boolean + can_invite_via_email: boolean + can_flag_topic: boolean + can_convert_topic: boolean + can_review_topic: boolean + can_close_topic: boolean + can_archive_topic: boolean + can_split_merge_topic: boolean + can_edit_staff_notes: boolean + can_toggle_topic_visibility: boolean + can_pin_unpin_topic: boolean + can_moderate_category: boolean + can_remove_self_id: number + participants: Participant[] + created_by: User + last_poster: User +} + +interface Participant extends User { + post_count: number + primary_group_name: string + flair_name: string + flair_url: string + flair_color: string + flair_bg_color: string + admin: boolean + moderator: boolean + trust_level: number +} diff --git a/backend/src/serverless/integrations/usecases/discourse/getCategories.ts b/backend/src/serverless/integrations/usecases/discourse/getCategories.ts new file mode 100644 index 0000000000..9e73dcbd57 --- /dev/null +++ b/backend/src/serverless/integrations/usecases/discourse/getCategories.ts @@ -0,0 +1,30 @@ +import axios, { AxiosRequestConfig } from 'axios' +import type { DiscourseConnectionParams } from '../../types/discourseTypes' +import { Logger } from '../../../../utils/logging' +import { DiscourseCategoryResponse } from '../../types/discourseTypes' + +export const getDiscourseCategories = async ( + params: DiscourseConnectionParams, + logger: Logger, +): Promise => { + logger.info({ + message: 'Fetching categories from Discourse', + forumHostName: params.forumHostname, + }) + const config: AxiosRequestConfig = { + method: 'get', + url: `${params.forumHostname}/categories.json`, + headers: { + 'Api-Key': params.apiKey, + 'Api-Username': params.apiUsername, + }, + } + + try { + const response = await axios(config) + return response.data + } catch (err) { + logger.error({ err, params }, 'Error while getting Discourse categories') + throw err + } +} diff --git a/backend/src/serverless/integrations/usecases/discourse/getPostsByIds.ts b/backend/src/serverless/integrations/usecases/discourse/getPostsByIds.ts new file mode 100644 index 0000000000..52bd385109 --- /dev/null +++ b/backend/src/serverless/integrations/usecases/discourse/getPostsByIds.ts @@ -0,0 +1,53 @@ +import axios, { AxiosRequestConfig } from 'axios' +import type { DiscourseConnectionParams } from '../../types/discourseTypes' +import { Logger } from '../../../../utils/logging' +import { DiscoursePostsByIdsResponse, DiscoursePostsByIdsInput } from '../../types/discourseTypes' + +const serializeArrayToQueryString = (params: Object) => + Object.entries(params) + .map(([key, value]) => { + if (Array.isArray(value)) { + return value + .map((val) => `${encodeURIComponent(key)}[]=${encodeURIComponent(val)}`) + .join('&') + } + return `${encodeURIComponent(key)}=${encodeURIComponent(value)}` + }) + .join('&') + +// this methods returns ids of posts in a topic +// then we need to parse each topic individually (can be batched) +export const getDiscoursePostsByIds = async ( + params: DiscourseConnectionParams, + input: DiscoursePostsByIdsInput, + logger: Logger, +): Promise => { + logger.info({ + message: 'Fetching posts by ids from Discourse', + params, + input, + }) + + const queryParameters = { + post_ids: input.post_ids, + } + + const queryString = serializeArrayToQueryString(queryParameters) + + const config: AxiosRequestConfig = { + method: 'get', + url: `${params.forumHostname}/t/${input.topic_id}/posts.json?${queryString}`, + headers: { + 'Api-Key': params.apiKey, + 'Api-Username': params.apiUsername, + }, + } + + try { + const response = await axios(config) + return response.data + } catch (err) { + logger.error({ err, params, input }, 'Error while getting posts by ids from Discourse ') + throw err + } +} diff --git a/backend/src/serverless/integrations/usecases/discourse/getPostsFromTopic.ts b/backend/src/serverless/integrations/usecases/discourse/getPostsFromTopic.ts new file mode 100644 index 0000000000..8d98df5213 --- /dev/null +++ b/backend/src/serverless/integrations/usecases/discourse/getPostsFromTopic.ts @@ -0,0 +1,34 @@ +import axios, { AxiosRequestConfig } from 'axios' +import type { DiscourseConnectionParams } from '../../types/discourseTypes' +import { Logger } from '../../../../utils/logging' +import { DiscoursePostsFromTopicResponse, DiscoursePostsInput } from '../../types/discourseTypes' + +// this methods returns ids of posts in a topic +// then we need to parse each topic individually (can be batched) +export const getDiscoursePostsFromTopic = async ( + params: DiscourseConnectionParams, + input: DiscoursePostsInput, + logger: Logger, +): Promise => { + logger.info({ + message: 'Fetching posts from topic from Discourse', + params, + input, + }) + const config: AxiosRequestConfig = { + method: 'get', + url: `${params.forumHostname}/t/${input.topic_slug}/${input.topic_id}.json`, + headers: { + 'Api-Key': params.apiKey, + 'Api-Username': params.apiUsername, + }, + } + + try { + const response = await axios(config) + return response.data + } catch (err) { + logger.error({ err, params, input }, 'Error while getting posts from topic from Discourse ') + throw err + } +} diff --git a/backend/src/serverless/integrations/usecases/discourse/getTopics.ts b/backend/src/serverless/integrations/usecases/discourse/getTopics.ts new file mode 100644 index 0000000000..adb5f7bd38 --- /dev/null +++ b/backend/src/serverless/integrations/usecases/discourse/getTopics.ts @@ -0,0 +1,34 @@ +import axios, { AxiosRequestConfig } from 'axios' +import type { DiscourseConnectionParams } from '../../types/discourseTypes' +import { Logger } from '../../../../utils/logging' +import { DiscourseCategoryResponse, DiscourseTopicsInput } from '../../types/discourseTypes' + +export const getDiscourseTopics = async ( + params: DiscourseConnectionParams, + input: DiscourseTopicsInput, + logger: Logger, +): Promise => { + logger.info({ + message: 'Fetching categories from Discourse', + forumHostName: params.forumHostname, + }) + const config: AxiosRequestConfig = { + method: 'get', + url: `${params.forumHostname}/c/${input.category_slug}/${input.category_id}.json`, + headers: { + 'Api-Key': params.apiKey, + 'Api-Username': params.apiUsername, + }, + params: { + page: input.page, + }, + } + + try { + const response = await axios(config) + return response.data + } catch (err) { + logger.error({ err, params }, 'Error while getting Discourse categories') + throw err + } +} diff --git a/backend/src/serverless/integrations/usecases/discourse/getUser.ts b/backend/src/serverless/integrations/usecases/discourse/getUser.ts new file mode 100644 index 0000000000..3ba6174f83 --- /dev/null +++ b/backend/src/serverless/integrations/usecases/discourse/getUser.ts @@ -0,0 +1,34 @@ +import axios, { AxiosRequestConfig } from 'axios' +import type { DiscourseConnectionParams } from '../../types/discourseTypes' +import { Logger } from '../../../../utils/logging' +import { DiscourseUserResponse, DisourseUserByUsernameInput } from '../../types/discourseTypes' + +// this methods returns ids of posts in a topic +// then we need to parse each topic individually (can be batched) +export const getDiscourseUserByUsername = async ( + params: DiscourseConnectionParams, + input: DisourseUserByUsernameInput, + logger: Logger, +): Promise => { + logger.info({ + message: 'Fetching user by username from Discourse', + params, + input, + }) + const config: AxiosRequestConfig = { + method: 'get', + url: `${params.forumHostname}/u/${input.username}.json`, + headers: { + 'Api-Key': params.apiKey, + 'Api-Username': params.apiUsername, + }, + } + + try { + const response = await axios(config) + return response.data + } catch (err) { + logger.error({ err, params, input }, 'Error while fetching user by username from Discourse ') + throw err + } +} diff --git a/backend/src/services/integrationService.ts b/backend/src/services/integrationService.ts index c88416e136..3dbd7a65f3 100644 --- a/backend/src/services/integrationService.ts +++ b/backend/src/services/integrationService.ts @@ -819,4 +819,50 @@ export default class IntegrationService { return integration } + + /** + * Adds/updates Discourse integration + * @param integrationData to create the integration object + * @returns integration object + */ + async discourseConnectOrUpdate(integrationData) { + const transaction = await SequelizeRepository.createTransaction(this.options) + let integration + let run + + try { + integration = await this.createOrUpdate( + { + platform: PlatformType.DISCOURSE, + settings: { + apiKey: integrationData.apiKey, + apiUsername: integrationData.apiUsername, + forumHostname: integrationData.forumHostname, + webhookSecret: integrationData.webhookSecret, + updateMemberAttributes: true, + }, + status: 'in-progress', + }, + transaction, + ) + + run = await new IntegrationRunRepository({ ...this.options, transaction }).create({ + integrationId: integration.id, + tenantId: integration.tenantId, + onboarding: true, + state: IntegrationRunState.PENDING, + }) + await SequelizeRepository.commitTransaction(transaction) + } catch (err) { + await SequelizeRepository.rollbackTransaction(transaction) + throw err + } + + await sendNodeWorkerMessage( + integration.tenantId, + new NodeWorkerIntegrationProcessMessage(run.id), + ) + + return integration + } } diff --git a/backend/src/types/activityTypes.ts b/backend/src/types/activityTypes.ts index b166f437c0..ed64461aab 100644 --- a/backend/src/types/activityTypes.ts +++ b/backend/src/types/activityTypes.ts @@ -7,6 +7,7 @@ import { RedditGrid } from '../serverless/integrations/grid/redditGrid' import { SlackGrid } from '../serverless/integrations/grid/slackGrid' import { StackOverflowGrid } from '../serverless/integrations/grid/stackOverflowGrid' import { TwitterGrid } from '../serverless/integrations/grid/twitterGrid' +import { DiscourseGrid } from '../serverless/integrations/grid/discourseGrid' import isUrl from '../utils/isUrl' import { PlatformType } from './integrationEnums' @@ -130,6 +131,13 @@ export enum StackOverflowActivityType { ANSWER = 'answer', } +export enum DiscourseActivityType { + CREATE_TOPIC = 'create_topic', + MESSAGE_IN_TOPIC = 'message_in_topic', + JOIN = 'join', + LIKE = 'like', +} + const githubUrl = 'https://github.com' const defaultGithubChannelFormatter = (channel) => { @@ -155,6 +163,18 @@ const defaultStackoverflowFormatter = (activity) => { return '' } +const cleanDiscourseUrl = (url) => { + // https://discourse-web-aah2.onrender.com/t/test-webhook-topic-cool/26/5 -> remove /5 so only url to topic remains + const urlSplit = url.split('/') + urlSplit.pop() + return urlSplit.join('/') +} + +const defaultDiscourseFormatter = (activity) => { + const topicUrl = cleanDiscourseUrl(activity.url) + return `#${activity.channel}` +} + export const UNKNOWN_ACTIVITY_TYPE_DISPLAY: ActivityTypeDisplayProperties = { default: 'Conducted an activity', short: 'conducted an activity', @@ -660,4 +680,48 @@ export const DEFAULT_ACTIVITY_TYPE_SETTINGS: DefaultActivityTypes = { isContribution: StackOverflowGrid.answer.isContribution, }, }, + [PlatformType.DISCOURSE]: { + [DiscourseActivityType.CREATE_TOPIC]: { + display: { + default: 'Created a topic {self}', + short: 'created a topic', + channel: '#{channel}', + formatter: { + self: defaultDiscourseFormatter, + }, + }, + isContribution: DiscourseGrid.create_topic.isContribution, + }, + [DiscourseActivityType.MESSAGE_IN_TOPIC]: { + display: { + default: 'Posted a message in {self}', + short: 'posted a message', + channel: '#{channel}', + formatter: { + self: defaultDiscourseFormatter, + }, + }, + isContribution: DiscourseGrid.message_in_topic.isContribution, + }, + [DiscourseActivityType.JOIN]: { + display: { + default: 'Joined a forum', + short: 'joined a forum', + channel: '', + }, + isContribution: DiscourseGrid.join.isContribution, + }, + [DiscourseActivityType.LIKE]: { + display: { + default: 'Liked a post in {self}', + short: 'liked a post', + channel: '#{channel}', + formatter: { + self: (activity) => + `#${activity.channel}`, + }, + }, + isContribution: DiscourseGrid.like.isContribution, + }, + }, } diff --git a/backend/src/types/integrationEnums.ts b/backend/src/types/integrationEnums.ts index de09281a3b..60f4c63606 100644 --- a/backend/src/types/integrationEnums.ts +++ b/backend/src/types/integrationEnums.ts @@ -15,6 +15,7 @@ export enum PlatformType { PRODUCTHUNT = 'producthunt', YOUTUBE = 'youtube', STACKOVERFLOW = 'stackoverflow', + DISCOURSE = 'discourse', GIT = 'git', CRUNCHBASE = 'crunchbase', OTHER = 'other', @@ -34,6 +35,7 @@ export enum IntegrationType { LINKEDIN = 'linkedin', CROWD = 'crowd', STACKOVERFLOW = 'stackoverflow', + DISCOURSE = 'discourse', GIT = 'git', } @@ -49,5 +51,6 @@ export const integrationLabel: Record = { [IntegrationType.LINKEDIN]: 'LinkedIn', [IntegrationType.CROWD]: 'Crowd', [IntegrationType.STACKOVERFLOW]: 'Stack Overflow', + [IntegrationType.DISCOURSE]: 'Discourse', [IntegrationType.GIT]: 'Git', } diff --git a/backend/src/types/webhooks.ts b/backend/src/types/webhooks.ts index 77efcf0575..3fb6ff528b 100644 --- a/backend/src/types/webhooks.ts +++ b/backend/src/types/webhooks.ts @@ -9,6 +9,7 @@ export enum WebhookState { export enum WebhookType { GITHUB = 'GITHUB', DISCORD = 'DISCORD', + DISCOURSE = 'DISCOURSE', } export enum DiscordWebsocketEvent { diff --git a/backend/src/utils/crypto.ts b/backend/src/utils/crypto.ts new file mode 100644 index 0000000000..1791c5a335 --- /dev/null +++ b/backend/src/utils/crypto.ts @@ -0,0 +1,21 @@ +import * as crypto from 'crypto' +import * as buffer from 'buffer' + +export function generateWebhookSecret(length: number = 32): string { + return crypto.randomBytes(length).toString('hex') +} + +export function verifyWebhookSignature( + payload: string, + secret: string, + signatureHeader: string, +): boolean { + const hmac = crypto.createHmac('sha256', secret) + hmac.update(payload) + const expectedSignature = `sha256=${hmac.digest('hex')}` + + return crypto.timingSafeEqual( + buffer.Buffer.from(signatureHeader), + buffer.Buffer.from(expectedSignature), + ) +} diff --git a/frontend/public/icons/crowd-icons.svg b/frontend/public/icons/crowd-icons.svg index 88cc849099..9c3fd0b8ee 100644 --- a/frontend/public/icons/crowd-icons.svg +++ b/frontend/public/icons/crowd-icons.svg @@ -92,4 +92,9 @@ + + + + + diff --git a/frontend/public/images/integrations/discourse.png b/frontend/public/images/integrations/discourse.png new file mode 100644 index 0000000000..dd010b37fc Binary files /dev/null and b/frontend/public/images/integrations/discourse.png differ diff --git a/frontend/src/integrations/discourse/components/discourse-connect-drawer.vue b/frontend/src/integrations/discourse/components/discourse-connect-drawer.vue new file mode 100644 index 0000000000..56536ba330 --- /dev/null +++ b/frontend/src/integrations/discourse/components/discourse-connect-drawer.vue @@ -0,0 +1,421 @@ + + + + + diff --git a/frontend/src/integrations/discourse/components/discourse-connect.vue b/frontend/src/integrations/discourse/components/discourse-connect.vue new file mode 100644 index 0000000000..7e47cd36d5 --- /dev/null +++ b/frontend/src/integrations/discourse/components/discourse-connect.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/frontend/src/integrations/discourse/config.js b/frontend/src/integrations/discourse/config.js new file mode 100644 index 0000000000..7550cd84f0 --- /dev/null +++ b/frontend/src/integrations/discourse/config.js @@ -0,0 +1,16 @@ +import DiscourseConnect from './components/discourse-connect.vue'; + +export default { + enabled: true, + name: 'Discourse', + backgroundColor: '#FFFFFF', + borderColor: '#FFFFFF', + description: + 'Connect Discourse to sync topics, posts, and replies from your account forums.', + image: '/images/integrations/discourse.png', + connectComponent: DiscourseConnect, + activityDisplay: { + showLinkToUrl: true, + }, + url: (attributes) => attributes.url.discourse, +}; diff --git a/frontend/src/integrations/discourse/index.js b/frontend/src/integrations/discourse/index.js new file mode 100644 index 0000000000..e81a18f3ed --- /dev/null +++ b/frontend/src/integrations/discourse/index.js @@ -0,0 +1,3 @@ +import config from './config'; + +export default config; diff --git a/frontend/src/integrations/integrations-config.js b/frontend/src/integrations/integrations-config.js index 563adae4e8..2f369bc4be 100644 --- a/frontend/src/integrations/integrations-config.js +++ b/frontend/src/integrations/integrations-config.js @@ -4,8 +4,8 @@ import slack from './slack'; import twitter from './twitter'; import devto from './devto'; import hackernews from './hackernews'; +import discourse from './discourse'; import hubspot from './hubspot'; -// import discourse from './discourse' import stackoverflow from './stackoverflow'; import reddit from './reddit'; import linkedin from './linkedin'; @@ -30,6 +30,7 @@ class IntegrationsConfig { zapier, git, crunchbase, + discourse, hubspot, make, facebook, diff --git a/frontend/src/modules/activity/activity-platform-field.js b/frontend/src/modules/activity/activity-platform-field.js index 71dc410e2e..1d9e82fd77 100644 --- a/frontend/src/modules/activity/activity-platform-field.js +++ b/frontend/src/modules/activity/activity-platform-field.js @@ -55,6 +55,10 @@ export default class ActivityPlatformField extends StringField { value: 'stackoverflow', label: 'Stack Overflow', }, + { + value: 'discourse', + label: 'Discourse', + }, ]; if (appConfig.isGitIntegrationEnabled) { diff --git a/frontend/src/modules/integration/integration-service.js b/frontend/src/modules/integration/integration-service.js index 37956a4347..325d7272f4 100644 --- a/frontend/src/modules/integration/integration-service.js +++ b/frontend/src/modules/integration/integration-service.js @@ -24,12 +24,9 @@ export class IntegrationService { const tenantId = AuthCurrentTenant.get(); - const response = await authAxios.delete( - `/tenant/${tenantId}/integration`, - { - params, - }, - ); + const response = await authAxios.delete(`/tenant/${tenantId}/integration`, { + params, + }); return response.data; } @@ -69,12 +66,9 @@ export class IntegrationService { const tenantId = AuthCurrentTenant.get(); - const response = await authAxios.get( - `/tenant/${tenantId}/integration`, - { - params, - }, - ); + const response = await authAxios.get(`/tenant/${tenantId}/integration`, { + params, + }); return response.data; } @@ -102,13 +96,10 @@ export class IntegrationService { const tenantId = AuthCurrentTenant.get(); // Calling connect devto function in the backend. - const response = await authAxios.post( - `/tenant/${tenantId}/devto-connect`, - { - users, - organizations, - }, - ); + const response = await authAxios.post(`/tenant/${tenantId}/devto-connect`, { + users, + organizations, + }); return response.data; } @@ -155,18 +146,13 @@ export class IntegrationService { // Getting the tenant_id const tenantId = AuthCurrentTenant.get(); // Calling the authenticate function in the backend. - const response = await authAxios.put( - `/reddit-onboard/${tenantId}`, - body, - ); + const response = await authAxios.put(`/reddit-onboard/${tenantId}`, body); return response.data; } static async linkedinConnect() { const tenantId = AuthCurrentTenant.get(); - const response = await authAxios.put( - `/linkedin-connect/${tenantId}`, - ); + const response = await authAxios.put(`/linkedin-connect/${tenantId}`); return response.data; } @@ -199,14 +185,11 @@ export class IntegrationService { static async devtoValidateUser(username) { const tenantId = AuthCurrentTenant.get(); - const response = await authAxios.get( - `/tenant/${tenantId}/devto-validate`, - { - params: { - username, - }, + const response = await authAxios.get(`/tenant/${tenantId}/devto-validate`, { + params: { + username, }, - ); + }); return response.data; } @@ -214,14 +197,11 @@ export class IntegrationService { static async devtoValidateOrganization(organization) { const tenantId = AuthCurrentTenant.get(); - const response = await authAxios.get( - `/tenant/${tenantId}/devto-validate`, - { - params: { - organization, - }, + const response = await authAxios.get(`/tenant/${tenantId}/devto-validate`, { + params: { + organization, }, - ); + }); return response.data; } @@ -289,13 +269,70 @@ export class IntegrationService { static async gitConnect(remotes) { const tenantId = AuthCurrentTenant.get(); - const response = await authAxios.put( - `/tenant/${tenantId}/git-connect`, + const response = await authAxios.put(`/tenant/${tenantId}/git-connect`, { + remotes, + }); + + return response.data; + } + + static async discourseValidateAPI(forumHostname, apiKey) { + const tenantId = AuthCurrentTenant.get(); + + const response = await authAxios.post( + `/tenant/${tenantId}/discourse-validate`, + { + forumHostname, + apiKey, + apiUsername: 'system', + }, + ); + + return response.status === 200; + } + + static async discourseConnect(forumHostname, apiKey, webhookSecret) { + const tenantId = AuthCurrentTenant.get(); + + const response = await authAxios.post( + `/tenant/${tenantId}/discourse-connect`, { - remotes, + forumHostname, + apiKey, + apiUsername: 'system', + webhookSecret, }, ); return response.data; } + + static async discourseSoftConnect(forumHostname, apiKey, webhookSecret) { + const tenantId = AuthCurrentTenant.get(); + + const response = await authAxios.post( + `/tenant/${tenantId}/discourse-soft-connect`, + { + forumHostname, + apiKey, + apiUsername: 'system', + webhookSecret, + }, + ); + + return response.data.id; + } + + static async discourseVerifyWebhook(integrationId) { + const tenantId = AuthCurrentTenant.get(); + + const response = await authAxios.post( + `/tenant/${tenantId}/discourse-test-webhook`, + { + integrationId, + }, + ); + + return response.data.isWebhooksReceived; + } } diff --git a/frontend/src/modules/integration/integration-store.js b/frontend/src/modules/integration/integration-store.js index c8a926b91f..e82b754783 100644 --- a/frontend/src/modules/integration/integration-store.js +++ b/frontend/src/modules/integration/integration-store.js @@ -489,5 +489,38 @@ export default { commit('CREATE_ERROR'); } }, + + async doDiscourseConnect( + { commit }, + { + forumHostname, apiKey, webhookSecret, isUpdate, + }, + ) { + try { + commit('CREATE_STARTED'); + + const integration = await IntegrationService.discourseConnect( + forumHostname, + apiKey, + webhookSecret, + ); + + commit('CREATE_SUCCESS', integration); + + Message.success( + 'The first activities will show up in a couple of seconds.

' + + 'This process might take a few minutes to finish, depending on the amount of data.', + { + title: + `Discourse integration ${isUpdate ? 'updated' : 'created'} successfully`, + }, + ); + + router.push('/integrations'); + } catch (error) { + Errors.handle(error); + commit('CREATE_ERROR'); + } + }, }, }; diff --git a/frontend/src/modules/member/member-identities-field.js b/frontend/src/modules/member/member-identities-field.js index 8433db1348..aa527de2cc 100644 --- a/frontend/src/modules/member/member-identities-field.js +++ b/frontend/src/modules/member/member-identities-field.js @@ -51,6 +51,10 @@ export default class MemberIdentitiesField extends StringField { value: 'stackoverflow', label: 'Stack Overflow', }, + { + value: 'discourse', + label: 'Discourse', + }, ]; if (appConfig.isGitIntegrationEnabled) {