From 769ab2a91010bd2480f23cb56cbd58f666e8ea43 Mon Sep 17 00:00:00 2001 From: Nick Funk Date: Thu, 27 Feb 2020 14:40:01 -0700 Subject: [PATCH] Q&A Fixes (#2866) * Fix various Q&A moderation/tagging issues - Allow Staff members to have their questions answered and appropriately tagged - Properly filter answering for only top level questions (comments) - Add documentation around various moderation phases and comment creation steps - Remove unnecessary status filter when setting the status for a comment * fix: abstracted out perspective configs * fix: reworked tag injection * Fix sorting/unused imports Co-authored-by: Wyatt Johnson --- src/core/server/events/listeners/index.ts | 5 + .../server/events/listeners/perspective.ts | 121 +++++++++++++ src/core/server/graph/mutators/Actions.ts | 2 - src/core/server/graph/mutators/Comments.ts | 1 - .../Subscription/commentStatusUpdated.ts | 1 + src/core/server/index.ts | 12 +- src/core/server/models/comment/comment.ts | 7 +- src/core/server/queue/tasks/rejector.ts | 4 - src/core/server/services/comments/actions.ts | 2 + .../comments/pipeline/phases/approve.ts | 17 +- .../comments/pipeline/phases/staff.ts | 9 +- .../pipeline/phases/tagExpertAnswers.ts | 35 ++-- .../comments/pipeline/phases/toxic.ts | 142 ++------------- .../services/comments/pipeline/pipeline.ts | 2 +- src/core/server/services/events/comments.ts | 2 + src/core/server/services/perspective/index.ts | 163 +---------------- .../services/perspective/perspective.ts | 168 ++++++++++++++++++ src/core/server/stacks/approveComment.ts | 16 +- src/core/server/stacks/createComment.ts | 50 ++---- src/core/server/stacks/editComment.ts | 1 + .../server/stacks/helpers/publishChanges.ts | 16 ++ src/core/server/stacks/rejectComment.ts | 16 +- 22 files changed, 393 insertions(+), 399 deletions(-) create mode 100644 src/core/server/events/listeners/index.ts create mode 100644 src/core/server/events/listeners/perspective.ts create mode 100644 src/core/server/services/perspective/perspective.ts diff --git a/src/core/server/events/listeners/index.ts b/src/core/server/events/listeners/index.ts new file mode 100644 index 000000000..64d14316b --- /dev/null +++ b/src/core/server/events/listeners/index.ts @@ -0,0 +1,5 @@ +export * from "./notifier"; +export * from "./perspective"; +export * from "./slack"; +export * from "./subscription"; +export * from "./webhook"; diff --git a/src/core/server/events/listeners/perspective.ts b/src/core/server/events/listeners/perspective.ts new file mode 100644 index 000000000..30b74a0fc --- /dev/null +++ b/src/core/server/events/listeners/perspective.ts @@ -0,0 +1,121 @@ +import striptags from "striptags"; + +import { reconstructTenantURL } from "coral-server/app/url"; +import { sendToPerspective } from "coral-server/services/perspective"; + +import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types"; + +import { CommentStatusUpdatedCoralEventPayload } from "../events"; +import { CoralEventListener, CoralEventPublisherFactory } from "../publisher"; +import { CoralEventType } from "../types"; + +type PerspectiveCoralEventListenerPayloads = CommentStatusUpdatedCoralEventPayload; + +export class PerspectiveCoralEventListener + implements CoralEventListener { + public readonly name = "perspective"; + public readonly events = [CoralEventType.COMMENT_STATUS_UPDATED]; + + private computeStatus(status: GQLCOMMENT_STATUS) { + if (status === GQLCOMMENT_STATUS.APPROVED) { + return "APPROVED"; + } + if (status === GQLCOMMENT_STATUS.REJECTED) { + return "DELETED"; + } + + return null; + } + + public initialize: CoralEventPublisherFactory< + PerspectiveCoralEventListenerPayloads + > = ctx => async ({ data: { newStatus, commentID, commentRevisionID } }) => { + const { + tenant: { + integrations: { perspective }, + }, + } = ctx; + + if ( + // If perspective isn't enabled, + !perspective.enabled || + // Or the key isn't provided + !perspective.key || + // Or the feedback sending option is disabled + !perspective.sendFeedback + ) { + // Exit out then. + return; + } + + // Compute the status being sent to perspective. + const status = this.computeStatus(newStatus); + if (!status) { + // The new status was not associated with + return; + } + + try { + // Get the comment in question. + const comment = await ctx.loaders.Comments.comment.load(commentID); + if (!comment) { + return; + } + + // Get the target revision. + const revision = comment.revisions.find(r => r.id === commentRevisionID); + if (!revision) { + return; + } + + // Get the story for this comment. + const story = await ctx.loaders.Stories.story.load(comment.storyID); + if (!story) { + return; + } + + // Reconstruct the Tenant URL. + const tenantURL = reconstructTenantURL(ctx.config, ctx.tenant); + + // This typecast is needed because the custom `ms` format does not return the + // desired `number` type even though that's the only type it can output. + const timeout = (ctx.config.get( + "perspective_timeout" + ) as unknown) as number; + + // Get the response from perspective. + const result = await sendToPerspective( + { key: perspective.key, endpoint: perspective.endpoint, timeout }, + { + operation: "comments:suggestscore", + locale: ctx.tenant.locale, + body: { + text: striptags(revision.body), + commentID: comment.id, + commentParentID: comment.parentID, + commentStatus: status, + storyURL: story.url, + tenantURL, + }, + } + ); + + if (result.ok) { + ctx.logger.debug( + { commentID: comment.id, commentRevisionID }, + "successfully sent moderation feedback to perspective" + ); + } else { + ctx.logger.error( + { status: result.status }, + "unable to send moderation feedback to perspective" + ); + } + } catch (err) { + ctx.logger.error( + { err }, + "unable to send moderation feedback to perspective" + ); + } + }; +} diff --git a/src/core/server/graph/mutators/Actions.ts b/src/core/server/graph/mutators/Actions.ts index cf396ffec..cf0d05040 100644 --- a/src/core/server/graph/mutators/Actions.ts +++ b/src/core/server/graph/mutators/Actions.ts @@ -11,7 +11,6 @@ export const Actions = (ctx: GraphContext) => ({ approveComment( ctx.mongo, ctx.redis, - ctx.config, ctx.broker, ctx.tenant, input.commentID, @@ -23,7 +22,6 @@ export const Actions = (ctx: GraphContext) => ({ rejectComment( ctx.mongo, ctx.redis, - ctx.config, ctx.broker, ctx.tenant, input.commentID, diff --git a/src/core/server/graph/mutators/Comments.ts b/src/core/server/graph/mutators/Comments.ts index 8ed0b3726..027232049 100644 --- a/src/core/server/graph/mutators/Comments.ts +++ b/src/core/server/graph/mutators/Comments.ts @@ -173,7 +173,6 @@ export const Comments = (ctx: GraphContext) => ({ ? approveComment( ctx.mongo, ctx.redis, - ctx.config, ctx.broker, ctx.tenant, commentID, diff --git a/src/core/server/graph/resolvers/Subscription/commentStatusUpdated.ts b/src/core/server/graph/resolvers/Subscription/commentStatusUpdated.ts index dcd870c84..9f69d6ac8 100644 --- a/src/core/server/graph/resolvers/Subscription/commentStatusUpdated.ts +++ b/src/core/server/graph/resolvers/Subscription/commentStatusUpdated.ts @@ -15,6 +15,7 @@ export interface CommentStatusUpdatedInput extends SubscriptionPayload { oldStatus: GQLCOMMENT_STATUS; moderatorID: string | null; commentID: string; + commentRevisionID: string; } export type CommentStatusUpdatedSubscription = SubscriptionType< diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 53ebee64e..2e72777ff 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -37,10 +37,13 @@ import { } from "coral-server/services/redis"; import TenantCache from "coral-server/services/tenant/cache"; -import { NotifierCoralEventListener } from "./events/listeners/notifier"; -import { SlackCoralEventListener } from "./events/listeners/slack"; -import { SubscriptionCoralEventListener } from "./events/listeners/subscription"; -import { WebhookCoralEventListener } from "./events/listeners/webhook"; +import { + NotifierCoralEventListener, + PerspectiveCoralEventListener, + SlackCoralEventListener, + SubscriptionCoralEventListener, + WebhookCoralEventListener, +} from "./events/listeners"; import CoralEventListenerBroker from "./events/publisher"; import { isInstalled } from "./services/tenant"; @@ -219,6 +222,7 @@ class Server { this.broker.register(new SlackCoralEventListener()); this.broker.register(new SubscriptionCoralEventListener()); this.broker.register(new WebhookCoralEventListener(this.tasks.webhook)); + this.broker.register(new PerspectiveCoralEventListener()); // Setup the metrics collectors. collectDefaultMetrics({ timeout: 5000 }); diff --git a/src/core/server/models/comment/comment.ts b/src/core/server/models/comment/comment.ts index 49b319bd4..cb8b3045a 100644 --- a/src/core/server/models/comment/comment.ts +++ b/src/core/server/models/comment/comment.ts @@ -152,7 +152,7 @@ export async function createComment( const { body, actionCounts = {}, metadata, ...rest } = input; // Generate the revision. - const revision: Revision = { + const revision: Readonly = { id: uuid.v4(), body, actionCounts, @@ -184,7 +184,7 @@ export async function createComment( // Insert it into the database. await collection(mongo).insertOne(comment); - return comment; + return { comment, revision }; } /** @@ -734,9 +734,6 @@ export async function updateCommentStatus( id, tenantID, "revisions.id": revisionID, - status: { - $ne: status, - }, }, { $set: { status }, diff --git a/src/core/server/queue/tasks/rejector.ts b/src/core/server/queue/tasks/rejector.ts index 3f16ed0cb..ba9a56771 100644 --- a/src/core/server/queue/tasks/rejector.ts +++ b/src/core/server/queue/tasks/rejector.ts @@ -2,7 +2,6 @@ import Queue, { Job } from "bull"; import { Db } from "mongodb"; import now from "performance-now"; -import { Config } from "coral-server/config"; import logger from "coral-server/logger"; import { Comment, @@ -22,7 +21,6 @@ const JOB_NAME = "rejector"; export interface RejectorProcessorOptions { mongo: Db; redis: AugmentedRedis; - config: Config; tenantCache: TenantCache; } @@ -49,7 +47,6 @@ const createJobProcessor = ({ mongo, redis, tenantCache, - config, }: RejectorProcessorOptions) => async (job: Job) => { // Pull out the job data. const { authorID, moderatorID, tenantID } = job.data; @@ -85,7 +82,6 @@ const createJobProcessor = ({ await rejectComment( mongo, redis, - config, null, tenant, comment.id, diff --git a/src/core/server/services/comments/actions.ts b/src/core/server/services/comments/actions.ts index 4a57aabe0..82f214938 100644 --- a/src/core/server/services/comments/actions.ts +++ b/src/core/server/services/comments/actions.ts @@ -120,6 +120,7 @@ async function addCommentAction( ...counts, before: oldComment, after: updatedComment, + commentRevisionID: input.commentRevisionID, }); return updatedComment; @@ -195,6 +196,7 @@ export async function removeCommentAction( ...counts, before: oldComment, after: updatedComment, + commentRevisionID, }); return updatedComment; diff --git a/src/core/server/services/comments/pipeline/phases/approve.ts b/src/core/server/services/comments/pipeline/phases/approve.ts index fafb57a73..dd00802b2 100644 --- a/src/core/server/services/comments/pipeline/phases/approve.ts +++ b/src/core/server/services/comments/pipeline/phases/approve.ts @@ -1,4 +1,3 @@ -import { CommentTag } from "coral-server/models/comment/tag"; import { IntermediateModerationPhase, IntermediatePhaseResult, @@ -9,19 +8,9 @@ import { GQLTAG, } from "coral-server/graph/schema/__generated__/types"; -interface Result { - status?: GQLCOMMENT_STATUS; - tags: CommentTag[]; -} - export const approve: IntermediateModerationPhase = ({ tags, - now, }): IntermediatePhaseResult | void => { - const result: Result = { - tags: [], - }; - // If the user is tagged STAFF or EXPERT then we approve // their comment. // @@ -34,8 +23,8 @@ export const approve: IntermediateModerationPhase = ({ if ( tags.some(tag => tag.type === GQLTAG.STAFF || tag.type === GQLTAG.EXPERT) ) { - result.status = GQLCOMMENT_STATUS.APPROVED; + return { + status: GQLCOMMENT_STATUS.APPROVED, + }; } - - return result; }; diff --git a/src/core/server/services/comments/pipeline/phases/staff.ts b/src/core/server/services/comments/pipeline/phases/staff.ts index 638f27095..78cc4a3be 100755 --- a/src/core/server/services/comments/pipeline/phases/staff.ts +++ b/src/core/server/services/comments/pipeline/phases/staff.ts @@ -1,18 +1,17 @@ -import { - GQLTAG, - GQLUSER_ROLE, -} from "coral-server/graph/schema/__generated__/types"; +import { hasStaffRole } from "coral-server/models/user/helpers"; import { IntermediateModerationPhase, IntermediatePhaseResult, } from "coral-server/services/comments/pipeline"; +import { GQLTAG } from "coral-server/graph/schema/__generated__/types"; + // If a given user is a staff member, always approve their comment. export const staff: IntermediateModerationPhase = ({ author, now, }): IntermediatePhaseResult | void => { - if (author.role !== GQLUSER_ROLE.COMMENTER) { + if (hasStaffRole(author)) { return { tags: [ { diff --git a/src/core/server/services/comments/pipeline/phases/tagExpertAnswers.ts b/src/core/server/services/comments/pipeline/phases/tagExpertAnswers.ts index 03fd70086..cfb774196 100644 --- a/src/core/server/services/comments/pipeline/phases/tagExpertAnswers.ts +++ b/src/core/server/services/comments/pipeline/phases/tagExpertAnswers.ts @@ -1,3 +1,4 @@ +import { CommentTag } from "coral-server/models/comment/tag"; import { IntermediateModerationPhase, IntermediatePhaseResult, @@ -12,23 +13,33 @@ export const tagExpertAnswers: IntermediateModerationPhase = ({ author, now, story, + comment, }): IntermediatePhaseResult | void => { if ( + // If we're in Q&A mode... story.settings.mode === GQLSTORY_MODE.QA && + // And we have experts for this story... story.settings.expertIDs && + // And the author is in expert list... story.settings.expertIDs.some(id => id === author.id) ) { - return { - tags: [ - { - type: GQLTAG.EXPERT, - createdAt: now, - }, - { - type: GQLTAG.FEATURED, - createdAt: now, - }, - ], - }; + // Assign this comment an expert tag! + const tags: CommentTag[] = [ + { + type: GQLTAG.EXPERT, + createdAt: now, + }, + ]; + + // If this comment is the first reply in a thread (depth of 1)... + if (comment.ancestorIDs.length === 1) { + // Add the featured tag! + tags.push({ + type: GQLTAG.FEATURED, + createdAt: now, + }); + } + + return { tags }; } }; diff --git a/src/core/server/services/comments/pipeline/phases/toxic.ts b/src/core/server/services/comments/pipeline/phases/toxic.ts index 3aed4d252..1ee663a68 100644 --- a/src/core/server/services/comments/pipeline/phases/toxic.ts +++ b/src/core/server/services/comments/pipeline/phases/toxic.ts @@ -1,14 +1,9 @@ import { isNil } from "lodash"; -import path from "path"; -import { URL } from "url"; import { - TOXICITY_ENDPOINT_DEFAULT, TOXICITY_MODEL_DEFAULT, TOXICITY_THRESHOLD_DEFAULT, } from "coral-common/constants"; -import { LanguageCode } from "coral-common/helpers"; -import { Omit } from "coral-common/types"; import { ToxicCommentError } from "coral-server/errors"; import { ACTION_TYPE } from "coral-server/models/action/comment"; import { hasFeatureFlag } from "coral-server/models/tenant"; @@ -17,20 +12,14 @@ import { IntermediatePhaseResult, ModerationPhaseContext, } from "coral-server/services/comments/pipeline"; -import { createFetch } from "coral-server/services/fetch"; +import { sendToPerspective } from "coral-server/services/perspective"; import { GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, GQLFEATURE_FLAG, - GQLPerspectiveExternalIntegration, } from "coral-server/graph/schema/__generated__/types"; -/** - * fetch is the phase hook fetcher used to communicate with the Perspective API. - */ -const fetch = createFetch({ name: "Hooks" }); - export const toxic: IntermediateModerationPhase = async ({ tenant, nudge, @@ -64,16 +53,6 @@ export const toxic: IntermediateModerationPhase = async ({ return; } - let endpoint = integration.endpoint; - if (isNil(endpoint)) { - endpoint = TOXICITY_ENDPOINT_DEFAULT; - - log.trace( - { endpoint }, - "endpoint missing in integration settings, using defaults" - ); - } - let threshold = integration.threshold; if (isNil(threshold)) { threshold = TOXICITY_THRESHOLD_DEFAULT / 100; @@ -106,24 +85,24 @@ export const toxic: IntermediateModerationPhase = async ({ // Pull the custom model out. const model = integration.model || TOXICITY_MODEL_DEFAULT; - // Get the language from the tenant's set language. This won't be a 1-1 - // mapping because the Perspective API doesn't support all the languages - // that Coral supports in production. - const language = convertLanguage(tenant.locale); - - // Call into the Toxic comment API. - const score = await getScore( - htmlStripped, + // Get the response from perspective. + const result = await sendToPerspective( + { key: integration.key, endpoint: integration.endpoint, timeout }, { - endpoint, - key: integration.key, - doNotStore, - model, - }, - language, - timeout + operation: "comments:analyze", + locale: tenant.locale, + body: { + text: htmlStripped, + doNotStore, + model, + }, + } ); + // Reformat the scores. + const score = result.data.attributeScores[model].summaryScore + .value as number; + const isToxic = score > threshold; if (isToxic) { // FEATURE_FLAG:DISABLE_WARN_USER_OF_TOXIC_COMMENT @@ -183,92 +162,3 @@ export const toxic: IntermediateModerationPhase = async ({ log.error({ err }, "could not determine comment toxicity"); } }; - -/** - * Language is the language key that is supported by the Perspective API in the - * ISO 631-1 format. - */ -type PerspectiveLanguage = "en" | "es" | "fr" | "de"; - -/** - * convertLanguage returns the language code for the related Perspective API - * model in the ISO 631-1 format. - * - * @param locale the language on the tenant in the BCP 47 format. - */ -function convertLanguage(locale: LanguageCode): PerspectiveLanguage { - switch (locale) { - case "en-US": - return "en"; - case "es": - return "es"; - case "de": - return "de"; - default: - return "en"; - } -} - -/** - * getScore will return the toxicity score for the comment text. - * - * @param text comment text to check for toxicity - * @param model the specific model to use when storing the toxicity - * @param language language to run perspective api - * @param timeout timeout for communicating with the perspective api - */ -async function getScore( - text: string, - { - key, - endpoint, - model, - doNotStore, - }: Required< - Omit< - GQLPerspectiveExternalIntegration, - "enabled" | "threshold" | "sendFeedback" - > - >, - language: PerspectiveLanguage, - timeout: number -): Promise { - // Prepare the URL to send the command to. - const url = new URL(endpoint.trim()); - url.pathname = path.join(url.pathname, "/comments:analyze"); - url.searchParams.set("key", key.trim()); - - try { - const response = await fetch(url.toString(), { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - timeout, - body: JSON.stringify({ - comment: { - text, - }, - languages: [language], - doNotStore, - requestedAttributes: { - [model]: {}, - }, - }), - }); - - // Grab the data out of the Perspective API. - const data = await response.json(); - - // Reformat the scores. - return data.attributeScores[model].summaryScore.value as number; - } catch (err) { - // Ensure that the API key doesn't get leaked to the logs by accident. - if (err.message) { - err.message = err.message.replace(url.searchParams.toString(), "***"); - } - - // Rethrow the error. - throw err; - } -} diff --git a/src/core/server/services/comments/pipeline/pipeline.ts b/src/core/server/services/comments/pipeline/pipeline.ts index 61d81891d..2c311a4ef 100644 --- a/src/core/server/services/comments/pipeline/pipeline.ts +++ b/src/core/server/services/comments/pipeline/pipeline.ts @@ -40,7 +40,7 @@ export interface ModerationPhaseContextInput { log: Logger; story: Story; tenant: Tenant; - comment: RequireProperty, "body">; + comment: RequireProperty, "body" | "ancestorIDs">; author: User; now: Date; action: "NEW" | "EDIT"; diff --git a/src/core/server/services/events/comments.ts b/src/core/server/services/events/comments.ts index 3c11dd052..47cec8d45 100644 --- a/src/core/server/services/events/comments.ts +++ b/src/core/server/services/events/comments.ts @@ -24,6 +24,7 @@ export async function publishCommentStatusChanges( oldStatus: GQLCOMMENT_STATUS, newStatus: GQLCOMMENT_STATUS, commentID: string, + commentRevisionID: string, moderatorID: string | null ) { if (oldStatus !== newStatus) { @@ -31,6 +32,7 @@ export async function publishCommentStatusChanges( newStatus, oldStatus, commentID, + commentRevisionID, moderatorID, }); } diff --git a/src/core/server/services/perspective/index.ts b/src/core/server/services/perspective/index.ts index 1153cf543..8c7fb7556 100644 --- a/src/core/server/services/perspective/index.ts +++ b/src/core/server/services/perspective/index.ts @@ -1,162 +1 @@ -import { Db } from "mongodb"; -import striptags from "striptags"; - -import { TOXICITY_ENDPOINT_DEFAULT } from "coral-common/constants"; -import { reconstructTenantURL } from "coral-server/app/url"; -import { Config } from "coral-server/config"; -import logger from "coral-server/logger"; -import { Comment } from "coral-server/models/comment"; -import { getURLWithCommentID, retrieveStory } from "coral-server/models/story"; -import { Tenant } from "coral-server/models/tenant"; - -import { - GQLCOMMENT_STATUS, - GQLPerspectiveExternalIntegration, -} from "coral-server/graph/schema/__generated__/types"; - -interface SendResult { - ok: boolean; - status: number; - data: any; -} - -async function send( - endpoint: string, - apiKey: string, - method: string, - body: any -): Promise { - const result = await fetch(`${endpoint}/${method}?key=${apiKey}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body, null, 2), - }); - - if (!result.ok) { - return { - ok: result.ok, - status: result.status, - data: null, - }; - } - - const data = await result.json(); - - return { - ok: result.ok, - status: result.status, - data, - }; -} - -function computeStatus(status: GQLCOMMENT_STATUS) { - if (status === GQLCOMMENT_STATUS.APPROVED) { - return "APPROVED"; - } - if (status === GQLCOMMENT_STATUS.REJECTED) { - return "DELETED"; - } - - return null; -} - -export async function notifyPerspectiveModerationDecision( - mongo: Db, - tenant: Tenant, - config: Config, - perspectiveConfig: GQLPerspectiveExternalIntegration, - comment: Comment, - commentRevisionID: string, - status: GQLCOMMENT_STATUS -) { - if ( - !perspectiveConfig.enabled || - !perspectiveConfig.key || - !perspectiveConfig.sendFeedback - ) { - return; - } - - const commentStatus = computeStatus(status); - if (!commentStatus) { - return; - } - - const revision = comment.revisions.find(c => c.id === commentRevisionID); - if (!revision) { - logger.warn( - { commentID: comment.id, commentRevisionID }, - "unable to find comment revision ID in comment revision history" - ); - return; - } - - const endpoint = perspectiveConfig.endpoint - ? perspectiveConfig.endpoint - : TOXICITY_ENDPOINT_DEFAULT; - const apiKey = perspectiveConfig.key; - - const tenantUrl = reconstructTenantURL(config, tenant, undefined, "/"); - const communityId = `Coral:${tenantUrl}`; - const clientToken = `comment:${comment.id}`; - - try { - const story = await retrieveStory(mongo, comment.tenantID, comment.storyID); - if (!story) { - logger.warn({ storyID: comment.storyID }, "could not find story"); - return; - } - - const url = getURLWithCommentID(story.url, comment.id); - - const body = { - comment: { - text: striptags(revision.body), - }, - context: { - entries: [ - { - text: JSON.stringify({ - url, - reply_to_id_Coral_comment_id: comment.parentID, - Coral_comment_id: comment.id, - }), - }, - ], - }, - attributeScores: { - [commentStatus]: { - summaryScore: { - value: 1, - }, - }, - }, - languages: ["EN"], - communityId, - clientToken, - }; - - const result = await send(endpoint, apiKey, "comments:suggestscore", body); - - if (result.ok) { - logger.debug( - { commentID: comment.id, commentRevisionID }, - "successfully sent moderation feedback to perspective" - ); - } else if (!result.ok) { - logger.error( - { status: result.status }, - "unable to send moderation feedback to perspective" - ); - } else if (!result.data || result.data.clientToken !== clientToken) { - logger.error( - { data: result.data }, - "result data from perspective did not contain the clientToken we expected" - ); - } - } catch (err) { - logger.error({ err }, "unable to send moderation feedback to perspective"); - } -} +export * from "./perspective"; diff --git a/src/core/server/services/perspective/perspective.ts b/src/core/server/services/perspective/perspective.ts new file mode 100644 index 000000000..bbcac43ef --- /dev/null +++ b/src/core/server/services/perspective/perspective.ts @@ -0,0 +1,168 @@ +import path from "path"; +import { URL } from "url"; + +import { TOXICITY_ENDPOINT_DEFAULT } from "coral-common/constants"; +import { LanguageCode } from "coral-common/helpers"; +import { getURLWithCommentID } from "coral-server/models/story"; +import { createFetch } from "coral-server/services/fetch"; + +const fetch = createFetch({ name: "perspective" }); + +/** + * Language is the language key that is supported by the Perspective API in the + * ISO 631-1 format. + */ +type PerspectiveLanguage = "en" | "es" | "fr" | "de"; + +/** + * convertLanguage returns the language code for the related Perspective API + * model in the ISO 631-1 format. + * + * @param locale the language on the tenant in the BCP 47 format. + */ +function convertLanguage(locale: LanguageCode): PerspectiveLanguage { + switch (locale) { + case "en-US": + return "en"; + case "es": + return "es"; + case "de": + return "de"; + default: + return "en"; + } +} + +interface Options { + endpoint?: string; + key: string; + timeout: number; +} + +type Request = + | { + operation: "comments:analyze"; + locale: LanguageCode; + body: { + /** + * text is htmlStripped version of the comment text. + */ + text: string; + doNotStore: boolean; + model: string; + }; + } + | { + operation: "comments:suggestscore"; + locale: LanguageCode; + body: { + /** + * text is htmlStripped version of the comment text. + */ + text: string; + commentID: string; + commentParentID?: string; + commentStatus: "APPROVED" | "DELETED"; + storyURL: string; + tenantURL: string; + }; + }; + +function formatBody(req: Request): object { + // Get the language from the locale. This won't be a 1-1 mapping because the + // Perspective API doesn't support all the languages that Coral supports in + // production. + const language = convertLanguage(req.locale); + + switch (req.operation) { + case "comments:analyze": + return { + comment: { + text: req.body.text, + }, + doNotStore: req.body.doNotStore, + requestedAttributes: { + [req.body.model]: {}, + }, + languages: [language], + }; + case "comments:suggestscore": + return { + comment: { + text: req.body.text, + }, + context: { + entries: [ + { + text: JSON.stringify({ + url: getURLWithCommentID(req.body.storyURL, req.body.commentID), + reply_to_id_Coral_comment_id: req.body.commentParentID, + Coral_comment_id: req.body.commentID, + }), + }, + ], + }, + attributeScores: { + [status]: { + summaryScore: { + value: 1, + }, + }, + }, + languages: [language], + communityId: `Coral:${req.body.tenantURL}`, + clientToken: `comment:${req.body.commentID}`, + }; + } +} + +export async function sendToPerspective( + { endpoint = TOXICITY_ENDPOINT_DEFAULT, key, timeout }: Options, + req: Request +) { + // Prepare the URL to send the command to. + const url = new URL(endpoint.trim()); + url.pathname = path.join(url.pathname, `/${req.operation}`); + url.searchParams.set("key", key.trim()); + + // Convert the request to a body to be sent to perspective. + const body = JSON.stringify(formatBody(req)); + + try { + // Create the request and send it. + const res = await fetch(url.toString(), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + timeout, + body, + }); + if (!res.ok) { + return { + ok: res.ok, + status: res.status, + data: null, + }; + } + + // Parse the JSON body and send back the result! + const data = await res.json(); + return { + ok: res.ok, + status: res.status, + data, + }; + } catch (err) { + // Ensure that the API key doesn't get leaked to the logs by accident. + if (err.message) { + err.message = err.message.replace( + url.searchParams.toString(), + "[Sensitive]" + ); + } + + // Rethrow the error. + throw err; + } +} diff --git a/src/core/server/stacks/approveComment.ts b/src/core/server/stacks/approveComment.ts index 1daabbe6d..15740bdf6 100644 --- a/src/core/server/stacks/approveComment.ts +++ b/src/core/server/stacks/approveComment.ts @@ -1,10 +1,8 @@ import { Db } from "mongodb"; -import { Config } from "coral-server/config"; import { CoralEventPublisherBroker } from "coral-server/events/publisher"; import { Tenant } from "coral-server/models/tenant"; import { moderate } from "coral-server/services/comments/moderation"; -import { notifyPerspectiveModerationDecision } from "coral-server/services/perspective"; import { AugmentedRedis } from "coral-server/services/redis"; import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types"; @@ -14,7 +12,6 @@ import { publishChanges, updateAllCommentCounts } from "./helpers"; const approveComment = async ( mongo: Db, redis: AugmentedRedis, - config: Config, broker: CoralEventPublisherBroker, tenant: Tenant, commentID: string, @@ -48,19 +45,8 @@ const approveComment = async ( ...result, ...counts, moderatorID, - }); - - // We don't want to await on this so that - // we don't hold up the moderation flow and response - notifyPerspectiveModerationDecision( - mongo, - tenant, - config, - tenant.integrations.perspective, - result.after, commentRevisionID, - GQLCOMMENT_STATUS.APPROVED - ); + }); // Return the resulting comment. return result.after; diff --git a/src/core/server/stacks/createComment.ts b/src/core/server/stacks/createComment.ts index 729bd81f6..2be7bc567 100644 --- a/src/core/server/stacks/createComment.ts +++ b/src/core/server/stacks/createComment.ts @@ -23,7 +23,6 @@ import { retrieveComment, } from "coral-server/models/comment"; import { - getLatestRevision, hasAncestors, hasPublishedStatus, } from "coral-server/models/comment/helpers"; @@ -43,10 +42,6 @@ import { PhaseResult, processForModeration, } from "coral-server/services/comments/pipeline"; -import { - publishCommentCreated, - publishCommentReplyCreated, -} from "coral-server/services/events"; import { AugmentedRedis } from "coral-server/services/redis"; import { updateUserLastCommentID } from "coral-server/services/users"; import { Request } from "coral-server/types/express"; @@ -67,7 +62,6 @@ export type CreateComment = Omit< const markCommentAsAnswered = async ( mongo: Db, redis: AugmentedRedis, - config: Config, broker: CoralEventPublisherBroker, tenant: Tenant, comment: Readonly, @@ -93,16 +87,19 @@ const markCommentAsAnswered = async ( return; } - // If we have experts and this reply is created by - // one of them, then this is an expert's answer. - if (story.settings.expertIDs.some(id => id === author.id)) { - // We need to mark this question as answered now. - // We can now remove the unanswered tag. + if ( + // If we are the export on this story... + story.settings.expertIDs.some(id => id === author.id) && + // And this is the first reply (depth of 1)... + comment.ancestorIDs.length === 1 + ) { + // We need to mark the parent question as answered. + // - Remove the unanswered tag. + // - Approve it since an expert has replied to it. await removeTag(mongo, tenant, comment.parentID, GQLTAG.UNANSWERED); await approveComment( mongo, redis, - config, broker, tenant, comment.parentID, @@ -181,7 +178,7 @@ export default async function create( nudge, story, tenant, - comment: input, + comment: { ...input, ancestorIDs }, author, req, now, @@ -218,7 +215,7 @@ export default async function create( } // Create the comment! - const comment = await createComment( + const { comment, revision } = await createComment( mongo, tenant.id, { @@ -234,6 +231,11 @@ export default async function create( now ); + log = log.child( + { commentID: comment.id, status, revisionID: revision.id }, + true + ); + // Updating some associated data. await Promise.all([ updateUserLastCommentID(redis, tenant, author, comment.id), @@ -241,7 +243,6 @@ export default async function create( markCommentAsAnswered( mongo, redis, - config, broker, tenant, comment, @@ -251,14 +252,6 @@ export default async function create( ), ]); - // Pull the revision out. - const revision = getLatestRevision(comment); - - log = log.child( - { commentID: comment.id, status, revisionID: revision.id }, - true - ); - log.trace("comment created"); if (input.parentID) { @@ -305,17 +298,8 @@ export default async function create( await publishChanges(broker, { ...counts, after: comment, + commentRevisionID: revision.id, }); - // If this is a reply, publish it. - if (input.parentID) { - publishCommentReplyCreated(broker, comment); - } - - // If this comment is visible (and not a reply), publish it. - if (!input.parentID && hasPublishedStatus(comment)) { - publishCommentCreated(broker, comment); - } - return comment; } diff --git a/src/core/server/stacks/editComment.ts b/src/core/server/stacks/editComment.ts index e92ca613f..6954e253a 100644 --- a/src/core/server/stacks/editComment.ts +++ b/src/core/server/stacks/editComment.ts @@ -195,6 +195,7 @@ export default async function edit( await publishChanges(broker, { ...result, ...counts, + commentRevisionID: result.revision.id, }); // Return the resulting comment. diff --git a/src/core/server/stacks/helpers/publishChanges.ts b/src/core/server/stacks/helpers/publishChanges.ts index 747e3c944..f423cbd96 100644 --- a/src/core/server/stacks/helpers/publishChanges.ts +++ b/src/core/server/stacks/helpers/publishChanges.ts @@ -6,7 +6,9 @@ import { hasPublishedStatus, } from "coral-server/models/comment"; import { + publishCommentCreated, publishCommentReleased, + publishCommentReplyCreated, publishCommentStatusChanges, publishModerationQueueChanges, } from "coral-server/services/events"; @@ -16,6 +18,7 @@ interface PublishChangesInput { after: Readonly; moderationQueue: CommentModerationQueueCounts; moderatorID?: string; + commentRevisionID: string; } export default async function publishChanges( @@ -33,11 +36,24 @@ export default async function publishChanges( input.before.status, input.after.status, input.after.id, + input.commentRevisionID, input.moderatorID || null ); if (hasModeratorStatus(input.before) && hasPublishedStatus(input.after)) { publishCommentReleased(broker, input.after); } + } else { + // This block is only hit if there is no before (if this is a new comment). + + // If this is a reply, publish it. + if (input.after.parentID) { + publishCommentReplyCreated(broker, input.after); + } + + // If this comment is visible (and not a reply), publish it. + if (!input.after.parentID && hasPublishedStatus(input.after)) { + publishCommentCreated(broker, input.after); + } } } diff --git a/src/core/server/stacks/rejectComment.ts b/src/core/server/stacks/rejectComment.ts index 4d4c59200..7eced5118 100644 --- a/src/core/server/stacks/rejectComment.ts +++ b/src/core/server/stacks/rejectComment.ts @@ -1,12 +1,10 @@ import { Db } from "mongodb"; -import { Config } from "coral-server/config"; import { CoralEventPublisherBroker } from "coral-server/events/publisher"; import { hasTag } from "coral-server/models/comment"; import { Tenant } from "coral-server/models/tenant"; import { removeTag } from "coral-server/services/comments"; import { moderate } from "coral-server/services/comments/moderation"; -import { notifyPerspectiveModerationDecision } from "coral-server/services/perspective"; import { AugmentedRedis } from "coral-server/services/redis"; import { @@ -19,7 +17,6 @@ import { publishChanges, updateAllCommentCounts } from "./helpers"; const rejectComment = async ( mongo: Db, redis: AugmentedRedis, - config: Config, broker: CoralEventPublisherBroker | null, tenant: Tenant, commentID: string, @@ -56,6 +53,7 @@ const rejectComment = async ( ...result, ...counts, moderatorID, + commentRevisionID, }); } @@ -64,18 +62,6 @@ const rejectComment = async ( return removeTag(mongo, tenant, result.after.id, GQLTAG.FEATURED); } - // We don't want to await on this so that - // we don't hold up the moderation flow and response - notifyPerspectiveModerationDecision( - mongo, - tenant, - config, - tenant.integrations.perspective, - result.after, - commentRevisionID, - GQLCOMMENT_STATUS.REJECTED - ); - // Return the resulting comment. return result.after; };