Gitter migration: Setup redirects (rollout pt. 3)
[gitter.git] / server / services / suggestions-service.js
blob132f681ce62dc98530a7ca4d75a0f0c81cea3a26
1 'use strict';
3 var Promise = require('bluebird');
4 var _ = require('lodash');
5 var env = require('gitter-web-env');
6 var config = env.config;
7 var promiseUtils = require('../utils/promise-utils');
8 var mongooseUtils = require('gitter-web-persistence-utils/lib/mongoose-utils');
9 var troupeService = require('gitter-web-rooms/lib/troupe-service');
10 var roomMembershipService = require('gitter-web-rooms/lib/room-membership-service');
11 var userService = require('gitter-web-users');
12 var userSettingsService = require('gitter-web-user-settings');
13 var userScopes = require('gitter-web-identity/lib/user-scopes');
14 var graphSuggestions = require('gitter-web-suggestions');
15 var cacheWrapper = require('gitter-web-cache-wrapper');
16 var debug = require('debug')('gitter:app:suggestions');
17 var logger = require('gitter-web-env').logger;
18 var groupRoomSuggestions = require('gitter-web-groups/lib/group-room-suggestions');
20 // the old github recommenders that find repos, to be filtered to rooms
21 // var ownedRepos = require('./recommendations/owned-repos');
22 var starredRepos = require('./recommendations/starred-repos');
23 // var watchedRepos = require('./recommendations/watched-repos');
25 var EXPIRES_SECONDS = config.get('suggestions:cache-timeout');
27 var NUM_SUGGESTIONS = 12;
28 var MAX_SUGGESTIONS_PER_ORG = 2;
29 var HIGHLIGHTED_ROOMS = [
31 uri: 'gitter/gitter',
32 localeLanguage: 'en'
35 uri: 'gitter/developers',
36 localeLanguage: 'en'
39 uri: 'LaravelRUS/chat',
40 localeLanguage: 'ru'
43 uri: 'google/material-design-lite',
44 localeLanguage: 'en'
47 uri: 'pydata/pandas',
48 localeLanguage: 'en'
51 uri: 'PerfectlySoft/Perfect',
52 localeLanguage: 'en'
55 uri: 'twbs/bootstrap',
56 localeLanguage: 'en'
59 uri: 'scala-js/scala-js',
60 localeLanguage: 'en'
63 uri: 'gitter/nodejs',
64 localeLanguage: 'en'
67 uri: 'FreeCodeCamp/FreeCodeCamp',
68 localeLanguage: 'en'
71 uri: 'webpack/webpack',
72 localeLanguage: 'en'
75 uri: 'angular-ui/ng-grid',
76 localeLanguage: 'en'
79 uri: 'dev-ua/frontend-ua',
80 localeLanguage: 'ua'
83 uri: 'rus-speaking/android',
84 localeLanguage: 'ru'
87 uri: 'FreeCodeCamp/Espanol',
88 localeLanguage: 'es'
92 function reposToRooms(repos) {
93 // Limit to a sane number that's a bit higher than the number we'll use
94 // because we're still going to be filtering out the ones the user is already
95 // in later.
96 repos = repos.slice(0, 100);
98 var uris = _.map(repos, function(repo) {
99 return repo.uri;
102 return troupeService.findPublicRoomsByTypeAndLinkPaths('GH_REPO', uris);
105 // var ownedRepoRooms = Promise.method(function(options) {
106 // var user = options.user;
107 // if (!user || !userScopes.isGitHubUser(user)) {
108 // return [];
109 // }
111 // debug('checking ownedRepoRooms');
113 // return ownedRepos(user)
114 // .then(reposToRooms)
115 // .then(function(rooms) {
116 // if (debug.enabled) {
117 // debug("ownedRepoRooms", _.pluck(rooms, "uri"));
118 // }
119 // return rooms;
120 // });
121 // });
123 var starredRepoRooms = Promise.method(function(options) {
124 var user = options.user;
125 if (!user || !userScopes.isGitHubUser(user)) {
126 return [];
129 debug('checking starredRepoRooms');
131 return starredRepos(user)
132 .then(reposToRooms)
133 .then(function(rooms) {
134 if (debug.enabled) {
135 debug('starredRepoRooms', _.pluck(rooms, 'uri'));
137 return rooms;
141 // var watchedRepoRooms = Promise.method(function(options) {
142 // var user = options.user;
143 // if (!user || !userScopes.isGitHubUser(user)) {
144 // return [];
145 // }
147 // debug('checking watchedRepoRooms');
149 // return watchedRepos(user)
150 // .then(reposToRooms)
151 // .then(function(rooms) {
152 // if (debug.enabled) {
153 // debug("watchedRepoRooms", _.pluck(rooms, "uri"));
154 // }
155 // return rooms;
156 // });
157 // });
159 var graphRooms = Promise.method(function(options) {
160 var existingRooms = options.rooms;
162 if (!existingRooms || !existingRooms.length) {
163 return [];
166 debug('checking graphRooms');
168 var language = options.language;
170 // limit how many we send to neo4j
171 var firstTen = existingRooms.slice(0, 10);
172 return graphSuggestions
173 .getSuggestionsForRooms(firstTen, language)
174 .timeout(2000)
175 .then(function(roomIds) {
176 return troupeService.findByIdsLean(roomIds);
178 .then(function(suggestedRooms) {
179 // Make sure there are no more than MAX_SUGGESTIONS_PER_ORG per
180 // organisation coming out of the graph.
181 // (The siblingRooms step might just add them back in anyway which is why
182 // this is not a standard step in filterRooms())
183 var orgTotals = {};
185 _.remove(suggestedRooms, function(room) {
186 if (orgTotals[room.groupId]) {
187 orgTotals[room.groupId] += 1;
188 } else {
189 orgTotals[room.groupId] = 1;
192 return orgTotals[room.groupId] > MAX_SUGGESTIONS_PER_ORG;
195 if (debug.enabled) {
196 debug('graphRooms', _.pluck(suggestedRooms, 'uri'));
199 return suggestedRooms;
201 .catch(function(err) {
202 logger.error('Neo4J error: ' + err, {
203 exception: err
205 return [];
209 var siblingRooms = Promise.method(function(options) {
210 var existingRooms = options.rooms;
211 var user = options.user;
213 if (!user || !existingRooms || !existingRooms.length) {
214 return [];
217 var userId = user._id;
219 debug('checking siblingRooms');
221 var groupIds = _.pluck(existingRooms, 'groupId').filter(function(groupId) {
222 return !!groupId;
225 return groupRoomSuggestions
226 .findUnjoinedRoomsInGroups(userId, _.uniq(groupIds))
227 .then(function(results) {
228 if (debug.enabled) {
229 debug('siblingRooms', _.pluck(results, 'uri'));
232 return results;
236 function hilightedRooms(options) {
237 var language = options.language;
239 // shuffle so we don't always present the same ones first
240 var shuffled = _.shuffle(HIGHLIGHTED_ROOMS);
242 var filtered = _.filter(shuffled, function(roomInfo) {
243 var roomLang = roomInfo.localeLanguage;
244 return roomLang === 'en' || roomLang === language;
247 var uris = _.map(filtered, function(highlighted) {
248 return highlighted.uri;
251 return troupeService.findByUris(uris).then(function(suggestedRooms) {
252 if (debug.enabled) {
253 debug('hilightedRooms', _.pluck(suggestedRooms, 'uri'));
256 return suggestedRooms;
260 function filterRooms(suggested, existing) {
261 // not necessarily sure where suggested comes from, so make sure id is filled
262 // in and a string so we can safely use that as a key in a map.
263 mongooseUtils.addIdToLeanArray(suggested);
265 // remove all the nulls/undefineds from things that didn't exist
266 var filtered = _.filter(suggested);
268 // filter out the existing rooms
269 var existingMap = _.indexBy(existing, 'id');
270 filtered = _.filter(filtered, function(room) {
271 // make very sure we only find public rooms
272 if (room.security !== 'PUBLIC') {
273 return false;
275 return existingMap[room.id] === undefined;
278 // filter out duplicates
279 var roomMap = {};
280 filtered = _.filter(filtered, function(room) {
281 if (roomMap[room.id]) {
282 return false;
283 } else {
284 roomMap[room.id] = true;
285 return true;
289 return filtered;
292 var recommenders = [
293 // Disabling these for now because they both just tend to find "my-org/*" and
294 // we have other places to suggest those already and you certainly have other
295 // ways of discovering or being told about your orgs' own rooms so I feel
296 // doubtful about the potential network effect here.
297 //ownedRepoRooms,
298 //watchedRepoRooms,
299 starredRepoRooms,
300 graphRooms,
301 siblingRooms,
302 hilightedRooms
306 * options can have the following and they are all optional
307 * - user
308 * - rooms (array)
309 * - language (defaults to 'en')
310 * The plugins can just skip themselves if options doesn't contain what they need.
312 function findSuggestionsForRooms(options) {
313 var existingRooms = options.rooms || [];
314 var language = options.language || 'en';
316 // copy the defaults back in so all the plugins get the defaults
317 options.rooms = existingRooms;
318 options.language = language;
320 // 1to1 rooms aren't included in the graph anyway, so filter them out first
321 existingRooms = _.filter(existingRooms, function(room) {
322 return room.oneToOne !== true;
325 function filterSuggestions(results) {
326 var filtered = filterRooms(results, existingRooms);
327 // Add all the rooms we've found so far to the rooms used by subsequent
328 // lookups. This means that a new github user that hasn't joined any rooms
329 // yet but has starred some GitHub rooms can get graph results whereas
330 // otherwise graphRooms would be skipped because it wouldn't have any rooms
331 // to use as input. This should also benefit siblingRooms because it will
332 // find siblings of suggested rooms (if it gets there) which is probably
333 // better than just serving hilighted rooms.
334 options.rooms = existingRooms.concat(filtered);
335 return filtered;
338 return promiseUtils.waterfall(recommenders, [options], filterSuggestions, NUM_SUGGESTIONS);
342 * Returns rooms for a user
343 * @private
345 function findRoomsByUserId(userId) {
346 return roomMembershipService.findRoomIdsForUser(userId).then(function(roomIds) {
347 return troupeService.findByIdsLean(roomIds, {
348 uri: 1,
349 groupId: 1,
350 lang: 1,
351 oneToOne: 1
357 * Find some suggestions for the current user
359 function findSuggestionsForUserId(userId) {
360 return Promise.all([
361 userService.findById(userId),
362 findRoomsByUserId(userId),
363 userSettingsService.getUserSettings(userId, 'lang')
364 ]).spread(function(user, existingRooms, language) {
365 return findSuggestionsForRooms({
366 user: user,
367 rooms: existingRooms,
368 language: language
373 module.exports = {
374 findSuggestionsForUserId: cacheWrapper('findSuggestionsForUserId', findSuggestionsForUserId, {
375 ttl: EXPIRES_SECONDS
377 findSuggestionsForRooms: findSuggestionsForRooms,
378 testOnly: {
379 HIGHLIGHTED_ROOMS: HIGHLIGHTED_ROOMS