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