Merge branch 'master' into fix-graphql-warnings

This commit is contained in:
Kim Gardner
2017-09-08 10:56:33 +01:00
committed by GitHub
35 changed files with 498 additions and 166 deletions
+1
View File
@@ -22,5 +22,6 @@ plugins/*
!plugins/talk-plugin-author-menu
!plugins/talk-plugin-member-since
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-toxic-comments
node_modules
+2
View File
@@ -29,6 +29,7 @@ plugins/*
!plugins/talk-plugin-comment-content
!plugins/talk-plugin-permalink
!plugins/talk-plugin-featured-comments
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-sort-newest
!plugins/talk-plugin-sort-oldest
!plugins/talk-plugin-sort-most-replied
@@ -38,5 +39,6 @@ plugins/*
!plugins/talk-plugin-author-menu
!plugins/talk-plugin-member-since
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-toxic-comments
**/node_modules/*
@@ -109,7 +109,7 @@ export default {
},
mutations: {
PostComment: ({
variables: {comment: {asset_id, body, parent_id, tags = []}},
variables: {input: {asset_id, body, parent_id, tags = []}},
state: {auth},
}) => ({
optimisticResponse: {
@@ -6,6 +6,7 @@ export default {
'SetCommentStatusResponse',
'SuspendUserResponse',
'RejectUsernameResponse',
'CreateCommentResponse',
'SetUserStatusResponse',
'CreateFlagResponse',
'EditCommentResponse',
+4 -4
View File
@@ -192,17 +192,17 @@ export const withSetUserStatus = withMutation(
export const withPostComment = withMutation(
gql`
mutation PostComment($comment: CreateCommentInput!) {
createComment(comment: $comment) {
mutation PostComment($input: CreateCommentInput!) {
createComment(input: $input) {
...CreateCommentResponse
}
}
`, {
props: ({mutate}) => ({
postComment: (comment) => {
postComment: (input) => {
return mutate({
variables: {
comment
input
},
});
}
+1 -1
View File
@@ -109,7 +109,7 @@ export function mergeDocuments(documents) {
export function getResponseErrors(mutationResult) {
const result = [];
Object.keys(mutationResult.data).forEach((response) => {
const errors = mutationResult.data[response].errors;
const errors = mutationResult.data[response] && mutationResult.data[response].errors;
if (errors && errors.length) {
result.push(...errors);
}
+3 -3
View File
@@ -54,7 +54,7 @@ class CommentBox extends React.Component {
return;
}
let comment = {
let input = {
asset_id: assetId,
parent_id: parentId,
body: this.state.body,
@@ -62,10 +62,10 @@ class CommentBox extends React.Component {
};
// Execute preSubmit Hooks
this.state.hooks.preSubmit.forEach((hook) => hook());
this.state.hooks.preSubmit.forEach((hook) => hook(input));
this.setState({loadingState: 'loading'});
postComment(comment, 'comments')
postComment(input, 'comments')
.then(({data}) => {
this.setState({loadingState: 'success', body: ''});
const postedComment = data.createComment.comment;
+54
View File
@@ -0,0 +1,54 @@
const {forEachField} = require('./utils');
const {maskErrors} = require('graphql-errors');
const errors = require('../errors');
const {Error: {ValidationError}} = require('mongoose');
// If an APIError happens in a mutation, then respond with `{errors: Array}`
// according to the schema.
const decorateWithMutationErrorHandler = (field) => {
const fieldResolver = field.resolve;
field.resolve = async (obj, args, ctx, info) => {
try {
return await fieldResolver(obj, args, ctx, info);
}
catch(err) {
if (err instanceof errors.APIError) {
return {
errors: [err]
};
} else if (err instanceof ValidationError) {
// TODO: wrap this with one of our internal errors.
throw err;
}
throw err;
}
};
};
/**
* Masks errors during production and handle mutation errors inside the schema.
* @param {GraphQLSchema} schema the schema to decorate
* @return {void}
*/
const decorateWithErrorHandler = (schema) => {
forEachField(schema, (field, typeName) => {
// Handle mutation errors.
if (typeName === 'RootMutation') {
decorateWithMutationErrorHandler(field);
}
// If we are in production mode, don't show server errors to the front end.
if (process.env.NODE_ENV === 'production') {
// Mask errors that are thrown if we are in a production environment.
maskErrors(field);
}
});
};
module.exports = {
decorateWithErrorHandler,
};
-34
View File
@@ -1,34 +0,0 @@
const errors = require('../../errors');
const {Error: {ValidationError}} = require('mongoose');
/**
* Wraps up a promise or value to return an object with the resolution of the promise
* keyed at `key` or an error caught at `errors`.
*/
const wrapResponse = (key) => async (promise) => {
try {
let value = await promise;
let res = {};
if (key) {
res[key] = value;
}
return res;
} catch (err) {
if (err instanceof errors.APIError) {
return {
errors: [err]
};
} else if (err instanceof ValidationError) {
// TODO: wrap this with one of our internal errors.
throw err;
}
throw err;
}
};
module.exports = wrapResponse;
+3 -31
View File
@@ -1,7 +1,4 @@
const {
GraphQLObjectType,
GraphQLInterfaceType
} = require('graphql');
const {forEachField} = require('./utils');
const debug = require('debug')('talk:graph:schema');
const Joi = require('joi');
@@ -26,33 +23,6 @@ const defaultResolveFn = (source, args, context, {fieldName}) => {
}
};
// This function is pretty much copied verbatim from the graphql-tools repo:
// https://github.com/apollographql/graphql-tools/blob/b12973c86e00be209d04af0184780998056051c4/src/schemaGenerator.ts#L180-L194
// With the small alteration that we look for the `resolveType` function on the
// schema so we can wrap post hooks around it to provide additional resolve
// points.
const forEachField = (schema, fn) => {
const typeMap = schema.getTypeMap();
Object.keys(typeMap).forEach((typeName) => {
const type = typeMap[typeName];
if (type instanceof GraphQLObjectType || type instanceof GraphQLInterfaceType) {
// Here we capture the change to extract the resolve type. We pass this
// with the `isResolveType = true` to introduce the specific beheviour.
if ('resolveType' in type) {
fn(type, typeName, '__resolveType', true);
}
const fields = type.getFields();
Object.keys(fields).forEach((fieldName) => {
const field = fields[fieldName];
fn(field, typeName, fieldName);
});
}
});
};
/**
* Decorates the field with the post resolvers (if available) and attaches a
* default type in the form of `Default${typeName}`.
@@ -239,6 +209,8 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa
return result;
}, result);
};
}, {
includeResolveType: true,
});
module.exports = {
+5 -4
View File
@@ -154,7 +154,7 @@ const adjustKarma = (Comments, id, status) => async () => {
* @param {String} [status='NONE'] the status of the new comment
* @return {Promise} resolves to the created comment
*/
const createComment = async (context, {tags = [], body, asset_id, parent_id = null}, status = 'NONE') => {
const createComment = async (context, {tags = [], body, asset_id, parent_id = null, metadata = {}}, status = 'NONE') => {
const {user, loaders: {Comments}, pubsub} = context;
// Resolve the tags for the comment.
@@ -166,7 +166,8 @@ const createComment = async (context, {tags = [], body, asset_id, parent_id = nu
parent_id,
status,
tags,
author_id: user.id
author_id: user.id,
metadata,
});
// If the loaders are present, clear the caches for these values because we
@@ -214,7 +215,7 @@ const filterNewComment = (context, {body, asset_id}) => {
* @param {Object} [wordlist={}] the results of the wordlist scan
* @return {Promise} resolves to the comment's status
*/
const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {}, settings = {}) => {
const resolveNewCommentStatus = async (context, {asset_id, body, status}, wordlist = {}, settings = {}) => {
let {user} = context;
// Check to see if the body is too short, if it is, then complain about it!
@@ -269,7 +270,7 @@ const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {},
}
}
return moderation === 'PRE' ? 'PREMOD' : 'NONE';
return (moderation === 'PRE' || status === 'PREMOD') ? 'PREMOD' : 'NONE';
};
/**
+42 -45
View File
@@ -1,43 +1,41 @@
const wrapResponse = require('../helpers/response');
const RootMutation = {
createComment(_, {comment}, {mutators: {Comment}}) {
return wrapResponse('comment')(Comment.create(comment));
createComment: async (_, {input}, {mutators: {Comment}}) => ({
comment: await Comment.create(input),
}),
editComment: async (_, {id, asset_id, edit: {body}}, {mutators: {Comment}}) => ({
comment: await Comment.edit({id, asset_id, edit: {body}}),
}),
createFlag: async (_, {flag: {item_id, item_type, reason, message}}, {mutators: {Action}}) => ({
flag: Action.create({item_id, item_type, action_type: 'FLAG', group_id: reason, metadata: {message}}),
}),
createDontAgree: async (_, {dontagree: {item_id, item_type, reason, message}}, {mutators: {Action}}) => ({
dontagree: await Action.create({item_id, item_type, action_type: 'DONTAGREE', group_id: reason, metadata: {message}}),
}),
deleteAction: async (_, {id}, {mutators: {Action}}) => {
await Action.delete({id});
},
editComment(_, {id, asset_id, edit: {body}}, {mutators: {Comment}}) {
return wrapResponse('comment')(Comment.edit({id, asset_id, edit: {body}}));
setUserStatus: async (_, {id, status}, {mutators: {User}}) => {
await User.setUserStatus({id, status});
},
createFlag(_, {flag: {item_id, item_type, reason, message}}, {mutators: {Action}}) {
return wrapResponse('flag')(Action.create({item_id, item_type, action_type: 'FLAG', group_id: reason, metadata: {message}}));
suspendUser: async (_, {input: {id, message, until}}, {mutators: {User}}) => {
await User.suspendUser({id, message, until});
},
createDontAgree(_, {dontagree: {item_id, item_type, reason, message}}, {mutators: {Action}}) {
return wrapResponse('dontagree')(Action.create({item_id, item_type, action_type: 'DONTAGREE', group_id: reason, metadata: {message}}));
rejectUsername: async (_, {input: {id, message}}, {mutators: {User}}) => {
await User.rejectUsername({id, message});
},
deleteAction(_, {id}, {mutators: {Action}}) {
return wrapResponse(null)(Action.delete({id}));
ignoreUser: async (_, {id}, {mutators: {User}}) => {
await User.ignoreUser({id});
},
setUserStatus(_, {id, status}, {mutators: {User}}) {
return wrapResponse(null)(User.setUserStatus({id, status}));
stopIgnoringUser: async (_, {id}, {mutators: {User}}) => {
await User.stopIgnoringUser({id});
},
suspendUser(_, {input: {id, message, until}}, {mutators: {User}}) {
return wrapResponse(null)(User.suspendUser({id, message, until}));
updateAssetSettings: async (_, {id, input: settings}, {mutators: {Asset}}) => {
await Asset.updateSettings(id, settings);
},
rejectUsername(_, {input: {id, message}}, {mutators: {User}}) {
return wrapResponse(null)(User.rejectUsername({id, message}));
updateAssetStatus: async (_, {id, input: status}, {mutators: {Asset}}) => {
await Asset.updateStatus(id, status);
},
updateAssetSettings(_, {id, input: settings}, {mutators: {Asset}}) {
return wrapResponse(null)(Asset.updateSettings(id, settings));
},
updateAssetStatus(_, {id, input: status}, {mutators: {Asset}}) {
return wrapResponse(null)(Asset.updateStatus(id, status));
},
ignoreUser(_, {id}, {mutators: {User}}) {
return wrapResponse(null)(User.ignoreUser({id}));
},
stopIgnoringUser(_, {id}, {mutators: {User}}) {
return wrapResponse(null)(User.stopIgnoringUser({id}));
},
async setCommentStatus(_, {id, status}, {mutators: {Comment}, pubsub}) {
setCommentStatus: async (_, {id, status}, {mutators: {Comment}, pubsub}) => {
const comment = await Comment.setStatus({id, status});
if (status === 'ACCEPTED') {
@@ -48,25 +46,24 @@ const RootMutation = {
// Publish the comment status change via the subscription.
pubsub.publish('commentRejected', comment);
}
return wrapResponse(null)(comment);
},
addTag(_, {tag}, {mutators: {Tag}}) {
return wrapResponse(null)(Tag.add(tag));
addTag: async (_, {tag}, {mutators: {Tag}}) => {
await Tag.add(tag);
},
removeTag(_, {tag}, {mutators: {Tag}}) {
return wrapResponse(null)(Tag.remove(tag));
removeTag: async (_, {tag}, {mutators: {Tag}}) => {
await Tag.remove(tag);
},
updateSettings(_, {input: settings}, {mutators: {Settings}}) {
return wrapResponse(null)(Settings.update(settings));
updateSettings: async (_, {input: settings}, {mutators: {Settings}}) => {
await Settings.update(settings);
},
updateWordlist(_, {input: wordlist}, {mutators: {Settings}}) {
return wrapResponse(null)(Settings.updateWordlist(wordlist));
updateWordlist: async (_, {input: wordlist}, {mutators: {Settings}}) => {
await Settings.updateWordlist(wordlist);
},
createToken(_, {input}, {mutators: {Token}}) {
return wrapResponse('token')(Token.create(input));
},
revokeToken(_, {input}, {mutators: {Token}}) {
return wrapResponse(null)(Token.revoke(input));
createToken: async (_, {input}, {mutators: {Token}}) => ({
token: await Token.create(input),
}),
revokeToken: async (_, {input}, {mutators: {Token}}) => {
await Token.revoke(input);
}
};
+3 -7
View File
@@ -1,6 +1,6 @@
const {makeExecutableSchema} = require('graphql-tools');
const {maskErrors} = require('graphql-errors');
const {decorateWithHooks} = require('./hooks');
const {decorateWithErrorHandler} = require('./errorHandler');
const plugins = require('../services/plugins');
const resolvers = require('./resolvers');
@@ -11,11 +11,7 @@ const schema = makeExecutableSchema({typeDefs, resolvers});
// Plugin to the schema level resolvers to provide an before/after hook.
decorateWithHooks(schema, plugins.get('server', 'hooks'));
// If we are in production mode, don't show server errors to the front end.
if (process.env.NODE_ENV === 'production') {
// Mask errors that are thrown if we are in a production environment.
maskErrors(schema);
}
// Handle errors like masking in production and mutation errors.
decorateWithErrorHandler(schema);
module.exports = schema;
+13 -13
View File
@@ -1254,7 +1254,7 @@ type RevokeTokenResponse implements Response {
type RootMutation {
# Creates a comment on the asset.
createComment(comment: CreateCommentInput!): CreateCommentResponse!
createComment(input: CreateCommentInput!): CreateCommentResponse!
# Creates a flag on an entity.
createFlag(flag: CreateFlagInput!): CreateFlagResponse!
@@ -1270,42 +1270,42 @@ type RootMutation {
# Sets User status. Requires the `ADMIN` role.
# Mutation is restricted.
setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse!
setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse
# Suspends a user. Requires the `ADMIN` role.
# Mutation is restricted.
suspendUser(input: SuspendUserInput!): SuspendUserResponse!
suspendUser(input: SuspendUserInput!): SuspendUserResponse
# Reject a username. Requires the `ADMIN` role.
# Mutation is restricted.
rejectUsername(input: RejectUsernameInput!): RejectUsernameResponse!
rejectUsername(input: RejectUsernameInput!): RejectUsernameResponse
# Sets Comment status. Requires the `ADMIN` role.
# Mutation is restricted.
setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse!
setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse
# Add a tag.
addTag(tag: ModifyTagInput!): ModifyTagResponse!
addTag(tag: ModifyTagInput!): ModifyTagResponse
# Removes a tag.
removeTag(tag: ModifyTagInput!): ModifyTagResponse!
removeTag(tag: ModifyTagInput!): ModifyTagResponse
# Updates settings on a given asset.
# Mutation is restricted.
updateAssetSettings(id: ID!, input: AssetSettingsInput!): UpdateAssetSettingsResponse!
updateAssetSettings(id: ID!, input: AssetSettingsInput!): UpdateAssetSettingsResponse
# Updates the status of an asset allowing you to close/reopen an asset for
# commenting.
# Mutation is restricted.
updateAssetStatus(id: ID!, input: UpdateAssetStatusInput!): UpdateAssetStatusResponse!
updateAssetStatus(id: ID!, input: UpdateAssetStatusInput!): UpdateAssetStatusResponse
# updateSettings will update the global settings.
# Mutation is restricted.
updateSettings(input: UpdateSettingsInput!): UpdateSettingsResponse!
updateSettings(input: UpdateSettingsInput!): UpdateSettingsResponse
# updateWordlist will update the given Wordlist.
# Mutation is restricted.
updateWordlist(input: UpdateWordlistInput!): UpdateWordlistResponse!
updateWordlist(input: UpdateWordlistInput!): UpdateWordlistResponse
# Ignore comments by another user
ignoreUser(id: ID!): IgnoreUserResponse
@@ -1316,10 +1316,10 @@ type RootMutation {
# RevokeToken will revoke an existing token.
# Mutation is restricted.
revokeToken(input: RevokeTokenInput!): RevokeTokenResponse!
revokeToken(input: RevokeTokenInput!): RevokeTokenResponse
# Stop Ignoring comments by another user.
stopIgnoringUser(id: ID!): StopIgnoringUserResponse!
stopIgnoringUser(id: ID!): StopIgnoringUserResponse
}
################################################################################
+46
View File
@@ -0,0 +1,46 @@
const {
GraphQLObjectType,
GraphQLInterfaceType
} = require('graphql');
/**
* Iterates over each field in a schema.
* This function is pretty much copied verbatim from the graphql-tools repo:
* https://github.com/apollographql/graphql-tools/blob/b12973c86e00be209d04af0184780998056051c4/src/schemaGenerator.ts#L180-L194
* With the small alteration that we look for the `resolveType` function on the
* schema so we can wrap post hooks around it to provide additional resolve
* points. (Only when `options.includeResolveType` is set to true).
*
* @param {GraphQLSchema} schema the schema to iterate over
* @param {function} fn callback to call on each field
* @param {object} [options] options
* @param {boolean} [options.includeResolveType] include resolveType during iteration
* @return {void}
*/
const forEachField = (schema, fn, options = {}) => {
const {includeResolveType = false} = options;
const typeMap = schema.getTypeMap();
Object.keys(typeMap).forEach((typeName) => {
const type = typeMap[typeName];
if (type instanceof GraphQLObjectType || type instanceof GraphQLInterfaceType) {
// Here we capture the change to extract the resolve type. We pass this
// with the `isResolveType = true` to introduce the specific beheviour.
if (includeResolveType && 'resolveType' in type) {
fn(type, typeName, '__resolveType', true);
}
const fields = type.getFields();
Object.keys(fields).forEach((fieldName) => {
const field = fields[fieldName];
fn(field, typeName, fieldName);
});
}
});
};
module.exports = {
forEachField,
};
+8 -13
View File
@@ -1,4 +1,3 @@
const wrapResponse = require('../../../graph/helpers/response');
const {SEARCH_OTHER_USERS} = require('../../../perms/constants');
const errors = require('../../../errors');
const pluralize = require('pluralize');
@@ -90,10 +89,6 @@ function getReactionConfig(reaction) {
}
type Delete${Reaction}ActionResponse implements Response {
# The ${reaction} that was created.
${reaction}: ${Reaction}Action
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
@@ -101,7 +96,7 @@ function getReactionConfig(reaction) {
type RootMutation {
# Creates a ${reaction} on an entity.
create${Reaction}Action(input: Create${Reaction}ActionInput!): Create${Reaction}ActionResponse
create${Reaction}Action(input: Create${Reaction}ActionInput!): Create${Reaction}ActionResponse!
delete${Reaction}Action(input: Delete${Reaction}ActionInput!): Delete${Reaction}ActionResponse
}
@@ -160,7 +155,7 @@ function getReactionConfig(reaction) {
}
},
RootMutation: {
[`create${Reaction}Action`]: (_, {input: {item_id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => wrapResponse(reaction)(async () => {
[`create${Reaction}Action`]: async (_, {input: {item_id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => {
const comment = await Comments.get.load(item_id);
let action;
@@ -180,9 +175,11 @@ function getReactionConfig(reaction) {
pubsub.publish(`${reaction}ActionCreated`, {action, comment});
}
return action;
}),
[`delete${Reaction}Action`]: (_, {input: {id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => wrapResponse(reaction)(async () => {
return {
[reaction]: action,
};
},
[`delete${Reaction}Action`]: async (_, {input: {id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => {
const action = await Action.delete({id});
if (!action) {
return null;
@@ -194,9 +191,7 @@ function getReactionConfig(reaction) {
// The comment is needed to allow better filtering e.g. by asset_id.
pubsub.publish(`${reaction}ActionDeleted`, {action, comment});
}
return action;
})
},
},
},
hooks: {
@@ -0,0 +1,14 @@
{
"presets": [
"es2015"
],
"plugins": [
"add-module-exports",
"transform-class-properties",
"transform-decorators-legacy",
"transform-object-assign",
"transform-object-rest-spread",
"transform-async-to-generator",
"transform-react-jsx"
]
}
@@ -0,0 +1,23 @@
{
"env": {
"browser": true,
"es6": true,
"mocha": true
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
@@ -0,0 +1,38 @@
import React from 'react';
/**
* CheckToxicityHook adds hooks to the `commentBox`
* that handles checking a comment for toxicity.
*/
export default class CheckToxicityHook extends React.Component {
// checked signifies if we already sent a request with the `checkToxicity` set to true.
checked = false;
componentDidMount() {
this.toxicityPreHook = this.props.registerHook('preSubmit', (input) => {
// If we haven't check the toxicity yet, make sure to include `checkToxicity=true` in the mutation.
// Otherwise post comment without checking the toxicity.
if (!this.checked) {
input.checkToxicity = true;
this.checked = true;
}
});
this.toxicityPostHook = this.props.registerHook('postSubmit', () => {
// Reset `checked` after comment was successfully posted.
this.checked = false;
});
}
componentWillUnmount() {
this.props.unregisterHook(this.toxicityPreHook);
this.props.unregisterHook(this.toxicityPostHook);
}
render() {
return null;
}
}
@@ -0,0 +1,9 @@
import translations from './translations.yml';
import CheckToxicityHook from './components/CheckToxicityHook';
export default {
translations,
slots: {
commentInputDetailArea: [CheckToxicityHook],
},
};
@@ -0,0 +1,6 @@
en:
error:
COMMENT_IS_TOXIC: |
Are you sure? The language in this comment might violate our community guidelines.
You can edit the comment or submit it for moderator review.
es:
@@ -0,0 +1,8 @@
const {readFileSync} = require('fs');
const path = require('path');
const hooks = require('./server/hooks');
module.exports = {
typeDefs: readFileSync(path.join(__dirname, 'server/typeDefs.graphql'), 'utf8'),
hooks,
};
@@ -0,0 +1,9 @@
{
"name": "@coralproject/talk-plugin-toxicity",
"pluginName": "talk-plugin-toxicity",
"version": "0.0.1",
"description": "Provides support for measuring the toxicity of user comments using the Perspectives API",
"main": "index.js",
"author": "The Coral Project Team <coral@mozillafoundation.org>",
"license": "Apache-2.0"
}
@@ -0,0 +1,12 @@
const config = {
API_ENDPOINT: process.env.TALK_PERSPECTIVE_API_ENDPOINT || 'https://commentanalyzer.googleapis.com/v1alpha1',
API_KEY: process.env.TALK_PERSPECTIVE_API_KEY,
THRESHOLD: process.env.TALK_TOXICITY_THRESHOLD || 0.8,
API_TIMEOUT: process.env.TALK_PERSPECTIVE_TIMEOUT || 300,
};
if (process.env.NODE_ENV !== 'test' && !config.API_KEY) {
throw new Error('Please set the TALK_PERSPECTIVE_API_KEY environment variable to use the toxic-comments plugin. Visit https://www.perspectiveapi.com/ to request API access.');
}
module.exports = config;
@@ -0,0 +1,13 @@
const {APIError} = require('errors');
// ErrToxic is sent during a `CreateComment` mutation where
// `input.checkToxicity` is set to true and the comment contains
// toxic language as determined by the perspective service.
const ErrToxic = new APIError('Comment is toxic', {
status: 400,
translation_key: 'COMMENT_IS_TOXIC',
});
module.exports = {
ErrToxic,
};
@@ -0,0 +1,67 @@
const {getScores, isToxic} = require('./perspective');
const {ErrToxic} = require('./errors');
const ActionsService = require('../../../services/actions');
// We don't add the hooks during _test_ as the perspective API is not available.
if (process.env.NODE_ENV === 'test') {
return null;
}
module.exports = {
RootMutation: {
createComment: {
async pre(_, {input}, _context, _info) {
let scores;
// Try getting scores.
try {
scores = await getScores(input.body);
}
catch(err) {
// Warn and let mutation pass.
console.trace(err);
return;
}
const commentIsToxic = isToxic(scores);
if (input.checkToxicity && commentIsToxic) {
throw ErrToxic;
}
// attach scores to metadata.
input.metadata = Object.assign({}, input.metadata, {
perspective: scores,
});
if (commentIsToxic) {
// TODO: this should have a different status than Premod.
input.status = 'PREMOD';
}
},
async post(_, _input, _context, _info, result) {
const metadata = result.comment.metadata;
if (metadata.perspective && isToxic(metadata.perspective)) {
// TODO: this is kind of fragile, we should refactor this to resolve
// all these const's that we're using like 'COMMENTS', 'FLAG' to be
// defined in a checkable schema.
// Add a flag to the comment.
await ActionsService.create({
item_id: result.comment.id,
item_type: 'COMMENTS',
action_type: 'FLAG',
user_id: null,
group_id: 'Comment contains toxic language',
metadata: {}
});
}
return result;
},
},
},
};
@@ -0,0 +1,85 @@
const fetch = require('node-fetch');
const {API_ENDPOINT, API_KEY, THRESHOLD, API_TIMEOUT} = require('./config');
/**
* Get scores from the perspective api
* @param {string} text text to be anaylized
* @return {object} object containing toxicity scores
*/
async function getScores(text) {
const response = await fetch(`${API_ENDPOINT}/comments:analyze?key=${API_KEY}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
timeout: API_TIMEOUT,
body: JSON.stringify({
comment: {
text,
},
// TODO: support other languages.
languages: ['en'],
requestedAttributes: {
TOXICITY: {},
SEVERE_TOXICITY: {},
}
}),
});
const data = await response.json();
return {
TOXICITY: {
summaryScore: data.attributeScores.TOXICITY.summaryScore.value
},
SEVERE_TOXICITY: {
summaryScore: data.attributeScores.SEVERE_TOXICITY.summaryScore.value
},
};
}
/**
* Get toxicity probability
* @param {object} scores scores as returned by `getScores`
* @return {number} toxicity probability from 0 - 1.0
*/
function getProbability(scores) {
return scores.SEVERE_TOXICITY.summaryScore;
}
/**
* isToxic determines if given probabilty or scores meets the toxicity threshold.
* @param {object|number} scoresOrProbability scores or probability
* @return {boolean}
*/
function isToxic(scoresOrProbability) {
const probability = typeof scoresOrProbability === 'object'
? getProbability(scoresOrProbability)
: scoresOrProbability;
return probability > THRESHOLD;
}
/**
* maskKeyInError is a decorator that calls fn and masks the
* API_KEY in errors before throwing.
* @param {function} fn Function that returns a Promise
* @return {function} decorated function
*/
function maskKeyInError(fn) {
return async (...args) => {
try {
return await fn(...args);
}
catch(err) {
if (err.message) {
err.message = err.message.replace(API_KEY, '***');
}
throw err;
}
};
}
module.exports = {
getScores: maskKeyInError(getScores),
getProbability,
isToxic,
};
@@ -0,0 +1,7 @@
input CreateCommentInput {
# If true, the mutation will fail when the
# body contains toxic language.
checkToxicity: Boolean
}
@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
+3 -3
View File
@@ -16,8 +16,8 @@ describe('graph.mutations.createComment', () => {
beforeEach(() => SettingsService.init());
const query = `
mutation CreateComment($comment: CreateCommentInput = {asset_id: 123, body: "Here's my comment!"}) {
createComment(comment: $comment) {
mutation CreateComment($input: CreateCommentInput = {asset_id: 123, body: "Here's my comment!"}) {
createComment(input: $input) {
comment {
id
status
@@ -176,7 +176,7 @@ describe('graph.mutations.createComment', () => {
const context = new Context({user: new UserModel({status: 'ACTIVE'})});
return graphql(schema, query, {}, context, {
comment: {
input: {
asset_id: '123',
body
}
+1 -1
View File
@@ -45,7 +45,7 @@ describe('graph.mutations.removeTag', () => {
console.error(response.errors);
}
expect(response.errors).to.be.empty;
expect(response.data.removeTag.errors).to.be.null;
expect(response.data.removeTag).to.be.null;
let retrievedComment = await CommentsService.findById(comment.id);
@@ -57,7 +57,10 @@ describe('graph.mutations.updateAssetSettings', () => {
expect(res.data.updateAssetSettings.errors).to.not.be.empty;
expect(res.data.updateAssetSettings.errors[0]).to.have.property('translation_key', error);
} else {
expect(res.data.updateAssetSettings.errors).to.be.null;
if (res.data.updateAssetSettings && res.data.updateAssetSettings.errors) {
console.error(res.data.updateAssetSettings.errors);
}
expect(res.data.updateAssetSettings).to.be.null;
const retrievedAsset = await AssetModel.findOne({id: asset.id});
Object.keys(settings).forEach((key) => {
@@ -55,7 +55,10 @@ describe('graph.mutations.updateAssetStatus', () => {
expect(res.data.updateAssetStatus.errors).to.not.be.empty;
expect(res.data.updateAssetStatus.errors[0]).to.have.property('translation_key', error);
} else {
expect(res.data.updateAssetStatus.errors).to.be.null;
if (res.data.updateAssetStatus && res.data.updateAssetStatus.errors) {
console.error(res.data.updateAssetStatus.errors);
}
expect(res.data.updateAssetStatus).to.be.null;
const retrievedAsset = await AssetModel.findOne({id: asset.id});
expect(retrievedAsset.closedAt).to.not.be.null;
@@ -58,10 +58,10 @@ describe('graph.mutations.updateSettings', () => {
expect(res.data.updateSettings.errors).to.not.be.empty;
expect(res.data.updateSettings.errors[0]).to.have.property('translation_key', error);
} else {
if (res.data.updateSettings.errors) {
if (res.data.updateSettings && res.data.updateSettings.errors) {
console.error(res.data.updateSettings.errors);
}
expect(res.data.updateSettings.errors).to.be.null;
expect(res.data.updateSettings).to.be.null;
const retrievedSettings = await SettingsService.retrieve();
Object.keys(newSettings).forEach((key) => {
@@ -65,10 +65,10 @@ describe('graph.mutations.updateWordlist', () => {
expect(retrievedWordlist).to.have.property('suspect');
expect(retrievedWordlist.suspect).to.have.members([]);
} else {
if (res.data.updateWordlist.errors) {
if (res.data.updateWordlist && res.data.updateWordlist.errors) {
console.error(res.data.updateWordlist.errors);
}
expect(res.data.updateWordlist.errors).to.be.null;
expect(res.data.updateWordlist).to.be.null;
const {wordlist: retrievedWordlist} = await SettingsService.retrieve();
expect(retrievedWordlist).to.have.property('banned');