[next] Perspective API Integration (#1797)

* feat: initial toxic comments impl

* feat: improved logging

* feat: tenant cache adapter

* feat: move more types into graphql
This commit is contained in:
Wyatt Johnson
2018-08-14 14:37:00 +00:00
committed by GitHub
parent d2106b3de5
commit 0b3aead1d2
33 changed files with 1088 additions and 417 deletions
+4 -3
View File
@@ -14,7 +14,7 @@ import { Request } from "talk-server/types/express";
export type CreateComment = Omit<
CreateCommentInput,
"status" | "action_counts"
"status" | "action_counts" | "metadata"
>;
export async function create(
@@ -44,7 +44,7 @@ export async function create(
}
// Run the comment through the moderation phases.
const { status } = await processForModeration({
const { status, metadata } = await processForModeration({
asset,
tenant,
comment: input,
@@ -55,9 +55,10 @@ export async function create(
// TODO: (wyattjoh) use the actions somehow.
const comment = await createComment(mongo, tenant.id, {
...input,
status,
action_counts: {},
...input,
metadata,
});
if (input.parent_id) {
@@ -0,0 +1,105 @@
import {
GQLACTION_GROUP,
GQLACTION_TYPE,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import {
compose,
ModerationPhaseContext,
} from "talk-server/services/comments/moderation";
describe("compose", () => {
it("handles when a phase throws an error", async () => {
const err = new Error("this is an error");
const enhanced = compose([
() => {
throw err;
},
]);
await expect(enhanced({} as ModerationPhaseContext)).rejects.toEqual(err);
});
it("handles when it returns a status", async () => {
const status = GQLCOMMENT_STATUS.ACCEPTED;
const enhanced = compose([() => ({ status })]);
await expect(enhanced({} as ModerationPhaseContext)).resolves.toEqual({
status,
metadata: {},
actions: [],
});
});
it("merges the metadata", async () => {
const status = GQLCOMMENT_STATUS.ACCEPTED;
const enhanced = compose([
() => ({ metadata: { first: true } }),
() => ({ status, metadata: { second: true } }),
() => ({ metadata: { third: true } }),
]);
await expect(enhanced({} as ModerationPhaseContext)).resolves.toEqual({
status,
metadata: { first: true, second: true },
actions: [],
});
});
it("merges actions", async () => {
const status = GQLCOMMENT_STATUS.ACCEPTED;
const flags = [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.TOXIC_COMMENT,
},
{
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.SPAM_COMMENT,
},
];
const enhanced = compose([
() => ({
actions: [flags[0]],
}),
() => ({
status,
actions: [flags[1]],
}),
() => ({
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.LINKS,
},
],
}),
]);
const final = await enhanced({} as ModerationPhaseContext);
for (const flag of flags) {
expect(final.actions).toContainEqual(flag);
}
expect(final.actions).not.toContainEqual({
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.LINKS,
});
});
it("handles when it does not return a status", async () => {
const enhanced = compose([
() => ({ metadata: { first: true } }),
() => ({ metadata: { second: true } }),
]);
await expect(enhanced({} as ModerationPhaseContext)).resolves.toEqual({
status: GQLCOMMENT_STATUS.NONE,
metadata: { first: true, second: true },
actions: [],
});
});
});
@@ -3,13 +3,13 @@ import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__
import { Action } from "talk-server/models/actions";
import { Asset } from "talk-server/models/asset";
import { Tenant } from "talk-server/models/tenant";
import { CreateComment } from "talk-server/services/comments";
import { User } from "talk-server/models/user";
import { CreateComment } from "talk-server/services/comments";
import { Request } from "talk-server/types/express";
import { moderationPhases } from "./phases";
// TODO: (wyattjoh) move into actions module.
// TODO: (wyattjoh) move into actions module once we have action methods.
export type CreateAction = Omit<
Action,
"id" | "item_type" | "item_id" | "created_at"
@@ -18,6 +18,7 @@ export type CreateAction = Omit<
export interface PhaseResult {
actions: CreateAction[];
status: GQLCOMMENT_STATUS;
metadata: Record<string, any>;
}
export interface ModerationPhaseContext {
@@ -42,31 +43,47 @@ export type IntermediateModerationPhase = (
* compose will create a moderation pipeline for which is executable with the
* passed actions.
*/
const compose = (
export const compose = (
phases: IntermediateModerationPhase[]
): ModerationPhase => async context => {
const actions: CreateAction[] = [];
const final: PhaseResult = {
status: GQLCOMMENT_STATUS.NONE,
actions: [],
metadata: {},
};
// Loop over all the moderation phases and see if we've resolved the status.
for (const phase of phases) {
const result = await phase(context);
if (result) {
if (result.actions) {
actions.push(...result.actions);
// If this result contained actions, then we should push it into the
// other actions.
const { actions } = result;
if (actions) {
final.actions.push(...actions);
}
// If this result contained metadata, then we should merge it into the
// other metadata.
const { metadata } = result;
if (metadata) {
final.metadata = {
...final.metadata,
...metadata,
};
}
// If this result contained a status, then we've finished resolving
// phases!
const { status } = result;
if (status) {
return { status, actions };
final.status = status;
break;
}
}
}
// If we didn't determine a different comment from a previous itteration, set
// it to 'NONE'.
return { status: GQLCOMMENT_STATUS.NONE, actions };
return final;
};
/**
@@ -1,12 +1,15 @@
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
// This phase checks to see if the asset being processed is closed or not.
export const assetClosed: IntermediateModerationPhase = ({ asset }) => {
export const assetClosed: IntermediateModerationPhase = ({
asset,
}): IntermediatePhaseResult | void => {
// Check to see if the asset has closed commenting...
if (asset.closedAt && asset.closedAt.valueOf() <= Date.now()) {
// TODO: (wyattjoh) return better error.
throw new Error("asset is currently closed for commenting");
}
return;
};
@@ -1,18 +1,22 @@
import {
GQLACTION_GROUP,
GQLACTION_TYPE,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ModerationSettings } from "talk-server/models/settings";
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
const testCharCount = (settings: Partial<ModerationSettings>, length: number) =>
settings.charCountEnable && settings.charCount && length > settings.charCount;
export const commentLength: IntermediateModerationPhase = async ({
export const commentLength: IntermediateModerationPhase = ({
asset,
tenant,
comment,
}) => {
}): IntermediatePhaseResult | void => {
const length = comment.body.length;
// Check to see if the body is too short, if it is, then complain about it!
@@ -32,7 +36,7 @@ export const commentLength: IntermediateModerationPhase = async ({
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: "BODY_COUNT",
group_id: GQLACTION_GROUP.BODY_COUNT,
metadata: {
count: length,
},
@@ -40,6 +44,4 @@ export const commentLength: IntermediateModerationPhase = async ({
],
};
}
return;
};
@@ -1,5 +1,8 @@
import { ModerationSettings } from "talk-server/models/settings";
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
const testDisabledCommenting = (settings: Partial<ModerationSettings>) =>
settings.disableCommenting;
@@ -7,7 +10,7 @@ const testDisabledCommenting = (settings: Partial<ModerationSettings>) =>
export const commentingDisabled: IntermediateModerationPhase = ({
asset,
tenant,
}) => {
}): IntermediatePhaseResult | void => {
// Check to see if the asset has closed commenting.
if (
testDisabledCommenting(tenant) ||
@@ -1,6 +1,7 @@
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
import { premod } from "talk-server/services/comments/moderation/phases/premod";
import { toxic } from "talk-server/services/comments/moderation/phases/toxic";
import { assetClosed } from "./assetClosed";
import { commentingDisabled } from "./commentingDisabled";
import { commentLength } from "./commentLength";
@@ -22,5 +23,6 @@ export const moderationPhases: IntermediateModerationPhase[] = [
links,
karma,
spam,
toxic,
premod,
];
@@ -1,8 +1,12 @@
import {
GQLACTION_GROUP,
GQLACTION_TYPE,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
import {
getCommentTrustScore,
isReliableCommenter,
@@ -10,7 +14,10 @@ import {
// This phase checks to see if the user making the comment is allowed to do so
// considering their reliability (Trust) status.
export const karma: IntermediateModerationPhase = ({ tenant, author }) => {
export const karma: IntermediateModerationPhase = ({
tenant,
author,
}): IntermediatePhaseResult | void => {
// If the user is not a reliable commenter (passed the unreliability
// threshold by having too many rejected comments) then we can change the
// status of the comment to `SYSTEM_WITHHELD`, therefore pushing the user's
@@ -27,7 +34,7 @@ export const karma: IntermediateModerationPhase = ({ tenant, author }) => {
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: "TRUST",
group_id: GQLACTION_GROUP.TRUST,
metadata: {
trust: getCommentTrustScore(author),
},
@@ -35,6 +42,4 @@ export const karma: IntermediateModerationPhase = ({ tenant, author }) => {
],
};
}
return;
};
@@ -2,11 +2,15 @@ import linkify from "linkify-it";
import tlds from "tlds";
import {
GQLACTION_GROUP,
GQLACTION_TYPE,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ModerationSettings } from "talk-server/models/settings";
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
/**
* The preloaded linkify instance with common tlds.
@@ -24,8 +28,7 @@ export const links: IntermediateModerationPhase = ({
asset,
tenant,
comment,
author,
}) => {
}): IntermediatePhaseResult | void => {
if (
testPremodLinksEnable(tenant, comment.body) ||
(asset.settings && testPremodLinksEnable(asset.settings, comment.body))
@@ -36,7 +39,7 @@ export const links: IntermediateModerationPhase = ({
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: "LINKS",
group_id: GQLACTION_GROUP.LINKS,
metadata: {
links: comment.body,
},
@@ -44,6 +47,4 @@ export const links: IntermediateModerationPhase = ({
],
};
}
return;
};
@@ -3,14 +3,20 @@ import {
GQLMODERATION_MODE,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ModerationSettings } from "talk-server/models/settings";
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
const testModerationMode = (settings: Partial<ModerationSettings>) =>
settings.moderation === GQLMODERATION_MODE.PRE;
// This phase checks to see if the settings have premod enabled, if they do,
// the comment is premod, otherwise, it's just none.
export const premod: IntermediateModerationPhase = ({ asset, tenant }) => {
export const premod: IntermediateModerationPhase = ({
asset,
tenant,
}): IntermediatePhaseResult | void => {
// If the settings say that we're in premod mode, then the comment is in
// premod status.
@@ -23,6 +29,4 @@ export const premod: IntermediateModerationPhase = ({ asset, tenant }) => {
status: GQLCOMMENT_STATUS.PREMOD,
};
}
return;
};
@@ -1,10 +1,15 @@
import { Client } from "akismet-api";
import {
GQLACTION_GROUP,
GQLACTION_TYPE,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
import logger from "talk-server/logger";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
export const spam: IntermediateModerationPhase = async ({
asset,
@@ -12,16 +17,26 @@ export const spam: IntermediateModerationPhase = async ({
comment,
author,
req,
}) => {
}): Promise<IntermediatePhaseResult | void> => {
const integration = tenant.integrations.akismet;
// We can only check for spam if this comment originated from a graphql
// request via an HTTP call.
if (!req || !integration.enabled) {
if (!req) {
logger.debug({ tenant_id: tenant.id }, "request was not available");
return;
}
if (!integration.enabled) {
logger.debug({ tenant_id: tenant.id }, "akismet integration was disabled");
return;
}
if (!integration.key || !integration.site) {
logger.error(
{ tenant_id: tenant.id },
"akismet integration was enabled but configuration was missing"
);
return;
}
@@ -34,41 +49,73 @@ export const spam: IntermediateModerationPhase = async ({
// Grab the properties we need.
const userIP = req.ip;
if (!userIP) {
logger.debug(
{ tenant_id: tenant.id },
"request did not contain ip address, aborting spam check"
);
return;
}
const userAgent = req.get("User-Agent");
if (!userAgent || userAgent.length === 0) {
logger.debug(
{ tenant_id: tenant.id },
"request did not contain User-Agent header, aborting spam check"
);
return;
}
const referrer = req.get("Referrer");
if (!referrer || referrer.length === 0) {
logger.debug(
{ tenant_id: tenant.id },
"request did not contain Referrer header, aborting spam check"
);
return;
}
// Check the comment for spam.
const isSpam = await client.checkSpam({
user_ip: userIP, // REQUIRED
referrer, // REQUIRED
user_agent: userAgent, // REQUIRED
comment_content: comment.body,
permalink: asset.url,
comment_author: author.displayName || author.username || "",
comment_type: "comment",
is_test: false,
});
if (isSpam) {
return {
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: "SPAM_COMMENT",
},
],
};
}
try {
logger.trace({ tenant_id: tenant.id }, "checking comment for spam");
return;
// Check the comment for spam.
const isSpam = await client.checkSpam({
user_ip: userIP, // REQUIRED
referrer, // REQUIRED
user_agent: userAgent, // REQUIRED
comment_content: comment.body,
permalink: asset.url,
comment_author: author.displayName || author.username || "",
comment_type: "comment",
is_test: false,
});
if (isSpam) {
logger.trace(
{ tenant_id: tenant.id, is_spam: isSpam },
"comment contained spam"
);
return {
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.SPAM_COMMENT,
},
],
metadata: {
// Store the spam result from Akismet in the Comment metadata.
akismet: spam,
},
};
}
logger.trace(
{ tenant_id: tenant.id, is_spam: isSpam },
"comment did not contain spam"
);
} catch (err) {
logger.error(
{ tenant_id: tenant.id, err },
"could not determine if comment contained spam"
);
}
};
@@ -2,7 +2,10 @@ import {
GQLCOMMENT_STATUS,
GQLUSER_ROLE,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
// If a given user is a staff member, always approve their comment.
export const staff: IntermediateModerationPhase = ({
@@ -10,12 +13,10 @@ export const staff: IntermediateModerationPhase = ({
tenant,
comment,
author,
}) => {
}): IntermediatePhaseResult | void => {
if (author.role !== GQLUSER_ROLE.COMMENTER) {
return {
status: GQLCOMMENT_STATUS.ACCEPTED,
};
}
return;
};
@@ -0,0 +1,183 @@
import { isNil } from "lodash";
import ms from "ms";
import fetch from "node-fetch";
import { Omit } from "talk-common/types";
import {
GQLACTION_GROUP,
GQLACTION_TYPE,
GQLCOMMENT_STATUS,
GQLPerspectiveExternalIntegration,
} from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
export const toxic: IntermediateModerationPhase = async ({
tenant,
comment,
}): Promise<IntermediatePhaseResult | void> => {
const integration = tenant.integrations.perspective;
if (!integration.enabled) {
// The Toxic comment plugin is not enabled.
logger.debug(
{ tenant_id: tenant.id },
"perspective integration was disabled"
);
return;
}
if (!integration.key) {
// The Toxic comment requires a key in order to communicate with the API.
logger.error(
{ tenant_id: tenant.id },
"perspective integration was enabled but configuration was missing"
);
return;
}
let endpoint = integration.endpoint;
if (isNil(endpoint)) {
// TODO: (wyattjoh) replace hardcoded default with config.
endpoint = "https://commentanalyzer.googleapis.com/v1alpha1";
logger.trace(
{ tenant_id: tenant.id, endpoint },
"endpoint missing in integration settings, using defaults"
);
}
let threshold = integration.threshold;
if (isNil(threshold)) {
// TODO: (wyattjoh) replace hardcoded default with config.
threshold = 0.8;
logger.trace(
{ tenant_id: tenant.id, threshold },
"threshold missing in integration settings, using defaults"
);
}
let doNotStore = integration.doNotStore;
if (isNil(doNotStore)) {
doNotStore = true;
logger.trace(
{ tenant_id: tenant.id, do_not_store: doNotStore },
"doNotStore missing in integration settings, using defaults"
);
}
// TODO: (wyattjoh) replace hardcoded default with config.
const timeout = ms("300ms");
try {
logger.trace({ tenant_id: tenant.id }, "checking comment toxicity");
// Call into the Toxic comment API.
const scores = await getScores(
comment.body,
{
endpoint,
key: integration.key,
doNotStore,
},
timeout
);
const score = scores.SEVERE_TOXICITY.summaryScore;
const isToxic = score > threshold;
if (isToxic) {
logger.trace(
{ tenant_id: tenant.id, score, is_toxic: isToxic, threshold },
"comment was toxic"
);
return {
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.TOXIC_COMMENT,
},
],
metadata: {
// Store the scores from perspective in the Comment metadata.
perspective: scores,
},
};
}
logger.trace(
{ tenant_id: tenant.id, score, is_toxic: isToxic, threshold },
"comment was not toxic"
);
} catch (err) {
logger.error(
{ tenant_id: tenant.id, err },
"could not determine comment toxicity"
);
}
};
/**
* getScores will return the toxicity scores for the comment text.
*
* @param text comment text to check for toxicity
* @param settings integration settings used to communicate with the perspective api
* @param timeout timeout for communicating with the perspective api
*/
async function getScores(
text: string,
{
key,
endpoint,
doNotStore,
}: Required<Omit<GQLPerspectiveExternalIntegration, "enabled" | "threshold">>,
timeout: number
) {
try {
const response = await fetch(`${endpoint}/comments:analyze?key=${key}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
timeout,
body: JSON.stringify({
comment: {
text,
},
// TODO: (wyattjoh) support other languages.
languages: ["en"],
doNotStore,
requestedAttributes: {
TOXICITY: {},
SEVERE_TOXICITY: {},
},
}),
});
// Grab the data out of the Perspective API.
const data = await response.json();
// Reformat the scores.
return {
TOXICITY: {
summaryScore: data.attributeScores.TOXICITY.summaryScore.value,
},
SEVERE_TOXICITY: {
summaryScore: data.attributeScores.SEVERE_TOXICITY.summaryScore.value,
},
};
} catch (err) {
// Ensure that the API key doesn't get leaked to the logs by accident.
if (err.message) {
err.message = err.message.replace(key, "***");
}
// Rethrow the error.
throw err;
}
}
@@ -1,17 +1,19 @@
import {
GQLACTION_GROUP,
GQLACTION_TYPE,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
import { containsMatchingPhrase } from "talk-server/services/comments/moderation/wordlist";
// This phase checks the comment against the wordlist.
export const wordlist: IntermediateModerationPhase = ({
asset,
tenant,
comment,
author,
}) => {
}): IntermediatePhaseResult | void => {
// Decide the status based on whether or not the current asset/settings
// has pre-mod enabled or not. If the comment was rejected based on the
// wordlist, then reject it, otherwise if the moderation setting is
@@ -23,7 +25,7 @@ export const wordlist: IntermediateModerationPhase = ({
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: "BANNED_WORD",
group_id: GQLACTION_GROUP.BANNED_WORD,
},
],
};
@@ -40,11 +42,9 @@ export const wordlist: IntermediateModerationPhase = ({
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: "SUSPECT_WORD",
group_id: GQLACTION_GROUP.SUSPECT_WORD,
},
],
};
}
return;
};