From 7fd524cd6a328d061ba3c4d9d1c11d30eeebb00f Mon Sep 17 00:00:00 2001 From: Tessa Thornton Date: Thu, 26 Mar 2020 21:21:09 -0400 Subject: [PATCH] [CORL-924/CORL-923] show all comments or staff comments in slack (#2872) * add allcomments option to slack config * create separate class for hadnling slack event publishing * add strings Co-authored-by: Wyatt Johnson Co-authored-by: Kim Gardner --- .../Configure/sections/Slack/SlackChannel.tsx | 36 +++ .../sections/Slack/SlackConfigContainer.tsx | 2 + src/core/server/events/listeners/slack.ts | 210 ------------------ .../server/events/listeners/slack/index.ts | 1 + .../events/listeners/slack/publishEvent.ts | 127 +++++++++++ .../server/events/listeners/slack/slack.ts | 144 ++++++++++++ src/core/server/graph/schema/schema.graphql | 25 ++- src/locales/en-US/admin.ftl | 2 + 8 files changed, 334 insertions(+), 213 deletions(-) delete mode 100644 src/core/server/events/listeners/slack.ts create mode 100644 src/core/server/events/listeners/slack/index.ts create mode 100644 src/core/server/events/listeners/slack/publishEvent.ts create mode 100644 src/core/server/events/listeners/slack/slack.ts diff --git a/src/core/client/admin/routes/Configure/sections/Slack/SlackChannel.tsx b/src/core/client/admin/routes/Configure/sections/Slack/SlackChannel.tsx index 2842e163e..2166e7663 100644 --- a/src/core/client/admin/routes/Configure/sections/Slack/SlackChannel.tsx +++ b/src/core/client/admin/routes/Configure/sections/Slack/SlackChannel.tsx @@ -153,6 +153,42 @@ const SlackChannel: FunctionComponent = ({
+ + {({ input }) => ( + + + All Comments + + + )} + + + {({ input }) => ( + + + Staff Comments + + + )} + ({ reportedComments pendingComments featuredComments + allComments + staffComments } } } diff --git a/src/core/server/events/listeners/slack.ts b/src/core/server/events/listeners/slack.ts deleted file mode 100644 index 546285536..000000000 --- a/src/core/server/events/listeners/slack.ts +++ /dev/null @@ -1,210 +0,0 @@ -import striptags from "striptags"; - -import { reconstructTenantURL } from "coral-server/app/url"; -import GraphContext from "coral-server/graph/context"; -import logger from "coral-server/logger"; -import { getLatestRevision } from "coral-server/models/comment"; -import { getStoryTitle, getURLWithCommentID } from "coral-server/models/story"; -import { createFetch } from "coral-server/services/fetch"; - -import { GQLMODERATION_QUEUE } from "coral-server/graph/schema/__generated__/types"; - -import { - CommentEnteredModerationQueueCoralEventPayload, - CommentFeaturedCoralEventPayload, -} from "../events"; -import { CoralEventListener, CoralEventPublisherFactory } from "../publisher"; -import { CoralEventType } from "../types"; - -type SlackCoralEventListenerPayloads = - | CommentFeaturedCoralEventPayload - | CommentEnteredModerationQueueCoralEventPayload; - -type Trigger = "reported" | "pending" | "featured"; - -export class SlackCoralEventListener - implements CoralEventListener { - public readonly name = "slack"; - public readonly events = [ - CoralEventType.COMMENT_FEATURED, - CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE, - ]; - private readonly fetch = createFetch({ name: "slack" }); - - private payloadTrigger( - payload: SlackCoralEventListenerPayloads - ): Trigger | null { - switch (payload.type) { - case CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE: - if (payload.data.queue === GQLMODERATION_QUEUE.REPORTED) { - return "reported"; - } else if (payload.data.queue === GQLMODERATION_QUEUE.PENDING) { - return "pending"; - } - break; - case CoralEventType.COMMENT_FEATURED: - return "featured"; - } - - return null; - } - - /** - * postMessage will prepare and send the incoming Slack webhook. - * - * @param ctx context of the request - * @param message the message prefix for the request - * @param payload payload for the event that occurred - * @param hookURL url to the Slack webhook that we should send the message to - */ - private async postMessage( - { loaders, config, tenant, req }: GraphContext, - message: string, - payload: SlackCoralEventListenerPayloads, - hookURL: string - ) { - // Get the comment. - const comment = await loaders.Comments.comment.load(payload.data.commentID); - if (!comment || !comment.authorID) { - return; - } - - // Get the story. - const story = await loaders.Stories.story.load(payload.data.storyID); - if (!story) { - return; - } - - // Get the author. - const author = await loaders.Users.user.load(comment.authorID); - if (!author) { - return; - } - - // Get some properties about the event. - const storyTitle = getStoryTitle(story); - const moderateLink = reconstructTenantURL( - config, - tenant, - req, - `/admin/moderate/comment/${comment.id}` - ); - const commentLink = getURLWithCommentID(story.url, comment.id); - - // Replace HTML link breaks with newlines. - const body = striptags(getLatestRevision(comment).body); - - // Send the post to the Slack URL. We don't wrap this in a try/catch because - // it's handled in the calling function. - const res = await this.fetch(hookURL, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `${message} on *<${story.url}|${storyTitle}>*`, - }, - }, - { type: "divider" }, - { - type: "section", - text: { - type: "plain_text", - text: body, - }, - }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: `Authored by *${author.username}* | <${moderateLink}|Go to Moderation> | <${commentLink}|See Comment>`, - }, - ], - }, - { type: "divider" }, - ], - }), - }); - - // Check that the request was completed successfully. - if (!res.ok) { - throw new Error(`slack returned non-200 status code: ${res.status}`); - } - } - - private getMessage(trigger: Trigger): string { - switch (trigger) { - case "featured": - return "This comment has been featured"; - case "pending": - return "This comment is pending"; - case "reported": - return "This comment has been reported"; - default: - throw new Error("invalid trigger"); - } - } - - public initialize: CoralEventPublisherFactory< - SlackCoralEventListenerPayloads - > = ctx => async payload => { - const { - tenant: { id: tenantID, slack }, - } = ctx; - - if ( - // If slack is not defined, - !slack || - // Or there are no slack channels, - slack.channels.length === 0 || - // Or each channel isn't enabled or configured right. - slack.channels.every(c => !c.enabled || !c.hookURL) - ) { - // Exit out then. - return; - } - - // Get the trigger that is associated with this payload. - const trigger = this.payloadTrigger(payload); - if (!trigger) { - return; - } - - // For each channel that is enabled with configuration. - for (const channel of slack.channels) { - if (!channel.enabled || !channel.hookURL) { - continue; - } - - if ( - // If featured comments are, and it's a featured comment, - (channel.triggers.featuredComments && trigger === "featured") || - // Or reported comments are, and it's a reported comment, - (channel.triggers.reportedComments && trigger === "reported") || - // Or pending comments are, and it's a pending comment, - (channel.triggers.pendingComments && trigger === "pending") - ) { - try { - // Post the message to slack. - await this.postMessage( - ctx, - this.getMessage(trigger), - payload, - channel.hookURL - ); - } catch (err) { - logger.error( - { err, tenantID, payload, channel }, - "could not post the comment to slack" - ); - } - } - } - }; -} diff --git a/src/core/server/events/listeners/slack/index.ts b/src/core/server/events/listeners/slack/index.ts new file mode 100644 index 000000000..15b73e9c1 --- /dev/null +++ b/src/core/server/events/listeners/slack/index.ts @@ -0,0 +1 @@ +export * from "./slack"; diff --git a/src/core/server/events/listeners/slack/publishEvent.ts b/src/core/server/events/listeners/slack/publishEvent.ts new file mode 100644 index 000000000..2e73c7eb0 --- /dev/null +++ b/src/core/server/events/listeners/slack/publishEvent.ts @@ -0,0 +1,127 @@ +import striptags from "striptags"; + +import { reconstructTenantURL } from "coral-server/app/url"; +import GraphContext from "coral-server/graph/context"; +import { Comment, getLatestRevision } from "coral-server/models/comment"; +import { + getStoryTitle, + getURLWithCommentID, + Story, +} from "coral-server/models/story"; +import { User } from "coral-server/models/user"; + +import { + GQLSlackChannel, + GQLUSER_ROLE, +} from "coral-server/graph/schema/__generated__/types"; + +export type Trigger = "reported" | "pending" | "featured" | "created"; + +export default class SlackPublishEvent { + public comment: Comment; + public story: Story; + public author: User; + public actionType: Trigger; + constructor( + actionType: Trigger, + comment: Comment, + story: Story, + author: User + ) { + this.actionType = actionType; + this.comment = comment; + this.story = story; + this.author = author; + } + + private getTrigger(): Trigger | "staffCreated" | null { + if ( + this.actionType && + this.actionType === "created" && + this.authorIsStaff() + ) { + return "staffCreated"; + } + return this.actionType; + } + + private authorIsStaff() { + return Boolean( + this.author && + [ + GQLUSER_ROLE.ADMIN, + GQLUSER_ROLE.MODERATOR, + GQLUSER_ROLE.STAFF, + ].includes(this.author.role) + ); + } + + public getMessage(): string { + switch (this.getTrigger()) { + case "created": + return "This comment has been created"; + case "staffCreated": + return "This comment has been created by staff"; + case "featured": + return "This comment has been featured"; + case "pending": + return "This comment is pending"; + case "reported": + return "This comment has been reported"; + default: + throw new Error("invalid trigger"); + } + } + + public shouldPublishToChannel({ triggers }: GQLSlackChannel) { + const trigger = this.getTrigger(); + return ( + (triggers.allComments && trigger === "created") || + (triggers.featuredComments && trigger === "featured") || + (triggers.reportedComments && trigger === "reported") || + (triggers.pendingComments && trigger === "pending") || + (triggers.staffComments && trigger === "staffCreated") + ); + } + + public getBlocks({ loaders, config, tenant, req }: GraphContext) { + const storyTitle = getStoryTitle(this.story); + const moderateLink = reconstructTenantURL( + config, + tenant, + req, + `/admin/moderate/comment/${this.comment.id}` + ); + const commentLink = getURLWithCommentID(this.story.url, this.comment.id); + + // Replace HTML link breaks with newlines. + const body = striptags(getLatestRevision(this.comment).body); + return [ + { + type: "section", + text: { + type: "mrkdwn", + text: `${this.getMessage()} on *<${this.story.url}|${storyTitle}>*`, + }, + }, + { type: "divider" }, + { + type: "section", + text: { + type: "plain_text", + text: body, + }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `Authored by *${this.author.username}* | <${moderateLink}|Go to Moderation> | <${commentLink}|See Comment>`, + }, + ], + }, + { type: "divider" }, + ]; + } +} diff --git a/src/core/server/events/listeners/slack/slack.ts b/src/core/server/events/listeners/slack/slack.ts new file mode 100644 index 000000000..2c751513c --- /dev/null +++ b/src/core/server/events/listeners/slack/slack.ts @@ -0,0 +1,144 @@ +import logger from "coral-server/logger"; +import { createFetch } from "coral-server/services/fetch"; + +import SlackPublishEvent, { Trigger } from "./publishEvent"; + +import { + CommentCreatedCoralEventPayload, + CommentEnteredModerationQueueCoralEventPayload, + CommentFeaturedCoralEventPayload, +} from "../../events"; +import { + CoralEventListener, + CoralEventPublisherFactory, +} from "../../publisher"; +import { CoralEventType } from "../../types"; + +import { GQLMODERATION_QUEUE } from "coral-server/graph/schema/__generated__/types"; +type SlackCoralEventListenerPayloads = + | CommentFeaturedCoralEventPayload + | CommentEnteredModerationQueueCoralEventPayload + | CommentCreatedCoralEventPayload; + +export class SlackCoralEventListener + implements CoralEventListener { + public readonly name = "slack"; + public readonly events = [ + CoralEventType.COMMENT_FEATURED, + CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE, + CoralEventType.COMMENT_CREATED, + ]; + private readonly fetch = createFetch({ name: "slack" }); + + /** + * postMessage will prepare and send the incoming Slack webhook. + * + * @param hookURL url to the Slack webhook that we should send the message to + * @param blocks the blocks for the message + */ + private async postMessage(hookURL: string, blocks: any[]) { + // Send the post to the Slack URL. We don't wrap this in a try/catch because + // it's handled in the calling function. + const res = await this.fetch(hookURL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + blocks, + }), + }); + + // Check that the request was completed successfully. + if (!res.ok) { + throw new Error(`slack returned non-200 status code: ${res.status}`); + } + } + private getActionType( + payload: SlackCoralEventListenerPayloads + ): Trigger | null { + switch (payload.type) { + case CoralEventType.COMMENT_CREATED: + return "created"; + case CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE: + if (payload.data.queue === GQLMODERATION_QUEUE.REPORTED) { + return "reported"; + } else if (payload.data.queue === GQLMODERATION_QUEUE.PENDING) { + return "pending"; + } + break; + case CoralEventType.COMMENT_FEATURED: + return "featured"; + } + + return null; + } + + public initialize: CoralEventPublisherFactory< + SlackCoralEventListenerPayloads + > = ctx => async payload => { + const { + tenant: { id: tenantID, slack }, + } = ctx; + + if ( + // If slack is not defined, + !slack || + // Or there are no slack channels, + slack.channels.length === 0 || + // Or each channel isn't enabled or configured right. + slack.channels.every(c => !c.enabled || !c.hookURL) + ) { + // Exit out then. + return; + } + + const actionType = this.getActionType(payload); + if (!actionType) { + return; + } + + const comment = await ctx.loaders.Comments.comment.load( + payload.data.commentID + ); + if (!comment || !comment.authorID) { + return; + } + + const author = await ctx.loaders.Users.user.load(comment.authorID); + if (!author) { + return; + } + + const story = await ctx.loaders.Stories.story.load(payload.data.storyID); + if (!story) { + return; + } + + const publishEvent = new SlackPublishEvent( + actionType, + comment, + story, + author + ); + + // For each channel that is enabled with configuration. + for (const channel of slack.channels) { + if (!channel.enabled || !channel.hookURL) { + continue; + } + + if (publishEvent.shouldPublishToChannel(channel)) { + try { + // Post the message to slack. + await this.postMessage(channel.hookURL, publishEvent.getBlocks(ctx)); + } catch (err) { + logger.error( + { err, tenantID, payload, channel }, + "could not post the comment to slack" + ); + } + } + } + }; +} diff --git a/src/core/server/graph/schema/schema.graphql b/src/core/server/graph/schema/schema.graphql index d8038beb0..bfd29561b 100644 --- a/src/core/server/graph/schema/schema.graphql +++ b/src/core/server/graph/schema/schema.graphql @@ -1040,17 +1040,27 @@ type SlackChannelTriggers { """ reportedComments is whether this channel will receive reported comments """ - reportedComments: Boolean! + reportedComments: Boolean """ pendingComments is whether this channel will receive pending comments """ - pendingComments: Boolean! + pendingComments: Boolean """ featuredComments is whether this channel will receive featured comments """ - featuredComments: Boolean! + featuredComments: Boolean + + """ + allComents is whether the channel will receive all comments + """ + allComments: Boolean + + """ + staffComments is whether the channel will receive staff comments + """ + staffComments: Boolean } type SlackChannel { @@ -3895,6 +3905,15 @@ input SlackTriggersConfigurationInput { featuredComments is whether this channel will receive featured comments """ featuredComments: Boolean + """ + allComents is whether the channel will receive all comments + """ + allComments: Boolean + + """ + staffComments is whether the channel will receive staff comments + """ + staffComments: Boolean } input SlackChannelConfigurationInput { diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 47a9093fc..c5a20936c 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -672,6 +672,8 @@ configure-slack-channel-triggers-label = configure-slack-channel-triggers-reportedComments = Reported Comments configure-slack-channel-triggers-pendingComments = Pending Comments configure-slack-channel-triggers-featuredComments = Featured Comments +configure-slack-channel-triggers-allComments = All Comments +configure-slack-channel-triggers-staffComments = Staff Comments ## moderate moderate-navigation-reported = reported