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 <-> 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}