mirror of
https://github.com/wassname/talk.git
synced 2026-09-10 12:43:11 +08:00
[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:
@@ -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 }),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -137,6 +137,7 @@ export type Settings = GlobalModerationSettings &
|
||||
| "communityGuidelines"
|
||||
| "stories"
|
||||
| "createdAt"
|
||||
| "slack"
|
||||
> & {
|
||||
/**
|
||||
* auth is the set of configured authentication integrations.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as slackPublisher } from "./publisher";
|
||||
export * from "./publisher";
|
||||
export * from "./context";
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user