[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
+33 -1
View File
@@ -14563,6 +14563,12 @@
"@babel/runtime": "^7.3.1"
}
},
"final-form-arrays": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-1.1.2.tgz",
"integrity": "sha512-Pmq3MXI9zbSsY7WZ2eodWQAHpsw2/i5YkagzcCgqzRcSSHB8BD3yWi7YPSGTPXuG0ixcQe9VAYpQ+UEWcqf4zg==",
"dev": true
},
"finalhandler": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz",
@@ -24795,7 +24801,7 @@
"dependencies": {
"async": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
"resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz",
"integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=",
"dev": true
}
@@ -29421,6 +29427,32 @@
}
}
},
"react-final-form-arrays": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.0.tgz",
"integrity": "sha512-eJdAlhTKzlDD/d1wedD592a99eJNGO0e9GzY++RLN99P23cMGKSzCmsiGWLPwpY0H/C/LmNSL4XzWyH/aZembA==",
"dev": true,
"requires": {
"@babel/runtime": "^7.4.5"
},
"dependencies": {
"@babel/runtime": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.7.4.tgz",
"integrity": "sha512-r24eVUUr0QqNZa+qrImUk8fn5SPhHq+IfYvIoIMg0do3GdK9sMdiLKP3GYVVaxpPKORgm8KRKaNTEhAjgIpLMw==",
"dev": true,
"requires": {
"regenerator-runtime": "^0.13.2"
}
},
"regenerator-runtime": {
"version": "0.13.3",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz",
"integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==",
"dev": true
}
}
},
"react-helmet": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-5.2.1.tgz",
+2
View File
@@ -277,6 +277,7 @@
"eventemitter2": "^5.0.1",
"farce": "^0.2.6",
"final-form": "4.18.6",
"final-form-arrays": "1.1.2",
"flat": "^4.1.0",
"fluent-intl-polyfill": "^0.1.0",
"fluent-langneg": "^0.1.1",
@@ -328,6 +329,7 @@
"react-dom": "^16.9.0-alpha.0",
"react-error-overlay": "^5.1.6",
"react-final-form": "6.3.0",
"react-final-form-arrays": "3.1.0",
"react-popper": "^1.3.2",
"react-relay": "^4.0.0",
"react-relay-network-modern": "^4.0.4",
+2
View File
@@ -15,6 +15,7 @@ import {
GeneralConfigRoute,
ModerationConfigRoute,
OrganizationConfigRoute,
SlackConfigRoute,
WordListConfigRoute,
} from "./routes/Configure/sections";
import ForgotPasswordRoute from "./routes/ForgotPassword";
@@ -75,6 +76,7 @@ export default makeRouteConfig(
<Route path="auth" {...AuthConfigRoute.routeConfig} />
<Route path="advanced" {...AdvancedConfigRoute.routeConfig} />
<Route path="email" {...EmailConfigRoute.routeConfig} />
<Route path="slack" {...SlackConfigRoute.routeConfig} />
</Route>
</Route>
</Route>
@@ -1,4 +1,5 @@
import { FormApi, FormState } from "final-form";
import arrayMutators from "final-form-arrays";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { Form, FormSpy } from "react-final-form";
@@ -24,7 +25,7 @@ const Configure: FunctionComponent<Props> = ({
children,
}) => (
<MainLayout data-testid="configure-container">
<Form onSubmit={onSubmit}>
<Form onSubmit={onSubmit} mutators={{ ...arrayMutators }}>
{({ handleSubmit, submitting, form, pristine, submitError }) => (
<form autoComplete="off" onSubmit={handleSubmit} id="configure-form">
<FormSpy onChange={onChange} />
@@ -52,6 +53,9 @@ const Configure: FunctionComponent<Props> = ({
<Localized id="configure-sideBarNavigation-email">
<Link to="/admin/configure/email">Email</Link>
</Localized>
<Localized id="configure-sideBarNavigation-slack">
<Link to="/admin/configure/slack">Slack</Link>
</Localized>
<Localized id="configure-sideBarNavigation-advanced">
<Link to="/admin/configure/advanced">Advanced</Link>
</Localized>
@@ -30,6 +30,7 @@ const UpdateSettingsMutation = createMutation(
...OrganizationConfigContainer_settings
...WordListConfigContainer_settings
...AdvancedConfigContainer_settings
...SlackConfigContainer_settings
}
clientMutationId
}
@@ -5,6 +5,19 @@ exports[`renders correctly 1`] = `
data-testid="configure-container"
>
<ReactFinalForm
mutators={
Object {
"insert": [Function],
"move": [Function],
"pop": [Function],
"push": [Function],
"remove": [Function],
"shift": [Function],
"swap": [Function],
"unshift": [Function],
"update": [Function],
}
}
onSubmit={[Function]}
>
[Function]
@@ -0,0 +1,28 @@
.header {
flex: 1;
}
.description {
padding-bottom: var(--spacing-1);
}
.channelName {
margin-right: var(--spacing-2);
}
.trigger {
padding-left: var(--spacing-1);
}
.removeButton {
padding-top: 0px;
padding-bottom: 0px;
margin-bottom: 2px;
float: right;
}
.buttonIcon {
padding-right: var(--spacing-1);
}
@@ -0,0 +1,230 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { Field } from "react-final-form";
import { parseBool } from "coral-framework/lib/form";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import { InputDescription, InputLabel } from "coral-ui/components";
import {
Button,
ButtonIcon,
CheckBox,
Flex,
FormField,
TextField,
} from "coral-ui/components/v2";
import Header from "../../Header";
import ConfigBoxWithToggleField from "../Auth/ConfigBoxWithToggleField";
import styles from "./SlackChannel.css";
interface Props {
channel: any;
disabled: boolean;
index: number;
onRemoveClicked: (index: number) => void;
}
const SlackChannel: FunctionComponent<Props> = ({
channel,
disabled,
index,
onRemoveClicked,
}) => {
const onRemove = useCallback(() => {
onRemoveClicked(index);
}, [index, onRemoveClicked]);
return (
<ConfigBoxWithToggleField
title={
<Flex
className={styles.header}
justifyContent="space-between"
alignItems="center"
>
<div>
<Field name={`${channel}.name`}>
{({ input }) => (
<Header className={styles.channelName}>{input.value}</Header>
)}
</Field>
</div>
<div>
<Button
size="small"
variant="filled"
color="alert"
onClick={onRemove}
className={styles.removeButton}
>
<ButtonIcon size="md" className={styles.buttonIcon}>
delete_forever
</ButtonIcon>
<Localized id="configure-slack-channel-remove">Remove</Localized>
</Button>
</div>
</Flex>
}
name={`${channel}.enabled`}
disabled={disabled}
>
{(disabledInside: boolean) => (
<>
<FormField>
<Field name={`${channel}.name`}>
{({ input, meta }) => (
<>
<Localized id="configure-slack-channel-name-label">
<InputLabel container="legend">Name</InputLabel>
</Localized>
<Localized id="configure-slack-channel-name-description">
<InputDescription className={styles.description}>
This is only for your information, to easily identify each
Slack connection. Slack does not tell us the name of the
channel/s you're connecting to Coral.
</InputDescription>
</Localized>
<TextField
id={`configure-slack-channel-name-${input.name}`}
disabled={disabled || disabledInside}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
fullWidth
{...input}
/>
</>
)}
</Field>
</FormField>
<FormField>
<Field name={`${channel}.hookURL`}>
{({ input, meta }) => (
<>
<Localized id="configure-slack-channel-hookURL-label">
<InputLabel container="legend">Webhook URL</InputLabel>
</Localized>
<Localized
id="configure-slack-channel-hookURL-description"
externalLink={
<ExternalLink href="https://docs.coralproject.net/coral/v5/integrating/slack/#i-need-to-find-the-webhook-url-again-where-is-it" />
}
>
<InputDescription className={styles.description}>
Slack provides a channel-specific URL to activate webhook
connections. To find the URL for one of your Slack
channels, follow the instructions here.
</InputDescription>
</Localized>
<TextField
id={`configure-slack-channel-hookURL-${input.name}`}
disabled={disabled || disabledInside}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
fullWidth
{...input}
/>
</>
)}
</Field>
</FormField>
<FormField>
<Localized id="configure-slack-channel-triggers-label">
<InputLabel container="legend">
Receive notifications in this Slack channel for
</InputLabel>
</Localized>
<Field
name={`${channel}.triggers.allComments`}
subscription={{ value: true }}
>
{({ input: { value } }) => (
<>
<Field
name={`${channel}.triggers.allComments`}
type="checkbox"
parse={parseBool}
>
{({ input }) => (
<CheckBox
id={`configure-slack-channel-triggers-allComments-${input.name}`}
disabled={disabled || disabledInside}
className={styles.trigger}
{...input}
>
<Localized id="configure-slack-channel-triggers-allComments">
All Comments
</Localized>
</CheckBox>
)}
</Field>
<Field
name={`${channel}.triggers.reportedComments`}
type="checkbox"
parse={parseBool}
>
{({ input }) => (
<CheckBox
id={`configure-slack-channel-triggers-reportedComments-${input.name}`}
disabled={disabled || value || disabledInside}
className={styles.trigger}
{...input}
>
<Localized id="configure-slack-channel-triggers-reportedComments">
Reported Comments
</Localized>
</CheckBox>
)}
</Field>
<Field
name={`${channel}.triggers.pendingComments`}
type="checkbox"
parse={parseBool}
>
{({ input }) => (
<CheckBox
id={`configure-slack-channel-triggers-pendingComments-${input.name}`}
disabled={disabled || value || disabledInside}
className={styles.trigger}
{...input}
>
<Localized id="configure-slack-channel-triggers-pendingComments">
Pending Comments
</Localized>
</CheckBox>
)}
</Field>
<Field
name={`${channel}.triggers.featuredComments`}
type="checkbox"
parse={parseBool}
>
{({ input }) => (
<CheckBox
id={`configure-slack-channel-triggers-featuredComments-${input.name}`}
disabled={disabled || value || disabledInside}
className={styles.trigger}
{...input}
>
<Localized id="configure-slack-channel-triggers-featuredComments">
Featured Comments
</Localized>
</CheckBox>
)}
</Field>
</>
)}
</Field>
</FormField>
</>
)}
</ConfigBoxWithToggleField>
);
};
export default SlackChannel;
@@ -0,0 +1,3 @@
.icon {
padding-right: var(--spacing-2);
}
@@ -0,0 +1,162 @@
import { FormApi } from "final-form";
import { Localized } from "fluent-react/compat";
import React, {
FunctionComponent,
useCallback,
useMemo,
useState,
} from "react";
import { FieldArray } from "react-final-form-arrays";
import { pureMerge } from "coral-common/utils";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import {
Button,
ButtonIcon,
FormFieldDescription,
HorizontalGutter,
} from "coral-ui/components/v2";
import { SlackConfigContainer_settings } from "coral-admin/__generated__/SlackConfigContainer_settings.graphql";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import SlackChannel from "./SlackChannel";
import styles from "./SlackConfigContainer.css";
interface Props {
form: FormApi;
submitting: boolean;
settings: SlackConfigContainer_settings;
}
const SlackConfigContainer: FunctionComponent<Props> = ({ form, settings }) => {
const [defaultValues] = useState({
slack: {
channels: [
{
enabled: false,
name: "",
hookURL: "",
triggers: {
allComments: false,
reportedComments: false,
pendingComments: false,
featuredComments: false,
},
},
],
},
});
const onAddChannel = useCallback(() => {
const mutators = form.mutators;
mutators.insert("slack.channels", 0, {
enabled: true,
hookURL: "",
triggers: {
allComments: false,
reportedComments: false,
pendingComments: false,
featuredComments: false,
},
});
}, [form]);
const onRemoveChannel = useCallback(
(index: number) => {
const mutators = form.mutators;
mutators.remove("slack.channels", index);
},
[form]
);
useMemo(() => {
if (
!settings ||
!settings.slack ||
!settings.slack.channels ||
settings.slack.channels.length === 0
) {
form.initialize(defaultValues);
} else {
const settingsValues = pureMerge(defaultValues, settings);
form.initialize(settingsValues);
}
}, [settings, defaultValues]);
return (
<HorizontalGutter size="double">
<ConfigBox
title={
<Localized id="configure-slack-header-title">
<Header htmlFor="configure-slack-header.title">
Slack Integrations
</Header>
</Localized>
}
>
<Localized
id="configure-slack-description"
externalLink={
<ExternalLink href="https://docs.coralproject.net/coral/v5/integrating/slack/" />
}
>
<FormFieldDescription>
Automatically send comments from Coral moderation queues to Slack
channels. You will need Slack admin access to set this up. For steps
on how to create a Slack App see our documentation.
</FormFieldDescription>
</Localized>
<Button
size="medium"
variant="filled"
color="emphasis"
onClick={onAddChannel}
>
<ButtonIcon size="md" className={styles.icon}>
add
</ButtonIcon>
<Localized id="configure-slack-addChannel">Add</Localized>
</Button>
<FieldArray name="slack.channels">
{({ fields }) =>
fields.map((channel: any, index: number) => (
<div key={index}>
<SlackChannel
channel={channel}
disabled={false}
index={index}
onRemoveClicked={onRemoveChannel}
/>
</div>
))
}
</FieldArray>
</ConfigBox>
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment SlackConfigContainer_settings on Settings {
slack {
channels {
enabled
name
hookURL
triggers {
allComments
reportedComments
pendingComments
featuredComments
}
}
}
}
`,
})(SlackConfigContainer);
export default enhanced;
@@ -0,0 +1,50 @@
import { FormApi } from "final-form";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { SlackConfigRouteQueryResponse } from "coral-admin/__generated__/SlackConfigRouteQuery.graphql";
import { withRouteConfig } from "coral-framework/lib/router";
import { Delay, Spinner } from "coral-ui/components";
import SlackConfigContainer from "./SlackConfigContainer";
interface Props {
data: SlackConfigRouteQueryResponse | null;
form: FormApi;
submitting: boolean;
}
const SlackConfigRoute: FunctionComponent<Props> = ({
data,
form,
submitting,
}) => {
if (!data) {
return (
<Delay>
<Spinner />
</Delay>
);
}
return (
<SlackConfigContainer
settings={data.settings}
form={form}
submitting={submitting}
/>
);
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query SlackConfigRouteQuery {
settings {
...SlackConfigContainer_settings
}
}
`,
cacheConfig: { force: true },
})(SlackConfigRoute);
export default enhanced;
@@ -0,0 +1 @@
export { default, default as SlackConfigRoute } from "./SlackConfigRoute";
@@ -5,3 +5,4 @@ export { GeneralConfigRoute } from "./General";
export { ModerationConfigRoute } from "./Moderation";
export { OrganizationConfigRoute } from "./Organization";
export { WordListConfigRoute } from "./WordList";
export { SlackConfigRoute } from "./Slack";
@@ -79,6 +79,15 @@ exports[`renders configure advanced 1`] = `
Email
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/slack"
onClick={[Function]}
>
Slack
</a>
</li>
<li>
<a
className="Link-link Link-linkActive"
@@ -79,6 +79,15 @@ exports[`renders configure auth 1`] = `
Email
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/slack"
onClick={[Function]}
>
Slack
</a>
</li>
<li>
<a
className="Link-link"
@@ -79,6 +79,15 @@ exports[`renders configure general 1`] = `
Email
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/slack"
onClick={[Function]}
>
Slack
</a>
</li>
<li>
<a
className="Link-link"
@@ -79,6 +79,15 @@ exports[`renders configure moderation 1`] = `
Email
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/slack"
onClick={[Function]}
>
Slack
</a>
</li>
<li>
<a
className="Link-link"
@@ -79,6 +79,15 @@ exports[`renders configure organization 1`] = `
Email
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/slack"
onClick={[Function]}
>
Slack
</a>
</li>
<li>
<a
className="Link-link"
@@ -79,6 +79,15 @@ exports[`renders configure wordList 1`] = `
Email
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/slack"
onClick={[Function]}
>
Slack
</a>
</li>
<li>
<a
className="Link-link"
@@ -90,14 +90,16 @@ it("change language", async () => {
act(() => languageField.props.onChange("es"));
// Send form
act(() => {
within(configureContainer)
await act(async () => {
await within(configureContainer)
.getByType("form")
.props.onSubmit();
});
// Submit button and text field should be disabled.
expect(saveChangesButton.props.disabled).toBe(true);
await wait(() => {
expect(saveChangesButton.props.disabled).toBe(true);
});
// Wait for submission to be finished
await act(async () => {
+3
View File
@@ -161,6 +161,9 @@ export const settings = createFixture<GQLSettings>({
changeUsername: true,
deleteAccount: true,
},
slack: {
channels: [],
},
});
export const settingsWithEmptyAuth = createFixture<GQLSettings>(
+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]);
+29
View File
@@ -78,6 +78,7 @@ configure-sideBarNavigation-organization = Organization
configure-sideBarNavigation-advanced = Advanced
configure-sideBarNavigation-email = Email
configure-sideBarNavigation-bannedAndSuspectWords = Banned and Suspect Words
configure-sideBarNavigation-slack = Slack
configure-sideBar-saveChanges = Save Changes
configure-configurationSubHeader = Configuration
@@ -380,6 +381,34 @@ decisionHistory-rejectedCommentBy = Rejected comment by <Username></Username>
decisionHistory-approvedCommentBy = Approved comment by <Username></Username>
decisionHistory-goToComment = Go to comment
### Slack
configure-slack-header-title = Slack Integrations
configure-slack-description =
Automatically send comments from Coral moderation queues to Slack
channels. You will need Slack admin access to set this up. For
steps on how to create a Slack App see our <externalLink>documentation</externalLink>.
configure-slack-addChannel = Add
configure-slack-channel-enabled = Enabled
configure-slack-channel-remove = Remove
configure-slack-channel-name-label = Name
configure-slack-channel-name-description =
This is only for your information, to easily identify
each Slack connection. Slack does not tell us the name
of the channel/s you're connecting to Coral.
configure-slack-channel-hookURL-label = Webhook URL
configure-slack-channel-hookURL-description =
Slack provides a channel-specific URL to activate webhook
connections. To find the URL for one of your Slack channels,
follow the instructions <externalLink>here</externalLink>.
configure-slack-channel-triggers-label =
Receive notifications in this Slack channel for
configure-slack-channel-triggers-allComments = All Comments
configure-slack-channel-triggers-reportedComments = Reported Comments
configure-slack-channel-triggers-pendingComments = Pending Comments
configure-slack-channel-triggers-featuredComments = Featured Comments
## moderate
moderate-navigation-reported = reported
moderate-navigation-pending = Pending