add shadow-muting chat hiding feature (automatic flagging of public posts from shadow-muted players) - fixes 10851 (#11239)

* move existing tests for chatRevoked users to 'mute user' describe block

* give consistent names to chatRevoked tests and use const not let

* improve methods for restoring chat permissions to test users

* add tests for shadow-muting and define constants for flag-related numbers

* update user profile URLs and reverse private/public 'if' statements

* implement shadow muting in the API and schemas

* add interface for mods to turn shadow muting on/off for a user

- checkbox in the Hall
- icon in the user's profile

* mark chat posts as being shadow muted (marking is visible to mods only)

* convert Admin Tools in profile from icons to text; make crown icon a toggle

* move logic for displaying flag count to a computed property

* prevent chat notifications for shadow-muted posts
This commit is contained in:
Alys
2019-07-31 03:09:42 +10:00
committed by Matteo Pagliazzi
parent b4b9caed83
commit c80de38572
13 changed files with 375 additions and 112 deletions

View File

@@ -12,6 +12,7 @@ import {
SPAM_MIN_EXEMPT_CONTRIB_LEVEL, SPAM_MIN_EXEMPT_CONTRIB_LEVEL,
TAVERN_ID, TAVERN_ID,
} from '../../../../../website/server/models/group'; } from '../../../../../website/server/models/group';
import { CHAT_FLAG_FROM_SHADOW_MUTE } from '../../../../../website/common/script/constants';
import { v4 as generateUUID } from 'uuid'; import { v4 as generateUUID } from 'uuid';
import { getMatchesByWordArray } from '../../../../../website/server/libs/stringUtils'; import { getMatchesByWordArray } from '../../../../../website/server/libs/stringUtils';
import bannedWords from '../../../../../website/server/libs/bannedWords'; import bannedWords from '../../../../../website/server/libs/bannedWords';
@@ -81,6 +82,10 @@ describe('POST /chat', () => {
}); });
describe('mute user', () => { describe('mute user', () => {
afterEach(() => {
member.update({'flags.chatRevoked': false});
});
it('returns an error when chat privileges are revoked when sending a message to a public guild', async () => { it('returns an error when chat privileges are revoked when sending a message to a public guild', async () => {
const userWithChatRevoked = await member.update({'flags.chatRevoked': true}); const userWithChatRevoked = await member.update({'flags.chatRevoked': true});
await expect(userWithChatRevoked.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ await expect(userWithChatRevoked.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({
@@ -89,6 +94,129 @@ describe('POST /chat', () => {
message: t('chatPrivilegesRevoked'), message: t('chatPrivilegesRevoked'),
}); });
}); });
it('does not error when chat privileges are revoked when sending a message to a private guild', async () => {
const { group, members } = await createAndPopulateGroup({
groupDetails: {
name: 'Private Guild',
type: 'guild',
privacy: 'private',
},
members: 1,
});
const privateGuildMemberWithChatsRevoked = members[0];
await privateGuildMemberWithChatsRevoked.update({'flags.chatRevoked': true});
const message = await privateGuildMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage});
expect(message.message.id).to.exist;
});
it('does not error when chat privileges are revoked when sending a message to a party', async () => {
const { group, members } = await createAndPopulateGroup({
groupDetails: {
name: 'Party',
type: 'party',
privacy: 'private',
},
members: 1,
});
const privatePartyMemberWithChatsRevoked = members[0];
await privatePartyMemberWithChatsRevoked.update({'flags.chatRevoked': true});
const message = await privatePartyMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage});
expect(message.message.id).to.exist;
});
});
describe('shadow-mute user', () => {
beforeEach(() => {
sandbox.spy(email, 'sendTxn');
sandbox.stub(IncomingWebhook.prototype, 'send');
});
afterEach(() => {
sandbox.restore();
member.update({'flags.chatShadowMuted': false});
});
it('creates a chat with flagCount already set and notifies mods when sending a message to a public guild', async () => {
const userWithChatShadowMuted = await member.update({'flags.chatShadowMuted': true});
const message = await userWithChatShadowMuted.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage});
expect(message.message.id).to.exist;
expect(message.message.flagCount).to.eql(CHAT_FLAG_FROM_SHADOW_MUTE);
// Email sent to mods
await sleep(0.5);
expect(email.sendTxn).to.be.calledOnce;
expect(email.sendTxn.args[0][1]).to.eql('shadow-muted-post-report-to-mods');
// Slack message to mods
expect(IncomingWebhook.prototype.send).to.be.calledOnce;
/* eslint-disable camelcase */
expect(IncomingWebhook.prototype.send).to.be.calledWith({
text: `@${member.auth.local.username} / ${member.profile.name} posted while shadow-muted`,
attachments: [{
fallback: 'Shadow-Muted Message',
color: 'danger',
author_name: `@${member.auth.local.username} ${member.profile.name} (${member.auth.local.email}; ${member._id})`,
title: 'Shadow-Muted Post in Test Guild',
title_link: `${BASE_URL}/groups/guild/${groupWithChat.id}`,
text: testMessage,
mrkdwn_in: [
'text',
],
}],
});
/* eslint-enable camelcase */
});
it('creates a chat with zero flagCount when sending a message to a private guild', async () => {
const { group, members } = await createAndPopulateGroup({
groupDetails: {
name: 'Private Guild',
type: 'guild',
privacy: 'private',
},
members: 1,
});
const userWithChatShadowMuted = members[0];
await userWithChatShadowMuted.update({'flags.chatShadowMuted': true});
const message = await userWithChatShadowMuted.post(`/groups/${group._id}/chat`, { message: testMessage});
expect(message.message.id).to.exist;
expect(message.message.flagCount).to.eql(0);
});
it('creates a chat with zero flagCount when sending a message to a party', async () => {
const { group, members } = await createAndPopulateGroup({
groupDetails: {
name: 'Party',
type: 'party',
privacy: 'private',
},
members: 1,
});
const userWithChatShadowMuted = members[0];
await userWithChatShadowMuted.update({'flags.chatShadowMuted': true});
const message = await userWithChatShadowMuted.post(`/groups/${group._id}/chat`, { message: testMessage});
expect(message.message.id).to.exist;
expect(message.message.flagCount).to.eql(0);
});
it('creates a chat with zero flagCount when non-shadow-muted user sends a message to a public guild', async () => {
const message = await member.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage});
expect(message.message.id).to.exist;
expect(message.message.flagCount).to.eql(0);
});
}); });
context('banned word', () => { context('banned word', () => {
@@ -235,6 +363,7 @@ describe('POST /chat', () => {
afterEach(() => { afterEach(() => {
sandbox.restore(); sandbox.restore();
user.update({'flags.chatRevoked': false});
}); });
it('errors and revokes privileges when chat message contains a banned slur', async () => { it('errors and revokes privileges when chat message contains a banned slur', async () => {
@@ -274,11 +403,6 @@ describe('POST /chat', () => {
error: 'NotAuthorized', error: 'NotAuthorized',
message: t('chatPrivilegesRevoked'), message: t('chatPrivilegesRevoked'),
}); });
// @TODO: The next test should not depend on this. We should reset the user test in a beforeEach
// Restore chat privileges to continue testing
user.flags.chatRevoked = false;
await user.update({'flags.chatRevoked': false});
}); });
it('does not allow slurs in private groups', async () => { it('does not allow slurs in private groups', async () => {
@@ -327,10 +451,6 @@ describe('POST /chat', () => {
error: 'NotAuthorized', error: 'NotAuthorized',
message: t('chatPrivilegesRevoked'), message: t('chatPrivilegesRevoked'),
}); });
// Restore chat privileges to continue testing
members[0].flags.chatRevoked = false;
await members[0].update({'flags.chatRevoked': false});
}); });
it('errors when slur is typed in mixed case', async () => { it('errors when slur is typed in mixed case', async () => {
@@ -345,42 +465,6 @@ describe('POST /chat', () => {
}); });
}); });
it('does not error when sending a message to a private guild with a user with revoked chat', async () => {
let { group, members } = await createAndPopulateGroup({
groupDetails: {
name: 'Private Guild',
type: 'guild',
privacy: 'private',
},
members: 1,
});
let privateGuildMemberWithChatsRevoked = members[0];
await privateGuildMemberWithChatsRevoked.update({'flags.chatRevoked': true});
let message = await privateGuildMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage});
expect(message.message.id).to.exist;
});
it('does not error when sending a message to a party with a user with revoked chat', async () => {
let { group, members } = await createAndPopulateGroup({
groupDetails: {
name: 'Party',
type: 'party',
privacy: 'private',
},
members: 1,
});
let privatePartyMemberWithChatsRevoked = members[0];
await privatePartyMemberWithChatsRevoked.update({'flags.chatRevoked': true});
let message = await privatePartyMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage});
expect(message.message.id).to.exist;
});
it('creates a chat', async () => { it('creates a chat', async () => {
const newMessage = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); const newMessage = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage});
const groupMessages = await user.get(`/groups/${groupWithChat._id}/chat`); const groupMessages = await user.get(`/groups/${groupWithChat._id}/chat`);
@@ -486,8 +570,13 @@ describe('POST /chat', () => {
}); });
}); });
context('chat notifications', () => {
beforeEach(() => {
member.update({newMessages: {}, notifications: []});
});
it('notifies other users of new messages for a guild', async () => { it('notifies other users of new messages for a guild', async () => {
let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage });
let memberWithNotification = await member.get('/user'); let memberWithNotification = await member.get('/user');
expect(message.message.id).to.exist; expect(message.message.id).to.exist;
@@ -507,7 +596,7 @@ describe('POST /chat', () => {
members: 1, members: 1,
}); });
let message = await groupLeader.post(`/groups/${group._id}/chat`, { message: testMessage}); let message = await groupLeader.post(`/groups/${group._id}/chat`, { message: testMessage });
let memberWithNotification = await members[0].get('/user'); let memberWithNotification = await members[0].get('/user');
expect(message.message.id).to.exist; expect(message.message.id).to.exist;
@@ -517,6 +606,21 @@ describe('POST /chat', () => {
})).to.exist; })).to.exist;
}); });
it('does not notify other users of a new message that is already hidden from shadow-muting', async () => {
await user.update({'flags.chatShadowMuted': true});
let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage });
let memberWithNotification = await member.get('/user');
await user.update({'flags.chatShadowMuted': false});
expect(message.message.id).to.exist;
expect(memberWithNotification.newMessages[`${groupWithChat._id}`]).to.not.exist;
expect(memberWithNotification.notifications.find(n => {
return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === groupWithChat._id;
})).to.not.exist;
});
});
context('Spam prevention', () => { context('Spam prevention', () => {
it('Returns an error when the user has been posting too many messages', async () => { it('Returns an error when the user has been posting too many messages', async () => {
// Post as many messages are needed to reach the spam limit // Post as many messages are needed to reach the spam limit
@@ -533,7 +637,7 @@ describe('POST /chat', () => {
}); });
it('contributor should not receive spam alert', async () => { it('contributor should not receive spam alert', async () => {
let userSocialite = await member.update({'contributor.level': SPAM_MIN_EXEMPT_CONTRIB_LEVEL, 'flags.chatRevoked': false}); let userSocialite = await member.update({'contributor.level': SPAM_MIN_EXEMPT_CONTRIB_LEVEL});
// Post 1 more message than the spam limit to ensure they do not reach the limit // Post 1 more message than the spam limit to ensure they do not reach the limit
for (let i = 0; i < SPAM_MESSAGE_LIMIT + 1; i++) { for (let i = 0; i < SPAM_MESSAGE_LIMIT + 1; i++) {

View File

@@ -105,16 +105,22 @@ describe('PUT /heroes/:heroId', () => {
it('updates chatRevoked flag', async () => { it('updates chatRevoked flag', async () => {
let hero = await generateUser(); let hero = await generateUser();
await user.put(`/hall/heroes/${hero._id}`, { await user.put(`/hall/heroes/${hero._id}`, {
flags: {chatRevoked: true}, flags: {chatRevoked: true},
}); });
await hero.sync(); await hero.sync();
expect(hero.flags.chatRevoked).to.eql(true); expect(hero.flags.chatRevoked).to.eql(true);
}); });
it('updates chatShadowMuted flag', async () => {
let hero = await generateUser();
await user.put(`/hall/heroes/${hero._id}`, {
flags: {chatShadowMuted: true},
});
await hero.sync();
expect(hero.flags.chatShadowMuted).to.eql(true);
});
it('updates contributor level', async () => { it('updates contributor level', async () => {
let hero = await generateUser({ let hero = await generateUser({
contributor: {level: 5}, contributor: {level: 5},

View File

@@ -1,8 +1,7 @@
<template lang="pug"> <template lang="pug">
div div
.mentioned-icon(v-if='isUserMentioned') .mentioned-icon(v-if='isUserMentioned')
.message-hidden(v-if='!inbox && msg.flagCount === 1 && user.contributor.admin') Message flagged once, not hidden .message-hidden(v-if='!inbox && user.contributor.admin && msg.flagCount') {{flagCountDescription}}
.message-hidden(v-if='!inbox && msg.flagCount > 1 && user.contributor.admin') Message hidden
.card-body .card-body
user-link(:userId="msg.uuid", :name="msg.user", :backer="msg.backer", :contributor="msg.contributor") user-link(:userId="msg.uuid", :name="msg.user", :backer="msg.backer", :contributor="msg.contributor")
p.time p.time
@@ -137,7 +136,8 @@ import copyIcon from 'assets/svg/copy.svg';
import likeIcon from 'assets/svg/like.svg'; import likeIcon from 'assets/svg/like.svg';
import likedIcon from 'assets/svg/liked.svg'; import likedIcon from 'assets/svg/liked.svg';
import reportIcon from 'assets/svg/report.svg'; import reportIcon from 'assets/svg/report.svg';
import {highlightUsers} from '../../libs/highlightUsers'; import { highlightUsers } from '../../libs/highlightUsers';
import { CHAT_FLAG_LIMIT_FOR_HIDING, CHAT_FLAG_FROM_SHADOW_MUTE } from '../../../common/script/constants';
export default { export default {
components: {userLink}, components: {userLink},
@@ -210,6 +210,12 @@ export default {
isMessageReported () { isMessageReported () {
return this.msg.flags && this.msg.flags[this.user.id] || this.reported; return this.msg.flags && this.msg.flags[this.user.id] || this.reported;
}, },
flagCountDescription () {
if (!this.msg.flagCount) return '';
if (this.msg.flagCount < CHAT_FLAG_LIMIT_FOR_HIDING) return 'Message flagged once, not hidden';
if (this.msg.flagCount < CHAT_FLAG_FROM_SHADOW_MUTE) return 'Message hidden';
return 'Message hidden (shadow-muted)';
},
}, },
methods: { methods: {
async like () { async like () {
@@ -274,6 +280,8 @@ export default {
}, },
}, },
mounted () { mounted () {
this.CHAT_FLAG_LIMIT_FOR_HIDING = CHAT_FLAG_LIMIT_FOR_HIDING;
this.CHAT_FLAG_FROM_SHADOW_MUTE = CHAT_FLAG_FROM_SHADOW_MUTE;
this.$emit('chat-card-mounted', this.msg.id); this.$emit('chat-card-mounted', this.msg.id);
}, },
}; };

View File

@@ -56,6 +56,11 @@
h4.expand-toggle(:class="{'open': expandAuth}", @click="expandAuth = !expandAuth") Auth h4.expand-toggle(:class="{'open': expandAuth}", @click="expandAuth = !expandAuth") Auth
div(v-if="expandAuth") div(v-if="expandAuth")
pre {{hero.auth}} pre {{hero.auth}}
.form-group
.checkbox
label
input(type='checkbox', v-if='hero.flags', v-model='hero.flags.chatShadowMuted')
strong Chat Shadow Muting On
.form-group .form-group
.checkbox .checkbox
label label
@@ -180,6 +185,7 @@ export default {
if (!this.hero.flags) { if (!this.hero.flags) {
this.hero.flags = { this.hero.flags = {
chatRevoked: false, chatRevoked: false,
chatShadowMuted: false,
}; };
} }
this.expandItems = false; this.expandItems = false;

View File

@@ -12,22 +12,29 @@
button.btn.btn-secondary.positive-icon(v-if='user._id !== this.userLoggedIn._id && userLoggedIn.inbox.blocks.indexOf(user._id) !== -1', button.btn.btn-secondary.positive-icon(v-if='user._id !== this.userLoggedIn._id && userLoggedIn.inbox.blocks.indexOf(user._id) !== -1',
@click="unblockUser()", v-b-tooltip.hover.right="$t('unblock')") @click="unblockUser()", v-b-tooltip.hover.right="$t('unblock')")
.svg-icon.positive-icon(v-html="icons.positive") .svg-icon.positive-icon(v-html="icons.positive")
button.btn.btn-secondary.positive-icon(v-if='this.userLoggedIn.contributor.admin && !adminToolsLoaded', button.btn.btn-secondary.positive-icon(v-if='this.userLoggedIn.contributor.admin',
@click="loadAdminTools()", v-b-tooltip.hover.right="'Admin - Load Tools'") @click="toggleAdminTools()", v-b-tooltip.hover.right="'Admin - Toggle Tools'")
.svg-icon.positive-icon(v-html="icons.staff") .svg-icon.positive-icon(v-html="icons.staff")
span(v-if='this.userLoggedIn.contributor.admin && adminToolsLoaded') .row.admin-profile-actions(v-if='this.userLoggedIn.contributor.admin && adminToolsLoaded')
button.btn.btn-secondary.positive-icon(v-if='!hero.flags || (hero.flags && !hero.flags.chatRevoked)', .col-12.text-right
@click="adminRevokeChat()", v-b-tooltip.hover.bottom="'Admin - Revoke Chat Privileges'") span.admin-action(v-if='!hero.flags || (hero.flags && !hero.flags.chatShadowMuted)',
.svg-icon.positive-icon(v-html="icons.megaphone") @click="adminTurnOnShadowMuting()", v-b-tooltip.hover.bottom="'Turn on Shadow Muting'")
button.btn.btn-secondary.positive-icon(v-if='hero.flags && hero.flags.chatRevoked', | shadow-mute
@click="adminReinstateChat()", v-b-tooltip.hover.bottom="'Admin - Reinstate Chat Privileges'") span.admin-action(v-if='hero.flags && hero.flags.chatShadowMuted',
.svg-icon.positive-icon(v-html="icons.challenge") @click="adminTurnOffShadowMuting()", v-b-tooltip.hover.bottom="'Turn off Shadow Muting'")
button.btn.btn-secondary.positive-icon(v-if='!hero.auth.blocked', | un-shadow-mute
@click="adminBlockUser()", v-b-tooltip.hover.right="'Admin - Ban User'") span.admin-action(v-if='!hero.flags || (hero.flags && !hero.flags.chatRevoked)',
.svg-icon.positive-icon(v-html="icons.lock") @click="adminRevokeChat()", v-b-tooltip.hover.bottom="'Revoke Chat Privileges'")
button.btn.btn-secondary.positive-icon(v-if='hero.auth.blocked', | mute
@click="adminUnblockUser()", v-b-tooltip.hover.right="'Admin - Unblock User'") span.admin-action(v-if='hero.flags && hero.flags.chatRevoked',
.svg-icon.positive-icon(v-html="icons.member") @click="adminReinstateChat()", v-b-tooltip.hover.bottom="'Reinstate Chat Privileges'")
| un-mute
span.admin-action(v-if='!hero.auth.blocked',
@click="adminBlockUser()", v-b-tooltip.hover.bottom="'Ban User'")
| ban
span.admin-action(v-if='hero.auth.blocked',
@click="adminUnblockUser()", v-b-tooltip.hover.bottom="'Un-Ban User'")
| un-ban
.row .row
.col-12 .col-12
member-details(:member="user") member-details(:member="user")
@@ -184,6 +191,16 @@
width: 100%; width: 100%;
} }
.admin-profile-actions {
margin-bottom: 3em;
.admin-action {
color: blue;
cursor: pointer;
padding: 0 1em;
}
}
.profile-actions { .profile-actions {
float: right; float: right;
margin-right: 1em; margin-right: 1em;
@@ -586,6 +603,22 @@ export default {
openSendGemsModal () { openSendGemsModal () {
this.$root.$emit('habitica::send-gems', this.user); this.$root.$emit('habitica::send-gems', this.user);
}, },
adminTurnOnShadowMuting () {
if (!this.hero.flags) {
this.hero.flags = {};
}
this.hero.flags.chatShadowMuted = true;
this.$store.dispatch('hall:updateHero', { heroDetails: this.hero });
},
adminTurnOffShadowMuting () {
if (!this.hero.flags) {
this.hero.flags = {};
}
this.hero.flags.chatShadowMuted = false;
this.$store.dispatch('hall:updateHero', { heroDetails: this.hero });
},
adminRevokeChat () { adminRevokeChat () {
if (!this.hero.flags) { if (!this.hero.flags) {
this.hero.flags = {}; this.hero.flags = {};
@@ -612,9 +645,13 @@ export default {
this.$store.dispatch('hall:updateHero', { heroDetails: this.hero }); this.$store.dispatch('hall:updateHero', { heroDetails: this.hero });
}, },
async loadAdminTools () { async toggleAdminTools () {
if (this.adminToolsLoaded) {
this.adminToolsLoaded = false;
} else {
this.hero = await this.$store.dispatch('hall:getHero', { uuid: this.user._id }); this.hero = await this.$store.dispatch('hall:getHero', { uuid: this.user._id });
this.adminToolsLoaded = true; this.adminToolsLoaded = true;
}
}, },
showAllocation () { showAllocation () {
return this.user._id === this.userLoggedIn._id && this.hasClass; return this.user._id === this.userLoggedIn._id && this.hasClass;

View File

@@ -10,6 +10,11 @@ export const MAX_SUMMARY_SIZE_FOR_GUILDS = 250;
export const MAX_SUMMARY_SIZE_FOR_CHALLENGES = 250; export const MAX_SUMMARY_SIZE_FOR_CHALLENGES = 250;
export const MIN_SHORTNAME_SIZE_FOR_CHALLENGES = 3; export const MIN_SHORTNAME_SIZE_FOR_CHALLENGES = 3;
export const CHAT_FLAG_LIMIT_FOR_HIDING = 2; // hide posts that have this many flags
export const CHAT_FLAG_FROM_MOD = 5; // a flag from a moderator counts as this many flags
export const CHAT_FLAG_FROM_SHADOW_MUTE = 10; // a shadow-muted user's post starts with this many flags
// @TODO use those constants to replace hard-coded numbers
export const SUPPORTED_SOCIAL_NETWORKS = [ export const SUPPORTED_SOCIAL_NETWORKS = [
{key: 'facebook', name: 'Facebook'}, {key: 'facebook', name: 'Facebook'},
{key: 'google', name: 'Google'}, {key: 'google', name: 'Google'},

View File

@@ -29,6 +29,9 @@ import {
SUPPORTED_SOCIAL_NETWORKS, SUPPORTED_SOCIAL_NETWORKS,
GUILDS_PER_PAGE, GUILDS_PER_PAGE,
PARTY_LIMIT_MEMBERS, PARTY_LIMIT_MEMBERS,
CHAT_FLAG_LIMIT_FOR_HIDING,
CHAT_FLAG_FROM_MOD,
CHAT_FLAG_FROM_SHADOW_MUTE,
} from './constants'; } from './constants';
api.constants = { api.constants = {
@@ -40,6 +43,9 @@ api.constants = {
SUPPORTED_SOCIAL_NETWORKS, SUPPORTED_SOCIAL_NETWORKS,
GUILDS_PER_PAGE, GUILDS_PER_PAGE,
PARTY_LIMIT_MEMBERS, PARTY_LIMIT_MEMBERS,
CHAT_FLAG_LIMIT_FOR_HIDING,
CHAT_FLAG_FROM_MOD,
CHAT_FLAG_FROM_SHADOW_MUTE,
}; };
// TODO Move these under api.constants // TODO Move these under api.constants
api.maxLevel = MAX_LEVEL; api.maxLevel = MAX_LEVEL;

View File

@@ -2,6 +2,7 @@ import { authWithHeaders } from '../../middlewares/auth';
import { model as Group } from '../../models/group'; import { model as Group } from '../../models/group';
import { model as User } from '../../models/user'; import { model as User } from '../../models/user';
import { chatModel as Chat } from '../../models/message'; import { chatModel as Chat } from '../../models/message';
import common from '../../../common';
import { import {
BadRequest, BadRequest,
NotFound, NotFound,
@@ -139,7 +140,7 @@ api.postChat = {
{name: 'AUTHOR_USERNAME', content: user.profile.name}, {name: 'AUTHOR_USERNAME', content: user.profile.name},
{name: 'AUTHOR_UUID', content: user._id}, {name: 'AUTHOR_UUID', content: user._id},
{name: 'AUTHOR_EMAIL', content: authorEmail}, {name: 'AUTHOR_EMAIL', content: authorEmail},
{name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId=${user._id}`}, {name: 'AUTHOR_MODAL_URL', content: `/profile/${user._id}`},
{name: 'GROUP_NAME', content: group.name}, {name: 'GROUP_NAME', content: group.name},
{name: 'GROUP_TYPE', content: group.type}, {name: 'GROUP_TYPE', content: group.type},
@@ -162,12 +163,12 @@ api.postChat = {
if (!group) throw new NotFound(res.t('groupNotFound')); if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.privacy !== 'private' && user.flags.chatRevoked) { if (group.privacy === 'public' && user.flags.chatRevoked) {
throw new NotAuthorized(res.t('chatPrivilegesRevoked')); throw new NotAuthorized(res.t('chatPrivilegesRevoked'));
} }
// prevent banned words being posted, except in private guilds/parties and in certain public guilds with specific topics // prevent banned words being posted, except in private guilds/parties and in certain public guilds with specific topics
if (group.privacy !== 'private' && !guildsAllowingBannedWords[group._id]) { if (group.privacy === 'public' && !guildsAllowingBannedWords[group._id]) {
let matchedBadWords = getBannedWordsFromText(req.body.message); let matchedBadWords = getBannedWordsFromText(req.body.message);
if (matchedBadWords.length > 0) { if (matchedBadWords.length > 0) {
throw new BadRequest(res.t('bannedWordUsed', {swearWordsUsed: matchedBadWords.join(', ')})); throw new BadRequest(res.t('bannedWordUsed', {swearWordsUsed: matchedBadWords.join(', ')}));
@@ -186,7 +187,43 @@ api.postChat = {
if (client) { if (client) {
client = client.replace('habitica-', ''); client = client.replace('habitica-', '');
} }
const newChatMessage = group.sendChat({message: req.body.message, user, metaData: null, client});
let flagCount = 0;
if (group.privacy === 'public' && user.flags.chatShadowMuted) {
flagCount = common.constants.CHAT_FLAG_FROM_SHADOW_MUTE;
let message = req.body.message;
// Email the mods
let authorEmail = getUserInfo(user, ['email']).email;
let groupUrl = getGroupUrl(group);
let report = [
{name: 'MESSAGE_TIME', content: (new Date()).toString()},
{name: 'MESSAGE_TEXT', content: message},
{name: 'AUTHOR_USERNAME', content: user.profile.name},
{name: 'AUTHOR_UUID', content: user._id},
{name: 'AUTHOR_EMAIL', content: authorEmail},
{name: 'AUTHOR_MODAL_URL', content: `/profile/${user._id}`},
{name: 'GROUP_NAME', content: group.name},
{name: 'GROUP_TYPE', content: group.type},
{name: 'GROUP_ID', content: group._id},
{name: 'GROUP_URL', content: groupUrl},
];
sendTxn(FLAG_REPORT_EMAILS, 'shadow-muted-post-report-to-mods', report);
// Slack the mods
slack.sendShadowMutedPostNotification({
authorEmail,
author: user,
group,
message,
});
}
const newChatMessage = group.sendChat({message: req.body.message, user, flagCount, metaData: null, client});
let toSave = [newChatMessage.save()]; let toSave = [newChatMessage.save()];
if (group.type === 'party') { if (group.type === 'party') {
@@ -372,12 +409,12 @@ api.clearChatFlags = {
{name: 'ADMIN_USERNAME', content: user.profile.name}, {name: 'ADMIN_USERNAME', content: user.profile.name},
{name: 'ADMIN_UUID', content: user._id}, {name: 'ADMIN_UUID', content: user._id},
{name: 'ADMIN_EMAIL', content: adminEmailContent}, {name: 'ADMIN_EMAIL', content: adminEmailContent},
{name: 'ADMIN_MODAL_URL', content: `/static/front/#?memberId=${user._id}`}, {name: 'ADMIN_MODAL_URL', content: `/profile/${user._id}`},
{name: 'AUTHOR_USERNAME', content: message.user}, {name: 'AUTHOR_USERNAME', content: message.user},
{name: 'AUTHOR_UUID', content: message.uuid}, {name: 'AUTHOR_UUID', content: message.uuid},
{name: 'AUTHOR_EMAIL', content: authorEmail}, {name: 'AUTHOR_EMAIL', content: authorEmail},
{name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId=${message.uuid}`}, {name: 'AUTHOR_MODAL_URL', content: `/profile/${message.uuid}`},
{name: 'GROUP_NAME', content: group.name}, {name: 'GROUP_NAME', content: group.name},
{name: 'GROUP_TYPE', content: group.type}, {name: 'GROUP_TYPE', content: group.type},

View File

@@ -145,7 +145,7 @@ api.getHeroes = {
// Note, while the following routes are called getHero / updateHero // Note, while the following routes are called getHero / updateHero
// they can be used by admins to get/update any user // they can be used by admins to get/update any user
const heroAdminFields = 'contributor balance profile.name purchased items auth flags.chatRevoked'; const heroAdminFields = 'contributor balance profile.name purchased items auth flags.chatRevoked flags.chatShadowMuted';
/** /**
* @api {get} /api/v3/hall/heroes/:heroId Get any user ("hero") given the UUID or Username * @api {get} /api/v3/hall/heroes/:heroId Get any user ("hero") given the UUID or Username
@@ -213,7 +213,10 @@ const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0};
* { * {
* "balance": 1000, * "balance": 1000,
* "auth": {"blocked": false}, * "auth": {"blocked": false},
* "flags": {"chatRevoked": true}, * "flags": {
* "chatRevoked": true,
* "chatShadowMuted": true
* },
* "purchased": {"ads": true}, * "purchased": {"ads": true},
* "contributor": { * "contributor": {
* "admin": true, * "admin": true,
@@ -286,6 +289,7 @@ api.updateHero = {
} }
if (updateData.flags && _.isBoolean(updateData.flags.chatRevoked)) hero.flags.chatRevoked = updateData.flags.chatRevoked; if (updateData.flags && _.isBoolean(updateData.flags.chatRevoked)) hero.flags.chatRevoked = updateData.flags.chatRevoked;
if (updateData.flags && _.isBoolean(updateData.flags.chatShadowMuted)) hero.flags.chatShadowMuted = updateData.flags.chatShadowMuted;
let savedHero = await hero.save(); let savedHero = await hero.save();
let heroJSON = savedHero.toJSON(); let heroJSON = savedHero.toJSON();

View File

@@ -194,6 +194,49 @@ function sendSubscriptionNotification ({
}); });
} }
function sendShadowMutedPostNotification ({
authorEmail,
author,
group,
message,
}) {
if (SKIP_FLAG_METHODS) {
return;
}
let titleLink;
let authorName;
let title = `Shadow-Muted Post in ${group.name}`;
let text = `@${author.auth.local.username} / ${author.profile.name} posted while shadow-muted`;
if (group.id === TAVERN_ID) {
titleLink = `${BASE_URL}/groups/tavern`;
} else {
titleLink = `${BASE_URL}/groups/guild/${group.id}`;
}
authorName = formatUser({
name: author.auth.local.username,
displayName: author.profile.name,
email: authorEmail,
uuid: author.id,
});
flagSlack.send({
text,
attachments: [{
fallback: 'Shadow-Muted Message',
color: 'danger',
author_name: authorName,
title,
title_link: titleLink,
text: message,
mrkdwn_in: [
'text',
],
}],
});
}
function sendSlurNotification ({ function sendSlurNotification ({
authorEmail, authorEmail,
author, author,
@@ -243,6 +286,7 @@ module.exports = {
sendFlagNotification, sendFlagNotification,
sendInboxFlagNotification, sendInboxFlagNotification,
sendSubscriptionNotification, sendSubscriptionNotification,
sendShadowMutedPostNotification,
sendSlurNotification, sendSlurNotification,
formatUser, formatUser,
}; };

View File

@@ -52,6 +52,8 @@ const LARGE_GROUP_COUNT_MESSAGE_CUTOFF = shared.constants.LARGE_GROUP_COUNT_MESS
const MAX_SUMMARY_SIZE_FOR_GUILDS = shared.constants.MAX_SUMMARY_SIZE_FOR_GUILDS; const MAX_SUMMARY_SIZE_FOR_GUILDS = shared.constants.MAX_SUMMARY_SIZE_FOR_GUILDS;
const GUILDS_PER_PAGE = shared.constants.GUILDS_PER_PAGE; const GUILDS_PER_PAGE = shared.constants.GUILDS_PER_PAGE;
const CHAT_FLAG_LIMIT_FOR_HIDING = shared.constants.CHAT_FLAG_LIMIT_FOR_HIDING;
const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true'; const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true';
const CRON_SEMI_SAFE_MODE = nconf.get('CRON_SEMI_SAFE_MODE') === 'true'; const CRON_SEMI_SAFE_MODE = nconf.get('CRON_SEMI_SAFE_MODE') === 'true';
const MAX_UPDATE_RETRIES = 5; const MAX_UPDATE_RETRIES = 5;
@@ -367,8 +369,8 @@ schema.statics.toJSONCleanChat = async function groupToJSONCleanChat (group, use
chatMsg.flags = {}; chatMsg.flags = {};
if (chatMsg._meta) chatMsg._meta = undefined; if (chatMsg._meta) chatMsg._meta = undefined;
// Messages with >= 2 flags are hidden to non admins and non authors // Messages with too many flags are hidden to non-admins and non-authors
if (user._id !== chatMsg.uuid && chatMsg.flagCount >= 2) return undefined; if (user._id !== chatMsg.uuid && chatMsg.flagCount >= CHAT_FLAG_LIMIT_FOR_HIDING) return undefined;
} }
return chatMsg; return chatMsg;
@@ -510,8 +512,8 @@ schema.methods.getMemberCount = async function getMemberCount () {
}; };
schema.methods.sendChat = function sendChat (options = {}) { schema.methods.sendChat = function sendChat (options = {}) {
const {message, user, metaData, client, info = {}} = options; const {message, user, metaData, client, flagCount = 0, info = {}} = options;
let newMessage = messageDefaults(message, user, client, info); let newMessage = messageDefaults(message, user, client, flagCount, info);
let newChatMessage = new Chat(); let newChatMessage = new Chat();
newChatMessage = Object.assign(newChatMessage, newMessage); newChatMessage = Object.assign(newChatMessage, newMessage);
newChatMessage.groupId = this._id; newChatMessage.groupId = this._id;
@@ -528,8 +530,11 @@ schema.methods.sendChat = function sendChat (options = {}) {
// newChatMessage is possibly returned // newChatMessage is possibly returned
this.sendGroupChatReceivedWebhooks(newChatMessage); this.sendGroupChatReceivedWebhooks(newChatMessage);
// do not send notifications for guilds with more than 5000 users and for the tavern // do not send notifications for:
if (NO_CHAT_NOTIFICATIONS.indexOf(this._id) !== -1 || this.memberCount > LARGE_GROUP_COUNT_MESSAGE_CUTOFF) { // - groups that never send notifications (e.g., Tavern)
// - groups with very many users
// - messages that have already been flagged to hide them
if (NO_CHAT_NOTIFICATIONS.indexOf(this._id) !== -1 || this.memberCount > LARGE_GROUP_COUNT_MESSAGE_CUTOFF || newChatMessage.flagCount >= CHAT_FLAG_LIMIT_FOR_HIDING) {
return newChatMessage; return newChatMessage;
} }

View File

@@ -108,7 +108,7 @@ export function setUserStyles (newMessage, user) {
newMessage.markModified('userStyles contributor'); newMessage.markModified('userStyles contributor');
} }
export function messageDefaults (msg, user, client, info = {}) { export function messageDefaults (msg, user, client, flagCount = 0, info = {}) {
const id = uuid(); const id = uuid();
const message = { const message = {
id, id,
@@ -118,7 +118,7 @@ export function messageDefaults (msg, user, client, info = {}) {
timestamp: Number(new Date()), timestamp: Number(new Date()),
likes: {}, likes: {},
flags: {}, flags: {},
flagCount: 0, flagCount,
client, client,
}; };

View File

@@ -230,6 +230,7 @@ let schema = new Schema({
return {}; return {};
}}, }},
chatRevoked: Boolean, chatRevoked: Boolean,
chatShadowMuted: Boolean,
// Used to track the status of recapture emails sent to each user, // Used to track the status of recapture emails sent to each user,
// can be 0 - no email sent - 1, 2, 3 or 4 - 4 means no more email will be sent to the user // can be 0 - no email sent - 1, 2, 3 or 4 - 4 means no more email will be sent to the user
recaptureEmailsPhase: {$type: Number, default: 0}, recaptureEmailsPhase: {$type: Number, default: 0},