diff --git a/package-lock.json b/package-lock.json index 93e985465..32308f823 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5f78b1000..73d34d87b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/client/admin/routeConfig.tsx b/src/core/client/admin/routeConfig.tsx index 158526561..d6a8eabc1 100644 --- a/src/core/client/admin/routeConfig.tsx +++ b/src/core/client/admin/routeConfig.tsx @@ -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( + diff --git a/src/core/client/admin/routes/Configure/Configure.tsx b/src/core/client/admin/routes/Configure/Configure.tsx index 74e3208a9..ce4ae6213 100644 --- a/src/core/client/admin/routes/Configure/Configure.tsx +++ b/src/core/client/admin/routes/Configure/Configure.tsx @@ -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 = ({ children, }) => ( -
+ {({ handleSubmit, submitting, form, pristine, submitError }) => ( @@ -52,6 +53,9 @@ const Configure: FunctionComponent = ({ Email + + Slack + Advanced diff --git a/src/core/client/admin/routes/Configure/UpdateSettingsMutation.ts b/src/core/client/admin/routes/Configure/UpdateSettingsMutation.ts index 449b2bf04..0e8f4ee4f 100644 --- a/src/core/client/admin/routes/Configure/UpdateSettingsMutation.ts +++ b/src/core/client/admin/routes/Configure/UpdateSettingsMutation.ts @@ -30,6 +30,7 @@ const UpdateSettingsMutation = createMutation( ...OrganizationConfigContainer_settings ...WordListConfigContainer_settings ...AdvancedConfigContainer_settings + ...SlackConfigContainer_settings } clientMutationId } diff --git a/src/core/client/admin/routes/Configure/__snapshots__/Configure.spec.tsx.snap b/src/core/client/admin/routes/Configure/__snapshots__/Configure.spec.tsx.snap index 49ffcedf4..3a07ebdd1 100644 --- a/src/core/client/admin/routes/Configure/__snapshots__/Configure.spec.tsx.snap +++ b/src/core/client/admin/routes/Configure/__snapshots__/Configure.spec.tsx.snap @@ -5,6 +5,19 @@ exports[`renders correctly 1`] = ` data-testid="configure-container" > [Function] diff --git a/src/core/client/admin/routes/Configure/sections/Slack/SlackChannel.css b/src/core/client/admin/routes/Configure/sections/Slack/SlackChannel.css new file mode 100644 index 000000000..6d335ada8 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Slack/SlackChannel.css @@ -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); +} diff --git a/src/core/client/admin/routes/Configure/sections/Slack/SlackChannel.tsx b/src/core/client/admin/routes/Configure/sections/Slack/SlackChannel.tsx new file mode 100644 index 000000000..fd263d089 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Slack/SlackChannel.tsx @@ -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 = ({ + channel, + disabled, + index, + onRemoveClicked, +}) => { + const onRemove = useCallback(() => { + onRemoveClicked(index); + }, [index, onRemoveClicked]); + + return ( + +
+ + {({ input }) => ( +
{input.value}
+ )} +
+
+
+ +
+ + } + name={`${channel}.enabled`} + disabled={disabled} + > + {(disabledInside: boolean) => ( + <> + + + {({ input, meta }) => ( + <> + + Name + + + + 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. + + + + + )} + + + + + {({ input, meta }) => ( + <> + + Webhook URL + + + } + > + + Slack provides a channel-specific URL to activate webhook + connections. To find the URL for one of your Slack + channels, follow the instructions here. + + + + + )} + + + + + + Receive notifications in this Slack channel for + + + + {({ input: { value } }) => ( + <> + + {({ input }) => ( + + + All Comments + + + )} + + + {({ input }) => ( + + + Reported Comments + + + )} + + + {({ input }) => ( + + + Pending Comments + + + )} + + + {({ input }) => ( + + + Featured Comments + + + )} + + + )} + + + + )} +
+ ); +}; + +export default SlackChannel; diff --git a/src/core/client/admin/routes/Configure/sections/Slack/SlackConfigContainer.css b/src/core/client/admin/routes/Configure/sections/Slack/SlackConfigContainer.css new file mode 100644 index 000000000..846f4d313 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Slack/SlackConfigContainer.css @@ -0,0 +1,3 @@ +.icon { + padding-right: var(--spacing-2); +} diff --git a/src/core/client/admin/routes/Configure/sections/Slack/SlackConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/Slack/SlackConfigContainer.tsx new file mode 100644 index 000000000..ce558baad --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Slack/SlackConfigContainer.tsx @@ -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 = ({ 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 ( + + +
+ Slack Integrations +
+ + } + > + + } + > + + 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. + + + + + {({ fields }) => + fields.map((channel: any, index: number) => ( +
+ +
+ )) + } +
+
+
+ ); +}; + +const enhanced = withFragmentContainer({ + settings: graphql` + fragment SlackConfigContainer_settings on Settings { + slack { + channels { + enabled + name + hookURL + triggers { + allComments + reportedComments + pendingComments + featuredComments + } + } + } + } + `, +})(SlackConfigContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Slack/SlackConfigRoute.tsx b/src/core/client/admin/routes/Configure/sections/Slack/SlackConfigRoute.tsx new file mode 100644 index 000000000..ceb1bc149 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Slack/SlackConfigRoute.tsx @@ -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 = ({ + data, + form, + submitting, +}) => { + if (!data) { + return ( + + + + ); + } + + return ( + + ); +}; + +const enhanced = withRouteConfig({ + query: graphql` + query SlackConfigRouteQuery { + settings { + ...SlackConfigContainer_settings + } + } + `, + cacheConfig: { force: true }, +})(SlackConfigRoute); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Slack/index.ts b/src/core/client/admin/routes/Configure/sections/Slack/index.ts new file mode 100644 index 000000000..d5b4ef5af --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Slack/index.ts @@ -0,0 +1 @@ +export { default, default as SlackConfigRoute } from "./SlackConfigRoute"; diff --git a/src/core/client/admin/routes/Configure/sections/index.ts b/src/core/client/admin/routes/Configure/sections/index.ts index 5c3f6d776..54d744d01 100644 --- a/src/core/client/admin/routes/Configure/sections/index.ts +++ b/src/core/client/admin/routes/Configure/sections/index.ts @@ -5,3 +5,4 @@ export { GeneralConfigRoute } from "./General"; export { ModerationConfigRoute } from "./Moderation"; export { OrganizationConfigRoute } from "./Organization"; export { WordListConfigRoute } from "./WordList"; +export { SlackConfigRoute } from "./Slack"; diff --git a/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap b/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap index 089cf3106..3891f5c31 100644 --- a/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap +++ b/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap @@ -79,6 +79,15 @@ exports[`renders configure advanced 1`] = ` Email +
  • + + Slack + +
  • +
  • + + Slack + +
  • +
  • + + Slack + +
  • +
  • + + Slack + +
  • +
  • + + Slack + +
  • +
  • + + Slack + +
  • { 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 () => { diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index 1b76e3efb..8f2c1a71b 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -161,6 +161,9 @@ export const settings = createFixture({ changeUsername: true, deleteAccount: true, }, + slack: { + channels: [], + }, }); export const settingsWithEmptyAuth = createFixture( diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index f48e6be7e..ed46f9de3 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -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); } diff --git a/src/core/server/graph/tenant/resolvers/Settings.ts b/src/core/server/graph/tenant/resolvers/Settings.ts index 825bd5aa4..61536fd41 100644 --- a/src/core/server/graph/tenant/resolvers/Settings.ts +++ b/src/core/server/graph/tenant/resolvers/Settings.ts @@ -2,5 +2,5 @@ import { GQLSettingsTypeResolver } from "coral-server/graph/tenant/schema/__gene import { Tenant } from "coral-server/models/tenant"; export const Settings: GQLSettingsTypeResolver = { - staticURI: (tenant, args, ctx) => ctx.config.get("static_uri") || null, + slack: ({ slack = {} }) => slack, }; diff --git a/src/core/server/graph/tenant/resolvers/SlackConfiguration.ts b/src/core/server/graph/tenant/resolvers/SlackConfiguration.ts new file mode 100644 index 000000000..530a8ffa3 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/SlackConfiguration.ts @@ -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, +}; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index 4d67b809f..97b57b9a5 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -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; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index ce4be9a75..a3e7d7061 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -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. """ diff --git a/src/core/server/graph/tenant/subscriptions/publisher.ts b/src/core/server/graph/tenant/subscriptions/publisher.ts index 5c30adced..9c5177892 100644 --- a/src/core/server/graph/tenant/subscriptions/publisher.ts +++ b/src/core/server/graph/tenant/subscriptions/publisher.ts @@ -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; +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 }), ]); }; diff --git a/src/core/server/models/settings.ts b/src/core/server/models/settings.ts index 98fd4f44b..c85880b6b 100644 --- a/src/core/server/models/settings.ts +++ b/src/core/server/models/settings.ts @@ -137,6 +137,7 @@ export type Settings = GlobalModerationSettings & | "communityGuidelines" | "stories" | "createdAt" + | "slack" > & { /** * auth is the set of configured authentication integrations. diff --git a/src/core/server/models/tenant/tenant.ts b/src/core/server/models/tenant/tenant.ts index 874ffd2b7..32535bdcc 100644 --- a/src/core/server/models/tenant/tenant.ts +++ b/src/core/server/models/tenant/tenant.ts @@ -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. diff --git a/src/core/server/services/slack/context.ts b/src/core/server/services/slack/context.ts new file mode 100644 index 000000000..24acfc1de --- /dev/null +++ b/src/core/server/services/slack/context.ts @@ -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 | null + > = new DataLoader(commentIDs => + retrieveManyComments(this.mongo, this.tenantID, commentIDs) + ); + + public readonly stories: DataLoader< + string, + Readonly | null + > = new DataLoader(storyIDs => + retrieveManyStories(this.mongo, this.tenantID, storyIDs) + ); + + public readonly users: DataLoader< + string, + Readonly | null + > = new DataLoader(userIDs => + retrieveManyUsers(this.mongo, this.tenantID, userIDs) + ); + + constructor({ mongo, tenantID }: Options) { + this.mongo = mongo; + this.tenantID = tenantID; + } +} + +export default SlackContext; diff --git a/src/core/server/services/slack/index.ts b/src/core/server/services/slack/index.ts new file mode 100644 index 000000000..b26ba5afc --- /dev/null +++ b/src/core/server/services/slack/index.ts @@ -0,0 +1,3 @@ +export { default as slackPublisher } from "./publisher"; +export * from "./publisher"; +export * from "./context"; diff --git a/src/core/server/services/slack/publisher.ts b/src/core/server/services/slack/publisher.ts new file mode 100644 index 000000000..9501c8310 --- /dev/null +++ b/src/core/server/services/slack/publisher.ts @@ -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(//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; + +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; diff --git a/src/core/server/services/users/download/download.ts b/src/core/server/services/users/download/download.ts index 195bb5a01..b3aa020b5 100644 --- a/src/core/server/services/users/download/download.ts +++ b/src/core/server/services/users/download/download.ts @@ -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]); diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 3d98fc896..b74c1b14c 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -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 decisionHistory-approvedCommentBy = Approved comment by 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 documentation. +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 here. +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