mirror of
https://github.com/wassname/talk.git
synced 2026-08-05 13:30:26 +08:00
[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:
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user