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