[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 <wyattjoh@gmail.com>
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
This commit is contained in:
Tessa Thornton
2020-03-26 21:21:09 -04:00
committed by GitHub
parent 3cb5d0ff9f
commit 7fd524cd6a
8 changed files with 334 additions and 213 deletions
@@ -153,6 +153,42 @@ const SlackChannel: FunctionComponent<Props> = ({
</div>
<div className={styles.notificationToggles}>
<Field
name={`${channel}.triggers.allComments`}
type="checkbox"
parse={parseBool}
>
{({ input }) => (
<CheckBox
id={`configure-slack-channel-triggers-allComments-${input.name}`}
disabled={disabled || !channelEnabled}
className={styles.trigger}
{...input}
>
<Localized id="configure-slack-channel-triggers-allComments">
All Comments
</Localized>
</CheckBox>
)}
</Field>
<Field
name={`${channel}.triggers.staffComments`}
type="checkbox"
parse={parseBool}
>
{({ input }) => (
<CheckBox
id={`configure-slack-channel-triggers-staffComments-${input.name}`}
disabled={disabled || !channelEnabled}
className={styles.trigger}
{...input}
>
<Localized id="configure-slack-channel-triggers-staffComments">
Staff Comments
</Localized>
</CheckBox>
)}
</Field>
<Field
name={`${channel}.triggers.reportedComments`}
type="checkbox"
@@ -144,6 +144,8 @@ const enhanced = withFragmentContainer<Props>({
reportedComments
pendingComments
featuredComments
allComments
staffComments
}
}
}
-210
View File
@@ -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<SlackCoralEventListenerPayloads> {
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"
);
}
}
}
};
}
@@ -0,0 +1 @@
export * from "./slack";
@@ -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" },
];
}
}
@@ -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<SlackCoralEventListenerPayloads> {
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"
);
}
}
}
};
}
+22 -3
View File
@@ -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 {
+2
View File
@@ -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