[CORL-645] Add Slack support for v5 (#2713)

* Create preliminary schema for setting Slack channels

CORL-645

* Implement preliminary slack notification using tenant slack channels

CORL-645

* Very preliminarily get data loading with FieldArray's for slack channels

CORL-645

* Update settings input on schema to allow saving Slack settings to tenant

CORL-645

* Filter off UNMODERATED queue events from slack channels

We don't send these to slack through our filters, we only care
about pending, reported, and featured

CORL-645

* Include a moderation link in comments pushed to Slack

CORL-645

* Hook up proper callback functions for adding/removing slack channels

CORL-645

* Add missing translation for Slack navigation item

CORL-645

* Update snapshots for preliminary Slack configuration changes

CORL-645

* Add some FormField wrappers around slack config elements

Makes the UI appear a little nicer

CORL-645

* Set up slack config to only provide one slack channel

We need to do this until we can get ArrayField's working
in final-form.

CORL-645

* Disable the other trigger checkboxes when "All Comments" is checked

CORL-645

* Clean up the formatting of Slack messages

CORL-645

* Add error handling around sending comments to Slack

CORL-645

* Add links to external Slack setup documentation

CORL-645

* Replace form state with wrapped field element

CORL-645

* Clean up fetch request sending Slack notifications

CORL-645

* Prefer global string replacement over RegEx

CORL-645

* Use URL class to construct comment URL's

CORL-645

* Require slack configuration in schema

CORL-645

* Initialize Slack in fixtures

Also fix up a flaky test that wasn't waiting
on form submission and on-change events.

CORL-645

* Preliminarily fix up styles to match other config pages

CORL-645

* Create placeholder add/remove buttons

* Convert SlackConfigContainer to FunctionalComponent

CORL-645

* Add name field to slack channels

CORL-645

* Disable inner fields on Slack channel when not enabled

CORL-645

* Improve the delete channel button

CORL-645

* Use pureMerge to extract slack channel settings

CORL-645

* Do a bit of cleanup on the add channel button

CORL-645
This commit is contained in:
Nick Funk
2019-12-03 16:06:38 -05:00
committed by Kim Gardner
parent c109188efe
commit 54296fa484
34 changed files with 1057 additions and 19 deletions
+7 -5
View File
@@ -12,6 +12,7 @@ import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
import { JWTSigningConfig } from "coral-server/services/jwt";
import createSlackPublisher from "coral-server/services/slack/publisher";
import TenantCache from "coral-server/services/tenant/cache";
import loaders from "./loaders";
@@ -58,12 +59,13 @@ export default class TenantContext extends CommonContext {
this.mailerQueue = options.mailerQueue;
this.signingConfig = options.signingConfig;
this.clientID = options.clientID;
this.publisher = createPublisher(
this.pubsub,
this.publisher = createPublisher({
pubsub: this.pubsub,
slackPublisher: createSlackPublisher(this.mongo, this.tenant),
notifierQueue,
this.tenant.id,
this.clientID
);
tenantID: this.tenant.id,
clientID: this.clientID,
});
this.loaders = loaders(this);
this.mutators = mutators(this);
}
@@ -2,5 +2,5 @@ import { GQLSettingsTypeResolver } from "coral-server/graph/tenant/schema/__gene
import { Tenant } from "coral-server/models/tenant";
export const Settings: GQLSettingsTypeResolver<Tenant> = {
staticURI: (tenant, args, ctx) => ctx.config.get("static_uri") || null,
slack: ({ slack = {} }) => slack,
};
@@ -0,0 +1,11 @@
import {
GQLSlackConfiguration,
GQLSlackConfigurationTypeResolver,
} from "coral-server/graph/tenant/schema/__generated__/types";
export const SlackConfiguration: GQLSlackConfigurationTypeResolver<
GQLSlackConfiguration
> = {
// TODO: Remove this when we create a migration to generate slack channels on existing tenants
channels: ({ channels = [] }) => channels,
};
@@ -37,6 +37,8 @@ import { Profile } from "./Profile";
import { Query } from "./Query";
import { RecentCommentHistory } from "./RecentCommentHistory";
import { RejectCommentPayload } from "./RejectCommentPayload";
import { Settings } from "./Settings";
import { SlackConfiguration } from "./SlackConfiguration";
import { SSOAuthIntegration } from "./SSOAuthIntegration";
import { Story } from "./Story";
import { StorySettings } from "./StorySettings";
@@ -97,6 +99,8 @@ const Resolvers: GQLResolver = {
UsernameHistory,
UsernameStatus,
UserStatus,
Settings,
SlackConfiguration,
};
export default Resolvers;
@@ -936,6 +936,64 @@ type EmailConfiguration {
smtp: SMTP! @auth(roles: [ADMIN])
}
################################################################################
## Slack Configuration
################################################################################
type SlackConfiguration {
"""
channels is the set of Slack channels configured to receive comments
"""
channels: [SlackChannel!]!
}
type SlackChannelTriggers {
"""
allComments is whether this channel will receive all comments entering moderation
"""
allComments: Boolean!
"""
reportedComments is whether this channel will receive reported comments
"""
reportedComments: Boolean!
"""
pendingComments is whether this channel will receive pending comments
"""
pendingComments: Boolean!
"""
featuredComments is whether this channel will receive featured comments
"""
featuredComments: Boolean!
}
type SlackChannel {
"""
enabled is whether this Slack channel is enabled to send comments to its
correlated web hook
"""
enabled: Boolean!
"""
name is the name assigned to the Slack channel
"""
name: String
"""
hookURL defines the URL that comments will be sent to
"""
hookURL: String
"""
triggers are the filters of types of comments that will be sent to
the correlated channel
"""
triggers: SlackChannelTriggers
}
################################################################################
## ReactionConfiguration
################################################################################
@@ -1217,6 +1275,11 @@ type Settings {
"""
email: EmailConfiguration! @auth(roles: [ADMIN, MODERATOR])
"""
slack is the configuration for slack communication
"""
slack: SlackConfiguration! @auth(roles: [ADMIN, MODERATOR])
"""
wordList will return a given list of words.
"""
@@ -3397,6 +3460,60 @@ input CommenterAccountFeaturesInput {
deleteAccount: Boolean
}
input SlackTriggersConfigurationInput {
"""
allComments is whether this channel will receive all comments entering moderation
"""
allComments: Boolean
"""
reportedComments is whether this channel will receive reported comments
"""
reportedComments: Boolean
"""
pendingComments is whether this channel will receive pending comments
"""
pendingComments: Boolean
"""
featuredComments is whether this channel will receive featured comments
"""
featuredComments: Boolean
}
input SlackChannelConfigurationInput {
"""
enabled is whether this Slack channel is enabled to send comments to its
correlated web hook
"""
enabled: Boolean
"""
name is the name assigned to the Slack channel
"""
name: String
"""
hookURL defines the URL that comments will be sent to
"""
hookURL: String
"""
triggers are the filters of types of comments that will be sent to
the correlated channel
"""
triggers: SlackTriggersConfigurationInput
}
input SlackConfigurationInput {
"""
channels are the channels configured for Slack integration
"""
channels: [SlackChannelConfigurationInput!]
}
"""
SettingsInput is the partial type of the Settings type for performing mutations.
"""
@@ -3507,6 +3624,11 @@ input SettingsInput {
"""
accountFeatures: CommenterAccountFeaturesInput
"""
slack specifies the configuration for Slack integration.
"""
slack: SlackConfigurationInput
"""
locale specifies the locale for this Tenant.
"""
@@ -4,24 +4,35 @@ import { createSubscriptionChannelName } from "coral-server/graph/tenant/resolve
import { SUBSCRIPTION_INPUT } from "coral-server/graph/tenant/resolvers/Subscription/types";
import logger from "coral-server/logger";
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
import { SlackPublisher } from "coral-server/services/slack/publisher";
export type Publisher = (input: SUBSCRIPTION_INPUT) => Promise<void>;
export interface PublisherOptions {
pubsub: RedisPubSub;
slackPublisher: SlackPublisher;
notifierQueue: NotifierQueue;
tenantID: string;
clientID?: string;
}
/**
* createPublisher will create a new Publisher that can be used to send events
* over the pubsub broker to facilitate live updates and notifications.
*
* TODO: Update
* @param pubsub the pubsub broker to be used to facilitate the publish action
* @param notifier
* @param tenantID the ID of the Tenant where the event will be published with
* @param clientID the ID of the client to de-duplicate mutation responses
*/
export const createPublisher = (
pubsub: RedisPubSub,
notifier: NotifierQueue,
tenantID: string,
clientID?: string
): Publisher => async input => {
export const createPublisher = ({
pubsub,
slackPublisher,
notifierQueue,
tenantID,
clientID,
}: PublisherOptions): Publisher => async input => {
const { channel, payload } = input;
logger.trace({ channel, tenantID, clientID }, "publishing event");
@@ -33,8 +44,11 @@ export const createPublisher = (
...payload,
clientID,
}),
slackPublisher(channel, payload),
// Notify the notifications queue so we can offload notification processing
// to it.
notifier.add({ tenantID, input }),
notifierQueue.add({ tenantID, input }),
]);
};
+1
View File
@@ -137,6 +137,7 @@ export type Settings = GlobalModerationSettings &
| "communityGuidelines"
| "stories"
| "createdAt"
| "slack"
> & {
/**
* auth is the set of configured authentication integrations.
+3
View File
@@ -190,6 +190,9 @@ export async function createTenant(
downloadComments: false,
},
createdAt: now,
slack: {
channels: [],
},
};
// Create the new Tenant by merging it together with the defaults.
+46
View File
@@ -0,0 +1,46 @@
import Logger from "bunyan";
import DataLoader from "dataloader";
import { Db } from "mongodb";
import { Comment, retrieveManyComments } from "coral-server/models/comment";
import { retrieveManyStories, Story } from "coral-server/models/story";
import { retrieveManyUsers, User } from "coral-server/models/user";
interface Options {
mongo: Db;
tenantID: string;
}
class SlackContext {
public readonly mongo: Db;
public readonly tenantID: string;
public readonly logger: Logger;
public readonly comments: DataLoader<
string,
Readonly<Comment> | null
> = new DataLoader(commentIDs =>
retrieveManyComments(this.mongo, this.tenantID, commentIDs)
);
public readonly stories: DataLoader<
string,
Readonly<Story> | null
> = new DataLoader(storyIDs =>
retrieveManyStories(this.mongo, this.tenantID, storyIDs)
);
public readonly users: DataLoader<
string,
Readonly<User> | null
> = new DataLoader(userIDs =>
retrieveManyUsers(this.mongo, this.tenantID, userIDs)
);
constructor({ mongo, tenantID }: Options) {
this.mongo = mongo;
this.tenantID = tenantID;
}
}
export default SlackContext;
+3
View File
@@ -0,0 +1,3 @@
export { default as slackPublisher } from "./publisher";
export * from "./publisher";
export * from "./context";
+213
View File
@@ -0,0 +1,213 @@
import { CommentCreatedInput } from "coral-server/graph/tenant/resolvers/Subscription/commentCreated";
import { CommentEnteredModerationQueueInput } from "coral-server/graph/tenant/resolvers/Subscription/commentEnteredModerationQueue";
import { CommentFeaturedInput } from "coral-server/graph/tenant/resolvers/Subscription/commentFeatured";
import { CommentLeftModerationQueueInput } from "coral-server/graph/tenant/resolvers/Subscription/commentLeftModerationQueue";
import { CommentReleasedInput } from "coral-server/graph/tenant/resolvers/Subscription/commentReleased";
import { CommentReplyCreatedInput } from "coral-server/graph/tenant/resolvers/Subscription/commentReplyCreated";
import { CommentStatusUpdatedInput } from "coral-server/graph/tenant/resolvers/Subscription/commentStatusUpdated";
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/tenant/resolvers/Subscription/types";
import logger from "coral-server/logger";
import { GQLMODERATION_QUEUE } from "coral-server/graph/tenant/schema/__generated__/types";
import { Tenant } from "coral-server/models/tenant";
import { Db } from "mongodb";
import SlackContext from "./context";
type Payload =
| CommentEnteredModerationQueueInput
| CommentLeftModerationQueueInput
| CommentStatusUpdatedInput
| CommentReplyCreatedInput
| CommentCreatedInput
| CommentFeaturedInput
| CommentReleasedInput;
function enteredModeration(channel: SUBSCRIPTION_CHANNELS, payload: Payload) {
const p: any = payload;
return (
channel === SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE &&
p.hasOwnProperty("queue") &&
(p.queue === GQLMODERATION_QUEUE.REPORTED ||
p.queue === GQLMODERATION_QUEUE.PENDING)
);
}
function isFeatured(channel: SUBSCRIPTION_CHANNELS) {
return channel === SUBSCRIPTION_CHANNELS.COMMENT_FEATURED;
}
function isReported(channel: SUBSCRIPTION_CHANNELS, payload: Payload) {
const p: any = payload;
return (
channel === SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE &&
p.hasOwnProperty("queue") &&
p.queue === GQLMODERATION_QUEUE.REPORTED
);
}
function isPending(channel: SUBSCRIPTION_CHANNELS, payload: Payload) {
const p: any = payload;
return (
channel === SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE &&
p.hasOwnProperty("queue") &&
p.queue === GQLMODERATION_QUEUE.PENDING
);
}
function createModerationLink(rootURL: string, commentID: string) {
return `${rootURL}/admin/moderate/comment/${commentID}`;
}
function createCommentLink(storyURL: string, commentID: string) {
const urlBuilder = new URL(storyURL);
urlBuilder.searchParams.set("commentID", commentID);
return urlBuilder.href;
}
async function postCommentToSlack(
ctx: SlackContext,
orgURL: string,
commentID: string,
webhookURL: string
) {
const comment = await ctx.comments.load(commentID);
if (comment === null) {
return;
}
const story = await ctx.stories.load(comment.storyID);
if (story === null) {
return;
}
const author = await ctx.users.load(comment.authorID);
if (author === null) {
return;
}
const storyTitle = story.metadata ? story.metadata.title : "";
const commentBody =
comment.revisions.length > 0
? comment.revisions[comment.revisions.length - 1].body
: "";
const body = commentBody.replace(/<br\/?>/g, "\n");
const moderateLink = createModerationLink(orgURL, comment.id);
const commentLink = createCommentLink(story.url, comment.id);
const data = {
text: `${author.username} commented on: ${storyTitle}`,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `${author.username} commented on:\n<${story.url}|${storyTitle}>`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `<${moderateLink}|Go to Moderation> | <${commentLink}|See Comment>`,
},
},
{
type: "divider",
},
{
type: "section",
text: {
type: "mrkdwn",
text: body,
},
},
],
};
try {
const response = await fetch(webhookURL, {
method: "POST",
cache: "no-cache",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (response.ok) {
logger.error({ response }, "error sending Slack comment");
}
} catch (err) {
logger.error({ err }, "error sending Slack comment");
}
}
export type SlackPublisher = (
channel: SUBSCRIPTION_CHANNELS,
payload: Payload
) => Promise<void>;
function createSlackPublisher(mongo: Db, tenant: Tenant): SlackPublisher {
if (
!tenant.slack ||
!tenant.slack.channels ||
tenant.slack.channels.length === 0
) {
return async () => {
// noop
};
}
const { channels } = tenant.slack;
return async (channel: SUBSCRIPTION_CHANNELS, payload: Payload) => {
const ctx = new SlackContext({ mongo, tenantID: tenant.id });
try {
const inModeration = enteredModeration(channel, payload);
const reported = isReported(channel, payload);
const pending = isPending(channel, payload);
const featured = isFeatured(channel);
const { commentID } = payload;
for (const ch of channels) {
if (!ch) {
return;
}
if (!ch.enabled) {
return;
}
const { hookURL } = ch;
if (!hookURL) {
return;
}
const { triggers } = ch;
if (!triggers) {
return;
}
const orgURL = tenant.organization.url;
if (
triggers.allComments &&
(reported || pending || featured || inModeration)
) {
await postCommentToSlack(ctx, orgURL, commentID, hookURL);
} else if (triggers.reportedComments && reported) {
await postCommentToSlack(ctx, orgURL, commentID, hookURL);
} else if (triggers.pendingComments && pending) {
await postCommentToSlack(ctx, orgURL, commentID, hookURL);
} else if (triggers.featuredComments && featured) {
await postCommentToSlack(ctx, orgURL, commentID, hookURL);
}
}
} catch (err) {
logger.error(
{ err, tenantID: tenant.id, channel, payload },
"could not handle comment in Slack publisher"
);
}
};
}
export default createSlackPublisher;
@@ -104,7 +104,9 @@ export async function sendUserDownload(
const commentID = comment.id;
const createdAt = formatter.format(new Date(comment.createdAt));
const storyURL = story.url;
const commentURL = `${storyURL}?commentID=${commentID}`;
const urlBuilder = new URL(storyURL);
urlBuilder.searchParams.set("commentID", commentID);
const commentURL = urlBuilder.href;
const body = revision.body;
csv.write([commentID, createdAt, storyURL, commentURL, body]);