You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1348 lines
51 KiB

  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OCA\Talk\Controller;
  8. use OCA\Talk\Chat\AutoComplete\SearchPlugin;
  9. use OCA\Talk\Chat\AutoComplete\Sorter;
  10. use OCA\Talk\Chat\ChatManager;
  11. use OCA\Talk\Chat\MessageParser;
  12. use OCA\Talk\Chat\Notifier;
  13. use OCA\Talk\Chat\ReactionManager;
  14. use OCA\Talk\Exceptions\CannotReachRemoteException;
  15. use OCA\Talk\Federation\Authenticator;
  16. use OCA\Talk\GuestManager;
  17. use OCA\Talk\MatterbridgeManager;
  18. use OCA\Talk\Middleware\Attribute\FederationSupported;
  19. use OCA\Talk\Middleware\Attribute\RequireAuthenticatedParticipant;
  20. use OCA\Talk\Middleware\Attribute\RequireLoggedInParticipant;
  21. use OCA\Talk\Middleware\Attribute\RequireModeratorOrNoLobby;
  22. use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant;
  23. use OCA\Talk\Middleware\Attribute\RequireParticipant;
  24. use OCA\Talk\Middleware\Attribute\RequirePermission;
  25. use OCA\Talk\Middleware\Attribute\RequireReadWriteConversation;
  26. use OCA\Talk\Model\Attachment;
  27. use OCA\Talk\Model\Attendee;
  28. use OCA\Talk\Model\Bot;
  29. use OCA\Talk\Model\Message;
  30. use OCA\Talk\Model\Session;
  31. use OCA\Talk\Participant;
  32. use OCA\Talk\ResponseDefinitions;
  33. use OCA\Talk\Room;
  34. use OCA\Talk\Service\AttachmentService;
  35. use OCA\Talk\Service\AvatarService;
  36. use OCA\Talk\Service\BotService;
  37. use OCA\Talk\Service\ParticipantService;
  38. use OCA\Talk\Service\ProxyCacheMessageService;
  39. use OCA\Talk\Service\ReminderService;
  40. use OCA\Talk\Service\RoomFormatter;
  41. use OCA\Talk\Service\SessionService;
  42. use OCA\Talk\Share\Helper\FilesMetadataCache;
  43. use OCA\Talk\Share\RoomShareProvider;
  44. use OCP\App\IAppManager;
  45. use OCP\AppFramework\Db\DoesNotExistException;
  46. use OCP\AppFramework\Http;
  47. use OCP\AppFramework\Http\Attribute\NoAdminRequired;
  48. use OCP\AppFramework\Http\Attribute\PublicPage;
  49. use OCP\AppFramework\Http\Attribute\UserRateLimit;
  50. use OCP\AppFramework\Http\DataResponse;
  51. use OCP\AppFramework\Utility\ITimeFactory;
  52. use OCP\Collaboration\AutoComplete\IManager;
  53. use OCP\Collaboration\Collaborators\ISearchResult;
  54. use OCP\Comments\IComment;
  55. use OCP\Comments\MessageTooLongException;
  56. use OCP\Comments\NotFoundException;
  57. use OCP\EventDispatcher\IEventDispatcher;
  58. use OCP\IL10N;
  59. use OCP\IRequest;
  60. use OCP\IUserManager;
  61. use OCP\RichObjectStrings\InvalidObjectExeption;
  62. use OCP\RichObjectStrings\IValidator;
  63. use OCP\Security\ITrustedDomainHelper;
  64. use OCP\Security\RateLimiting\IRateLimitExceededException;
  65. use OCP\Share\Exceptions\ShareNotFound;
  66. use OCP\Share\IShare;
  67. use OCP\User\Events\UserLiveStatusEvent;
  68. use OCP\UserStatus\IManager as IUserStatusManager;
  69. use OCP\UserStatus\IUserStatus;
  70. /**
  71. * @psalm-import-type TalkChatMentionSuggestion from ResponseDefinitions
  72. * @psalm-import-type TalkChatMessage from ResponseDefinitions
  73. * @psalm-import-type TalkChatMessageWithParent from ResponseDefinitions
  74. * @psalm-import-type TalkChatReminder from ResponseDefinitions
  75. * @psalm-import-type TalkRoom from ResponseDefinitions
  76. */
  77. class ChatController extends AEnvironmentAwareController {
  78. /** @var string[] */
  79. protected array $guestNames;
  80. public function __construct(
  81. string $appName,
  82. private ?string $userId,
  83. IRequest $request,
  84. private IUserManager $userManager,
  85. private IAppManager $appManager,
  86. private ChatManager $chatManager,
  87. private RoomFormatter $roomFormatter,
  88. private ReactionManager $reactionManager,
  89. private ParticipantService $participantService,
  90. private SessionService $sessionService,
  91. protected AttachmentService $attachmentService,
  92. protected AvatarService $avatarService,
  93. protected ReminderService $reminderService,
  94. private GuestManager $guestManager,
  95. private MessageParser $messageParser,
  96. protected RoomShareProvider $shareProvider,
  97. protected FilesMetadataCache $filesMetadataCache,
  98. private IManager $autoCompleteManager,
  99. private IUserStatusManager $statusManager,
  100. protected MatterbridgeManager $matterbridgeManager,
  101. protected BotService $botService,
  102. private SearchPlugin $searchPlugin,
  103. private ISearchResult $searchResult,
  104. protected ITimeFactory $timeFactory,
  105. protected IEventDispatcher $eventDispatcher,
  106. protected IValidator $richObjectValidator,
  107. protected ITrustedDomainHelper $trustedDomainHelper,
  108. private IL10N $l,
  109. protected Authenticator $federationAuthenticator,
  110. protected ProxyCacheMessageService $pcmService,
  111. protected Notifier $notifier,
  112. ) {
  113. parent::__construct($appName, $request);
  114. }
  115. /**
  116. * @return list{0: Attendee::ACTOR_*, 1: string}
  117. */
  118. protected function getActorInfo(string $actorDisplayName = ''): array {
  119. $remoteCloudId = $this->federationAuthenticator->getCloudId();
  120. if ($remoteCloudId !== '') {
  121. if ($actorDisplayName) {
  122. $this->participantService->updateDisplayNameForActor(Attendee::ACTOR_FEDERATED_USERS, $remoteCloudId, $actorDisplayName);
  123. }
  124. return [Attendee::ACTOR_FEDERATED_USERS, $remoteCloudId];
  125. }
  126. if ($this->userId === null) {
  127. if ($actorDisplayName) {
  128. $this->guestManager->updateName($this->room, $this->participant, $actorDisplayName);
  129. }
  130. return [Attendee::ACTOR_GUESTS, $this->participant->getAttendee()->getActorId()];
  131. }
  132. if ($this->userId === MatterbridgeManager::BRIDGE_BOT_USERID && $actorDisplayName) {
  133. return [Attendee::ACTOR_BRIDGED, str_replace(['/', '"'], '', $actorDisplayName)];
  134. }
  135. return [Attendee::ACTOR_USERS, $this->userId];
  136. }
  137. /**
  138. * @return DataResponse<Http::STATUS_CREATED, ?TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>
  139. */
  140. protected function parseCommentToResponse(IComment $comment, ?Message $parentMessage = null): DataResponse {
  141. $chatMessage = $this->messageParser->createMessage($this->room, $this->participant, $comment, $this->l);
  142. $this->messageParser->parseMessage($chatMessage);
  143. if (!$chatMessage->getVisibility()) {
  144. $headers = [];
  145. if ($this->participant->getAttendee()->getReadPrivacy() === Participant::PRIVACY_PUBLIC) {
  146. $headers = ['X-Chat-Last-Common-Read' => (string) $this->chatManager->getLastCommonReadMessage($this->room)];
  147. }
  148. return new DataResponse(null, Http::STATUS_CREATED, $headers);
  149. }
  150. $data = $chatMessage->toArray($this->getResponseFormat());
  151. if ($parentMessage instanceof Message) {
  152. $data['parent'] = $parentMessage->toArray($this->getResponseFormat());
  153. }
  154. $headers = [];
  155. if ($this->participant->getAttendee()->getReadPrivacy() === Participant::PRIVACY_PUBLIC) {
  156. $headers = ['X-Chat-Last-Common-Read' => (string) $this->chatManager->getLastCommonReadMessage($this->room)];
  157. }
  158. return new DataResponse($data, Http::STATUS_CREATED, $headers);
  159. }
  160. /**
  161. * Sends a new chat message to the given room
  162. *
  163. * The author and timestamp are automatically set to the current user/guest
  164. * and time.
  165. *
  166. * @param string $message the message to send
  167. * @param string $actorDisplayName for guests
  168. * @param string $referenceId for the message to be able to later identify it again
  169. * @param int $replyTo Parent id which this message is a reply to
  170. * @psalm-param non-negative-int $replyTo
  171. * @param bool $silent If sent silent the chat message will not create any notifications
  172. * @return DataResponse<Http::STATUS_CREATED, ?TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_REQUEST_ENTITY_TOO_LARGE|Http::STATUS_TOO_MANY_REQUESTS, array<empty>, array{}>
  173. *
  174. * 201: Message sent successfully
  175. * 400: Sending message is not possible
  176. * 404: Actor not found
  177. * 413: Message too long
  178. * 429: Mention rate limit exceeded (guests only)
  179. */
  180. #[FederationSupported]
  181. #[PublicPage]
  182. #[RequireModeratorOrNoLobby]
  183. #[RequireParticipant]
  184. #[RequirePermission(permission: RequirePermission::CHAT)]
  185. #[RequireReadWriteConversation]
  186. public function sendMessage(string $message, string $actorDisplayName = '', string $referenceId = '', int $replyTo = 0, bool $silent = false): DataResponse {
  187. if ($this->room->isFederatedConversation()) {
  188. /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController $proxy */
  189. $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController::class);
  190. return $proxy->sendMessage($this->room, $this->participant, $message, $referenceId, $replyTo, $silent);
  191. }
  192. if (trim($message) === '') {
  193. return new DataResponse([], Http::STATUS_BAD_REQUEST);
  194. }
  195. [$actorType, $actorId] = $this->getActorInfo($actorDisplayName);
  196. if (!$actorId) {
  197. return new DataResponse([], Http::STATUS_NOT_FOUND);
  198. }
  199. $parent = $parentMessage = null;
  200. if ($replyTo !== 0) {
  201. try {
  202. $parent = $this->chatManager->getParentComment($this->room, (string) $replyTo);
  203. } catch (NotFoundException $e) {
  204. // Someone is trying to reply cross-rooms or to a non-existing message
  205. return new DataResponse([], Http::STATUS_BAD_REQUEST);
  206. }
  207. $parentMessage = $this->messageParser->createMessage($this->room, $this->participant, $parent, $this->l);
  208. $this->messageParser->parseMessage($parentMessage);
  209. if (!$parentMessage->isReplyable()) {
  210. return new DataResponse([], Http::STATUS_BAD_REQUEST);
  211. }
  212. }
  213. $this->participantService->ensureOneToOneRoomIsFilled($this->room);
  214. $creationDateTime = $this->timeFactory->getDateTime('now', new \DateTimeZone('UTC'));
  215. try {
  216. $comment = $this->chatManager->sendMessage($this->room, $this->participant, $actorType, $actorId, $message, $creationDateTime, $parent, $referenceId, $silent);
  217. } catch (MessageTooLongException) {
  218. return new DataResponse([], Http::STATUS_REQUEST_ENTITY_TOO_LARGE);
  219. } catch (IRateLimitExceededException) {
  220. return new DataResponse([], Http::STATUS_TOO_MANY_REQUESTS);
  221. } catch (\Exception $e) {
  222. return new DataResponse([], Http::STATUS_BAD_REQUEST);
  223. }
  224. return $this->parseCommentToResponse($comment, $parentMessage);
  225. }
  226. /**
  227. * Sends a rich-object to the given room
  228. *
  229. * The author and timestamp are automatically set to the current user/guest
  230. * and time.
  231. *
  232. * @param string $objectType Type of the object
  233. * @param string $objectId ID of the object
  234. * @param string $metaData Additional metadata
  235. * @param string $actorDisplayName Guest name
  236. * @param string $referenceId Reference ID
  237. * @return DataResponse<Http::STATUS_CREATED, ?TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_REQUEST_ENTITY_TOO_LARGE, array<empty>, array{}>
  238. *
  239. * 201: Object shared successfully
  240. * 400: Sharing object is not possible
  241. * 404: Actor not found
  242. * 413: Message too long
  243. */
  244. #[PublicPage]
  245. #[RequireModeratorOrNoLobby]
  246. #[RequireParticipant]
  247. #[RequirePermission(permission: RequirePermission::CHAT)]
  248. #[RequireReadWriteConversation]
  249. public function shareObjectToChat(string $objectType, string $objectId, string $metaData = '', string $actorDisplayName = '', string $referenceId = ''): DataResponse {
  250. [$actorType, $actorId] = $this->getActorInfo($actorDisplayName);
  251. if (!$actorId) {
  252. return new DataResponse([], Http::STATUS_NOT_FOUND);
  253. }
  254. $data = $metaData !== '' ? json_decode($metaData, true) : [];
  255. if (!is_array($data)) {
  256. $data = [];
  257. }
  258. $data['type'] = $objectType;
  259. $data['id'] = $objectId;
  260. $data['icon-url'] = $this->avatarService->getAvatarUrl($this->room);
  261. if (isset($data['link']) && !$this->trustedDomainHelper->isTrustedUrl($data['link'])) {
  262. return new DataResponse([], Http::STATUS_BAD_REQUEST);
  263. }
  264. try {
  265. $this->richObjectValidator->validate('{object}', ['object' => $data]);
  266. } catch (InvalidObjectExeption $e) {
  267. return new DataResponse([], Http::STATUS_BAD_REQUEST);
  268. }
  269. if ($data['type'] === 'geo-location'
  270. && !preg_match(ChatManager::GEO_LOCATION_VALIDATOR, $data['id'])) {
  271. return new DataResponse([], Http::STATUS_BAD_REQUEST);
  272. }
  273. $this->participantService->ensureOneToOneRoomIsFilled($this->room);
  274. $creationDateTime = $this->timeFactory->getDateTime('now', new \DateTimeZone('UTC'));
  275. $message = json_encode([
  276. 'message' => 'object_shared',
  277. 'parameters' => [
  278. 'objectType' => $objectType,
  279. 'objectId' => $objectId,
  280. 'metaData' => $data,
  281. ],
  282. ]);
  283. try {
  284. $comment = $this->chatManager->addSystemMessage($this->room, $actorType, $actorId, $message, $creationDateTime, true, $referenceId);
  285. } catch (MessageTooLongException $e) {
  286. return new DataResponse([], Http::STATUS_REQUEST_ENTITY_TOO_LARGE);
  287. } catch (\Exception $e) {
  288. return new DataResponse([], Http::STATUS_BAD_REQUEST);
  289. }
  290. return $this->parseCommentToResponse($comment);
  291. }
  292. /*
  293. * Gather share IDs from the comments and preload share definitions
  294. * and files metadata to avoid separate database query for each
  295. * individual share/node later on.
  296. *
  297. * @param IComment[] $comments
  298. */
  299. protected function preloadShares(array $comments): void {
  300. // Scan messages for share IDs
  301. $shareIds = [];
  302. foreach ($comments as $comment) {
  303. $verb = $comment->getVerb();
  304. if ($verb === 'object_shared') {
  305. $message = $comment->getMessage();
  306. $data = json_decode($message, true);
  307. if (isset($data['parameters']['share'])) {
  308. $shareIds[] = $data['parameters']['share'];
  309. }
  310. }
  311. }
  312. if (!empty($shareIds)) {
  313. // Retrieved Share objects will be cached by
  314. // the RoomShareProvider and returned from the cache to
  315. // the Parser\SystemMessage without additional database queries.
  316. $shares = $this->shareProvider->getSharesByIds($shareIds);
  317. // Preload files metadata as well
  318. $fileIds = array_filter(array_map(static fn (IShare $share) => $share->getNodeId(), $shares));
  319. $this->filesMetadataCache->preloadMetadata($fileIds);
  320. }
  321. }
  322. /**
  323. * Receives chat messages from the given room
  324. *
  325. * - Receiving the history ($lookIntoFuture=0):
  326. * The next $limit messages after $lastKnownMessageId will be returned.
  327. * The new $lastKnownMessageId for the follow up query is available as
  328. * `X-Chat-Last-Given` header.
  329. *
  330. * - Looking into the future ($lookIntoFuture=1):
  331. * If there are currently no messages the response will not be sent
  332. * immediately. Instead, HTTP connection will be kept open waiting for new
  333. * messages to arrive and, when they do, then the response will be sent. The
  334. * connection will not be kept open indefinitely, though; the number of
  335. * seconds to wait for new messages to arrive can be set using the timeout
  336. * parameter; the default timeout is 30 seconds, maximum timeout is 60
  337. * seconds. If the timeout ends a successful but empty response will be
  338. * sent.
  339. * If messages have been returned (status=200) the new $lastKnownMessageId
  340. * for the follow up query is available as `X-Chat-Last-Given` header.
  341. *
  342. * The limit specifies the maximum number of messages that will be returned,
  343. * although the actual number of returned messages could be lower if some
  344. * messages are not visible to the participant. Note that if none of the
  345. * messages are visible to the participant the returned number of messages
  346. * will be 0, yet the status will still be 200. Also note that
  347. * `X-Chat-Last-Given` may reference a message not visible and thus not
  348. * returned, but it should be used nevertheless as the $lastKnownMessageId
  349. * for the follow-up query.
  350. *
  351. * @param 0|1 $lookIntoFuture Polling for new messages (1) or getting the history of the chat (0)
  352. * @param int $limit Number of chat messages to receive (100 by default, 200 at most)
  353. * @param int $lastKnownMessageId The last known message (serves as offset)
  354. * @psalm-param non-negative-int $lastKnownMessageId
  355. * @param int $lastCommonReadId The last known common read message
  356. * (so the response is 200 instead of 304 when
  357. * it changes even when there are no messages)
  358. * @psalm-param non-negative-int $lastCommonReadId
  359. * @param int<0, 30> $timeout Number of seconds to wait for new messages (30 by default, 30 at most)
  360. * @param 0|1 $setReadMarker Automatically set the last read marker when 1,
  361. * if your client does this itself via chat/{token}/read set to 0
  362. * @param 0|1 $includeLastKnown Include the $lastKnownMessageId in the messages when 1 (default 0)
  363. * @param 0|1 $noStatusUpdate When the user status should not be automatically set to online set to 1 (default 0)
  364. * @param 0|1 $markNotificationsAsRead Set to 0 when notifications should not be marked as read (default 1)
  365. * @return DataResponse<Http::STATUS_OK, TalkChatMessageWithParent[], array{'X-Chat-Last-Common-Read'?: numeric-string, X-Chat-Last-Given?: numeric-string}>|DataResponse<Http::STATUS_NOT_MODIFIED, array<empty>, array<empty>>
  366. *
  367. * 200: Messages returned
  368. * 304: No messages
  369. */
  370. #[FederationSupported]
  371. #[PublicPage]
  372. #[RequireModeratorOrNoLobby]
  373. #[RequireParticipant]
  374. public function receiveMessages(int $lookIntoFuture,
  375. int $limit = 100,
  376. int $lastKnownMessageId = 0,
  377. int $lastCommonReadId = 0,
  378. int $timeout = 30,
  379. int $setReadMarker = 1,
  380. int $includeLastKnown = 0,
  381. int $noStatusUpdate = 0,
  382. int $markNotificationsAsRead = 1): DataResponse {
  383. $limit = min(200, $limit);
  384. $timeout = min(30, $timeout);
  385. if ($this->room->isFederatedConversation()) {
  386. /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController $proxy */
  387. $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController::class);
  388. return $proxy->receiveMessages(
  389. $this->room,
  390. $this->participant,
  391. $lookIntoFuture,
  392. $limit,
  393. $lastKnownMessageId,
  394. $lastCommonReadId,
  395. $timeout,
  396. $setReadMarker,
  397. $includeLastKnown,
  398. $noStatusUpdate,
  399. $markNotificationsAsRead,
  400. );
  401. }
  402. $session = $this->participant->getSession();
  403. if ($noStatusUpdate === 0 && $session instanceof Session) {
  404. // The mobile apps dont do internal signaling unless in a call
  405. $isMobileApp = $this->request->isUserAgent([
  406. IRequest::USER_AGENT_TALK_ANDROID,
  407. IRequest::USER_AGENT_TALK_IOS,
  408. ]);
  409. if ($isMobileApp && $session->getInCall() === Participant::FLAG_DISCONNECTED) {
  410. $this->sessionService->updateLastPing($session, $this->timeFactory->getTime());
  411. if ($lookIntoFuture) {
  412. $attendee = $this->participant->getAttendee();
  413. if ($attendee->getActorType() === Attendee::ACTOR_USERS) {
  414. // Bump the user status again
  415. $event = new UserLiveStatusEvent(
  416. $this->userManager->get($attendee->getActorId()),
  417. IUserStatus::ONLINE,
  418. $this->timeFactory->getTime()
  419. );
  420. $this->eventDispatcher->dispatchTyped($event);
  421. }
  422. }
  423. }
  424. }
  425. /**
  426. * Automatic last read message marking for old clients
  427. * This is pretty dumb and does not give the best and native feeling
  428. * you are used to from other chat apps. The clients should manually
  429. * set the read marker depending on the view port of the set of messages.
  430. *
  431. * We are only setting it automatically here for old clients and the
  432. * web UI, until it can be fixed in Vue. To not use too much broken data,
  433. * we only update the read marker to the last known id, when it is higher
  434. * then the current read marker.
  435. */
  436. $attendee = $this->participant->getAttendee();
  437. if ($lookIntoFuture && $setReadMarker === 1 &&
  438. $lastKnownMessageId > $attendee->getLastReadMessage()) {
  439. $this->participantService->updateLastReadMessage($this->participant, $lastKnownMessageId);
  440. }
  441. $currentUser = $this->userManager->get($this->userId);
  442. if ($lookIntoFuture) {
  443. $comments = $this->chatManager->waitForNewMessages($this->room, $lastKnownMessageId, $limit, $timeout, $currentUser, (bool)$includeLastKnown, (bool)$markNotificationsAsRead);
  444. } else {
  445. $comments = $this->chatManager->getHistory($this->room, $lastKnownMessageId, $limit, (bool)$includeLastKnown);
  446. }
  447. return $this->prepareCommentsAsDataResponse($comments, $lastCommonReadId);
  448. }
  449. /**
  450. * @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_MODIFIED, TalkChatMessageWithParent[], array{X-Chat-Last-Common-Read?: numeric-string, X-Chat-Last-Given?: numeric-string}>
  451. */
  452. protected function prepareCommentsAsDataResponse(array $comments, int $lastCommonReadId = 0): DataResponse {
  453. if (empty($comments)) {
  454. if ($lastCommonReadId && $this->participant->getAttendee()->getReadPrivacy() === Participant::PRIVACY_PUBLIC) {
  455. $newLastCommonRead = $this->chatManager->getLastCommonReadMessage($this->room);
  456. if ($lastCommonReadId !== $newLastCommonRead) {
  457. // Set the status code to 200 so the header is sent to the client.
  458. // As per "section 10.3.5 of RFC 2616" entity headers shall be
  459. // stripped out on 304: https://stackoverflow.com/a/17822709
  460. /** @var array{X-Chat-Last-Common-Read?: numeric-string, X-Chat-Last-Given?: numeric-string} $headers */
  461. $headers = ['X-Chat-Last-Common-Read' => (string) $newLastCommonRead];
  462. return new DataResponse([], Http::STATUS_OK, $headers);
  463. }
  464. }
  465. return new DataResponse([], Http::STATUS_NOT_MODIFIED);
  466. }
  467. $this->preloadShares($comments);
  468. $i = 0;
  469. $now = $this->timeFactory->getDateTime();
  470. $messages = $commentIdToIndex = $parentIds = [];
  471. foreach ($comments as $comment) {
  472. $id = (int) $comment->getId();
  473. $message = $this->messageParser->createMessage($this->room, $this->participant, $comment, $this->l);
  474. $this->messageParser->parseMessage($message);
  475. $expireDate = $message->getExpirationDateTime();
  476. if ($expireDate instanceof \DateTime && $expireDate < $now) {
  477. $commentIdToIndex[$id] = null;
  478. continue;
  479. }
  480. if (!$message->getVisibility()) {
  481. $commentIdToIndex[$id] = null;
  482. continue;
  483. }
  484. if ($comment->getParentId() !== '0') {
  485. $parentIds[$id] = $comment->getParentId();
  486. }
  487. $messages[] = $message->toArray($this->getResponseFormat());
  488. $commentIdToIndex[$id] = $i;
  489. $i++;
  490. }
  491. /**
  492. * Set the parent for reply-messages
  493. */
  494. $loadedParents = [];
  495. foreach ($parentIds as $commentId => $parentId) {
  496. $commentKey = $commentIdToIndex[$commentId];
  497. // Parent is already parsed in the message list
  498. if (isset($commentIdToIndex[$parentId])) {
  499. $parentKey = $commentIdToIndex[$parentId];
  500. $messages[$commentKey]['parent'] = $messages[$parentKey];
  501. // We don't show nested parents…
  502. unset($messages[$commentKey]['parent']['parent']);
  503. continue;
  504. }
  505. // Parent was already loaded manually for another comment
  506. if (!empty($loadedParents[$parentId])) {
  507. $messages[$commentKey]['parent'] = $loadedParents[$parentId];
  508. continue;
  509. }
  510. // Parent was not skipped due to visibility, so we need to manually grab it.
  511. if (!isset($commentIdToIndex[$parentId])) {
  512. try {
  513. $comment = $this->chatManager->getParentComment($this->room, $parentId);
  514. $message = $this->messageParser->createMessage($this->room, $this->participant, $comment, $this->l);
  515. $this->messageParser->parseMessage($message);
  516. if ($message->getVisibility()) {
  517. $loadedParents[$parentId] = $message->toArray($this->getResponseFormat());
  518. $messages[$commentKey]['parent'] = $loadedParents[$parentId];
  519. continue;
  520. }
  521. $expireDate = $message->getComment()->getExpireDate();
  522. if ($expireDate instanceof \DateTime && $expireDate < $now) {
  523. $commentIdToIndex[$id] = null;
  524. continue;
  525. }
  526. $loadedParents[$parentId] = [
  527. 'id' => (int) $parentId,
  528. 'deleted' => true,
  529. ];
  530. } catch (NotFoundException $e) {
  531. }
  532. }
  533. // Message is not visible to the user
  534. $messages[$commentKey]['parent'] = [
  535. 'id' => (int) $parentId,
  536. 'deleted' => true,
  537. ];
  538. }
  539. $messages = $this->loadSelfReactions($messages, $commentIdToIndex);
  540. $headers = [];
  541. $newLastKnown = end($comments);
  542. if ($newLastKnown instanceof IComment) {
  543. $headers = ['X-Chat-Last-Given' => (string) (int) $newLastKnown->getId()];
  544. if ($this->participant->getAttendee()->getReadPrivacy() === Participant::PRIVACY_PUBLIC) {
  545. /**
  546. * This falsely set the read marker on new messages, although you
  547. * navigated away to a different chat already. So we removed this
  548. * and instead update the read marker before your next waiting.
  549. * So when you are still there, it will just have a wrong read
  550. * marker for the time until your next request starts, while it will
  551. * not update the value, when you actually left the chat already.
  552. * if ($setReadMarker === 1 && $lookIntoFuture) {
  553. * $this->participantService->updateLastReadMessage($this->participant, (int) $newLastKnown->getId());
  554. * }
  555. */
  556. $headers['X-Chat-Last-Common-Read'] = (string)$this->chatManager->getLastCommonReadMessage($this->room);
  557. }
  558. }
  559. return new DataResponse($messages, Http::STATUS_OK, $headers);
  560. }
  561. /**
  562. * Get the context of a message
  563. *
  564. * @param int $messageId The focused message which should be in the "middle" of the returned context
  565. * @psalm-param non-negative-int $messageId
  566. * @param int<1, 100> $limit Number of chat messages to receive in both directions (50 by default, 100 at most, might return 201 messages)
  567. * @return DataResponse<Http::STATUS_OK, TalkChatMessageWithParent[], array{'X-Chat-Last-Common-Read'?: numeric-string, X-Chat-Last-Given?: numeric-string}>|DataResponse<Http::STATUS_NOT_MODIFIED, array<empty>, array<empty>>
  568. *
  569. * 200: Message context returned
  570. * 304: No messages
  571. */
  572. #[FederationSupported]
  573. #[PublicPage]
  574. #[RequireModeratorOrNoLobby]
  575. #[RequireParticipant]
  576. public function getMessageContext(
  577. int $messageId,
  578. int $limit = 50): DataResponse {
  579. $limit = min(100, $limit);
  580. if ($this->room->isFederatedConversation()) {
  581. /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController $proxy */
  582. $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController::class);
  583. return $proxy->getMessageContext($this->room, $this->participant, $messageId, $limit);
  584. }
  585. $currentUser = $this->userManager->get($this->userId);
  586. $commentsHistory = $this->chatManager->getHistory($this->room, $messageId, $limit, true);
  587. $commentsHistory = array_reverse($commentsHistory);
  588. $commentsFuture = $this->chatManager->waitForNewMessages($this->room, $messageId, $limit, 0, $currentUser, false);
  589. return $this->prepareCommentsAsDataResponse(array_merge($commentsHistory, $commentsFuture));
  590. }
  591. protected function loadSelfReactions(array $messages, array $commentIdToIndex): array {
  592. // Get message ids with reactions
  593. $messageIdsWithReactions = array_map(
  594. static fn (array $message) => $message['id'],
  595. array_filter($messages, static fn (array $message) => !empty($message['reactions']))
  596. );
  597. // Get parents with reactions
  598. $parentsWithReactions = array_map(
  599. static fn (array $message) => ['parent' => $message['parent']['id'], 'message' => $message['id']],
  600. array_filter($messages, static fn (array $message) => !empty($message['parent']['reactions']))
  601. );
  602. // Create a map, so we can translate the parent's $messageId to the correct child entries
  603. $parentMap = $parentIdsWithReactions = [];
  604. foreach ($parentsWithReactions as $entry) {
  605. $parentMap[(int) $entry['parent']] ??= [];
  606. $parentMap[(int) $entry['parent']][] = (int) $entry['message'];
  607. $parentIdsWithReactions[] = (int) $entry['parent'];
  608. }
  609. // Unique list for the query
  610. $idsWithReactions = array_unique(array_merge($messageIdsWithReactions, $parentIdsWithReactions));
  611. $reactionsById = $this->reactionManager->getReactionsByActorForMessages($this->participant, $idsWithReactions);
  612. // Inject the reactions self into the $messages array
  613. foreach ($reactionsById as $messageId => $reactions) {
  614. if (isset($commentIdToIndex[$messageId]) && isset($messages[$commentIdToIndex[$messageId]])) {
  615. $messages[$commentIdToIndex[$messageId]]['reactionsSelf'] = $reactions;
  616. }
  617. // Add the self part also to potential parent elements
  618. if (isset($parentMap[$messageId])) {
  619. foreach ($parentMap[$messageId] as $mid) {
  620. if (isset($messages[$commentIdToIndex[$mid]])) {
  621. $messages[$commentIdToIndex[$mid]]['parent']['reactionsSelf'] = $reactions;
  622. }
  623. }
  624. }
  625. }
  626. return $messages;
  627. }
  628. /**
  629. * Delete a chat message
  630. *
  631. * @param int $messageId ID of the message
  632. * @psalm-param non-negative-int $messageId
  633. * @return DataResponse<Http::STATUS_OK|Http::STATUS_ACCEPTED, TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_METHOD_NOT_ALLOWED, array<empty>, array{}>
  634. *
  635. * 200: Message deleted successfully
  636. * 202: Message deleted successfully, but a bot or Matterbridge is configured, so the information can be replicated elsewhere
  637. * 400: Deleting message is not possible
  638. * 403: Missing permissions to delete message
  639. * 404: Message not found
  640. * 405: Deleting this message type is not allowed
  641. */
  642. #[FederationSupported]
  643. #[PublicPage]
  644. #[RequireModeratorOrNoLobby]
  645. #[RequireAuthenticatedParticipant]
  646. #[RequirePermission(permission: RequirePermission::CHAT)]
  647. #[RequireReadWriteConversation]
  648. public function deleteMessage(int $messageId): DataResponse {
  649. if ($this->room->isFederatedConversation()) {
  650. /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController $proxy */
  651. $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController::class);
  652. return $proxy->deleteMessage(
  653. $this->room,
  654. $this->participant,
  655. $messageId,
  656. );
  657. }
  658. try {
  659. $message = $this->chatManager->getComment($this->room, (string) $messageId);
  660. } catch (NotFoundException $e) {
  661. return new DataResponse([], Http::STATUS_NOT_FOUND);
  662. }
  663. $attendee = $this->participant->getAttendee();
  664. $isOwnMessage = $message->getActorType() === $attendee->getActorType()
  665. && $message->getActorId() === $attendee->getActorId();
  666. // Special case for if the message is a bridged message, then the message is the bridge bot's message.
  667. $isOwnMessage = $isOwnMessage || ($message->getActorType() === Attendee::ACTOR_BRIDGED && $attendee->getActorId() === MatterbridgeManager::BRIDGE_BOT_USERID);
  668. if (!$isOwnMessage
  669. && (!$this->participant->hasModeratorPermissions(false)
  670. || $this->room->getType() === Room::TYPE_ONE_TO_ONE
  671. || $this->room->getType() === Room::TYPE_ONE_TO_ONE_FORMER)) {
  672. // Actor is not a moderator or not the owner of the message
  673. return new DataResponse([], Http::STATUS_FORBIDDEN);
  674. }
  675. if ($message->getVerb() !== ChatManager::VERB_MESSAGE && $message->getVerb() !== ChatManager::VERB_OBJECT_SHARED) {
  676. // System message (since the message is not parsed, it has type "system")
  677. return new DataResponse([], Http::STATUS_METHOD_NOT_ALLOWED);
  678. }
  679. try {
  680. $systemMessageComment = $this->chatManager->deleteMessage(
  681. $this->room,
  682. $message,
  683. $this->participant,
  684. $this->timeFactory->getDateTime()
  685. );
  686. } catch (ShareNotFound $e) {
  687. return new DataResponse([], Http::STATUS_NOT_FOUND);
  688. }
  689. $systemMessage = $this->messageParser->createMessage($this->room, $this->participant, $systemMessageComment, $this->l);
  690. $this->messageParser->parseMessage($systemMessage);
  691. $comment = $this->chatManager->getComment($this->room, (string) $messageId);
  692. $message = $this->messageParser->createMessage($this->room, $this->participant, $comment, $this->l);
  693. $this->messageParser->parseMessage($message);
  694. $data = $systemMessage->toArray($this->getResponseFormat());
  695. $data['parent'] = $message->toArray($this->getResponseFormat());
  696. $hasBotOrBridge = !empty($this->botService->getBotsForToken($this->room->getToken(), Bot::FEATURE_WEBHOOK));
  697. if (!$hasBotOrBridge) {
  698. $bridge = $this->matterbridgeManager->getBridgeOfRoom($this->room);
  699. $hasBotOrBridge = $bridge['enabled'];
  700. }
  701. $headers = [];
  702. if ($this->participant->getAttendee()->getReadPrivacy() === Participant::PRIVACY_PUBLIC) {
  703. $headers = ['X-Chat-Last-Common-Read' => (string) $this->chatManager->getLastCommonReadMessage($this->room)];
  704. }
  705. return new DataResponse($data, $hasBotOrBridge ? Http::STATUS_ACCEPTED : Http::STATUS_OK, $headers);
  706. }
  707. /**
  708. * Edit a chat message
  709. *
  710. * @param int $messageId ID of the message
  711. * @param string $message the message to send
  712. * @psalm-param non-negative-int $messageId
  713. * @return DataResponse<Http::STATUS_OK|Http::STATUS_ACCEPTED, TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_METHOD_NOT_ALLOWED|Http::STATUS_REQUEST_ENTITY_TOO_LARGE, array<empty>, array{}>
  714. *
  715. * 200: Message edited successfully
  716. * 202: Message edited successfully, but a bot or Matterbridge is configured, so the information can be replicated to other services
  717. * 400: Editing message is not possible, e.g. when the new message is empty or the message is too old
  718. * 403: Missing permissions to edit message
  719. * 404: Message not found
  720. * 405: Editing this message type is not allowed
  721. * 413: Message too long
  722. */
  723. #[FederationSupported]
  724. #[PublicPage]
  725. #[RequireModeratorOrNoLobby]
  726. #[RequireAuthenticatedParticipant]
  727. #[RequirePermission(permission: RequirePermission::CHAT)]
  728. #[RequireReadWriteConversation]
  729. public function editMessage(int $messageId, string $message): DataResponse {
  730. if ($this->room->isFederatedConversation()) {
  731. /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController $proxy */
  732. $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController::class);
  733. return $proxy->editMessage(
  734. $this->room,
  735. $this->participant,
  736. $messageId,
  737. $message,
  738. );
  739. }
  740. try {
  741. $comment = $this->chatManager->getComment($this->room, (string) $messageId);
  742. } catch (NotFoundException $e) {
  743. return new DataResponse([], Http::STATUS_NOT_FOUND);
  744. }
  745. $attendee = $this->participant->getAttendee();
  746. $isOwnMessage = $comment->getActorType() === $attendee->getActorType()
  747. && $comment->getActorId() === $attendee->getActorId();
  748. // Special case for if the message is a bridged message, then the message is the bridge bot's message.
  749. $isOwnMessage = $isOwnMessage || ($comment->getActorType() === Attendee::ACTOR_BRIDGED && $attendee->getActorId() === MatterbridgeManager::BRIDGE_BOT_USERID);
  750. if (!$isOwnMessage
  751. && (!$this->participant->hasModeratorPermissions(false)
  752. || $this->room->getType() === Room::TYPE_ONE_TO_ONE
  753. || $this->room->getType() === Room::TYPE_ONE_TO_ONE_FORMER)) {
  754. // Actor is not a moderator or not the owner of the message
  755. return new DataResponse([], Http::STATUS_FORBIDDEN);
  756. }
  757. if ($comment->getVerb() !== ChatManager::VERB_MESSAGE && $comment->getVerb() !== ChatManager::VERB_OBJECT_SHARED) {
  758. // System message (since the message is not parsed, it has type "system")
  759. return new DataResponse([], Http::STATUS_METHOD_NOT_ALLOWED);
  760. }
  761. $maxAge = $this->timeFactory->getDateTime();
  762. $maxAge->sub(new \DateInterval('P1D'));
  763. if ($comment->getCreationDateTime() < $maxAge) {
  764. // Message is too old
  765. return new DataResponse(['error' => 'age'], Http::STATUS_BAD_REQUEST);
  766. }
  767. try {
  768. $systemMessageComment = $this->chatManager->editMessage(
  769. $this->room,
  770. $comment,
  771. $this->participant,
  772. $this->timeFactory->getDateTime(),
  773. $message
  774. );
  775. } catch (MessageTooLongException) {
  776. return new DataResponse([], Http::STATUS_REQUEST_ENTITY_TOO_LARGE);
  777. } catch (\InvalidArgumentException $e) {
  778. if ($e->getMessage() === 'object_share') {
  779. return new DataResponse([], Http::STATUS_METHOD_NOT_ALLOWED);
  780. }
  781. return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
  782. }
  783. $systemMessage = $this->messageParser->createMessage($this->room, $this->participant, $systemMessageComment, $this->l);
  784. $this->messageParser->parseMessage($systemMessage);
  785. $comment = $this->chatManager->getComment($this->room, (string) $messageId);
  786. $parseMessage = $this->messageParser->createMessage($this->room, $this->participant, $comment, $this->l);
  787. $this->messageParser->parseMessage($parseMessage);
  788. $data = $systemMessage->toArray($this->getResponseFormat());
  789. $data['parent'] = $parseMessage->toArray($this->getResponseFormat());
  790. $hasBotOrBridge = !empty($this->botService->getBotsForToken($this->room->getToken(), Bot::FEATURE_WEBHOOK));
  791. if (!$hasBotOrBridge) {
  792. $bridge = $this->matterbridgeManager->getBridgeOfRoom($this->room);
  793. $hasBotOrBridge = $bridge['enabled'];
  794. }
  795. $headers = [];
  796. if ($this->participant->getAttendee()->getReadPrivacy() === Participant::PRIVACY_PUBLIC) {
  797. $headers = ['X-Chat-Last-Common-Read' => (string) $this->chatManager->getLastCommonReadMessage($this->room)];
  798. }
  799. return new DataResponse($data, $hasBotOrBridge ? Http::STATUS_ACCEPTED : Http::STATUS_OK, $headers);
  800. }
  801. /**
  802. * Set a reminder for a chat message
  803. *
  804. * @param int $messageId ID of the message
  805. * @psalm-param non-negative-int $messageId
  806. * @param int $timestamp Timestamp of the reminder
  807. * @psalm-param non-negative-int $timestamp
  808. * @return DataResponse<Http::STATUS_CREATED, TalkChatReminder, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error?: string}, array{}>
  809. *
  810. * 201: Reminder created successfully
  811. * 404: Message not found
  812. */
  813. #[FederationSupported]
  814. #[NoAdminRequired]
  815. #[RequireModeratorOrNoLobby]
  816. #[RequireLoggedInParticipant]
  817. #[UserRateLimit(limit: 60, period: 3600)]
  818. public function setReminder(int $messageId, int $timestamp): DataResponse {
  819. try {
  820. $this->validateMessageExists($messageId, sync: true);
  821. } catch (DoesNotExistException) {
  822. return new DataResponse(['error' => 'message'], Http::STATUS_NOT_FOUND);
  823. }
  824. $reminder = $this->reminderService->setReminder(
  825. $this->participant->getAttendee()->getActorId(),
  826. $this->room->getToken(),
  827. $messageId,
  828. $timestamp
  829. );
  830. return new DataResponse($reminder->jsonSerialize(), Http::STATUS_CREATED);
  831. }
  832. /**
  833. * Get the reminder for a chat message
  834. *
  835. * @param int $messageId ID of the message
  836. * @psalm-param non-negative-int $messageId
  837. * @return DataResponse<Http::STATUS_OK, TalkChatReminder, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error?: string}, array{}>
  838. *
  839. * 200: Reminder returned
  840. * 404: No reminder found
  841. * 404: Message not found
  842. */
  843. #[FederationSupported]
  844. #[NoAdminRequired]
  845. #[RequireModeratorOrNoLobby]
  846. #[RequireLoggedInParticipant]
  847. public function getReminder(int $messageId): DataResponse {
  848. try {
  849. $this->validateMessageExists($messageId);
  850. } catch (DoesNotExistException) {
  851. return new DataResponse(['error' => 'message'], Http::STATUS_NOT_FOUND);
  852. }
  853. try {
  854. $reminder = $this->reminderService->getReminder(
  855. $this->participant->getAttendee()->getActorId(),
  856. $this->room->getToken(),
  857. $messageId,
  858. );
  859. return new DataResponse($reminder->jsonSerialize(), Http::STATUS_OK);
  860. } catch (DoesNotExistException) {
  861. return new DataResponse(['error' => 'reminder'], Http::STATUS_NOT_FOUND);
  862. }
  863. }
  864. /**
  865. * Delete a chat reminder
  866. *
  867. * @param int $messageId ID of the message
  868. * @psalm-param non-negative-int $messageId
  869. * @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, array{error?: string}, array{}>
  870. *
  871. * 200: Reminder deleted successfully
  872. * 404: Message not found
  873. */
  874. #[FederationSupported]
  875. #[NoAdminRequired]
  876. #[RequireModeratorOrNoLobby]
  877. #[RequireLoggedInParticipant]
  878. public function deleteReminder(int $messageId): DataResponse {
  879. try {
  880. $this->validateMessageExists($messageId);
  881. } catch (DoesNotExistException) {
  882. return new DataResponse(['error' => 'message'], Http::STATUS_NOT_FOUND);
  883. }
  884. $this->reminderService->deleteReminder(
  885. $this->participant->getAttendee()->getActorId(),
  886. $this->room->getToken(),
  887. $messageId,
  888. );
  889. return new DataResponse([], Http::STATUS_OK);
  890. }
  891. /**
  892. * @throws DoesNotExistException
  893. * @throws CannotReachRemoteException
  894. */
  895. protected function validateMessageExists(int $messageId, bool $sync = false): void {
  896. if ($this->room->isFederatedConversation()) {
  897. try {
  898. $this->pcmService->findByRemote($this->room->getRemoteServer(), $this->room->getRemoteToken(), $messageId);
  899. } catch (DoesNotExistException) {
  900. if ($sync) {
  901. $this->pcmService->syncRemoteMessage($this->room, $this->participant, $messageId);
  902. }
  903. }
  904. return;
  905. }
  906. try {
  907. $this->chatManager->getComment($this->room, (string)$messageId);
  908. } catch (NotFoundException $e) {
  909. throw new DoesNotExistException($e->getMessage());
  910. }
  911. }
  912. /**
  913. * Clear the chat history
  914. *
  915. * @return DataResponse<Http::STATUS_OK|Http::STATUS_ACCEPTED, TalkChatMessage, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_FORBIDDEN, array<empty>, array{}>
  916. *
  917. * 200: History cleared successfully
  918. * 202: History cleared successfully, but Matterbridge is configured, so the information can be replicated elsewhere
  919. * 403: Missing permissions to clear history
  920. */
  921. #[NoAdminRequired]
  922. #[RequireModeratorParticipant]
  923. #[RequireReadWriteConversation]
  924. public function clearHistory(): DataResponse {
  925. $attendee = $this->participant->getAttendee();
  926. if (!$this->participant->hasModeratorPermissions(false)
  927. || $this->room->getType() === Room::TYPE_ONE_TO_ONE
  928. || $this->room->getType() === Room::TYPE_ONE_TO_ONE_FORMER) {
  929. // Actor is not a moderator or not the owner of the message
  930. return new DataResponse([], Http::STATUS_FORBIDDEN);
  931. }
  932. $systemMessageComment = $this->chatManager->clearHistory(
  933. $this->room,
  934. $attendee->getActorType(),
  935. $attendee->getActorId()
  936. );
  937. $systemMessage = $this->messageParser->createMessage($this->room, $this->participant, $systemMessageComment, $this->l);
  938. $this->messageParser->parseMessage($systemMessage);
  939. $data = $systemMessage->toArray($this->getResponseFormat());
  940. $bridge = $this->matterbridgeManager->getBridgeOfRoom($this->room);
  941. $headers = [];
  942. if ($this->participant->getAttendee()->getReadPrivacy() === Participant::PRIVACY_PUBLIC) {
  943. $headers = ['X-Chat-Last-Common-Read' => (string) $this->chatManager->getLastCommonReadMessage($this->room)];
  944. }
  945. return new DataResponse($data, $bridge['enabled'] ? Http::STATUS_ACCEPTED : Http::STATUS_OK, $headers);
  946. }
  947. /**
  948. * Set the read marker to a specific message
  949. *
  950. * @param int|null $lastReadMessage ID if the last read message (Optional only with `chat-read-last` capability)
  951. * @psalm-param non-negative-int|null $lastReadMessage
  952. * @return DataResponse<Http::STATUS_OK, TalkRoom, array{X-Chat-Last-Common-Read?: numeric-string}>
  953. *
  954. * 200: Read marker set successfully
  955. */
  956. #[FederationSupported]
  957. #[PublicPage]
  958. #[RequireAuthenticatedParticipant]
  959. public function setReadMarker(?int $lastReadMessage = null): DataResponse {
  960. $setToMessage = $lastReadMessage ?? $this->room->getLastMessageId();
  961. if ($setToMessage === $this->room->getLastMessageId()
  962. && $this->participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
  963. $this->notifier->markMentionNotificationsRead($this->room, $this->participant->getAttendee()->getActorId());
  964. }
  965. if ($this->room->isFederatedConversation()) {
  966. /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController $proxy */
  967. $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController::class);
  968. return $proxy->setReadMarker($this->room, $this->participant, $this->getResponseFormat(), $lastReadMessage);
  969. }
  970. $this->participantService->updateLastReadMessage($this->participant, $setToMessage);
  971. $attendee = $this->participant->getAttendee();
  972. $headers = $lastCommonRead = [];
  973. if ($attendee->getReadPrivacy() === Participant::PRIVACY_PUBLIC) {
  974. $lastCommonRead[$this->room->getId()] = $this->chatManager->getLastCommonReadMessage($this->room);
  975. $headers = ['X-Chat-Last-Common-Read' => (string) $lastCommonRead[$this->room->getId()]];
  976. }
  977. return new DataResponse($this->roomFormatter->formatRoom(
  978. $this->getResponseFormat(),
  979. $lastCommonRead,
  980. $this->room,
  981. $this->participant,
  982. ), Http::STATUS_OK, $headers);
  983. }
  984. /**
  985. * Mark a chat as unread
  986. *
  987. * @return DataResponse<Http::STATUS_OK, TalkRoom, array{X-Chat-Last-Common-Read?: numeric-string}>
  988. *
  989. * 200: Read marker set successfully
  990. */
  991. #[FederationSupported]
  992. #[PublicPage]
  993. #[RequireAuthenticatedParticipant]
  994. public function markUnread(): DataResponse {
  995. if ($this->room->isFederatedConversation()) {
  996. /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController $proxy */
  997. $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController::class);
  998. return $proxy->markUnread($this->room, $this->participant, $this->getResponseFormat());
  999. }
  1000. $message = $this->room->getLastMessage();
  1001. $unreadId = 0;
  1002. if ($message instanceof IComment) {
  1003. try {
  1004. $previousMessage = $this->chatManager->getPreviousMessageWithVerb(
  1005. $this->room,
  1006. (int)$message->getId(),
  1007. [ChatManager::VERB_MESSAGE],
  1008. $message->getVerb() === ChatManager::VERB_MESSAGE
  1009. );
  1010. $unreadId = (int) $previousMessage->getId();
  1011. } catch (NotFoundException $e) {
  1012. // No chat message found, only system messages.
  1013. // Marking unread from beginning
  1014. }
  1015. }
  1016. return $this->setReadMarker($unreadId);
  1017. }
  1018. /**
  1019. * Get objects that are shared in the room overview
  1020. *
  1021. * @param int<1, 20> $limit Maximum number of objects
  1022. * @return DataResponse<Http::STATUS_OK, array<string, TalkChatMessage[]>, array{}>
  1023. *
  1024. * 200: List of shared objects messages of each type returned
  1025. */
  1026. #[PublicPage]
  1027. #[RequireModeratorOrNoLobby]
  1028. #[RequireParticipant]
  1029. public function getObjectsSharedInRoomOverview(int $limit = 7): DataResponse {
  1030. $limit = min(20, $limit);
  1031. $objectTypes = [
  1032. Attachment::TYPE_AUDIO,
  1033. Attachment::TYPE_DECK_CARD,
  1034. Attachment::TYPE_FILE,
  1035. Attachment::TYPE_LOCATION,
  1036. Attachment::TYPE_MEDIA,
  1037. Attachment::TYPE_OTHER,
  1038. Attachment::TYPE_POLL,
  1039. Attachment::TYPE_RECORDING,
  1040. Attachment::TYPE_VOICE,
  1041. ];
  1042. $messageIdsByType = [];
  1043. // Get all attachments
  1044. foreach ($objectTypes as $objectType) {
  1045. $attachments = $this->attachmentService->getAttachmentsByType($this->room, $objectType, 0, $limit);
  1046. $messageIdsByType[$objectType] = array_map(static fn (Attachment $attachment): int => $attachment->getMessageId(), $attachments);
  1047. }
  1048. $messages = $this->getMessagesForRoom(array_merge(...array_values($messageIdsByType)));
  1049. $messagesByType = [];
  1050. // Convert list of $messages to array grouped by type
  1051. foreach ($objectTypes as $objectType) {
  1052. $messagesByType[$objectType] = [];
  1053. foreach ($messageIdsByType[$objectType] as $messageId) {
  1054. if (isset($messages[$messageId])) {
  1055. $messagesByType[$objectType][] = $messages[$messageId];
  1056. }
  1057. }
  1058. }
  1059. return new DataResponse($messagesByType, Http::STATUS_OK);
  1060. }
  1061. /**
  1062. * Get objects that are shared in the room
  1063. *
  1064. * @param string $objectType Type of the objects
  1065. * @param int $lastKnownMessageId ID of the last known message
  1066. * @psalm-param non-negative-int $lastKnownMessageId
  1067. * @param int<1, 200> $limit Maximum number of objects
  1068. * @return DataResponse<Http::STATUS_OK, TalkChatMessage[], array{X-Chat-Last-Given?: numeric-string}>
  1069. *
  1070. * 200: List of shared objects messages returned
  1071. */
  1072. #[PublicPage]
  1073. #[RequireModeratorOrNoLobby]
  1074. #[RequireParticipant]
  1075. public function getObjectsSharedInRoom(string $objectType, int $lastKnownMessageId = 0, int $limit = 100): DataResponse {
  1076. $offset = max(0, $lastKnownMessageId);
  1077. $limit = min(200, $limit);
  1078. $attachments = $this->attachmentService->getAttachmentsByType($this->room, $objectType, $offset, $limit);
  1079. $messageIds = array_map(static fn (Attachment $attachment): int => $attachment->getMessageId(), $attachments);
  1080. /** @var TalkChatMessage[] $messages */
  1081. $messages = $this->getMessagesForRoom($messageIds);
  1082. $headers = [];
  1083. if (!empty($messages)) {
  1084. $newLastKnown = (string) (int) min(array_keys($messages));
  1085. $headers = ['X-Chat-Last-Given' => $newLastKnown];
  1086. }
  1087. return new DataResponse($messages, Http::STATUS_OK, $headers);
  1088. }
  1089. /**
  1090. * @return TalkChatMessage[]
  1091. */
  1092. protected function getMessagesForRoom(array $messageIds): array {
  1093. $comments = $this->chatManager->getMessagesForRoomById($this->room, $messageIds);
  1094. $this->preloadShares($comments);
  1095. $messages = [];
  1096. $comments = $this->chatManager->filterCommentsWithNonExistingFiles($comments);
  1097. foreach ($comments as $comment) {
  1098. $message = $this->messageParser->createMessage($this->room, $this->participant, $comment, $this->l);
  1099. $this->messageParser->parseMessage($message);
  1100. $now = $this->timeFactory->getDateTime();
  1101. $expireDate = $message->getComment()->getExpireDate();
  1102. if ($expireDate instanceof \DateTime && $expireDate < $now) {
  1103. continue;
  1104. }
  1105. if (!$message->getVisibility()) {
  1106. continue;
  1107. }
  1108. $messages[(int) $comment->getId()] = $message->toArray($this->getResponseFormat());
  1109. }
  1110. return $messages;
  1111. }
  1112. /**
  1113. * Search for mentions
  1114. *
  1115. * @param string $search Text to search for
  1116. * @param int $limit Maximum number of results
  1117. * @param bool $includeStatus Include the user statuses
  1118. * @return DataResponse<Http::STATUS_OK, TalkChatMentionSuggestion[], array{}>
  1119. *
  1120. * 200: List of mention suggestions returned
  1121. */
  1122. #[FederationSupported]
  1123. #[PublicPage]
  1124. #[RequireModeratorOrNoLobby]
  1125. #[RequireParticipant]
  1126. #[RequirePermission(permission: RequirePermission::CHAT)]
  1127. #[RequireReadWriteConversation]
  1128. public function mentions(string $search, int $limit = 20, bool $includeStatus = false): DataResponse {
  1129. if ($this->room->isFederatedConversation()) {
  1130. /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController $proxy */
  1131. $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController::class);
  1132. return $proxy->mentions($this->room, $this->participant, $search, $limit, $includeStatus);
  1133. }
  1134. $this->searchPlugin->setContext([
  1135. 'itemType' => 'chat',
  1136. 'itemId' => $this->room->getId(),
  1137. 'room' => $this->room,
  1138. ]);
  1139. $this->searchPlugin->search($search, $limit, 0, $this->searchResult);
  1140. $results = $this->searchResult->asArray();
  1141. $exactMatches = $results['exact'];
  1142. unset($results['exact']);
  1143. $results = array_merge_recursive($exactMatches, $results);
  1144. $this->autoCompleteManager->registerSorter(Sorter::class);
  1145. $this->autoCompleteManager->runSorters(['talk_chat_participants'], $results, [
  1146. 'itemType' => 'chat',
  1147. 'itemId' => (string) $this->room->getId(),
  1148. 'search' => $search,
  1149. ]);
  1150. $statuses = [];
  1151. if ($this->userId !== null
  1152. && $includeStatus
  1153. && $this->appManager->isEnabledForUser('user_status')) {
  1154. $userIds = array_filter(array_map(static function (array $userResult) {
  1155. return $userResult['value']['shareWith'];
  1156. }, $results['users']));
  1157. $statuses = $this->statusManager->getUserStatuses($userIds);
  1158. }
  1159. $results = $this->prepareResultArray($results, $statuses);
  1160. $results = $this->chatManager->addConversationNotify($results, $search, $this->room, $this->participant);
  1161. return new DataResponse($results);
  1162. }
  1163. /**
  1164. * @param array $results
  1165. * @param IUserStatus[] $statuses
  1166. * @return TalkChatMentionSuggestion[]
  1167. */
  1168. protected function prepareResultArray(array $results, array $statuses): array {
  1169. $output = [];
  1170. foreach ($results as $type => $subResult) {
  1171. foreach ($subResult as $result) {
  1172. $data = [
  1173. 'id' => $result['value']['shareWith'],
  1174. 'label' => $result['label'],
  1175. 'source' => $type,
  1176. 'mentionId' => $this->createMentionString($type, $result['value']['shareWith']),
  1177. ];
  1178. if ($type === Attendee::ACTOR_USERS && isset($statuses[$data['id']])) {
  1179. $data['status'] = $statuses[$data['id']]->getStatus();
  1180. $data['statusIcon'] = $statuses[$data['id']]->getIcon();
  1181. $data['statusMessage'] = $statuses[$data['id']]->getMessage();
  1182. $data['statusClearAt'] = $statuses[$data['id']]->getClearAt();
  1183. }
  1184. $output[] = $data;
  1185. }
  1186. }
  1187. return $output;
  1188. }
  1189. protected function createMentionString(string $type, string $id): string {
  1190. if ($type !== Attendee::ACTOR_FEDERATED_USERS) {
  1191. return $id;
  1192. }
  1193. // We want "federated_user/admin@example.tld" so we have to strip off the trailing "s" from the type "federated_users"
  1194. return substr($type, 0, -1) . '/' . $id;
  1195. }
  1196. }