001/*
002 *  Copyright 2024 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.rocket.chat;
017
018import java.io.ByteArrayInputStream;
019import java.io.IOException;
020import java.io.InputStream;
021import java.nio.charset.StandardCharsets;
022import java.time.Duration;
023import java.time.ZonedDateTime;
024import java.util.ArrayList;
025import java.util.Base64;
026import java.util.Base64.Decoder;
027import java.util.Base64.Encoder;
028import java.util.Collection;
029import java.util.Collections;
030import java.util.HashMap;
031import java.util.HashSet;
032import java.util.LinkedHashMap;
033import java.util.List;
034import java.util.Map;
035import java.util.Map.Entry;
036import java.util.Optional;
037import java.util.Set;
038import java.util.stream.Collectors;
039
040import org.apache.avalon.framework.activity.Initializable;
041import org.apache.avalon.framework.component.Component;
042import org.apache.avalon.framework.context.Context;
043import org.apache.avalon.framework.context.ContextException;
044import org.apache.avalon.framework.context.Contextualizable;
045import org.apache.avalon.framework.service.ServiceException;
046import org.apache.avalon.framework.service.ServiceManager;
047import org.apache.avalon.framework.service.Serviceable;
048import org.apache.cocoon.components.ContextHelper;
049import org.apache.cocoon.environment.Request;
050import org.apache.commons.codec.digest.Sha2Crypt;
051import org.apache.commons.lang3.StringUtils;
052import org.apache.commons.lang3.tuple.Pair;
053import org.apache.excalibur.source.Source;
054import org.apache.excalibur.source.SourceResolver;
055import org.apache.http.Consts;
056import org.apache.http.HttpEntity;
057import org.apache.http.client.methods.CloseableHttpResponse;
058import org.apache.http.client.methods.HttpGet;
059import org.apache.http.client.methods.HttpPost;
060import org.apache.http.client.methods.HttpUriRequest;
061import org.apache.http.entity.ContentType;
062import org.apache.http.entity.StringEntity;
063import org.apache.http.entity.mime.HttpMultipartMode;
064import org.apache.http.entity.mime.MultipartEntityBuilder;
065import org.apache.http.impl.client.CloseableHttpClient;
066import org.apache.http.impl.client.HttpClients;
067import org.apache.http.util.EntityUtils;
068import org.apache.poi.util.IOUtils;
069import org.apache.tika.Tika;
070
071import org.ametys.core.authentication.AuthenticateAction;
072import org.ametys.core.cache.AbstractCacheManager;
073import org.ametys.core.cache.Cache;
074import org.ametys.core.ui.Callable;
075import org.ametys.core.user.CurrentUserProvider;
076import org.ametys.core.user.User;
077import org.ametys.core.user.UserIdentity;
078import org.ametys.core.user.UserManager;
079import org.ametys.core.user.population.PopulationContextHelper;
080import org.ametys.core.userpref.UserPreferencesException;
081import org.ametys.core.userpref.UserPreferencesManager;
082import org.ametys.core.util.CryptoHelper;
083import org.ametys.core.util.DateUtils;
084import org.ametys.core.util.JSONUtils;
085import org.ametys.core.util.LambdaUtils;
086import org.ametys.core.util.URIUtils;
087import org.ametys.runtime.config.Config;
088import org.ametys.runtime.i18n.I18nizableText;
089import org.ametys.runtime.plugin.component.AbstractLogEnabled;
090import org.ametys.web.WebHelper;
091import org.ametys.web.transformation.xslt.AmetysXSLTHelper;
092
093/**
094 * Helper for the rocket.chat link
095 */
096public class RocketChatHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable, Initializable
097{
098    /** The Avalon role */
099    public static final String ROLE = RocketChatHelper.class.getName();
100    
101    private static final String __USERPREF_PREF_PASSWORD = "rocket.chat-connector-password";
102    private static final String __USERPREF_PREF_TOKEN = "rocket.chat-connector-token";
103    private static final String __USERPREF_PREF_ID = "rocket.chat-connector-id";
104    private static final String __USERPREF_CONTEXT = "/rocket.chat-connector";
105
106    private static final String __CONFIG_ADMIN_ID = "rocket.chat.rocket.admin.id";
107    private static final String __CONFIG_ADMIN_TOKEN = "rocket.chat.rocket.admin.token";
108    private static final String __CONFIG_URL = "rocket.chat.rocket.url";
109    
110    private static final String __CACHE_STATUS = RocketChatHelper.class.getName() + "$status";
111    private static final int __CACHE_STATUS_DURATION = 120;
112    private static final String __CACHE_UPDATES = RocketChatHelper.class.getName() + "$updates";
113    private static final int __CACHE_UPDATES_DURATION = 120;
114    
115    private static final Encoder __BASE64_ENCODER = Base64.getUrlEncoder().withoutPadding();
116    private static final Decoder __BASE64_DECODER = Base64.getUrlDecoder();
117    
118    /** JSON Utils */
119    protected JSONUtils _jsonUtils;
120    
121    /** User Manager */
122    protected UserManager _userManager;
123    
124    /** User Preferences */
125    protected UserPreferencesManager _userPreferencesManager;
126    
127    /** Cryptography */
128    protected CryptoHelper _cryptoHelper;
129
130    /** Current user provider */
131    protected CurrentUserProvider _currentUserProvider;
132
133    private SourceResolver _sourceResolver;
134
135    private Context _context;
136
137    private AbstractCacheManager _cacheManager;
138
139    private PopulationContextHelper _populationContextHelper;
140    
141    public void contextualize(Context context) throws ContextException
142    {
143        _context = context;
144    }
145    
146    public void service(ServiceManager manager) throws ServiceException
147    {
148        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
149        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
150        _userPreferencesManager = (UserPreferencesManager) manager.lookup(UserPreferencesManager.ROLE);
151        _cryptoHelper = (CryptoHelper) manager.lookup("org.ametys.plugins.rocket.chat.cryptoHelper");
152        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
153        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
154        _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE);
155        _populationContextHelper = (PopulationContextHelper) manager.lookup(PopulationContextHelper.ROLE);
156    }
157    
158    public void initialize() throws Exception
159    {
160        _createCaches();
161    }
162    
163    /**
164     * Creates the caches
165     */
166    protected void _createCaches()
167    {
168        _cacheManager.createMemoryCache(__CACHE_STATUS,
169                new I18nizableText("plugin.rocket.chat", "PLUGINS_ROCKETCHAT_HELPER_STATUS_CACHE_LABEL"),
170                new I18nizableText("plugin.rocket.chat", "PLUGINS_ROCKETCHAT_HELPER_STATUS_CACHE_DESC"),
171                true,
172                Duration.ofSeconds(__CACHE_STATUS_DURATION));
173        _cacheManager.createMemoryCache(__CACHE_UPDATES,
174                new I18nizableText("plugin.rocket.chat", "PLUGINS_ROCKETCHAT_HELPER_STATUS_UPDATES_LABEL"),
175                new I18nizableText("plugin.rocket.chat", "PLUGINS_ROCKETCHAT_HELPER_STATUS_UPDATES_DESC"),
176                true,
177                Duration.ofSeconds(__CACHE_UPDATES_DURATION));
178    }
179    
180    private Cache<UserIdentity, String> _getStatusCache()
181    {
182        return _cacheManager.get(__CACHE_STATUS);
183    }
184    
185    private Cache<UserIdentity, Boolean> _getUpdatesCache()
186    {
187        return _cacheManager.get(__CACHE_UPDATES);
188    }
189    
190    private Map<String, Object> _doGet(String api, Map<String, String> parameters) throws IOException
191    {
192        return _doGet(api, parameters, Config.getInstance().getValue(__CONFIG_ADMIN_TOKEN), Config.getInstance().getValue(__CONFIG_ADMIN_ID));
193    }
194    
195    private Map<String, Object> _doGet(String api, Map<String, String> parameters, String authToken, String userId) throws IOException
196    {
197        String path = Config.getInstance().getValue(__CONFIG_URL) + "/api/" + api;
198
199        String uri = URIUtils.encodeURI(path, parameters);
200        HttpGet request = new HttpGet(uri);
201        request.setHeader("Content-Type", "application/json");
202
203        return _execRequest(request, authToken, userId);
204    }
205
206    private Map<String, Object> _doPOST(String api, Map<String, Object> parameters) throws IOException
207    {
208        return _doPOST(api, parameters, Config.getInstance().getValue(__CONFIG_ADMIN_TOKEN), Config.getInstance().getValue(__CONFIG_ADMIN_ID));
209    }
210    
211    private Map<String, Object> _doPOST(String api, Map<String, Object> parameters, String authToken, String userId) throws IOException
212    {
213        String path = Config.getInstance().getValue(__CONFIG_URL) + "/api/" + api;
214        
215        HttpPost request = new HttpPost(path);
216        
217        String json = _jsonUtils.convertObjectToJson(parameters);
218        request.setEntity(new StringEntity(json, ContentType.create("application/json", StandardCharsets.UTF_8)));
219        request.setHeader("Content-Type", "application/json");
220        
221        return _execRequest(request, authToken, userId);
222    }
223    
224    private Map<String, Object> _doMultipartPOST(String api, Map<String, Object> parameters) throws IOException
225    {
226        return _doMultipartPOST(api, parameters, Config.getInstance().getValue(__CONFIG_ADMIN_TOKEN), Config.getInstance().getValue(__CONFIG_ADMIN_ID));
227    }
228    
229    private Map<String, Object> _doMultipartPOST(String api, Map<String, Object> parameters, String authToken, String userId) throws IOException
230    {
231        String path = Config.getInstance().getValue(__CONFIG_URL) + "/api/" + api;
232        
233        HttpPost request = new HttpPost(path);
234        
235        MultipartEntityBuilder builder = MultipartEntityBuilder.create();
236        builder.setMode(HttpMultipartMode.RFC6532);
237        
238        for (Entry<String, Object> p : parameters.entrySet())
239        {
240            if (p.getValue() instanceof String)
241            {
242                builder.addTextBody(p.getKey(), (String) p.getValue(),  ContentType.create("text/plain", Consts.UTF_8));
243            }
244            else if (p.getValue() instanceof InputStream is)
245            {
246                byte[] imageAsBytes = IOUtils.toByteArray(is);
247                ByteArrayInputStream bis = new ByteArrayInputStream(imageAsBytes);
248                Tika tika = new Tika();
249                String mimeType = tika.detect(imageAsBytes);
250                
251                builder.addBinaryBody(p.getKey(), bis, ContentType.create(mimeType), p.getKey());
252            }
253            else
254            {
255                throw new UnsupportedOperationException("Cannot post the type " + p.getValue().getClass().getName() + " for parameter " + p.getKey());
256            }
257        }
258
259        HttpEntity multipart = builder.build();
260        request.setEntity(multipart);
261
262        return _execRequest(request, authToken, userId);
263    }
264    
265    private Map<String, Object> _execRequest(HttpUriRequest request, String authToken, String userId) throws IOException
266    {
267        getLogger().debug("Request to Rocket.Chat server {}", request.getURI());
268
269        request.setHeader("X-Auth-Token", authToken);
270        request.setHeader("X-User-Id", userId);
271
272        try (CloseableHttpClient httpClient = HttpClients.createDefault();
273             CloseableHttpResponse response = httpClient.execute(request))
274        {
275            Map<String, Object> convertJsonToMap = _jsonUtils.convertJsonToMap(EntityUtils.toString(response.getEntity()));
276            return convertJsonToMap;
277        }
278    }
279    
280    private String _getError(Map<String, Object> info)
281    {
282        if (info.containsKey("error"))
283        {
284            return (String) info.get("error");
285        }
286        else if (info.containsKey("message"))
287        {
288            return (String) info.get("message");
289        }
290        else
291        {
292            return "";
293        }
294    }
295    
296    /**
297     * Get (or create) a new user.
298     * @param userIdentity the user that will be mirrored into chat
299     * @param updateIfNotNew If the user was already existing, should it be updated (except password)?
300     * @return the user info or null if user does not exist in chat and create was not required
301     * @throws IOException something went wrong
302     * @throws UserPreferencesException error while reading the user preferences
303     * @throws InterruptedException error while reading the user preferences
304     */
305    @SuppressWarnings("unchecked")
306    public Map<String, Object> getUser(UserIdentity userIdentity, boolean updateIfNotNew) throws IOException, UserPreferencesException, InterruptedException
307    {
308        User user = _userManager.getUser(userIdentity);
309        if (user == null)
310        {
311            throw new IllegalStateException("Cannot create user in Rocket.Chat for unexisting user " + UserIdentity.userIdentityToString(userIdentity));
312        }
313        
314        Map<String, Object> userInfo = _doGet("v1/users.info", Map.of("username", _userIdentitytoUserName(userIdentity)));
315        if (!_isOperationSuccessful(userInfo))
316        {
317            Map<String, String> ametysUserInfo = getAmetysUserInfo(user, true, 64);
318            String userName = ametysUserInfo.get("userName");
319            String userEmail = ametysUserInfo.get("userEmail");
320            
321            userInfo = _doPOST("v1/users.create", Map.of("username", _userIdentitytoUserName(userIdentity),
322                                                        "email", userEmail,
323                                                        "name", userName,
324                                                        "verified", true,
325                                                        "password", _getUserPassword(userIdentity)));
326            if (!_isOperationSuccessful(userInfo))
327            {
328                throw new IllegalStateException("Cannot create user in Rocket.Chat for " + UserIdentity.userIdentityToString(userIdentity) + ": " +  _getError(userInfo));
329            }
330            
331            getLogger().debug("User " + UserIdentity.userIdentityToString(userIdentity) + " created on the chat server");
332            
333            _updateAvatar(userIdentity);
334            _getUpdatesCache().put(userIdentity, true);
335        }
336        else if (_getUpdatesCache().get(userIdentity) == null) // Lets avoid calling updateUserInfos too often since there is an unavoidable 60 seconds rate limit ROCKETCHAT-4
337        {
338            updateUserInfos(userIdentity, false);
339            _getUpdatesCache().put(userIdentity, true);
340        }
341        
342        Map<String, Object> userMap = (Map<String, Object>) userInfo.get("user");
343        
344        _userPreferencesManager.addUserPreference(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_ID, (String) userMap.get("_id"));
345        
346        
347        return userMap;
348    }
349    
350    /**
351     * Get the user info
352     * @param user The user
353     * @param externalUrl Is the image url external?
354     * @param imageSize The size of the avatar
355     * @return The name, email and avatar
356     */
357    public Map<String, String> getAmetysUserInfo(User user, boolean externalUrl, int imageSize)
358    {
359        return Map.of("userName", user.getFullName(),
360                      "userSortableName", user.getSortableName(),
361                      "userEmail", user.getEmail(),
362                      "userAvatar", externalUrl ? AmetysXSLTHelper.uriPrefix() + "/_plugins/core-ui/user/" + user.getIdentity().getPopulationId() + "/" + URIUtils.encodePath(user.getIdentity().getLogin()) + "/image_" + imageSize + "?lang=en" // TODO get the user pref of language? The purpose is to always get the same avatar in every languages since it is shared in RC
363                              : "cocoon://_plugins/core-ui/user/" + user.getIdentity().getPopulationId() + "/" + user.getIdentity().getLogin() + "/image_" + imageSize
364        );
365    }
366    
367    /**
368     * Update user name, email, avatar... on chat server
369     * @param userIdentity The user to update
370     * @param changePassword Update the user password (slow)
371     * @throws UserPreferencesException If an error occurred while getting user infos
372     * @throws IOException If an error occurred while updating
373     * @throws InterruptedException If an error occurred while updating
374     */
375    public void updateUserInfos(UserIdentity userIdentity, boolean changePassword) throws IOException, UserPreferencesException, InterruptedException
376    {
377        User user = _userManager.getUser(userIdentity);
378        if (user == null)
379        {
380            throw new IllegalStateException("Cannot update user in Rocket.Chat for unexisting user " + UserIdentity.userIdentityToString(userIdentity));
381        }
382
383        Map<String, String> ametysUserInfo = getAmetysUserInfo(user, true, 64);
384        String userName = ametysUserInfo.get("userName");
385        String userEmail = ametysUserInfo.get("userEmail");
386        
387        Map<String, String> data = new HashMap<>();
388        data.put("email", userEmail);
389        data.put("name", userName);
390        if (changePassword)
391        {
392            data.put("password", _getUserPassword(userIdentity));
393        }
394        
395        Map<String, Object> updateInfos = _doPOST("v1/users.update", Map.of("userId", _getUserId(userIdentity),
396                                                                            "data", data));
397        if (!_isOperationSuccessful(updateInfos))
398        {
399            throw new IOException("Cannot update user " + UserIdentity.userIdentityToString(userIdentity) + " on chat server: " + _getError(updateInfos));
400        }
401        
402        if (changePassword)
403        {
404            // When changing password, it unlogs people and this takes time
405            Thread.sleep(1000);
406        }
407        
408        _updateAvatar(userIdentity);
409    }
410    
411    private void _updateAvatar(UserIdentity user)
412    {
413        ContextHelper.getRequest(_context).setAttribute(AuthenticateAction.REQUEST_ATTRIBUTE_INTERNAL_ALLOWED, true);
414        
415        Source src = null;
416        try
417        {
418            User u = _userManager.getUser(user);
419            src = _sourceResolver.resolveURI(getAmetysUserInfo(u, false, 64).get("userAvatar"));
420            try (InputStream is = src.getInputStream())
421            {
422                Map<String, Object> avatarInfo = _doMultipartPOST("v1/users.setAvatar", Map.of("username", _userIdentitytoUserName(user),
423                                                                                                "image", is));
424                if (!_isOperationSuccessful(avatarInfo))
425                {
426                    getLogger().warn("Fail to update avatar for user " + UserIdentity.userIdentityToString(user) + ": " + _getError(avatarInfo));
427                }
428            }
429        }
430        catch (Exception e)
431        {
432            getLogger().warn("Fail to update avatar for user " + UserIdentity.userIdentityToString(user), e);
433        }
434        finally
435        {
436            _sourceResolver.release(src);
437        }
438        
439    }
440    
441    private String _userIdentitytoUserName(UserIdentity userIdentity)
442    {
443        return UserIdentity.userIdentityToString(userIdentity).replaceAll("[^a-zA-Z-.]", "_")
444                + "." + new String(__BASE64_ENCODER.encode(UserIdentity.userIdentityToString(userIdentity).getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8);
445    }
446    
447    private String _getUserPassword(UserIdentity userIdentity) throws UserPreferencesException
448    {
449        String cryptedPassword = _userPreferencesManager.getUserPreferenceAsString(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_PASSWORD);
450        if (!StringUtils.isBlank(cryptedPassword))
451        {
452            try
453            {
454                return _cryptoHelper.decrypt(cryptedPassword);
455            }
456            catch (CryptoHelper.WrongKeyException e)
457            {
458                getLogger().warn("Password of user {} cannot be decrypted, and thus will be reset",  UserIdentity.userIdentityToString(userIdentity), e);
459            }
460        }
461        
462        return _generateAndStorePassword(userIdentity);
463    }
464    
465    // renewIfNecessary Set to true, if there is a serious possibility that the token is no more valid
466    // as it cost, do not do it, if there was a valid request a few minutes before
467    private String _getUserAuthToken(UserIdentity userIdentity, boolean renewIfNecessary) throws UserPreferencesException, IOException, InterruptedException
468    {
469        String cryptedToken = _userPreferencesManager.getUserPreferenceAsString(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_TOKEN);
470        if (!StringUtils.isBlank(cryptedToken))
471        {
472            try
473            {
474                String token = _cryptoHelper.decrypt(cryptedToken);
475                if (renewIfNecessary)
476                {
477                    token = _renewTokenIfNecessary(userIdentity, token, _getUserId(userIdentity));
478                }
479                return token;
480            }
481            catch (CryptoHelper.WrongKeyException e)
482            {
483                getLogger().warn("Token of user {} cannot be decrypted, and thus will be reset",  UserIdentity.userIdentityToString(userIdentity), e);
484            }
485        }
486        return _generateAndStoreAuthToken(userIdentity, true);
487    }
488    
489    
490    private String _getUserId(UserIdentity userIdentity) throws UserPreferencesException
491    {
492        String userId = _userPreferencesManager.getUserPreferenceAsString(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_ID);
493        return userId;
494    }
495
496    private String _generateAndStorePassword(UserIdentity user) throws UserPreferencesException
497    {
498        Double random = Math.random();
499        byte[] randoms = {random.byteValue()};
500        String randomPassword = Sha2Crypt.sha256Crypt(randoms);
501        
502        String cryptedPassword = _cryptoHelper.encrypt(randomPassword);
503        _userPreferencesManager.addUserPreference(user, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_PASSWORD, cryptedPassword);
504        
505        return randomPassword;
506    }
507    
508    
509    @SuppressWarnings("unchecked")
510    private String _generateAndStoreAuthToken(UserIdentity user, boolean tryToChangePassword) throws IOException, UserPreferencesException, InterruptedException
511    {
512        Map<String, Object> loginInfo = _doPOST("v1/login", Map.of("user", _userIdentitytoUserName(user),
513                                                                  "password", _getUserPassword(user)));
514        if (_isOperationSuccessful(loginInfo))
515        {
516            String authToken = (String) ((Map<String, Object>) loginInfo.get("data")).get("authToken");
517            String cryptedAuthToken = _cryptoHelper.encrypt(authToken);
518            _userPreferencesManager.addUserPreference(user, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_TOKEN, cryptedAuthToken);
519            
520            return authToken;
521        }
522        else if (tryToChangePassword)
523        {
524            this.updateUserInfos(user, true);
525            
526            return _generateAndStoreAuthToken(user, false);
527        }
528        else
529        {
530            throw new IOException("Could not log user " + UserIdentity.userIdentityToString(user) + " into chat " + _getError(loginInfo));
531        }
532    }
533    
534    /**
535     * Read the JSON result to test for success
536     * @param result the JSON result of a rest call
537     * @return true if success
538     */
539    protected boolean _isOperationSuccessful(Map<String, Object> result)
540    {
541        Boolean success = false;
542        if (result != null)
543        {
544            Object successObj =  result.get("success");
545            if (successObj instanceof Boolean)
546            {
547                success = (Boolean) successObj;
548            }
549            else if (successObj instanceof String)
550            {
551                success = "true".equalsIgnoreCase((String) successObj);
552            }
553            else
554            {
555                Object statusObj =  result.get("status");
556                if (statusObj instanceof String)
557                {
558                    success = "success".equalsIgnoreCase((String) statusObj);
559                }
560            }
561        }
562        return success;
563    }
564    
565    private String _renewTokenIfNecessary(UserIdentity userIdentity, String authToken, String userId) throws IOException, UserPreferencesException, InterruptedException
566    {
567        String newToken = authToken;
568        
569        // Ensure token validity by getting status on chat server
570        String status = _computeStatus(userIdentity, authToken, userId, null); // No cache here on purpose, since we need to test the authToken
571        if (status == null)
572        {
573            // If we cannot get status, this is probably because the auth token has expired or the user was recreated. Try a new one
574            newToken = _generateAndStoreAuthToken(userIdentity, true);
575            
576            status = _computeStatus(userIdentity, authToken, userId, "offline");
577        }
578        _getStatusCache().put(userIdentity, status);
579        
580        return newToken;
581    }
582    
583    /**
584     * Login the current user
585     * @return The info about the user
586     * @throws UserPreferencesException If the user password stored in prefs has an issue
587     * @throws IOException If an error occurred
588     * @throws InterruptedException If an error occurred
589     */
590    @Callable(allowAnonymous = true)
591    public Map<String, Object> login() throws IOException, UserPreferencesException, InterruptedException
592    {
593        // Get current user in Ametys
594        UserIdentity userIdentity = _currentUserProvider.getUser();
595        if (userIdentity == null)
596        {
597            return null;
598        }
599        
600        User user = _userManager.getUser(userIdentity);
601        if (user == null)
602        {
603            return null;
604        }
605        
606        boolean notLoggedRecently = _getUpdatesCache().get(userIdentity) == null;
607        if (notLoggedRecently) // Do not call this too often to accelerate the loading of the page
608        {
609            // Ensure user exists on chat server
610            getUser(userIdentity, true);
611        }
612        
613        // Get the login info of the user
614        String authToken = _getUserAuthToken(userIdentity, notLoggedRecently);
615        String userId = _getUserId(userIdentity);
616        
617        return Map.of(
618            "authToken", authToken,
619            "userId", userId,
620            "userName", _userIdentitytoUserName(userIdentity),
621            "status", _computeStatusWithCache(userIdentity, authToken, userId),
622            "url", Config.getInstance().getValue("rocket.chat.rocket.url")
623        );
624    }
625    
626    /**
627     * Get the current cache status
628     * @return The association login#populationId &lt;-&gt; status (online, offline...)
629     * @throws InterruptedException If an error occurred
630     * @throws IOException If an error occurred
631     * @throws UserPreferencesException If an error occurred
632     */
633    @Callable(allowAnonymous = true)
634    public Map<String, String> getStatusCache() throws UserPreferencesException, IOException, InterruptedException
635    {
636        // Get current user in Ametys
637        UserIdentity userIdentity = _currentUserProvider.getUser();
638        if (userIdentity == null)
639        {
640            return null;
641        }
642
643        Request request = ContextHelper.getRequest(_context);
644        
645        String sitename = WebHelper.getSiteName(request);
646        Set<String> populations = _populationContextHelper.getUserPopulationsOnContexts(Set.of("/sites/" + sitename, "/sites-fo/" + sitename), false, true);
647        
648        return _getStatusCache().asMap().entrySet().stream()
649            .filter(e -> populations.contains(e.getKey().getPopulationId()))
650            .map(e -> Pair.of(UserIdentity.userIdentityToString(e.getKey()), e.getValue()))
651            .collect(Collectors.toMap(Pair::getKey, Pair::getValue));
652    }
653    
654    /**
655     * Set the current user new status
656     * @param newStatus The new status between online, offline, busy or away
657     * @throws InterruptedException If an error occurred
658     * @throws IOException If an error occurred
659     * @throws UserPreferencesException If an error occurred
660     */
661    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
662    public void setStatus(String newStatus) throws UserPreferencesException, IOException, InterruptedException
663    {
664        // Get current user in Ametys
665        UserIdentity userIdentity = _currentUserProvider.getUser();
666        
667        // Get the login info of the user
668        String authToken = _getUserAuthToken(userIdentity, false);
669        String userId = _getUserId(userIdentity);
670
671        Map<String, Object> response = _doPOST("v1/users.setStatus", Map.of("message", "-", "status", newStatus), authToken, userId);
672        if (!_isOperationSuccessful(response))
673        {
674            getLogger().error("Cannot set status of " + userIdentity + " because: " + response.get("error"));
675        }
676        else
677        {
678            _getStatusCache().put(userIdentity, newStatus);
679        }
680    }
681    
682    /**
683     * Get the last messages of the current user
684     * @return The messages
685     * @throws IOException something went wrong
686     * @throws UserPreferencesException something went wrong
687     * @throws InterruptedException something went wrong
688     */
689    @SuppressWarnings("unchecked")
690    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
691    public Collection<Map<String, Object>> getLastMessages() throws IOException, UserPreferencesException, InterruptedException
692    {
693        Map<String, Map<String, Object>> responses = new LinkedHashMap<>();
694        
695        // Get current user in Ametys
696        UserIdentity user = _currentUserProvider.getUser();
697        
698        // Get the login info of the user
699        String authToken = _getUserAuthToken(user, false);
700        String userId = _getUserId(user);
701        
702        Map<String, Object> statusInfo = _doGet("v1/im.list", Map.of("sort", "{ \"_updatedAt\": -1 }"), authToken, userId);
703        if (_isOperationSuccessful(statusInfo))
704        {
705            List<Map<String, Object>> ims = (List<Map<String, Object>>) statusInfo.get("ims");
706            for (Map<String, Object> im : ims)
707            {
708                List<User> users = _getUsers((List<String>) im.get("_USERNAMES"), UserIdentity.userIdentityToString(user).toString());
709                
710                if (users.size() > 0)
711                {
712                    Map<String, Object> response = new HashMap<>();
713                    response.putAll(Map.of(
714                        "id", im.get("_id"),
715                        "authors", users.stream().map(u -> Map.of(
716                                "identity", UserIdentity.userIdentityToString(u.getIdentity()),
717                                "fullname", u.getFullName(),
718                                "avatar", getAmetysUserInfo(u, true, 76).get("userAvatar"),
719                                "status", users.size() == 1 ? _computeStatusWithCache(u.getIdentity(), authToken, userId) : "")).toList(),
720                        "lastDate", im.get("_updatedAt"),
721                        "lastMessage", _getLastMessage((Map<String, Object>) im.get("lastMessage"))
722                    ));
723                    
724                    responses.put((String) im.get("_id"), response);
725                }
726            }
727            
728            Map<String, Object> unreadInfo = _doGet("v1/subscriptions.get", Map.of(), authToken, userId);
729            if (unreadInfo != null)
730            {
731                List<Map<String, Object>> updates = (List<Map<String, Object>>) unreadInfo.get("update");
732                for (Map<String, Object> update : updates)
733                {
734                    String id = (String) update.get("rid");
735                    Map<String, Object> response = responses.get(id);
736                    if (response != null)
737                    {
738                        response.put("unread", (int) update.get("unread"));
739                        response.put("mentions", ((int) update.get("userMentions")) > 0
740                                                || ((int) update.get("groupMentions")) > 0);
741                    }
742                }
743            }
744            return responses.values();
745        }
746        else
747        {
748            getLogger().error("Cannot get last messages of " + user + " because: " + statusInfo.get("error"));
749            return null;
750        }
751        
752    }
753    
754    private String _computeStatusWithCache(UserIdentity user, String authToken, String userId)
755    {
756        return _getStatusCache().get(user, __ -> _computeStatus(user, authToken, userId, "offline"));
757    }
758    
759    private String _computeStatus(UserIdentity user, String authToken, String userId, String defaultValue)
760    {
761        try
762        {
763            Map<String, Object> statusInfo = _doGet("v1/users.getStatus", Map.of("username", _userIdentitytoUserName(user)), authToken, userId);
764            if (!_isOperationSuccessful(statusInfo))
765            {
766                getLogger().error("Cannot get status of user " + UserIdentity.userIdentityToString(user) + " because Rocket.Chat returned: " + statusInfo.get("error"));
767                return defaultValue;
768            }
769            else
770            {
771                return (String) statusInfo.get("status");
772            }
773        }
774        catch (IOException e)
775        {
776            throw new RuntimeException(e);
777        }
778    }
779    
780    private String _usernameToUserIdentity(String username)
781    {
782        return Optional.ofNullable(username)
783                        .filter(u -> u.contains("."))
784                        .map(u -> StringUtils.substringAfterLast(u, "."))
785                        .map(b64 -> new String(__BASE64_DECODER.decode(b64.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8))
786                        .orElse("");
787    }
788    
789    private List<User> _getUsers(List<String> usernames, String avoid)
790    {
791        return Optional.ofNullable(usernames)
792                .map(us -> us.stream()
793                            .map(u -> _usernameToUserIdentity(u))
794                            .filter(u -> StringUtils.isNotBlank(u) && !StringUtils.equals(u, avoid))
795                            .map(UserIdentity::stringToUserIdentity)
796                            .map(ud -> _userManager.getUser(ud))
797                            .toList())
798                .orElse(List.of());
799    }
800
801    @SuppressWarnings("unchecked")
802    private Map<String, Object> _getLastMessage(Map<String, Object> lastMessage)
803    {
804        if (lastMessage == null)
805        {
806            return Map.of();
807        }
808        
809        return Map.of(
810            "author", _usernameToUserIdentity(((Map<String, String>) lastMessage.get("u")).get("username")),
811            "message", _getMessageText(lastMessage)
812        );
813    }
814    
815    private String _getMessageText(Map<String, Object> message)
816    {
817        String simpleText = (String) message.get("msg");
818        
819        @SuppressWarnings("unchecked")
820        List<Map<String, Object>> attachments = (List<Map<String, Object>>) message.get("attachments");
821        
822        if (StringUtils.isNotBlank(simpleText))
823        {
824            return simpleText.replaceAll("\\[([^\\]]+)\\]\\([^)]+\\)", "$1") // Link with description text: [Ametys](https://www.ametys.org) => Ametys
825                             .replaceAll("\\[[^\\]]*\\]\\(([^)]+)\\)", "$1") // Link with NO description text: [](https://www.ametys.org) => https://www.ametys.org
826                             .replaceAll("\\*([^\\s][^\\n]*)\\*", "$1") // Bold text: *test* => test
827                             .replaceAll("_([^\\s][^\\n]*)_", "$1") // Underline text: _test_ => test
828                             .replaceAll("~([^\s][^\n]*)~", "$1") // Stroke text: ~test~ => test
829                             .replaceAll("```", "") // Multiline code: `test` => test (same as rocket.chat) do
830                             .replaceAll("`([^\n]*)`", "$1"); // Inline code: `test` => test
831            
832        }
833        else if (attachments != null && attachments.size() > 0)
834        {
835            return StringUtils.defaultIfBlank((String) attachments.get(0).get("description"), (String) attachments.get(0).get("title"));
836        }
837        else
838        {
839            return "";
840        }
841    }
842    
843    /**
844     * Creates a new chat with the current user and the given users
845     * @param users The parteners
846     * @return The chat id
847     * @throws InterruptedException If an error occurred
848     * @throws IOException If an error occurred
849     * @throws UserPreferencesException If an error occurred
850     */
851    @SuppressWarnings("unchecked")
852    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
853    public String createChat(List<String> users) throws UserPreferencesException, IOException, InterruptedException
854    {
855        // Get current user in Ametys
856        UserIdentity user = _currentUserProvider.getUser();
857        
858        // Get the login info of the user
859        String authToken = _getUserAuthToken(user, false);
860        String userId = _getUserId(user);
861        
862        // Creates users if not existing
863        List<String> rcUsers = users.stream()
864            .map(UserIdentity::stringToUserIdentity)
865            .map(LambdaUtils.wrap(ud -> getUser(ud, false)))
866            .map(m -> (String) m.get("username"))
867            .toList();
868
869        List<String> finalRcUsers = new ArrayList<>(rcUsers);
870        // finalRcUsers.addFirst(userIdentitytoUserName(user));
871        
872        Map<String, Object> statusInfo = _doPOST("v1/im.create", Map.of("usernames", StringUtils.join(finalRcUsers, ", ")), authToken, userId);
873        if (!_isOperationSuccessful(statusInfo))
874        {
875            getLogger().error("Cannot create the new chat: " + statusInfo.get("message"));
876            return null;
877        }
878        
879        return (String) ((Map<String, Object>) statusInfo.get("room")).get("rid");
880    }
881    
882    /**
883     * List all the users with messages in the given time window
884     * @param since The date since the messages should be considered
885     * @return The list of users
886     * @throws IOException If an error occurred
887     */
888    public Set<UserIdentity> getUsersWithRecentMessages(ZonedDateTime since) throws IOException
889    {
890        Set<UserIdentity> users = new HashSet<>();
891        
892        int offset = 0;
893        final int count = 50;
894        int total = 1;
895
896        while (total > offset)
897        {
898            Map<String, Object> dmEveryone = _doGet("v1/im.list.everyone", Map.of(
899                "sort", "{ \"_updatedAt\": -1 }",
900                "offset", Integer.toString(offset),
901                "count", Integer.toString(count)
902            ));
903    
904            if (!_isOperationSuccessful(dmEveryone))
905            {
906                throw new IOException("Cannot get the DM: " + dmEveryone.get("message"));
907            }
908            
909            total = (int) dmEveryone.get("total");
910
911            @SuppressWarnings("unchecked")
912            List<Map<String, Object>> ims = (List<Map<String, Object>>) dmEveryone.get("ims");
913            for (Map<String, Object> im : ims)
914            {
915                String updatedAtString = (String) im.get("_updatedAt");
916                ZonedDateTime updatedAt = DateUtils.parseZonedDateTime(updatedAtString);
917                
918                if (updatedAt.compareTo(since) < 0)
919                {
920                    offset = total;
921                    break;
922                }
923                
924                @SuppressWarnings("unchecked")
925                Map<String, Object> lastMessage = (Map<String, Object>) im.get("lastMessage");
926                if (lastMessage != null)
927                {
928                    ZonedDateTime ts = DateUtils.parseZonedDateTime((String) lastMessage.get("ts"));
929                    
930                    if (ts.compareTo(since) >= 0)
931                    {
932                        @SuppressWarnings("unchecked")
933                        List<String> usernames = (List<String>) im.get("usernames");
934                        for (String username : usernames)
935                        {
936                            UserIdentity user = UserIdentity.stringToUserIdentity(_usernameToUserIdentity(username));
937                            users.add(user);
938                        }
939                    }
940                }
941            }
942            
943            offset += count;
944        }
945
946        return users;
947    }
948    
949    /**
950     * List the unread messages of a user in the given time window
951     * @param user The user to consider
952     * @param since The date to consider
953     * @return The ids of the room containing unread messages
954     * @throws IOException If an error occurred
955     * @throws UserPreferencesException If an error occurred
956     * @throws InterruptedException If an error occurred
957     */
958    public Set<RoomInfo> getThreadsWithUnreadMessages(UserIdentity user, ZonedDateTime since) throws IOException, UserPreferencesException, InterruptedException
959    {
960        Set<RoomInfo>  roomsInfos = new HashSet<>();
961        
962        // Ensure user exists on chat server
963        getUser(user, false);
964        
965        // Get the login info of the user
966        String authToken = _getUserAuthToken(user, true);
967        String userId = _getUserId(user);
968        
969        Map<String, Object> messages = _doGet("v1/subscriptions.get", Map.of(
970            "updatedSince", DateUtils.zonedDateTimeToString(since)
971        ), authToken, userId);
972        
973        if (!_isOperationSuccessful(messages))
974        {
975            throw new IOException("Cannot get the messages of " + UserIdentity.userIdentityToString(user) + ": " + messages.get("message"));
976        }
977        
978        @SuppressWarnings("unchecked")
979        List<Map<String, Object>> updates = (List<Map<String, Object>>) messages.get("update");
980        for (Map<String, Object> update : updates)
981        {
982            int unread = (int) update.get("unread");
983            if (unread > 0)
984            {
985                String roomId = (String) update.get("rid");
986                String roomLabel = (String) update.get("fname");
987                
988                roomsInfos.add(new RoomInfo(roomId, roomLabel, unread));
989            }
990        }
991        
992        return roomsInfos;
993    }
994    
995    /**
996     * Get the n last messages of the user in the room
997     * @param user The user to consider
998     * @param roomId The room id to consider
999     * @param count The number of messages to retrieve
1000     * @param since Since the max date
1001     * @return The message
1002     * @throws IOException If an error occurred
1003     * @throws UserPreferencesException If an error occurred
1004     * @throws InterruptedException If an error occurred
1005     */
1006    public List<Message> getLastMessages(UserIdentity user, String roomId, int count, ZonedDateTime since) throws IOException, UserPreferencesException, InterruptedException
1007    {
1008        List<Message> messagesReceived = new ArrayList<>();
1009        
1010        // Get the login info of the user
1011        String authToken = _getUserAuthToken(user, false);
1012        String userId = _getUserId(user);
1013
1014        Map<String, Object> messagesInfo = _doGet("v1/im.messages", Map.of(
1015            "sort", "{ \"_updatedAt\": -1 }",
1016            "roomId", roomId,
1017            "count", Integer.toString(count)
1018        ), authToken, userId);
1019        
1020        if (!_isOperationSuccessful(messagesInfo))
1021        {
1022            if ("[invalid-channel]".equals(messagesInfo.get("error")))
1023            {
1024                // can happen when destroying/recreating users
1025                return List.of();
1026            }
1027            throw new IOException("Cannot get the messages of " + UserIdentity.userIdentityToString(user) + ": " + messagesInfo.get("error"));
1028        }
1029        
1030        @SuppressWarnings("unchecked")
1031        List<Map<String, Object>> messages = (List<Map<String, Object>>) messagesInfo.get("messages");
1032        for (Map<String, Object> message : messages)
1033        {
1034            ZonedDateTime ts = DateUtils.parseZonedDateTime((String) message.get("ts"));
1035            
1036            if (ts.compareTo(since) >= 0)
1037            {
1038                String text = _getMessageText(message);
1039                @SuppressWarnings("unchecked")
1040                UserIdentity author = UserIdentity.stringToUserIdentity(_usernameToUserIdentity((String) ((Map<String, Object>) message.get("u")).get("username")));
1041                ZonedDateTime date = ts;
1042                
1043                messagesReceived.add(new Message(author, date, text));
1044            }
1045        }
1046        
1047        messagesReceived.sort((d1, d2) -> d1.date().compareTo(d2.date()));
1048        
1049        return messagesReceived;
1050    }
1051    
1052    /**
1053     * A Rocket.Chat message
1054     * @param author The author of the message
1055     * @param date The date of the message
1056     * @param text The text of the message
1057     */
1058    public record Message(UserIdentity author, ZonedDateTime date, String text) { /* empty */ }
1059    /**
1060     * A Rocket.Chat room info for a user
1061     * @param roomId The room id
1062     * @param roomLabel The room name
1063     * @param unread The number of unread items for the user
1064     */
1065    public record RoomInfo(String roomId, String roomLabel, int unread) { /* empty */ }
1066}