001/*
002 *  Copyright 2021 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.plugins.workspaces.chat;
017
018import java.io.ByteArrayInputStream;
019import java.io.IOException;
020import java.io.InputStream;
021import java.nio.charset.StandardCharsets;
022import java.util.Collections;
023import java.util.HashMap;
024import java.util.List;
025import java.util.Map;
026import java.util.Map.Entry;
027import java.util.stream.Collectors;
028
029import org.apache.avalon.framework.component.Component;
030import org.apache.avalon.framework.context.Context;
031import org.apache.avalon.framework.context.ContextException;
032import org.apache.avalon.framework.context.Contextualizable;
033import org.apache.avalon.framework.service.ServiceException;
034import org.apache.avalon.framework.service.ServiceManager;
035import org.apache.avalon.framework.service.Serviceable;
036import org.apache.cocoon.components.ContextHelper;
037import org.apache.cocoon.environment.Request;
038import org.apache.commons.codec.digest.Sha2Crypt;
039import org.apache.commons.lang3.StringUtils;
040import org.apache.excalibur.source.Source;
041import org.apache.excalibur.source.SourceResolver;
042import org.apache.http.Consts;
043import org.apache.http.HttpEntity;
044import org.apache.http.client.methods.CloseableHttpResponse;
045import org.apache.http.client.methods.HttpGet;
046import org.apache.http.client.methods.HttpPost;
047import org.apache.http.client.methods.HttpUriRequest;
048import org.apache.http.entity.ContentType;
049import org.apache.http.entity.StringEntity;
050import org.apache.http.entity.mime.HttpMultipartMode;
051import org.apache.http.entity.mime.MultipartEntityBuilder;
052import org.apache.http.impl.client.CloseableHttpClient;
053import org.apache.http.impl.client.HttpClients;
054import org.apache.http.util.EntityUtils;
055import org.apache.poi.util.IOUtils;
056import org.apache.tika.Tika;
057
058import org.ametys.cms.repository.Content;
059import org.ametys.core.authentication.AuthenticateAction;
060import org.ametys.core.ui.Callable;
061import org.ametys.core.user.CurrentUserProvider;
062import org.ametys.core.user.User;
063import org.ametys.core.user.UserIdentity;
064import org.ametys.core.user.UserManager;
065import org.ametys.core.userpref.UserPreferencesException;
066import org.ametys.core.userpref.UserPreferencesManager;
067import org.ametys.core.util.CryptoHelper;
068import org.ametys.core.util.JSONUtils;
069import org.ametys.core.util.URIUtils;
070import org.ametys.plugins.userdirectory.UserDirectoryHelper;
071import org.ametys.runtime.config.Config;
072import org.ametys.runtime.plugin.component.AbstractLogEnabled;
073
074/**
075 * Helper for chat
076 */
077public class ChatHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable
078{
079    /** The Avalon role */
080    public static final String ROLE = ChatHelper.class.getName();
081    
082    private static final String __USERPREF_PREF_PASSWORD = "workspaces.chat-connector-password";
083    
084    private static final String __USERPREF_PREF_TOKEN = "workspaces.chat-connector-token";
085
086    private static final String __USERPREF_PREF_ID = "workspaces.chat-connector-id";
087
088    private static final String __USERPREF_CONTEXT = "/workspaces.chat-connector";
089
090    /** Rocket.Chat admin ID */
091    private static final String CONFIG_ADMIN_ID = "workspaces.chat.rocket.admin.id";
092
093    /** Rocket.Chat admin Token */
094    private static final String CONFIG_ADMIN_TOKEN = "workspaces.chat.rocket.admin.token";
095    
096    /** Rocket.Chat URL */
097    private static final String CONFIG_URL = "workspaces.chat.rocket.url";
098    
099    /** JSON Utils */
100    protected JSONUtils _jsonUtils;
101    
102    /** User Manager */
103    protected UserManager _userManager;
104    
105    /** User Preferences */
106    protected UserPreferencesManager _userPreferencesManager;
107    
108    /** Cryptography */
109    protected CryptoHelper _cryptoHelper;
110
111    /** Current user provider */
112    protected CurrentUserProvider _currentUserProvider;
113
114    private SourceResolver _sourceResolver;
115
116    private UserDirectoryHelper _userDirectoryHelper;
117
118    private Context _context;
119    
120    public void contextualize(Context context) throws ContextException
121    {
122        _context = context;
123    }
124    
125    public void service(ServiceManager manager) throws ServiceException
126    {
127        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
128        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
129        _userPreferencesManager = (UserPreferencesManager) manager.lookup(UserPreferencesManager.ROLE);
130        _cryptoHelper = (CryptoHelper) manager.lookup("org.ametys.plugins.workspaces.chat.cryptoHelper");
131        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
132        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
133        _userDirectoryHelper = (UserDirectoryHelper) manager.lookup(UserDirectoryHelper.ROLE);
134    }
135
136    private Map<String, Object> _doGet(String api, Map<String, String> parameters) throws IOException
137    {
138        return _doGet(api, parameters, Config.getInstance().getValue(CONFIG_ADMIN_TOKEN), Config.getInstance().getValue(CONFIG_ADMIN_ID));
139    }
140    
141    private Map<String, Object> _doGet(String api, Map<String, String> parameters, String authToken, String userId) throws IOException
142    {
143        String path = Config.getInstance().getValue(CONFIG_URL) + "/api/" + api;
144
145        String uri = URIUtils.encodeURI(path, parameters);
146        HttpGet request = new HttpGet(uri);
147        request.setHeader("Content-Type", "application/json");
148
149        return _execRequest(request, authToken, userId);
150    }
151
152    private Map<String, Object> _doPOST(String api, Map<String, Object> parameters) throws IOException
153    {
154        return _doPOST(api, parameters, Config.getInstance().getValue(CONFIG_ADMIN_TOKEN), Config.getInstance().getValue(CONFIG_ADMIN_ID));
155    }
156    
157    private Map<String, Object> _doPOST(String api, Map<String, Object> parameters, String authToken, String userId) throws IOException
158    {
159        String path = Config.getInstance().getValue(CONFIG_URL) + "/api/" + api;
160        
161        HttpPost request = new HttpPost(path);
162        
163        String json = _jsonUtils.convertObjectToJson(parameters);
164        request.setEntity(new StringEntity(json, ContentType.create("application/json", StandardCharsets.UTF_8)));
165        request.setHeader("Content-Type", "application/json");
166        
167        return _execRequest(request, authToken, userId);
168    }
169    
170    private Map<String, Object> _doMultipartPOST(String api, Map<String, Object> parameters) throws IOException
171    {
172        return _doMultipartPOST(api, parameters, Config.getInstance().getValue(CONFIG_ADMIN_TOKEN), Config.getInstance().getValue(CONFIG_ADMIN_ID));
173    }
174    
175    private Map<String, Object> _doMultipartPOST(String api, Map<String, Object> parameters, String authToken, String userId) throws IOException
176    {
177        String path = Config.getInstance().getValue(CONFIG_URL) + "/api/" + api;
178        
179        HttpPost request = new HttpPost(path);
180        
181        MultipartEntityBuilder builder = MultipartEntityBuilder.create();
182        builder.setMode(HttpMultipartMode.RFC6532);
183        
184        for (Entry<String, Object> p : parameters.entrySet())
185        {
186            if (p.getValue() instanceof String)
187            {
188                builder.addTextBody(p.getKey(), (String) p.getValue(),  ContentType.create("text/plain", Consts.UTF_8));
189            }
190            else if (p.getValue() instanceof InputStream is)
191            {
192                byte[] imageAsBytes = IOUtils.toByteArray(is);
193                ByteArrayInputStream bis = new ByteArrayInputStream(imageAsBytes);
194                Tika tika = new Tika();
195                String mimeType = tika.detect(imageAsBytes);
196                
197                builder.addBinaryBody(p.getKey(), bis, ContentType.create(mimeType), p.getKey());
198            }
199            else
200            {
201                throw new UnsupportedOperationException("Cannot post the type " + p.getValue().getClass().getName() + " for parameter " + p.getKey());
202            }
203        }
204
205        HttpEntity multipart = builder.build();
206        request.setEntity(multipart);
207
208        return _execRequest(request, authToken, userId);
209    }
210    
211
212    private boolean _isPOSTSucessful(String api, Map<String, Object> parameters) throws IOException
213    {
214        return _isOperationSuccessful(_doPOST(api, parameters));
215    }
216
217    private Map<String, Object> _execRequest(HttpUriRequest request, String authToken, String userId) throws IOException
218    {
219        request.setHeader("X-Auth-Token", authToken);
220        request.setHeader("X-User-Id", userId);
221
222        try (CloseableHttpClient httpClient = HttpClients.createDefault();
223             CloseableHttpResponse response = httpClient.execute(request))
224        {
225            Map<String, Object> convertJsonToMap = _jsonUtils.convertJsonToMap(EntityUtils.toString(response.getEntity()));
226            return convertJsonToMap;
227        }
228    }
229    
230    private String _getError(Map<String, Object> info)
231    {
232        if (info.containsKey("error"))
233        {
234            return (String) info.get("error");
235        }
236        else if (info.containsKey("message"))
237        {
238            return (String) info.get("message");
239        }
240        else
241        {
242            return "";
243        }
244    }
245    
246    /**
247     * Creates a new private group. If the group already exists the operation will succeed.
248     * @param roomName name of the group
249     * @param create Create if necessary
250     * @return The group info. Can be null if no creation allowed
251     * @throws IOException something went wrong
252     */
253    @SuppressWarnings("unchecked")
254    public Map<String, Object> getRoom(String roomName, boolean create) throws IOException
255    {
256        Map<String, Object> groupInfo = _doGet("v1/groups.info", Map.of("roomName", roomName));
257        if (!_isOperationSuccessful(groupInfo))
258        {
259            if (create)
260            {
261                groupInfo = _doPOST("v1/groups.create", Map.of("name", roomName));
262                if (!_isOperationSuccessful(groupInfo))
263                {
264                    throw new IOException("Could not create room " + roomName + ", because " + _getError(groupInfo));
265                }
266            }
267            else
268            {
269                return null;
270            }
271        }
272        return (Map<String, Object>) groupInfo.get("group");
273    }
274    
275    /**
276     * Remove a private group. If the group does not exist the operation will succeed.
277     * @param roomName name of the group
278     * @throws IOException something went wrong
279     */
280    public void deleteRoom(String roomName) throws IOException
281    {
282        if (getRoom(roomName, false) != null)
283        {
284            Map<String, Object> deleteInfos = _doPOST("v1/groups.delete", Map.of("roomName", roomName));
285            if (!_isOperationSuccessful(deleteInfos))
286            {
287                throw new IOException("Could not delete room " + roomName + ", because " + _getError(deleteInfos));
288            }
289        }
290    }
291    
292    /**
293     * Get (or create) a new user.
294     * @param userIdentity the user that will be mirrored into chat
295     * @param create Create if missing
296     * @return the user info or null if user does not exist in chat and create was not required
297     * @throws IOException something went wrong
298     * @throws UserPreferencesException error while reading the user preferences
299     */
300    @SuppressWarnings("unchecked")
301    public Map<String, Object> getUser(UserIdentity userIdentity, boolean create) throws IOException, UserPreferencesException
302    {
303        User user = _userManager.getUser(userIdentity);
304        if (user == null)
305        {
306            throw new IllegalStateException("Cannot create user in Rocket.Chat for unexisting user " + UserIdentity.userIdentityToString(userIdentity));
307        }
308        
309        Map<String, Object> userInfo = _doGet("v1/users.info", Map.of("username", userIdentitytoUserName(userIdentity)));
310        if (!_isOperationSuccessful(userInfo))
311        {
312            if (create)
313            {
314                Map<String, String> ametysUserInfo = _getAmetysUserInfo(user);
315                String userName = ametysUserInfo.get("userName");
316                String userEmail = ametysUserInfo.get("userEmail");
317                
318                userInfo = _doPOST("v1/users.create", Map.of("username", userIdentitytoUserName(userIdentity),
319                                                            "email", userEmail,
320                                                            "name", userName,
321                                                            "verified", true,
322                                                            "password", _getUserPassword(userIdentity)));
323                if (!_isOperationSuccessful(userInfo))
324                {
325                    throw new IllegalStateException("Cannot create user in Rocket.Chat for " + UserIdentity.userIdentityToString(userIdentity) + ": " +  _getError(userInfo));
326                }
327                
328                getLogger().debug("User " + UserIdentity.userIdentityToString(userIdentity) + " created on the chat server");
329                
330                _updateAvatar(userIdentity);
331            }
332            else
333            {
334                return null;
335            }
336        }
337        
338        Map<String, Object> userMap = (Map<String, Object>) userInfo.get("user");
339        
340        _userPreferencesManager.addUserPreference(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_ID, (String) userMap.get("_id"));
341        
342        return userMap;
343    }
344    
345    private Map<String, String> _getAmetysUserInfo(User user)
346    {
347        String userName = user.getFullName();
348        String userEmail = user.getEmail();
349        
350        Content userContent = _userDirectoryHelper.getUserContent(user.getIdentity(), null);
351        if (userContent != null)
352        {
353            userName = StringUtils.defaultIfBlank(StringUtils.join(new String[] {userContent.getValue("firstname"), userContent.getValue("lastname")}, " "), userName);
354            userEmail = StringUtils.defaultIfBlank(userContent.getValue("email"), userEmail);
355        }
356
357        return Map.of("userName", userName,
358                      "userEmail", userEmail);
359    }
360    
361    /**
362     * Update user name, email, avatar... on chat server
363     * @param userIdentity The user to update
364     * @param changePassword Update the user password (slow)
365     * @throws UserPreferencesException If an error occurred while getting user infos
366     * @throws IOException If an error occurred while updating
367     * @throws InterruptedException If an error occurred while updating
368     */
369    public void updateUserInfos(UserIdentity userIdentity, boolean changePassword) throws IOException, UserPreferencesException, InterruptedException
370    {
371        User user = _userManager.getUser(userIdentity);
372        if (user == null)
373        {
374            throw new IllegalStateException("Cannot update user in Rocket.Chat for unexisting user " + UserIdentity.userIdentityToString(userIdentity));
375        }
376        
377        Map<String, String> ametysUserInfo = _getAmetysUserInfo(user);
378        String userName = ametysUserInfo.get("userName");
379        String userEmail = ametysUserInfo.get("userEmail");
380        
381        Map<String, String> data = new HashMap<>();
382        data.put("email", userEmail);
383        data.put("name", userName);
384        if (changePassword)
385        {
386            data.put("password", _getUserPassword(userIdentity));
387        }
388        
389        Map<String, Object> updateInfos = _doPOST("v1/users.update", Map.of("userId", _getUserId(userIdentity),
390                                                                            "data", data));
391        if (!_isOperationSuccessful(updateInfos))
392        {
393            throw new IOException("Cannot update user " + UserIdentity.userIdentityToString(userIdentity) + " on chat server: " + _getError(updateInfos));
394        }
395        
396        if (changePassword)
397        {
398            // When changing password, it unlogs people and this takes time
399            Thread.sleep(1000);
400        }
401        
402        _updateAvatar(userIdentity);
403    }
404    
405    private void _updateAvatar(UserIdentity user)
406    {
407        Request request = ContextHelper.getRequest(_context);
408        request.setAttribute(AuthenticateAction.REQUEST_ATTRIBUTE_INTERNAL_ALLOWED, true);
409        
410        String oldPluginName = (String) request.getAttribute("pluginName");
411        
412        Source src = null;
413        try
414        {
415            src = _sourceResolver.resolveURI("cocoon://_plugins/user-directory/user/" + user.getPopulationId() + "/" + user.getLogin() + "/image_64");
416            try (InputStream is = src.getInputStream())
417            {
418                Map<String, Object> avatarInfo = _doMultipartPOST("v1/users.setAvatar", Map.of("username", userIdentitytoUserName(user),
419                                                                                                "image", is));
420                if (!_isOperationSuccessful(avatarInfo))
421                {
422                    getLogger().warn("Fail to update avatar for user " + UserIdentity.userIdentityToString(user) + ": " + _getError(avatarInfo));
423                }
424            }
425        }
426        catch (Exception e)
427        {
428            getLogger().warn("Fail to update avatar for user " + UserIdentity.userIdentityToString(user), e);
429        }
430        finally
431        {
432            _sourceResolver.release(src);
433            request.setAttribute("pluginName", oldPluginName);
434        }
435        
436    }
437    
438    private boolean _isUserInGroup(UserIdentity userIdentity, String roomName) throws IOException
439    {
440        Map<String, Object> getMembers = _doGet("v1/groups.members", Map.of("roomName", roomName));
441        
442        if (_isOperationSuccessful(getMembers))
443        {
444            @SuppressWarnings("unchecked")
445            List<Map<String, String>> membersObject = (List<Map<String, String>>) getMembers.get("members");
446            for (Map<String, String> map : membersObject)
447            {
448                if (userIdentitytoUserName(userIdentity).equalsIgnoreCase(map.get("username")))
449                {
450                    return true;
451                }
452            }
453        }
454        
455        return false;
456    }
457    
458    /**
459     * Adds a user to the private group.
460     * @param userIdentity user to invite
461     * @param roomName name of the group where to invite the user
462     * @throws IOException something went wrong
463     * @throws UserPreferencesException If the user need to be created and its password cannot be stored
464     */
465    public void addUserToRoom(UserIdentity userIdentity, String roomName) throws IOException, UserPreferencesException
466    {
467        if (!_isUserInGroup(userIdentity, roomName))
468        {
469            Map<String, Object> userInfo = getUser(userIdentity, true);
470            Map<String, Object> groupInfo = getRoom(roomName, true);
471            
472            String roomId = (String) groupInfo.get("_id");
473            String userId = (String) userInfo.get("_id");
474            
475            Map<String, Object> inviteInfo = _doPOST("v1/groups.invite", Map.of("roomId", roomId,
476                                                                                "userId", userId));
477            
478            if (!_isOperationSuccessful(inviteInfo))
479            {
480                throw new IOException("Could not add user " + UserIdentity.userIdentityToString(userIdentity) + " to room " + roomName + ": " + _getError(inviteInfo));
481            }
482        }
483    }
484    
485    /**
486     * Remove all existing user
487     * @param roomName The room name
488     * @param except Do not remove these users
489     * @throws IOException If an error occurred
490     */
491    public void removeAllUsersFromRoom(String roomName, List<UserIdentity> except) throws IOException
492    {
493        List<String> exceptUsernames = except.stream().map(ChatHelper::userIdentitytoUserName).collect(Collectors.toList());
494        
495        Map<String, Object> groupInfo = getRoom(roomName, false);
496        String roomId = (String) groupInfo.get("_id");
497        
498        Map<String, Object> membersInfo = _doGet("v1/groups.members", Map.of("roomId", roomId));
499        if (_isOperationSuccessful(membersInfo))
500        {
501            @SuppressWarnings("unchecked")
502            List<Map<String, Object>> members = (List<Map<String, Object>>) membersInfo.get("members");
503            for (Map<String, Object> member : members)
504            {
505                if (!exceptUsernames.contains(member.get("username")))
506                {
507                    _doPOST("v1/groups.kick", Map.of("roomId", roomId,
508                                                 "userId", (String) member.get("_id")));
509                }
510            }
511        }
512    }
513    
514    /**
515     * Removes a user from the private group.
516     * @param user user to kick
517     * @param roomName name of the group from where to kick the user
518     * @return true if success
519     * @throws IOException something went wrong
520     * @throws UserPreferencesException If an error occurred with user password
521     */
522    public boolean removeUserFromRoom(UserIdentity user, String roomName) throws IOException, UserPreferencesException
523    {
524        if (_isUserInGroup(user, roomName))
525        {
526            Map<String, Object> userInfo = getUser(user, false);
527            Map<String, Object> groupInfo = getRoom(roomName, false);
528            
529            if (userInfo != null && groupInfo != null)
530            {
531                String roomId = (String) groupInfo.get("_id");
532                String userId = (String) userInfo.get("_id");
533                
534                return _isPOSTSucessful("v1/groups.kick", Map.of("roomId", roomId,
535                                                                "userId", userId));
536            }
537            else
538            {
539                return false;
540            }
541        }
542        
543        return true;
544    }
545    
546    /**
547     * Convert a useridentity to a chat server username
548     * @param userIdentity The user to convert
549     * @return the chat username
550     */
551    public static String userIdentitytoUserName(UserIdentity userIdentity)
552    {
553        return UserIdentity.userIdentityToString(userIdentity).replaceAll("[@# ]", "_");
554    }
555    
556    private String _getUserPassword(UserIdentity userIdentity) throws UserPreferencesException
557    {
558        String cryptedPassword = _userPreferencesManager.getUserPreferenceAsString(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_PASSWORD);
559        if (!StringUtils.isBlank(cryptedPassword))
560        {
561            try
562            {
563                return _cryptoHelper.decrypt(cryptedPassword);
564            }
565            catch (CryptoHelper.WrongKeyException e)
566            {
567                getLogger().warn("Password of user {} cannot be decrypted, and thus will be reset",  UserIdentity.userIdentityToString(userIdentity), e);
568            }
569        }
570        
571        return _generateAndStorePassword(userIdentity);
572    }
573    
574    private String _getUserAuthToken(UserIdentity userIdentity) throws UserPreferencesException, IOException, InterruptedException
575    {
576        String cryptedToken = _userPreferencesManager.getUserPreferenceAsString(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_TOKEN);
577        if (!StringUtils.isBlank(cryptedToken))
578        {
579            try
580            {
581                return _cryptoHelper.decrypt(cryptedToken);
582            }
583            catch (CryptoHelper.WrongKeyException e)
584            {
585                getLogger().warn("Token of user {} cannot be decrypted, and thus will be reset",  UserIdentity.userIdentityToString(userIdentity), e);
586            }
587        }
588        return _generateAndStoreAuthToken(userIdentity, true);
589    }
590    
591    
592    private String _getUserId(UserIdentity userIdentity) throws UserPreferencesException
593    {
594        String userId = _userPreferencesManager.getUserPreferenceAsString(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_ID);
595        return userId;
596    }
597
598    private String _generateAndStorePassword(UserIdentity user) throws UserPreferencesException
599    {
600        Double random = Math.random();
601        byte[] randoms = {random.byteValue()};
602        String randomPassword = Sha2Crypt.sha256Crypt(randoms);
603        
604        String cryptedPassword = _cryptoHelper.encrypt(randomPassword);
605        _userPreferencesManager.addUserPreference(user, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_PASSWORD, cryptedPassword);
606        
607        return randomPassword;
608    }
609    
610    
611    @SuppressWarnings("unchecked")
612    private String _generateAndStoreAuthToken(UserIdentity user, boolean tryToChangePassword) throws IOException, UserPreferencesException, InterruptedException
613    {
614        Map<String, Object> loginInfo = _doPOST("v1/login", Map.of("user", userIdentitytoUserName(user),
615                                                                  "password", _getUserPassword(user)));
616        if (_isOperationSuccessful(loginInfo))
617        {
618            String authToken = (String) ((Map<String, Object>) loginInfo.get("data")).get("authToken");
619            String cryptedAuthToken = _cryptoHelper.encrypt(authToken);
620            _userPreferencesManager.addUserPreference(user, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_TOKEN, cryptedAuthToken);
621            
622            return authToken;
623        }
624        else if (tryToChangePassword)
625        {
626            this.updateUserInfos(user, true);
627            
628            return _generateAndStoreAuthToken(user, false);
629        }
630        else
631        {
632            throw new IOException("Could not log user " + UserIdentity.userIdentityToString(user) + " into chat " + _getError(loginInfo));
633        }
634    }
635    
636    private String _getUserStatus(UserIdentity user, String authToken, String userId) throws IOException
637    {
638        Map<String, Object> statusInfo = _doGet("v1/users.getStatus", Map.of("user", userIdentitytoUserName(user)), authToken, userId);
639        if (_isOperationSuccessful(statusInfo))
640        {
641            return (String) statusInfo.get("status");
642        }
643        else
644        {
645            return null;
646        }
647    }
648    
649    /**
650     * Read the JSON result to test for success
651     * @param result the JSON result of a rest call
652     * @return true if success
653     */
654    protected boolean _isOperationSuccessful(Map<String, Object> result)
655    {
656        Boolean success = false;
657        if (result != null)
658        {
659            Object successObj =  result.get("success");
660            if (successObj instanceof Boolean)
661            {
662                success = (Boolean) successObj;
663            }
664            else if (successObj instanceof String)
665            {
666                success = "true".equalsIgnoreCase((String) successObj);
667            }
668            else
669            {
670                Object statusObj =  result.get("status");
671                if (statusObj instanceof String)
672                {
673                    success = "success".equalsIgnoreCase((String) statusObj);
674                }
675            }
676        }
677        return success;
678    }
679    
680    /**
681     * Login the current user to the given room
682     * @param roomName The room to log in
683     * @return The info about the user
684     * @throws UserPreferencesException If the user password stored in prefs has an issue
685     * @throws IOException If an error occurred
686     * @throws InterruptedException If an error occurred
687     */
688    @Callable
689    public Map<String, Object> login(String roomName) throws IOException, UserPreferencesException, InterruptedException
690    {
691        // Get current user in Ametys
692        UserIdentity user = _currentUserProvider.getUser();
693        
694        // Ensure user exists on chat server
695        getUser(user, true);
696        
697        // Ensure the room exists on chat server
698        getRoom(roomName, true);
699        
700        // Ensure the user is part of the room  on chat server
701        addUserToRoom(user, roomName);
702        
703        // Get the login info of the user
704        String authToken = _getUserAuthToken(user);
705        String userId = _getUserId(user);
706        
707        // Ensure token validity by getting status on chat server
708        String status = _getUserStatus(user, authToken, userId);
709        if (status == null)
710        {
711            // If we cannot get status, this is probably because the auth token has expired or the user was recreated. Try a new one
712            authToken = _generateAndStoreAuthToken(user, true);
713            
714            status = _getUserStatus(user, authToken, userId);
715            
716            if (status == null)
717            {
718                throw new IllegalStateException("Cannot get the status of user " + UserIdentity.userIdentityToString(user));
719            }
720        }
721        
722        return Map.of("userId", userId,
723                      "authToken", authToken,
724                      "userName", userIdentitytoUserName(user),
725                      "status", status);
726    }
727}