diff --git a/test/api/unit/middlewares/auth.test.js b/test/api/unit/middlewares/auth.test.js index 25ba3d17ea..830af9be04 100644 --- a/test/api/unit/middlewares/auth.test.js +++ b/test/api/unit/middlewares/auth.test.js @@ -16,7 +16,7 @@ describe('auth middleware', () => { describe('auth with headers', () => { it('allows to specify a list of user field that we do not want to load', (done) => { const authWithHeaders = authWithHeadersFactory({ - userFieldsToExclude: ['items', 'flags', 'auth.timestamps'], + userFieldsToExclude: ['items'], }); req.headers['x-api-user'] = user._id; @@ -27,11 +27,34 @@ describe('auth middleware', () => { const userToJSON = res.locals.user.toJSON(); expect(userToJSON.items).to.not.exist; - expect(userToJSON.flags).to.not.exist; - expect(userToJSON.auth.timestamps).to.not.exist; + expect(userToJSON.auth).to.exist; + + done(); + }); + }); + + it('makes sure some fields are always included', (done) => { + const authWithHeaders = authWithHeadersFactory({ + userFieldsToExclude: [ + 'items', 'auth.timestamps', + 'preferences', 'notifications', '_id', 'flags', 'auth', // these are always loaded + ], + }); + + req.headers['x-api-user'] = user._id; + req.headers['x-api-key'] = user.apiToken; + + authWithHeaders(req, res, (err) => { + if (err) return done(err); + + const userToJSON = res.locals.user.toJSON(); + expect(userToJSON.items).to.not.exist; + expect(userToJSON.auth.timestamps).to.exist; expect(userToJSON.auth).to.exist; expect(userToJSON.notifications).to.exist; expect(userToJSON.preferences).to.exist; + expect(userToJSON._id).to.exist; + expect(userToJSON.flags).to.exist; done(); }); diff --git a/test/api/unit/models/group.test.js b/test/api/unit/models/group.test.js index 45a100afd7..7d120c055a 100644 --- a/test/api/unit/models/group.test.js +++ b/test/api/unit/models/group.test.js @@ -1,7 +1,7 @@ import moment from 'moment'; import { v4 as generateUUID } from 'uuid'; import validator from 'validator'; -import { sleep } from '../../../helpers/api-unit.helper'; +import { sleep, translationCheck } from '../../../helpers/api-unit.helper'; import { SPAM_MESSAGE_LIMIT, SPAM_MIN_EXEMPT_CONTRIB_LEVEL, @@ -271,7 +271,16 @@ describe('Group Model', () => { party = await Group.findOne({_id: party._id}); expect(Group.prototype.sendChat).to.be.calledOnce; - expect(Group.prototype.sendChat).to.be.calledWith('`Participating Member attacks Wailing Whale for 5.0 damage.` `Wailing Whale attacks party for 7.5 damage.`'); + expect(Group.prototype.sendChat).to.be.calledWith({ + message: '`Participating Member attacks Wailing Whale for 5.0 damage. Wailing Whale attacks party for 7.5 damage.`', + info: { + bossDamage: '7.5', + quest: 'whale', + type: 'boss_damage', + user: 'Participating Member', + userDamage: '5.0', + }, + }); }); it('applies damage only to participating members of party', async () => { @@ -344,7 +353,10 @@ describe('Group Model', () => { party = await Group.findOne({_id: party._id}); expect(Group.prototype.sendChat).to.be.calledTwice; - expect(Group.prototype.sendChat).to.be.calledWith('`You defeated Wailing Whale! Questing party members receive the rewards of victory.`'); + expect(Group.prototype.sendChat).to.be.calledWith({ + message: '`You defeated Wailing Whale! Questing party members receive the rewards of victory.`', + info: { quest: 'whale', type: 'boss_defeated' }, + }); }); it('calls finishQuest when boss has <= 0 hp', async () => { @@ -387,7 +399,10 @@ describe('Group Model', () => { party = await Group.findOne({_id: party._id}); - expect(Group.prototype.sendChat).to.be.calledWith(quest.boss.rage.effect('en')); + expect(Group.prototype.sendChat).to.be.calledWith({ + message: quest.boss.rage.effect('en'), + info: { quest: 'trex_undead', type: 'boss_rage' }, + }); expect(party.quest.progress.hp).to.eql(383.5); expect(party.quest.progress.rage).to.eql(0); }); @@ -437,7 +452,10 @@ describe('Group Model', () => { party = await Group.findOne({_id: party._id}); - expect(Group.prototype.sendChat).to.be.calledWith(quest.boss.rage.effect('en')); + expect(Group.prototype.sendChat).to.be.calledWith({ + message: quest.boss.rage.effect('en'), + info: { quest: 'lostMasterclasser4', type: 'boss_rage' }, + }); expect(party.quest.progress.rage).to.eql(0); let drainedUser = await User.findById(participatingMember._id); @@ -488,7 +506,15 @@ describe('Group Model', () => { party = await Group.findOne({_id: party._id}); expect(Group.prototype.sendChat).to.be.calledOnce; - expect(Group.prototype.sendChat).to.be.calledWith('`Participating Member found 5 Bars of Soap.`'); + expect(Group.prototype.sendChat).to.be.calledWith({ + message: '`Participating Member found 5 Bars of Soap.`', + info: { + items: { soapBars: 5 }, + quest: 'atom1', + type: 'user_found_items', + user: 'Participating Member', + }, + }); }); it('sends a chat message if no progress is made', async () => { @@ -499,7 +525,15 @@ describe('Group Model', () => { party = await Group.findOne({_id: party._id}); expect(Group.prototype.sendChat).to.be.calledOnce; - expect(Group.prototype.sendChat).to.be.calledWith('`Participating Member found 0 Bars of Soap.`'); + expect(Group.prototype.sendChat).to.be.calledWith({ + message: '`Participating Member found 0 Bars of Soap.`', + info: { + items: { soapBars: 0 }, + quest: 'atom1', + type: 'user_found_items', + user: 'Participating Member', + }, + }); }); it('sends a chat message if no progress is made on quest with multiple items', async () => { @@ -516,9 +550,15 @@ describe('Group Model', () => { party = await Group.findOne({_id: party._id}); expect(Group.prototype.sendChat).to.be.calledOnce; - expect(Group.prototype.sendChat).to.be.calledWithMatch(/`Participating Member found/); - expect(Group.prototype.sendChat).to.be.calledWithMatch(/0 Blue Fins/); - expect(Group.prototype.sendChat).to.be.calledWithMatch(/0 Fire Coral/); + expect(Group.prototype.sendChat).to.be.calledWith({ + message: '`Participating Member found 0 Fire Coral, 0 Blue Fins.`', + info: { + items: { blueFins: 0, fireCoral: 0 }, + quest: 'dilatoryDistress1', + type: 'user_found_items', + user: 'Participating Member', + }, + }); }); it('handles collection quests with multiple items', async () => { @@ -535,8 +575,14 @@ describe('Group Model', () => { party = await Group.findOne({_id: party._id}); expect(Group.prototype.sendChat).to.be.calledOnce; - expect(Group.prototype.sendChat).to.be.calledWithMatch(/`Participating Member found/); - expect(Group.prototype.sendChat).to.be.calledWithMatch(/\d* (Tracks|Broken Twigs)/); + expect(Group.prototype.sendChat).to.be.calledWithMatch({ + message: sinon.match(/`Participating Member found/).and(sinon.match(/\d* (Tracks|Broken Twigs)/)), + info: { + quest: 'evilsanta2', + type: 'user_found_items', + user: 'Participating Member', + }, + }); }); it('sends message about victory', async () => { @@ -547,7 +593,10 @@ describe('Group Model', () => { party = await Group.findOne({_id: party._id}); expect(Group.prototype.sendChat).to.be.calledTwice; - expect(Group.prototype.sendChat).to.be.calledWith('`All items found! Party has received their rewards.`'); + expect(Group.prototype.sendChat).to.be.calledWith({ + message: '`All items found! Party has received their rewards.`', + info: { type: 'all_items_found' }, + }); }); it('calls finishQuest when all items are found', async () => { @@ -718,6 +767,258 @@ describe('Group Model', () => { expect(res.t).to.not.be.called; }); }); + + describe('translateSystemMessages', () => { + it('translate quest_start', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'quest_start', + quest: 'basilist', + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + + it('translate boss_damage', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'boss_damage', + user: questLeader.profile.name, + quest: 'basilist', + userDamage: 15.3, + bossDamage: 3.7, + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + + it('translate boss_dont_attack', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'boss_dont_attack', + user: questLeader.profile.name, + quest: 'basilist', + userDamage: 15.3, + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + + it('translate boss_rage', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'boss_rage', + quest: 'lostMasterclasser3', + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + + it('translate boss_defeated', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'boss_defeated', + quest: 'lostMasterclasser3', + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + + it('translate user_found_items', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'user_found_items', + user: questLeader.profile.name, + quest: 'lostMasterclasser1', + items: { + ancientTome: 3, + forbiddenTome: 2, + hiddenTome: 1, + }, + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + + it('translate all_items_found', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'all_items_found', + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + + it('translate spell_cast_party', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'spell_cast_party', + user: questLeader.profile.name, + class: 'wizard', + spell: 'earth', + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + + it('translate spell_cast_user', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'spell_cast_user', + user: questLeader.profile.name, + class: 'special', + spell: 'snowball', + target: participatingMember.profile.name, + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + + it('translate quest_cancel', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'quest_cancel', + user: questLeader.profile.name, + quest: 'basilist', + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + + it('translate quest_abort', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'quest_abort', + user: questLeader.profile.name, + quest: 'basilist', + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + + it('translate tavern_quest_completed', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'tavern_quest_completed', + quest: 'stressbeast', + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + + it('translate tavern_boss_rage_tired', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'tavern_boss_rage_tired', + quest: 'stressbeast', + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + + it('translate tavern_boss_rage', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'tavern_boss_rage', + quest: 'dysheartener', + scene: 'market', + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + + it('translate tavern_boss_desperation', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'tavern_boss_desperation', + quest: 'stressbeast', + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + + it('translate claim_task', async () => { + questLeader.preferences.language = 'en'; + party.chat = [{ + info: { + type: 'claim_task', + user: questLeader.profile.name, + task: 'Feed the pet', + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + translationCheck(toJSON.chat[0].text); + }); + }); + + describe('toJSONCleanChat', () => { + it('shows messages with 1 flag to non-admins', async () => { + party.chat = [{ + flagCount: 1, + info: { + type: 'quest_start', + quest: 'basilist', + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + expect(toJSON.chat.length).to.equal(1); + }); + + it('shows messages with >= 2 flag to admins', async () => { + party.chat = [{ + flagCount: 3, + info: { + type: 'quest_start', + quest: 'basilist', + }, + }]; + const admin = new User({'contributor.admin': true}); + let toJSON = await Group.toJSONCleanChat(party, admin); + expect(toJSON.chat.length).to.equal(1); + }); + + it('doesn\'t show flagged messages to non-admins', async () => { + party.chat = [{ + flagCount: 3, + info: { + type: 'quest_start', + quest: 'basilist', + }, + }]; + let toJSON = await Group.toJSONCleanChat(party, questLeader); + expect(toJSON.chat.length).to.equal(0); + }); + }); }); context('Instance Methods', () => { @@ -1007,20 +1308,22 @@ describe('Group Model', () => { }); it('formats message', () => { - const chatMessage = party.sendChat('a new message', { - _id: 'user-id', - profile: { name: 'user name' }, - contributor: { - toObject () { - return 'contributor object'; + const chatMessage = party.sendChat({ + message: 'a new message', user: { + _id: 'user-id', + profile: { name: 'user name' }, + contributor: { + toObject () { + return 'contributor object'; + }, }, - }, - backer: { - toObject () { - return 'backer object'; + backer: { + toObject () { + return 'backer object'; + }, }, - }, - }); + }} + ); const chat = chatMessage; @@ -1037,7 +1340,7 @@ describe('Group Model', () => { }); it('formats message as system if no user is passed in', () => { - const chat = party.sendChat('a system message'); + const chat = party.sendChat({message: 'a system message'}); expect(chat.text).to.eql('a system message'); expect(validator.isUUID(chat.id)).to.eql(true); @@ -1052,7 +1355,7 @@ describe('Group Model', () => { }); it('updates users about new messages in party', () => { - party.sendChat('message'); + party.sendChat({message: 'message'}); expect(User.update).to.be.calledOnce; expect(User.update).to.be.calledWithMatch({ @@ -1066,7 +1369,7 @@ describe('Group Model', () => { type: 'guild', }); - group.sendChat('message'); + group.sendChat({message: 'message'}); expect(User.update).to.be.calledOnce; expect(User.update).to.be.calledWithMatch({ @@ -1076,7 +1379,7 @@ describe('Group Model', () => { }); it('does not send update to user that sent the message', () => { - party.sendChat('message', {_id: 'user-id', profile: { name: 'user' }}); + party.sendChat({message: 'message', user: {_id: 'user-id', profile: { name: 'user' }}}); expect(User.update).to.be.calledOnce; expect(User.update).to.be.calledWithMatch({ @@ -1088,7 +1391,7 @@ describe('Group Model', () => { it('skips sending new message notification for guilds with > 5000 members', () => { party.memberCount = 5001; - party.sendChat('message'); + party.sendChat({message: 'message'}); expect(User.update).to.not.be.called; }); @@ -1096,7 +1399,7 @@ describe('Group Model', () => { it('skips sending messages to the tavern', () => { party._id = TAVERN_ID; - party.sendChat('message'); + party.sendChat({message: 'message'}); expect(User.update).to.not.be.called; }); @@ -1928,7 +2231,7 @@ describe('Group Model', () => { await guild.save(); - const groupMessage = guild.sendChat('Test message.'); + const groupMessage = guild.sendChat({message: 'Test message.'}); await groupMessage.save(); await sleep(); diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js index 834be404b6..bbfb871a63 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js @@ -127,7 +127,13 @@ describe('POST /groups/:groupId/quests/abort', () => { members: {}, }); expect(Group.prototype.sendChat).to.be.calledOnce; - expect(Group.prototype.sendChat).to.be.calledWithMatch(/aborted the party quest Wail of the Whale.`/); + expect(Group.prototype.sendChat).to.be.calledWithMatch({ + message: sinon.match(/aborted the party quest Wail of the Whale.`/), + info: { + quest: 'whale', + type: 'quest_abort', + }, + }); stub.restore(); }); diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js index a8095a3cee..4032c36306 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js @@ -141,7 +141,14 @@ describe('POST /groups/:groupId/quests/cancel', () => { members: {}, }); expect(Group.prototype.sendChat).to.be.calledOnce; - expect(Group.prototype.sendChat).to.be.calledWithMatch(/cancelled the party quest Wail of the Whale.`/); + expect(Group.prototype.sendChat).to.be.calledWithMatch({ + message: sinon.match(/cancelled the party quest Wail of the Whale.`/), + info: { + quest: 'whale', + type: 'quest_cancel', + user: sinon.match.any, + }, + }); stub.restore(); }); diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 9b8d13d420..b43389bf21 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -8,6 +8,7 @@ import mongo from './mongo'; // eslint-disable-line import moment from 'moment'; import i18n from '../../website/common/script/i18n'; import * as Tasks from '../../website/server/models/task'; +export { translationCheck } from './translate'; afterEach((done) => { sandbox.restore(); diff --git a/test/helpers/translate.js b/test/helpers/translate.js index e146c27a71..c12e97c147 100644 --- a/test/helpers/translate.js +++ b/test/helpers/translate.js @@ -16,3 +16,9 @@ export function translate (key, variables, language) { return translatedString; } + +export function translationCheck (translatedString) { + expect(translatedString).to.not.be.empty; + expect(translatedString).to.not.eql(STRING_ERROR_MSG); + expect(translatedString).to.not.match(STRING_DOES_NOT_EXIST_MSG); +} diff --git a/website/common/locales/en/character.json b/website/common/locales/en/character.json index 518e2a4d18..79a845437a 100644 --- a/website/common/locales/en/character.json +++ b/website/common/locales/en/character.json @@ -175,6 +175,8 @@ "youCast": "You cast <%= spell %>.", "youCastTarget": "You cast <%= spell %> on <%= target %>.", "youCastParty": "You cast <%= spell %> for the party.", + "chatCastSpellParty": "<%= username %> casts <%= spell %> for the party.", + "chatCastSpellUser": "<%= username %> casts <%= spell %> on <%= target %>.", "critBonus": "Critical Hit! Bonus: ", "gainedGold": "You gained some Gold", "gainedMana": "You gained some Mana", diff --git a/website/common/locales/en/quests.json b/website/common/locales/en/quests.json index 84471c052e..4103252374 100644 --- a/website/common/locales/en/quests.json +++ b/website/common/locales/en/quests.json @@ -127,5 +127,14 @@ "bossHealth": "<%= currentHealth %> / <%= maxHealth %> Health", "rageAttack": "Rage Attack:", "bossRage": "<%= currentRage %> / <%= maxRage %> Rage", - "rageStrikes": "Rage Strikes" + "rageStrikes": "Rage Strikes", + "chatQuestStarted": "Your quest, <%= questName %>, has started.", + "chatBossDamage": "<%= username %> attacks <%= bossName %> for <%= userDamage %> damage. <%= bossName %> attacks party for <%= bossDamage %> damage.", + "chatBossDontAttack": "<%= username %> attacks <%= bossName %> for <%= userDamage %> damage. <%= bossName %> does not attack, because it respects the fact that there are some bugs post-maintenance, and it doesn't want to hurt anyone unfairly. It will continue its rampage soon!", + "chatBossDefeated": "You defeated <%= bossName %>! Questing party members receive the rewards of victory.", + "chatFindItems": "<%= username %> found <%= items %>.", + "chatItemQuestFinish": "All items found! Party has received their rewards.", + "chatQuestAborted": "<%= username %> aborted the party quest <%= questName %>.", + "chatQuestCancelled": "<%= username %> cancelled the party quest <%= questName %>.", + "tavernBossTired": "<%= bossName %> tries to unleash <%= rageName %> but is too tired." } diff --git a/website/server/controllers/api-v3/challenges.js b/website/server/controllers/api-v3/challenges.js index a60f9502b7..214f3baf78 100644 --- a/website/server/controllers/api-v3/challenges.js +++ b/website/server/controllers/api-v3/challenges.js @@ -442,7 +442,8 @@ api.getGroupChallenges = { method: 'GET', url: '/challenges/groups/:groupId', middlewares: [authWithHeaders({ - userFieldsToInclude: ['_id', 'party', 'guilds'], + // Some fields (including _id) are always loaded (see middlewares/auth) + userFieldsToInclude: ['party', 'guilds'], // Some fields are always loaded (see middlewares/auth) })], async handler (req, res) { let user = res.locals.user; diff --git a/website/server/controllers/api-v3/chat.js b/website/server/controllers/api-v3/chat.js index 84703ac0da..c2d6c787bc 100644 --- a/website/server/controllers/api-v3/chat.js +++ b/website/server/controllers/api-v3/chat.js @@ -186,7 +186,7 @@ api.postChat = { if (client) { client = client.replace('habitica-', ''); } - const newChatMessage = group.sendChat(req.body.message, user, null, client); + const newChatMessage = group.sendChat({message: req.body.message, user, metaData: null, client}); let toSave = [newChatMessage.save()]; if (group.type === 'party') { diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index eed4aff4cb..5b0aebe079 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -375,7 +375,8 @@ api.getGroup = { method: 'GET', url: '/groups/:groupId', middlewares: [authWithHeaders({ - userFieldsToInclude: ['_id', 'party', 'guilds', 'contributor'], + // Some fields (including _id, preferences) are always loaded (see middlewares/auth) + userFieldsToInclude: ['party', 'guilds', 'contributor'], })], async handler (req, res) { let user = res.locals.user; diff --git a/website/server/controllers/api-v3/quests.js b/website/server/controllers/api-v3/quests.js index c8b2b25ebe..d0f96b8a80 100644 --- a/website/server/controllers/api-v3/quests.js +++ b/website/server/controllers/api-v3/quests.js @@ -371,7 +371,14 @@ api.cancelQuest = { if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest')); let questName = questScrolls[group.quest.key].text('en'); - const newChatMessage = group.sendChat(`\`${user.profile.name} cancelled the party quest ${questName}.\``); + const newChatMessage = group.sendChat({ + message: `\`${user.profile.name} cancelled the party quest ${questName}.\``, + info: { + type: 'quest_cancel', + user: user.profile.name, + quest: group.quest.key, + }, + }); group.quest = Group.cleanGroupQuest(); group.markModified('quest'); @@ -427,7 +434,14 @@ api.abortQuest = { if (user._id !== group.leader && user._id !== group.quest.leader) throw new NotAuthorized(res.t('onlyLeaderAbortQuest')); let questName = questScrolls[group.quest.key].text('en'); - const newChatMessage = group.sendChat(`\`${user.profile.name} aborted the party quest ${questName}.\``); + const newChatMessage = group.sendChat({ + message: `\`${common.i18n.t('chatQuestAborted', {username: user.profile.name, questName}, 'en')}\``, + info: { + type: 'quest_abort', + user: user.profile.name, + quest: group.quest.key, + }, + }); await newChatMessage.save(); let memberUpdates = User.update({ diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index 524669d3a1..c7035f0f99 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -285,7 +285,8 @@ api.getUserTasks = { method: 'GET', url: '/tasks/user', middlewares: [authWithHeaders({ - userFieldsToInclude: ['_id', 'tasksOrder', 'preferences'], + // Some fields (including _id, preferences) are always loaded (see middlewares/auth) + userFieldsToInclude: ['tasksOrder'], })], async handler (req, res) { let types = Tasks.tasksTypes.map(type => `${type}s`); diff --git a/website/server/controllers/api-v3/tasks/groups.js b/website/server/controllers/api-v3/tasks/groups.js index 93ce3cb1eb..aee836dafd 100644 --- a/website/server/controllers/api-v3/tasks/groups.js +++ b/website/server/controllers/api-v3/tasks/groups.js @@ -204,7 +204,14 @@ api.assignTask = { // User is claiming the task if (user._id === assignedUserId) { let message = res.t('userIsClamingTask', {username: user.profile.name, task: task.text}); - const newMessage = group.sendChat(message); + const newMessage = group.sendChat({ + message, + info: { + type: 'claim_task', + user: user.profile.name, + task: task.text, + }, + }); promises.push(newMessage.save()); } else { const taskText = task.text; diff --git a/website/server/libs/chat/group-chat.js b/website/server/libs/chat/group-chat.js index 72edb4d52d..92123b6cf2 100644 --- a/website/server/libs/chat/group-chat.js +++ b/website/server/libs/chat/group-chat.js @@ -1,6 +1,10 @@ import { chatModel as Chat } from '../../models/message'; +import shared from '../../../common'; +import _ from 'lodash'; import { MAX_CHAT_COUNT, MAX_SUBBED_GROUP_CHAT_COUNT } from '../../models/group'; +const questScrolls = shared.content.quests; + // @TODO: Don't use this method when the group can be saved. export async function getGroupChat (group) { const maxChatCount = group.isSubscribed() ? MAX_SUBBED_GROUP_CHAT_COUNT : MAX_CHAT_COUNT; @@ -22,3 +26,82 @@ export async function getGroupChat (group) { return previous; }, []); } + +export function translateMessage (lang, info) { + let msg; + let foundText = ''; + let spells = shared.content.spells; + let quests = shared.content.quests; + + switch (info.type) { + case 'quest_start': + msg = `\`${shared.i18n.t('chatQuestStarted', {questName: questScrolls[info.quest].text(lang)}, lang)}\``; + break; + + case 'boss_damage': + msg = `\`${shared.i18n.t('chatBossDamage', {username: info.user, bossName: questScrolls[info.quest].boss.name(lang), userDamage: info.userDamage, bossDamage: info.bossDamage}, lang)}\``; + break; + + case 'boss_dont_attack': + msg = `\`${shared.i18n.t('chatBossDontAttack', {username: info.user, bossName: questScrolls[info.quest].boss.name(lang), userDamage: info.userDamage}, lang)}\``; + break; + + case 'boss_rage': + msg = `\`${questScrolls[info.quest].boss.rage.effect(lang)}\``; + break; + + case 'boss_defeated': + msg = `\`${shared.i18n.t('chatBossDefeated', {bossName: questScrolls[info.quest].boss.name(lang)}, lang)}\``; + break; + + case 'user_found_items': + foundText = _.reduce(info.items, (m, v, k) => { + m.push(`${v} ${questScrolls[info.quest].collect[k].text(lang)}`); + return m; + }, []).join(', '); + msg = `\`${shared.i18n.t('chatFindItems', {username: info.user, items: foundText}, lang)}\``; + break; + + case 'all_items_found': + msg = `\`${shared.i18n.t('chatItemQuestFinish', lang)}\``; + break; + + case 'spell_cast_party': + msg = `\`${shared.i18n.t('chatCastSpellParty', {username: info.user, spell: spells[info.class][info.spell].text(lang)}, lang)}\``; + break; + + case 'spell_cast_user': + msg = `\`${shared.i18n.t('chatCastSpellUser', {username: info.user, spell: spells[info.class][info.spell].text(lang), target: info.target}, lang)}\``; + break; + + case 'quest_cancel': + msg = `\`${shared.i18n.t('chatQuestCancelled', {username: info.user, questName: questScrolls[info.quest].text(lang)}, lang)}\``; + break; + + case 'quest_abort': + msg = `\`${shared.i18n.t('chatQuestAborted', {username: info.user, questName: questScrolls[info.quest].text(lang)}, lang)}\``; + break; + + case 'tavern_quest_completed': + msg = `\`${quests[info.quest].completionChat(lang)}\``; + break; + + case 'tavern_boss_rage_tired': + msg = `\`${shared.i18n.t('tavernBossTired', {rageName: quests[info.quest].boss.rage.title(lang), bossName: quests[info.quest].boss.name(lang)}, lang)}\``; + break; + + case 'tavern_boss_rage': + msg = `\`${quests[info.quest].boss.rage[info.scene](lang)}\``; + break; + + case 'tavern_boss_desperation': + msg = `\`${quests[info.quest].boss.desperation.text(lang)}\``; + break; + + case 'claim_task': + msg = `${shared.i18n.t('userIsClamingTask', {username: info.user, task: info.task}, lang)}`; + break; + } + + return msg; +} diff --git a/website/server/libs/spells.js b/website/server/libs/spells.js index 7315b87f08..8c46fcb1ab 100644 --- a/website/server/libs/spells.js +++ b/website/server/libs/spells.js @@ -197,9 +197,30 @@ async function castSpell (req, res, {isV3 = false}) { }); if (party && !spell.silent) { - let message = `\`${user.profile.name} casts ${spell.text()}${targetType === 'user' ? ` on ${partyMembers.profile.name}` : ' for the party'}.\``; - const newChatMessage = party.sendChat(message); - await newChatMessage.save(); + if (targetType === 'user') { + const newChatMessage = party.sendChat({ + message: `\`${common.i18n.t('chatCastSpellUser', {username: user.profile.name, spell: spell.text(), target: partyMembers.profile.name}, 'en')}\``, + info: { + type: 'spell_cast_user', + user: user.profile.name, + class: klass, + spell: spellId, + target: partyMembers.profile.name, + }, + }); + await newChatMessage.save(); + } else { + const newChatMessage = party.sendChat({ + message: `\`${common.i18n.t('chatCastSpellParty', {username: user.profile.name, spell: spell.text()}, 'en')}\``, + info: { + type: 'spell_cast_party', + user: user.profile.name, + class: klass, + spell: spellId, + }, + }); + await newChatMessage.save(); + } } } } diff --git a/website/server/middlewares/auth.js b/website/server/middlewares/auth.js index f4c301acf9..c0881008af 100644 --- a/website/server/middlewares/auth.js +++ b/website/server/middlewares/auth.js @@ -9,22 +9,27 @@ import url from 'url'; import gcpStackdriverTracer from '../libs/gcpTraceAgent'; const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL'); +const USER_FIELDS_ALWAYS_LOADED = ['_id', 'notifications', 'preferences', 'auth', 'flags']; function getUserFields (options, req) { // A list of user fields that aren't needed for the route and are not loaded from the db. // Must be an array if (options.userFieldsToExclude) { - return options.userFieldsToExclude.map(field => { - return `-${field}`; // -${field} means exclude ${field} in mongodb - }).join(' '); + return options.userFieldsToExclude + .filter(field => { + return !USER_FIELDS_ALWAYS_LOADED.find(fieldToInclude => field.startsWith(fieldToInclude)); + }) + .map(field => { + return `-${field}`; // -${field} means exclude ${field} in mongodb + }) + .join(' '); } if (options.userFieldsToInclude) { - return options.userFieldsToInclude.join(' '); + return options.userFieldsToInclude.concat(USER_FIELDS_ALWAYS_LOADED).join(' '); } // Allows GET requests to /user to specify a list of user fields to return instead of the entire doc - // Notifications are always included const urlPath = url.parse(req.url).pathname; const userFields = req.query.userFields; if (!userFields || urlPath !== '/user') return ''; @@ -32,7 +37,7 @@ function getUserFields (options, req) { const userFieldOptions = userFields.split(','); if (userFieldOptions.length === 0) return ''; - return `notifications ${userFieldOptions.join(' ')}`; + return userFieldOptions.concat(USER_FIELDS_ALWAYS_LOADED).join(' '); } // Make sure stackdriver traces are storing the user id diff --git a/website/server/models/group.js b/website/server/models/group.js index fee789a878..636eb3ca0e 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -37,7 +37,7 @@ import { } from './subscriptionPlan'; import amazonPayments from '../libs/payments/amazon'; import stripePayments from '../libs/payments/stripe'; -import { getGroupChat } from '../libs/chat/group-chat'; +import { getGroupChat, translateMessage } from '../libs/chat/group-chat'; import { model as UserNotification } from './userNotification'; const questScrolls = shared.content.quests; @@ -344,26 +344,38 @@ schema.statics.toJSONCleanChat = async function groupToJSONCleanChat (group, use await getGroupChat(group); } - let toJSON = group.toJSON(); + const groupToJson = group.toJSON(); + const userLang = user.preferences.language; - if (!user.contributor.admin) { - _.remove(toJSON.chat, chatMsg => { - chatMsg.flags = {}; - if (chatMsg._meta) chatMsg._meta = undefined; - return user._id !== chatMsg.uuid && chatMsg.flagCount >= 2; - }); - } + groupToJson.chat = groupToJson.chat + .map(chatMsg => { + // Translate system messages + if (!_.isEmpty(chatMsg.info)) { + chatMsg.text = translateMessage(userLang, chatMsg.info); + } - // Convert to timestamps because Android expects it - toJSON.chat.forEach(chat => { - // old chats are saved with a numeric timestamp - // new chats use `Date` which then has to be converted to the numeric timestamp - if (chat.timestamp && chat.timestamp.getTime) { - chat.timestamp = chat.timestamp.getTime(); - } - }); + // Convert to timestamps because Android expects it + // old chats are saved with a numeric timestamp + // new chats use `Date` which then has to be converted to the numeric timestamp + if (chatMsg.timestamp && chatMsg.timestamp.getTime) { + chatMsg.timestamp = chatMsg.timestamp.getTime(); + } - return toJSON; + if (!user.contributor.admin) { + // Flags are hidden to non admins + chatMsg.flags = {}; + if (chatMsg._meta) chatMsg._meta = undefined; + + // Messages with >= 2 flags are hidden to non admins and non authors + if (user._id !== chatMsg.uuid && chatMsg.flagCount >= 2) return undefined; + } + + return chatMsg; + }) + // Used to filter for undefined chat messages that should not be shown to non-admins + .filter(chatMsg => chatMsg !== undefined); + + return groupToJson; }; function getInviteError (uuids, emails, usernames) { @@ -496,8 +508,9 @@ schema.methods.getMemberCount = async function getMemberCount () { return await User.count(query).exec(); }; -schema.methods.sendChat = function sendChat (message, user, metaData, client) { - let newMessage = messageDefaults(message, user, client); +schema.methods.sendChat = function sendChat (options = {}) { + const {message, user, metaData, client, info = {}} = options; + let newMessage = messageDefaults(message, user, client, info); let newChatMessage = new Chat(); newChatMessage = Object.assign(newChatMessage, newMessage); newChatMessage.groupId = this._id; @@ -653,8 +666,15 @@ schema.methods.startQuest = async function startQuest (user) { }, _cleanQuestParty(), { multi: true }).exec(); - const newMessage = this.sendChat(`\`Your quest, ${quest.text('en')}, has started.\``, null, { - participatingMembers: this.getParticipatingQuestMembers().join(', '), + const newMessage = this.sendChat({ + message: `\`${shared.i18n.t('chatQuestStarted', {questName: quest.text('en')}, 'en')}\``, + metaData: { + participatingMembers: this.getParticipatingQuestMembers().join(', '), + }, + info: { + type: 'quest_start', + quest: quest.key, + }, }); await newMessage.save(); @@ -913,18 +933,42 @@ schema.methods._processBossQuest = async function processBossQuest (options) { const promises = []; group.quest.progress.hp -= progress.up; - // TODO Create a party preferred language option so emits like this can be localized. Suggestion: Always display the English version too. Or, if English is not displayed to the players, at least include it in a new field in the chat object that's visible in the database - essential for admins when troubleshooting quests! - let playerAttack = `${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage.`; - let bossAttack = CRON_SAFE_MODE || CRON_SEMI_SAFE_MODE ? `${quest.boss.name('en')} does not attack, because it respects the fact that there are some bugs\` \`post-maintenance and it doesn't want to hurt anyone unfairly. It will continue its rampage soon!` : `${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.`; - // TODO Consider putting the safe mode boss attack message in an ENV var - const groupMessage = group.sendChat(`\`${playerAttack}\` \`${bossAttack}\``); - promises.push(groupMessage.save()); + if (CRON_SAFE_MODE || CRON_SEMI_SAFE_MODE) { + const groupMessage = group.sendChat({ + message: `\`${shared.i18n.t('chatBossDontAttack', {bossName: quest.boss.name('en')}, 'en')}\``, + info: { + type: 'boss_dont_attack', + user: user.profile.name, + quest: group.quest.key, + userDamage: progress.up.toFixed(1), + }, + }); + promises.push(groupMessage.save()); + } else { + const groupMessage = group.sendChat({ + message: `\`${shared.i18n.t('chatBossDamage', {username: user.profile.name, bossName: quest.boss.name('en'), userDamage: progress.up.toFixed(1), bossDamage: Math.abs(down).toFixed(1)}, user.preferences.language)}\``, + info: { + type: 'boss_damage', + user: user.profile.name, + quest: group.quest.key, + userDamage: progress.up.toFixed(1), + bossDamage: Math.abs(down).toFixed(1), + }, + }); + promises.push(groupMessage.save()); + } // If boss has Rage, increment Rage as well if (quest.boss.rage) { group.quest.progress.rage += Math.abs(down); if (group.quest.progress.rage >= quest.boss.rage.value) { - const rageMessage = group.sendChat(quest.boss.rage.effect('en')); + const rageMessage = group.sendChat({ + message: quest.boss.rage.effect('en'), + info: { + type: 'boss_rage', + quest: quest.key, + }, + }); promises.push(rageMessage.save()); group.quest.progress.rage = 0; @@ -952,7 +996,13 @@ schema.methods._processBossQuest = async function processBossQuest (options) { // Boss slain, finish quest if (group.quest.progress.hp <= 0) { - const questFinishChat = group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``); + const questFinishChat = group.sendChat({ + message: `\`${shared.i18n.t('chatBossDefeated', {bossName: quest.boss.name('en')}, 'en')}\``, + info: { + type: 'boss_defeated', + quest: quest.key, + }, + }); promises.push(questFinishChat.save()); // Participants: Grant rewards & achievements, finish quest @@ -1005,7 +1055,15 @@ schema.methods._processCollectionQuest = async function processCollectionQuest ( }, []); foundText = foundText.join(', '); - const foundChat = group.sendChat(`\`${user.profile.name} found ${foundText}.\``); + const foundChat = group.sendChat({ + message: `\`${shared.i18n.t('chatFindItems', {username: user.profile.name, items: foundText}, 'en')}\``, + info: { + type: 'user_found_items', + user: user.profile.name, + quest: quest.key, + items: itemsFound, + }, + }); group.markModified('quest.progress.collect'); // Still needs completing @@ -1018,7 +1076,12 @@ schema.methods._processCollectionQuest = async function processCollectionQuest ( } await group.finishQuest(quest); - const allItemsFoundChat = group.sendChat('`All items found! Party has received their rewards.`'); + const allItemsFoundChat = group.sendChat({ + message: `\`${shared.i18n.t('chatItemQuestFinish', 'en')}\``, + info: { + type: 'all_items_found', + }, + }); const promises = [group.save(), foundChat.save(), allItemsFoundChat.save()]; @@ -1082,7 +1145,13 @@ schema.statics.tavernBoss = async function tavernBoss (user, progress) { const chatPromises = []; if (tavern.quest.progress.hp <= 0) { - const completeChat = tavern.sendChat(quest.completionChat('en')); + const completeChat = tavern.sendChat({ + message: quest.completionChat('en'), + info: { + type: 'tavern_quest_completed', + quest: quest.key, + }, + }); chatPromises.push(completeChat.save()); await tavern.finishQuest(quest); _.assign(tavernQuest, {extra: null}); @@ -1111,11 +1180,24 @@ schema.statics.tavernBoss = async function tavernBoss (user, progress) { } if (!scene) { - const tiredChat = tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``); + const tiredChat = tavern.sendChat({ + message: `\`${shared.i18n.t('tavernBossTired', {rageName: quest.boss.rage.title('en'), bossName: quest.boss.name('en')}, 'en')}\``, + info: { + type: 'tavern_boss_rage_tired', + quest: quest.key, + }, + }); chatPromises.push(tiredChat.save()); tavern.quest.progress.rage = 0; // quest.boss.rage.value; } else { - const rageChat = tavern.sendChat(quest.boss.rage[scene]('en')); + const rageChat = tavern.sendChat({ + message: quest.boss.rage[scene]('en'), + info: { + type: 'tavern_boss_rage', + quest: quest.key, + scene, + }, + }); chatPromises.push(rageChat.save()); tavern.quest.extra.worldDmg[scene] = true; tavern.markModified('quest.extra.worldDmg'); @@ -1127,7 +1209,13 @@ schema.statics.tavernBoss = async function tavernBoss (user, progress) { } if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) { - const progressChat = tavern.sendChat(quest.boss.desperation.text('en')); + const progressChat = tavern.sendChat({ + message: quest.boss.desperation.text('en'), + info: { + type: 'tavern_boss_desperation', + quest: quest.key, + }, + }); chatPromises.push(progressChat.save()); tavern.quest.extra.desperate = true; tavern.quest.extra.def = quest.boss.desperation.def; diff --git a/website/server/models/message.js b/website/server/models/message.js index f93d90ccb6..93db62a1f9 100644 --- a/website/server/models/message.js +++ b/website/server/models/message.js @@ -7,6 +7,7 @@ const defaultSchema = () => ({ id: String, timestamp: Date, text: String, + info: {$type: mongoose.Schema.Types.Mixed}, // sender properties user: String, // profile name (unfortunately) @@ -101,12 +102,13 @@ export function setUserStyles (newMessage, user) { newMessage.markModified('userStyles'); } -export function messageDefaults (msg, user, client) { +export function messageDefaults (msg, user, client, info = {}) { const id = uuid(); const message = { id, _id: id, text: msg.substring(0, 3000), + info, timestamp: Number(new Date()), likes: {}, flags: {},