UPSTREAM BASELINE: nextcloud/spreed v22.0.12 (без изменений)
Type checking / changes (push) Has been cancelled
Type checking / test (push) Has been cancelled
Type checking / typescript-summary (push) Has been cancelled
Node tests / changes (push) Has been cancelled
Node tests / test (push) Has been cancelled
Node tests / test-summary (push) Has been cancelled
Type checking / changes (push) Has been cancelled
Type checking / test (push) Has been cancelled
Type checking / typescript-summary (push) Has been cancelled
Node tests / changes (push) Has been cancelled
Node tests / test (push) Has been cancelled
Node tests / test-summary (push) Has been cancelled
Источник: https://github.com/nextcloud/spreed/archive/refs/tags/v22.0.12.tar.gz С этого коммита ветка официального Nextcloud Talk отрезана (решение владельца 2026-07-06). Все дальнейшие изменения — только наши; версии релизов: 22.0.12-f7.N.
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Activity;
|
||||
|
||||
use OCA\Talk\Chat\ChatManager;
|
||||
use OCA\Talk\Events\ACallEndedEvent;
|
||||
use OCA\Talk\Events\ARoomEvent;
|
||||
use OCA\Talk\Events\AttendeesAddedEvent;
|
||||
use OCA\Talk\Events\CallEndedEvent;
|
||||
use OCA\Talk\Events\CallEndedForEveryoneEvent;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\RecordingService;
|
||||
use OCA\Talk\Service\RoomService;
|
||||
use OCP\Activity\Exceptions\InvalidValueException;
|
||||
use OCP\Activity\IManager;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class Listener implements IEventListener {
|
||||
|
||||
public function __construct(
|
||||
protected IManager $activityManager,
|
||||
protected IUserSession $userSession,
|
||||
protected ChatManager $chatManager,
|
||||
protected ParticipantService $participantService,
|
||||
protected RoomService $roomService,
|
||||
protected RecordingService $recordingService,
|
||||
protected LoggerInterface $logger,
|
||||
protected ITimeFactory $timeFactory,
|
||||
protected Setting $setting,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if ($event instanceof ARoomEvent && $event->getRoom()->isFederatedConversation()) {
|
||||
return;
|
||||
}
|
||||
|
||||
match (get_class($event)) {
|
||||
CallEndedEvent::class,
|
||||
CallEndedForEveryoneEvent::class => $this->generateCallActivity($event),
|
||||
AttendeesAddedEvent::class => $this->generateInvitationActivity($event->getRoom(), $event->getAttendees()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Call activity: "You attended a call with {user1} and {user2}"
|
||||
*/
|
||||
protected function generateCallActivity(ACallEndedEvent $event): void {
|
||||
$room = $event->getRoom();
|
||||
$actor = $event->getActor();
|
||||
$activeSince = $event->getOldValue();
|
||||
|
||||
$duration = $this->timeFactory->getTime() - $activeSince->getTimestamp();
|
||||
$userIds = $this->participantService->getParticipantUserIds($room, $activeSince);
|
||||
$cloudIds = $this->participantService->getParticipantActorIdsByActorType($room, [Attendee::ACTOR_FEDERATED_USERS], $activeSince);
|
||||
$numGuests = $this->participantService->getActorsCountByType($room, Attendee::ACTOR_GUESTS, $activeSince->getTimestamp());
|
||||
$numGuests += $this->participantService->getActorsCountByType($room, Attendee::ACTOR_EMAILS, $activeSince->getTimestamp());
|
||||
|
||||
$message = 'call_ended';
|
||||
if (($room->getType() === Room::TYPE_ONE_TO_ONE || $room->getType() === Room::TYPE_ONE_TO_ONE_FORMER) && \count($userIds) === 1) {
|
||||
$message = 'call_missed';
|
||||
} elseif ($event instanceof CallEndedForEveryoneEvent) {
|
||||
$message = 'call_ended_everyone';
|
||||
}
|
||||
|
||||
if ($actor instanceof Participant) {
|
||||
$actorId = $actor->getAttendee()->getActorId();
|
||||
$actorType = $actor->getAttendee()->getActorType();
|
||||
} else {
|
||||
$actorType = Attendee::ACTOR_GUESTS;
|
||||
$actorId = Attendee::ACTOR_ID_SYSTEM;
|
||||
}
|
||||
$this->chatManager->addSystemMessage($room, $actor, $actorType, $actorId, json_encode([
|
||||
'message' => $message,
|
||||
'parameters' => [
|
||||
'users' => $userIds,
|
||||
'cloudIds' => $cloudIds,
|
||||
'guests' => $numGuests,
|
||||
'duration' => $duration,
|
||||
],
|
||||
]), $this->timeFactory->getDateTime(), false);
|
||||
|
||||
if (empty($userIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activity = $this->activityManager->generateEvent();
|
||||
try {
|
||||
$activity->setApp('spreed')
|
||||
->setType('spreed')
|
||||
->setAuthor('')
|
||||
->setObject('room', $room->getId())
|
||||
->setTimestamp($this->timeFactory->getTime())
|
||||
->setSubject('call', [
|
||||
'room' => $room->getId(),
|
||||
'users' => $userIds,
|
||||
'cloudIds' => $cloudIds,
|
||||
'guests' => $numGuests,
|
||||
'duration' => $duration,
|
||||
]);
|
||||
} catch (InvalidValueException $e) {
|
||||
$this->logger->error($e->getMessage(), ['exception' => $e]);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->activityManager->bulkPublish($activity, $userIds, $this->setting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invitation activity: "{actor} invited you to {call}"
|
||||
*
|
||||
* @param Room $room
|
||||
* @param Attendee[] $attendees
|
||||
*/
|
||||
protected function generateInvitationActivity(Room $room, array $attendees): void {
|
||||
$actor = $this->userSession->getUser();
|
||||
if (!$actor instanceof IUser) {
|
||||
return;
|
||||
}
|
||||
$actorId = $actor->getUID();
|
||||
|
||||
$event = $this->activityManager->generateEvent();
|
||||
try {
|
||||
$event->setApp('spreed')
|
||||
->setType('spreed')
|
||||
->setAuthor($actorId)
|
||||
->setObject('room', $room->getId())
|
||||
->setTimestamp($this->timeFactory->getTime())
|
||||
->setSubject('invitation', [
|
||||
'user' => $actor->getUID(),
|
||||
'room' => $room->getId(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error($e->getMessage(), ['exception' => $e]);
|
||||
return;
|
||||
}
|
||||
|
||||
// We know the new participant is in the room,
|
||||
// so skip loading them just to make sure they can read it.
|
||||
// Must be overwritten later on for one-to-one chats.
|
||||
$roomName = $room->getDisplayName($actorId);
|
||||
|
||||
foreach ($attendees as $attendee) {
|
||||
if ($attendee->getActorType() !== Attendee::ACTOR_USERS) {
|
||||
// No user => no activity
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($actorId === $attendee->getActorId()) {
|
||||
// No activity for self-joining and the creator
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($room->getType() === Room::TYPE_ONE_TO_ONE) {
|
||||
// Overwrite the room name with the other participant
|
||||
$roomName = $room->getDisplayName($attendee->getActorId());
|
||||
}
|
||||
$event
|
||||
->setObject('room', $room->getId(), $roomName)
|
||||
->setSubject('invitation', [
|
||||
'user' => $actor->getUID(),
|
||||
'room' => $room->getId(),
|
||||
'name' => $roomName,
|
||||
])
|
||||
->setAffectedUser($attendee->getActorId());
|
||||
$this->activityManager->publish($event);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error($e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Activity\Provider;
|
||||
|
||||
use OCA\Talk\Config;
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\AvatarService;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCP\Activity\Exceptions\UnknownActivityException;
|
||||
use OCP\Activity\IEvent;
|
||||
use OCP\Activity\IManager;
|
||||
use OCP\Activity\IProvider;
|
||||
use OCP\Federation\ICloudIdManager;
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
use OCP\L10N\IFactory;
|
||||
|
||||
abstract class Base implements IProvider {
|
||||
|
||||
public function __construct(
|
||||
protected IFactory $languageFactory,
|
||||
protected IURLGenerator $url,
|
||||
protected Config $config,
|
||||
protected IManager $activityManager,
|
||||
protected IUserManager $userManager,
|
||||
protected ICloudIdManager $cloudIdManager,
|
||||
protected ParticipantService $participantService,
|
||||
protected AvatarService $avatarService,
|
||||
protected Manager $manager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IEvent $event
|
||||
* @return IEvent
|
||||
* @throws UnknownActivityException
|
||||
*/
|
||||
public function preParse(IEvent $event): IEvent {
|
||||
if ($event->getApp() !== 'spreed') {
|
||||
throw new UnknownActivityException('app');
|
||||
}
|
||||
|
||||
$uid = $event->getAffectedUser();
|
||||
$user = $this->userManager->get($uid);
|
||||
if (!$user instanceof IUser || $this->config->isDisabledForUser($user)) {
|
||||
throw new UnknownActivityException('User can not use Talk');
|
||||
}
|
||||
|
||||
if ($this->activityManager->getRequirePNG()) {
|
||||
$event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('spreed', 'app-dark.png')));
|
||||
} else {
|
||||
$event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('spreed', 'app-dark.svg')));
|
||||
}
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IEvent $event
|
||||
* @param string $subject
|
||||
* @param array $parameters
|
||||
*/
|
||||
protected function setSubjects(IEvent $event, string $subject, array $parameters): void {
|
||||
$placeholders = $replacements = [];
|
||||
foreach ($parameters as $placeholder => $parameter) {
|
||||
$placeholders[] = '{' . $placeholder . '}';
|
||||
$replacements[] = $parameter['name'];
|
||||
}
|
||||
|
||||
$event->setParsedSubject(str_replace($placeholders, $replacements, $subject))
|
||||
->setRichSubject($subject, $parameters);
|
||||
}
|
||||
|
||||
protected function getRoom(Room $room, string $userId): array {
|
||||
switch ($room->getType()) {
|
||||
case Room::TYPE_ONE_TO_ONE:
|
||||
case Room::TYPE_ONE_TO_ONE_FORMER:
|
||||
$stringType = 'one2one';
|
||||
break;
|
||||
case Room::TYPE_GROUP:
|
||||
$stringType = 'group';
|
||||
break;
|
||||
case Room::TYPE_PUBLIC:
|
||||
default:
|
||||
$stringType = 'public';
|
||||
break;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'call',
|
||||
'id' => (string)$room->getId(),
|
||||
'name' => $room->getDisplayName($userId),
|
||||
'link' => $this->url->linkToRouteAbsolute('spreed.Page.showCall', ['token' => $room->getToken()]),
|
||||
'call-type' => $stringType,
|
||||
'icon-url' => $this->avatarService->getAvatarUrl($room),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getFormerRoom(IL10N $l): array {
|
||||
return [
|
||||
'type' => 'highlight',
|
||||
'id' => 'deleted',
|
||||
'name' => $l->t('a conversation'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getUser(string $uid): array {
|
||||
return [
|
||||
'type' => 'user',
|
||||
'id' => $uid,
|
||||
'name' => $this->userManager->getDisplayName($uid) ?? $uid,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getRemoteUser(Room $room, string $federationId): array {
|
||||
$cloudId = $this->cloudIdManager->resolveCloudId($federationId);
|
||||
$displayName = $cloudId->getDisplayId();
|
||||
try {
|
||||
$participant = $this->participantService->getParticipantByActor($room, Attendee::ACTOR_FEDERATED_USERS, $federationId);
|
||||
$displayName = $participant->getAttendee()->getDisplayName();
|
||||
} catch (ParticipantNotFoundException) {
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'user',
|
||||
'id' => $cloudId->getUser(),
|
||||
'name' => $displayName,
|
||||
'server' => $cloudId->getRemote(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Activity\Provider;
|
||||
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Room;
|
||||
use OCP\Activity\Exceptions\UnknownActivityException;
|
||||
use OCP\Activity\IEvent;
|
||||
use OCP\IL10N;
|
||||
|
||||
class Call extends Base {
|
||||
/**
|
||||
* @param string $language
|
||||
* @param IEvent $event
|
||||
* @param IEvent|null $previousEvent
|
||||
* @return IEvent
|
||||
* @throws UnknownActivityException
|
||||
* @since 11.0.0
|
||||
*/
|
||||
#[\Override]
|
||||
public function parse($language, IEvent $event, ?IEvent $previousEvent = null): IEvent {
|
||||
$event = $this->preParse($event);
|
||||
|
||||
if ($event->getSubject() === 'call') {
|
||||
$l = $this->languageFactory->get('spreed', $language);
|
||||
$parameters = $event->getSubjectParameters();
|
||||
|
||||
try {
|
||||
$room = $this->manager->getRoomForUser((int)$parameters['room'], $this->activityManager->getCurrentUserId());
|
||||
} catch (RoomNotFoundException) {
|
||||
$room = null;
|
||||
}
|
||||
|
||||
$result = $this->parseCall($room, $event, $l);
|
||||
$result['subject'] .= ' ' . $this->getDuration($l, (int)$parameters['duration']);
|
||||
// $result['params']['call'] = $roomParameter;
|
||||
$this->setSubjects($event, $result['subject'], $result['params']);
|
||||
} else {
|
||||
throw new UnknownActivityException('subject');
|
||||
}
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
protected function getDuration(IL10N $l, int $seconds): string {
|
||||
$hours = floor($seconds / 3600);
|
||||
$seconds %= 3600;
|
||||
$minutes = floor($seconds / 60);
|
||||
$seconds %= 60;
|
||||
|
||||
if ($hours > 0) {
|
||||
$duration = sprintf('%1$d:%2$02d:%3$02d', $hours, $minutes, $seconds);
|
||||
} else {
|
||||
$duration = sprintf('%1$d:%2$02d', $minutes, $seconds);
|
||||
}
|
||||
|
||||
return $l->t('(Duration %s)', $duration);
|
||||
}
|
||||
|
||||
protected function parseCall(?Room $room, IEvent $event, IL10N $l): array {
|
||||
$parameters = $event->getSubjectParameters();
|
||||
|
||||
$currentUser = array_search($this->activityManager->getCurrentUserId(), $parameters['users'], true);
|
||||
if ($currentUser === false) {
|
||||
throw new UnknownActivityException('Unknown case');
|
||||
}
|
||||
unset($parameters['users'][$currentUser]);
|
||||
sort($parameters['users']);
|
||||
|
||||
if (!isset($parameters['cloudIds'])) {
|
||||
// Compatibility with old messages
|
||||
$parameters['cloudIds'] = [];
|
||||
}
|
||||
sort($parameters['users']);
|
||||
sort($parameters['cloudIds']);
|
||||
|
||||
$numUsers = $numRealUsers = count($parameters['users']);
|
||||
|
||||
// Without room, we can not resolve cloudIds, so we list them as guests instead
|
||||
if (!$room instanceof Room) {
|
||||
$numUsers += count($parameters['cloudIds']);
|
||||
} else {
|
||||
$parameters['guests'] += count($parameters['cloudIds']);
|
||||
}
|
||||
$displayedUsers = $numUsers;
|
||||
|
||||
switch ($numUsers) {
|
||||
case 0:
|
||||
$subject = $l->t('You attended a call with {user1}');
|
||||
$subject = str_replace('{user1}', $l->n('%n guest', '%n guests', $parameters['guests']), $subject);
|
||||
break;
|
||||
case 1:
|
||||
if ($parameters['guests'] === 0) {
|
||||
$subject = $l->t('You attended a call with {user1}');
|
||||
} else {
|
||||
$subject = $l->t('You attended a call with {user1} and {user2}');
|
||||
$subject = str_replace('{user2}', $l->n('%n guest', '%n guests', $parameters['guests']), $subject);
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
if ($parameters['guests'] === 0) {
|
||||
$subject = $l->t('You attended a call with {user1} and {user2}');
|
||||
} else {
|
||||
$subject = $l->t('You attended a call with {user1}, {user2} and {user3}');
|
||||
$subject = str_replace('{user3}', $l->n('%n guest', '%n guests', $parameters['guests']), $subject);
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
if ($parameters['guests'] === 0) {
|
||||
$subject = $l->t('You attended a call with {user1}, {user2} and {user3}');
|
||||
} else {
|
||||
$subject = $l->t('You attended a call with {user1}, {user2}, {user3} and {user4}');
|
||||
$subject = str_replace('{user4}', $l->n('%n guest', '%n guests', $parameters['guests']), $subject);
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
if ($parameters['guests'] === 0) {
|
||||
$subject = $l->t('You attended a call with {user1}, {user2}, {user3} and {user4}');
|
||||
} else {
|
||||
$subject = $l->t('You attended a call with {user1}, {user2}, {user3}, {user4} and {user5}');
|
||||
$subject = str_replace('{user5}', $l->n('%n guest', '%n guests', $parameters['guests']), $subject);
|
||||
}
|
||||
break;
|
||||
case 5:
|
||||
default:
|
||||
$subject = $l->t('You attended a call with {user1}, {user2}, {user3}, {user4} and {user5}');
|
||||
if ($numUsers === 5 && $parameters['guests'] === 0) {
|
||||
$displayedUsers = 5;
|
||||
} else {
|
||||
$displayedUsers = 4;
|
||||
$numOthers = $parameters['guests'] + $numUsers - $displayedUsers;
|
||||
$subject = str_replace('{user5}', $l->n('%n other', '%n others', $numOthers), $subject);
|
||||
}
|
||||
}
|
||||
|
||||
$params = [];
|
||||
for ($i = 1; $i <= $displayedUsers; $i++) {
|
||||
if ($i <= $numRealUsers) {
|
||||
$params['user' . $i] = $this->getUser($parameters['users'][$i - 1]);
|
||||
} else {
|
||||
$params['user' . $i] = $this->getRemoteUser($room, $parameters['cloudIds'][$i - $numRealUsers - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'subject' => $subject,
|
||||
'params' => $params,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Activity\Provider;
|
||||
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCP\Activity\Exceptions\UnknownActivityException;
|
||||
use OCP\Activity\IEvent;
|
||||
|
||||
class Invitation extends Base {
|
||||
/**
|
||||
* @param string $language
|
||||
* @param IEvent $event
|
||||
* @param IEvent|null $previousEvent
|
||||
* @return IEvent
|
||||
* @throws UnknownActivityException
|
||||
* @since 11.0.0
|
||||
*/
|
||||
#[\Override]
|
||||
public function parse($language, IEvent $event, ?IEvent $previousEvent = null): IEvent {
|
||||
$event = $this->preParse($event);
|
||||
|
||||
if ($event->getSubject() === 'invitation') {
|
||||
$l = $this->languageFactory->get('spreed', $language);
|
||||
$parameters = $event->getSubjectParameters();
|
||||
|
||||
try {
|
||||
$room = $this->manager->getRoomById((int)$parameters['room']);
|
||||
$roomParameter = $this->getRoom($room, $event->getAffectedUser());
|
||||
} catch (RoomNotFoundException) {
|
||||
$roomParameter = $this->getFormerRoom($l);
|
||||
}
|
||||
|
||||
$this->setSubjects($event, $l->t('{actor} invited you to {call}'), [
|
||||
'actor' => $this->getUser($parameters['user']),
|
||||
'call' => $roomParameter,
|
||||
]);
|
||||
} else {
|
||||
throw new UnknownActivityException('subject');
|
||||
}
|
||||
|
||||
return $event;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Activity;
|
||||
|
||||
use OCP\Activity\ActivitySettings;
|
||||
use OCP\IL10N;
|
||||
|
||||
class Setting extends ActivitySettings {
|
||||
|
||||
public function __construct(
|
||||
protected IL10N $l,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Lowercase a-z and underscore only identifier
|
||||
* @since 11.0.0
|
||||
*/
|
||||
#[\Override]
|
||||
public function getIdentifier(): string {
|
||||
return 'spreed';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string A translated string
|
||||
* @since 11.0.0
|
||||
*/
|
||||
#[\Override]
|
||||
public function getName(): string {
|
||||
return $this->l->t('You were invited to a <strong>conversation</strong> or had a <strong>call</strong>');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function getGroupIdentifier(): string {
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function getGroupName(): string {
|
||||
return $this->l->t('Other activities');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function getPriority(): int {
|
||||
return 51;
|
||||
}
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function canChangeNotification(): bool {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function isDefaultEnabledNotification(): bool {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\AppInfo;
|
||||
|
||||
use OCA\Circles\Events\AddingCircleMemberEvent;
|
||||
use OCA\Circles\Events\CircleDestroyedEvent;
|
||||
use OCA\Circles\Events\CircleEditedEvent;
|
||||
use OCA\Circles\Events\EditingCircleEvent;
|
||||
use OCA\Circles\Events\RemovingCircleMemberEvent;
|
||||
use OCA\Files\Event\LoadSidebar;
|
||||
use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent;
|
||||
use OCA\Talk\Activity\Listener as ActivityListener;
|
||||
use OCA\Talk\Capabilities;
|
||||
use OCA\Talk\Chat\Changelog\Listener as ChangelogListener;
|
||||
use OCA\Talk\Chat\Listener as ChatListener;
|
||||
use OCA\Talk\Chat\Parser\Changelog;
|
||||
use OCA\Talk\Chat\Parser\ReactionParser;
|
||||
use OCA\Talk\Chat\Parser\SystemMessage;
|
||||
use OCA\Talk\Chat\Parser\UserMention;
|
||||
use OCA\Talk\Chat\SystemMessage\Listener as SystemMessageListener;
|
||||
use OCA\Talk\Collaboration\Collaborators\Listener as CollaboratorsListener;
|
||||
use OCA\Talk\Collaboration\Reference\ReferenceInvalidationListener;
|
||||
use OCA\Talk\Collaboration\Reference\TalkReferenceProvider;
|
||||
use OCA\Talk\Collaboration\Resources\ConversationProvider;
|
||||
use OCA\Talk\Collaboration\Resources\Listener as ResourceListener;
|
||||
use OCA\Talk\Config;
|
||||
use OCA\Talk\Dashboard\TalkWidget;
|
||||
use OCA\Talk\Deck\DeckPluginLoader;
|
||||
use OCA\Talk\Events\AttendeeRemovedEvent;
|
||||
use OCA\Talk\Events\AttendeesAddedEvent;
|
||||
use OCA\Talk\Events\AttendeesRemovedEvent;
|
||||
use OCA\Talk\Events\BeforeAttendeeRemovedEvent;
|
||||
use OCA\Talk\Events\BeforeAttendeesAddedEvent;
|
||||
use OCA\Talk\Events\BeforeCallStartedEvent;
|
||||
use OCA\Talk\Events\BeforeDuplicateShareSentEvent;
|
||||
use OCA\Talk\Events\BeforeGuestJoinedRoomEvent;
|
||||
use OCA\Talk\Events\BeforeParticipantModifiedEvent;
|
||||
use OCA\Talk\Events\BeforeRoomDeletedEvent;
|
||||
use OCA\Talk\Events\BeforeRoomsFetchEvent;
|
||||
use OCA\Talk\Events\BeforeRoomSyncedEvent;
|
||||
use OCA\Talk\Events\BeforeSessionLeftRoomEvent;
|
||||
use OCA\Talk\Events\BeforeUserJoinedRoomEvent;
|
||||
use OCA\Talk\Events\BotDisabledEvent;
|
||||
use OCA\Talk\Events\BotEnabledEvent;
|
||||
use OCA\Talk\Events\BotInstallEvent;
|
||||
use OCA\Talk\Events\BotUninstallEvent;
|
||||
use OCA\Talk\Events\CallEndedEvent;
|
||||
use OCA\Talk\Events\CallEndedForEveryoneEvent;
|
||||
use OCA\Talk\Events\CallNotificationSendEvent;
|
||||
use OCA\Talk\Events\CallStartedEvent;
|
||||
use OCA\Talk\Events\ChatMessageSentEvent;
|
||||
use OCA\Talk\Events\EmailInvitationSentEvent;
|
||||
use OCA\Talk\Events\GuestJoinedRoomEvent;
|
||||
use OCA\Talk\Events\GuestsCleanedUpEvent;
|
||||
use OCA\Talk\Events\LobbyModifiedEvent;
|
||||
use OCA\Talk\Events\MessageParseEvent;
|
||||
use OCA\Talk\Events\ParticipantModifiedEvent;
|
||||
use OCA\Talk\Events\ReactionAddedEvent;
|
||||
use OCA\Talk\Events\ReactionRemovedEvent;
|
||||
use OCA\Talk\Events\RoomCreatedEvent;
|
||||
use OCA\Talk\Events\RoomDeletedEvent;
|
||||
use OCA\Talk\Events\RoomExtendedEvent;
|
||||
use OCA\Talk\Events\RoomModifiedEvent;
|
||||
use OCA\Talk\Events\RoomSyncedEvent;
|
||||
use OCA\Talk\Events\SessionLeftRoomEvent;
|
||||
use OCA\Talk\Events\SystemMessageSentEvent;
|
||||
use OCA\Talk\Events\SystemMessagesMultipleSentEvent;
|
||||
use OCA\Talk\Events\UserJoinedRoomEvent;
|
||||
use OCA\Talk\Federation\CloudFederationProviderTalk;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\Listener\ResourceTypeRegisterListener;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\Notifier\BeforeRoomDeletedListener as TalkV1BeforeRoomDeletedListener;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\Notifier\CancelRetryOCMListener as TalkV1CancelRetryOCMListener;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\Notifier\MessageSentListener as TalkV1MessageSentListener;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\Notifier\ParticipantModifiedListener as TalkV1ParticipantModifiedListener;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\Notifier\RoomModifiedListener as TalkV1RoomModifiedListener;
|
||||
use OCA\Talk\Files\Listener as FilesListener;
|
||||
use OCA\Talk\Files\TemplateLoader as FilesTemplateLoader;
|
||||
use OCA\Talk\Flow\RegisterOperationsListener;
|
||||
use OCA\Talk\Listener\AddMissingIndicesListener;
|
||||
use OCA\Talk\Listener\BeforeUserLoggedOutListener;
|
||||
use OCA\Talk\Listener\BotListener;
|
||||
use OCA\Talk\Listener\CalDavEventListener;
|
||||
use OCA\Talk\Listener\CircleDeletedListener;
|
||||
use OCA\Talk\Listener\CircleEditedListener;
|
||||
use OCA\Talk\Listener\CircleMembershipListener;
|
||||
use OCA\Talk\Listener\CSPListener;
|
||||
use OCA\Talk\Listener\DisplayNameListener;
|
||||
use OCA\Talk\Listener\FeaturePolicyListener;
|
||||
use OCA\Talk\Listener\GroupDeletedListener;
|
||||
use OCA\Talk\Listener\GroupMembershipListener;
|
||||
use OCA\Talk\Listener\NoteToSelfListener;
|
||||
use OCA\Talk\Listener\RestrictStartingCalls as RestrictStartingCallsListener;
|
||||
use OCA\Talk\Listener\SampleConversationsListener;
|
||||
use OCA\Talk\Listener\ThreadListener;
|
||||
use OCA\Talk\Listener\UserDeletedListener;
|
||||
use OCA\Talk\Maps\MapsPluginLoader;
|
||||
use OCA\Talk\Middleware\CanUseTalkMiddleware;
|
||||
use OCA\Talk\Middleware\InjectionMiddleware;
|
||||
use OCA\Talk\Middleware\ParameterOutOfRangeMiddleware;
|
||||
use OCA\Talk\Notification\Listener as NotificationListener;
|
||||
use OCA\Talk\Notification\Notifier;
|
||||
use OCA\Talk\OCP\TalkBackend;
|
||||
use OCA\Talk\Profile\TalkAction;
|
||||
use OCA\Talk\PublicShare\TemplateLoader as PublicShareTemplateLoader;
|
||||
use OCA\Talk\PublicShareAuth\Listener as PublicShareAuthListener;
|
||||
use OCA\Talk\PublicShareAuth\TemplateLoader as PublicShareAuthTemplateLoader;
|
||||
use OCA\Talk\Recording\Listener as RecordingListener;
|
||||
use OCA\Talk\Search\ConversationSearch;
|
||||
use OCA\Talk\Search\CurrentMessageSearch;
|
||||
use OCA\Talk\Search\MessageSearch;
|
||||
use OCA\Talk\Search\UnifiedSearchCSSLoader;
|
||||
use OCA\Talk\Search\UnifiedSearchFilterPlugin;
|
||||
use OCA\Talk\Settings\BeforePreferenceSetEventListener;
|
||||
use OCA\Talk\Settings\Personal;
|
||||
use OCA\Talk\SetupCheck\BackgroundBlurLoading;
|
||||
use OCA\Talk\SetupCheck\Configuration;
|
||||
use OCA\Talk\SetupCheck\FederationLockCache;
|
||||
use OCA\Talk\SetupCheck\HighPerformanceBackend;
|
||||
use OCA\Talk\SetupCheck\NotifyPush;
|
||||
use OCA\Talk\SetupCheck\RecordingBackend;
|
||||
use OCA\Talk\SetupCheck\SIPConfiguration;
|
||||
use OCA\Talk\Share\Listener as ShareListener;
|
||||
use OCA\Talk\Signaling\Listener as SignalingListener;
|
||||
use OCA\Talk\Status\Listener as StatusListener;
|
||||
use OCA\Talk\Team\TalkTeamResourceProvider;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\App;
|
||||
use OCP\AppFramework\Bootstrap\IBootContext;
|
||||
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
||||
use OCP\Calendar\Events\CalendarObjectCreatedEvent;
|
||||
use OCP\Calendar\Events\CalendarObjectUpdatedEvent;
|
||||
use OCP\Collaboration\AutoComplete\AutoCompleteFilterEvent;
|
||||
use OCP\Collaboration\Resources\IProviderManager;
|
||||
use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent;
|
||||
use OCP\Config\BeforePreferenceSetEvent;
|
||||
use OCP\DB\Events\AddMissingIndicesEvent;
|
||||
use OCP\EventDispatcher\IEventDispatcher;
|
||||
use OCP\Federation\ICloudFederationProvider;
|
||||
use OCP\Federation\ICloudFederationProviderManager;
|
||||
use OCP\Group\Events\GroupChangedEvent;
|
||||
use OCP\Group\Events\GroupDeletedEvent;
|
||||
use OCP\Group\Events\UserAddedEvent;
|
||||
use OCP\Group\Events\UserRemovedEvent;
|
||||
use OCP\IConfig;
|
||||
use OCP\INavigationManager;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
use OCP\L10N\IFactory;
|
||||
use OCP\OCM\Events\ResourceTypeRegisterEvent;
|
||||
use OCP\Security\CSP\AddContentSecurityPolicyEvent;
|
||||
use OCP\Security\FeaturePolicy\AddFeaturePolicyEvent;
|
||||
use OCP\Server;
|
||||
use OCP\Settings\IManager;
|
||||
use OCP\Share\Events\BeforeShareCreatedEvent;
|
||||
use OCP\Share\Events\ShareCreatedEvent;
|
||||
use OCP\Share\Events\VerifyMountPointEvent;
|
||||
use OCP\TaskProcessing\Events\TaskFailedEvent;
|
||||
use OCP\TaskProcessing\Events\TaskSuccessfulEvent;
|
||||
use OCP\User\Events\BeforeUserLoggedOutEvent;
|
||||
use OCP\User\Events\UserChangedEvent;
|
||||
use OCP\User\Events\UserDeletedEvent;
|
||||
use OCP\Util;
|
||||
use OCP\WorkflowEngine\Events\RegisterOperationsEvent;
|
||||
|
||||
class Application extends App implements IBootstrap {
|
||||
public const APP_ID = 'spreed';
|
||||
|
||||
public function __construct(array $urlParams = []) {
|
||||
parent::__construct(self::APP_ID, $urlParams);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function register(IRegistrationContext $context): void {
|
||||
$context->registerMiddleWare(CanUseTalkMiddleware::class);
|
||||
$context->registerMiddleWare(InjectionMiddleware::class);
|
||||
$context->registerMiddleWare(ParameterOutOfRangeMiddleware::class);
|
||||
$context->registerCapability(Capabilities::class);
|
||||
|
||||
// Listeners to load the UI and integrate it into other apps
|
||||
$context->registerEventListener(AddContentSecurityPolicyEvent::class, CSPListener::class);
|
||||
$context->registerEventListener(AddFeaturePolicyEvent::class, FeaturePolicyListener::class);
|
||||
$context->registerEventListener(\OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent::class, UnifiedSearchCSSLoader::class);
|
||||
$context->registerEventListener(\OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent::class, DeckPluginLoader::class);
|
||||
$context->registerEventListener(\OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent::class, MapsPluginLoader::class);
|
||||
$context->registerEventListener(\OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent::class, UnifiedSearchFilterPlugin::class);
|
||||
$context->registerEventListener(RegisterOperationsEvent::class, RegisterOperationsListener::class);
|
||||
$context->registerEventListener(BeforeTemplateRenderedEvent::class, PublicShareTemplateLoader::class);
|
||||
$context->registerEventListener(BeforeTemplateRenderedEvent::class, PublicShareAuthTemplateLoader::class);
|
||||
$context->registerEventListener(LoadSidebar::class, FilesTemplateLoader::class);
|
||||
$context->registerEventListener(BeforePreferenceSetEvent::class, BeforePreferenceSetEventListener::class);
|
||||
|
||||
// Activity listeners
|
||||
$context->registerEventListener(AttendeesAddedEvent::class, ActivityListener::class);
|
||||
$context->registerEventListener(CallEndedEvent::class, ActivityListener::class);
|
||||
$context->registerEventListener(CallEndedForEveryoneEvent::class, ActivityListener::class);
|
||||
|
||||
// Bot listeners
|
||||
$context->registerEventListener(BotDisabledEvent::class, BotListener::class);
|
||||
$context->registerEventListener(BotEnabledEvent::class, BotListener::class);
|
||||
$context->registerEventListener(BotInstallEvent::class, BotListener::class);
|
||||
$context->registerEventListener(BotUninstallEvent::class, BotListener::class);
|
||||
$context->registerEventListener(ChatMessageSentEvent::class, BotListener::class);
|
||||
$context->registerEventListener(ReactionAddedEvent::class, BotListener::class);
|
||||
$context->registerEventListener(ReactionRemovedEvent::class, BotListener::class);
|
||||
$context->registerEventListener(SystemMessageSentEvent::class, BotListener::class);
|
||||
|
||||
// Chat listeners
|
||||
$context->registerEventListener(BeforeRoomsFetchEvent::class, ChangelogListener::class);
|
||||
$context->registerEventListener(RoomDeletedEvent::class, ChatListener::class);
|
||||
$context->registerEventListener(BeforeRoomsFetchEvent::class, NoteToSelfListener::class);
|
||||
$context->registerEventListener(BeforeRoomsFetchEvent::class, SampleConversationsListener::class);
|
||||
$context->registerEventListener(AttendeesAddedEvent::class, SystemMessageListener::class);
|
||||
$context->registerEventListener(AttendeeRemovedEvent::class, SystemMessageListener::class);
|
||||
$context->registerEventListener(AttendeesRemovedEvent::class, SystemMessageListener::class);
|
||||
$context->registerEventListener(BeforeDuplicateShareSentEvent::class, SystemMessageListener::class);
|
||||
$context->registerEventListener(BeforeParticipantModifiedEvent::class, SystemMessageListener::class);
|
||||
$context->registerEventListener(BeforeShareCreatedEvent::class, SystemMessageListener::class);
|
||||
$context->registerEventListener(LobbyModifiedEvent::class, SystemMessageListener::class);
|
||||
$context->registerEventListener(ParticipantModifiedEvent::class, SystemMessageListener::class, 100);
|
||||
$context->registerEventListener(RoomCreatedEvent::class, SystemMessageListener::class);
|
||||
$context->registerEventListener(RoomModifiedEvent::class, SystemMessageListener::class);
|
||||
$context->registerEventListener(ShareCreatedEvent::class, SystemMessageListener::class);
|
||||
|
||||
// Chat parser
|
||||
$context->registerEventListener(MessageParseEvent::class, Changelog::class, -75);
|
||||
$context->registerEventListener(MessageParseEvent::class, ReactionParser::class);
|
||||
$context->registerEventListener(MessageParseEvent::class, SystemMessage::class);
|
||||
$context->registerEventListener(MessageParseEvent::class, SystemMessage::class, 9999);
|
||||
$context->registerEventListener(MessageParseEvent::class, UserMention::class, -100);
|
||||
|
||||
// Calendar listeners
|
||||
$context->registerEventListener(CalendarObjectCreatedEvent::class, CalDavEventListener::class);
|
||||
$context->registerEventListener(CalendarObjectUpdatedEvent::class, CalDavEventListener::class);
|
||||
|
||||
// Files integration listeners
|
||||
$context->registerEventListener(BeforeGuestJoinedRoomEvent::class, FilesListener::class);
|
||||
$context->registerEventListener(BeforeUserJoinedRoomEvent::class, FilesListener::class);
|
||||
|
||||
// Collaborators / Auto complete listeners
|
||||
$context->registerEventListener(AutoCompleteFilterEvent::class, CollaboratorsListener::class);
|
||||
|
||||
// Reference listeners
|
||||
$context->registerEventListener(AttendeesAddedEvent::class, ReferenceInvalidationListener::class);
|
||||
$context->registerEventListener(AttendeesRemovedEvent::class, ReferenceInvalidationListener::class);
|
||||
$context->registerEventListener(LobbyModifiedEvent::class, ReferenceInvalidationListener::class);
|
||||
$context->registerEventListener(RoomDeletedEvent::class, ReferenceInvalidationListener::class);
|
||||
$context->registerEventListener(RoomModifiedEvent::class, ReferenceInvalidationListener::class);
|
||||
|
||||
// Resources listeners
|
||||
$context->registerEventListener(AttendeesAddedEvent::class, ResourceListener::class);
|
||||
$context->registerEventListener(AttendeesRemovedEvent::class, ResourceListener::class);
|
||||
$context->registerEventListener(EmailInvitationSentEvent::class, ResourceListener::class);
|
||||
$context->registerEventListener(RoomDeletedEvent::class, ResourceListener::class);
|
||||
$context->registerEventListener(RoomModifiedEvent::class, ResourceListener::class);
|
||||
|
||||
// Sharing listeners
|
||||
$context->registerEventListener(BeforeShareCreatedEvent::class, ShareListener::class, 1000);
|
||||
$context->registerEventListener(VerifyMountPointEvent::class, ShareListener::class, 1000);
|
||||
$context->registerEventListener(RoomDeletedEvent::class, ShareListener::class);
|
||||
|
||||
// Group and Circles listeners
|
||||
$context->registerEventListener(GroupDeletedEvent::class, GroupDeletedListener::class);
|
||||
$context->registerEventListener(GroupChangedEvent::class, DisplayNameListener::class);
|
||||
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
|
||||
$context->registerEventListener(UserChangedEvent::class, DisplayNameListener::class);
|
||||
$context->registerEventListener(UserAddedEvent::class, GroupMembershipListener::class);
|
||||
$context->registerEventListener(UserRemovedEvent::class, GroupMembershipListener::class);
|
||||
$context->registerEventListener(CircleDestroyedEvent::class, CircleDeletedListener::class);
|
||||
$context->registerEventListener(EditingCircleEvent::class, CircleEditedListener::class);
|
||||
$context->registerEventListener(CircleEditedEvent::class, CircleEditedListener::class);
|
||||
$context->registerEventListener(AddingCircleMemberEvent::class, CircleMembershipListener::class);
|
||||
$context->registerEventListener(RemovingCircleMemberEvent::class, CircleMembershipListener::class);
|
||||
|
||||
// Notification listeners
|
||||
$context->registerEventListener(AttendeesAddedEvent::class, NotificationListener::class);
|
||||
$context->registerEventListener(BeforeCallStartedEvent::class, NotificationListener::class);
|
||||
$context->registerEventListener(CallStartedEvent::class, NotificationListener::class);
|
||||
$context->registerEventListener(CallNotificationSendEvent::class, NotificationListener::class);
|
||||
$context->registerEventListener(ParticipantModifiedEvent::class, NotificationListener::class);
|
||||
$context->registerEventListener(UserJoinedRoomEvent::class, NotificationListener::class);
|
||||
|
||||
// Call listeners
|
||||
$context->registerEventListener(BeforeUserLoggedOutEvent::class, BeforeUserLoggedOutListener::class);
|
||||
$context->registerEventListener(BeforeParticipantModifiedEvent::class, RestrictStartingCallsListener::class, 1000);
|
||||
$context->registerEventListener(BeforeParticipantModifiedEvent::class, StatusListener::class);
|
||||
$context->registerEventListener(CallEndedForEveryoneEvent::class, StatusListener::class);
|
||||
|
||||
// Recording listeners
|
||||
$context->registerEventListener(RoomDeletedEvent::class, RecordingListener::class);
|
||||
$context->registerEventListener(CallEndedEvent::class, RecordingListener::class);
|
||||
$context->registerEventListener(CallEndedForEveryoneEvent::class, RecordingListener::class);
|
||||
$context->registerEventListener(TaskSuccessfulEvent::class, RecordingListener::class);
|
||||
$context->registerEventListener(TaskFailedEvent::class, RecordingListener::class);
|
||||
|
||||
// Federation listeners
|
||||
$context->registerEventListener(BeforeRoomDeletedEvent::class, TalkV1BeforeRoomDeletedListener::class);
|
||||
$context->registerEventListener(ParticipantModifiedEvent::class, TalkV1ParticipantModifiedListener::class);
|
||||
$context->registerEventListener(CallEndedEvent::class, TalkV1RoomModifiedListener::class);
|
||||
$context->registerEventListener(CallEndedForEveryoneEvent::class, TalkV1RoomModifiedListener::class);
|
||||
$context->registerEventListener(CallStartedEvent::class, TalkV1RoomModifiedListener::class);
|
||||
$context->registerEventListener(LobbyModifiedEvent::class, TalkV1RoomModifiedListener::class);
|
||||
$context->registerEventListener(RoomModifiedEvent::class, TalkV1RoomModifiedListener::class);
|
||||
$context->registerEventListener(ChatMessageSentEvent::class, TalkV1MessageSentListener::class);
|
||||
$context->registerEventListener(SystemMessageSentEvent::class, TalkV1MessageSentListener::class);
|
||||
$context->registerEventListener(SystemMessagesMultipleSentEvent::class, TalkV1MessageSentListener::class);
|
||||
$context->registerEventListener(AttendeeRemovedEvent::class, TalkV1CancelRetryOCMListener::class);
|
||||
$context->registerEventListener(ResourceTypeRegisterEvent::class, ResourceTypeRegisterListener::class);
|
||||
|
||||
// Signaling listeners (External)
|
||||
$context->registerEventListener(AttendeesAddedEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(AttendeeRemovedEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(AttendeesRemovedEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(SessionLeftRoomEvent::class, SignalingListener::class);
|
||||
|
||||
$context->registerEventListener(CallEndedForEveryoneEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(GuestsCleanedUpEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(LobbyModifiedEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(BeforeRoomSyncedEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(RoomSyncedEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(RoomExtendedEvent::class, SignalingListener::class);
|
||||
|
||||
$context->registerEventListener(ChatMessageSentEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(SystemMessageSentEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(SystemMessagesMultipleSentEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(ReactionAddedEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(ReactionRemovedEvent::class, SignalingListener::class);
|
||||
|
||||
// Signaling listeners (Both)
|
||||
$context->registerEventListener(BeforeRoomDeletedEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(ParticipantModifiedEvent::class, SignalingListener::class, 50);
|
||||
$context->registerEventListener(RoomModifiedEvent::class, SignalingListener::class);
|
||||
|
||||
// Signaling listeners (Internal)
|
||||
$context->registerEventListener(BeforeSessionLeftRoomEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(BeforeAttendeeRemovedEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(GuestJoinedRoomEvent::class, SignalingListener::class);
|
||||
$context->registerEventListener(UserJoinedRoomEvent::class, SignalingListener::class);
|
||||
|
||||
// Threads listeners
|
||||
$context->registerEventListener(AttendeesRemovedEvent::class, ThreadListener::class);
|
||||
|
||||
// Video verification
|
||||
$context->registerEventListener(BeforeUserJoinedRoomEvent::class, PublicShareAuthListener::class);
|
||||
$context->registerEventListener(BeforeGuestJoinedRoomEvent::class, PublicShareAuthListener::class);
|
||||
$context->registerEventListener(BeforeAttendeesAddedEvent::class, PublicShareAuthListener::class);
|
||||
$context->registerEventListener(AttendeeRemovedEvent::class, PublicShareAuthListener::class);
|
||||
$context->registerEventListener(SessionLeftRoomEvent::class, PublicShareAuthListener::class);
|
||||
$context->registerEventListener(GuestsCleanedUpEvent::class, PublicShareAuthListener::class);
|
||||
|
||||
// Register other integrations of Talk
|
||||
$context->registerSearchProvider(ConversationSearch::class);
|
||||
$context->registerSearchProvider(CurrentMessageSearch::class);
|
||||
$context->registerSearchProvider(MessageSearch::class);
|
||||
|
||||
// Fix database issues
|
||||
$context->registerEventListener(AddMissingIndicesEvent::class, AddMissingIndicesListener::class);
|
||||
|
||||
$context->registerDashboardWidget(TalkWidget::class);
|
||||
|
||||
$context->registerNotifierService(Notifier::class);
|
||||
|
||||
$context->registerProfileLinkAction(TalkAction::class);
|
||||
|
||||
$context->registerReferenceProvider(TalkReferenceProvider::class);
|
||||
|
||||
$context->registerTalkBackend(TalkBackend::class);
|
||||
|
||||
$context->registerTeamResourceProvider(TalkTeamResourceProvider::class);
|
||||
|
||||
$context->registerSetupCheck(Configuration::class);
|
||||
$context->registerSetupCheck(HighPerformanceBackend::class);
|
||||
$context->registerSetupCheck(FederationLockCache::class);
|
||||
$context->registerSetupCheck(NotifyPush::class);
|
||||
$context->registerSetupCheck(RecordingBackend::class);
|
||||
$context->registerSetupCheck(SIPConfiguration::class);
|
||||
$context->registerSetupCheck(BackgroundBlurLoading::class);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function boot(IBootContext $context): void {
|
||||
$context->injectFn([$this, 'registerCollaborationResourceProvider']);
|
||||
$context->injectFn([$this, 'registerClientLinks']);
|
||||
$context->injectFn([$this, 'registerNavigationLink']);
|
||||
$context->injectFn([$this, 'registerCloudFederationProviderManager']);
|
||||
}
|
||||
|
||||
public function registerCollaborationResourceProvider(IProviderManager $resourceManager, IEventDispatcher $dispatcher): void {
|
||||
$resourceManager->registerResourceProvider(ConversationProvider::class);
|
||||
$dispatcher->addListener(LoadAdditionalScriptsEvent::class, static function (): void {
|
||||
Util::addScript(self::APP_ID, 'talk-collections');
|
||||
});
|
||||
}
|
||||
|
||||
public function registerClientLinks(IAppManager $appManager, IManager $settingManager): void {
|
||||
if ($appManager->isEnabledForUser('firstrunwizard')) {
|
||||
$settingManager->registerSetting('personal', Personal::class);
|
||||
}
|
||||
}
|
||||
|
||||
public function registerNavigationLink(INavigationManager $navigationManager): void {
|
||||
$navigationManager->add(static function () {
|
||||
$config = Server::get(Config::class);
|
||||
$userSession = Server::get(IUserSession::class);
|
||||
$urlGenerator = Server::get(IURLGenerator::class);
|
||||
$l = Server::get(IFactory::class)->get(self::APP_ID);
|
||||
$user = $userSession->getUser();
|
||||
return [
|
||||
'id' => self::APP_ID,
|
||||
'name' => $l->t('Talk'),
|
||||
'href' => $urlGenerator->linkToRouteAbsolute('spreed.Page.index'),
|
||||
'icon' => $urlGenerator->imagePath(self::APP_ID, 'app.svg'),
|
||||
'order' => -5,
|
||||
'type' => $user instanceof IUser && !$config->isDisabledForUser($user) ? 'link' : 'hidden',
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function registerCloudFederationProviderManager(
|
||||
IConfig $config,
|
||||
ICloudFederationProviderManager $manager,
|
||||
): void {
|
||||
if ($config->getAppValue('spreed', 'federation_enabled', 'no') !== 'yes') {
|
||||
return;
|
||||
}
|
||||
|
||||
$manager->addCloudFederationProvider(
|
||||
'talk-room',
|
||||
'Talk Federation',
|
||||
static fn (): ICloudFederationProvider => Server::get(CloudFederationProviderTalk::class)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\BackgroundJob;
|
||||
|
||||
use OCA\Talk\AppInfo\Application;
|
||||
use OCA\Talk\Config;
|
||||
use OCA\Talk\Service\CertificateService;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use OCP\IGroup;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\Notification\IManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class CheckCertificates extends TimedJob {
|
||||
public function __construct(
|
||||
protected CertificateService $certService,
|
||||
protected Config $talkConfig,
|
||||
protected ITimeFactory $timeFactory,
|
||||
protected IGroupManager $groupManager,
|
||||
protected IManager $notificationManager,
|
||||
protected LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($timeFactory);
|
||||
|
||||
// Run once a week
|
||||
$this->setInterval(60 * 60 * 24 * 7);
|
||||
$this->setTimeSensitivity(IJob::TIME_INSENSITIVE);
|
||||
}
|
||||
|
||||
/*
|
||||
* @return string[]
|
||||
*/
|
||||
private function getUsersToNotify(): array {
|
||||
$users = [];
|
||||
|
||||
$groupToNotify = $this->groupManager->get('admin');
|
||||
if ($groupToNotify instanceof IGroup) {
|
||||
foreach ($groupToNotify->getUsers() as $user) {
|
||||
$users[] = $user->getUID();
|
||||
}
|
||||
}
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a notification and inform admins about the certificate which is about to expire
|
||||
*
|
||||
* @param string $host The host which was checked
|
||||
* @param int $days Number of days until the certificate expires
|
||||
*/
|
||||
private function createNotifications(string $host, int $days): void {
|
||||
$notification = $this->notificationManager->createNotification();
|
||||
|
||||
try {
|
||||
$notification->setApp(Application::APP_ID)
|
||||
->setDateTime(new \DateTime())
|
||||
->setObject('certificate_expiration', $host);
|
||||
|
||||
$notification->setSubject('certificate_expiration', [
|
||||
'host' => $host,
|
||||
'days_to_expire' => $days,
|
||||
]);
|
||||
|
||||
foreach ($this->getUsersToNotify() as $uid) {
|
||||
$notification->setUser($uid);
|
||||
$this->notificationManager->notify($notification);
|
||||
}
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the certificate of the specified host
|
||||
*
|
||||
* @param string $host The host to check the certificate of without scheme
|
||||
*/
|
||||
private function checkServerCertificate(string $host): void {
|
||||
$expirationInDays = $this->certService->getCertificateExpirationInDays($host);
|
||||
|
||||
if ($expirationInDays == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($expirationInDays < 10) {
|
||||
$this->logger->warning('Certificate of ' . $host . ' expires in less than ' . $expirationInDays . ' days');
|
||||
|
||||
$this->createNotifications($host, $expirationInDays);
|
||||
} else {
|
||||
$this->logger->debug('Certificate of ' . $host . ' is valid for ' . $expirationInDays . ' days');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
protected function run($argument): void {
|
||||
$turnServers = $this->talkConfig->getTurnServers(false);
|
||||
|
||||
foreach ($turnServers as $turnServer) {
|
||||
// Only check server which support the 'turns' protocol
|
||||
if (!str_contains($turnServer['schemes'], 'turns')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->checkServerCertificate($turnServer['server']);
|
||||
}
|
||||
|
||||
$signalingServers = $this->talkConfig->getSignalingServers();
|
||||
|
||||
foreach ($signalingServers as $signalingServer) {
|
||||
if ((bool)$signalingServer['verify']) {
|
||||
$this->checkServerCertificate($signalingServer['server']);
|
||||
}
|
||||
}
|
||||
|
||||
$recordingServers = $this->talkConfig->getRecordingServers();
|
||||
|
||||
foreach ($recordingServers as $recordingServer) {
|
||||
if ((bool)$recordingServer['verify']) {
|
||||
$this->checkServerCertificate($recordingServer['server']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\BackgroundJob;
|
||||
|
||||
use OCA\Talk\Config;
|
||||
use OCA\Talk\DataObjects\AccountId;
|
||||
use OCA\Talk\Exceptions\HostedSignalingServerAPIException;
|
||||
use OCA\Talk\Service\HostedSignalingServerService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use OCP\IConfig;
|
||||
use OCP\IGroup;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Notification\IManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class CheckHostedSignalingServer extends TimedJob {
|
||||
|
||||
public function __construct(
|
||||
ITimeFactory $timeFactory,
|
||||
private HostedSignalingServerService $hostedSignalingServerService,
|
||||
private IConfig $config,
|
||||
private IManager $notificationManager,
|
||||
private IGroupManager $groupManager,
|
||||
private IURLGenerator $urlGenerator,
|
||||
private LoggerInterface $logger,
|
||||
private Config $talkConfig,
|
||||
) {
|
||||
parent::__construct($timeFactory);
|
||||
|
||||
// Every hour
|
||||
$this->setInterval(3600);
|
||||
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
||||
|
||||
}
|
||||
|
||||
private function formatTurnSchemes(array $schemes): string {
|
||||
if (in_array('turn', $schemes, true) && in_array('turns', $schemes, true)) {
|
||||
return 'turn,turns';
|
||||
} elseif (in_array('turn', $schemes, true)) {
|
||||
return 'turn';
|
||||
} elseif (in_array('turns', $schemes, true)) {
|
||||
return 'turns';
|
||||
} else {
|
||||
return 'turn';
|
||||
}
|
||||
}
|
||||
|
||||
private function formatTurnProtocols(array $protocols): string {
|
||||
if (in_array('udp', $protocols, true) && in_array('tcp', $protocols, true)) {
|
||||
return 'udp,tcp';
|
||||
} elseif (in_array('udp', $protocols, true)) {
|
||||
return 'udp';
|
||||
} elseif (in_array('tcp', $protocols, true)) {
|
||||
return 'tcp';
|
||||
} else {
|
||||
return 'udp';
|
||||
}
|
||||
}
|
||||
|
||||
private function updateStunTurnSettings(array $oldAccountInfo, array $accountInfo) {
|
||||
if (!empty($accountInfo['stun']['servers'])) {
|
||||
if ($this->talkConfig->getStunServers() !== $accountInfo['stun']['servers']) {
|
||||
// STUN servers were added / changed
|
||||
$this->config->setAppValue('spreed', 'stun_servers', json_encode($accountInfo['stun']['servers']));
|
||||
}
|
||||
} elseif (!empty($oldAccountInfo['stun']['servers'])) {
|
||||
// STUN servers are no longer available, reset to default.
|
||||
$this->config->deleteAppValue('spreed', 'stun_servers');
|
||||
}
|
||||
|
||||
if (!empty($accountInfo['turn']['servers'])) {
|
||||
$newTurnServers = [];
|
||||
foreach ($accountInfo['turn']['servers'] as $server) {
|
||||
$newTurnServers[] = [
|
||||
'server' => $server['server'],
|
||||
'secret' => $server['secret'],
|
||||
'schemes' => $this->formatTurnSchemes($server['schemes']),
|
||||
'protocols' => $this->formatTurnProtocols($server['protocols']),
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->talkConfig->getTurnServers() !== $newTurnServers) {
|
||||
// TURN servers were added / changed
|
||||
$this->config->setAppValue('spreed', 'turn_servers', json_encode($newTurnServers));
|
||||
}
|
||||
} elseif (!empty($oldAccountInfo['turn']['servers'])) {
|
||||
// TURN servers are no longer available, reset to default.
|
||||
$this->config->deleteAppValue('spreed', 'turn_servers');
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function run($argument): void {
|
||||
$accountId = $this->config->getAppValue('spreed', 'hosted-signaling-server-account-id', '');
|
||||
$oldAccountInfo = json_decode($this->config->getAppValue('spreed', 'hosted-signaling-server-account', '{}'), true);
|
||||
|
||||
if ($accountId === '') {
|
||||
return;
|
||||
}
|
||||
$accountId = new AccountId($accountId);
|
||||
try {
|
||||
$accountInfo = $this->hostedSignalingServerService->fetchAccountInfo($accountId);
|
||||
} catch (HostedSignalingServerAPIException $e) {
|
||||
if ($e->getCode() === Http::STATUS_NOT_FOUND) {
|
||||
// Account was deleted, so remove the information locally
|
||||
$accountInfo = ['status' => 'deleted'];
|
||||
} else {
|
||||
// API or connection issues - do nothing and just try again later
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$oldStatus = $oldAccountInfo['status'] ?? '';
|
||||
$newStatus = $accountInfo['status'];
|
||||
|
||||
$notificationSubject = null;
|
||||
$notificationParameters = [];
|
||||
|
||||
// the status has changed
|
||||
if ($oldStatus !== $newStatus) {
|
||||
if ($newStatus === 'deleted') {
|
||||
// remove signaling servers if account is not active anymore
|
||||
$this->config->deleteAppValue('spreed', 'signaling_mode');
|
||||
$this->config->deleteAppValue('spreed', 'signaling_servers');
|
||||
|
||||
$notificationSubject = 'removed';
|
||||
} elseif ($newStatus === 'active') {
|
||||
// add signaling servers if account got active
|
||||
$this->config->deleteAppValue('spreed', 'signaling_mode');
|
||||
$this->config->setAppValue('spreed', 'signaling_servers', json_encode([
|
||||
'servers' => [
|
||||
[
|
||||
'server' => $accountInfo['signaling']['url'],
|
||||
'verify' => true,
|
||||
]
|
||||
],
|
||||
'secret' => $accountInfo['signaling']['secret'],
|
||||
]));
|
||||
$this->updateStunTurnSettings($oldAccountInfo, $accountInfo);
|
||||
|
||||
$notificationSubject = 'added';
|
||||
}
|
||||
|
||||
if (is_null($notificationSubject)) {
|
||||
$notificationSubject = 'changed-status';
|
||||
$notificationParameters = [
|
||||
'oldstatus' => $oldAccountInfo['status'],
|
||||
'newstatus' => $accountInfo['status'],
|
||||
];
|
||||
}
|
||||
|
||||
// only credentials have changed
|
||||
} elseif ($newStatus === 'active') {
|
||||
if ($oldAccountInfo['signaling']['url'] !== $accountInfo['signaling']['url']
|
||||
|| $oldAccountInfo['signaling']['secret'] !== $accountInfo['signaling']['secret']) {
|
||||
$this->config->setAppValue('spreed', 'signaling_servers', json_encode([
|
||||
'servers' => [
|
||||
[
|
||||
'server' => $accountInfo['signaling']['url'],
|
||||
'verify' => true,
|
||||
]
|
||||
],
|
||||
'secret' => $accountInfo['signaling']['secret'],
|
||||
]));
|
||||
}
|
||||
$this->updateStunTurnSettings($oldAccountInfo, $accountInfo);
|
||||
}
|
||||
|
||||
// store new account info
|
||||
if ($oldAccountInfo !== $accountInfo) {
|
||||
$this->config->setAppValue('spreed', 'hosted-signaling-server-account', json_encode($accountInfo));
|
||||
}
|
||||
|
||||
if (!is_null($notificationSubject)) {
|
||||
$this->logger->info('Hosted signaling server background job caused a notification: ' . $notificationSubject . ' ' . json_encode($notificationParameters));
|
||||
|
||||
$notification = $this->notificationManager->createNotification();
|
||||
$notification
|
||||
->setApp('spreed')
|
||||
->setDateTime(new \DateTime())
|
||||
->setObject('hosted-signaling-server', $notificationSubject)
|
||||
->setSubject($notificationSubject, $notificationParameters)
|
||||
->setLink($this->urlGenerator->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'talk']) . '#signaling_server')
|
||||
->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('spreed', 'app-dark.svg')))
|
||||
;
|
||||
|
||||
$adminGroup = $this->groupManager->get('admin');
|
||||
if ($adminGroup instanceof IGroup) {
|
||||
$users = $adminGroup->getUsers();
|
||||
foreach ($users as $user) {
|
||||
// Now add the new notification
|
||||
$notification->setUser($user->getUID());
|
||||
$this->notificationManager->notify($notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\BackgroundJob;
|
||||
|
||||
use OCA\Talk\MatterbridgeManager;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use OCP\IConfig;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Class CheckMatterbridges
|
||||
*
|
||||
* @package OCA\Talk\BackgroundJob
|
||||
*/
|
||||
class CheckMatterbridges extends TimedJob {
|
||||
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
protected IConfig $serverConfig,
|
||||
protected MatterbridgeManager $bridgeManager,
|
||||
protected LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
|
||||
// Every 15 minutes
|
||||
$this->setInterval(60 * 15);
|
||||
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
||||
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function run($argument): void {
|
||||
if ($this->serverConfig->getAppValue('spreed', 'enable_matterbridge', '0') === '1') {
|
||||
$this->bridgeManager->checkAllBridges();
|
||||
$this->bridgeManager->killZombieBridges();
|
||||
$this->logger->info('Checked if Matterbridge instances are running correctly.');
|
||||
} else {
|
||||
if ($this->bridgeManager->stopAllBridges()) {
|
||||
$this->logger->info('Stopped all Matterbridge instances as it is disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\BackgroundJob;
|
||||
|
||||
use OCA\Talk\Chat\ChatManager;
|
||||
use OCA\Talk\Service\ProxyCacheMessageService;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
|
||||
class ExpireChatMessages extends TimedJob {
|
||||
|
||||
public function __construct(
|
||||
ITimeFactory $timeFactory,
|
||||
private ChatManager $chatManager,
|
||||
private ProxyCacheMessageService $pcmService,
|
||||
) {
|
||||
parent::__construct($timeFactory);
|
||||
|
||||
// Every 5 minutes
|
||||
$this->setInterval(5 * 60);
|
||||
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
protected function run($argument): void {
|
||||
$this->chatManager->deleteExpiredMessages();
|
||||
$this->pcmService->deleteExpiredMessages();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\BackgroundJob;
|
||||
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\RoomService;
|
||||
use OCP\AppFramework\Services\IAppConfig;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class ExpireObjectRooms extends TimedJob {
|
||||
|
||||
public function __construct(
|
||||
ITimeFactory $timeFactory,
|
||||
protected Manager $manager,
|
||||
protected RoomService $roomService,
|
||||
protected LoggerInterface $logger,
|
||||
protected IAppConfig $appConfig,
|
||||
) {
|
||||
parent::__construct($timeFactory);
|
||||
$this->setInterval(60 * 60);
|
||||
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function run($argument): void {
|
||||
$phoneRetention = $this->appConfig->getAppValueInt('retention_phone_rooms', 7);
|
||||
if ($phoneRetention !== 0) {
|
||||
$this->executeRetention(Room::OBJECT_TYPE_PHONE_TEMPORARY, $phoneRetention);
|
||||
}
|
||||
|
||||
$eventRetention = $this->appConfig->getAppValueInt('retention_event_rooms', 28);
|
||||
if ($eventRetention !== 0) {
|
||||
$this->executeRetention(Room::OBJECT_TYPE_EVENT, $eventRetention);
|
||||
}
|
||||
|
||||
$instantMeetingRetention = $this->appConfig->getAppValueInt('retention_instant_meetings', 1);
|
||||
if ($instantMeetingRetention !== 0) {
|
||||
$this->executeRetention(Room::OBJECT_TYPE_INSTANT_MEETING, $instantMeetingRetention);
|
||||
}
|
||||
}
|
||||
|
||||
protected function executeRetention(string $objectType, int $retention): void {
|
||||
$now = $this->time->getTime();
|
||||
$minimumLastActivity = $now - $retention * 24 * 3600;
|
||||
$rooms = $this->manager->getExpiringRoomsForObjectType($objectType, $minimumLastActivity);
|
||||
|
||||
$numDeletedRooms = 0;
|
||||
foreach ($rooms as $room) {
|
||||
if ($objectType === Room::OBJECT_TYPE_EVENT) {
|
||||
[, $endTime] = explode('#', $room->getObjectId());
|
||||
if ($endTime >= $minimumLastActivity) {
|
||||
// Event time is in the future, so don't even consider deleting
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$this->roomService->deleteRoom($room);
|
||||
$numDeletedRooms++;
|
||||
}
|
||||
|
||||
$this->logger->info('Deleted {numDeletedRooms} {objectType} rooms because they did not have activity since {minimumLastActivity} days', [
|
||||
'objectType' => $objectType,
|
||||
'numDeletedRooms' => $numDeletedRooms,
|
||||
'minimumLastActivity' => $retention,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\BackgroundJob;
|
||||
|
||||
use OCA\Talk\Signaling\Messages;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
|
||||
/**
|
||||
* Class ExpireSignalingMessage
|
||||
*
|
||||
* @package OCA\Talk\BackgroundJob
|
||||
*/
|
||||
class ExpireSignalingMessage extends TimedJob {
|
||||
|
||||
public function __construct(
|
||||
ITimeFactory $timeFactory,
|
||||
protected Messages $messages,
|
||||
) {
|
||||
parent::__construct($timeFactory);
|
||||
|
||||
// Every 5 minutes
|
||||
$this->setInterval(60 * 5);
|
||||
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
||||
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function run($argument): void {
|
||||
// Older than 5 minutes
|
||||
$this->messages->expireOlderThan(5 * 60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\BackgroundJob;
|
||||
|
||||
use OCA\Talk\Config;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\RoomService;
|
||||
use OCA\Talk\Webinary;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class LockInactiveRooms extends TimedJob {
|
||||
|
||||
public function __construct(
|
||||
ITimeFactory $timeFactory,
|
||||
private RoomService $roomService,
|
||||
private Config $appConfig,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($timeFactory);
|
||||
|
||||
// Every hour
|
||||
$this->setInterval(60 * 60 * 24);
|
||||
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
public function run($argument): void {
|
||||
$interval = $this->appConfig->getInactiveLockTime();
|
||||
$forceLobby = $this->appConfig->enableLobbyOnLockedRooms();
|
||||
if ($interval === 0) {
|
||||
return;
|
||||
}
|
||||
$timestamp = $this->time->getTime() - $interval * 60 * 60 * 24;
|
||||
$time = $this->time->getDateTime('@' . $timestamp);
|
||||
$rooms = $this->roomService->getInactiveRooms($time);
|
||||
array_map(function (Room $room) use ($forceLobby) {
|
||||
$this->roomService->setReadOnly($room, Room::READ_ONLY);
|
||||
$this->logger->debug("Locking room {$room->getId()} due to inactivity");
|
||||
if ($forceLobby) {
|
||||
$this->roomService->setLobby($room, Webinary::LOBBY_NON_MODERATORS, $this->time->getDateTime());
|
||||
$this->logger->debug("Enabling lobby for room {$room->getId()}");
|
||||
}
|
||||
}, $rooms);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OCA\Talk\BackgroundJob;
|
||||
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\RoomService;
|
||||
use OCP\AppFramework\Services\IAppConfig;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
|
||||
class MaximumCallDuration extends TimedJob {
|
||||
public function __construct(
|
||||
private IAppConfig $appConfig,
|
||||
private Manager $manager,
|
||||
private RoomService $roomService,
|
||||
private ParticipantService $participantService,
|
||||
ITimeFactory $time,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
|
||||
// Every time the jobs run
|
||||
$this->setInterval(1);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function run($argument): void {
|
||||
$maxCallDuration = $this->appConfig->getAppValueInt('max_call_duration');
|
||||
if ($maxCallDuration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = $this->time->getDateTime();
|
||||
$maxActiveSince = $now->sub(new \DateInterval('PT' . $maxCallDuration . 'S'));
|
||||
$rooms = $this->manager->getRoomsLongerActiveSince($maxActiveSince);
|
||||
|
||||
foreach ($rooms as $room) {
|
||||
if ($room->isFederatedConversation()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->roomService->resetActiveSinceInDatabaseOnly($room);
|
||||
if (!$result) {
|
||||
// Someone else won the race condition, make sure this user disconnects directly and then return
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->participantService->endCallForEveryone($room, null);
|
||||
$this->roomService->resetActiveSinceInModelOnly($room);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\BackgroundJob;
|
||||
|
||||
use OCA\Talk\Service\ReminderService;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
|
||||
class Reminder extends TimedJob {
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
protected ReminderService $reminderService,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
// Every minute
|
||||
$this->setInterval(60);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
protected function run($argument): void {
|
||||
$this->reminderService->executeReminders($this->time->getDateTime());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\BackgroundJob;
|
||||
|
||||
use OCA\Talk\Federation\FederationManager;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\RoomService;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use OCP\Files\Config\IUserMountCache;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Class RemoveEmptyRooms
|
||||
*
|
||||
* @package OCA\Talk\BackgroundJob
|
||||
*/
|
||||
class RemoveEmptyRooms extends TimedJob {
|
||||
|
||||
protected int $numDeletedRooms = 0;
|
||||
|
||||
public function __construct(
|
||||
ITimeFactory $timeFactory,
|
||||
protected Manager $manager,
|
||||
protected RoomService $roomService,
|
||||
protected ParticipantService $participantService,
|
||||
protected FederationManager $federationManager,
|
||||
protected LoggerInterface $logger,
|
||||
protected IUserMountCache $userMountCache,
|
||||
) {
|
||||
parent::__construct($timeFactory);
|
||||
|
||||
// Every 5 minutes
|
||||
$this->setInterval(60 * 5);
|
||||
$this->setTimeSensitivity(IJob::TIME_INSENSITIVE);
|
||||
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function run($argument): void {
|
||||
$this->manager->forAllRooms([$this, 'callback']);
|
||||
|
||||
if ($this->numDeletedRooms) {
|
||||
$this->logger->info('Deleted {numDeletedRooms} rooms because they were empty', [
|
||||
'numDeletedRooms' => $this->numDeletedRooms,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function callback(Room $room): void {
|
||||
if ($room->getType() === Room::TYPE_CHANGELOG) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->deleteIfIsEmpty($room)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->deleteIfFileIsRemoved($room);
|
||||
}
|
||||
|
||||
private function deleteIfIsEmpty(Room $room): bool {
|
||||
if ($room->getObjectType() === 'file') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->participantService->getNumberOfActors($room) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($room->isFederatedConversation()
|
||||
&& $this->federationManager->getNumberOfInvitations($room) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->doDeleteRoom($room);
|
||||
return true;
|
||||
}
|
||||
|
||||
private function deleteIfFileIsRemoved(Room $room): bool {
|
||||
if ($room->getObjectType() !== 'file') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$mountsForFile = $this->userMountCache->getMountsForFileId((int)$room->getObjectId());
|
||||
if (!empty($mountsForFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->doDeleteRoom($room);
|
||||
return true;
|
||||
}
|
||||
|
||||
private function doDeleteRoom(Room $room): void {
|
||||
$this->roomService->deleteRoom($room);
|
||||
$this->numDeletedRooms++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\BackgroundJob;
|
||||
|
||||
use OCA\Talk\CachePrefix;
|
||||
use OCA\Talk\Manager;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use OCP\ICache;
|
||||
use OCP\ICacheFactory;
|
||||
|
||||
class ResetAssignedSignalingServer extends TimedJob {
|
||||
protected ICache $cache;
|
||||
|
||||
/**
|
||||
* @param ITimeFactory $time
|
||||
* @param Manager $manager
|
||||
* @param ICacheFactory $cacheFactory
|
||||
*/
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
protected Manager $manager,
|
||||
ICacheFactory $cacheFactory,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
|
||||
// Every 5 minutes
|
||||
$this->setInterval(60 * 5);
|
||||
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
||||
|
||||
$this->cache = $cacheFactory->createDistributed(CachePrefix::SIGNALING_ASSIGNED_SERVER);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function run($argument): void {
|
||||
$this->manager->resetAssignedSignalingServers($this->cache);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OCA\Talk\BackgroundJob;
|
||||
|
||||
use OCA\Talk\Federation\BackendNotifier;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
|
||||
/**
|
||||
* Retry to send OCM notifications
|
||||
*/
|
||||
class RetryNotificationsJob extends TimedJob {
|
||||
public function __construct(
|
||||
private BackendNotifier $backendNotifier,
|
||||
ITimeFactory $timeFactory,
|
||||
) {
|
||||
parent::__construct($timeFactory);
|
||||
|
||||
// Every time the jobs run
|
||||
$this->setInterval(1);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function run($argument): void {
|
||||
$this->backendNotifier->retrySendingFailedNotifications($this->time->getDateTime());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk;
|
||||
|
||||
class CachePrefix {
|
||||
public const FEDERATED_PCM = 'talk/pcm/';
|
||||
public const CHAT_LAST_MESSAGE_ID = 'talk/lastmsgid';
|
||||
public const CHAT_UNREAD_COUNT = 'talk/unreadcount';
|
||||
public const SIGNALING_ASSIGNED_SERVER = 'hpb_servers';
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk;
|
||||
|
||||
use OCA\Guests\UserBackend;
|
||||
use OCA\Talk\Chat\ChatManager;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Service\LiveTranscriptionService;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Services\IAppConfig;
|
||||
use OCP\Capabilities\IPublicCapability;
|
||||
use OCP\Comments\ICommentsManager;
|
||||
use OCP\ICache;
|
||||
use OCP\ICacheFactory;
|
||||
use OCP\IConfig;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
use OCP\TaskProcessing\IManager as ITaskProcessingManager;
|
||||
use OCP\TaskProcessing\TaskTypes\TextToTextSummary;
|
||||
use OCP\TaskProcessing\TaskTypes\TextToTextTranslate;
|
||||
use OCP\Translation\ITranslationManager;
|
||||
use OCP\Util;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkCapabilities from ResponseDefinitions
|
||||
*/
|
||||
class Capabilities implements IPublicCapability {
|
||||
public const FEATURES = [
|
||||
'audio',
|
||||
'video',
|
||||
'chat-v2',
|
||||
'conversation-v4',
|
||||
'guest-signaling',
|
||||
'empty-group-room',
|
||||
'guest-display-names',
|
||||
'multi-room-users',
|
||||
'favorites',
|
||||
'last-room-activity',
|
||||
'no-ping',
|
||||
'system-messages',
|
||||
'delete-messages',
|
||||
'mention-flag',
|
||||
'in-call-flags',
|
||||
'conversation-call-flags',
|
||||
'notification-levels',
|
||||
'invite-groups-and-mails',
|
||||
'locked-one-to-one-rooms',
|
||||
'read-only-rooms',
|
||||
'listable-rooms',
|
||||
'chat-read-marker',
|
||||
'chat-unread',
|
||||
'webinary-lobby',
|
||||
'start-call-flag',
|
||||
'chat-replies',
|
||||
'circles-support',
|
||||
'force-mute',
|
||||
'sip-support',
|
||||
'sip-support-nopin',
|
||||
'chat-read-status',
|
||||
'phonebook-search',
|
||||
'raise-hand',
|
||||
'room-description',
|
||||
'rich-object-sharing',
|
||||
'temp-user-avatar-api',
|
||||
'geo-location-sharing',
|
||||
'voice-message-sharing',
|
||||
'signaling-v3',
|
||||
'publishing-permissions',
|
||||
'clear-history',
|
||||
'direct-mention-flag',
|
||||
'notification-calls',
|
||||
'conversation-permissions',
|
||||
'rich-object-list-media',
|
||||
'rich-object-delete',
|
||||
'unified-search',
|
||||
'chat-permission',
|
||||
'silent-send',
|
||||
'silent-call',
|
||||
'send-call-notification',
|
||||
'talk-polls',
|
||||
'breakout-rooms-v1',
|
||||
'recording-v1',
|
||||
'avatar',
|
||||
'chat-get-context',
|
||||
'single-conversation-status',
|
||||
'chat-keep-notifications',
|
||||
'typing-privacy',
|
||||
'remind-me-later',
|
||||
'bots-v1',
|
||||
'markdown-messages',
|
||||
'media-caption',
|
||||
'session-state',
|
||||
'note-to-self',
|
||||
'recording-consent',
|
||||
'sip-support-dialout',
|
||||
'delete-messages-unlimited',
|
||||
'edit-messages',
|
||||
'silent-send-state',
|
||||
'chat-read-last',
|
||||
'federation-v1',
|
||||
'federation-v2',
|
||||
'ban-v1',
|
||||
'chat-reference-id',
|
||||
'mention-permissions',
|
||||
'edit-messages-note-to-self',
|
||||
'archived-conversations-v2',
|
||||
'talk-polls-drafts',
|
||||
'download-call-participants',
|
||||
'email-csv-import',
|
||||
'conversation-creation-password',
|
||||
'call-notification-state-api',
|
||||
'schedule-meeting',
|
||||
'edit-draft-poll',
|
||||
'conversation-creation-all',
|
||||
'important-conversations',
|
||||
'unbind-conversation',
|
||||
'sip-direct-dialin',
|
||||
'dashboard-event-rooms',
|
||||
'mutual-calendar-events',
|
||||
'upcoming-reminders',
|
||||
'sensitive-conversations',
|
||||
'threads',
|
||||
'federated-shared-items',
|
||||
];
|
||||
|
||||
public const CONDITIONAL_FEATURES = [
|
||||
'message-expiration',
|
||||
'reactions',
|
||||
'chat-summary-api',
|
||||
'call-end-to-end-encryption',
|
||||
];
|
||||
|
||||
public const LOCAL_FEATURES = [
|
||||
'favorites',
|
||||
'chat-read-status',
|
||||
'listable-rooms',
|
||||
'phonebook-search',
|
||||
'temp-user-avatar-api',
|
||||
'unified-search',
|
||||
'avatar',
|
||||
'remind-me-later',
|
||||
'note-to-self',
|
||||
'archived-conversations-v2',
|
||||
'chat-summary-api',
|
||||
'call-notification-state-api',
|
||||
'schedule-meeting',
|
||||
'conversation-creation-all',
|
||||
'important-conversations',
|
||||
'sip-direct-dialin',
|
||||
'dashboard-event-rooms',
|
||||
'mutual-calendar-events',
|
||||
'upcoming-reminders',
|
||||
'sensitive-conversations',
|
||||
];
|
||||
|
||||
public const LOCAL_CONFIGS = [
|
||||
'attachments' => [
|
||||
'allowed',
|
||||
'folder',
|
||||
],
|
||||
'call' => [
|
||||
'predefined-backgrounds',
|
||||
'predefined-backgrounds-v2',
|
||||
'can-upload-background',
|
||||
'start-without-media',
|
||||
'blur-virtual-background',
|
||||
'play-sounds',
|
||||
'grid-limit',
|
||||
'grid-limit-enforced',
|
||||
],
|
||||
'chat' => [
|
||||
'read-privacy',
|
||||
'has-translation-providers',
|
||||
'has-translation-task-providers',
|
||||
'typing-privacy',
|
||||
'summary-threshold',
|
||||
'matterbridge-enabled',
|
||||
],
|
||||
'conversations' => [
|
||||
'can-create',
|
||||
'list-style',
|
||||
'description-length',
|
||||
],
|
||||
'federation' => [
|
||||
'enabled',
|
||||
'incoming-enabled',
|
||||
'outgoing-enabled',
|
||||
'only-trusted-servers',
|
||||
],
|
||||
'previews' => [
|
||||
'max-gif-size',
|
||||
],
|
||||
'signaling' => [
|
||||
'session-ping-limit',
|
||||
'hello-v2-token-key',
|
||||
'mode',
|
||||
],
|
||||
'experiments' => [
|
||||
'enabled',
|
||||
],
|
||||
'permissions' => [
|
||||
],
|
||||
];
|
||||
|
||||
protected ICache $talkCache;
|
||||
|
||||
public function __construct(
|
||||
protected IConfig $serverConfig,
|
||||
protected Config $talkConfig,
|
||||
protected IAppConfig $appConfig,
|
||||
protected ICommentsManager $commentsManager,
|
||||
protected IUserSession $userSession,
|
||||
protected IAppManager $appManager,
|
||||
protected ITranslationManager $translationManager,
|
||||
protected ITaskProcessingManager $taskProcessingManager,
|
||||
protected LiveTranscriptionService $liveTranscriptionService,
|
||||
ICacheFactory $cacheFactory,
|
||||
) {
|
||||
$this->talkCache = $cacheFactory->createLocal('talk::');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* spreed?: TalkCapabilities,
|
||||
* }
|
||||
*/
|
||||
#[\Override]
|
||||
public function getCapabilities(): array {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user instanceof IUser && $this->talkConfig->isDisabledForUser($user)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$capabilities = [
|
||||
'features' => self::FEATURES,
|
||||
'features-local' => self::LOCAL_FEATURES,
|
||||
'config' => [
|
||||
'attachments' => [
|
||||
'allowed' => $user instanceof IUser && $user->getBackendClassName() !== UserBackend::class,
|
||||
// 'folder' => string,
|
||||
],
|
||||
'call' => [
|
||||
'enabled' => ((int)$this->serverConfig->getAppValue('spreed', 'start_calls', (string)Room::START_CALL_EVERYONE)) !== Room::START_CALL_NOONE,
|
||||
'breakout-rooms' => $this->talkConfig->isBreakoutRoomsEnabled(),
|
||||
'recording' => $this->talkConfig->isRecordingEnabled(),
|
||||
'recording-consent' => $this->talkConfig->recordingConsentRequired(),
|
||||
'supported-reactions' => ['❤️', '🎉', '👏', '👋', '👍', '👎', '🔥', '😂', '🤩', '🤔', '😲', '😥'],
|
||||
// 'predefined-backgrounds' => list<string>,
|
||||
// 'predefined-backgrounds-v2' => list<string>,
|
||||
'can-upload-background' => false,
|
||||
'sip-enabled' => $this->talkConfig->isSIPConfigured(),
|
||||
'sip-dialout-enabled' => $this->talkConfig->isSIPDialOutEnabled(),
|
||||
'default-phone-region' => $this->serverConfig->getSystemValueString('default_phone_region'),
|
||||
'can-enable-sip' => false,
|
||||
'start-without-media' => $this->talkConfig->getCallsStartWithoutMedia($user?->getUID()),
|
||||
'max-duration' => $this->appConfig->getAppValueInt('max_call_duration'),
|
||||
'blur-virtual-background' => $this->talkConfig->getBlurVirtualBackground($user?->getUID()),
|
||||
'end-to-end-encryption' => $this->talkConfig->isCallEndToEndEncryptionEnabled(),
|
||||
'live-transcription' => $this->talkConfig->getSignalingMode() === Config::SIGNALING_EXTERNAL
|
||||
&& $this->liveTranscriptionService->isLiveTranscriptionAppEnabled(),
|
||||
'play-sounds' => $this->talkConfig->getPlaySoundsForUser($user),
|
||||
'grid-limit' => $this->talkConfig->getGridVideosLimit(),
|
||||
'grid-limit-enforced' => $this->talkConfig->getGridVideosLimitEnforced(),
|
||||
],
|
||||
'chat' => [
|
||||
'max-length' => ChatManager::MAX_CHAT_LENGTH,
|
||||
'read-privacy' => Participant::PRIVACY_PUBLIC,
|
||||
'has-translation-providers' => $this->translationManager->hasProviders(),
|
||||
'has-translation-task-providers' => false,
|
||||
'typing-privacy' => Participant::PRIVACY_PUBLIC,
|
||||
'summary-threshold' => max(1, $this->appConfig->getAppValueInt('summary_threshold', 100)),
|
||||
'matterbridge-enabled' => $user instanceof IUser && $this->serverConfig->getAppValue('spreed', 'enable_matterbridge', '0') === '1',
|
||||
],
|
||||
'conversations' => [
|
||||
'can-create' => $user instanceof IUser && !$this->talkConfig->isNotAllowedToCreateConversations($user),
|
||||
'force-passwords' => $this->talkConfig->isPasswordEnforced(),
|
||||
'list-style' => $this->talkConfig->getConversationsListStyle($user?->getUID()),
|
||||
'description-length' => Room::DESCRIPTION_MAXIMUM_LENGTH,
|
||||
'retention-event' => max(0, $this->appConfig->getAppValueInt('retention_event_rooms', 28)),
|
||||
'retention-phone' => max(0, $this->appConfig->getAppValueInt('retention_phone_rooms', 7)),
|
||||
'retention-instant-meetings' => max(0, $this->appConfig->getAppValueInt('retention_instant_meetings', 1)),
|
||||
],
|
||||
'federation' => [
|
||||
'enabled' => false,
|
||||
'incoming-enabled' => false,
|
||||
'outgoing-enabled' => false,
|
||||
'only-trusted-servers' => true,
|
||||
],
|
||||
'previews' => [
|
||||
'max-gif-size' => (int)$this->serverConfig->getAppValue('spreed', 'max-gif-size', '3145728'),
|
||||
],
|
||||
'signaling' => [
|
||||
'session-ping-limit' => max(0, (int)$this->serverConfig->getAppValue('spreed', 'session-ping-limit', '200')),
|
||||
'mode' => $this->talkConfig->getSignalingMode(),
|
||||
// 'hello-v2-token-key' => string,
|
||||
],
|
||||
'experiments' => [
|
||||
'enabled' => max(0, $this->appConfig->getAppValueInt($user instanceof IUser ? 'experiments_users' : 'experiments_guests')),
|
||||
],
|
||||
'permissions' => [
|
||||
'max-default' => Attendee::PERMISSIONS_MAX_DEFAULT,
|
||||
'max-custom' => Attendee::PERMISSIONS_MAX_CUSTOM,
|
||||
'default' => $this->talkConfig->getDefaultPermissions(),
|
||||
],
|
||||
],
|
||||
'config-local' => self::LOCAL_CONFIGS,
|
||||
'version' => $this->appManager->getAppVersion('spreed'),
|
||||
];
|
||||
|
||||
if ($this->serverConfig->getAppValue('core', 'backgroundjobs_mode', 'ajax') === 'cron') {
|
||||
$capabilities['features'][] = 'message-expiration';
|
||||
}
|
||||
|
||||
if ($this->commentsManager->supportReactions()) {
|
||||
$capabilities['features'][] = 'reactions';
|
||||
}
|
||||
|
||||
if ($user instanceof IUser) {
|
||||
if ($this->talkConfig->isFederationEnabled() && $this->talkConfig->isFederationEnabledForUserId($user)) {
|
||||
$capabilities['config']['federation'] = [
|
||||
'enabled' => true,
|
||||
'incoming-enabled' => $this->appConfig->getAppValueBool('federation_incoming_enabled', true),
|
||||
'outgoing-enabled' => $this->appConfig->getAppValueBool('federation_outgoing_enabled', true),
|
||||
'only-trusted-servers' => $this->appConfig->getAppValueBool('federation_only_trusted_servers'),
|
||||
];
|
||||
}
|
||||
|
||||
$capabilities['config']['attachments']['folder'] = $this->talkConfig->getAttachmentFolder($user->getUID());
|
||||
$capabilities['config']['chat']['read-privacy'] = $this->talkConfig->getUserReadPrivacy($user->getUID());
|
||||
$capabilities['config']['chat']['typing-privacy'] = $this->talkConfig->getUserTypingPrivacy($user->getUID());
|
||||
$capabilities['config']['call']['blur-virtual-background'] = $this->talkConfig->getBlurVirtualBackground($user->getUID());
|
||||
}
|
||||
|
||||
$pubKey = $this->talkConfig->getSignalingTokenPublicKey();
|
||||
if ($pubKey) {
|
||||
$capabilities['config']['signaling']['hello-v2-token-key'] = $pubKey;
|
||||
}
|
||||
|
||||
$includeBrandedBackgrounds = $user instanceof IUser || $this->appConfig->getAppValueBool('backgrounds_branded_for_guests');
|
||||
$includeDefaultBackgrounds = !$user instanceof IUser || $this->appConfig->getAppValueBool('backgrounds_default_for_users', true);
|
||||
|
||||
$predefinedBackgrounds = [];
|
||||
$defaultBackgrounds = $this->getBackgroundsFromDirectory(__DIR__ . '/../img/backgrounds', '_default');
|
||||
if ($includeBrandedBackgrounds) {
|
||||
$predefinedBackgrounds = $this->getBackgroundsFromDirectory(\OC::$SERVERROOT . '/themes/talk-backgrounds', '_branded');
|
||||
$predefinedBackgrounds = array_map(static fn ($fileName) => '/themes/talk-backgrounds/' . $fileName, $predefinedBackgrounds);
|
||||
}
|
||||
|
||||
if ($includeDefaultBackgrounds) {
|
||||
$spreedWebPath = $this->appManager->getAppWebPath('spreed');
|
||||
$prefixedDefaultBackgrounds = array_map(static fn ($fileName) => $spreedWebPath . '/img/backgrounds/' . $fileName, $defaultBackgrounds);
|
||||
$predefinedBackgrounds = array_merge($predefinedBackgrounds, $prefixedDefaultBackgrounds);
|
||||
}
|
||||
|
||||
$capabilities['config']['call']['predefined-backgrounds'] = $defaultBackgrounds;
|
||||
$capabilities['config']['call']['predefined-backgrounds-v2'] = array_values($predefinedBackgrounds);
|
||||
|
||||
if ($user instanceof IUser) {
|
||||
$userAllowedToUpload = $this->appConfig->getAppValueBool('backgrounds_upload_users', true);
|
||||
if ($userAllowedToUpload) {
|
||||
$quota = $user->getQuota();
|
||||
if ($quota !== 'none') {
|
||||
$quota = Util::computerFileSize($quota);
|
||||
}
|
||||
$capabilities['config']['call']['can-upload-background'] = $quota === 'none' || $quota > 0;
|
||||
}
|
||||
$capabilities['config']['call']['can-enable-sip'] = $this->talkConfig->canUserEnableSIP($user);
|
||||
}
|
||||
|
||||
$supportedTaskTypeIds = $this->taskProcessingManager->getAvailableTaskTypeIds();
|
||||
if (in_array(TextToTextSummary::ID, $supportedTaskTypeIds, true)) {
|
||||
$capabilities['features'][] = 'chat-summary-api';
|
||||
}
|
||||
if (in_array(TextToTextTranslate::ID, $supportedTaskTypeIds, true)) {
|
||||
$capabilities['config']['chat']['has-translation-task-providers'] = true;
|
||||
}
|
||||
|
||||
if ($this->talkConfig->getSignalingMode() === Config::SIGNALING_EXTERNAL) {
|
||||
$capabilities['features'][] = 'call-end-to-end-encryption';
|
||||
}
|
||||
|
||||
return [
|
||||
'spreed' => $capabilities,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function getBackgroundsFromDirectory(string $directory, string $cacheSuffix): array {
|
||||
$cacheKey = 'predefined_backgrounds' . $cacheSuffix;
|
||||
|
||||
/** @var ?list<string> $predefinedBackgrounds */
|
||||
$predefinedBackgrounds = null;
|
||||
$cachedPredefinedBackgrounds = $this->talkCache->get($cacheKey);
|
||||
if ($cachedPredefinedBackgrounds !== null) {
|
||||
// Try using cached value
|
||||
/** @var list<string>|null $predefinedBackgrounds */
|
||||
$predefinedBackgrounds = json_decode($cachedPredefinedBackgrounds, true);
|
||||
}
|
||||
|
||||
if (!is_array($predefinedBackgrounds)) {
|
||||
if (file_exists($directory) && is_dir($directory)) {
|
||||
$directoryIterator = new \DirectoryIterator($directory);
|
||||
foreach ($directoryIterator as $file) {
|
||||
if (!$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
if ($file->isDot()) {
|
||||
continue;
|
||||
}
|
||||
if ($file->getFilename() === 'COPYING') {
|
||||
continue;
|
||||
}
|
||||
$predefinedBackgrounds[] = $file->getFilename();
|
||||
}
|
||||
sort($predefinedBackgrounds);
|
||||
}
|
||||
|
||||
$this->talkCache->set($cacheKey, json_encode($predefinedBackgrounds), 300);
|
||||
}
|
||||
|
||||
return $predefinedBackgrounds ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Chat\AutoComplete;
|
||||
|
||||
use OCA\Talk\Federation\Authenticator;
|
||||
use OCA\Talk\Files\Util;
|
||||
use OCA\Talk\GuestManager;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\TalkSession;
|
||||
use OCP\Collaboration\Collaborators\ISearchPlugin;
|
||||
use OCP\Collaboration\Collaborators\ISearchResult;
|
||||
use OCP\Collaboration\Collaborators\SearchResultType;
|
||||
use OCP\IL10N;
|
||||
use OCP\IUserManager;
|
||||
|
||||
class SearchPlugin implements ISearchPlugin {
|
||||
|
||||
protected ?Room $room = null;
|
||||
|
||||
public function __construct(
|
||||
protected IUserManager $userManager,
|
||||
protected GuestManager $guestManager,
|
||||
protected TalkSession $talkSession,
|
||||
protected ParticipantService $participantService,
|
||||
protected Util $util,
|
||||
protected ?string $userId,
|
||||
protected IL10N $l,
|
||||
protected Authenticator $federationAuthenticator,
|
||||
) {
|
||||
}
|
||||
|
||||
public function setContext(array $context): void {
|
||||
$this->room = $context['room'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $search
|
||||
* @param int $limit
|
||||
* @param int $offset
|
||||
* @param ISearchResult $searchResult
|
||||
* @return bool whether the plugin has more results
|
||||
* @since 13.0.0
|
||||
*/
|
||||
#[\Override]
|
||||
public function search($search, $limit, $offset, ISearchResult $searchResult): bool {
|
||||
if ($this->room->getObjectType() === 'file') {
|
||||
$usersWithFileAccess = $this->util->getUsersWithAccessFile($this->room->getObjectId());
|
||||
if (!empty($usersWithFileAccess)) {
|
||||
$users = [];
|
||||
foreach ($usersWithFileAccess as $userId) {
|
||||
$users[$userId] = $this->userManager->getDisplayName($userId) ?? $userId;
|
||||
}
|
||||
$this->searchUsers($search, $users, $searchResult);
|
||||
}
|
||||
}
|
||||
|
||||
/** @var array<string, string> $userIds */
|
||||
$userIds = [];
|
||||
/** @var array<string, string> $groupIds */
|
||||
$groupIds = [];
|
||||
/** @var array<string, string> $cloudIds */
|
||||
$cloudIds = [];
|
||||
/** @var array<string, Attendee> $emailAttendees */
|
||||
$emailAttendees = [];
|
||||
/** @var list<Attendee> $guestAttendees */
|
||||
$guestAttendees = [];
|
||||
/** @var array<string, string> $teamIds */
|
||||
$teamIds = [];
|
||||
|
||||
if ($this->room->getType() === Room::TYPE_ONE_TO_ONE) {
|
||||
// Add potential leavers of one-to-one rooms again.
|
||||
$participants = json_decode($this->room->getName(), true);
|
||||
foreach ($participants as $userId) {
|
||||
$userIds[$userId] = $this->userManager->getDisplayName($userId) ?? $userId;
|
||||
}
|
||||
} else {
|
||||
$participants = $this->participantService->getParticipantsForRoom($this->room);
|
||||
foreach ($participants as $participant) {
|
||||
$attendee = $participant->getAttendee();
|
||||
if ($attendee->getActorType() === Attendee::ACTOR_GUESTS) {
|
||||
$guestAttendees[] = $attendee;
|
||||
} elseif ($attendee->getActorType() === Attendee::ACTOR_EMAILS) {
|
||||
$emailAttendees[$attendee->getActorId()] = $attendee;
|
||||
} elseif ($attendee->getActorType() === Attendee::ACTOR_USERS) {
|
||||
$userIds[$attendee->getActorId()] = $attendee->getDisplayName();
|
||||
} elseif ($attendee->getActorType() === Attendee::ACTOR_FEDERATED_USERS) {
|
||||
$cloudIds[$attendee->getActorId()] = $attendee->getDisplayName();
|
||||
} elseif ($attendee->getActorType() === Attendee::ACTOR_GROUPS) {
|
||||
$groupIds[$attendee->getActorId()] = $attendee->getDisplayName();
|
||||
} elseif ($attendee->getActorType() === Attendee::ACTOR_CIRCLES) {
|
||||
$teamIds[$attendee->getActorId()] = $attendee->getDisplayName();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->searchUsers($search, $userIds, $searchResult);
|
||||
$this->searchGroups($search, $groupIds, $searchResult);
|
||||
$this->searchGuests($search, $guestAttendees, $searchResult);
|
||||
$this->searchEmails($search, $emailAttendees, $searchResult);
|
||||
$this->searchFederatedUsers($search, $cloudIds, $searchResult);
|
||||
$this->searchTeams($search, $teamIds, $searchResult);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int, string> $users
|
||||
*/
|
||||
protected function searchUsers(string $search, array $users, ISearchResult $searchResult): void {
|
||||
$search = mb_strtolower($search);
|
||||
|
||||
$type = new SearchResultType('users');
|
||||
|
||||
$matches = $exactMatches = [];
|
||||
foreach ($users as $userId => $displayName) {
|
||||
$userId = (string)$userId;
|
||||
if ($searchResult->hasResult($type, $userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($search === '') {
|
||||
$matches[] = $this->createResult('user', $userId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strtolower($userId) === $search) {
|
||||
$exactMatches[] = $this->createResult('user', $userId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stripos($userId, $search) !== false) {
|
||||
$matches[] = $this->createResult('user', $userId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($displayName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_strtolower($displayName) === $search) {
|
||||
$exactMatches[] = $this->createResult('user', $userId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_stripos($displayName, $search) !== false) {
|
||||
$matches[] = $this->createResult('user', $userId, $displayName);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$searchResult->addResultSet($type, $matches, $exactMatches);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $cloudIds
|
||||
*/
|
||||
protected function searchFederatedUsers(string $search, array $cloudIds, ISearchResult $searchResult): void {
|
||||
$search = mb_strtolower($search);
|
||||
|
||||
$type = new SearchResultType('federated_users');
|
||||
|
||||
$matches = $exactMatches = [];
|
||||
foreach ($cloudIds as $cloudId => $displayName) {
|
||||
if ($searchResult->hasResult($type, $cloudId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($search === '') {
|
||||
$matches[] = $this->createResult('federated_user', $cloudId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_strtolower($cloudId) === $search) {
|
||||
$exactMatches[] = $this->createResult('federated_user', $cloudId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stripos($cloudId, $search) !== false) {
|
||||
$matches[] = $this->createResult('federated_user', $cloudId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($displayName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_strtolower($displayName) === $search) {
|
||||
$exactMatches[] = $this->createResult('federated_user', $cloudId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_stripos($displayName, $search) !== false) {
|
||||
$matches[] = $this->createResult('federated_user', $cloudId, $displayName);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$searchResult->addResultSet($type, $matches, $exactMatches);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int, string> $groups
|
||||
*/
|
||||
protected function searchGroups(string $search, array $groups, ISearchResult $searchResult): void {
|
||||
$search = mb_strtolower($search);
|
||||
|
||||
$type = new SearchResultType('groups');
|
||||
|
||||
$matches = $exactMatches = [];
|
||||
foreach ($groups as $groupId => $displayName) {
|
||||
if ($displayName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupId = (string)$groupId;
|
||||
if ($searchResult->hasResult($type, $groupId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($search === '') {
|
||||
$matches[] = $this->createGroupResult($groupId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_strtolower($groupId) === $search) {
|
||||
$exactMatches[] = $this->createGroupResult($groupId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_stripos($groupId, $search) !== false) {
|
||||
$matches[] = $this->createGroupResult($groupId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_strtolower($displayName) === $search) {
|
||||
$exactMatches[] = $this->createGroupResult($groupId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_stripos($displayName, $search) !== false) {
|
||||
$matches[] = $this->createGroupResult($groupId, $displayName);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$searchResult->addResultSet($type, $matches, $exactMatches);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $search
|
||||
* @param list<Attendee> $attendees
|
||||
* @param ISearchResult $searchResult
|
||||
*/
|
||||
protected function searchGuests(string $search, array $attendees, ISearchResult $searchResult): void {
|
||||
if (empty($attendees)) {
|
||||
$type = new SearchResultType('guests');
|
||||
$searchResult->addResultSet($type, [], []);
|
||||
return;
|
||||
}
|
||||
|
||||
$search = mb_strtolower($search);
|
||||
$matches = $exactMatches = [];
|
||||
foreach ($attendees as $attendee) {
|
||||
$name = $attendee->getDisplayName() ?: $this->l->t('Guest');
|
||||
if ($search === '') {
|
||||
$matches[] = $this->createGuestResult($attendee->getActorId(), $name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_strtolower($name) === $search) {
|
||||
$exactMatches[] = $this->createGuestResult($attendee->getActorId(), $name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_stripos($name, $search) !== false) {
|
||||
$matches[] = $this->createGuestResult($attendee->getActorId(), $name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$type = new SearchResultType('guests');
|
||||
$searchResult->addResultSet($type, $matches, $exactMatches);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $search
|
||||
* @param array<string, Attendee> $attendees
|
||||
* @param ISearchResult $searchResult
|
||||
*/
|
||||
protected function searchEmails(string $search, array $attendees, ISearchResult $searchResult): void {
|
||||
if (empty($attendees)) {
|
||||
$type = new SearchResultType('emails');
|
||||
$searchResult->addResultSet($type, [], []);
|
||||
return;
|
||||
}
|
||||
|
||||
$search = mb_strtolower($search);
|
||||
$currentSessionHash = null;
|
||||
if (!$this->userId) {
|
||||
// Best effort: Might not work on guests that reloaded but not worth too much performance impact atm.
|
||||
$currentSessionHash = false; // FIXME sha1($this->talkSession->getSessionForRoom($this->room->getToken()));
|
||||
}
|
||||
|
||||
$matches = $exactMatches = [];
|
||||
foreach ($attendees as $actorId => $attendee) {
|
||||
if ($currentSessionHash === $actorId) {
|
||||
// Do not suggest the current guest
|
||||
continue;
|
||||
}
|
||||
|
||||
$displayName = $attendee->getDisplayName() ?: $this->l->t('Guest');
|
||||
if ($search === '') {
|
||||
$matches[] = $this->createEmailResult($actorId, $displayName, $attendee->getInvitedCloudId());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_strtolower($displayName) === $search) {
|
||||
$exactMatches[] = $this->createEmailResult($actorId, $displayName, $attendee->getInvitedCloudId());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_stripos($displayName, $search) !== false) {
|
||||
$matches[] = $this->createEmailResult($actorId, $displayName, $attendee->getInvitedCloudId());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$type = new SearchResultType('emails');
|
||||
$searchResult->addResultSet($type, $matches, $exactMatches);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $search
|
||||
* @param array<string, Attendee> $attendees
|
||||
* @param ISearchResult $searchResult
|
||||
*/
|
||||
/**
|
||||
* @param array<string|int, string> $teams
|
||||
*/
|
||||
protected function searchTeams(string $search, array $teams, ISearchResult $searchResult): void {
|
||||
$search = mb_strtolower($search);
|
||||
|
||||
$type = new SearchResultType('teams');
|
||||
|
||||
$matches = $exactMatches = [];
|
||||
foreach ($teams as $teamId => $displayName) {
|
||||
if ($displayName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$teamId = (string)$teamId;
|
||||
if ($searchResult->hasResult($type, $teamId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($search === '') {
|
||||
$matches[] = $this->createTeamResult($teamId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strtolower($teamId) === $search) {
|
||||
$exactMatches[] = $this->createTeamResult($teamId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stripos($teamId, $search) !== false) {
|
||||
$matches[] = $this->createTeamResult($teamId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_strtolower($displayName) === $search) {
|
||||
$exactMatches[] = $this->createTeamResult($teamId, $displayName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_stripos($displayName, $search) !== false) {
|
||||
$matches[] = $this->createTeamResult($teamId, $displayName);
|
||||
}
|
||||
}
|
||||
|
||||
$searchResult->addResultSet($type, $matches, $exactMatches);
|
||||
}
|
||||
|
||||
protected function createResult(string $type, string $uid, string $name): array {
|
||||
if ($type === 'user' && $name === '') {
|
||||
$name = $this->userManager->getDisplayName($uid) ?? $uid;
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => $name,
|
||||
'value' => [
|
||||
'shareType' => $type,
|
||||
'shareWith' => $uid,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function createGroupResult(string $groupId, string $name): array {
|
||||
return [
|
||||
'label' => $name,
|
||||
'value' => [
|
||||
'shareType' => 'group',
|
||||
'shareWith' => 'group/' . $groupId,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function createGuestResult(string $actorId, string $name): array {
|
||||
return [
|
||||
'label' => $name,
|
||||
'value' => [
|
||||
'shareType' => 'guest',
|
||||
'shareWith' => 'guest/' . $actorId,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function createEmailResult(string $actorId, string $name, ?string $email): array {
|
||||
$data = [
|
||||
'label' => $name,
|
||||
'value' => [
|
||||
'shareType' => 'email',
|
||||
'shareWith' => 'email/' . $actorId,
|
||||
],
|
||||
];
|
||||
|
||||
if ($email) {
|
||||
$data['details'] = ['email' => $email];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function createTeamResult(string $actorId, string $name): array {
|
||||
return [
|
||||
'label' => $name,
|
||||
'value' => [
|
||||
'shareType' => 'team',
|
||||
'shareWith' => 'team/' . $actorId,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Chat\AutoComplete;
|
||||
|
||||
use OCA\Talk\Chat\ChatManager;
|
||||
use OCA\Talk\Chat\CommentsManager;
|
||||
use OCP\Collaboration\AutoComplete\ISorter;
|
||||
|
||||
class Sorter implements ISorter {
|
||||
public function __construct(
|
||||
protected CommentsManager $commentsManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The ID of the sorter, e.g. commenters
|
||||
* @since 13.0.0
|
||||
*/
|
||||
#[\Override]
|
||||
public function getId(): string {
|
||||
return 'talk_chat_participants';
|
||||
}
|
||||
|
||||
/**
|
||||
* executes the sort action
|
||||
*
|
||||
* @param array $sortArray the array to be sorted, provided as reference
|
||||
* @param array{itemType: string, itemId: string, search?: string, selfUserId?: ?string, selfCloudId?: ?string} $context carries key 'itemType' and 'itemId' of the source object (e.g. a file)
|
||||
* @since 13.0.0
|
||||
*/
|
||||
#[\Override]
|
||||
public function sort(array &$sortArray, array $context): void {
|
||||
foreach ($sortArray as $type => &$byType) {
|
||||
if ($type !== 'users') {
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @var \DateTime[] $lastComments */
|
||||
$lastComments = $this->commentsManager->getLastCommentDateByActor(
|
||||
$context['itemType'],
|
||||
$context['itemId'],
|
||||
ChatManager::VERB_MESSAGE,
|
||||
$type,
|
||||
array_map(function (array $suggestion) {
|
||||
return $suggestion['value']['shareWith'];
|
||||
}, $byType));
|
||||
|
||||
$search = $context['search'];
|
||||
$selfUserId = $context['selfUserId'] ?? null;
|
||||
|
||||
usort($byType, static function (array $a, array $b) use ($lastComments, $search, $selfUserId) {
|
||||
if ($selfUserId === $a['value']['shareWith']) {
|
||||
return 1;
|
||||
}
|
||||
if ($selfUserId === $b['value']['shareWith']) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
// If the user searched for "Dani" we make sure "Daniel" comes before "Madani"
|
||||
if (stripos($a['label'], $search) === 0) {
|
||||
if (stripos($b['label'], $search) !== 0) {
|
||||
return -1;
|
||||
}
|
||||
} elseif (stripos($b['label'], $search) === 0) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($lastComments[$b['value']['shareWith']])) {
|
||||
return -1;
|
||||
}
|
||||
if (!isset($lastComments[$a['value']['shareWith']])) {
|
||||
return 1;
|
||||
}
|
||||
return $lastComments[$b['value']['shareWith']]->getTimestamp() - $lastComments[$a['value']['shareWith']]->getTimestamp();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Chat\Changelog;
|
||||
|
||||
use OCA\Talk\Events\BeforeRoomsFetchEvent;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\IConfig;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class Listener implements IEventListener {
|
||||
public function __construct(
|
||||
protected Manager $manager,
|
||||
protected IConfig $serverConfig,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof BeforeRoomsFetchEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->serverConfig->getAppValue('spreed', 'changelog', 'yes') !== 'yes') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->manager->updateChangelog($event->getUserId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Chat\Changelog;
|
||||
|
||||
use OCA\Talk\Chat\ChatManager;
|
||||
use OCA\Talk\Manager as RoomManager;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\IConfig;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\IL10N;
|
||||
use OCP\PreConditionNotMetException;
|
||||
|
||||
class Manager {
|
||||
|
||||
public function __construct(
|
||||
protected IConfig $config,
|
||||
protected IDBConnection $connection,
|
||||
protected RoomManager $roomManager,
|
||||
protected ChatManager $chatManager,
|
||||
protected ITimeFactory $timeFactory,
|
||||
protected IL10N $l,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getChangelogForUser(string $userId): int {
|
||||
return (int)$this->config->getUserValue($userId, 'spreed', 'changelog', '0');
|
||||
}
|
||||
|
||||
public function updateChangelog(string $userId): void {
|
||||
$logs = $this->getChangelogs();
|
||||
$hasReceivedLog = $this->getChangelogForUser($userId);
|
||||
$shouldHaveReceived = count($logs);
|
||||
|
||||
if ($hasReceivedLog === $shouldHaveReceived) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->config->setUserValue($userId, 'spreed', 'changelog', (string)$shouldHaveReceived, (string)$hasReceivedLog);
|
||||
} catch (PreConditionNotMetException) {
|
||||
// Parallel request won the race
|
||||
return;
|
||||
}
|
||||
|
||||
$room = $this->roomManager->getChangelogRoom($userId);
|
||||
|
||||
foreach ($logs as $key => $changelog) {
|
||||
if ($key < $hasReceivedLog || $changelog === '') {
|
||||
continue;
|
||||
}
|
||||
$this->chatManager->addChangelogMessage($room, $changelog);
|
||||
}
|
||||
}
|
||||
|
||||
public function getChangelogs(): array {
|
||||
return [
|
||||
$this->l->t(
|
||||
"## Welcome to Nextcloud Talk!\n"
|
||||
. 'In this conversation you will be informed about new features available in Nextcloud Talk.'
|
||||
),
|
||||
$this->l->t('## New in Talk %s', ['6']),
|
||||
$this->l->t('- Microsoft Edge and Safari can now be used to participate in audio and video calls'),
|
||||
$this->l->t('- One-to-one conversations are now persistent and cannot be turned into group conversations by accident anymore. Also when one of the participants leaves the conversation, the conversation is not automatically deleted anymore. Only if both participants leave, the conversation is deleted from the server'),
|
||||
$this->l->t('- You can now notify all participants by posting "@all" into the chat'),
|
||||
$this->l->t('- With the "arrow-up" key you can repost your last message'),
|
||||
$this->l->t('- Talk can now have commands, send "/help" as a chat message to see if your administrator configured some'),
|
||||
$this->l->t('- With projects you can create quick links between conversations, files and other items'),
|
||||
$this->l->t('## New in Talk %s', ['7']),
|
||||
$this->l->t('- You can now mention guests in the chat'),
|
||||
$this->l->t('- Conversations can now have a lobby. This will allow moderators to join the chat and call already to prepare the meeting, while users and guests have to wait'),
|
||||
$this->l->t('## New in Talk %s', ['8']),
|
||||
$this->l->t('- You can now directly reply to messages giving the other users more context what your message is about'),
|
||||
$this->l->t('- Searching for conversations and participants will now also filter your existing conversations, making it much easier to find previous conversations'),
|
||||
$this->l->t('- You can now add custom user groups to conversations when the circles app is installed'),
|
||||
$this->l->t('## New in Talk %s', ['9']),
|
||||
$this->l->t('- Check out the new grid and call view'),
|
||||
$this->l->t('- You can now upload and drag\'n\'drop files directly from your device into the chat'),
|
||||
$this->l->t('- Shared files are now opened directly inside the chat view with the viewer apps'),
|
||||
$this->l->t('## New in Talk %s', ['10']),
|
||||
$this->l->t('- You can now search for chats and messages in the unified search in the top bar'),
|
||||
$this->l->t('- Spice up your messages with emojis from the emoji picker'),
|
||||
$this->l->t('- You can now change your camera and microphone while being in a call'),
|
||||
$this->l->t('## New in Talk %s', ['11']),
|
||||
$this->l->t('- Give your conversations some context with a description and open it up so logged in users can find it and join themselves'),
|
||||
$this->l->t('- See a read status and send failed messages again'),
|
||||
$this->l->t('- Raise your hand in a call with the R key'),
|
||||
$this->l->t('## New in Talk %s', ['12']),
|
||||
$this->l->t('- Join the same conversation and call from multiple devices'),
|
||||
$this->l->t('- Send voice messages, share your location or contact details'),
|
||||
$this->l->t('- Add groups to a conversation and new group members will automatically be added as participants'),
|
||||
$this->l->t('## New in Talk %s', ['13']),
|
||||
$this->l->t('- A preview of your audio and video is shown before joining a call'),
|
||||
$this->l->t('- You can now blur your background in the newly designed call view'),
|
||||
$this->l->t('- Moderators can now assign general and individual permissions to participants'),
|
||||
$this->l->t('## New in Talk %s', ['14']),
|
||||
$this->l->t('- You can now react to chat messages'),
|
||||
$this->l->t('- In the sidebar you can now find an overview of the latest shared items'),
|
||||
$this->l->t('## New in Talk %s', ['15']),
|
||||
$this->l->t('- Use a poll to collect the opinions of others or settle on a date'),
|
||||
$this->l->t('- Configure an expiration time for chat messages'),
|
||||
$this->l->t('- Start calls without notifying others in big conversations. You can send individual call notifications once the call has started.'),
|
||||
$this->l->t('- Send chat messages without notifying the recipients in case it is not urgent'),
|
||||
$this->l->t('## New in Talk %s', ['16']),
|
||||
$this->l->t('- Emojis can now be autocompleted by typing a ":"'),
|
||||
$this->l->t('- Link various items using the new smart-picker by typing a "/"'),
|
||||
$this->l->t('- Moderators can now create breakout rooms (requires the High-performance backend)'),
|
||||
$this->l->t('- Calls can now be recorded (requires the High-performance backend)'),
|
||||
$this->l->t('## New in Talk %s', ['17']) . "\n"
|
||||
. $this->l->t('- Conversations can now have an avatar or emoji as icon') . "\n"
|
||||
. $this->l->t('- Virtual backgrounds are now available in addition to the blurred background in video calls') . "\n"
|
||||
. $this->l->t('- Reactions are now available during calls') . "\n"
|
||||
. $this->l->t('- Typing indicators show which users are currently typing a message') . "\n"
|
||||
. $this->l->t('- Groups can now be mentioned in chats') . "\n"
|
||||
. $this->l->t('- Call recordings are automatically transcribed if a transcription provider app is registered') . "\n"
|
||||
. $this->l->t('- Chat messages can be translated if a translation provider app is registered'),
|
||||
$this->l->t('## New in Talk %s', ['17.1']) . "\n"
|
||||
. $this->l->t('- **Markdown** can now be used in _chat_ messages') . "\n"
|
||||
. $this->l->t('- Webhooks are now available to implement bots. See the documentation for more information https://nextcloud-talk.readthedocs.io/en/latest/bot-list/') . "\n"
|
||||
. $this->l->t('- Set a reminder on a chat message to be notified later again'),
|
||||
$this->l->t('## New in Talk %s', ['18']) . "\n"
|
||||
. $this->l->t('- Use the **Note to self** conversation to take notes and share information between your devices') . "\n"
|
||||
. $this->l->t('- Captions allow to send a message with a file at the same time') . "\n"
|
||||
. $this->l->t('- Video of the speaker is now visible while sharing the screen and call reactions are animated'),
|
||||
$this->l->t('## New in Talk %s', ['19']) . "\n"
|
||||
. $this->l->t('- Messages can now be edited by logged-in authors and moderators for 6 hours') . "\n"
|
||||
. $this->l->t('- Unsent message drafts are now saved in your browser') . "\n"
|
||||
. $this->l->t('- Text chatting can now be done in a federated way with other Talk servers'),
|
||||
$this->l->t('## New in Talk %s', ['20']) . "\n"
|
||||
. $this->l->t('- Moderators can now ban accounts and guests to prevent them from rejoining a conversation') . "\n"
|
||||
. $this->l->t('- Upcoming calls from linked calendar events and out-of-office replacements are now shown in conversations') . "\n"
|
||||
. $this->l->t('- Calls can now be done in a federated way with other Talk servers (requires the High-performance backend)'),
|
||||
$this->l->t('## New in Talk %s', ['20.1']) . "\n"
|
||||
. $this->l->t('- Introducing the Nextcloud Talk desktop client for Windows, macOS and Linux: %s', ['https://nextcloud.com/talk-desktop-install']) . "\n"
|
||||
. $this->l->t('- Summarize call recordings and unread messages in chats with the Nextcloud Assistant') . "\n"
|
||||
. $this->l->t('- Improved meetings with recognizing guests invited via their email address, import of participant lists, drafts for polls and downloading call participant lists') . "\n"
|
||||
. $this->l->t('- Archive conversations to stay focused'),
|
||||
$this->l->t('## New in Talk %s', ['21']) . "\n"
|
||||
. $this->l->t('- Schedule a meeting into your calendar from within a conversation') . "\n"
|
||||
. $this->l->t('- Search for messages of the current conversation directly in the right sidebar') . "\n"
|
||||
. $this->l->t('- See more conversations on a first glance with the new compact list (enable in the Talk settings)'),
|
||||
$this->l->t('## New in Talk %s', ['21.1']) . "\n"
|
||||
. $this->l->t('- Meeting conversations now sync the title and description from the calendar and are hidden with a search filter until they are close to the start') . "\n"
|
||||
. $this->l->t('- Mark conversations as sensitive in the notification settings, to hide the message content from the conversation list and notifications') . "\n"
|
||||
. $this->l->t('- To receive push notifications during "Do not disturb", mark conversations as important') . "\n"
|
||||
. $this->l->t('- Add other participants to a one-to-one call to create a new group call on the fly') . "\n",
|
||||
$this->l->t('## New in Talk %s', ['22']) . "\n"
|
||||
. $this->l->t('- Use threads to keep your chat and discussions organized') . "\n"
|
||||
. $this->l->t('- Live transcriptions now available during the call (requires the live-transcription ExApp and the High-performance backend)') . "\n",
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Chat;
|
||||
|
||||
use OC\Comments\Comment;
|
||||
use OC\Comments\Manager;
|
||||
use OCP\Comments\IComment;
|
||||
use OCP\DB\Exception;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
|
||||
class CommentsManager extends Manager {
|
||||
/**
|
||||
* @param array $data
|
||||
* @return IComment
|
||||
*/
|
||||
public function getCommentFromData(array $data): IComment {
|
||||
$message = $data['message'];
|
||||
unset($data['message']);
|
||||
$comment = new Comment($this->normalizeDatabaseData($data));
|
||||
$comment->setMessage($message, ChatManager::MAX_CHAT_LENGTH);
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $ids
|
||||
* @return IComment[]
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getCommentsById(array $ids): array {
|
||||
$commentIds = array_map('intval', $ids);
|
||||
|
||||
$query = $this->dbConn->getQueryBuilder();
|
||||
$query->select('*')
|
||||
->from('comments')
|
||||
->where($query->expr()->in('id', $query->createNamedParameter($commentIds, IQueryBuilder::PARAM_INT_ARRAY)));
|
||||
|
||||
$comments = [];
|
||||
$result = $query->execute();
|
||||
while ($row = $result->fetch()) {
|
||||
$comments[(int)$row['id']] = $this->getCommentFromData($row);
|
||||
}
|
||||
$result->closeCursor();
|
||||
|
||||
return $comments;
|
||||
}
|
||||
|
||||
/**
|
||||
* FIXME: TEMPORARY method until https://github.com/nextcloud/server/pull/53896 is merged
|
||||
*
|
||||
* @param string $objectType the object type, e.g. 'files'
|
||||
* @param string $objectId the id of the object
|
||||
* @param int $lastKnownCommentId the last known comment (will be used as offset)
|
||||
* @param string $sortDirection direction of the comments (`asc` or `desc`)
|
||||
* @param int $limit optional, number of maximum comments to be returned. if
|
||||
* set to 0, all comments are returned.
|
||||
* @param bool $includeLastKnown
|
||||
* @return list<IComment>
|
||||
*/
|
||||
#[\Override]
|
||||
public function getForObjectSince(
|
||||
string $objectType,
|
||||
string $objectId,
|
||||
int $lastKnownCommentId,
|
||||
string $sortDirection = 'asc',
|
||||
int $limit = 30,
|
||||
bool $includeLastKnown = false,
|
||||
string $topmostParentId = '',
|
||||
): array {
|
||||
return $this->getCommentsWithVerbForObjectSinceComment(
|
||||
$objectType,
|
||||
$objectId,
|
||||
[],
|
||||
$lastKnownCommentId,
|
||||
$sortDirection,
|
||||
$limit,
|
||||
$includeLastKnown,
|
||||
$topmostParentId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FIXME: TEMPORARY method until https://github.com/nextcloud/server/pull/53896 is merged
|
||||
*
|
||||
* @param string $objectType the object type, e.g. 'files'
|
||||
* @param string $objectId the id of the object
|
||||
* @param string[] $verbs List of verbs to filter by
|
||||
* @param int $lastKnownCommentId the last known comment (will be used as offset)
|
||||
* @param string $sortDirection direction of the comments (`asc` or `desc`)
|
||||
* @param int $limit optional, number of maximum comments to be returned. if
|
||||
* set to 0, all comments are returned.
|
||||
* @param bool $includeLastKnown
|
||||
* @return list<IComment>
|
||||
*/
|
||||
#[\Override]
|
||||
public function getCommentsWithVerbForObjectSinceComment(
|
||||
string $objectType,
|
||||
string $objectId,
|
||||
array $verbs,
|
||||
int $lastKnownCommentId,
|
||||
string $sortDirection = 'asc',
|
||||
int $limit = 30,
|
||||
bool $includeLastKnown = false,
|
||||
string $topmostParentId = '',
|
||||
): array {
|
||||
$comments = [];
|
||||
|
||||
$query = $this->dbConn->getQueryBuilder();
|
||||
$query->select('*')
|
||||
->from('comments')
|
||||
->where($query->expr()->eq('object_type', $query->createNamedParameter($objectType)))
|
||||
->andWhere($query->expr()->eq('object_id', $query->createNamedParameter($objectId)))
|
||||
->orderBy('creation_timestamp', $sortDirection === 'desc' ? 'DESC' : 'ASC')
|
||||
->addOrderBy('id', $sortDirection === 'desc' ? 'DESC' : 'ASC');
|
||||
|
||||
if ($limit > 0) {
|
||||
$query->setMaxResults($limit);
|
||||
}
|
||||
|
||||
if (!empty($verbs)) {
|
||||
$query->andWhere($query->expr()->in('verb', $query->createNamedParameter($verbs, IQueryBuilder::PARAM_STR_ARRAY)));
|
||||
}
|
||||
|
||||
if ($topmostParentId !== '') {
|
||||
$query->andWhere($query->expr()->orX(
|
||||
$query->expr()->eq('id', $query->createNamedParameter($topmostParentId)),
|
||||
$query->expr()->eq('topmost_parent_id', $query->createNamedParameter($topmostParentId)),
|
||||
));
|
||||
}
|
||||
|
||||
$lastKnownComment = $lastKnownCommentId > 0 ? $this->getLastKnownComment(
|
||||
$objectType,
|
||||
$objectId,
|
||||
$lastKnownCommentId
|
||||
) : null;
|
||||
if ($lastKnownComment instanceof IComment) {
|
||||
$lastKnownCommentDateTime = $lastKnownComment->getCreationDateTime();
|
||||
if ($sortDirection === 'desc') {
|
||||
if ($includeLastKnown) {
|
||||
$idComparison = $query->expr()->lte('id', $query->createNamedParameter($lastKnownCommentId));
|
||||
} else {
|
||||
$idComparison = $query->expr()->lt('id', $query->createNamedParameter($lastKnownCommentId));
|
||||
}
|
||||
$query->andWhere(
|
||||
$query->expr()->orX(
|
||||
$query->expr()->lt(
|
||||
'creation_timestamp',
|
||||
$query->createNamedParameter($lastKnownCommentDateTime, IQueryBuilder::PARAM_DATETIME_MUTABLE),
|
||||
IQueryBuilder::PARAM_DATETIME_MUTABLE
|
||||
),
|
||||
$query->expr()->andX(
|
||||
$query->expr()->eq(
|
||||
'creation_timestamp',
|
||||
$query->createNamedParameter($lastKnownCommentDateTime, IQueryBuilder::PARAM_DATETIME_MUTABLE),
|
||||
IQueryBuilder::PARAM_DATETIME_MUTABLE
|
||||
),
|
||||
$idComparison
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
if ($includeLastKnown) {
|
||||
$idComparison = $query->expr()->gte('id', $query->createNamedParameter($lastKnownCommentId));
|
||||
} else {
|
||||
$idComparison = $query->expr()->gt('id', $query->createNamedParameter($lastKnownCommentId));
|
||||
}
|
||||
$query->andWhere(
|
||||
$query->expr()->orX(
|
||||
$query->expr()->gt(
|
||||
'creation_timestamp',
|
||||
$query->createNamedParameter($lastKnownCommentDateTime, IQueryBuilder::PARAM_DATETIME_MUTABLE),
|
||||
IQueryBuilder::PARAM_DATETIME_MUTABLE
|
||||
),
|
||||
$query->expr()->andX(
|
||||
$query->expr()->eq(
|
||||
'creation_timestamp',
|
||||
$query->createNamedParameter($lastKnownCommentDateTime, IQueryBuilder::PARAM_DATETIME_MUTABLE),
|
||||
IQueryBuilder::PARAM_DATETIME_MUTABLE
|
||||
),
|
||||
$idComparison
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
} elseif ($lastKnownCommentId > 0) {
|
||||
// We didn't find the "$lastKnownComment" but we still use the ID as an offset.
|
||||
// This is required as a fall-back for expired messages in talk and deleted comments in other apps.
|
||||
if ($sortDirection === 'desc') {
|
||||
if ($includeLastKnown) {
|
||||
$query->andWhere($query->expr()->lte('id', $query->createNamedParameter($lastKnownCommentId)));
|
||||
} else {
|
||||
$query->andWhere($query->expr()->lt('id', $query->createNamedParameter($lastKnownCommentId)));
|
||||
}
|
||||
} else {
|
||||
if ($includeLastKnown) {
|
||||
$query->andWhere($query->expr()->gte('id', $query->createNamedParameter($lastKnownCommentId)));
|
||||
} else {
|
||||
$query->andWhere($query->expr()->gt('id', $query->createNamedParameter($lastKnownCommentId)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$resultStatement = $query->execute();
|
||||
while ($data = $resultStatement->fetch()) {
|
||||
$comment = $this->getCommentFromData($data);
|
||||
$this->cache($comment);
|
||||
$comments[] = $comment;
|
||||
}
|
||||
$resultStatement->closeCursor();
|
||||
|
||||
return $comments;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param string $actorType
|
||||
* @param string $actorId
|
||||
* @param string[] $messageIds
|
||||
* @return array
|
||||
* @psalm-return array<int, string[]>
|
||||
*/
|
||||
public function retrieveReactionsByActor(string $actorType, string $actorId, array $messageIds): array {
|
||||
$commentIds = array_map('intval', $messageIds);
|
||||
|
||||
$query = $this->dbConn->getQueryBuilder();
|
||||
$query->select('*')
|
||||
->from('reactions')
|
||||
->where($query->expr()->eq('actor_type', $query->createNamedParameter($actorType)))
|
||||
->andWhere($query->expr()->eq('actor_id', $query->createNamedParameter($actorId)))
|
||||
->andWhere($query->expr()->in('parent_id', $query->createNamedParameter($commentIds, IQueryBuilder::PARAM_INT_ARRAY)));
|
||||
|
||||
$reactions = [];
|
||||
$result = $query->executeQuery();
|
||||
while ($row = $result->fetch()) {
|
||||
$reactions[(int)$row['parent_id']] ??= [];
|
||||
$reactions[(int)$row['parent_id']][] = $row['reaction'];
|
||||
}
|
||||
$result->closeCursor();
|
||||
|
||||
return $reactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for comments on one or more objects with a given content
|
||||
*
|
||||
* @param string $search content to search for
|
||||
* @param string $objectType Limit the search by object type
|
||||
* @param string[] $objectIds Limit the search by object ids
|
||||
* @param string[] $verbs Limit the verb of the comment
|
||||
* @return list<IComment>
|
||||
*/
|
||||
public function searchForObjectsWithFilters(string $search, string $objectType, array $objectIds, array $verbs, ?\DateTimeImmutable $since, ?\DateTimeImmutable $until, ?string $actorType, ?string $actorId, int $offset, int $limit = 50): array {
|
||||
$query = $this->dbConn->getQueryBuilder();
|
||||
|
||||
$query->select('*')
|
||||
->from('comments')
|
||||
->orderBy('creation_timestamp', 'DESC')
|
||||
->addOrderBy('id', 'DESC')
|
||||
->setMaxResults($limit);
|
||||
|
||||
if ($search !== '') {
|
||||
$query->where($query->expr()->iLike('message', $query->createNamedParameter(
|
||||
'%' . $this->dbConn->escapeLikeParameter($search) . '%'
|
||||
)));
|
||||
}
|
||||
|
||||
if ($since !== null) {
|
||||
$query->andWhere($query->expr()->gte('creation_timestamp', $query->createNamedParameter($since, IQueryBuilder::PARAM_DATE), IQueryBuilder::PARAM_DATE));
|
||||
}
|
||||
|
||||
if ($until !== null) {
|
||||
$query->andWhere($query->expr()->lte('creation_timestamp', $query->createNamedParameter($until, IQueryBuilder::PARAM_DATE), IQueryBuilder::PARAM_DATE));
|
||||
}
|
||||
|
||||
if ($actorType !== null && $actorId !== null) {
|
||||
$query->andWhere($query->expr()->lte('actor_type', $query->createNamedParameter($actorType)))
|
||||
->andWhere($query->expr()->lte('actor_id', $query->createNamedParameter($actorId)));
|
||||
}
|
||||
|
||||
if ($objectType !== '') {
|
||||
$query->andWhere($query->expr()->eq('object_type', $query->createNamedParameter($objectType)));
|
||||
}
|
||||
if (!empty($objectIds)) {
|
||||
$query->andWhere($query->expr()->in('object_id', $query->createNamedParameter($objectIds, IQueryBuilder::PARAM_STR_ARRAY)));
|
||||
}
|
||||
if (!empty($verbs)) {
|
||||
$query->andWhere($query->expr()->in('verb', $query->createNamedParameter($verbs, IQueryBuilder::PARAM_STR_ARRAY)));
|
||||
}
|
||||
if ($offset !== 0) {
|
||||
$query->setFirstResult($offset);
|
||||
}
|
||||
|
||||
$comments = [];
|
||||
$result = $query->executeQuery();
|
||||
while ($data = $result->fetch()) {
|
||||
$comment = $this->getCommentFromData($data);
|
||||
$this->cache($comment);
|
||||
$comments[] = $comment;
|
||||
}
|
||||
$result->closeCursor();
|
||||
|
||||
return $comments;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Chat;
|
||||
|
||||
use OCA\Talk\Events\RoomDeletedEvent;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class Listener implements IEventListener {
|
||||
public function __construct(
|
||||
protected ChatManager $chatManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if ($event instanceof RoomDeletedEvent) {
|
||||
$this->chatManager->deleteMessages($event->getRoom());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Chat;
|
||||
|
||||
use OCA\Talk\Events\MessageParseEvent;
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\MatterbridgeManager;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Model\Message;
|
||||
use OCA\Talk\Model\ProxyCacheMessage;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\BotService;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCP\Comments\IComment;
|
||||
use OCP\Comments\ICommentsManager;
|
||||
use OCP\EventDispatcher\IEventDispatcher;
|
||||
use OCP\IL10N;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
|
||||
/**
|
||||
* Helper class to get a rich message from a plain text message.
|
||||
*/
|
||||
class MessageParser {
|
||||
|
||||
protected array $guestNames = [];
|
||||
protected array $federatedUsersNames = [];
|
||||
protected array $bots = [];
|
||||
protected array $botNames = [];
|
||||
|
||||
public function __construct(
|
||||
protected IEventDispatcher $dispatcher,
|
||||
protected IUserManager $userManager,
|
||||
protected ParticipantService $participantService,
|
||||
protected BotService $botService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createMessage(Room $room, ?Participant $participant, IComment $comment, IL10N $l): Message {
|
||||
return new Message($room, $participant, $comment, $l);
|
||||
}
|
||||
|
||||
public function createMessageFromProxyCache(Room $room, ?Participant $participant, ProxyCacheMessage $proxy, IL10N $l): Message {
|
||||
$message = new Message($room, $participant, null, $l, $proxy);
|
||||
|
||||
$message->setActor(
|
||||
$proxy->getActorType(),
|
||||
$proxy->getActorId(),
|
||||
$proxy->getActorDisplayName() ?? '',
|
||||
);
|
||||
|
||||
$message->setMessageType($proxy->getMessageType());
|
||||
|
||||
$message->setMessage(
|
||||
$proxy->getMessage(),
|
||||
$proxy->getParsedMessageParameters()
|
||||
);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $allowInaccurate File share messages will not have fully correct data for the file object
|
||||
* E.g. path is only the file name and preview generation is estimated by
|
||||
* mimetype only. This is done to prevent a filesystem setup.
|
||||
*/
|
||||
public function parseMessage(Message $message, bool $allowInaccurate = false): void {
|
||||
$message->setMessage($message->getComment()->getMessage(), []);
|
||||
|
||||
$verb = $message->getComment()->getVerb();
|
||||
if ($verb === ChatManager::VERB_OBJECT_SHARED) {
|
||||
$verb = ChatManager::VERB_SYSTEM;
|
||||
}
|
||||
$message->setMessageType($verb);
|
||||
$this->setMessageActor($message);
|
||||
$this->setLastEditInfo($message);
|
||||
|
||||
$event = new MessageParseEvent($message->getRoom(), $message, $allowInaccurate);
|
||||
$this->dispatcher->dispatchTyped($event);
|
||||
}
|
||||
|
||||
protected function setMessageActor(Message $message): void {
|
||||
[$actorType, $actorId, $displayName] = $this->getActorInformation(
|
||||
$message,
|
||||
$message->getComment()->getActorType(),
|
||||
$message->getComment()->getActorId()
|
||||
);
|
||||
|
||||
$message->setActor(
|
||||
$actorType,
|
||||
$actorId,
|
||||
$displayName
|
||||
);
|
||||
}
|
||||
|
||||
protected function setLastEditInfo(Message $message): void {
|
||||
$metaData = $message->getComment()->getMetaData();
|
||||
if (!empty($metaData)) {
|
||||
if (isset($metaData['last_edited_by_type'], $metaData['last_edited_by_id'], $metaData['last_edited_time'])) {
|
||||
[$actorType, $actorId, $displayName] = $this->getActorInformation(
|
||||
$message,
|
||||
$metaData['last_edited_by_type'],
|
||||
$metaData['last_edited_by_id'],
|
||||
$metaData['last_edited_by_displayname'] ?? '',
|
||||
);
|
||||
|
||||
$message->setLastEdit(
|
||||
$actorType,
|
||||
$actorId,
|
||||
$displayName,
|
||||
$metaData['last_edited_time']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function getActorInformation(Message $message, string $actorType, string $actorId, string $displayName = ''): array {
|
||||
if ($actorType === Attendee::ACTOR_USERS) {
|
||||
$tempDisplayName = $this->userManager->getDisplayName($actorId);
|
||||
if ($tempDisplayName === null) {
|
||||
$user = $this->userManager->get($actorId);
|
||||
if (!$user instanceof IUser) {
|
||||
// Deleted user
|
||||
return [
|
||||
ICommentsManager::DELETED_USER,
|
||||
ICommentsManager::DELETED_USER,
|
||||
'',
|
||||
];
|
||||
}
|
||||
$displayName = $user->getDisplayName();
|
||||
} else {
|
||||
$displayName = $tempDisplayName;
|
||||
}
|
||||
} elseif ($actorType === Attendee::ACTOR_BRIDGED) {
|
||||
$displayName = $actorId;
|
||||
$actorId = MatterbridgeManager::BRIDGE_BOT_USERID;
|
||||
} elseif (($actorType === Attendee::ACTOR_GUESTS || $actorType === Attendee::ACTOR_EMAILS)
|
||||
&& !in_array($actorId, [Attendee::ACTOR_ID_CLI, Attendee::ACTOR_ID_SYSTEM, Attendee::ACTOR_ID_CHANGELOG, Attendee::ACTOR_ID_SAMPLE], true)) {
|
||||
$cacheKey = $actorType . '/' . $actorId;
|
||||
if (isset($this->guestNames[$cacheKey])) {
|
||||
$displayName = $this->guestNames[$cacheKey];
|
||||
} else {
|
||||
try {
|
||||
$participant = $this->participantService->getParticipantByActor($message->getRoom(), $actorType, $actorId);
|
||||
$displayName = $participant->getAttendee()->getDisplayName();
|
||||
} catch (ParticipantNotFoundException) {
|
||||
}
|
||||
$this->guestNames[$cacheKey] = $displayName;
|
||||
}
|
||||
} elseif ($actorType === Attendee::ACTOR_BOTS) {
|
||||
$displayName = $actorId . '-bot';
|
||||
$token = $message->getRoom()->getToken();
|
||||
if (str_starts_with($actorId, Attendee::ACTOR_BOT_PREFIX)) {
|
||||
$urlHash = substr($actorId, strlen(Attendee::ACTOR_BOT_PREFIX));
|
||||
$botName = $this->getBotNameByUrlHashForConversation($token, $urlHash);
|
||||
if ($botName) {
|
||||
$displayName = $botName . ' (Bot)';
|
||||
}
|
||||
}
|
||||
} elseif ($actorType === Attendee::ACTOR_FEDERATED_USERS) {
|
||||
if (isset($this->federatedUsersNames[$actorId])) {
|
||||
$displayName = $this->federatedUsersNames[$actorId];
|
||||
} else {
|
||||
$displayName = $actorId;
|
||||
try {
|
||||
$participant = $this->participantService->getParticipantByActor($message->getRoom(), Attendee::ACTOR_FEDERATED_USERS, $actorId);
|
||||
$displayName = $participant->getAttendee()->getDisplayName();
|
||||
} catch (ParticipantNotFoundException) {
|
||||
// FIXME Read from some addressbooks?
|
||||
}
|
||||
$this->federatedUsersNames[$actorId] = $displayName;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
$actorType,
|
||||
$actorId,
|
||||
$displayName
|
||||
];
|
||||
}
|
||||
|
||||
protected function getBotNameByUrlHashForConversation(string $token, string $urlHash): ?string {
|
||||
if (!isset($this->botNames[$token])) {
|
||||
$this->botNames[$token] = [];
|
||||
$bots = $this->botService->getBotsForToken($token, null);
|
||||
foreach ($bots as $bot) {
|
||||
$botServer = $bot->getBotServer();
|
||||
$this->botNames[$token][$botServer->getUrlHash()] = $botServer->getName();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->botNames[$token][$urlHash] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Chat;
|
||||
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Files\Util;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Model\Session;
|
||||
use OCA\Talk\Model\ThreadAttendee;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\ThreadService;
|
||||
use OCA\Talk\Webinary;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\Comments\IComment;
|
||||
use OCP\IConfig;
|
||||
use OCP\IGroup;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\IUserManager;
|
||||
use OCP\Notification\IManager as INotificationManager;
|
||||
use OCP\Notification\INotification;
|
||||
|
||||
/**
|
||||
* Helper class for notifications related to user mentions in chat messages.
|
||||
*
|
||||
* This class uses the NotificationManager to create and remove the
|
||||
* notifications as needed; OCA\Talk\Notification\Notifier is the one that
|
||||
* prepares the notifications for display.
|
||||
*/
|
||||
class Notifier {
|
||||
public const PRIORITY_NONE = 0;
|
||||
public const PRIORITY_NORMAL = 1;
|
||||
public const PRIORITY_IMPORTANT = 2;
|
||||
|
||||
public function __construct(
|
||||
private INotificationManager $notificationManager,
|
||||
private IUserManager $userManager,
|
||||
private IGroupManager $groupManager,
|
||||
private ParticipantService $participantService,
|
||||
private ThreadService $threadService,
|
||||
private IConfig $config,
|
||||
private ITimeFactory $timeFactory,
|
||||
private Util $util,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the user mentioned in the comment.
|
||||
*
|
||||
* The comment must be a chat message comment. That is, its "objectId" must
|
||||
* be the room ID.
|
||||
*
|
||||
* Not every user mentioned in the message is notified, but only those that
|
||||
* are able to participate in the room.
|
||||
*
|
||||
* @param Room $chat
|
||||
* @param IComment $comment
|
||||
* @param array[] $alreadyNotifiedUsers
|
||||
* @psalm-param array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}> $alreadyNotifiedUsers
|
||||
* @param bool $silent
|
||||
* @param Participant|null $participant
|
||||
* @return string[] Users that were mentioned
|
||||
* @psalm-return array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}>
|
||||
*/
|
||||
public function notifyMentionedUsers(Room $chat, IComment $comment, array $alreadyNotifiedUsers, bool $silent, ?Participant $participant = null, ?int $threadId = null): array {
|
||||
$usersToNotify = $this->getUsersToNotify($chat, $comment, $alreadyNotifiedUsers, $participant);
|
||||
|
||||
if (!$usersToNotify) {
|
||||
return $alreadyNotifiedUsers;
|
||||
}
|
||||
|
||||
$shouldFlush = false;
|
||||
if (!$silent) {
|
||||
$notification = $this->createNotification($chat, $comment, 'mention', threadId: $threadId);
|
||||
$parameters = $notification->getSubjectParameters();
|
||||
$shouldFlush = $this->notificationManager->defer();
|
||||
}
|
||||
|
||||
foreach ($usersToNotify as $mentionedUser) {
|
||||
$shouldMentionedUserBeNotified = $this->shouldMentionedUserBeNotified($mentionedUser['id'], $comment, $chat, $mentionedUser['attendee'] ?? null);
|
||||
if ($shouldMentionedUserBeNotified !== self::PRIORITY_NONE) {
|
||||
if (!$silent) {
|
||||
$notification->setUser($mentionedUser['id']);
|
||||
if (isset($mentionedUser['reason'])) {
|
||||
$notification->setSubject('mention_' . $mentionedUser['reason'], array_merge($parameters, [
|
||||
'sourceId' => $mentionedUser['sourceId'] ?? null,
|
||||
]));
|
||||
} else {
|
||||
$notification->setSubject('mention', $parameters);
|
||||
}
|
||||
$notification->setPriorityNotification($shouldMentionedUserBeNotified === self::PRIORITY_IMPORTANT);
|
||||
$this->notificationManager->notify($notification);
|
||||
}
|
||||
$alreadyNotifiedUsers[] = $mentionedUser;
|
||||
}
|
||||
}
|
||||
|
||||
if ($shouldFlush) {
|
||||
$this->notificationManager->flush();
|
||||
}
|
||||
|
||||
return $alreadyNotifiedUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $chat
|
||||
* @param IComment $comment
|
||||
* @param array $alreadyNotifiedUsers
|
||||
* @psalm-param array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}> $alreadyNotifiedUsers
|
||||
* @param Participant|null $participant
|
||||
* @return array
|
||||
* @psalm-return array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}>
|
||||
*/
|
||||
public function getUsersToNotify(Room $chat, IComment $comment, array $alreadyNotifiedUsers, ?Participant $participant = null): array {
|
||||
$usersToNotify = $this->getMentionedUsers($comment);
|
||||
$usersToNotify = $this->getMentionedGroupMembers($chat, $comment, $usersToNotify);
|
||||
$usersToNotify = $this->getMentionedTeamMembers($chat, $comment, $usersToNotify);
|
||||
$usersToNotify = $this->addMentionAllToList($chat, $usersToNotify, $participant);
|
||||
$usersToNotify = $this->removeAlreadyNotifiedUsers($usersToNotify, $alreadyNotifiedUsers);
|
||||
|
||||
return $usersToNotify;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $usersToNotify
|
||||
* @psalm-param array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}> $usersToNotify
|
||||
* @param array $alreadyNotifiedUsers
|
||||
* @psalm-param array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}> $alreadyNotifiedUsers
|
||||
* @return array
|
||||
* @psalm-return array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}>
|
||||
*/
|
||||
private function removeAlreadyNotifiedUsers(array $usersToNotify, array $alreadyNotifiedUsers): array {
|
||||
return array_filter($usersToNotify, static function (array $userToNotify) use ($alreadyNotifiedUsers): bool {
|
||||
foreach ($alreadyNotifiedUsers as $alreadyNotified) {
|
||||
if ($alreadyNotified['id'] === $userToNotify['id'] && $alreadyNotified['type'] === $userToNotify['type']) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $chat
|
||||
* @param array $list
|
||||
* @psalm-param array<int, array{id: string, type: string, reason: string, sourceId?: string}> $list
|
||||
* @param Participant|null $participant
|
||||
* @return array
|
||||
* @psalm-return array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}>
|
||||
*/
|
||||
private function addMentionAllToList(Room $chat, array $list, ?Participant $participant = null): array {
|
||||
$usersToNotify = array_filter($list, static function (array $entry): bool {
|
||||
return $entry['type'] !== Attendee::ACTOR_USERS || $entry['id'] !== 'all';
|
||||
});
|
||||
|
||||
if (count($list) === count($usersToNotify)) {
|
||||
return $usersToNotify;
|
||||
}
|
||||
if ($chat->getMentionPermissions() === Room::MENTION_PERMISSIONS_MODERATORS && (!$participant instanceof Participant || !$participant->hasModeratorPermissions())) {
|
||||
return $usersToNotify;
|
||||
}
|
||||
|
||||
$attendees = $this->participantService->getActorsByType($chat, Attendee::ACTOR_USERS);
|
||||
foreach ($attendees as $attendee) {
|
||||
$alreadyAddedToNotify = array_filter($list, static function ($user) use ($attendee): bool {
|
||||
return $user['id'] === $attendee->getActorId();
|
||||
});
|
||||
if (!empty($alreadyAddedToNotify)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$usersToNotify[] = [
|
||||
'id' => $attendee->getActorId(),
|
||||
'type' => $attendee->getActorType(),
|
||||
'attendee' => $attendee,
|
||||
'reason' => 'all',
|
||||
];
|
||||
}
|
||||
|
||||
return $usersToNotify;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Notifies the author that wrote the comment which was replied to
|
||||
*
|
||||
* The comment must be a chat message comment. That is, its "objectId" must
|
||||
* be the room ID.
|
||||
*
|
||||
* The author of the message is notified only if they are still able to participate in the room
|
||||
*
|
||||
* @param Room $chat
|
||||
* @param IComment $comment
|
||||
* @param IComment $replyTo
|
||||
* @param bool $silent
|
||||
* @return array[] Actor that was replied to
|
||||
* @psalm-return array<int, array{id: string, type: string, reason: string}>
|
||||
*/
|
||||
public function notifyReplyToAuthor(Room $chat, IComment $comment, IComment $replyTo, bool $silent, ?int $threadId = null): array {
|
||||
if ($replyTo->getActorType() !== Attendee::ACTOR_USERS && $replyTo->getActorType() !== Attendee::ACTOR_FEDERATED_USERS) {
|
||||
// No reply notification when the replyTo-author was not a user or federated user
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($replyTo->getActorType() === Attendee::ACTOR_FEDERATED_USERS) {
|
||||
return [
|
||||
[
|
||||
'id' => $replyTo->getActorId(),
|
||||
'type' => $replyTo->getActorType(),
|
||||
'reason' => 'reply',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$shouldMentionedUserBeNotified = $this->shouldMentionedUserBeNotified($replyTo->getActorId(), $comment, $chat);
|
||||
if ($shouldMentionedUserBeNotified === self::PRIORITY_NONE) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!$silent) {
|
||||
$notification = $this->createNotification($chat, $comment, 'reply', threadId: $threadId);
|
||||
$notification->setUser($replyTo->getActorId());
|
||||
$notification->setPriorityNotification($shouldMentionedUserBeNotified === self::PRIORITY_IMPORTANT);
|
||||
$this->notificationManager->notify($notification);
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => $replyTo->getActorId(),
|
||||
'type' => $replyTo->getActorType(),
|
||||
'reason' => 'reply',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the user mentioned in the comment.
|
||||
*
|
||||
* The comment must be a chat message comment. That is, its "objectId" must
|
||||
* be the room ID.
|
||||
*
|
||||
* Not every user mentioned in the message is notified, but only those that
|
||||
* are able to participate in the room.
|
||||
*
|
||||
* @param Room $chat
|
||||
* @param IComment $comment
|
||||
* @param array[] $alreadyNotifiedUsers
|
||||
* @param bool $silent
|
||||
* @psalm-param array<int, array{id: string, type: string, reason: string, sourceId?: string, attendee?: Attendee}> $alreadyNotifiedUsers
|
||||
*/
|
||||
public function notifyOtherParticipant(Room $chat, IComment $comment, array $alreadyNotifiedUsers, bool $silent): void {
|
||||
if ($silent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$participants = $this->participantService->getParticipantsByNotificationLevel($chat, Participant::NOTIFY_ALWAYS);
|
||||
$threadId = (int)$comment->getTopmostParentId();
|
||||
/** @var array<int, ThreadAttendee> $threadAttendees */
|
||||
$threadAttendees = [];
|
||||
if ($threadId !== 0) {
|
||||
$threadAttendees = $this->threadService->findAttendeesForNotificationByThreadId($chat->getId(), $threadId);
|
||||
}
|
||||
|
||||
// Handle participants that only subscribed with Participant::NOTIFY_ALWAYS to the thread, but not the conversation
|
||||
$threadAttendeeIds = array_map(static fn (ThreadAttendee $threadAttendee): int => $threadAttendee->getAttendeeId(),
|
||||
array_filter($threadAttendees, static fn (ThreadAttendee $threadAttendee): bool => $threadAttendee->getNotificationLevel() === Participant::NOTIFY_ALWAYS)
|
||||
);
|
||||
if (!empty($threadAttendeeIds)) {
|
||||
$participantIds = array_map(static fn (Participant $participant): int => $participant->getAttendee()->getId(), $participants);
|
||||
$missingParticipantIds = array_diff($threadAttendeeIds, $participantIds);
|
||||
if (!empty($missingParticipantIds)) {
|
||||
$missingParticipants = $this->participantService->getParticipantsByAttendeeId($chat, $missingParticipantIds);
|
||||
if (!empty($missingParticipants)) {
|
||||
$participants = array_merge($participants, $missingParticipants);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$notification = $this->createNotification($chat, $comment, 'chat', threadId: $threadId);
|
||||
foreach ($participants as $participant) {
|
||||
$attendeeId = $participant->getAttendee()->getId();
|
||||
$shouldParticipantBeNotified = $this->shouldParticipantBeNotified($participant, $comment, $alreadyNotifiedUsers);
|
||||
|
||||
if (isset($threadAttendees[$attendeeId])) {
|
||||
$threadAttendee = $threadAttendees[$attendeeId];
|
||||
if ($threadAttendee->getNotificationLevel() !== Participant::NOTIFY_ALWAYS) {
|
||||
// User unsubscribed from this thread
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($shouldParticipantBeNotified === self::PRIORITY_NONE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$notification->setUser($participant->getAttendee()->getActorId());
|
||||
$notification->setPriorityNotification($shouldParticipantBeNotified === self::PRIORITY_IMPORTANT);
|
||||
$this->notificationManager->notify($notification);
|
||||
}
|
||||
|
||||
// Also notify default participants in one-to-one chats or when the admin default is "always"
|
||||
if ($this->getDefaultGroupNotification() === Participant::NOTIFY_ALWAYS || $chat->getType() === Room::TYPE_ONE_TO_ONE) {
|
||||
$participants = $this->participantService->getParticipantsByNotificationLevel($chat, Participant::NOTIFY_DEFAULT);
|
||||
foreach ($participants as $participant) {
|
||||
$shouldParticipantBeNotified = $this->shouldParticipantBeNotified($participant, $comment, $alreadyNotifiedUsers);
|
||||
if ($shouldParticipantBeNotified === self::PRIORITY_NONE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$notification->setUser($participant->getAttendee()->getActorId());
|
||||
$notification->setPriorityNotification($shouldParticipantBeNotified === self::PRIORITY_IMPORTANT);
|
||||
$this->notificationManager->notify($notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function notifyReacted(Room $chat, IComment $comment, IComment $reaction): void {
|
||||
if ($comment->getActorType() !== Attendee::ACTOR_USERS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($comment->getActorType() === $reaction->getActorType() && $comment->getActorId() === $reaction->getActorId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$participant = $this->participantService->getParticipant($chat, $comment->getActorId(), false);
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notificationLevel = $participant->getAttendee()->getNotificationLevel();
|
||||
if ($notificationLevel === Participant::NOTIFY_DEFAULT) {
|
||||
if ($chat->getType() === Room::TYPE_ONE_TO_ONE) {
|
||||
$notificationLevel = Participant::NOTIFY_ALWAYS;
|
||||
} else {
|
||||
$notificationLevel = $this->getDefaultGroupNotification();
|
||||
}
|
||||
}
|
||||
|
||||
if ($notificationLevel === Participant::NOTIFY_ALWAYS) {
|
||||
$notification = $this->createNotification($chat, $comment, 'reaction', [
|
||||
'reaction' => $reaction->getMessage(),
|
||||
], $reaction);
|
||||
$notification->setUser($comment->getActorId());
|
||||
$this->notificationManager->notify($notification);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all the pending notifications for the room with the given ID.
|
||||
*/
|
||||
public function removePendingNotificationsForRoom(Room $chat, bool $chatOnly = false): void {
|
||||
$notification = $this->notificationManager->createNotification();
|
||||
$shouldFlush = $this->notificationManager->defer();
|
||||
|
||||
// @todo this should be in the Notifications\Hooks
|
||||
$notification->setApp('spreed');
|
||||
|
||||
$objectTypes = [
|
||||
'chat',
|
||||
'reminder',
|
||||
];
|
||||
if (!$chatOnly) {
|
||||
$objectTypes = [
|
||||
'call',
|
||||
'chat',
|
||||
'room',
|
||||
'recording',
|
||||
'recording_information',
|
||||
'remote_talk_share',
|
||||
];
|
||||
}
|
||||
foreach ($objectTypes as $type) {
|
||||
$notification->setObject($type, $chat->getToken());
|
||||
$this->notificationManager->markProcessed($notification);
|
||||
}
|
||||
|
||||
if ($shouldFlush) {
|
||||
$this->notificationManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all the pending mention notifications for the room
|
||||
*
|
||||
* @param Room $chat
|
||||
* @param ?string $userId
|
||||
*/
|
||||
public function markMentionNotificationsRead(Room $chat, ?string $userId): void {
|
||||
if ($userId === null || $userId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$shouldFlush = $this->notificationManager->defer();
|
||||
$notification = $this->notificationManager->createNotification();
|
||||
|
||||
$notification
|
||||
->setApp('spreed')
|
||||
->setObject('chat', $chat->getToken())
|
||||
->setUser($userId);
|
||||
|
||||
$this->notificationManager->markProcessed($notification);
|
||||
if ($shouldFlush) {
|
||||
$this->notificationManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all mention notifications of users that got their mention removed
|
||||
*
|
||||
* @param list<string> $userIds
|
||||
*/
|
||||
public function removeMentionNotificationAfterEdit(Room $chat, IComment $comment, array $userIds): void {
|
||||
$shouldFlush = $this->notificationManager->defer();
|
||||
$notification = $this->notificationManager->createNotification();
|
||||
|
||||
$notification
|
||||
->setApp('spreed')
|
||||
->setObject('chat', $chat->getToken())
|
||||
// FIXME message_parameters are not handled by notification app, so this removes all notifications :(
|
||||
->setMessage('comment', [
|
||||
'commentId' => $comment->getId(),
|
||||
]);
|
||||
|
||||
foreach (['mention_all', 'mention_direct'] as $subject) {
|
||||
$notification->setSubject($subject);
|
||||
foreach ($userIds as $userId) {
|
||||
$notification->setUser($userId);
|
||||
$this->notificationManager->markProcessed($notification);
|
||||
}
|
||||
}
|
||||
|
||||
if ($shouldFlush) {
|
||||
$this->notificationManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IDs of the users mentioned in the given comment.
|
||||
*
|
||||
* @param IComment $comment
|
||||
* @return string[] the mentioned user IDs
|
||||
*/
|
||||
public function getMentionedUserIds(IComment $comment): array {
|
||||
$mentionedUsers = $this->getMentionedUsers($comment);
|
||||
return array_map(static function ($mentionedUser) {
|
||||
return $mentionedUser['id'];
|
||||
}, $mentionedUsers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cloud IDs of the federated users mentioned in the given comment.
|
||||
*
|
||||
* @param IComment $comment
|
||||
* @return string[] the mentioned cloud IDs
|
||||
*/
|
||||
public function getMentionedCloudIds(IComment $comment): array {
|
||||
$mentionedFederatedUsers = $this->getMentionedFederatedUsers($comment);
|
||||
return array_map(static function ($mentionedUser) {
|
||||
return $mentionedUser['id'];
|
||||
}, $mentionedFederatedUsers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IComment $comment
|
||||
* @return array[]
|
||||
* @psalm-return array<int, array{type: string, id: string, reason: string}>
|
||||
*/
|
||||
private function getMentionedUsers(IComment $comment): array {
|
||||
$mentions = $comment->getMentions();
|
||||
|
||||
if (empty($mentions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$mentionedUsers = [];
|
||||
foreach ($mentions as $mention) {
|
||||
if ($mention['type'] !== 'user') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mentionedUsers[] = [
|
||||
'id' => $mention['id'],
|
||||
'type' => Attendee::ACTOR_USERS,
|
||||
'reason' => 'direct',
|
||||
];
|
||||
}
|
||||
return $mentionedUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IComment $comment
|
||||
* @return array[]
|
||||
* @psalm-return array<int, array{type: string, id: string, reason: string}>
|
||||
*/
|
||||
private function getMentionedFederatedUsers(IComment $comment): array {
|
||||
$mentions = $comment->getMentions();
|
||||
|
||||
if (empty($mentions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$mentionedUsers = [];
|
||||
foreach ($mentions as $mention) {
|
||||
if ($mention['type'] !== 'federated_user') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mentionedUsers[] = [
|
||||
'id' => $mention['id'],
|
||||
'type' => Attendee::ACTOR_FEDERATED_USERS,
|
||||
'reason' => 'direct',
|
||||
];
|
||||
}
|
||||
return $mentionedUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $chat
|
||||
* @param IComment $comment
|
||||
* @param array $list
|
||||
* @psalm-param array<int, array{id: string, type: string, reason: string}> $list
|
||||
* @return array[]
|
||||
* @psalm-return array<int, array{type: string, id: string, reason: string, sourceId?: string}>
|
||||
*/
|
||||
private function getMentionedGroupMembers(Room $chat, IComment $comment, array $list): array {
|
||||
$mentions = $comment->getMentions();
|
||||
|
||||
if (empty($mentions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$alreadyMentionedUserIds = array_filter(
|
||||
array_map(static fn (array $entry) => $entry['type'] === Attendee::ACTOR_USERS ? $entry['id'] : null, $list),
|
||||
static fn ($userId) => $userId !== null
|
||||
);
|
||||
$alreadyMentionedUserIds = array_flip($alreadyMentionedUserIds);
|
||||
|
||||
foreach ($mentions as $mention) {
|
||||
if ($mention['type'] !== 'group') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$group = $this->groupManager->get($mention['id']);
|
||||
if (!$group instanceof IGroup) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->participantService->getParticipantByActor($chat, Attendee::ACTOR_GROUPS, $group->getGID());
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$members = $group->getUsers();
|
||||
foreach ($members as $member) {
|
||||
if (isset($alreadyMentionedUserIds[$member->getUID()])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$list[] = [
|
||||
'id' => $member->getUID(),
|
||||
'type' => Attendee::ACTOR_USERS,
|
||||
'reason' => 'group',
|
||||
'sourceId' => $group->getGID(),
|
||||
];
|
||||
$alreadyMentionedUserIds[$member->getUID()] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $chat
|
||||
* @param IComment $comment
|
||||
* @param array $list
|
||||
* @psalm-param array<int, array{type: string, id: string, reason: string, sourceId?: string}> $list
|
||||
* @return array[]
|
||||
* @psalm-return array<int, array{type: string, id: string, reason: string, sourceId?: string}>
|
||||
*/
|
||||
private function getMentionedTeamMembers(Room $chat, IComment $comment, array $list): array {
|
||||
$mentions = $comment->getMentions();
|
||||
|
||||
if (empty($mentions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$alreadyMentionedUserIds = array_filter(
|
||||
array_map(static fn (array $entry) => $entry['type'] === Attendee::ACTOR_USERS ? $entry['id'] : null, $list),
|
||||
static fn ($userId) => $userId !== null
|
||||
);
|
||||
$alreadyMentionedUserIds = array_flip($alreadyMentionedUserIds);
|
||||
|
||||
foreach ($mentions as $mention) {
|
||||
if ($mention['type'] !== 'team') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->participantService->getParticipantByActor($chat, Attendee::ACTOR_CIRCLES, $mention['id']);
|
||||
} catch (ParticipantNotFoundException) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$members = $this->participantService->getCircleMembers($mention['id']);
|
||||
if (empty($members)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($members as $member) {
|
||||
$list[] = [
|
||||
'id' => $member->getUserId(),
|
||||
'type' => Attendee::ACTOR_USERS,
|
||||
'reason' => 'team',
|
||||
'sourceId' => $mention['id'],
|
||||
];
|
||||
$alreadyMentionedUserIds[$member->getUserId()] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a notification for the given chat message comment and mentioned
|
||||
* user ID.
|
||||
*/
|
||||
private function createNotification(Room $chat, IComment $comment, string $subject, array $subjectData = [], ?IComment $reaction = null, ?int $threadId = null): INotification {
|
||||
$subjectData['userType'] = $reaction ? $reaction->getActorType() : $comment->getActorType();
|
||||
$subjectData['userId'] = $reaction ? $reaction->getActorId() : $comment->getActorId();
|
||||
|
||||
$messageData = [
|
||||
'commentId' => $comment->getId(),
|
||||
];
|
||||
|
||||
if ($threadId !== null && $threadId !== 0) {
|
||||
$messageData['threadId'] = $threadId;
|
||||
}
|
||||
|
||||
$notification = $this->notificationManager->createNotification();
|
||||
$notification
|
||||
->setApp('spreed')
|
||||
->setObject('chat', $chat->getToken())
|
||||
->setSubject($subject, $subjectData)
|
||||
->setMessage($comment->getVerb(), $messageData)
|
||||
->setDateTime($reaction ? $reaction->getCreationDateTime() : $comment->getCreationDateTime());
|
||||
|
||||
return $notification;
|
||||
}
|
||||
|
||||
protected function getDefaultGroupNotification(): int {
|
||||
return (int)$this->config->getAppValue('spreed', 'default_group_notification', (string)Participant::NOTIFY_MENTION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a user should be notified about the mention:
|
||||
*
|
||||
* 1. The user did not mention themself
|
||||
* 2. The user must exist
|
||||
* 3. The user must be a participant of the room
|
||||
* 4. The user must not be active in the room
|
||||
*/
|
||||
protected function shouldMentionedUserBeNotified(string $userId, IComment $comment, Room $room, ?Attendee $attendee = null): int {
|
||||
if ($comment->getActorType() === Attendee::ACTOR_USERS && $userId === $comment->getActorId()) {
|
||||
// Do not notify the user if they mentioned themselves
|
||||
return self::PRIORITY_NONE;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!$attendee instanceof Attendee) {
|
||||
if (!$this->userManager->userExists($userId)) {
|
||||
return self::PRIORITY_NONE;
|
||||
}
|
||||
|
||||
$participant = $this->participantService->getParticipant($room, $userId, false);
|
||||
$attendee = $participant->getAttendee();
|
||||
} else {
|
||||
$participant = new Participant($room, $attendee, null);
|
||||
}
|
||||
|
||||
if ($room->getLobbyState() !== Webinary::LOBBY_NONE
|
||||
&& !($participant->getPermissions() & Attendee::PERMISSIONS_LOBBY_IGNORE)) {
|
||||
return self::PRIORITY_NONE;
|
||||
}
|
||||
|
||||
$notificationLevel = $attendee->getNotificationLevel();
|
||||
$threadId = (int)$comment->getTopmostParentId();
|
||||
if ($threadId !== 0) {
|
||||
$threadAttendees = $this->threadService->findAttendeeByThreadIds($attendee, [$threadId]);
|
||||
$threadAttendee = array_shift($threadAttendees);
|
||||
if ($threadAttendee !== null && $threadAttendee->getNotificationLevel() !== Participant::NOTIFY_DEFAULT) {
|
||||
$notificationLevel = $threadAttendee->getNotificationLevel();
|
||||
}
|
||||
}
|
||||
|
||||
if ($notificationLevel === Participant::NOTIFY_DEFAULT) {
|
||||
if ($room->getType() === Room::TYPE_ONE_TO_ONE) {
|
||||
$notificationLevel = Participant::NOTIFY_ALWAYS;
|
||||
} else {
|
||||
$notificationLevel = $this->getDefaultGroupNotification();
|
||||
}
|
||||
}
|
||||
if ($notificationLevel === Participant::NOTIFY_NEVER) {
|
||||
return self::PRIORITY_NONE;
|
||||
}
|
||||
|
||||
if ($attendee->isImportant()) {
|
||||
return self::PRIORITY_IMPORTANT;
|
||||
}
|
||||
return self::PRIORITY_NORMAL;
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
if ($room->getObjectType() === 'file' && $this->util->canUserAccessFile($room->getObjectId(), $userId)) {
|
||||
// Users are added on mentions in file-rooms,
|
||||
// so they can see the room in their room list and
|
||||
// the notification can be parsed and links to an existing room,
|
||||
// where they are a participant of.
|
||||
$userDisplayName = $this->userManager->getDisplayName($userId);
|
||||
$this->participantService->addUsers($room, [[
|
||||
'actorType' => Attendee::ACTOR_USERS,
|
||||
'actorId' => $userId,
|
||||
'displayName' => $userDisplayName ?? $userId,
|
||||
]]);
|
||||
return self::PRIORITY_NORMAL;
|
||||
}
|
||||
return self::PRIORITY_NONE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a participant should be notified about the message:
|
||||
*
|
||||
* 1. The participant is not a guest
|
||||
* 2. The participant is not the writing user
|
||||
* 3. The participant was not mentioned already
|
||||
* 4. The participant must not be active in the room
|
||||
*
|
||||
* @psalm-param array<int, array{type: string, id: string, reason: string, sourceId?: string, attendee?: Attendee}> $alreadyNotifiedUsers
|
||||
*/
|
||||
protected function shouldParticipantBeNotified(Participant $participant, IComment $comment, array $alreadyNotifiedUsers): int {
|
||||
if ($participant->getAttendee()->getActorType() !== Attendee::ACTOR_USERS) {
|
||||
return self::PRIORITY_NONE;
|
||||
}
|
||||
|
||||
$userId = $participant->getAttendee()->getActorId();
|
||||
if ($comment->getActorType() === Attendee::ACTOR_USERS && $userId === $comment->getActorId()) {
|
||||
// Do not notify the author
|
||||
return self::PRIORITY_NONE;
|
||||
}
|
||||
|
||||
$actorType = $participant->getAttendee()->getActorType();
|
||||
foreach ($alreadyNotifiedUsers as $user) {
|
||||
if ($user['id'] === $userId && $user['type'] === $actorType) {
|
||||
return self::PRIORITY_NONE;
|
||||
}
|
||||
}
|
||||
|
||||
if ($participant->getSession()?->getLastPing() >= $this->timeFactory->getTime() - Session::SESSION_TIMEOUT) {
|
||||
// User is online
|
||||
return self::PRIORITY_NONE;
|
||||
}
|
||||
|
||||
if ($participant->getAttendee()->isImportant()) {
|
||||
return self::PRIORITY_IMPORTANT;
|
||||
}
|
||||
|
||||
return self::PRIORITY_NORMAL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Chat\Parser;
|
||||
|
||||
use OCA\Talk\Chat\ChatManager;
|
||||
use OCA\Talk\Events\MessageParseEvent;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCP\Defaults;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\Server;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class Changelog implements IEventListener {
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof MessageParseEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$chatMessage = $event->getMessage();
|
||||
if ($chatMessage->getMessageType() !== ChatManager::VERB_MESSAGE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($chatMessage->getActorType() !== Attendee::ACTOR_GUESTS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($chatMessage->getActorId() === Attendee::ACTOR_ID_CHANGELOG) {
|
||||
$l = $chatMessage->getL10n();
|
||||
$chatMessage->setActor(Attendee::ACTOR_BOTS, Attendee::ACTOR_ID_CHANGELOG, $l->t('Talk updates ✅'));
|
||||
$event->stopPropagation();
|
||||
}
|
||||
|
||||
if ($chatMessage->getActorId() === Attendee::ACTOR_ID_SAMPLE) {
|
||||
$theme = Server::get(Defaults::class);
|
||||
$chatMessage->setActor(Attendee::ACTOR_BOTS, Attendee::ACTOR_ID_SAMPLE, $theme->getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Chat\Parser;
|
||||
|
||||
use OCA\Talk\Chat\ChatManager;
|
||||
use OCA\Talk\Events\MessageParseEvent;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class Command implements IEventListener {
|
||||
public const RESPONSE_NONE = 0;
|
||||
public const RESPONSE_USER = 1;
|
||||
public const RESPONSE_ALL = 2;
|
||||
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof MessageParseEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = $event->getMessage();
|
||||
|
||||
if ($message->getMessageType() !== ChatManager::VERB_COMMAND) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message->setVisibility(false);
|
||||
|
||||
$comment = $message->getComment();
|
||||
$data = json_decode($comment->getMessage(), true);
|
||||
if (!\is_array($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->stopPropagation();
|
||||
|
||||
if ($data['visibility'] === self::RESPONSE_NONE) {
|
||||
$message->setVisibility(false);
|
||||
return;
|
||||
}
|
||||
|
||||
$participant = $message->getParticipant();
|
||||
if ($data['visibility'] !== self::RESPONSE_ALL
|
||||
&& $participant !== null
|
||||
&& ($participant->getAttendee()->getActorType() !== Attendee::ACTOR_USERS
|
||||
|| $data['user'] !== $participant->getAttendee()->getActorId())) {
|
||||
$message->setVisibility(false);
|
||||
return;
|
||||
}
|
||||
|
||||
$message->setMessage($data['output'], []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Chat\Parser;
|
||||
|
||||
use OCA\Talk\Chat\ChatManager;
|
||||
use OCA\Talk\Events\MessageParseEvent;
|
||||
use OCA\Talk\Model\Message;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class ReactionParser implements IEventListener {
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof MessageParseEvent) {
|
||||
return;
|
||||
}
|
||||
$message = $event->getMessage();
|
||||
if ($message->getMessageType() !== ChatManager::VERB_REACTION && $message->getMessageType() !== ChatManager::VERB_REACTION_DELETED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$comment = $message->getComment();
|
||||
if (!in_array($comment->getVerb(), [ChatManager::VERB_REACTION, ChatManager::VERB_REACTION_DELETED], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message->setMessageType(ChatManager::VERB_SYSTEM);
|
||||
if ($comment->getVerb() === ChatManager::VERB_REACTION_DELETED) {
|
||||
// This message is necessary to make compatible with old clients
|
||||
$message->setMessage($message->getL10n()->t('Reaction deleted by author'), [], $comment->getVerb());
|
||||
} else {
|
||||
$message->setMessage($message->getMessage(), [], $comment->getVerb());
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Chat\Parser;
|
||||
|
||||
use OCA\Circles\CirclesManager;
|
||||
use OCA\Talk\Chat\ChatManager;
|
||||
use OCA\Talk\Events\MessageParseEvent;
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\GuestManager;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Model\Message;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\AvatarService;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\Comments\ICommentsManager;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\Federation\ICloudIdManager;
|
||||
use OCP\IGroup;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\IL10N;
|
||||
use OCP\IUserManager;
|
||||
use OCP\Server;
|
||||
|
||||
/**
|
||||
* Helper class to get a rich message from a plain text message.
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class UserMention implements IEventListener {
|
||||
/** @var array<string, string> */
|
||||
protected array $circleNames = [];
|
||||
/** @var array<string, string> */
|
||||
protected array $circleLinks = [];
|
||||
|
||||
public function __construct(
|
||||
protected IAppManager $appManager,
|
||||
protected ICommentsManager $commentsManager,
|
||||
protected IUserManager $userManager,
|
||||
protected IGroupManager $groupManager,
|
||||
protected GuestManager $guestManager,
|
||||
protected AvatarService $avatarService,
|
||||
protected ICloudIdManager $cloudIdManager,
|
||||
protected ParticipantService $participantService,
|
||||
protected IL10N $l,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof MessageParseEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = $event->getMessage();
|
||||
if ($message->getMessageType() !== ChatManager::VERB_MESSAGE) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->parseMessage($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the equivalent rich message to the given comment.
|
||||
*
|
||||
* The mentions in the comment are replaced by "{mention-$type$index}" in
|
||||
* the returned rich message; each "mention-$type$index" parameter contains
|
||||
* the following attributes:
|
||||
* -type: the type of the mention ("user")
|
||||
* -id: the ID of the user
|
||||
* -name: the display name of the user, or an empty string if it could
|
||||
* not be resolved.
|
||||
*
|
||||
* @param Message $chatMessage
|
||||
*/
|
||||
protected function parseMessage(Message $chatMessage): void {
|
||||
$comment = $chatMessage->getComment();
|
||||
$message = $chatMessage->getMessage();
|
||||
$messageParameters = $chatMessage->getMessageParameters();
|
||||
|
||||
$mentionTypeCount = [];
|
||||
|
||||
// Set the current message as comment content, so that the message finds
|
||||
// mentions which are now part of the message, but were not on the original
|
||||
// comment, e.g. mentions at the beginning of captions
|
||||
$originalCommentMessage = $comment->getMessage();
|
||||
$comment->setMessage($message, ChatManager::MAX_CHAT_LENGTH + 10000);
|
||||
$mentions = $comment->getMentions();
|
||||
$comment->setMessage($originalCommentMessage, ChatManager::MAX_CHAT_LENGTH);
|
||||
|
||||
// TODO This can be removed once getMentions() returns sorted results (Nextcloud 21+)
|
||||
usort($mentions, static function (array $m1, array $m2) {
|
||||
return mb_strlen($m2['id']) <=> mb_strlen($m1['id']);
|
||||
});
|
||||
|
||||
$metadata = $comment->getMetaData() ?? [];
|
||||
foreach ($mentions as $mention) {
|
||||
if ($mention['type'] === 'user' && $mention['id'] === 'all') {
|
||||
if (!isset($metadata[Message::METADATA_CAN_MENTION_ALL])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mention['type'] = 'call';
|
||||
}
|
||||
|
||||
if ($mention['type'] === 'user') {
|
||||
$userDisplayName = $this->userManager->getDisplayName($mention['id']);
|
||||
if ($userDisplayName === null) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!array_key_exists($mention['type'], $mentionTypeCount)) {
|
||||
$mentionTypeCount[$mention['type']] = 0;
|
||||
}
|
||||
$mentionTypeCount[$mention['type']]++;
|
||||
|
||||
$search = $mention['id'];
|
||||
if (
|
||||
$mention['type'] === 'email'
|
||||
|| $mention['type'] === 'group'
|
||||
// || $mention['type'] === 'federated_group'
|
||||
|| $mention['type'] === 'team'
|
||||
// || $mention['type'] === 'federated_team'
|
||||
|| $mention['type'] === 'federated_user') {
|
||||
$search = $mention['type'] . '/' . $mention['id'];
|
||||
}
|
||||
|
||||
// To keep a limited character set in parameter IDs ([a-zA-Z0-9-])
|
||||
// the mention parameter ID does not include the mention ID (which
|
||||
// could contain characters like '@' for user IDs) but a one-based
|
||||
// index of the mentions of that type.
|
||||
$mentionParameterId = 'mention-' . str_replace('_', '-', $mention['type']) . $mentionTypeCount[$mention['type']];
|
||||
|
||||
$message = str_replace('@"' . $search . '"', '{' . $mentionParameterId . '}', $message);
|
||||
if (!str_contains($search, ' ')
|
||||
&& !str_starts_with($search, 'guest/')
|
||||
&& !str_starts_with($search, 'email/')
|
||||
&& !str_starts_with($search, 'group/')
|
||||
// && !str_starts_with($search, 'federated_group/')
|
||||
&& !str_starts_with($search, 'team/')
|
||||
// && !str_starts_with($search, 'federated_team/')
|
||||
&& !str_starts_with($search, 'federated_user/')) {
|
||||
$message = str_replace('@' . $search, '{' . $mentionParameterId . '}', $message);
|
||||
}
|
||||
|
||||
if ($mention['type'] === 'call') {
|
||||
$userId = '';
|
||||
if ($chatMessage->getParticipant()?->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
|
||||
$userId = $chatMessage->getParticipant()->getAttendee()->getActorId();
|
||||
}
|
||||
|
||||
$messageParameters[$mentionParameterId] = [
|
||||
'type' => $mention['type'],
|
||||
'id' => $chatMessage->getRoom()->getToken(),
|
||||
'name' => $chatMessage->getRoom()->getDisplayName($userId, true),
|
||||
'call-type' => $this->getRoomType($chatMessage->getRoom()),
|
||||
'icon-url' => $this->avatarService->getAvatarUrl($chatMessage->getRoom()),
|
||||
'mention-id' => $search,
|
||||
];
|
||||
} elseif ($mention['type'] === 'guest') {
|
||||
try {
|
||||
$participant = $this->participantService->getParticipantByActor($chatMessage->getRoom(), Attendee::ACTOR_GUESTS, substr($mention['id'], strlen('guest/')));
|
||||
$displayName = $participant->getAttendee()->getDisplayName() ?: $this->l->t('Guest');
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
$displayName = $this->l->t('Guest');
|
||||
}
|
||||
|
||||
$messageParameters[$mentionParameterId] = [
|
||||
'type' => $mention['type'],
|
||||
'id' => $mention['id'],
|
||||
'name' => $displayName,
|
||||
'mention-id' => $search,
|
||||
];
|
||||
} elseif ($mention['type'] === 'email') {
|
||||
try {
|
||||
$participant = $this->participantService->getParticipantByActor($chatMessage->getRoom(), Attendee::ACTOR_EMAILS, $mention['id']);
|
||||
$displayName = $participant->getAttendee()->getDisplayName() ?: $this->l->t('Guest');
|
||||
} catch (ParticipantNotFoundException) {
|
||||
$displayName = $this->l->t('Guest');
|
||||
}
|
||||
|
||||
$messageParameters[$mentionParameterId] = [
|
||||
'type' => $mention['type'],
|
||||
'id' => $mention['id'],
|
||||
'name' => $displayName,
|
||||
'mention-id' => $search,
|
||||
];
|
||||
} elseif ($mention['type'] === 'federated_user') {
|
||||
try {
|
||||
$cloudId = $this->cloudIdManager->resolveCloudId($mention['id']);
|
||||
} catch (\Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$participant = $this->participantService->getParticipantByActor($chatMessage->getRoom(), Attendee::ACTOR_FEDERATED_USERS, $mention['id']);
|
||||
$displayName = $participant->getAttendee()->getDisplayName() ?: $cloudId->getDisplayId();
|
||||
} catch (ParticipantNotFoundException) {
|
||||
$displayName = $mention['id'];
|
||||
}
|
||||
|
||||
$messageParameters[$mentionParameterId] = [
|
||||
'type' => 'user',
|
||||
'id' => $cloudId->getUser(),
|
||||
'name' => $displayName,
|
||||
'server' => $cloudId->getRemote(),
|
||||
'mention-id' => $search,
|
||||
];
|
||||
} elseif ($mention['type'] === 'group') {
|
||||
$group = $this->groupManager->get($mention['id']);
|
||||
if ($group instanceof IGroup) {
|
||||
$displayName = $group->getDisplayName();
|
||||
} else {
|
||||
$displayName = $mention['id'];
|
||||
}
|
||||
|
||||
$messageParameters[$mentionParameterId] = [
|
||||
'type' => 'user-group',
|
||||
'id' => $mention['id'],
|
||||
'name' => $displayName,
|
||||
'mention-id' => $search,
|
||||
];
|
||||
} elseif ($mention['type'] === 'team') {
|
||||
$messageParameters[$mentionParameterId] = $this->getCircle($mention['id']);
|
||||
} else {
|
||||
try {
|
||||
$displayName = $this->commentsManager->resolveDisplayName($mention['type'], $mention['id']);
|
||||
} catch (\OutOfBoundsException $e) {
|
||||
// There is no registered display name resolver for the mention
|
||||
// type, so the client decides what to display.
|
||||
$displayName = '';
|
||||
}
|
||||
|
||||
$messageParameters[$mentionParameterId] = [
|
||||
'type' => $mention['type'],
|
||||
'id' => $mention['id'],
|
||||
'name' => $displayName,
|
||||
'mention-id' => $search,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (str_starts_with($message, '//')) {
|
||||
$message = substr($message, 1);
|
||||
}
|
||||
|
||||
$chatMessage->setMessage($message, $messageParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @return string
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
protected function getRoomType(Room $room): string {
|
||||
switch ($room->getType()) {
|
||||
case Room::TYPE_ONE_TO_ONE:
|
||||
case Room::TYPE_ONE_TO_ONE_FORMER:
|
||||
case Room::TYPE_NOTE_TO_SELF:
|
||||
return 'one2one';
|
||||
case Room::TYPE_GROUP:
|
||||
return 'group';
|
||||
case Room::TYPE_PUBLIC:
|
||||
return 'public';
|
||||
default:
|
||||
throw new \InvalidArgumentException('Unknown room type');
|
||||
}
|
||||
}
|
||||
|
||||
protected function getCircle(string $circleId): array {
|
||||
if (!$this->appManager->isEnabledForUser('circles')) {
|
||||
return [
|
||||
'type' => 'highlight',
|
||||
'id' => $circleId,
|
||||
'name' => $circleId,
|
||||
];
|
||||
}
|
||||
|
||||
if (!isset($this->circleNames[$circleId])) {
|
||||
$this->loadCircleDetails($circleId);
|
||||
}
|
||||
|
||||
if (!isset($this->circleNames[$circleId])) {
|
||||
return [
|
||||
'type' => 'highlight',
|
||||
'id' => $circleId,
|
||||
'name' => $circleId,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'circle',
|
||||
'id' => $circleId,
|
||||
'name' => $this->circleNames[$circleId],
|
||||
'link' => $this->circleLinks[$circleId],
|
||||
'mention-id' => 'team/' . $circleId,
|
||||
];
|
||||
}
|
||||
|
||||
protected function loadCircleDetails(string $circleId): void {
|
||||
try {
|
||||
$circlesManager = Server::get(CirclesManager::class);
|
||||
$circlesManager->startSuperSession();
|
||||
$circle = $circlesManager->getCircle($circleId);
|
||||
|
||||
$this->circleNames[$circleId] = $circle->getDisplayName();
|
||||
$this->circleLinks[$circleId] = $circle->getUrl();
|
||||
} catch (\Exception) {
|
||||
} finally {
|
||||
$circlesManager?->stopSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Chat;
|
||||
|
||||
use OCA\Talk\Events\BeforeReactionAddedEvent;
|
||||
use OCA\Talk\Events\BeforeReactionRemovedEvent;
|
||||
use OCA\Talk\Events\ReactionAddedEvent;
|
||||
use OCA\Talk\Events\ReactionRemovedEvent;
|
||||
use OCA\Talk\Exceptions\ReactionAlreadyExistsException;
|
||||
use OCA\Talk\Exceptions\ReactionNotSupportedException;
|
||||
use OCA\Talk\Exceptions\ReactionOutOfContextException;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Room;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\Comments\IComment;
|
||||
use OCP\Comments\NotFoundException;
|
||||
use OCP\EventDispatcher\IEventDispatcher;
|
||||
use OCP\IL10N;
|
||||
use OCP\PreConditionNotMetException;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkReaction from ResponseDefinitions
|
||||
*/
|
||||
class ReactionManager {
|
||||
|
||||
public function __construct(
|
||||
private ChatManager $chatManager,
|
||||
private CommentsManager $commentsManager,
|
||||
private IL10N $l,
|
||||
private MessageParser $messageParser,
|
||||
private Notifier $notifier,
|
||||
protected IEventDispatcher $dispatcher,
|
||||
protected ITimeFactory $timeFactory,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add reaction
|
||||
*
|
||||
* @throws NotFoundException
|
||||
* @throws ReactionAlreadyExistsException
|
||||
* @throws ReactionNotSupportedException
|
||||
* @throws ReactionOutOfContextException
|
||||
*/
|
||||
public function addReactionMessage(Room $chat, string $actorType, string $actorId, string $actorDisplayName, int $messageId, string $reaction): IComment {
|
||||
$parentMessage = $this->getCommentToReact($chat, (string)$messageId);
|
||||
try {
|
||||
// Check if the user already reacted with the same reaction
|
||||
$this->commentsManager->getReactionComment(
|
||||
(int)$parentMessage->getId(),
|
||||
$actorType,
|
||||
$actorId,
|
||||
$reaction
|
||||
);
|
||||
throw new ReactionAlreadyExistsException();
|
||||
} catch (NotFoundException $e) {
|
||||
}
|
||||
|
||||
/** @var IComment $comment */
|
||||
$comment = $this->commentsManager->create(
|
||||
$actorType,
|
||||
$actorId,
|
||||
'chat',
|
||||
(string)$chat->getId()
|
||||
);
|
||||
$comment->setParentId($parentMessage->getId());
|
||||
$comment->setMessage($reaction);
|
||||
$comment->setVerb(ChatManager::VERB_REACTION);
|
||||
$comment->setExpireDate($parentMessage->getExpireDate());
|
||||
|
||||
$event = new BeforeReactionAddedEvent($chat, $parentMessage, $actorType, $actorId, $actorDisplayName, $reaction);
|
||||
$this->dispatcher->dispatchTyped($event);
|
||||
|
||||
$this->commentsManager->save($comment);
|
||||
|
||||
$event = new ReactionAddedEvent($chat, $parentMessage, $actorType, $actorId, $actorDisplayName, $reaction, $comment);
|
||||
$this->dispatcher->dispatchTyped($event);
|
||||
|
||||
$this->notifier->notifyReacted($chat, $parentMessage, $comment);
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete reaction
|
||||
*
|
||||
* @param Room $chat
|
||||
* @param string $actorType
|
||||
* @param string $actorId
|
||||
* @param integer $messageId
|
||||
* @param string $reaction
|
||||
* @return IComment
|
||||
* @throws NotFoundException
|
||||
* @throws ReactionNotSupportedException
|
||||
* @throws ReactionOutOfContextException
|
||||
*/
|
||||
public function deleteReactionMessage(Room $chat, string $actorType, string $actorId, string $actorDisplayName, int $messageId, string $reaction): IComment {
|
||||
// Just to verify that messageId is part of the room and throw error if not.
|
||||
$parentComment = $this->getCommentToReact($chat, (string)$messageId);
|
||||
|
||||
$event = new BeforeReactionRemovedEvent($chat, $parentComment, $actorType, $actorId, $actorDisplayName, $reaction);
|
||||
$this->dispatcher->dispatchTyped($event);
|
||||
|
||||
$comment = $this->commentsManager->getReactionComment(
|
||||
$messageId,
|
||||
$actorType,
|
||||
$actorId,
|
||||
$reaction
|
||||
);
|
||||
$comment->setMessage(
|
||||
json_encode([
|
||||
'deleted_by_type' => $actorType,
|
||||
'deleted_by_id' => $actorId,
|
||||
'deleted_on' => $this->timeFactory->getDateTime()->getTimestamp(),
|
||||
])
|
||||
);
|
||||
$comment->setVerb(ChatManager::VERB_REACTION_DELETED);
|
||||
$this->commentsManager->save($comment);
|
||||
|
||||
$this->chatManager->addSystemMessage(
|
||||
$chat,
|
||||
null,
|
||||
$actorType,
|
||||
$actorId,
|
||||
json_encode(['message' => 'reaction_revoked', 'parameters' => ['message' => (int)$comment->getId()]]),
|
||||
$this->timeFactory->getDateTime(),
|
||||
false,
|
||||
null,
|
||||
$parentComment,
|
||||
true
|
||||
);
|
||||
|
||||
$event = new ReactionRemovedEvent($chat, $parentComment, $actorType, $actorId, $actorDisplayName, $reaction, $comment);
|
||||
$this->dispatcher->dispatchTyped($event);
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<TalkReaction>>
|
||||
* @throws PreConditionNotMetException
|
||||
*/
|
||||
public function retrieveReactionMessages(Room $chat, Participant $participant, int $messageId, ?string $reaction = null): array {
|
||||
if ($reaction) {
|
||||
$comments = $this->commentsManager->retrieveAllReactionsWithSpecificReaction($messageId, $reaction);
|
||||
} else {
|
||||
$comments = $this->commentsManager->retrieveAllReactions($messageId);
|
||||
}
|
||||
|
||||
$reactions = [];
|
||||
foreach ($comments as $comment) {
|
||||
$message = $this->messageParser->createMessage($chat, $participant, $comment, $this->l);
|
||||
$this->messageParser->parseMessage($message);
|
||||
|
||||
$reactions[$comment->getMessage()][] = [
|
||||
'actorType' => $comment->getActorType(),
|
||||
'actorId' => $comment->getActorId(),
|
||||
'actorDisplayName' => $message->getActorDisplayName(),
|
||||
'timestamp' => $comment->getCreationDateTime()->getTimestamp(),
|
||||
];
|
||||
}
|
||||
return $reactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Participant $participant
|
||||
* @param array $messageIds
|
||||
* @return array[]
|
||||
* @psalm-return array<int, string[]>
|
||||
*/
|
||||
public function getReactionsByActorForMessages(Participant $participant, array $messageIds): array {
|
||||
return $this->commentsManager->retrieveReactionsByActor(
|
||||
$participant->getAttendee()->getActorType(),
|
||||
$participant->getAttendee()->getActorId(),
|
||||
$messageIds
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $chat
|
||||
* @param string $messageId
|
||||
* @return IComment
|
||||
* @throws NotFoundException
|
||||
* @throws ReactionNotSupportedException
|
||||
* @throws ReactionOutOfContextException
|
||||
*/
|
||||
public function getCommentToReact(Room $chat, string $messageId): IComment {
|
||||
if (!$this->commentsManager->supportReactions()) {
|
||||
throw new ReactionNotSupportedException();
|
||||
}
|
||||
$comment = $this->commentsManager->get($messageId);
|
||||
|
||||
if ($comment->getObjectType() !== 'chat'
|
||||
|| $comment->getObjectId() !== (string)$chat->getId()
|
||||
|| !in_array($comment->getVerb(), [
|
||||
ChatManager::VERB_MESSAGE,
|
||||
ChatManager::VERB_OBJECT_SHARED,
|
||||
], true)) {
|
||||
throw new ReactionOutOfContextException();
|
||||
}
|
||||
|
||||
return $comment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Chat\SystemMessage;
|
||||
|
||||
use DateInterval;
|
||||
use OCA\Talk\Chat\ChatManager;
|
||||
use OCA\Talk\Chat\MessageParser;
|
||||
use OCA\Talk\Events\AAttendeeRemovedEvent;
|
||||
use OCA\Talk\Events\AParticipantModifiedEvent;
|
||||
use OCA\Talk\Events\ARoomEvent;
|
||||
use OCA\Talk\Events\ARoomModifiedEvent;
|
||||
use OCA\Talk\Events\AttendeeRemovedEvent;
|
||||
use OCA\Talk\Events\AttendeesAddedEvent;
|
||||
use OCA\Talk\Events\AttendeesRemovedEvent;
|
||||
use OCA\Talk\Events\BeforeDuplicateShareSentEvent;
|
||||
use OCA\Talk\Events\BeforeParticipantModifiedEvent;
|
||||
use OCA\Talk\Events\LobbyModifiedEvent;
|
||||
use OCA\Talk\Events\ParticipantModifiedEvent;
|
||||
use OCA\Talk\Events\RoomCreatedEvent;
|
||||
use OCA\Talk\Events\RoomModifiedEvent;
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Model\BreakoutRoom;
|
||||
use OCA\Talk\Model\Message;
|
||||
use OCA\Talk\Model\Session;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\NoteToSelfService;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\SampleConversationsService;
|
||||
use OCA\Talk\Service\ThreadService;
|
||||
use OCA\Talk\TalkSession;
|
||||
use OCA\Talk\Webinary;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\Comments\IComment;
|
||||
use OCP\Comments\NotFoundException;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use OCP\ISession;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
use OCP\Share\Events\BeforeShareCreatedEvent;
|
||||
use OCP\Share\Events\ShareCreatedEvent;
|
||||
use OCP\Share\IShare;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class Listener implements IEventListener {
|
||||
|
||||
public function __construct(
|
||||
protected IRequest $request,
|
||||
protected ChatManager $chatManager,
|
||||
protected TalkSession $talkSession,
|
||||
protected ISession $session,
|
||||
protected IUserSession $userSession,
|
||||
protected ITimeFactory $timeFactory,
|
||||
protected Manager $manager,
|
||||
protected ParticipantService $participantService,
|
||||
protected MessageParser $messageParser,
|
||||
protected ThreadService $threadService,
|
||||
protected IL10N $l,
|
||||
protected LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if ($event instanceof ARoomEvent && $event->getRoom()->isFederatedConversation()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event instanceof AttendeesAddedEvent) {
|
||||
$this->attendeesAddedEvent($event);
|
||||
} elseif ($event instanceof AttendeeRemovedEvent) {
|
||||
$this->sendSystemMessageUserRemoved($event);
|
||||
} elseif ($event instanceof AttendeesRemovedEvent) {
|
||||
$this->attendeesRemovedEvent($event);
|
||||
} elseif ($event instanceof RoomCreatedEvent) {
|
||||
$this->sendSystemMessageAboutConversationCreated($event);
|
||||
} elseif ($event instanceof LobbyModifiedEvent) {
|
||||
$this->sendSystemLobbyMessage($event);
|
||||
} elseif ($event instanceof RoomModifiedEvent) {
|
||||
match ($event->getProperty()) {
|
||||
ARoomModifiedEvent::PROPERTY_AVATAR => $this->avatarChanged($event),
|
||||
ARoomModifiedEvent::PROPERTY_CALL_RECORDING => $this->setCallRecording($event),
|
||||
ARoomModifiedEvent::PROPERTY_DESCRIPTION => $this->sendSystemMessageAboutRoomDescriptionChanges($event),
|
||||
ARoomModifiedEvent::PROPERTY_LISTABLE => $this->sendSystemListableMessage($event),
|
||||
ARoomModifiedEvent::PROPERTY_MESSAGE_EXPIRATION => $this->afterSetMessageExpiration($event),
|
||||
ARoomModifiedEvent::PROPERTY_NAME => $this->sendSystemMessageAboutConversationRenamed($event),
|
||||
ARoomModifiedEvent::PROPERTY_PASSWORD => $this->sendSystemMessageAboutRoomPassword($event),
|
||||
ARoomModifiedEvent::PROPERTY_READ_ONLY => $this->sendSystemReadOnlyMessage($event),
|
||||
ARoomModifiedEvent::PROPERTY_TYPE => $this->sendSystemGuestPermissionsMessage($event),
|
||||
default => null,
|
||||
};
|
||||
} elseif ($event instanceof BeforeParticipantModifiedEvent) {
|
||||
match ($event->getProperty()) {
|
||||
AParticipantModifiedEvent::PROPERTY_IN_CALL => $this->sendSystemMessageAboutBeginOfCall($event),
|
||||
default => null,
|
||||
};
|
||||
} elseif ($event instanceof ParticipantModifiedEvent) {
|
||||
match ($event->getProperty()) {
|
||||
AParticipantModifiedEvent::PROPERTY_TYPE => $this->sendSystemMessageAboutPromoteOrDemoteModerator($event),
|
||||
AParticipantModifiedEvent::PROPERTY_IN_CALL => $this->sendSystemMessageAboutCallLeft($event),
|
||||
default => null,
|
||||
};
|
||||
} elseif ($event instanceof BeforeShareCreatedEvent) {
|
||||
$this->setShareExpiration($event);
|
||||
} elseif ($event instanceof BeforeDuplicateShareSentEvent || $event instanceof ShareCreatedEvent) {
|
||||
$this->fixMimeTypeOfVoiceMessage($event);
|
||||
}
|
||||
}
|
||||
|
||||
protected function sendSystemMessageAboutBeginOfCall(BeforeParticipantModifiedEvent $event): void {
|
||||
if ($event->getOldValue() !== Participant::FLAG_DISCONNECTED
|
||||
|| $event->getNewValue() === Participant::FLAG_DISCONNECTED) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->participantService->hasActiveSessionsInCall($event->getRoom())) {
|
||||
$this->sendSystemMessage($event->getRoom(), 'call_joined', [], $event->getParticipant());
|
||||
} else {
|
||||
$silent = $event->getDetail(AParticipantModifiedEvent::DETAIL_IN_CALL_SILENT) ?? false;
|
||||
$this->sendSystemMessage($event->getRoom(), 'call_started', [], $event->getParticipant(), silent: $silent);
|
||||
}
|
||||
}
|
||||
|
||||
protected function sendSystemMessageAboutCallLeft(ParticipantModifiedEvent $event): void {
|
||||
if ($event->getDetail(AParticipantModifiedEvent::DETAIL_IN_CALL_END_FOR_EVERYONE)) {
|
||||
// No individual system message if the call is ended for everyone
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event->getNewValue() === $event->getOldValue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event->getOldValue() === Participant::FLAG_DISCONNECTED
|
||||
|| $event->getNewValue() !== Participant::FLAG_DISCONNECTED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session = $event->getParticipant()->getSession();
|
||||
if (!$session instanceof Session) {
|
||||
// This happens in case the user was kicked/lobbied
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendSystemMessage($event->getRoom(), 'call_left', [], $event->getParticipant());
|
||||
}
|
||||
|
||||
protected function sendSystemMessageAboutConversationCreated(RoomCreatedEvent $event): void {
|
||||
if ($event->getRoom()->getType() === Room::TYPE_CHANGELOG || $this->isCreatingNoteToSelfAutomatically($event) || $this->isCreatingSample($event)) {
|
||||
$this->sendSystemMessage($event->getRoom(), 'conversation_created', forceSystemAsActor: true);
|
||||
} else {
|
||||
$this->sendSystemMessage($event->getRoom(), 'conversation_created');
|
||||
}
|
||||
}
|
||||
|
||||
protected function sendSystemMessageAboutConversationRenamed(RoomModifiedEvent $event): void {
|
||||
if ($event->getOldValue() === ''
|
||||
|| $event->getNewValue() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendSystemMessage($event->getRoom(), 'conversation_renamed', [
|
||||
'newName' => $event->getNewValue(),
|
||||
'oldName' => $event->getOldValue(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function sendSystemMessageAboutRoomDescriptionChanges(RoomModifiedEvent $event): void {
|
||||
if ($event->getNewValue() !== '') {
|
||||
if ($this->isCreatingNoteToSelf($event) || $this->isCreatingSample($event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendSystemMessage($event->getRoom(), 'description_set', [
|
||||
'newDescription' => $event->getNewValue(),
|
||||
]);
|
||||
} else {
|
||||
$this->sendSystemMessage($event->getRoom(), 'description_removed');
|
||||
}
|
||||
}
|
||||
|
||||
protected function sendSystemMessageAboutRoomPassword(RoomModifiedEvent $event): void {
|
||||
if ($event->getNewValue() !== '') {
|
||||
$this->sendSystemMessage($event->getRoom(), 'password_set');
|
||||
} else {
|
||||
$this->sendSystemMessage($event->getRoom(), 'password_removed');
|
||||
}
|
||||
}
|
||||
|
||||
protected function sendSystemGuestPermissionsMessage(RoomModifiedEvent $event): void {
|
||||
if ($event->getOldValue() === Room::TYPE_ONE_TO_ONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event->getNewValue() === Room::TYPE_PUBLIC) {
|
||||
$this->sendSystemMessage($event->getRoom(), 'guests_allowed');
|
||||
} elseif ($event->getNewValue() === Room::TYPE_GROUP) {
|
||||
$this->sendSystemMessage($event->getRoom(), 'guests_disallowed');
|
||||
}
|
||||
}
|
||||
|
||||
protected function sendSystemReadOnlyMessage(RoomModifiedEvent $event): void {
|
||||
$room = $event->getRoom();
|
||||
|
||||
if ($room->getType() === Room::TYPE_CHANGELOG) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event->getNewValue() === Room::READ_ONLY) {
|
||||
$this->sendSystemMessage($room, 'read_only');
|
||||
} elseif ($event->getNewValue() === Room::READ_WRITE) {
|
||||
$this->sendSystemMessage($room, 'read_only_off');
|
||||
}
|
||||
}
|
||||
|
||||
protected function sendSystemListableMessage(RoomModifiedEvent $event): void {
|
||||
if ($event->getNewValue() === Room::LISTABLE_NONE) {
|
||||
$this->sendSystemMessage($event->getRoom(), 'listable_none');
|
||||
} elseif ($event->getNewValue() === Room::LISTABLE_USERS) {
|
||||
$this->sendSystemMessage($event->getRoom(), 'listable_users');
|
||||
} elseif ($event->getNewValue() === Room::LISTABLE_ALL) {
|
||||
$this->sendSystemMessage($event->getRoom(), 'listable_all');
|
||||
}
|
||||
}
|
||||
|
||||
protected function sendSystemLobbyMessage(LobbyModifiedEvent $event): void {
|
||||
if ($event->getNewValue() === $event->getOldValue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$room = $event->getRoom();
|
||||
if ($room->getObjectType() === BreakoutRoom::PARENT_OBJECT_TYPE) {
|
||||
if ($event->getNewValue() === Webinary::LOBBY_NONE) {
|
||||
$this->sendSystemMessage($room, 'breakout_rooms_started');
|
||||
} else {
|
||||
$this->sendSystemMessage($room, 'breakout_rooms_stopped');
|
||||
}
|
||||
} elseif ($event->isTimerReached()) {
|
||||
$this->sendSystemMessage($room, 'lobby_timer_reached');
|
||||
} elseif ($event->getNewValue() === Webinary::LOBBY_NONE) {
|
||||
$this->sendSystemMessage($room, 'lobby_none');
|
||||
} elseif ($event->getNewValue() === Webinary::LOBBY_NON_MODERATORS) {
|
||||
$this->sendSystemMessage($room, 'lobby_non_moderators');
|
||||
}
|
||||
}
|
||||
|
||||
protected function addSystemMessageUserAdded(AttendeesAddedEvent $event, Attendee $attendee): void {
|
||||
$room = $event->getRoom();
|
||||
if ($room->getType() === Room::TYPE_ONE_TO_ONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($room->getType() === Room::TYPE_CHANGELOG) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userJoinedFileRoom = $room->getObjectType() === Room::OBJECT_TYPE_FILE && $attendee->getParticipantType() !== Participant::USER_SELF_JOINED;
|
||||
|
||||
// add a message "X joined the conversation", whenever user $userId:
|
||||
if (
|
||||
// - has joined a file room but not through a public link
|
||||
$userJoinedFileRoom
|
||||
// - has been added by another user (and not when creating a conversation)
|
||||
|| $this->getUserId() !== $attendee->getActorId()
|
||||
// - has joined a listable room on their own
|
||||
|| $attendee->getParticipantType() === Participant::USER) {
|
||||
$this->logger->debug('User "' . $attendee->getActorId() . '" added to room "' . $room->getToken() . '"', ['app' => 'spreed-bfp']);
|
||||
$comment = $this->sendSystemMessage(
|
||||
$room,
|
||||
'user_added',
|
||||
['user' => $attendee->getActorId()],
|
||||
null,
|
||||
$event->shouldSkipLastMessageUpdate()
|
||||
);
|
||||
|
||||
$event->setLastMessage($comment);
|
||||
}
|
||||
}
|
||||
|
||||
protected function sendSystemMessageUserRemoved(AttendeeRemovedEvent $event): void {
|
||||
$room = $event->getRoom();
|
||||
|
||||
if ($event->getAttendee()->getActorType() !== Attendee::ACTOR_USERS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($room->getType() === Room::TYPE_ONE_TO_ONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event->getReason() === AAttendeeRemovedEvent::REASON_LEFT
|
||||
&& $event->getAttendee()->getParticipantType() === Participant::USER_SELF_JOINED) {
|
||||
// Self-joined user closes the tab/window or leaves via the menu
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->debug('User "' . $event->getAttendee()->getActorId() . '" removed from room "' . $room->getToken() . '"', ['app' => 'spreed-bfp']);
|
||||
$this->sendSystemMessage($room, 'user_removed', ['user' => $event->getAttendee()->getActorId()]);
|
||||
}
|
||||
|
||||
public function sendSystemMessageAboutPromoteOrDemoteModerator(ParticipantModifiedEvent $event): void {
|
||||
$room = $event->getRoom();
|
||||
$attendee = $event->getParticipant()->getAttendee();
|
||||
|
||||
if (!in_array($attendee->getActorType(), [
|
||||
Attendee::ACTOR_USERS,
|
||||
Attendee::ACTOR_EMAILS,
|
||||
Attendee::ACTOR_GUESTS,
|
||||
], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event->getNewValue() === Participant::MODERATOR) {
|
||||
$this->sendSystemMessage($room, 'moderator_promoted', ['user' => $attendee->getActorId()]);
|
||||
} elseif ($event->getNewValue() === Participant::USER) {
|
||||
if ($event->getOldValue() === Participant::USER_SELF_JOINED) {
|
||||
$this->sendSystemMessage($room, 'user_added', ['user' => $attendee->getActorId()]);
|
||||
} else {
|
||||
$this->sendSystemMessage($room, 'moderator_demoted', ['user' => $attendee->getActorId()]);
|
||||
}
|
||||
} elseif ($event->getNewValue() === Participant::GUEST_MODERATOR) {
|
||||
$this->sendSystemMessage($room, 'guest_moderator_promoted', ['type' => $attendee->getActorType(), 'id' => $attendee->getActorId()]);
|
||||
} elseif ($event->getNewValue() === Participant::GUEST) {
|
||||
$this->sendSystemMessage($room, 'guest_moderator_demoted', ['type' => $attendee->getActorType(), 'id' => $attendee->getActorId()]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function setShareExpiration(BeforeShareCreatedEvent $event): void {
|
||||
$share = $event->getShare();
|
||||
|
||||
if ($share->getShareType() !== IShare::TYPE_ROOM) {
|
||||
return;
|
||||
}
|
||||
|
||||
$room = $this->manager->getRoomByToken($share->getSharedWith());
|
||||
|
||||
$messageExpiration = $room->getMessageExpiration();
|
||||
if (!$messageExpiration) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dateTime = $this->timeFactory->getDateTime();
|
||||
$dateTime->add(DateInterval::createFromDateString($messageExpiration . ' seconds'));
|
||||
$share->setExpirationDate($dateTime);
|
||||
}
|
||||
|
||||
protected function fixMimeTypeOfVoiceMessage(ShareCreatedEvent|BeforeDuplicateShareSentEvent $event): void {
|
||||
$share = $event->getShare();
|
||||
|
||||
if ($share->getShareType() !== IShare::TYPE_ROOM) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strtolower($this->request->getParam('_route')) === 'ocs.spreed.recording.sharetochat') {
|
||||
return;
|
||||
}
|
||||
$room = $this->manager->getRoomByToken($share->getSharedWith());
|
||||
$this->participantService->ensureOneToOneRoomIsFilled($room);
|
||||
|
||||
$metaData = $this->request->getParam('talkMetaData') ?? '';
|
||||
$metaData = json_decode($metaData, true);
|
||||
$metaData = is_array($metaData) ? $metaData : [];
|
||||
|
||||
if (isset($metaData['messageType']) && $metaData['messageType'] === ChatManager::VERB_VOICE_MESSAGE) {
|
||||
if ($share->getNode()->getMimeType() !== 'audio/mpeg'
|
||||
&& $share->getNode()->getMimeType() !== 'audio/wav') {
|
||||
unset($metaData['messageType']);
|
||||
}
|
||||
}
|
||||
$metaData['mimeType'] = $share->getNode()->getMimeType();
|
||||
|
||||
if (isset($metaData['caption'])) {
|
||||
if (is_string($metaData['caption']) && trim($metaData['caption']) !== '') {
|
||||
$metaData['caption'] = trim($metaData['caption']);
|
||||
} else {
|
||||
unset($metaData['caption']);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($metaData[Message::METADATA_SILENT])) {
|
||||
$silent = (bool)$metaData[Message::METADATA_SILENT];
|
||||
} else {
|
||||
$silent = false;
|
||||
}
|
||||
|
||||
$replyTo = null;
|
||||
if (isset($metaData['replyTo'])) {
|
||||
$replyTo = (int)$metaData['replyTo'];
|
||||
unset($metaData['replyTo']);
|
||||
}
|
||||
$threadId = null;
|
||||
if (isset($metaData['threadId'])) {
|
||||
$threadId = (int)$metaData['threadId'];
|
||||
unset($metaData['threadId']);
|
||||
}
|
||||
|
||||
$threadTitle = '';
|
||||
if (isset($metaData['threadTitle'])) {
|
||||
if (is_string($metaData['threadTitle']) && trim($metaData['threadTitle']) !== '') {
|
||||
$threadTitle = trim($metaData['threadTitle']);
|
||||
}
|
||||
unset($metaData['threadTitle']);
|
||||
}
|
||||
|
||||
$comment = $this->sendSystemMessage(
|
||||
$room,
|
||||
'file_shared',
|
||||
['share' => $share->getId(), 'metaData' => $metaData],
|
||||
silent: $silent,
|
||||
replyTo: $replyTo,
|
||||
threadId: $threadId,
|
||||
);
|
||||
$messageId = (int)$comment->getId();
|
||||
|
||||
if ($threadTitle !== '' && $comment->getTopmostParentId() === '0') {
|
||||
$thread = $this->threadService->createThread($room, $messageId, $threadTitle);
|
||||
try {
|
||||
// Add to subscribed threads list
|
||||
$participant = $this->participantService->getParticipant($room, $this->getUserId());
|
||||
$this->threadService->setNotificationLevel($participant->getAttendee(), $thread->getId(), Participant::NOTIFY_DEFAULT);
|
||||
} catch (ParticipantNotFoundException) {
|
||||
}
|
||||
|
||||
$this->sendSystemMessage(
|
||||
$room,
|
||||
'thread_created',
|
||||
['thread' => $messageId, 'title' => $thread->getName()],
|
||||
shouldSkipLastMessageUpdate: true,
|
||||
silent: true,
|
||||
parent: $comment,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function attendeesAddedEvent(AttendeesAddedEvent $event): void {
|
||||
foreach ($event->getAttendees() as $attendee) {
|
||||
$this->logger->debug($attendee->getActorType() . ' "' . $attendee->getActorId() . '" added to room "' . $event->getRoom()->getToken() . '"', ['app' => 'spreed-bfp']);
|
||||
if ($attendee->getActorType() === Attendee::ACTOR_GROUPS) {
|
||||
$this->sendSystemMessage($event->getRoom(), 'group_added', ['group' => $attendee->getActorId()]);
|
||||
} elseif ($attendee->getActorType() === Attendee::ACTOR_CIRCLES) {
|
||||
$this->sendSystemMessage($event->getRoom(), 'circle_added', ['circle' => $attendee->getActorId()]);
|
||||
} elseif ($attendee->getActorType() === Attendee::ACTOR_FEDERATED_USERS) {
|
||||
$this->sendSystemMessage($event->getRoom(), 'federated_user_added', ['federated_user' => $attendee->getActorId()]);
|
||||
} elseif ($attendee->getActorType() === Attendee::ACTOR_PHONES) {
|
||||
$this->sendSystemMessage($event->getRoom(), 'phone_added', ['phone' => $attendee->getActorId(), 'name' => $attendee->getDisplayName()]);
|
||||
} elseif ($attendee->getActorType() === Attendee::ACTOR_USERS) {
|
||||
$this->addSystemMessageUserAdded($event, $attendee);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function attendeesRemovedEvent(AttendeesRemovedEvent $event): void {
|
||||
foreach ($event->getAttendees() as $attendee) {
|
||||
$this->logger->debug($attendee->getActorType() . ' "' . $attendee->getActorId() . '" removed from room "' . $event->getRoom()->getToken() . '"', ['app' => 'spreed-bfp']);
|
||||
if ($attendee->getActorType() === Attendee::ACTOR_GROUPS) {
|
||||
$this->sendSystemMessage($event->getRoom(), 'group_removed', ['group' => $attendee->getActorId()]);
|
||||
} elseif ($attendee->getActorType() === Attendee::ACTOR_CIRCLES) {
|
||||
$this->sendSystemMessage($event->getRoom(), 'circle_removed', ['circle' => $attendee->getActorId()]);
|
||||
} elseif ($attendee->getActorType() === Attendee::ACTOR_FEDERATED_USERS) {
|
||||
$this->sendSystemMessage($event->getRoom(), 'federated_user_removed', ['federated_user' => $attendee->getActorId()]);
|
||||
} elseif ($attendee->getActorType() === Attendee::ACTOR_PHONES) {
|
||||
$this->sendSystemMessage($event->getRoom(), 'phone_removed', ['phone' => $attendee->getActorId(), 'name' => $attendee->getDisplayName()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function sendSystemMessage(
|
||||
Room $room,
|
||||
string $message,
|
||||
array $parameters = [],
|
||||
?Participant $participant = null,
|
||||
bool $shouldSkipLastMessageUpdate = false,
|
||||
bool $silent = false,
|
||||
bool $forceSystemAsActor = false,
|
||||
?int $replyTo = null,
|
||||
?IComment $parent = null,
|
||||
?int $threadId = null,
|
||||
): IComment {
|
||||
if ($participant instanceof Participant) {
|
||||
$actorType = $participant->getAttendee()->getActorType();
|
||||
$actorId = $participant->getAttendee()->getActorId();
|
||||
} elseif ($forceSystemAsActor) {
|
||||
$actorType = Attendee::ACTOR_GUESTS;
|
||||
$actorId = Attendee::ACTOR_ID_SYSTEM;
|
||||
} else {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user instanceof IUser) {
|
||||
$actorType = Attendee::ACTOR_USERS;
|
||||
$actorId = $user->getUID();
|
||||
} elseif (\OC::$CLI || $this->session->exists('talk-overwrite-actor-cli')) {
|
||||
$actorType = Attendee::ACTOR_GUESTS;
|
||||
$actorId = Attendee::ACTOR_ID_CLI;
|
||||
} elseif ($this->session->exists('talk-overwrite-actor-type')) {
|
||||
$actorType = $this->session->get('talk-overwrite-actor-type');
|
||||
$actorId = $this->session->get('talk-overwrite-actor-id');
|
||||
} elseif ($this->session->exists('talk-overwrite-actor-id')) {
|
||||
$actorType = Attendee::ACTOR_USERS;
|
||||
$actorId = $this->session->get('talk-overwrite-actor-id');
|
||||
} else {
|
||||
$actorType = Attendee::ACTOR_GUESTS;
|
||||
$sessionId = $this->talkSession->getSessionForRoom($room->getToken());
|
||||
$actorId = $sessionId ? sha1($sessionId) : 'failed-to-get-session';
|
||||
}
|
||||
}
|
||||
|
||||
// Little hack to get the reference id from the share request into
|
||||
// the system message left for the share in the chat.
|
||||
$referenceId = $this->request->getParam('referenceId', null);
|
||||
if ($referenceId !== null) {
|
||||
$referenceId = (string)$referenceId;
|
||||
}
|
||||
|
||||
if ($parent === null && $replyTo !== null) {
|
||||
try {
|
||||
$parentComment = $this->chatManager->getParentComment($room, (string)$replyTo);
|
||||
$parentMessage = $this->messageParser->createMessage($room, $participant, $parentComment, $this->l);
|
||||
$this->messageParser->parseMessage($parentMessage, true);
|
||||
if ($parentMessage->isReplyable()) {
|
||||
$parent = $parentComment;
|
||||
}
|
||||
} catch (NotFoundException) {
|
||||
}
|
||||
} elseif ($parent === null && $threadId !== null) {
|
||||
if (!$this->threadService->validateThread($room->getId(), $threadId)) {
|
||||
$threadId = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->chatManager->addSystemMessage(
|
||||
$room, $participant, $actorType, $actorId,
|
||||
json_encode(['message' => $message, 'parameters' => $parameters]),
|
||||
$this->timeFactory->getDateTime(),
|
||||
$message === 'file_shared',
|
||||
$referenceId,
|
||||
$parent,
|
||||
$shouldSkipLastMessageUpdate,
|
||||
$silent,
|
||||
$threadId ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
protected function getUserId(): ?string {
|
||||
$user = $this->userSession->getUser();
|
||||
return $user instanceof IUser ? $user->getUID() : null;
|
||||
}
|
||||
|
||||
protected function afterSetMessageExpiration(RoomModifiedEvent $event): void {
|
||||
$seconds = $event->getNewValue();
|
||||
|
||||
if ($seconds > 0) {
|
||||
$message = 'message_expiration_enabled';
|
||||
} else {
|
||||
$message = 'message_expiration_disabled';
|
||||
}
|
||||
|
||||
$this->sendSystemMessage(
|
||||
$event->getRoom(),
|
||||
$message,
|
||||
[
|
||||
'seconds' => $seconds,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
protected function setCallRecording(RoomModifiedEvent $event): void {
|
||||
$recordingHasStarted = in_array($event->getOldValue(), [Room::RECORDING_NONE, Room::RECORDING_VIDEO_STARTING, Room::RECORDING_AUDIO_STARTING, Room::RECORDING_FAILED], true)
|
||||
&& in_array($event->getNewValue(), [Room::RECORDING_VIDEO, Room::RECORDING_AUDIO], true);
|
||||
$recordingHasStopped = in_array($event->getOldValue(), [Room::RECORDING_VIDEO, Room::RECORDING_AUDIO], true)
|
||||
&& $event->getNewValue() === Room::RECORDING_NONE;
|
||||
$recordingHasFailed = in_array($event->getOldValue(), [Room::RECORDING_VIDEO, Room::RECORDING_AUDIO], true)
|
||||
&& $event->getNewValue() === Room::RECORDING_FAILED;
|
||||
|
||||
if (!$recordingHasStarted && !$recordingHasStopped && !$recordingHasFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor = $event->getActor();
|
||||
if ($recordingHasStopped && $actor === null) {
|
||||
// No actor means the recording was stopped by the end of the call.
|
||||
// So we are not generating a system message
|
||||
return;
|
||||
}
|
||||
|
||||
$prefix = $this->getCallRecordingPrefix($event);
|
||||
$suffix = $this->getCallRecordingSuffix($event);
|
||||
$systemMessage = $prefix . 'recording_' . $suffix;
|
||||
|
||||
$this->sendSystemMessage($event->getRoom(), $systemMessage, [], $actor);
|
||||
}
|
||||
|
||||
protected function getCallRecordingSuffix(RoomModifiedEvent $event): string {
|
||||
$newStatus = $event->getNewValue();
|
||||
$startStatus = [
|
||||
Room::RECORDING_VIDEO,
|
||||
Room::RECORDING_AUDIO,
|
||||
];
|
||||
if (in_array($newStatus, $startStatus, true)) {
|
||||
return 'started';
|
||||
}
|
||||
if ($newStatus === Room::RECORDING_FAILED) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
protected function getCallRecordingPrefix(RoomModifiedEvent $event): string {
|
||||
$newValue = $event->getNewValue();
|
||||
$oldValue = $event->getOldValue();
|
||||
$isAudioStatus = $newValue === Room::RECORDING_AUDIO
|
||||
|| ($oldValue === Room::RECORDING_AUDIO && $newValue !== Room::RECORDING_FAILED);
|
||||
return $isAudioStatus ? 'audio_' : '';
|
||||
}
|
||||
|
||||
protected function avatarChanged(RoomModifiedEvent $event): void {
|
||||
if ($event->getNewValue()) {
|
||||
if ($this->isCreatingNoteToSelf($event) || $this->isCreatingSample($event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = 'avatar_set';
|
||||
} else {
|
||||
$message = 'avatar_removed';
|
||||
}
|
||||
|
||||
$this->sendSystemMessage($event->getRoom(), $message);
|
||||
}
|
||||
|
||||
protected function isCreatingNoteToSelf(RoomModifiedEvent $event): bool {
|
||||
if ($event->getRoom()->getType() !== Room::TYPE_NOTE_TO_SELF) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$exception = new \Exception();
|
||||
$trace = $exception->getTrace();
|
||||
|
||||
foreach ($trace as $step) {
|
||||
if (isset($step['class']) && $step['class'] === NoteToSelfService::class
|
||||
&& isset($step['function']) && $step['function'] === 'initialCreateNoteToSelfForUser') {
|
||||
return true;
|
||||
}
|
||||
if (isset($step['class']) && $step['class'] === NoteToSelfService::class
|
||||
&& isset($step['function']) && $step['function'] === 'ensureNoteToSelfExistsForUser') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function isCreatingSample(ARoomEvent $event): bool {
|
||||
if ($event->getRoom()->getType() !== Room::TYPE_GROUP) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$exception = new \Exception();
|
||||
$trace = $exception->getTrace();
|
||||
|
||||
foreach ($trace as $step) {
|
||||
if (isset($step['class']) && $step['class'] === SampleConversationsService::class
|
||||
&& isset($step['function']) && $step['function'] === 'initialCreateSamples') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function isCreatingNoteToSelfAutomatically(RoomCreatedEvent $event): bool {
|
||||
if ($event->getRoom()->getType() !== Room::TYPE_NOTE_TO_SELF) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$exception = new \Exception();
|
||||
$trace = $exception->getTrace();
|
||||
|
||||
foreach ($trace as $step) {
|
||||
if (isset($step['class']) && $step['class'] === NoteToSelfService::class
|
||||
&& isset($step['function']) && $step['function'] === 'initialCreateNoteToSelfForUser') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Collaboration\Collaborators;
|
||||
|
||||
use OCA\Talk\Config;
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\MatterbridgeManager;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\TalkSession;
|
||||
use OCP\Collaboration\AutoComplete\AutoCompleteFilterEvent;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class Listener implements IEventListener {
|
||||
/** @var string[] */
|
||||
protected array $allowedGroupIds = [];
|
||||
protected string $roomToken;
|
||||
protected ?Room $room = null;
|
||||
|
||||
public function __construct(
|
||||
protected Manager $manager,
|
||||
protected IUserManager $userManager,
|
||||
protected ParticipantService $participantService,
|
||||
protected Config $config,
|
||||
protected TalkSession $talkSession,
|
||||
protected ?string $userId,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof AutoCompleteFilterEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event->getItemType() !== 'call') {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->setResults($this->filterUsersAndGroupsWithoutTalk($event->getResults()));
|
||||
|
||||
$event->setResults($this->filterBridgeBot($event->getResults()));
|
||||
if ($event->getItemId() !== 'new') {
|
||||
$event->setResults($this->filterExistingParticipants($event->getItemId(), $event->getResults()));
|
||||
}
|
||||
}
|
||||
|
||||
protected function filterUsersAndGroupsWithoutTalk(array $results): array {
|
||||
$this->allowedGroupIds = $this->config->getAllowedTalkGroupIds();
|
||||
if (empty($this->allowedGroupIds)) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
if (!empty($results['groups'])) {
|
||||
$results['groups'] = array_filter($results['groups'], [$this, 'filterBlockedGroupResult']);
|
||||
}
|
||||
if (!empty($results['exact']['groups'])) {
|
||||
$results['exact']['groups'] = array_filter($results['exact']['groups'], [$this, 'filterBlockedGroupResult']);
|
||||
}
|
||||
|
||||
if (!empty($results['users'])) {
|
||||
$results['users'] = array_filter($results['users'], [$this, 'filterBlockedUserResult']);
|
||||
}
|
||||
if (!empty($results['exact']['users'])) {
|
||||
$results['exact']['users'] = array_filter($results['exact']['users'], [$this, 'filterBlockedUserResult']);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
protected function filterBlockedUserResult(array $result): bool {
|
||||
$user = $this->userManager->get($result['value']['shareWith']);
|
||||
return $user instanceof IUser && !$this->config->isDisabledForUser($user);
|
||||
}
|
||||
|
||||
protected function filterBlockedGroupResult(array $result): bool {
|
||||
return \in_array($result['value']['shareWith'], $this->allowedGroupIds, true);
|
||||
}
|
||||
|
||||
protected function filterBridgeBot(array $results): array {
|
||||
if (!empty($results['users'])) {
|
||||
$results['users'] = array_filter($results['users'], [$this, 'filterBridgeBotUserResult']);
|
||||
}
|
||||
if (!empty($results['exact']['users'])) {
|
||||
$results['exact']['users'] = array_filter($results['exact']['users'], [$this, 'filterBridgeBotUserResult']);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
protected function filterExistingParticipants(string $token, array $results): array {
|
||||
$sessionId = $this->talkSession->getSessionForRoom($token);
|
||||
try {
|
||||
$this->room = $this->manager->getRoomForUserByToken($token, $this->userId);
|
||||
if ($this->userId !== null) {
|
||||
$this->participantService->getParticipant($this->room, $this->userId, $sessionId);
|
||||
} else {
|
||||
$this->participantService->getParticipantBySession($this->room, $sessionId);
|
||||
}
|
||||
} catch (RoomNotFoundException|ParticipantNotFoundException) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
if ($this->room->isFederatedConversation()) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
if (!empty($results['groups'])) {
|
||||
$results['groups'] = array_filter($results['groups'], [$this, 'filterParticipantGroupResult']);
|
||||
}
|
||||
if (!empty($results['exact']['groups'])) {
|
||||
$results['exact']['groups'] = array_filter($results['exact']['groups'], [$this, 'filterParticipantGroupResult']);
|
||||
}
|
||||
|
||||
if (!empty($results['users'])) {
|
||||
$results['users'] = array_filter($results['users'], [$this, 'filterParticipantUserResult']);
|
||||
}
|
||||
if (!empty($results['exact']['users'])) {
|
||||
$results['exact']['users'] = array_filter($results['exact']['users'], [$this, 'filterParticipantUserResult']);
|
||||
}
|
||||
|
||||
if (!empty($results['circles'])) {
|
||||
$results['circles'] = array_filter($results['circles'], [$this, 'filterParticipantTeamResult']);
|
||||
}
|
||||
if (!empty($results['exact']['circles'])) {
|
||||
$results['exact']['circles'] = array_filter($results['exact']['circles'], [$this, 'filterParticipantTeamResult']);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
protected function filterBridgeBotUserResult(array $result): bool {
|
||||
return $result['value']['shareWith'] !== MatterbridgeManager::BRIDGE_BOT_USERID;
|
||||
}
|
||||
|
||||
protected function filterParticipantUserResult(array $result): bool {
|
||||
$userId = $result['value']['shareWith'];
|
||||
|
||||
try {
|
||||
$participant = $this->participantService->getParticipant($this->room, $userId, false);
|
||||
if ($participant->getAttendee()->getParticipantType() === Participant::USER_SELF_JOINED) {
|
||||
// do list self-joined users so they can be added as permanent participants by moderators
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
protected function filterParticipantGroupResult(array $result): bool {
|
||||
$groupId = $result['value']['shareWith'];
|
||||
|
||||
try {
|
||||
$this->participantService->getParticipantByActor($this->room, Attendee::ACTOR_GROUPS, $groupId);
|
||||
return false;
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
protected function filterParticipantTeamResult(array $result): bool {
|
||||
$circleId = $result['value']['shareWith'];
|
||||
|
||||
try {
|
||||
$this->participantService->getParticipantByActor($this->room, Attendee::ACTOR_CIRCLES, $circleId);
|
||||
return false;
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Collaboration\Collaborators;
|
||||
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCP\Collaboration\Collaborators\ISearchPlugin;
|
||||
use OCP\Collaboration\Collaborators\ISearchResult;
|
||||
use OCP\Collaboration\Collaborators\SearchResultType;
|
||||
use OCP\IUserSession;
|
||||
use OCP\Share\IShare;
|
||||
|
||||
class RoomPlugin implements ISearchPlugin {
|
||||
|
||||
public function __construct(
|
||||
protected Manager $manager,
|
||||
protected ParticipantService $participantService,
|
||||
protected IUserSession $userSession,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
public function search($search, $limit, $offset, ISearchResult $searchResult): bool {
|
||||
if (!is_string($search) || $search === '') {
|
||||
return false;
|
||||
}
|
||||
$search = mb_strtolower($search);
|
||||
|
||||
$userId = $this->userSession->getUser()->getUID();
|
||||
|
||||
$result = ['wide' => [], 'exact' => []];
|
||||
|
||||
$rooms = $this->manager->getRoomsForUser($userId);
|
||||
foreach ($rooms as $room) {
|
||||
if ($room->getReadOnly() === Room::READ_ONLY) {
|
||||
// Can not add new shares to read-only rooms
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($room->isFederatedConversation()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$participant = $this->participantService->getParticipant($room, $userId, false);
|
||||
if (!($participant->getPermissions() & Attendee::PERMISSIONS_CHAT)) {
|
||||
// No chat permissions is like read-only
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_stripos($room->getDisplayName($userId), $search) !== false) {
|
||||
$item = $this->roomToSearchResultItem($room, $userId);
|
||||
|
||||
if (mb_strtolower($item['label']) === mb_strtolower($search)) {
|
||||
$result['exact'][] = $item;
|
||||
} else {
|
||||
$result['wide'][] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$type = new SearchResultType('rooms');
|
||||
$searchResult->addResultSet($type, $result['wide'], $result['exact']);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function roomToSearchResultItem(Room $room, string $userId): array {
|
||||
return
|
||||
[
|
||||
'label' => $room->getDisplayName($userId),
|
||||
'value' => [
|
||||
'shareType' => IShare::TYPE_ROOM,
|
||||
'shareWith' => $room->getToken()
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Collaboration\Reference;
|
||||
|
||||
use OCA\Talk\Events\AttendeesAddedEvent;
|
||||
use OCA\Talk\Events\AttendeesRemovedEvent;
|
||||
use OCA\Talk\Events\LobbyModifiedEvent;
|
||||
use OCA\Talk\Events\RoomDeletedEvent;
|
||||
use OCA\Talk\Events\RoomModifiedEvent;
|
||||
use OCP\Collaboration\Reference\IReferenceManager;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class ReferenceInvalidationListener implements IEventListener {
|
||||
|
||||
public function __construct(
|
||||
protected IReferenceManager $referenceManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if ($event instanceof AttendeesAddedEvent
|
||||
|| $event instanceof AttendeesRemovedEvent
|
||||
|| $event instanceof LobbyModifiedEvent
|
||||
|| $event instanceof RoomDeletedEvent
|
||||
|| $event instanceof RoomModifiedEvent) {
|
||||
$this->referenceManager->invalidateCache($event->getRoom()->getToken());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Collaboration\Reference;
|
||||
|
||||
use OCA\Talk\AppInfo\Application;
|
||||
use OCA\Talk\Chat\ChatManager;
|
||||
use OCA\Talk\Chat\MessageParser;
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Model\ProxyCacheMessageMapper;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\AvatarService;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\Collaboration\Reference\ADiscoverableReferenceProvider;
|
||||
use OCP\Collaboration\Reference\IReference;
|
||||
use OCP\Collaboration\Reference\ISearchableReferenceProvider;
|
||||
use OCP\Collaboration\Reference\Reference;
|
||||
use OCP\Comments\NotFoundException;
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
|
||||
/**
|
||||
* @psalm-type ReferenceMatch = array{token: string, message: int|null}
|
||||
*/
|
||||
class TalkReferenceProvider extends ADiscoverableReferenceProvider implements ISearchableReferenceProvider {
|
||||
|
||||
public function __construct(
|
||||
protected IURLGenerator $urlGenerator,
|
||||
protected Manager $roomManager,
|
||||
protected ParticipantService $participantService,
|
||||
protected ChatManager $chatManager,
|
||||
protected ProxyCacheMessageMapper $proxyCacheMessageMapper,
|
||||
protected AvatarService $avatarService,
|
||||
protected MessageParser $messageParser,
|
||||
protected IL10N $l,
|
||||
protected ?string $userId,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
#[\Override]
|
||||
public function matchReference(string $referenceText): bool {
|
||||
return $this->getTalkAppLinkToken($referenceText) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $referenceText
|
||||
* @return array|null
|
||||
* @psalm-return ReferenceMatch|null
|
||||
*/
|
||||
protected function getTalkAppLinkToken(string $referenceText): ?array {
|
||||
$indexPhpUrl = $this->urlGenerator->getAbsoluteURL('/index.php/call/');
|
||||
$rewriteUrl = $this->urlGenerator->getAbsoluteURL('/call/');
|
||||
|
||||
if (str_starts_with($referenceText, $indexPhpUrl)) {
|
||||
$urlOfInterest = substr($referenceText, strlen($indexPhpUrl));
|
||||
} elseif (str_starts_with($referenceText, $rewriteUrl)) {
|
||||
$urlOfInterest = substr($referenceText, strlen($rewriteUrl));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hashPosition = strpos($urlOfInterest, '#');
|
||||
$queryPosition = strpos($urlOfInterest, '?');
|
||||
|
||||
if ($hashPosition === false && $queryPosition === false) {
|
||||
return [
|
||||
'token' => $urlOfInterest,
|
||||
'message' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($hashPosition !== false && $queryPosition !== false) {
|
||||
$cutPosition = min($hashPosition, $queryPosition);
|
||||
} elseif ($hashPosition !== false) {
|
||||
$cutPosition = $hashPosition;
|
||||
} else {
|
||||
$cutPosition = $queryPosition;
|
||||
}
|
||||
|
||||
$token = substr($urlOfInterest, 0, $cutPosition);
|
||||
$messageId = null;
|
||||
if ($hashPosition !== false) {
|
||||
$afterHash = substr($urlOfInterest, $hashPosition + 1);
|
||||
if (preg_match('/^message_(\d+)$/', $afterHash, $matches)) {
|
||||
$messageId = (int)$matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'message' => $messageId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
public function resolveReference(string $referenceText): ?IReference {
|
||||
if ($this->matchReference($referenceText)) {
|
||||
$reference = new Reference($referenceText);
|
||||
try {
|
||||
$this->fetchReference($reference);
|
||||
} catch (RoomNotFoundException|ParticipantNotFoundException $e) {
|
||||
$reference->setRichObject('call', null);
|
||||
$reference->setAccessible(false);
|
||||
}
|
||||
return $reference;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RoomNotFoundException
|
||||
*/
|
||||
protected function fetchReference(Reference $reference): void {
|
||||
if ($this->userId === null) {
|
||||
throw new RoomNotFoundException();
|
||||
}
|
||||
|
||||
$referenceMatch = $this->getTalkAppLinkToken($reference->getId());
|
||||
if ($referenceMatch === null) {
|
||||
throw new RoomNotFoundException();
|
||||
}
|
||||
|
||||
$room = $this->roomManager->getRoomForUserByToken($referenceMatch['token'], $this->userId);
|
||||
try {
|
||||
$participant = $this->participantService->getParticipant($room, $this->userId);
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
$participant = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default handling:
|
||||
* Title is the conversation name
|
||||
* Description the conversation description
|
||||
*/
|
||||
$roomName = $room->getDisplayName($this->userId);
|
||||
$title = $roomName;
|
||||
$description = '';
|
||||
$messageId = null;
|
||||
|
||||
if ($participant instanceof Participant
|
||||
|| $this->roomManager->isRoomListableByUser($room, $this->userId)) {
|
||||
$description = $room->getDescription();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* If linking to a comment and the user is already a participant
|
||||
* Title is "Message of {user} in {conversation}"
|
||||
* Description is the plain text chat message
|
||||
*/
|
||||
if ($participant && !empty($referenceMatch['message'])) {
|
||||
$messageId = (string)$referenceMatch['message'];
|
||||
if (!$room->isFederatedConversation()) {
|
||||
try {
|
||||
$comment = $this->chatManager->getComment($room, $messageId);
|
||||
} catch (NotFoundException) {
|
||||
throw new RoomNotFoundException();
|
||||
}
|
||||
$message = $this->messageParser->createMessage($room, $participant, $comment, $this->l);
|
||||
$this->messageParser->parseMessage($message, true);
|
||||
} else {
|
||||
try {
|
||||
$proxy = $this->proxyCacheMessageMapper->findById($room, (int)$messageId);
|
||||
if ($proxy->getLocalToken() !== $room->getToken()) {
|
||||
throw new RoomNotFoundException();
|
||||
}
|
||||
} catch (DoesNotExistException) {
|
||||
throw new RoomNotFoundException();
|
||||
}
|
||||
$message = $this->messageParser->createMessageFromProxyCache($room, $participant, $proxy, $this->l);
|
||||
}
|
||||
|
||||
$placeholders = $replacements = [];
|
||||
foreach ($message->getMessageParameters() as $placeholder => $parameter) {
|
||||
$placeholders[] = '{' . $placeholder . '}';
|
||||
if ($parameter['type'] === 'user' || $parameter['type'] === 'guest') {
|
||||
$replacements[] = '@' . $parameter['name'];
|
||||
} else {
|
||||
$replacements[] = $parameter['name'];
|
||||
}
|
||||
}
|
||||
$description = str_replace($placeholders, $replacements, $message->getMessage());
|
||||
|
||||
$titleLine = $this->l->t('Message of {user} in {conversation}');
|
||||
if ($room->getType() === Room::TYPE_ONE_TO_ONE || $room->getType() === Room::TYPE_ONE_TO_ONE_FORMER) {
|
||||
$titleLine = $this->l->t('Message of {user}');
|
||||
}
|
||||
|
||||
$displayName = $message->getActorDisplayName();
|
||||
if (in_array($message->getActorType(), [Attendee::ACTOR_GUESTS, Attendee::ACTOR_EMAILS], true)) {
|
||||
if ($displayName === '') {
|
||||
$displayName = $this->l->t('Guest');
|
||||
} else {
|
||||
$displayName = $this->l->t('%s (guest)', $displayName);
|
||||
}
|
||||
} elseif ($displayName === '') {
|
||||
$titleLine = $this->l->t('Message of a deleted user in {conversation}');
|
||||
}
|
||||
|
||||
$title = str_replace(
|
||||
['{user}', '{conversation}'],
|
||||
[$displayName, $title],
|
||||
$titleLine
|
||||
);
|
||||
}
|
||||
|
||||
$reference->setTitle($title);
|
||||
$reference->setDescription($description);
|
||||
$reference->setUrl($this->urlGenerator->linkToRouteAbsolute('spreed.Page.showCall', ['token' => $room->getToken()]));
|
||||
$reference->setImageUrl($this->avatarService->getAvatarUrl($room));
|
||||
|
||||
$referenceData = [
|
||||
'id' => $room->getToken(),
|
||||
'name' => $roomName,
|
||||
'link' => $reference->getUrl(),
|
||||
'call-type' => $this->getRoomType($room),
|
||||
];
|
||||
|
||||
if ($messageId) {
|
||||
$referenceData['message-id'] = $messageId;
|
||||
}
|
||||
|
||||
$reference->setRichObject('call', $referenceData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
public function getCachePrefix(string $referenceId): string {
|
||||
$referenceMatch = $this->getTalkAppLinkToken($referenceId);
|
||||
if ($referenceMatch === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $referenceMatch['token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
public function getCacheKey(string $referenceId): ?string {
|
||||
$referenceMatch = $this->getTalkAppLinkToken($referenceId);
|
||||
if ($referenceMatch === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return ($this->userId ?? '') . '#' . ($referenceMatch['message'] ?? 0);
|
||||
}
|
||||
|
||||
protected function getRoomType(Room $room): string {
|
||||
switch ($room->getType()) {
|
||||
case Room::TYPE_ONE_TO_ONE:
|
||||
case Room::TYPE_ONE_TO_ONE_FORMER:
|
||||
return 'one2one';
|
||||
case Room::TYPE_GROUP:
|
||||
return 'group';
|
||||
case Room::TYPE_PUBLIC:
|
||||
return 'public';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
public function getId(): string {
|
||||
return Application::APP_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
public function getTitle(): string {
|
||||
return $this->l->t('Talk conversations');
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
public function getOrder(): int {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
public function getIconUrl(): string {
|
||||
return $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getSupportedSearchProviderIds(): array {
|
||||
if ($this->userId === null) {
|
||||
return [];
|
||||
}
|
||||
return ['talk-conversations'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Collaboration\Resources;
|
||||
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\AvatarService;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCP\Collaboration\Resources\IProvider;
|
||||
use OCP\Collaboration\Resources\IResource;
|
||||
use OCP\Collaboration\Resources\ResourceException;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
|
||||
class ConversationProvider implements IProvider {
|
||||
|
||||
public function __construct(
|
||||
protected Manager $manager,
|
||||
protected AvatarService $avatarService,
|
||||
protected ParticipantService $participantService,
|
||||
protected IUserSession $userSession,
|
||||
protected IURLGenerator $urlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getResourceRichObject(IResource $resource): array {
|
||||
try {
|
||||
$user = $this->userSession->getUser();
|
||||
$userId = $user instanceof IUser ? $user->getUID() : '';
|
||||
$room = $this->manager->getRoomByToken($resource->getId(), $userId);
|
||||
|
||||
$iconURL = $this->avatarService->getAvatarUrl($room);
|
||||
/**
|
||||
* Disabled for now, because it would show a square avatar
|
||||
* if ($room->getType() === Room::TYPE_ONE_TO_ONE) {
|
||||
* $iconURL = $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => 'admin', 'size' => 32]);
|
||||
* }
|
||||
*/
|
||||
|
||||
return [
|
||||
'type' => 'room',
|
||||
'id' => $resource->getId(),
|
||||
'name' => $room->getDisplayName($userId),
|
||||
'call-type' => $this->getRoomType($room),
|
||||
'iconUrl' => $iconURL,
|
||||
'link' => $this->urlGenerator->linkToRouteAbsolute('spreed.Page.showCall', ['token' => $room->getToken()])
|
||||
];
|
||||
} catch (RoomNotFoundException $e) {
|
||||
throw new ResourceException('Conversation not found');
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function canAccessResource(IResource $resource, ?IUser $user = null): bool {
|
||||
$userId = $user instanceof IUser ? $user->getUID() : null;
|
||||
if ($userId === null) {
|
||||
throw new ResourceException('Guests are not supported at the moment');
|
||||
}
|
||||
|
||||
try {
|
||||
$room = $this->manager->getRoomForUserByToken(
|
||||
$resource->getId(),
|
||||
$userId
|
||||
);
|
||||
|
||||
// Logged in users need to have a regular participant,
|
||||
// before they can do anything with the room.
|
||||
$participant = $this->participantService->getParticipant($room, $userId, false);
|
||||
return $participant->getAttendee()->getParticipantType() !== Participant::USER_SELF_JOINED;
|
||||
} catch (RoomNotFoundException $e) {
|
||||
throw new ResourceException('Conversation not found');
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
throw new ResourceException('Participant not found');
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getType(): string {
|
||||
return 'room';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @return string
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
protected function getRoomType(Room $room): string {
|
||||
switch ($room->getType()) {
|
||||
case Room::TYPE_ONE_TO_ONE:
|
||||
case Room::TYPE_ONE_TO_ONE_FORMER:
|
||||
return 'one2one';
|
||||
case Room::TYPE_GROUP:
|
||||
return 'group';
|
||||
case Room::TYPE_PUBLIC:
|
||||
return 'public';
|
||||
default:
|
||||
throw new \InvalidArgumentException('Unknown room type');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Collaboration\Resources;
|
||||
|
||||
use OCA\Talk\Events\ARoomModifiedEvent;
|
||||
use OCA\Talk\Events\AttendeesAddedEvent;
|
||||
use OCA\Talk\Events\AttendeesRemovedEvent;
|
||||
use OCA\Talk\Events\EmailInvitationSentEvent;
|
||||
use OCA\Talk\Events\RoomDeletedEvent;
|
||||
use OCA\Talk\Events\RoomModifiedEvent;
|
||||
use OCP\Collaboration\Resources\IManager;
|
||||
use OCP\Collaboration\Resources\ResourceException;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class Listener implements IEventListener {
|
||||
public function __construct(
|
||||
protected IManager $resourceManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if ($event instanceof AttendeesAddedEvent
|
||||
|| $event instanceof AttendeesRemovedEvent
|
||||
|| $event instanceof RoomDeletedEvent
|
||||
|| $event instanceof EmailInvitationSentEvent
|
||||
|| ($event instanceof RoomModifiedEvent
|
||||
&& $event->getProperty() === ARoomModifiedEvent::PROPERTY_TYPE)) {
|
||||
try {
|
||||
$resource = $this->resourceManager->getResourceForUser('room', $event->getRoom()->getToken(), null);
|
||||
} catch (ResourceException) {
|
||||
return;
|
||||
}
|
||||
$this->resourceManager->invalidateAccessCacheForResource($resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Bot;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Model\Bot;
|
||||
use OCA\Talk\Model\BotServer;
|
||||
use OCA\Talk\Model\BotServerMapper;
|
||||
use OCA\Talk\Service\BotService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\DB\Exception;
|
||||
use OCP\Security\ISecureRandom;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Create extends Base {
|
||||
public function __construct(
|
||||
private BotService $botService,
|
||||
private BotServerMapper $botServerMapper,
|
||||
private ISecureRandom $secureRandom,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
$this
|
||||
->setName('talk:bot:create')
|
||||
->setDescription('Creates a new bot on the server with \'response\' feature only.')
|
||||
->addArgument(
|
||||
'name',
|
||||
InputArgument::REQUIRED,
|
||||
'The name under which the messages will be posted (min. 1 char, max. 64 chars)'
|
||||
)
|
||||
->addArgument(
|
||||
'description',
|
||||
InputArgument::OPTIONAL,
|
||||
'Optional description shown in the admin settings (max. 4000 chars)'
|
||||
)
|
||||
->addOption(
|
||||
'secret',
|
||||
's',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Secret used to validate API calls (min. 40 chars, max. 128 chars). When none is provided, a random 64 chars string is generated and output.'
|
||||
)
|
||||
->addOption(
|
||||
'no-setup',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Prevent moderators from setting up the bot in a conversation'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$name = $input->getArgument('name');
|
||||
$description = $input->getArgument('description') ?? '';
|
||||
$noSetup = $input->getOption('no-setup');
|
||||
$featureFlags = Bot::FEATURE_RESPONSE;
|
||||
|
||||
$secret = $input->getOption('secret') ?? $this->secureRandom->generate(64);
|
||||
$url = Bot::URL_RESPONSE_ONLY_PREFIX . bin2hex(random_bytes(16));
|
||||
|
||||
try {
|
||||
$this->botService->validateBotParameters($name, $secret, $url, $description);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->botServerMapper->findByUrl($url);
|
||||
$output->writeln('<error>Bot with the same URL is already registered</error>');
|
||||
return 2;
|
||||
} catch (DoesNotExistException) {
|
||||
}
|
||||
|
||||
$bot = new BotServer();
|
||||
$bot->setName($name);
|
||||
$bot->setSecret($secret);
|
||||
$bot->setUrl($url);
|
||||
$bot->setUrlHash(sha1($url));
|
||||
$bot->setDescription($description);
|
||||
$bot->setState($noSetup ? Bot::STATE_NO_SETUP : Bot::STATE_ENABLED);
|
||||
$bot->setFeatures($featureFlags);
|
||||
try {
|
||||
$botEntity = $this->botServerMapper->insert($bot);
|
||||
} catch (\Exception $e) {
|
||||
if ($e instanceof Exception && $e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
|
||||
$output->writeln('<error>Bot with the same secret is already registered</error>');
|
||||
return 3;
|
||||
} else {
|
||||
$output->writeln('<error>' . get_class($e) . ': ' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln('<info>Bot installed</info>');
|
||||
$output->writeln('ID: ' . $botEntity->getId());
|
||||
|
||||
if ($input->getOption('secret') === null) {
|
||||
$output->writeln('Secret: ' . $secret);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Bot;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Model\Bot;
|
||||
use OCA\Talk\Model\BotServer;
|
||||
use OCA\Talk\Model\BotServerMapper;
|
||||
use OCA\Talk\Service\BotService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\DB\Exception;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Install extends Base {
|
||||
public function __construct(
|
||||
private BotService $botService,
|
||||
private BotServerMapper $botServerMapper,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
$this
|
||||
->setName('talk:bot:install')
|
||||
->setDescription('Install a new bot on the server')
|
||||
->addArgument(
|
||||
'name',
|
||||
InputArgument::REQUIRED,
|
||||
'The name under which the messages will be posted (min. 1 char, max. 64 chars)'
|
||||
)
|
||||
->addArgument(
|
||||
'secret',
|
||||
InputArgument::REQUIRED,
|
||||
'Secret used to validate API calls (min. 40 chars, max. 128 chars)'
|
||||
)
|
||||
->addArgument(
|
||||
'url',
|
||||
InputArgument::REQUIRED,
|
||||
'Webhook endpoint to post messages to (max. 4000 chars)'
|
||||
)
|
||||
->addArgument(
|
||||
'description',
|
||||
InputArgument::OPTIONAL,
|
||||
'Optional description shown in the admin settings (max. 4000 chars)'
|
||||
)
|
||||
->addOption(
|
||||
'no-setup',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Prevent moderators from setting up the bot in a conversation'
|
||||
)
|
||||
->addOption(
|
||||
'feature',
|
||||
'f',
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
'Specify the list of features for the bot' . "\n"
|
||||
. ' - webhook: The bot receives posted chat messages as webhooks' . "\n"
|
||||
. ' - response: The bot can post messages and reactions as a response' . "\n"
|
||||
. ' - event: The bot reads posted messages from local events' . "\n"
|
||||
. ' - reaction: The bot is notified about adding and removing of reactions' . "\n"
|
||||
. ' - none: When all features should be disabled for the bot'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$name = $input->getArgument('name');
|
||||
$secret = $input->getArgument('secret');
|
||||
$url = $input->getArgument('url');
|
||||
$description = $input->getArgument('description') ?? '';
|
||||
$noSetup = $input->getOption('no-setup');
|
||||
|
||||
if (!empty($input->getOption('feature'))) {
|
||||
$featureFlags = Bot::featureLabelsToFlags($input->getOption('feature'));
|
||||
if (str_starts_with($url, Bot::URL_APP_PREFIX)) {
|
||||
$featureFlags &= ~Bot::FEATURE_WEBHOOK;
|
||||
}
|
||||
} elseif (str_starts_with($url, Bot::URL_APP_PREFIX)) {
|
||||
$featureFlags = Bot::FEATURE_EVENT;
|
||||
} else {
|
||||
$featureFlags = Bot::FEATURE_WEBHOOK + Bot::FEATURE_RESPONSE;
|
||||
}
|
||||
|
||||
if ($featureFlags & Bot::FEATURE_EVENT
|
||||
&& ($featureFlags & Bot::FEATURE_WEBHOOK
|
||||
|| $featureFlags & Bot::FEATURE_RESPONSE
|
||||
|| $featureFlags & Bot::FEATURE_REACTION)) {
|
||||
$output->writeln('<error>Bots with feature "event" can not support "webhook", "response" or "reaction" feature. They are mutual exclusive</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->botService->validateBotParameters($name, $secret, $url, $description);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->botServerMapper->findByUrl($url);
|
||||
$output->writeln('<error>Bot with the same URL is already registered</error>');
|
||||
return 2;
|
||||
} catch (DoesNotExistException) {
|
||||
}
|
||||
|
||||
$bot = new BotServer();
|
||||
$bot->setName($name);
|
||||
$bot->setSecret($secret);
|
||||
$bot->setUrl($url);
|
||||
$bot->setUrlHash(sha1($url));
|
||||
$bot->setDescription($description);
|
||||
$bot->setState($noSetup ? Bot::STATE_NO_SETUP : Bot::STATE_ENABLED);
|
||||
$bot->setFeatures($featureFlags);
|
||||
try {
|
||||
$botEntity = $this->botServerMapper->insert($bot);
|
||||
} catch (\Exception $e) {
|
||||
if ($e instanceof Exception && $e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
|
||||
$output->writeln('<error>Bot with the same secret is already registered</error>');
|
||||
return 3;
|
||||
} else {
|
||||
$output->writeln('<error>' . get_class($e) . ': ' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln('<info>Bot installed</info>');
|
||||
$output->writeln('ID: ' . $botEntity->getId());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Bot;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Model\Bot;
|
||||
use OCA\Talk\Model\BotConversation;
|
||||
use OCA\Talk\Model\BotConversationMapper;
|
||||
use OCA\Talk\Model\BotServerMapper;
|
||||
use OCA\Talk\Service\BotService;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class ListBots extends Base {
|
||||
public function __construct(
|
||||
private BotConversationMapper $botConversationMapper,
|
||||
private BotServerMapper $botServerMapper,
|
||||
private BotService $botService,
|
||||
private IAppManager $appManager,
|
||||
private ITimeFactory $timeFactory,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
$this
|
||||
->setName('talk:bot:list')
|
||||
->setDescription('List all installed bots of the server or a conversation')
|
||||
->addArgument(
|
||||
'token',
|
||||
InputArgument::OPTIONAL,
|
||||
'Conversation token to limit the bot list for'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$bots = $this->botServerMapper->getAllBots();
|
||||
$token = $input->getArgument('token');
|
||||
|
||||
if ($token) {
|
||||
$botIds = array_map(static function (BotConversation $bot): int {
|
||||
return $bot->getBotId();
|
||||
}, $this->botConversationMapper->findForToken($token));
|
||||
}
|
||||
|
||||
$data = [];
|
||||
foreach ($bots as $bot) {
|
||||
if ($token && !in_array($bot->getId(), $botIds, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$botData = $bot->jsonSerialize();
|
||||
$botData['features'] = Bot::featureFlagsToLabels($botData['features']);
|
||||
|
||||
if (!$this->botService->isAppForBotEnabled($bot)) {
|
||||
$botData['state'] = Bot::STATE_UNAVAILABLE;
|
||||
if ($input->getOption('output') === 'plain') {
|
||||
$botData['error_count'] = '<error>' . 1 . '</error>';
|
||||
} else {
|
||||
$botData['error_count'] = 1;
|
||||
}
|
||||
$botData['last_error_date'] = $this->timeFactory->getTime();
|
||||
if ($input->getOption('output') === 'plain') {
|
||||
$botData['last_error_message'] = '<error>App disabled</error>';
|
||||
} else {
|
||||
$botData['last_error_message'] = 'App disabled';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$output->isVerbose()) {
|
||||
unset($botData['url']);
|
||||
unset($botData['url_hash']);
|
||||
unset($botData['secret']);
|
||||
unset($botData['last_error_date']);
|
||||
unset($botData['last_error_message']);
|
||||
}
|
||||
|
||||
$data[] = $botData;
|
||||
}
|
||||
|
||||
$this->writeTableInOutputFormat($input, $output, $data);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Bot;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Events\BotDisabledEvent;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Model\BotConversationMapper;
|
||||
use OCA\Talk\Model\BotServerMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\EventDispatcher\IEventDispatcher;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Remove extends Base {
|
||||
public function __construct(
|
||||
private BotConversationMapper $botConversationMapper,
|
||||
private BotServerMapper $botServerMapper,
|
||||
private IEventDispatcher $dispatcher,
|
||||
private Manager $roomManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
$this
|
||||
->setName('talk:bot:remove')
|
||||
->setDescription('Remove a bot from a conversation')
|
||||
->addArgument(
|
||||
'bot-id',
|
||||
InputArgument::REQUIRED,
|
||||
'The ID of the bot to remove in a conversation'
|
||||
)
|
||||
->addArgument(
|
||||
'token',
|
||||
InputArgument::IS_ARRAY,
|
||||
'Conversation tokens to remove bot up for'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$botId = (int)$input->getArgument('bot-id');
|
||||
$tokens = $input->getArgument('token');
|
||||
|
||||
try {
|
||||
$botServer = $this->botServerMapper->findById($botId);
|
||||
} catch (DoesNotExistException) {
|
||||
$output->writeln('<error>Bot could not be found by id: ' . $botId . '</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->botConversationMapper->deleteByBotIdAndTokens($botId, $tokens);
|
||||
$output->writeln('<info>Remove bot from given conversations</info>');
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
try {
|
||||
$room = $this->roomManager->getRoomByToken($token);
|
||||
} catch (RoomNotFoundException) {
|
||||
continue;
|
||||
}
|
||||
$event = new BotDisabledEvent($room, $botServer);
|
||||
$this->dispatcher->dispatchTyped($event);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Bot;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Events\BotEnabledEvent;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Model\Bot;
|
||||
use OCA\Talk\Model\BotConversation;
|
||||
use OCA\Talk\Model\BotConversationMapper;
|
||||
use OCA\Talk\Model\BotServerMapper;
|
||||
use OCA\Talk\Service\BotService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\DB\Exception;
|
||||
use OCP\EventDispatcher\IEventDispatcher;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Setup extends Base {
|
||||
public function __construct(
|
||||
private Manager $roomManager,
|
||||
private BotServerMapper $botServerMapper,
|
||||
private BotConversationMapper $botConversationMapper,
|
||||
private BotService $botService,
|
||||
private IEventDispatcher $dispatcher,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
$this
|
||||
->setName('talk:bot:setup')
|
||||
->setDescription('Add a bot to a conversation')
|
||||
->addArgument(
|
||||
'bot-id',
|
||||
InputArgument::REQUIRED,
|
||||
'The ID of the bot to set up in a conversation'
|
||||
)
|
||||
->addArgument(
|
||||
'token',
|
||||
InputArgument::IS_ARRAY,
|
||||
'Conversation tokens to set the bot up for'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$botId = (int)$input->getArgument('bot-id');
|
||||
$tokens = $input->getArgument('token');
|
||||
|
||||
try {
|
||||
$botServer = $this->botServerMapper->findById($botId);
|
||||
} catch (DoesNotExistException) {
|
||||
$output->writeln('<error>Bot could not be found by id: ' . $botId . '</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$this->botService->isAppForBotEnabled($botServer)) {
|
||||
$output->writeln('<error>Bot app is disabled: ' . $botServer->getUrl() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$returnCode = 0;
|
||||
foreach ($tokens as $token) {
|
||||
try {
|
||||
$room = $this->roomManager->getRoomByToken($token);
|
||||
|
||||
if ($room->isFederatedConversation()) {
|
||||
$output->writeln('<error>Federated conversations can not have bots: ' . $token . '</error>');
|
||||
$returnCode = 2;
|
||||
continue;
|
||||
}
|
||||
} catch (RoomNotFoundException) {
|
||||
$output->writeln('<error>Conversation could not be found by token: ' . $token . '</error>');
|
||||
$returnCode = 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
$bot = new BotConversation();
|
||||
$bot->setBotId($botId);
|
||||
$bot->setToken($token);
|
||||
$bot->setState(Bot::STATE_ENABLED);
|
||||
|
||||
try {
|
||||
$this->botConversationMapper->insert($bot);
|
||||
$output->writeln('<info>Successfully set up for conversation ' . $token . '</info>');
|
||||
|
||||
$event = new BotEnabledEvent($room, $botServer);
|
||||
$this->dispatcher->dispatchTyped($event);
|
||||
} catch (\Exception $e) {
|
||||
if ($e instanceof Exception && $e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
|
||||
$output->writeln('<error>Bot is already set up for the conversation ' . $token . '</error>');
|
||||
$returnCode = 3;
|
||||
} else {
|
||||
$output->writeln('<error>' . get_class($e) . ': ' . $e->getMessage() . '</error>');
|
||||
$returnCode = 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $returnCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Bot;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Model\Bot;
|
||||
use OCA\Talk\Model\BotServerMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class State extends Base {
|
||||
public function __construct(
|
||||
private BotServerMapper $botServerMapper,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
$this
|
||||
->setName('talk:bot:state')
|
||||
->setDescription('Change the state or feature list for a bot')
|
||||
->addArgument(
|
||||
'bot-id',
|
||||
InputArgument::REQUIRED,
|
||||
'Bot ID to change the state for'
|
||||
)
|
||||
->addArgument(
|
||||
'state',
|
||||
InputArgument::REQUIRED,
|
||||
'New state for the bot (0 = disabled, 1 = enabled, 2 = no setup via GUI)'
|
||||
)
|
||||
->addOption(
|
||||
'feature',
|
||||
'f',
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
'Specify the list of features for the bot' . "\n"
|
||||
. ' - webhook: The bot receives posted chat messages as webhooks' . "\n"
|
||||
. ' - response: The bot can post messages and reactions as a response' . "\n"
|
||||
. ' - event: The bot reads posted messages from local events' . "\n"
|
||||
. ' - reaction: The bot is notified about adding and removing of reactions' . "\n"
|
||||
. ' - none: When all features should be disabled for the bot'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$botId = (int)$input->getArgument('bot-id');
|
||||
$state = (int)$input->getArgument('state');
|
||||
|
||||
$featureFlags = null;
|
||||
if (!empty($input->getOption('feature'))) {
|
||||
$featureFlags = Bot::featureLabelsToFlags($input->getOption('feature'));
|
||||
}
|
||||
|
||||
if (!in_array($state, [Bot::STATE_DISABLED, Bot::STATE_ENABLED, Bot::STATE_NO_SETUP], true)) {
|
||||
$output->writeln('<error>Provided state is invalid</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$bot = $this->botServerMapper->findById($botId);
|
||||
} catch (DoesNotExistException) {
|
||||
$output->writeln('<error>Bot could not be found by id: ' . $botId . '</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$bot->setState($state);
|
||||
if ($featureFlags !== null) {
|
||||
if (str_starts_with($bot->getUrl(), Bot::URL_RESPONSE_ONLY_PREFIX)) {
|
||||
$output->writeln('<error>Feature flags of response-only bots cannot be changed</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$bot->setFeatures($featureFlags);
|
||||
}
|
||||
$this->botServerMapper->update($bot);
|
||||
|
||||
if ($featureFlags !== null) {
|
||||
$output->writeln('<info>Bot state set to ' . $state . ' with features: ' . Bot::featureFlagsToLabels($featureFlags) . '</info>');
|
||||
} else {
|
||||
$output->writeln('<info>Bot state set to ' . $state . '</info>');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Bot;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Model\BotConversationMapper;
|
||||
use OCA\Talk\Model\BotServerMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Uninstall extends Base {
|
||||
public function __construct(
|
||||
private BotConversationMapper $botConversationMapper,
|
||||
private BotServerMapper $botServerMapper,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
$this
|
||||
->setName('talk:bot:uninstall')
|
||||
->setDescription('Uninstall a bot from the server')
|
||||
->addArgument(
|
||||
'id',
|
||||
InputArgument::OPTIONAL,
|
||||
'The ID of the bot'
|
||||
)
|
||||
->addOption(
|
||||
'url',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The URL of the bot (required when no ID is given, ignored otherwise)'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$botId = (int)$input->getArgument('id');
|
||||
|
||||
try {
|
||||
if ($botId === 0) {
|
||||
$url = $input->getOption('url');
|
||||
if ($url === null) {
|
||||
$output->writeln('<error>URL is required when no ID is given</error>');
|
||||
return 1;
|
||||
}
|
||||
$bot = $this->botServerMapper->findByUrl($url);
|
||||
} else {
|
||||
$bot = $this->botServerMapper->findById($botId);
|
||||
}
|
||||
} catch (DoesNotExistException) {
|
||||
$output->writeln('<error>Bot not found</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->botConversationMapper->deleteByBotId($bot->getId());
|
||||
$this->botServerMapper->deleteById($bot->getId());
|
||||
|
||||
$output->writeln('<info>Bot uninstalled</info>');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Developer;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IConfig;
|
||||
use OCP\IDBConnection;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class AgeChatMessages extends Base {
|
||||
public function __construct(
|
||||
private readonly IConfig $config,
|
||||
private readonly IDBConnection $connection,
|
||||
private readonly Manager $manager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function isEnabled(): bool {
|
||||
return $this->config->getSystemValue('debug', false) === true;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:developer:age-chat-messages')
|
||||
->setDescription('Artificially ages chat messages in the given conversation, so deletion and other things can be tested')
|
||||
->addArgument(
|
||||
'token',
|
||||
InputArgument::REQUIRED,
|
||||
'Token of the room to manipulate'
|
||||
)
|
||||
->addOption(
|
||||
'hours',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Number of hours to age all chat messages',
|
||||
24
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$token = $input->getArgument('token');
|
||||
$hours = (int)$input->getOption('hours');
|
||||
if ($hours < 1) {
|
||||
$output->writeln('<error>Invalid age: ' . $hours . '</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$room = $this->manager->getRoomByToken($token);
|
||||
} catch (RoomNotFoundException) {
|
||||
$output->writeln('<error>Room not found: ' . $token . '</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$update = $this->connection->getQueryBuilder();
|
||||
$update->update('comments')
|
||||
->set('creation_timestamp', $update->createParameter('creation_timestamp'))
|
||||
->set('expire_date', $update->createParameter('expire_date'))
|
||||
->set('meta_data', $update->createParameter('meta_data'))
|
||||
->where($update->expr()->eq('id', $update->createParameter('id')));
|
||||
|
||||
$query = $this->connection->getQueryBuilder();
|
||||
$query->select('id', 'creation_timestamp', 'expire_date', 'meta_data')
|
||||
->from('comments')
|
||||
->where($query->expr()->eq('object_type', $query->createNamedParameter('chat')))
|
||||
->andWhere($query->expr()->eq('object_id', $query->createNamedParameter($room->getId())));
|
||||
|
||||
$result = $query->executeQuery();
|
||||
while ($row = $result->fetch()) {
|
||||
$creationTimestamp = new \DateTime($row['creation_timestamp']);
|
||||
$creationTimestamp->sub(new \DateInterval('PT' . $hours . 'H'));
|
||||
|
||||
$expireDate = null;
|
||||
if ($row['expire_date']) {
|
||||
$expireDate = new \DateTime($row['expire_date']);
|
||||
$expireDate->sub(new \DateInterval('PT' . $hours . 'H'));
|
||||
}
|
||||
|
||||
$metaData = 'null';
|
||||
if ($row['meta_data'] !== 'null') {
|
||||
$metaData = json_decode($row['meta_data'], true);
|
||||
if (isset($metaData['last_edited_time'])) {
|
||||
$metaData['last_edited_time'] -= $hours * 3600;
|
||||
}
|
||||
$metaData = json_encode($metaData);
|
||||
}
|
||||
|
||||
$update->setParameter('id', $row['id']);
|
||||
$update->setParameter('creation_timestamp', $creationTimestamp, IQueryBuilder::PARAM_DATE);
|
||||
$update->setParameter('expire_date', $expireDate, IQueryBuilder::PARAM_DATE);
|
||||
$update->setParameter('meta_data', $metaData);
|
||||
$update->executeStatement();
|
||||
}
|
||||
$result->closeCursor();
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Developer;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\IConfig;
|
||||
use OCP\Server;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class UpdateDocs extends Base {
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
private IAppManager $appManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function isEnabled(): bool {
|
||||
return $this->config->getSystemValue('debug', false) === true;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:developer:update-docs')
|
||||
->setDescription('Update documentation of commands')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$info = $this->appManager->getAppInfo('spreed');
|
||||
$documentation = "# Talk occ commands\n\n";
|
||||
foreach ($info['commands'] as $namespace) {
|
||||
if ($namespace === self::class
|
||||
|| $namespace === AgeChatMessages::class) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$command = $this->getCommand($namespace);
|
||||
$documentation .= $this->getDocumentation($command) . "\n";
|
||||
}
|
||||
|
||||
$handle = fopen(__DIR__ . '/../../../docs/occ.md', 'w');
|
||||
fwrite($handle, $documentation);
|
||||
fclose($handle);
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function getCommand(string $namespace): Command {
|
||||
$command = Server::get($namespace);
|
||||
// Clean full definition of command that have the default Symfony options
|
||||
$command->setApplication($this->getApplication());
|
||||
return $command;
|
||||
}
|
||||
|
||||
protected function getDocumentation(Command $command): string {
|
||||
$doc = '## ' . $command->getName() . "\n\n";
|
||||
$doc .= $command->getDescription() . "\n\n";
|
||||
$doc
|
||||
.= '### Usage' . "\n\n"
|
||||
. array_reduce(
|
||||
array_merge(
|
||||
[$command->getSynopsis()],
|
||||
$command->getAliases(),
|
||||
$command->getUsages()
|
||||
),
|
||||
function ($carry, $usage) {
|
||||
return $carry . '* `' . $usage . '`' . "\n";
|
||||
}
|
||||
);
|
||||
$doc .= $this->describeInputDefinition($command);
|
||||
|
||||
return $doc;
|
||||
}
|
||||
|
||||
protected function describeInputDefinition(Command $command): string {
|
||||
$definition = $command->getDefinition();
|
||||
$text = '';
|
||||
if (\count($definition->getArguments()) > 0) {
|
||||
$text .= "\n";
|
||||
$text .= "| Arguments | Description | Is required | Is array | Default |\n";
|
||||
$text .= '|---|---|---|---|---|';
|
||||
foreach ($definition->getArguments() as $argument) {
|
||||
$describeInputArgument = $this->describeInputArgument($argument);
|
||||
if ($describeInputArgument) {
|
||||
$text .= "\n" . $describeInputArgument;
|
||||
}
|
||||
}
|
||||
$text .= "\n";
|
||||
}
|
||||
|
||||
if (\count($definition->getOptions()) > 0) {
|
||||
$text .= "\n";
|
||||
|
||||
$text .= "| Options | Description | Accept value | Is value required | Is multiple | Default |\n";
|
||||
$text .= '|---|---|---|---|---|---|';
|
||||
foreach ($definition->getOptions() as $option) {
|
||||
$describeInputOption = $this->describeInputOption($option);
|
||||
if ($describeInputOption) {
|
||||
$text .= "\n" . $describeInputOption;
|
||||
}
|
||||
}
|
||||
$text .= "\n";
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
protected function describeInputArgument(InputArgument $argument): string {
|
||||
$description = $argument->getDescription();
|
||||
|
||||
return
|
||||
'| `' . ($argument->getName() ?: '<none>') . '` | '
|
||||
. ($description ? preg_replace('/\s*[\r\n]\s*/', ' ', $description) : '') . ' | '
|
||||
. ($argument->isRequired() ? 'yes' : 'no') . ' | '
|
||||
. ($argument->isArray() ? 'yes' : 'no') . ' | '
|
||||
. ($argument->isRequired() ? '*Required*' : '`' . str_replace("\n", '', var_export($argument->getDefault(), true)) . '`') . ' |';
|
||||
}
|
||||
|
||||
protected function describeInputOption(InputOption $option): string {
|
||||
$name = '--' . $option->getName();
|
||||
if ($option->getShortcut()) {
|
||||
$name .= '\|-' . str_replace('|', '\|-', $option->getShortcut());
|
||||
}
|
||||
$description = $option->getDescription();
|
||||
|
||||
return
|
||||
'| `' . $name . '` | '
|
||||
. ($description ? preg_replace('/\s*[\r\n]\s*/', ' ', $description) : '') . ' | '
|
||||
. ($option->acceptValue() ? 'yes' : 'no') . ' | '
|
||||
. ($option->isValueRequired() ? 'yes' : 'no') . ' | '
|
||||
. ($option->isArray() ? 'yes' : 'no') . ' | '
|
||||
. ($option->isValueRequired() ? '*Required*' : '`' . str_replace("\n", '', var_export($option->getDefault(), true)) . '`') . ' |';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Monitor;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Participant;
|
||||
use OCP\IDBConnection;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Calls extends Base {
|
||||
|
||||
public function __construct(
|
||||
protected IDBConnection $connection,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
|
||||
$this
|
||||
->setName('talk:monitor:calls')
|
||||
->setDescription('Prints a list with conversations that have an active call as well as their participant count')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$query = $this->connection->getQueryBuilder();
|
||||
$subQuery = $this->connection->getQueryBuilder();
|
||||
$subQuery->select('attendee_id')
|
||||
->from('talk_sessions')
|
||||
->where($subQuery->expr()->gt('in_call', $query->createNamedParameter(Participant::FLAG_DISCONNECTED)))
|
||||
->andWhere($subQuery->expr()->gt('last_ping', $query->createNamedParameter(time() - 60)))
|
||||
->groupBy('attendee_id');
|
||||
|
||||
$query->select('r.token', $query->func()->count('*', 'num_attendees'))
|
||||
->from('talk_attendees', 'a')
|
||||
->leftJoin('a', 'talk_rooms', 'r', $query->expr()->eq('a.room_id', 'r.id'))
|
||||
->where($query->expr()->in('a.id', $query->createFunction($subQuery->getSQL())))
|
||||
->groupBy('r.token');
|
||||
|
||||
$data = [];
|
||||
$result = $query->executeQuery();
|
||||
while ($row = $result->fetch()) {
|
||||
$key = (string)$row['token'];
|
||||
if ($input->getOption('output') === Base::OUTPUT_FORMAT_PLAIN) {
|
||||
$key = '"' . $key . '"';
|
||||
}
|
||||
|
||||
$data[$key] = (int)$row['num_attendees'];
|
||||
}
|
||||
$result->closeCursor();
|
||||
|
||||
if ($input->getOption('output') === Base::OUTPUT_FORMAT_PLAIN) {
|
||||
$numCalls = count($data);
|
||||
$numParticipants = array_sum($data);
|
||||
|
||||
if (empty($data)) {
|
||||
$output->writeln('<info>No calls in progress</info>');
|
||||
} else {
|
||||
$output->writeln(sprintf('<error>There are currently %1$d calls in progress with %2$d participants</error>', $numCalls, $numParticipants));
|
||||
}
|
||||
}
|
||||
|
||||
$this->writeArrayInOutputFormat($input, $output, $data);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Monitor;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Participant;
|
||||
use OCP\IDBConnection;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class HasActiveCalls extends Base {
|
||||
|
||||
public function __construct(
|
||||
protected IDBConnection $connection,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
|
||||
$this
|
||||
->setName('talk:active-calls')
|
||||
->setDescription('Allows you to check if calls are currently in process')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$query = $this->connection->getQueryBuilder();
|
||||
|
||||
$query->select($query->func()->count('*', 'num_calls'))
|
||||
->from('talk_rooms')
|
||||
->where($query->expr()->isNotNull('active_since'));
|
||||
|
||||
$result = $query->executeQuery();
|
||||
$numCalls = (int)$result->fetchColumn();
|
||||
$result->closeCursor();
|
||||
|
||||
if ($numCalls === 0) {
|
||||
if ($input->getOption('output') === 'plain') {
|
||||
$output->writeln('<info>No calls in progress</info>');
|
||||
} else {
|
||||
$data = ['calls' => 0, 'participants' => 0];
|
||||
$this->writeArrayInOutputFormat($input, $output, $data);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
$query = $this->connection->getQueryBuilder();
|
||||
$query->select($query->func()->count('*', 'num_participants'))
|
||||
->from('talk_sessions')
|
||||
->where($query->expr()->gt('in_call', $query->createNamedParameter(Participant::FLAG_DISCONNECTED)))
|
||||
->andWhere($query->expr()->gt('last_ping', $query->createNamedParameter(time() - 60)));
|
||||
|
||||
$result = $query->executeQuery();
|
||||
$numParticipants = (int)$result->fetchColumn();
|
||||
$result->closeCursor();
|
||||
|
||||
|
||||
if ($input->getOption('output') === 'plain') {
|
||||
$output->writeln(sprintf('<error>There are currently %1$d calls in progress with %2$d participants</error>', $numCalls, $numParticipants));
|
||||
} else {
|
||||
$data = ['calls' => $numCalls, 'participants' => $numParticipants];
|
||||
$this->writeArrayInOutputFormat($input, $output, $data);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Monitor;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Participant;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Room extends Base {
|
||||
|
||||
public function __construct(
|
||||
protected IDBConnection $connection,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
|
||||
$this
|
||||
->setName('talk:monitor:room')
|
||||
->setDescription('Prints the number of attendees, active sessions and participant in the call.')
|
||||
->addArgument(
|
||||
'token',
|
||||
InputArgument::REQUIRED,
|
||||
'Token of the room to monitor'
|
||||
)
|
||||
->addOption(
|
||||
'separator',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Separator for the CSV list when output=csv is used',
|
||||
','
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$token = $input->getArgument('token');
|
||||
|
||||
$query = $this->connection->getQueryBuilder();
|
||||
$query->select('id')
|
||||
->from('talk_rooms')
|
||||
->where($query->expr()->eq('token', $query->createNamedParameter($token)));
|
||||
|
||||
$result = $query->executeQuery();
|
||||
$roomId = (int)$result->fetchOne();
|
||||
$result->closeCursor();
|
||||
|
||||
if ($roomId === 0) {
|
||||
if ($input->getOption('output') === Base::OUTPUT_FORMAT_PLAIN) {
|
||||
$output->writeln(sprintf('<error>Room with token %1$s not found</error>', $token));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
$query = $this->connection->getQueryBuilder();
|
||||
$query->select($query->func()->count('*', 'num_attendees'))
|
||||
->from('talk_attendees')
|
||||
->where($query->expr()->eq('room_id', $query->createNamedParameter($roomId, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
$result = $query->executeQuery();
|
||||
$numAttendees = (int)$result->fetchOne();
|
||||
$result->closeCursor();
|
||||
|
||||
$numSessions = $numSessionsInCall = 0;
|
||||
$query = $this->connection->getQueryBuilder();
|
||||
$query->select($query->func()->count('s.id', 'num_sessions'))
|
||||
->from('talk_sessions', 's')
|
||||
->leftJoin('s', 'talk_attendees', 'a', $query->expr()->eq('a.id', 's.attendee_id'))
|
||||
->where($query->expr()->eq('a.room_id', $query->createNamedParameter($roomId, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($query->expr()->gt('s.last_ping', $query->createNamedParameter(time() - 60, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
$result = $query->executeQuery();
|
||||
$numSessions = (int)$result->fetchOne();
|
||||
$result->closeCursor();
|
||||
|
||||
$query = $this->connection->getQueryBuilder();
|
||||
$query->select($query->func()->count('s.id', 'num_sessions'))
|
||||
->from('talk_sessions', 's')
|
||||
->leftJoin('s', 'talk_attendees', 'a', $query->expr()->eq('a.id', 's.attendee_id'))
|
||||
->where($query->expr()->eq('a.room_id', $query->createNamedParameter($roomId, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($query->expr()->gt('s.in_call', $query->createNamedParameter(Participant::FLAG_DISCONNECTED, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($query->expr()->gt('s.last_ping', $query->createNamedParameter(time() - 60, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
$result = $query->executeQuery();
|
||||
$numSessionsInCall = (int)$result->fetchOne();
|
||||
$result->closeCursor();
|
||||
|
||||
if ($input->getOption('output') === Base::OUTPUT_FORMAT_PLAIN) {
|
||||
$output->writeln(sprintf(
|
||||
'The conversation has %1$d attendees with %2$d sessions of which %3$d are in the call.',
|
||||
$numAttendees,
|
||||
$numSessions,
|
||||
$numSessionsInCall
|
||||
));
|
||||
return 0;
|
||||
}
|
||||
if ($input->getOption('output') === 'csv') {
|
||||
$separator = $input->getOption('separator');
|
||||
$output->writeln($numAttendees . $separator . $numSessions . $separator . $numSessionsInCall);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->writeArrayInOutputFormat($input, $output, [
|
||||
'attendees' => $numAttendees,
|
||||
'sessions' => $numSessions,
|
||||
'call' => $numSessionsInCall,
|
||||
]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\PhoneNumber;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Model\PhoneNumber;
|
||||
use OCA\Talk\Model\PhoneNumberMapper;
|
||||
use OCA\Talk\Service\PhoneNumberValidation;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class AddPhoneNumber extends Base {
|
||||
|
||||
public function __construct(
|
||||
private IUserManager $userManager,
|
||||
private PhoneNumberValidation $phoneNumberValidation,
|
||||
private PhoneNumberMapper $mapper,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:phone-number:add')
|
||||
->setDescription('Add a mapping entry to map a phone number to an user')
|
||||
->addArgument(
|
||||
'phone',
|
||||
InputArgument::REQUIRED,
|
||||
'Phone number that will be called',
|
||||
)
|
||||
->addArgument(
|
||||
'user',
|
||||
InputArgument::REQUIRED,
|
||||
'User to be added to the conversation',
|
||||
)
|
||||
->addOption(
|
||||
'force',
|
||||
'f',
|
||||
InputOption::VALUE_NONE,
|
||||
'Force the number to the given user even when it is assigned already',
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$phoneNumber = $input->getArgument('phone');
|
||||
$userId = $input->getArgument('user');
|
||||
$force = (bool)$input->getOption('force');
|
||||
|
||||
$user = $this->userManager->get($userId);
|
||||
if (!$user instanceof IUser) {
|
||||
$output->writeln('<error>Invalid user "' . $userId . '" provided</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$userId = $user->getUID();
|
||||
|
||||
try {
|
||||
$phoneNumber = $this->phoneNumberValidation->validateNumber($phoneNumber);
|
||||
} catch (\InvalidArgumentException) {
|
||||
$output->writeln('<error>Not a valid phone number ' . $phoneNumber . '. The format is invalid.</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$entry = $this->mapper->findByPhoneNumber($phoneNumber);
|
||||
} catch (DoesNotExistException) {
|
||||
$entry = null;
|
||||
}
|
||||
|
||||
if ($entry !== null) {
|
||||
$oldActor = $entry->getActorId();
|
||||
if (!$force) {
|
||||
$output->writeln('<error>Phone number is already assigned to ' . $oldActor . '</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$entry->setActorId($userId);
|
||||
$this->mapper->update($entry);
|
||||
|
||||
$output->writeln('<info>Phone number ' . $entry->getPhoneNumber() . ' is now assigned to ' . $entry->getActorId() . '</info>');
|
||||
$output->writeln('Was assigned to ' . $oldActor . ' before');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$entry = new PhoneNumber();
|
||||
$entry->setPhoneNumber($phoneNumber);
|
||||
$entry->setActorId($userId);
|
||||
$this->mapper->insert($entry);
|
||||
|
||||
$output->writeln('<info>Phone number ' . $entry->getPhoneNumber() . ' is now assigned to ' . $entry->getActorId() . '</info>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\PhoneNumber;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Model\PhoneNumberMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class FindPhoneNumber extends Base {
|
||||
|
||||
public function __construct(
|
||||
private PhoneNumberMapper $mapper,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:phone-number:find')
|
||||
->setDescription('Find a phone number or the phone number of an user')
|
||||
->addOption(
|
||||
'phone',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Phone number to search for',
|
||||
)
|
||||
->addOption(
|
||||
'user',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'User to get number(s) for',
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$phoneNumber = (string)$input->getOption('phone');
|
||||
$userId = (string)$input->getOption('user');
|
||||
|
||||
if ($phoneNumber !== '') {
|
||||
try {
|
||||
$entry = $this->mapper->findByPhoneNumber($phoneNumber);
|
||||
} catch (DoesNotExistException) {
|
||||
$output->writeln('<error>Phone number ' . $phoneNumber . ' could not be found</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$output->writeln('Phone number ' . $entry->getPhoneNumber() . ' is assigned to ' . $entry->getActorId());
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($userId === '') {
|
||||
$output->writeln('<error>Neither phone number nor user provided</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$entries = $this->mapper->findByUser($userId);
|
||||
if (empty($entries)) {
|
||||
$output->writeln('<error>No phone number found for ' . $userId . '</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (count($entries) === 1) {
|
||||
$entry = array_pop($entries);
|
||||
$output->writeln($entry->getActorId() . ' has phone number ' . $entry->getPhoneNumber() . ' assigned');
|
||||
} else {
|
||||
$output->writeln($userId . ' has the following phone numbers assigned:');
|
||||
foreach ($entries as $entry) {
|
||||
$output->writeln(' - ' . $entry->getPhoneNumber());
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\PhoneNumber;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Model\PhoneNumber;
|
||||
use OCA\Talk\Model\PhoneNumberMapper;
|
||||
use OCA\Talk\Service\PhoneNumberValidation;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class ImportPhoneNumbers extends Base {
|
||||
|
||||
public function __construct(
|
||||
private IUserManager $userManager,
|
||||
private PhoneNumberValidation $phoneNumberValidation,
|
||||
private PhoneNumberMapper $mapper,
|
||||
private IDBConnection $db,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:phone-number:import')
|
||||
->setDescription('Import a CSV list (format: "number","user") for SIP dial-in')
|
||||
->addOption(
|
||||
'reset',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Delete all phone numbers before importing',
|
||||
)
|
||||
->addOption(
|
||||
'force',
|
||||
'f',
|
||||
InputOption::VALUE_NONE,
|
||||
'Force the numbers to the given user even when they are assigned already',
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$reset = (bool)$input->getOption('reset');
|
||||
$force = (bool)$input->getOption('force');
|
||||
|
||||
$this->db->beginTransaction();
|
||||
if ($reset) {
|
||||
$this->db->truncateTable('talk_phone_numbers', false);
|
||||
$force = false;
|
||||
}
|
||||
|
||||
$handle = $this->getResourceFromStdin();
|
||||
if ($handle === false) {
|
||||
$output->writeln('<error>Invalid StdIn provided</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$map = [];
|
||||
while ($row = fgetcsv($handle, escape: '')) {
|
||||
if (count($row) !== 2 || $row[0] === '' || $row[1] === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$row[0] = $this->phoneNumberValidation->validateNumber($row[0]);
|
||||
} catch (\InvalidArgumentException) {
|
||||
$output->writeln('<error>Not a valid phone number ' . $row[0] . '. The format is invalid.</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$user = $this->userManager->get($row[1]);
|
||||
if (!$user instanceof IUser) {
|
||||
$output->writeln('<error>Invalid user "' . $row[1] . '" provided</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$row[1] = $user->getUID();
|
||||
|
||||
$map[$row[0]] = $row[1];
|
||||
}
|
||||
|
||||
$entries = $this->mapper->findByPhoneNumbers(array_keys($map));
|
||||
|
||||
if (!$force && !empty($entries)) {
|
||||
$output->writeln('<error>Phone number already assigned:</error>');
|
||||
foreach ($entries as $entry) {
|
||||
$output->writeln(' - ' . $entry->getPhoneNumber());
|
||||
}
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
foreach ($map as $phoneNumber => $userId) {
|
||||
$entry = new PhoneNumber();
|
||||
$entry->setPhoneNumber($phoneNumber);
|
||||
$entry->setActorId($userId);
|
||||
$this->mapper->insert($entry);
|
||||
|
||||
$output->writeln('<info>Phone number ' . $entry->getPhoneNumber() . ' is now assigned to ' . $entry->getActorId() . '</info>');
|
||||
}
|
||||
|
||||
$this->db->commit();
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resource from stdin ("talk:phone-numbers:import < file.csv")
|
||||
* @return resource|false
|
||||
*/
|
||||
protected function getResourceFromStdin() {
|
||||
return fopen('php://stdin', 'rb');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\PhoneNumber;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Model\PhoneNumberMapper;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class RemovePhoneNumber extends Base {
|
||||
|
||||
public function __construct(
|
||||
private PhoneNumberMapper $mapper,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:phone-number:remove')
|
||||
->setDescription('Remove a mapping entry by phone number')
|
||||
->addArgument(
|
||||
'phone',
|
||||
InputArgument::REQUIRED,
|
||||
'Phone number to remove the mapping entry for',
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$phoneNumber = $input->getArgument('phone');
|
||||
$this->mapper->deleteByPhoneNumber($phoneNumber);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\PhoneNumber;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Model\PhoneNumberMapper;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class RemoveUser extends Base {
|
||||
|
||||
public function __construct(
|
||||
private PhoneNumberMapper $mapper,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:phone-number:remove-user')
|
||||
->setDescription('Remove mapping entries by user')
|
||||
->addArgument(
|
||||
'user',
|
||||
InputArgument::REQUIRED,
|
||||
'User to remove all mapping entries for',
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$userId = $input->getArgument('user');
|
||||
$this->mapper->deleteByUser($userId);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Recording;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\ConsentService;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Consent extends Base {
|
||||
|
||||
public function __construct(
|
||||
protected Manager $roomManager,
|
||||
protected ConsentService $consentService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
$this
|
||||
->setName('talk:recording:consent')
|
||||
->setDescription('List all matching consent that were given to be audio and video recorded during a call (requires administrator or moderator configuration)')
|
||||
->addOption(
|
||||
'token',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Limit to the given conversation'
|
||||
)
|
||||
->addOption(
|
||||
'actor-type',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Limit to the given actor (only valid when --actor-id is also provided)'
|
||||
)
|
||||
->addOption(
|
||||
'actor-id',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Limit to the given actor (only valid when --actor-type is also provided)'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$token = $input->getOption('token');
|
||||
$actorType = $input->getOption('actor-type');
|
||||
$actorId = $input->getOption('actor-id');
|
||||
if (($actorType !== null) !== ($actorId !== null)) {
|
||||
$output->writeln('<error>actor-type and actor-id must either both be specified or both left out</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$room = null;
|
||||
if ($token !== null) {
|
||||
try {
|
||||
$room = $this->roomManager->getRoomByToken($token);
|
||||
} catch (RoomNotFoundException) {
|
||||
$output->writeln('<error>Conversation could not be found by token</error>');
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
if ($actorType) {
|
||||
if ($room instanceof Room) {
|
||||
$consentData = $this->consentService->getConsentForRoomByActor($room, $actorType, $actorId);
|
||||
} else {
|
||||
$consentData = $this->consentService->getConsentForActor($actorType, $actorId);
|
||||
}
|
||||
} elseif ($room instanceof Room) {
|
||||
$consentData = $this->consentService->getConsentForRoom($room);
|
||||
} else {
|
||||
$output->writeln('<error>No conversation or actor provided</error>');
|
||||
return 3;
|
||||
}
|
||||
|
||||
$this->writeTableInOutputFormat(
|
||||
$input,
|
||||
$output,
|
||||
array_map(static fn (\OCA\Talk\Model\Consent $consent) => $consent->jsonSerialize(), $consentData)
|
||||
);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Room;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Room;
|
||||
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Add extends Base {
|
||||
use TRoomCommand;
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:room:add')
|
||||
->setDescription('Adds users to a room')
|
||||
->addArgument(
|
||||
'token',
|
||||
InputArgument::REQUIRED,
|
||||
'Token of the room to add users to'
|
||||
)->addOption(
|
||||
'user',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
'Invites the given users to the room'
|
||||
)->addOption(
|
||||
'group',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
'Invites all members of the given groups to the room'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$token = $input->getArgument('token');
|
||||
$users = $input->getOption('user');
|
||||
$groups = $input->getOption('group');
|
||||
|
||||
try {
|
||||
$room = $this->manager->getRoomByToken($token);
|
||||
} catch (RoomNotFoundException $e) {
|
||||
$output->writeln('<error>Room not found.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($room->isFederatedConversation()) {
|
||||
$output->writeln('<error>Room is a federated conversation.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (in_array($room->getType(), [Room::TYPE_ONE_TO_ONE, Room::TYPE_ONE_TO_ONE_FORMER], true)) {
|
||||
$output->writeln('<error>Room is a private (1 to 1) conversation.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->addRoomParticipants($room, $users);
|
||||
$this->addRoomParticipantsByGroup($room, $groups);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
|
||||
return 1;
|
||||
}
|
||||
|
||||
$output->writeln('<info>Users successfully added to room.</info>');
|
||||
return 0;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function completeOptionValues($optionName, CompletionContext $context) {
|
||||
switch ($optionName) {
|
||||
case 'user':
|
||||
return $this->completeUserValues($context);
|
||||
|
||||
case 'group':
|
||||
return $this->completeGroupValues($context);
|
||||
}
|
||||
|
||||
return parent::completeOptionValues($optionName, $context);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function completeArgumentValues($argumentName, CompletionContext $context) {
|
||||
switch ($argumentName) {
|
||||
case 'token':
|
||||
return $this->completeTokenValues($context);
|
||||
}
|
||||
|
||||
return parent::completeArgumentValues($argumentName, $context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Room;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Room;
|
||||
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Create extends Base {
|
||||
use TRoomCommand;
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:room:create')
|
||||
->setDescription('Create a new room')
|
||||
->addArgument(
|
||||
'name',
|
||||
InputArgument::REQUIRED,
|
||||
'The name of the room to create'
|
||||
)->addOption(
|
||||
'description',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The description of the room to create'
|
||||
)->addOption(
|
||||
'user',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
'Invites the given users to the room to create'
|
||||
)->addOption(
|
||||
'group',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
'Invites all members of the given group to the room to create'
|
||||
)->addOption(
|
||||
'public',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Creates the room as public room if set'
|
||||
)->addOption(
|
||||
'readonly',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Creates the room with read-only access only if set'
|
||||
)->addOption(
|
||||
'listable',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Creates the room with the given listable scope'
|
||||
)->addOption(
|
||||
'password',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Protects the room to create with the given password'
|
||||
)->addOption(
|
||||
'owner',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Sets the given user as owner of the room to create'
|
||||
)->addOption(
|
||||
'moderator',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
'Promotes the given users to moderators'
|
||||
)->addOption(
|
||||
'message-expiration',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Seconds to expire a message after sent. If zero will disable the expire message duration.'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$name = $input->getArgument('name');
|
||||
$description = $input->getOption('description');
|
||||
$users = $input->getOption('user');
|
||||
$groups = $input->getOption('group');
|
||||
$public = $input->getOption('public');
|
||||
$readonly = $input->getOption('readonly');
|
||||
$listable = $input->getOption('listable');
|
||||
$password = $input->getOption('password');
|
||||
$owner = $input->getOption('owner');
|
||||
$moderators = $input->getOption('moderator');
|
||||
$messageExpiration = $input->getOption('message-expiration');
|
||||
|
||||
if (!in_array($listable, [
|
||||
null,
|
||||
(string)Room::LISTABLE_NONE,
|
||||
(string)Room::LISTABLE_USERS,
|
||||
(string)Room::LISTABLE_ALL,
|
||||
], true)) {
|
||||
$output->writeln('<error>Invalid value for option "--listable" given.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$roomType = $public ? Room::TYPE_PUBLIC : Room::TYPE_GROUP;
|
||||
try {
|
||||
$room = $this->roomService->createConversation($roomType, $name);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
if ($e->getMessage() === 'name') {
|
||||
$output->writeln('<error>Invalid room name.</error>');
|
||||
return 1;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($description !== null) {
|
||||
$this->setRoomDescription($room, $description);
|
||||
}
|
||||
|
||||
$this->setRoomReadOnly($room, $readonly);
|
||||
$this->setRoomListable($room, (int)$listable);
|
||||
|
||||
if ($password !== null) {
|
||||
$this->setRoomPassword($room, $password);
|
||||
}
|
||||
|
||||
$this->addRoomParticipants($room, $users);
|
||||
$this->addRoomParticipantsByGroup($room, $groups);
|
||||
$this->addRoomModerators($room, $moderators);
|
||||
|
||||
if ($owner !== null) {
|
||||
$this->setRoomOwner($room, $owner);
|
||||
}
|
||||
|
||||
if ($messageExpiration !== null) {
|
||||
$this->setMessageExpiration($room, (int)$messageExpiration);
|
||||
}
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->roomService->deleteRoom($room);
|
||||
|
||||
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
|
||||
return 1;
|
||||
}
|
||||
$output->writeln('Room token: ' . $room->getToken());
|
||||
|
||||
$output->writeln('<info>Room successfully created.</info>');
|
||||
return 0;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function completeOptionValues($optionName, CompletionContext $context) {
|
||||
switch ($optionName) {
|
||||
case 'user':
|
||||
return $this->completeUserValues($context);
|
||||
|
||||
case 'group':
|
||||
return $this->completeGroupValues($context);
|
||||
|
||||
case 'owner':
|
||||
case 'moderator':
|
||||
return $this->completeParticipantValues($context);
|
||||
case 'readonly':
|
||||
return [(string)Room::READ_ONLY, (string)Room::READ_WRITE];
|
||||
case 'listable':
|
||||
return [
|
||||
(string)Room::LISTABLE_ALL,
|
||||
(string)Room::LISTABLE_USERS,
|
||||
(string)Room::LISTABLE_NONE,
|
||||
];
|
||||
}
|
||||
|
||||
return parent::completeOptionValues($optionName, $context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Room;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Room;
|
||||
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Delete extends Base {
|
||||
use TRoomCommand;
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:room:delete')
|
||||
->setDescription('Deletes a room')
|
||||
->addArgument(
|
||||
'token',
|
||||
InputArgument::REQUIRED,
|
||||
'Token of the room to delete'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$token = $input->getArgument('token');
|
||||
|
||||
try {
|
||||
$room = $this->manager->getRoomByToken($token);
|
||||
} catch (RoomNotFoundException $e) {
|
||||
$output->writeln('<error>Room not found.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($room->isFederatedConversation()) {
|
||||
$output->writeln('<error>Room is a federated conversation.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (in_array($room->getType(), [Room::TYPE_ONE_TO_ONE], true)) {
|
||||
$output->writeln('<error>Room is a private (1 to 1) conversation.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->roomService->deleteRoom($room);
|
||||
|
||||
$output->writeln('<info>Room successfully deleted.</info>');
|
||||
return 0;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function completeArgumentValues($argumentName, CompletionContext $context) {
|
||||
switch ($argumentName) {
|
||||
case 'token':
|
||||
return $this->completeTokenValues($context);
|
||||
}
|
||||
|
||||
return parent::completeArgumentValues($argumentName, $context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Room;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Room;
|
||||
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Demote extends Base {
|
||||
use TRoomCommand;
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:room:demote')
|
||||
->setDescription('Demotes participants of a room to regular users')
|
||||
->addArgument(
|
||||
'token',
|
||||
InputArgument::REQUIRED,
|
||||
'Token of the room in which users should be demoted'
|
||||
)->addArgument(
|
||||
'participant',
|
||||
InputArgument::REQUIRED | InputArgument::IS_ARRAY,
|
||||
'Demotes the given participants of the room to regular users'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$token = $input->getArgument('token');
|
||||
$users = $input->getArgument('participant');
|
||||
|
||||
try {
|
||||
$room = $this->manager->getRoomByToken($token);
|
||||
} catch (RoomNotFoundException $e) {
|
||||
$output->writeln('<error>Room not found.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($room->isFederatedConversation()) {
|
||||
$output->writeln('<error>Room is a federated conversation.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (in_array($room->getType(), [Room::TYPE_ONE_TO_ONE, Room::TYPE_ONE_TO_ONE_FORMER], true)) {
|
||||
$output->writeln('<error>Room is a private (1 to 1) conversation.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->removeRoomModerators($room, $users);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
|
||||
return 1;
|
||||
}
|
||||
|
||||
$output->writeln('<info>Participants successfully demoted to regular users.</info>');
|
||||
return 0;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function completeArgumentValues($argumentName, CompletionContext $context) {
|
||||
switch ($argumentName) {
|
||||
case 'token':
|
||||
return $this->completeTokenValues($context);
|
||||
|
||||
case 'participant':
|
||||
return $this->completeParticipantValues($context);
|
||||
}
|
||||
|
||||
return parent::completeArgumentValues($argumentName, $context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Room;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Room;
|
||||
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Promote extends Base {
|
||||
use TRoomCommand;
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:room:promote')
|
||||
->setDescription('Promotes participants of a room to moderators')
|
||||
->addArgument(
|
||||
'token',
|
||||
InputArgument::REQUIRED,
|
||||
'Token of the room in which users should be promoted'
|
||||
)->addArgument(
|
||||
'participant',
|
||||
InputArgument::REQUIRED | InputArgument::IS_ARRAY,
|
||||
'Promotes the given participants of the room to moderators'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$token = $input->getArgument('token');
|
||||
$users = $input->getArgument('participant');
|
||||
|
||||
try {
|
||||
$room = $this->manager->getRoomByToken($token);
|
||||
} catch (RoomNotFoundException $e) {
|
||||
$output->writeln('<error>Room not found.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($room->isFederatedConversation()) {
|
||||
$output->writeln('<error>Room is a federated conversation.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (in_array($room->getType(), [Room::TYPE_ONE_TO_ONE, Room::TYPE_ONE_TO_ONE_FORMER], true)) {
|
||||
$output->writeln('<error>Room is a private (1 to 1) conversation.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->addRoomModerators($room, $users);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
|
||||
return 1;
|
||||
}
|
||||
|
||||
$output->writeln('<info>Participants successfully promoted to moderators.</info>');
|
||||
return 0;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function completeArgumentValues($argumentName, CompletionContext $context) {
|
||||
switch ($argumentName) {
|
||||
case 'token':
|
||||
return $this->completeTokenValues($context);
|
||||
|
||||
case 'participant':
|
||||
return $this->completeParticipantValues($context);
|
||||
}
|
||||
|
||||
return parent::completeArgumentValues($argumentName, $context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Room;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Room;
|
||||
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Remove extends Base {
|
||||
use TRoomCommand;
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:room:remove')
|
||||
->setDescription('Remove users from a room')
|
||||
->addArgument(
|
||||
'token',
|
||||
InputArgument::REQUIRED,
|
||||
'Token of the room to remove users from'
|
||||
)->addArgument(
|
||||
'participant',
|
||||
InputArgument::REQUIRED | InputArgument::IS_ARRAY,
|
||||
'Removes the given participants from the room'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$token = $input->getArgument('token');
|
||||
$users = $input->getArgument('participant');
|
||||
|
||||
try {
|
||||
$room = $this->manager->getRoomByToken($token);
|
||||
} catch (RoomNotFoundException $e) {
|
||||
$output->writeln('<error>Room not found.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($room->isFederatedConversation()) {
|
||||
$output->writeln('<error>Room is a federated conversation.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (in_array($room->getType(), [Room::TYPE_ONE_TO_ONE, Room::TYPE_ONE_TO_ONE_FORMER], true)) {
|
||||
$output->writeln('<error>Room is a private (1 to 1) conversation.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->removeRoomParticipants($room, $users);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
|
||||
return 1;
|
||||
}
|
||||
|
||||
$output->writeln('<info>Users successfully removed from room.</info>');
|
||||
return 0;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function completeArgumentValues($argumentName, CompletionContext $context) {
|
||||
switch ($argumentName) {
|
||||
case 'token':
|
||||
return $this->completeTokenValues($context);
|
||||
|
||||
case 'participant':
|
||||
return $this->completeParticipantValues($context);
|
||||
}
|
||||
|
||||
return parent::completeArgumentValues($argumentName, $context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Room;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use OCA\Talk\Events\AAttendeeRemovedEvent;
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Exceptions\RoomProperty\DescriptionException;
|
||||
use OCA\Talk\Exceptions\RoomProperty\ListableException;
|
||||
use OCA\Talk\Exceptions\RoomProperty\MessageExpirationException;
|
||||
use OCA\Talk\Exceptions\RoomProperty\NameException;
|
||||
use OCA\Talk\Exceptions\RoomProperty\PasswordException;
|
||||
use OCA\Talk\Exceptions\RoomProperty\ReadOnlyException;
|
||||
use OCA\Talk\Exceptions\RoomProperty\TypeException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\MatterbridgeManager;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\RoomService;
|
||||
use OCP\IGroup;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
|
||||
trait TRoomCommand {
|
||||
|
||||
public function __construct(
|
||||
protected Manager $manager,
|
||||
protected RoomService $roomService,
|
||||
protected ParticipantService $participantService,
|
||||
protected IUserManager $userManager,
|
||||
protected IGroupManager $groupManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param string $name
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function setRoomName(Room $room, string $name): void {
|
||||
$name = trim($name);
|
||||
if ($name === $room->getName()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->validateRoomName($name)) {
|
||||
throw new InvalidArgumentException('Invalid room name.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->roomService->setName($room, $name);
|
||||
} catch (NameException) {
|
||||
throw new InvalidArgumentException('Unable to change room name.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function validateRoomName(string $name): bool {
|
||||
$name = trim($name);
|
||||
return (($name !== '') && !isset($name[255]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param string $description
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function setRoomDescription(Room $room, string $description): void {
|
||||
try {
|
||||
$this->roomService->setDescription($room, $description);
|
||||
} catch (DescriptionException $e) {
|
||||
throw new InvalidArgumentException('Invalid room description.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param bool $public
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function setRoomPublic(Room $room, bool $public): void {
|
||||
if ($public === ($room->getType() === Room::TYPE_PUBLIC)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->roomService->setType($room, $public ? Room::TYPE_PUBLIC : Room::TYPE_GROUP);
|
||||
} catch (TypeException) {
|
||||
throw new InvalidArgumentException('Unable to change room type.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param bool $readOnly
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function setRoomReadOnly(Room $room, bool $readOnly): void {
|
||||
if ($readOnly === ($room->getReadOnly() === Room::READ_ONLY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->roomService->setReadOnly($room, $readOnly ? Room::READ_ONLY : Room::READ_WRITE);
|
||||
} catch (ReadOnlyException) {
|
||||
throw new InvalidArgumentException('Unable to change room state.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param int $listable
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function setRoomListable(Room $room, int $listable): void {
|
||||
if ($room->getListable() === $listable) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->roomService->setListable($room, $listable);
|
||||
} catch (ListableException) {
|
||||
throw new InvalidArgumentException('Unable to change room state.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param string $password
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function setRoomPassword(Room $room, string $password): void {
|
||||
if ($room->hasPassword() ? $this->roomService->verifyPassword($room, $password)['result'] : ($password === '')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (($password !== '') && ($room->getType() !== Room::TYPE_PUBLIC)) {
|
||||
throw new InvalidArgumentException('Unable to add password protection to private room.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->roomService->setPassword($room, $password);
|
||||
} catch (PasswordException $e) {
|
||||
if ($e->getReason() === PasswordException::REASON_VALUE) {
|
||||
throw new InvalidArgumentException($e->getHint());
|
||||
}
|
||||
throw new InvalidArgumentException('Unable to change room password.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param string $userId
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function setRoomOwner(Room $room, string $userId): void {
|
||||
try {
|
||||
$participant = $this->participantService->getParticipant($room, $userId, false);
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
throw new InvalidArgumentException(sprintf("User '%s' is no participant.", $userId));
|
||||
}
|
||||
|
||||
if ($userId === MatterbridgeManager::BRIDGE_BOT_USERID) {
|
||||
throw new InvalidArgumentException('Can not promote the bridge-bot user.');
|
||||
}
|
||||
|
||||
$this->unsetRoomOwner($room);
|
||||
|
||||
$this->participantService->updateParticipantType($room, $participant, Participant::OWNER);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function unsetRoomOwner(Room $room): void {
|
||||
$participants = $this->participantService->getParticipantsForRoom($room);
|
||||
foreach ($participants as $participant) {
|
||||
if ($participant->getAttendee()->getParticipantType() === Participant::OWNER) {
|
||||
$this->participantService->updateParticipantType($room, $participant, Participant::USER);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param string[] $groupIds
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function addRoomParticipantsByGroup(Room $room, array $groupIds): void {
|
||||
if (!$groupIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($groupIds as $groupId) {
|
||||
$group = $this->groupManager->get($groupId);
|
||||
if ($group === null) {
|
||||
throw new InvalidArgumentException(sprintf("Group '%s' not found.", $groupId));
|
||||
}
|
||||
|
||||
$this->participantService->addGroup($room, $group);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param string[] $userIds
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function addRoomParticipants(Room $room, array $userIds): void {
|
||||
if (!$userIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var array<string, array{actorType: string, actorId: string, displayName: string}> $participants */
|
||||
$participants = [];
|
||||
foreach ($userIds as $userId) {
|
||||
if ($userId === MatterbridgeManager::BRIDGE_BOT_USERID) {
|
||||
throw new InvalidArgumentException('Can not add the bridge-bot user.');
|
||||
}
|
||||
|
||||
$user = $this->userManager->get($userId);
|
||||
if ($user === null) {
|
||||
throw new InvalidArgumentException(sprintf("User '%s' not found.", $userId));
|
||||
}
|
||||
|
||||
if (isset($participants[$user->getUID()])) {
|
||||
// nothing to do, user is going to be a participant already
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->participantService->getParticipant($room, $user->getUID(), false);
|
||||
|
||||
// nothing to do, user is a participant already
|
||||
continue;
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
// we expect the user not to be a participant yet
|
||||
}
|
||||
|
||||
$participants[$user->getUID()] = [
|
||||
'actorType' => Attendee::ACTOR_USERS,
|
||||
'actorId' => $user->getUID(),
|
||||
'displayName' => $user->getDisplayName(),
|
||||
];
|
||||
}
|
||||
|
||||
$this->participantService->addUsers($room, $participants);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param string[] $userIds
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function removeRoomParticipants(Room $room, array $userIds): void {
|
||||
$users = [];
|
||||
foreach ($userIds as $userId) {
|
||||
try {
|
||||
$this->participantService->getParticipant($room, $userId, false);
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
throw new InvalidArgumentException(sprintf("User '%s' is no participant.", $userId));
|
||||
}
|
||||
|
||||
$users[] = $this->userManager->get($userId);
|
||||
}
|
||||
|
||||
foreach ($users as $user) {
|
||||
$this->participantService->removeUser($room, $user, AAttendeeRemovedEvent::REASON_REMOVED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param string[] $userIds
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function addRoomModerators(Room $room, array $userIds): void {
|
||||
$participants = [];
|
||||
foreach ($userIds as $userId) {
|
||||
if ($userId === MatterbridgeManager::BRIDGE_BOT_USERID) {
|
||||
throw new InvalidArgumentException('Can not promote the bridge-bot user.');
|
||||
}
|
||||
|
||||
try {
|
||||
$participant = $this->participantService->getParticipant($room, $userId, false);
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
throw new InvalidArgumentException(sprintf("User '%s' is no participant.", $userId));
|
||||
}
|
||||
|
||||
if ($participant->getAttendee()->getParticipantType() !== Participant::OWNER) {
|
||||
$participants[] = $participant;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($participants as $participant) {
|
||||
$this->participantService->updateParticipantType($room, $participant, Participant::MODERATOR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param string[] $userIds
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function removeRoomModerators(Room $room, array $userIds): void {
|
||||
$participants = [];
|
||||
foreach ($userIds as $userId) {
|
||||
try {
|
||||
$participant = $this->participantService->getParticipant($room, $userId, false);
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
throw new InvalidArgumentException(sprintf("User '%s' is no participant.", $userId));
|
||||
}
|
||||
|
||||
if ($participant->getAttendee()->getParticipantType() === Participant::MODERATOR) {
|
||||
$participants[] = $participant;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($participants as $participant) {
|
||||
$this->participantService->updateParticipantType($room, $participant, Participant::USER);
|
||||
}
|
||||
}
|
||||
|
||||
protected function completeTokenValues(CompletionContext $context): array {
|
||||
return array_map(function (Room $room) {
|
||||
return $room->getToken();
|
||||
}, $this->manager->searchRoomsByToken($context->getCurrentWord()));
|
||||
}
|
||||
|
||||
protected function completeUserValues(CompletionContext $context): array {
|
||||
return array_map(function (IUser $user) {
|
||||
if ($user->getUID() === MatterbridgeManager::BRIDGE_BOT_USERID) {
|
||||
return '';
|
||||
}
|
||||
return $user->getUID();
|
||||
}, $this->userManager->search($context->getCurrentWord()));
|
||||
}
|
||||
|
||||
protected function completeGroupValues(CompletionContext $context): array {
|
||||
return array_map(function (IGroup $group) {
|
||||
return $group->getGID();
|
||||
}, $this->groupManager->search($context->getCurrentWord()));
|
||||
}
|
||||
|
||||
protected function completeParticipantValues(CompletionContext $context): array {
|
||||
$definition = new InputDefinition();
|
||||
|
||||
if ($this->getApplication() !== null) {
|
||||
$definition->addArguments($this->getApplication()->getDefinition()->getArguments());
|
||||
$definition->addOptions($this->getApplication()->getDefinition()->getOptions());
|
||||
}
|
||||
|
||||
$definition->addArguments($this->getDefinition()->getArguments());
|
||||
$definition->addOptions($this->getDefinition()->getOptions());
|
||||
|
||||
$input = new ArgvInput($context->getWords(), $definition);
|
||||
if ($input->hasArgument('token')) {
|
||||
$token = $input->getArgument('token');
|
||||
} elseif ($input->hasOption('token')) {
|
||||
$token = $input->getOption('token');
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$room = $this->manager->getRoomByToken($token);
|
||||
} catch (RoomNotFoundException $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_filter($this->participantService->getParticipantUserIds($room), static function ($userId) use ($context) {
|
||||
return stripos($userId, $context->getCurrentWord()) !== false;
|
||||
});
|
||||
}
|
||||
|
||||
protected function setMessageExpiration(Room $room, int $seconds): void {
|
||||
try {
|
||||
$this->roomService->setMessageExpiration($room, $seconds);
|
||||
} catch (MessageExpirationException) {
|
||||
throw new InvalidArgumentException('Unable to change message expiration.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Room;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Room;
|
||||
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Update extends Base {
|
||||
use TRoomCommand;
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:room:update')
|
||||
->setDescription('Updates a room')
|
||||
->addArgument(
|
||||
'token',
|
||||
InputArgument::REQUIRED,
|
||||
'The token of the room to update'
|
||||
)->addOption(
|
||||
'name',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Sets a new name for the room'
|
||||
)->addOption(
|
||||
'description',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Sets a new description for the room'
|
||||
)->addOption(
|
||||
'public',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Modifies the room to be a public room (value 1) or private room (value 0)'
|
||||
)->addOption(
|
||||
'readonly',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Modifies the room to be read-only (value 1) or read-write (value 0)'
|
||||
)->addOption(
|
||||
'listable',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Modifies the room\'s listable scope'
|
||||
)->addOption(
|
||||
'password',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Sets a new password for the room; pass an empty value to remove password protection'
|
||||
)->addOption(
|
||||
'owner',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Sets the given user as owner of the room; pass an empty value to remove the owner'
|
||||
)->addOption(
|
||||
'message-expiration',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Seconds to expire a message after sent. If zero will disable the expire message duration.'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$token = $input->getArgument('token');
|
||||
$name = $input->getOption('name');
|
||||
$description = $input->getOption('description');
|
||||
$public = $input->getOption('public');
|
||||
$readOnly = $input->getOption('readonly');
|
||||
$listable = $input->getOption('listable');
|
||||
$password = $input->getOption('password');
|
||||
$owner = $input->getOption('owner');
|
||||
$messageExpiration = $input->getOption('message-expiration');
|
||||
|
||||
if (!in_array($public, [null, '0', '1'], true)) {
|
||||
$output->writeln('<error>Invalid value for option "--public" given.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!in_array($readOnly, [null, (string)Room::READ_WRITE, (string)Room::READ_ONLY], true)) {
|
||||
$output->writeln('<error>Invalid value for option "--readonly" given.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!in_array($listable, [
|
||||
null,
|
||||
(string)Room::LISTABLE_NONE,
|
||||
(string)Room::LISTABLE_USERS,
|
||||
(string)Room::LISTABLE_ALL,
|
||||
], true)) {
|
||||
$output->writeln('<error>Invalid value for option "--listable" given.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$room = $this->manager->getRoomByToken($token);
|
||||
} catch (RoomNotFoundException $e) {
|
||||
$output->writeln('<error>Room not found.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($room->isFederatedConversation()) {
|
||||
$output->writeln('<error>Room is a federated conversation.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (in_array($room->getType(), [Room::TYPE_ONE_TO_ONE, Room::TYPE_ONE_TO_ONE_FORMER], true)) {
|
||||
$output->writeln('<error>Room is a private (1 to 1) conversation.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($name !== null) {
|
||||
$this->setRoomName($room, $name);
|
||||
}
|
||||
|
||||
if ($description !== null) {
|
||||
$this->setRoomDescription($room, $description);
|
||||
}
|
||||
|
||||
if ($public !== null) {
|
||||
$this->setRoomPublic($room, ($public === '1'));
|
||||
}
|
||||
|
||||
if ($readOnly !== null) {
|
||||
$this->setRoomReadOnly($room, ($readOnly === '1'));
|
||||
}
|
||||
|
||||
if ($listable !== null) {
|
||||
$this->setRoomListable($room, (int)$listable);
|
||||
}
|
||||
|
||||
if ($password !== null) {
|
||||
$this->setRoomPassword($room, $password);
|
||||
}
|
||||
|
||||
if ($owner !== null) {
|
||||
if ($owner !== '') {
|
||||
$this->setRoomOwner($room, $owner);
|
||||
} else {
|
||||
$this->unsetRoomOwner($room);
|
||||
}
|
||||
}
|
||||
|
||||
if ($messageExpiration !== null) {
|
||||
$this->setMessageExpiration($room, (int)$messageExpiration);
|
||||
}
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
|
||||
return 1;
|
||||
}
|
||||
|
||||
$output->writeln('<info>Room successfully updated.</info>');
|
||||
return 0;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function completeOptionValues($optionName, CompletionContext $context) {
|
||||
switch ($optionName) {
|
||||
case 'public':
|
||||
case 'readonly':
|
||||
return [(string)Room::READ_ONLY, (string)Room::READ_WRITE];
|
||||
case 'listable':
|
||||
return [
|
||||
(string)Room::LISTABLE_ALL,
|
||||
(string)Room::LISTABLE_USERS,
|
||||
(string)Room::LISTABLE_NONE,
|
||||
];
|
||||
|
||||
case 'owner':
|
||||
return $this->completeParticipantValues($context);
|
||||
}
|
||||
|
||||
return parent::completeOptionValues($optionName, $context);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function completeArgumentValues($argumentName, CompletionContext $context) {
|
||||
switch ($argumentName) {
|
||||
case 'token':
|
||||
return $this->completeTokenValues($context);
|
||||
}
|
||||
|
||||
return parent::completeArgumentValues($argumentName, $context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Signaling;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCP\IConfig;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Add extends Base {
|
||||
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:signaling:add')
|
||||
->setDescription('Add an external signaling server.')
|
||||
->addArgument(
|
||||
'server',
|
||||
InputArgument::REQUIRED,
|
||||
'A server string, ex. wss://signaling.example.org'
|
||||
)->addArgument(
|
||||
'secret',
|
||||
InputArgument::REQUIRED,
|
||||
'A shared secret string.'
|
||||
)->addOption(
|
||||
'verify',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Validate SSL certificate if set.'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$server = $input->getArgument('server');
|
||||
$secret = $input->getArgument('secret');
|
||||
$verify = $input->getOption('verify');
|
||||
|
||||
// quick validation, similar to signaling-server.js
|
||||
if (trim($server) === '') {
|
||||
$output->writeln('<error>Server cannot be empty.</error>');
|
||||
return 1;
|
||||
}
|
||||
if (trim($secret) === '') {
|
||||
$output->writeln('<error>Secret cannot be empty.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$config = $this->config->getAppValue('spreed', 'signaling_servers');
|
||||
|
||||
$signaling = json_decode($config, true);
|
||||
if ($signaling === null || empty($signaling) || !is_array($signaling)) {
|
||||
$servers = [];
|
||||
} else {
|
||||
$servers = is_array($signaling['servers']) ? $signaling['servers'] : [];
|
||||
}
|
||||
$servers[] = [
|
||||
'server' => $server,
|
||||
'verify' => $verify,
|
||||
];
|
||||
$signaling = [
|
||||
'servers' => $servers,
|
||||
'secret' => $secret,
|
||||
];
|
||||
|
||||
$this->config->setAppValue('spreed', 'signaling_servers', json_encode($signaling));
|
||||
$output->writeln('<info>Added signaling server ' . $server . '.</info>');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Signaling;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCP\IConfig;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Delete extends Base {
|
||||
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:signaling:delete')
|
||||
->setDescription('Remove an existing signaling server.')
|
||||
->addArgument(
|
||||
'server',
|
||||
InputArgument::REQUIRED,
|
||||
'An external signaling server string, ex. wss://signaling.example.org'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$server = $input->getArgument('server');
|
||||
|
||||
$config = $this->config->getAppValue('spreed', 'signaling_servers');
|
||||
$signaling = json_decode($config, true);
|
||||
if ($signaling === null || empty($signaling) || !is_array($signaling)) {
|
||||
$signaling = [
|
||||
'servers' => [],
|
||||
'secret' => '',
|
||||
];
|
||||
}
|
||||
$count = count($signaling['servers']);
|
||||
// remove all occurrences of $server
|
||||
$servers = array_filter($signaling['servers'], function ($s) use ($server) {
|
||||
return $s['server'] !== $server;
|
||||
});
|
||||
$signaling['servers'] = array_values($servers); // reindex
|
||||
|
||||
$this->config->setAppValue('spreed', 'signaling_servers', json_encode($signaling));
|
||||
if ($count > count($signaling['servers'])) {
|
||||
$output->writeln('<info>Deleted ' . $server . '.</info>');
|
||||
} else {
|
||||
$output->writeln('<info>There is nothing to delete.</info>');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Signaling;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCP\IConfig;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class ListCommand extends Base {
|
||||
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
|
||||
$this
|
||||
->setName('talk:signaling:list')
|
||||
->setDescription('List external signaling servers.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$config = $this->config->getAppValue('spreed', 'signaling_servers');
|
||||
$signaling = json_decode($config, true);
|
||||
if (!is_array($signaling)) {
|
||||
$signaling = [];
|
||||
}
|
||||
|
||||
$this->writeMixedInOutputFormat($input, $output, $signaling);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Signaling;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Config;
|
||||
use OCP\IConfig;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class VerifyKeys extends Base {
|
||||
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
private Config $talkConfig,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
|
||||
$this
|
||||
->setName('talk:signaling:verify-keys')
|
||||
->setDescription('Verify if the stored public key matches the stored private key for the signaling server')
|
||||
->addOption('update', null, InputOption::VALUE_NONE, 'Updates the stored public key to match the private key if there is a mis-match');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$update = $input->getOption('update');
|
||||
|
||||
$alg = $this->talkConfig->getSignalingTokenAlgorithm();
|
||||
$privateKey = $this->talkConfig->getSignalingTokenPrivateKey();
|
||||
$publicKey = $this->talkConfig->getSignalingTokenPublicKey();
|
||||
$publicKeyDerived = $this->talkConfig->deriveSignalingTokenPublicKey($privateKey, $alg);
|
||||
|
||||
$output->writeln('Stored public key:');
|
||||
$output->writeln($publicKey);
|
||||
$output->writeln('Derived public key:');
|
||||
$output->writeln($publicKeyDerived);
|
||||
|
||||
if ($publicKey != $publicKeyDerived) {
|
||||
if ($update) {
|
||||
$output->writeln('<comment>Stored public key for algorithm ' . strtolower($alg) . ' did not match stored private key.</comment>');
|
||||
$output->writeln('<info>A new public key was created and stored.</info>');
|
||||
$this->config->setAppValue('spreed', 'signaling_token_pubkey_' . strtolower($alg), $publicKeyDerived);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$output->writeln('<error>Stored public key for algorithm ' . strtolower($alg) . ' does not match stored private key</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$output->writeln('<info>Stored public key for algorithm ' . strtolower($alg) . ' matches stored private key</info>');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Stun;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCP\IConfig;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Add extends Base {
|
||||
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:stun:add')
|
||||
->setDescription('Add a new STUN server.')
|
||||
->addArgument(
|
||||
'server',
|
||||
InputArgument::REQUIRED,
|
||||
'A domain name and port number separated by the colons, ex. stun.nextcloud.com:443'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$server = $input->getArgument('server');
|
||||
// check input, similar to stun-server.js
|
||||
$host = parse_url($server, PHP_URL_HOST);
|
||||
$port = parse_url($server, PHP_URL_PORT);
|
||||
if (empty($host) || empty($port)) {
|
||||
$output->writeln('<error>Incorrect value. Must be stunserver:port.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$config = $this->config->getAppValue('spreed', 'stun_servers');
|
||||
$servers = json_decode($config, true);
|
||||
|
||||
if ($servers === null || empty($servers) || !is_array($servers)) {
|
||||
$servers = [];
|
||||
}
|
||||
|
||||
// check if the server is already in the list
|
||||
foreach ($servers as $existingServer) {
|
||||
if ($existingServer === "$host:$port") {
|
||||
$output->writeln('<error>Server already exists.</error>');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$servers[] = "$host:$port";
|
||||
|
||||
$this->config->setAppValue('spreed', 'stun_servers', json_encode($servers));
|
||||
$output->writeln('<info>Added ' . "$host:$port" . '.</info>');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Stun;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCP\IConfig;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Delete extends Base {
|
||||
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:stun:delete')
|
||||
->setDescription('Remove an existing STUN server.')
|
||||
->addArgument(
|
||||
'server',
|
||||
InputArgument::REQUIRED,
|
||||
'A domain name and port number separated by the colons, ex. stun.nextcloud.com:443'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$server = $input->getArgument('server');
|
||||
|
||||
$config = $this->config->getAppValue('spreed', 'stun_servers');
|
||||
$servers = json_decode($config);
|
||||
if (! is_array($servers)) {
|
||||
$servers = [];
|
||||
}
|
||||
$count = count($servers);
|
||||
// remove all occurrences of $server
|
||||
$servers = array_filter($servers, function ($s) use ($server) {
|
||||
return $s !== $server;
|
||||
});
|
||||
$servers = array_values($servers); // reindex
|
||||
|
||||
if (empty($servers)) {
|
||||
$servers = ['stun.nextcloud.com:443'];
|
||||
$this->config->setAppValue('spreed', 'stun_servers', json_encode($servers));
|
||||
$output->writeln('<info>You deleted all STUN servers. A default STUN server was added.</info>');
|
||||
} else {
|
||||
$this->config->setAppValue('spreed', 'stun_servers', json_encode($servers));
|
||||
if ($count > count($servers)) {
|
||||
$output->writeln('<info>Deleted ' . $server . '.</info>');
|
||||
} else {
|
||||
$output->writeln('<info>There is nothing to delete.</info>');
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Stun;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCP\IConfig;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class ListCommand extends Base {
|
||||
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
|
||||
$this
|
||||
->setName('talk:stun:list')
|
||||
->setDescription('List STUN servers.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$config = $this->config->getAppValue('spreed', 'stun_servers');
|
||||
$servers = json_decode($config);
|
||||
if (!is_array($servers)) {
|
||||
$servers = [];
|
||||
}
|
||||
|
||||
$this->writeArrayInOutputFormat($input, $output, $servers);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Turn;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCP\IConfig;
|
||||
use OCP\Security\ISecureRandom;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Add extends Base {
|
||||
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
private ISecureRandom $secureRandom,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:turn:add')
|
||||
->setDescription('Add a TURN server.')
|
||||
->addArgument(
|
||||
'schemes',
|
||||
InputArgument::REQUIRED,
|
||||
'Schemes, can be turn or turns or turn,turns.'
|
||||
)->addArgument(
|
||||
'server',
|
||||
InputArgument::REQUIRED,
|
||||
'A domain name, ex. turn.nextcloud.com'
|
||||
)->addArgument(
|
||||
'protocols',
|
||||
InputArgument::REQUIRED,
|
||||
'Protocols, can be udp or tcp or udp,tcp.'
|
||||
)->addOption(
|
||||
'secret',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'A shard secret string'
|
||||
)->addOption(
|
||||
'generate-secret',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Generate secret if set.'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$schemes = $input->getArgument('schemes');
|
||||
$server = $input->getArgument('server');
|
||||
$protocols = $input->getArgument('protocols');
|
||||
$secret = $input->getOption('secret');
|
||||
$generate = $input->getOption('generate-secret');
|
||||
|
||||
if (!in_array($schemes, ['turn', 'turns', 'turn,turns'])) {
|
||||
$output->writeln('<error>Not allowed schemes, must be turn or turns or turn,turns.</error>');
|
||||
return 1;
|
||||
}
|
||||
if (!in_array($protocols, ['tcp', 'udp', 'udp,tcp'])) {
|
||||
$output->writeln('<error>Not allowed protocols, must be udp or tcp or udp,tcp.</error>');
|
||||
return 1;
|
||||
}
|
||||
// quick validation, similar to turn-server.js
|
||||
if (trim($server) === '') {
|
||||
$output->writeln('<error>Server cannot be empty.</error>');
|
||||
return 1;
|
||||
}
|
||||
if (($generate === false && $secret === null)
|
||||
|| ($generate && $secret !== null)) {
|
||||
$output->writeln('<error>You must provide --secret or --generate-secret.</error>');
|
||||
return 1;
|
||||
}
|
||||
if (!$generate && trim($secret) === '') {
|
||||
$output->writeln('<error>Secret cannot be empty.</error>');
|
||||
return 1;
|
||||
}
|
||||
if ($generate) {
|
||||
$secret = $this->secureRandom->generate(128);
|
||||
}
|
||||
if (stripos($server, 'https://') === 0) {
|
||||
$server = substr($server, 8);
|
||||
}
|
||||
if (stripos($server, 'http://') === 0) {
|
||||
$server = substr($server, 7);
|
||||
}
|
||||
|
||||
$config = $this->config->getAppValue('spreed', 'turn_servers');
|
||||
$servers = json_decode($config, true);
|
||||
|
||||
if ($servers === null || empty($servers) || !is_array($servers)) {
|
||||
$servers = [];
|
||||
}
|
||||
|
||||
//Checking if the server is already added
|
||||
foreach ($servers as $existingServer) {
|
||||
if (
|
||||
$existingServer['schemes'] === $schemes
|
||||
&& $existingServer['server'] === $server
|
||||
&& $existingServer['protocols'] === $protocols
|
||||
) {
|
||||
$output->writeln('<error>Server already exists with the same configuration.</error>');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$servers[] = [
|
||||
'schemes' => $schemes,
|
||||
'server' => $server,
|
||||
'secret' => $secret, // @todo: check the order
|
||||
'protocols' => $protocols,
|
||||
];
|
||||
|
||||
$this->config->setAppValue('spreed', 'turn_servers', json_encode($servers));
|
||||
$output->writeln('<info>Added ' . $server . '.</info>');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Turn;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCP\IConfig;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Delete extends Base {
|
||||
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:turn:delete')
|
||||
->setDescription('Remove an existing TURN server.')
|
||||
->addArgument(
|
||||
'schemes',
|
||||
InputArgument::REQUIRED,
|
||||
'Schemes, can be turn or turns or turn,turns'
|
||||
)->addArgument(
|
||||
'server',
|
||||
InputArgument::REQUIRED,
|
||||
'A domain name, ex. turn.nextcloud.com'
|
||||
)->addArgument(
|
||||
'protocols',
|
||||
InputArgument::REQUIRED,
|
||||
'Protocols, can be udp or tcp or udp,tcp'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$schemes = $input->getArgument('schemes');
|
||||
$server = $input->getArgument('server');
|
||||
$protocols = $input->getArgument('protocols');
|
||||
|
||||
$config = $this->config->getAppValue('spreed', 'turn_servers');
|
||||
$servers = json_decode($config, true);
|
||||
|
||||
if ($servers === null || empty($servers) || !is_array($servers)) {
|
||||
$servers = [];
|
||||
}
|
||||
|
||||
$count = count($servers);
|
||||
// remove all occurrences which match $schemes, $server and $protocols
|
||||
$servers = array_filter($servers, function ($s) use ($schemes, $server, $protocols) {
|
||||
return $s['schemes'] !== $schemes || $s['server'] !== $server || $s['protocols'] !== $protocols;
|
||||
});
|
||||
$servers = array_values($servers); // reindex
|
||||
|
||||
$this->config->setAppValue('spreed', 'turn_servers', json_encode($servers));
|
||||
if ($count > count($servers)) {
|
||||
$output->writeln('<info>Deleted ' . $server . '.</info>');
|
||||
} else {
|
||||
$output->writeln('<info>There is nothing to delete.</info>');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\Turn;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCP\IConfig;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class ListCommand extends Base {
|
||||
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
|
||||
$this
|
||||
->setName('talk:turn:list')
|
||||
->setDescription('List TURN servers.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$config = $this->config->getAppValue('spreed', 'turn_servers');
|
||||
$servers = json_decode($config, true);
|
||||
if (!is_array($servers)) {
|
||||
$servers = [];
|
||||
}
|
||||
|
||||
$this->writeMixedInOutputFormat($input, $output, $servers);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\User;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Manager;
|
||||
use OCP\IUserManager;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Remove extends Base {
|
||||
|
||||
public function __construct(
|
||||
private IUserManager $userManager,
|
||||
private Manager $manager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:user:remove')
|
||||
->setDescription('Remove a user from all their rooms')
|
||||
->addOption(
|
||||
'user',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
'Remove the given users from all rooms'
|
||||
)
|
||||
->addOption(
|
||||
'private-only',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Only remove the user from private rooms, retaining membership in public and open conversations as well as one-to-ones'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$userIds = $input->getOption('user');
|
||||
$privateOnly = $input->getOption('private-only');
|
||||
|
||||
$users = [];
|
||||
foreach ($userIds as $userId) {
|
||||
$user = $this->userManager->get($userId);
|
||||
if (!$user) {
|
||||
$output->writeln('<error>' . sprintf("User '%s' not found.", $userId) . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$users[] = $user;
|
||||
}
|
||||
|
||||
foreach ($users as $user) {
|
||||
$this->manager->removeUserFromAllRooms($user, $privateOnly);
|
||||
}
|
||||
|
||||
$output->writeln('<info>Users successfully removed from all rooms.</info>');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Command\User;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\Talk\Events\AAttendeeRemovedEvent;
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\RoomService;
|
||||
use OCP\IUserManager;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class TransferOwnership extends Base {
|
||||
private RoomService $roomService;
|
||||
|
||||
public function __construct(
|
||||
private ParticipantService $participantService,
|
||||
private Manager $manager,
|
||||
private IUserManager $userManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('talk:user:transfer-ownership')
|
||||
->setDescription('Adds the destination-user with the same participant type to all (not one-to-one) conversations of source-user')
|
||||
->addArgument(
|
||||
'source-user',
|
||||
InputArgument::REQUIRED,
|
||||
'Owner of conversations which shall be moved'
|
||||
)
|
||||
->addArgument(
|
||||
'destination-user',
|
||||
InputArgument::REQUIRED,
|
||||
'User who will be the new owner of the conversations'
|
||||
)
|
||||
->addOption(
|
||||
'include-non-moderator',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Also include conversations where the source-user is a normal user'
|
||||
)
|
||||
->addOption(
|
||||
'remove-source-user',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Remove the source-user from the conversations'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$sourceUID = $input->getArgument('source-user');
|
||||
$destinationUID = $input->getArgument('destination-user');
|
||||
|
||||
$destinationUser = $this->userManager->get($destinationUID);
|
||||
if ($destinationUser === null) {
|
||||
$output->writeln('<error>Destination user could not be found.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$includeNonModeratorRooms = $input->getOption('include-non-moderator');
|
||||
$removeSourceUser = $input->getOption('remove-source-user');
|
||||
|
||||
$modified = $federatedRooms = 0;
|
||||
$rooms = $this->manager->getRoomsForActor(Attendee::ACTOR_USERS, $sourceUID);
|
||||
foreach ($rooms as $room) {
|
||||
if ($room->getType() !== Room::TYPE_GROUP && $room->getType() !== Room::TYPE_PUBLIC) {
|
||||
// Skip one-to-one, changelog and any other room types
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($room->getObjectType() === Room::OBJECT_TYPE_SAMPLE) {
|
||||
// Skip sample rooms
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($room->isFederatedConversation()) {
|
||||
$federatedRooms++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourceParticipant = $this->participantService->getParticipantByActor($room, Attendee::ACTOR_USERS, $sourceUID);
|
||||
|
||||
if ($sourceParticipant->getAttendee()->getParticipantType() === Participant::USER_SELF_JOINED) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$includeNonModeratorRooms && !$sourceParticipant->hasModeratorPermissions()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$destinationParticipant = $this->participantService->getParticipantByActor($room, Attendee::ACTOR_USERS, $destinationUser->getUID());
|
||||
|
||||
$targetType = $this->shouldUpdateParticipantType($sourceParticipant->getAttendee()->getParticipantType(), $destinationParticipant->getAttendee()->getParticipantType());
|
||||
|
||||
if ($targetType !== null) {
|
||||
$this->participantService->updateParticipantType(
|
||||
$room,
|
||||
$destinationParticipant,
|
||||
$sourceParticipant->getAttendee()->getParticipantType()
|
||||
);
|
||||
$modified++;
|
||||
}
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
$this->participantService->addUsers($room, [
|
||||
[
|
||||
'actorType' => Attendee::ACTOR_USERS,
|
||||
'actorId' => $destinationUser->getUID(),
|
||||
'displayName' => $destinationUser->getDisplayName(),
|
||||
'participantType' => $sourceParticipant->getAttendee()->getParticipantType(),
|
||||
]
|
||||
]);
|
||||
$modified++;
|
||||
}
|
||||
|
||||
if ($removeSourceUser) {
|
||||
$this->participantService->removeAttendee($room, $sourceParticipant, AAttendeeRemovedEvent::REASON_REMOVED);
|
||||
}
|
||||
}
|
||||
|
||||
if ($federatedRooms > 0) {
|
||||
$output->writeln('<comment>Could not transfer membership in ' . $federatedRooms . ' federated rooms.</comment>');
|
||||
}
|
||||
|
||||
$output->writeln('<info>Added or promoted user ' . $destinationUser->getUID() . ' in ' . $modified . ' rooms.</info>');
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function shouldUpdateParticipantType(int $sourceParticipantType, int $destinationParticipantType): ?int {
|
||||
if ($sourceParticipantType === Participant::OWNER) {
|
||||
if ($destinationParticipantType === Participant::OWNER) {
|
||||
return null;
|
||||
}
|
||||
return $sourceParticipantType;
|
||||
}
|
||||
|
||||
if ($sourceParticipantType === Participant::MODERATOR) {
|
||||
if ($destinationParticipantType === Participant::OWNER || $destinationParticipantType === Participant::MODERATOR) {
|
||||
return null;
|
||||
}
|
||||
return $sourceParticipantType;
|
||||
}
|
||||
|
||||
if ($sourceParticipantType === Participant::USER) {
|
||||
if ($destinationParticipantType !== Participant::USER_SELF_JOINED) {
|
||||
return null;
|
||||
}
|
||||
return $sourceParticipantType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+823
@@ -0,0 +1,823 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk;
|
||||
|
||||
use OCA\Talk\AppInfo\Application;
|
||||
use OCA\Talk\Events\BeforeTurnServersGetEvent;
|
||||
use OCA\Talk\Federation\Authenticator;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Service\RecordingService;
|
||||
use OCA\Talk\Settings\UserPreference;
|
||||
use OCA\Talk\Vendor\Firebase\JWT\JWT;
|
||||
use OCP\AppFramework\Services\IAppConfig;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\Config\IUserConfig;
|
||||
use OCP\EventDispatcher\IEventDispatcher;
|
||||
use OCP\IConfig;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
use OCP\Security\ISecureRandom;
|
||||
|
||||
class Config {
|
||||
public const ALLOWED_BACKEND_TIMEOFFSET = 45;
|
||||
public const SIGNALING_INTERNAL = 'internal';
|
||||
public const SIGNALING_EXTERNAL = 'external';
|
||||
public const SIGNALING_CLUSTER_CONVERSATION = 'conversation_cluster';
|
||||
|
||||
public const EXPERIMENTAL_UPDATE_PARTICIPANTS = 1;
|
||||
public const EXPERIMENTAL_RECOVER_SESSION = 2;
|
||||
public const EXPERIMENTAL_CHAT_RELAY = 4;
|
||||
|
||||
public const SIGNALING_TICKET_V1 = 1;
|
||||
public const SIGNALING_TICKET_V2 = 2;
|
||||
|
||||
/**
|
||||
* Currently limiting to 1k users because the user_status API would yield
|
||||
* an error on Oracle otherwise. Clients should use a virtual scrolling
|
||||
* mechanism so the data should not be a problem nowadays
|
||||
*/
|
||||
public const USER_STATUS_INTEGRATION_LIMIT = 1000;
|
||||
|
||||
private const EXPERIMENT_CHAT_RELAY = 4;
|
||||
|
||||
/** @var array<string, bool> */
|
||||
protected array $canEnableSIP = [];
|
||||
|
||||
public function __construct(
|
||||
protected IConfig $config,
|
||||
protected IAppConfig $appConfig,
|
||||
protected IUserConfig $userConfig,
|
||||
private ISecureRandom $secureRandom,
|
||||
private IGroupManager $groupManager,
|
||||
private IUserManager $userManager,
|
||||
private IURLGenerator $urlGenerator,
|
||||
protected ITimeFactory $timeFactory,
|
||||
private IEventDispatcher $dispatcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getAllowedTalkGroupIds(): array {
|
||||
$groups = $this->config->getAppValue('spreed', 'allowed_groups', '[]');
|
||||
$groups = json_decode($groups, true);
|
||||
return \is_array($groups) ? $groups : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Participant::PRIVACY_*
|
||||
*/
|
||||
public function getUserReadPrivacy(string $userId): int {
|
||||
return match ((int)$this->config->getUserValue(
|
||||
$userId,
|
||||
'spreed', UserPreference::READ_STATUS_PRIVACY,
|
||||
(string)Participant::PRIVACY_PUBLIC)) {
|
||||
Participant::PRIVACY_PUBLIC => Participant::PRIVACY_PUBLIC,
|
||||
default => Participant::PRIVACY_PRIVATE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Participant::PRIVACY_*
|
||||
*/
|
||||
public function getUserTypingPrivacy(string $userId): int {
|
||||
return match ((int)$this->config->getUserValue(
|
||||
$userId,
|
||||
'spreed', UserPreference::TYPING_PRIVACY,
|
||||
(string)Participant::PRIVACY_PUBLIC)) {
|
||||
Participant::PRIVACY_PUBLIC => Participant::PRIVACY_PUBLIC,
|
||||
default => Participant::PRIVACY_PRIVATE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getSIPGroups(): array {
|
||||
$groups = $this->config->getAppValue('spreed', 'sip_bridge_groups', '[]');
|
||||
$groups = json_decode($groups, true);
|
||||
return \is_array($groups) ? $groups : [];
|
||||
}
|
||||
|
||||
public function isSIPConfigured(): bool {
|
||||
return $this->getSIPSharedSecret() !== ''
|
||||
&& $this->getDialInInfo() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if Talk federation is enabled on this instance
|
||||
*/
|
||||
public function isFederationEnabled(): bool {
|
||||
// TODO: Set to default true once implementation is complete
|
||||
return $this->config->getAppValue('spreed', 'federation_enabled', 'no') === 'yes';
|
||||
}
|
||||
|
||||
public function isFederationEnabledForUserId(IUser $user): bool {
|
||||
$allowedGroups = $this->appConfig->getAppValueArray('federation_allowed_groups', lazy: true);
|
||||
if (empty($allowedGroups)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$userGroups = $this->groupManager->getUserGroupIds($user);
|
||||
return !empty(array_intersect($allowedGroups, $userGroups));
|
||||
}
|
||||
|
||||
public function isBreakoutRoomsEnabled(): bool {
|
||||
return $this->config->getAppValue('spreed', 'breakout_rooms', 'yes') === 'yes';
|
||||
}
|
||||
|
||||
public function getDialInInfo(): string {
|
||||
return $this->config->getAppValue('spreed', 'sip_bridge_dialin_info');
|
||||
}
|
||||
|
||||
public function getSIPSharedSecret(): string {
|
||||
return $this->config->getAppValue('spreed', 'sip_bridge_shared_secret');
|
||||
}
|
||||
|
||||
public function canUserEnableSIP(IUser $user): bool {
|
||||
if (isset($this->canEnableSIP[$user->getUID()])) {
|
||||
return $this->canEnableSIP[$user->getUID()];
|
||||
}
|
||||
|
||||
$this->canEnableSIP[$user->getUID()] = false;
|
||||
|
||||
$allowedGroups = $this->getSIPGroups();
|
||||
if (empty($allowedGroups)) {
|
||||
$this->canEnableSIP[$user->getUID()] = true;
|
||||
} else {
|
||||
$userGroups = $this->groupManager->getUserGroupIds($user);
|
||||
$this->canEnableSIP[$user->getUID()] = !empty(array_intersect($allowedGroups, $userGroups));
|
||||
}
|
||||
|
||||
return $this->canEnableSIP[$user->getUID()];
|
||||
}
|
||||
|
||||
public function canUserDialOutSIP(IUser $user): bool {
|
||||
if (!$this->isSIPDialOutEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->canUserEnableSIP($user);
|
||||
}
|
||||
|
||||
public function isSIPDialOutEnabled(): bool {
|
||||
return $this->config->getAppValue('spreed', 'sip_dialout', 'no') !== 'no';
|
||||
}
|
||||
|
||||
public function getRecordingServers(): array {
|
||||
$config = $this->config->getAppValue('spreed', 'recording_servers');
|
||||
$recording = json_decode($config, true);
|
||||
|
||||
if (!is_array($recording) || !isset($recording['servers'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $recording['servers'];
|
||||
}
|
||||
|
||||
public function getRecordingSecret(): string {
|
||||
$config = $this->config->getAppValue('spreed', 'recording_servers');
|
||||
$recording = json_decode($config, true);
|
||||
|
||||
if (!is_array($recording)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $recording['secret'];
|
||||
}
|
||||
|
||||
public function isRecordingEnabled(): bool {
|
||||
if ($this->getSignalingMode() === self::SIGNALING_INTERNAL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->config->getAppValue('spreed', 'call_recording', 'yes') !== 'yes') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->getRecordingSecret() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$recordingServers = $this->getRecordingServers();
|
||||
if (empty($recordingServers)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return RecordingService::CONSENT_REQUIRED_*
|
||||
*/
|
||||
public function recordingConsentRequired(): int {
|
||||
if (!$this->isRecordingEnabled()) {
|
||||
return RecordingService::CONSENT_REQUIRED_NO;
|
||||
}
|
||||
|
||||
return $this->getRecordingConsentConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return RecordingService::CONSENT_REQUIRED_*
|
||||
*/
|
||||
public function getRecordingConsentConfig(): int {
|
||||
return match ((int)$this->config->getAppValue('spreed', 'recording_consent', (string)RecordingService::CONSENT_REQUIRED_NO)) {
|
||||
RecordingService::CONSENT_REQUIRED_YES => RecordingService::CONSENT_REQUIRED_YES,
|
||||
RecordingService::CONSENT_REQUIRED_OPTIONAL => RecordingService::CONSENT_REQUIRED_OPTIONAL,
|
||||
default => RecordingService::CONSENT_REQUIRED_NO,
|
||||
};
|
||||
}
|
||||
|
||||
public function getRecordingFolder(string $userId): string {
|
||||
return $this->config->getUserValue(
|
||||
$userId,
|
||||
'spreed',
|
||||
UserPreference::RECORDING_FOLDER,
|
||||
$this->getAttachmentFolder($userId) . '/Recording'
|
||||
);
|
||||
}
|
||||
|
||||
public function isDisabledForUser(IUser $user): bool {
|
||||
$allowedGroups = $this->getAllowedTalkGroupIds();
|
||||
if (empty($allowedGroups)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userGroups = $this->groupManager->getUserGroupIds($user);
|
||||
return empty(array_intersect($allowedGroups, $userGroups));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getAllowedConversationsGroupIds(): array {
|
||||
$groups = $this->config->getAppValue('spreed', 'start_conversations', '[]');
|
||||
$groups = json_decode($groups, true);
|
||||
return \is_array($groups) ? $groups : [];
|
||||
}
|
||||
|
||||
public function isNotAllowedToCreateConversations(IUser $user): bool {
|
||||
$allowedGroups = $this->getAllowedConversationsGroupIds();
|
||||
if (empty($allowedGroups)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userGroups = $this->groupManager->getUserGroupIds($user);
|
||||
return empty(array_intersect($allowedGroups, $userGroups));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int<0, 255>
|
||||
* @psalm-return int-mask-of<Attendee::PERMISSIONS_*>
|
||||
*/
|
||||
public function getDefaultPermissions(): int {
|
||||
// Admin configured default permissions
|
||||
$configurableDefault = $this->config->getAppValue('spreed', 'default_permissions');
|
||||
if ($configurableDefault !== '') {
|
||||
return min(Attendee::PERMISSIONS_MAX_CUSTOM, max(Attendee::PERMISSIONS_DEFAULT, (int)$configurableDefault));
|
||||
}
|
||||
|
||||
// Falling back to an unrestricted set of permissions, only ignoring the lobby is off
|
||||
return Attendee::PERMISSIONS_MAX_DEFAULT & ~Attendee::PERMISSIONS_LOBBY_IGNORE;
|
||||
}
|
||||
|
||||
public function getAttachmentFolder(string $userId): string {
|
||||
$defaultAttachmentFolder = $this->config->getAppValue('spreed', 'default_attachment_folder', '/Talk');
|
||||
return $this->config->getUserValue($userId, 'spreed', UserPreference::ATTACHMENT_FOLDER, $defaultAttachmentFolder);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getAllServerUrlsForCSP(): array {
|
||||
$urls = [];
|
||||
|
||||
foreach ($this->getStunServers() as $server) {
|
||||
$urls[] = $server;
|
||||
}
|
||||
|
||||
foreach ($this->getTurnServers() as $server) {
|
||||
$urls[] = $server['server'];
|
||||
}
|
||||
|
||||
foreach ($this->getSignalingServers() as $server) {
|
||||
$urls[] = $this->getWebSocketDomainForSignalingServer($server['server']);
|
||||
}
|
||||
|
||||
return array_filter($urls);
|
||||
}
|
||||
|
||||
protected function getWebSocketDomainForSignalingServer(string $url): string {
|
||||
if (str_ends_with($url, ':') || str_ends_with($url, ':/') || str_ends_with($url, '://')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$url .= '/';
|
||||
if (str_starts_with($url, 'https://')) {
|
||||
return 'wss://' . substr($url, 8, strpos($url, '/', 8) - 8);
|
||||
}
|
||||
|
||||
if (str_starts_with($url, 'http://')) {
|
||||
return 'ws://' . substr($url, 7, strpos($url, '/', 7) - 7);
|
||||
}
|
||||
|
||||
if (str_starts_with($url, 'wss://')) {
|
||||
return substr($url, 0, strpos($url, '/', 6));
|
||||
}
|
||||
|
||||
if (str_starts_with($url, 'ws://')) {
|
||||
return substr($url, 0, strpos($url, '/', 5));
|
||||
}
|
||||
|
||||
$protocol = strpos($url, '://');
|
||||
if ($protocol !== false) {
|
||||
return substr($url, $protocol + 3, strpos($url, '/', $protocol + 3) - $protocol - 3);
|
||||
}
|
||||
|
||||
return substr($url, 0, strpos($url, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getStunServers(): array {
|
||||
$config = $this->config->getAppValue('spreed', 'stun_servers', json_encode(['stun.nextcloud.com:443']));
|
||||
$servers = json_decode($config, true);
|
||||
|
||||
if (!is_array($servers) || empty($servers)) {
|
||||
$servers = ['stun.nextcloud.com:443'];
|
||||
}
|
||||
|
||||
if (!$this->config->getSystemValueBool('has_internet_connection', true)) {
|
||||
$servers = array_filter($servers, static function ($server) {
|
||||
return $server !== 'stun.nextcloud.com:443';
|
||||
});
|
||||
}
|
||||
|
||||
return $servers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a username and password for the TURN server
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getTurnServers(bool $withEvent = true): array {
|
||||
$config = $this->config->getAppValue('spreed', 'turn_servers');
|
||||
$servers = json_decode($config, true);
|
||||
|
||||
if ($servers === null || empty($servers) || !is_array($servers)) {
|
||||
$servers = [];
|
||||
}
|
||||
|
||||
if ($withEvent) {
|
||||
$event = new BeforeTurnServersGetEvent($servers);
|
||||
$this->dispatcher->dispatchTyped($event);
|
||||
$servers = $event->getServers();
|
||||
}
|
||||
|
||||
foreach ($servers as $key => $server) {
|
||||
$servers[$key]['schemes'] = $server['schemes'] ?? 'turn';
|
||||
}
|
||||
|
||||
return $servers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a list of TURN servers with username and password
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getTurnSettings(): array {
|
||||
$servers = $this->getTurnServers();
|
||||
|
||||
if (empty($servers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Credentials are valid for 24h
|
||||
// FIXME add the TTL to the response and properly reconnect then
|
||||
$timestamp = $this->timeFactory->getTime() + 86400;
|
||||
$rnd = $this->secureRandom->generate(16);
|
||||
$username = $timestamp . ':' . $rnd;
|
||||
|
||||
foreach ($servers as $server) {
|
||||
$u = $server['username'] ?? $username;
|
||||
$password = $server['password'] ?? base64_encode(hash_hmac('sha1', $u, $server['secret'], true));
|
||||
|
||||
$turnSettings[] = [
|
||||
'schemes' => $server['schemes'],
|
||||
'server' => $server['server'],
|
||||
'username' => $u,
|
||||
'password' => $password,
|
||||
'protocols' => $server['protocols'],
|
||||
];
|
||||
}
|
||||
|
||||
return $turnSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-return self::SIGNALING_INTERNAL|self::SIGNALING_EXTERNAL|self::SIGNALING_CLUSTER_CONVERSATION
|
||||
*/
|
||||
public function getSignalingMode(bool $cleanExternalSignaling = true): string {
|
||||
$validModes = [
|
||||
self::SIGNALING_INTERNAL,
|
||||
self::SIGNALING_EXTERNAL,
|
||||
self::SIGNALING_CLUSTER_CONVERSATION,
|
||||
];
|
||||
|
||||
$mode = $this->config->getAppValue('spreed', 'signaling_mode', null);
|
||||
if ($mode === self::SIGNALING_INTERNAL) {
|
||||
return self::SIGNALING_INTERNAL;
|
||||
}
|
||||
|
||||
$numSignalingServers = count($this->getSignalingServers());
|
||||
if ($numSignalingServers === 0) {
|
||||
return self::SIGNALING_INTERNAL;
|
||||
}
|
||||
if ($numSignalingServers === 1
|
||||
&& $cleanExternalSignaling) {
|
||||
return self::SIGNALING_EXTERNAL;
|
||||
}
|
||||
|
||||
return $mode === self::SIGNALING_CLUSTER_CONVERSATION ? $mode : self::SIGNALING_EXTERNAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list of signaling servers. Each entry contains the URL of the
|
||||
* server and a flag whether the certificate should be verified.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getSignalingServers(): array {
|
||||
$config = $this->config->getAppValue('spreed', 'signaling_servers');
|
||||
$signaling = json_decode($config, true);
|
||||
if (!is_array($signaling) || !isset($signaling['servers'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $signaling['servers'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getSignalingSecret(): string {
|
||||
$config = $this->config->getAppValue('spreed', 'signaling_servers');
|
||||
$signaling = json_decode($config, true);
|
||||
|
||||
if (!is_array($signaling)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $signaling['secret'];
|
||||
}
|
||||
|
||||
public function getHideSignalingWarning(): bool {
|
||||
return $this->config->getAppValue('spreed', 'hide_signaling_warning', 'no') === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $version
|
||||
* @param string|null $userId
|
||||
* @return string
|
||||
*/
|
||||
public function getSignalingTicket(int $version, ?string $userId, ?string $cloudId = null): string {
|
||||
switch ($version) {
|
||||
case self::SIGNALING_TICKET_V2:
|
||||
return $this->getSignalingTicketV2($userId, $cloudId);
|
||||
case self::SIGNALING_TICKET_V1:
|
||||
default:
|
||||
return $this->getSignalingTicketV1($userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $userId
|
||||
* @return string
|
||||
*/
|
||||
private function getSignalingTicketV1(?string $userId): string {
|
||||
if (empty($userId)) {
|
||||
$secret = $this->config->getAppValue('spreed', 'signaling_ticket_secret');
|
||||
} else {
|
||||
$secret = $this->config->getUserValue($userId, 'spreed', 'signaling_ticket_secret');
|
||||
}
|
||||
if (empty($secret)) {
|
||||
// Create secret lazily on first access.
|
||||
// TODO(fancycode): Is there a possibility for a race condition?
|
||||
$secret = $this->secureRandom->generate(255);
|
||||
if (empty($userId)) {
|
||||
$this->config->setAppValue('spreed', 'signaling_ticket_secret', $secret);
|
||||
} else {
|
||||
$this->config->setUserValue($userId, 'spreed', 'signaling_ticket_secret', $secret);
|
||||
}
|
||||
}
|
||||
|
||||
// Format is "random:timestamp:userid:checksum" and "checksum" is the
|
||||
// SHA256-HMAC of "random:timestamp:userid" with the per-user secret.
|
||||
$random = $this->secureRandom->generate(16);
|
||||
$timestamp = $this->timeFactory->getTime();
|
||||
$data = $random . ':' . $timestamp . ':' . $userId;
|
||||
$hash = hash_hmac('sha256', $data, $secret);
|
||||
return $data . ':' . $hash;
|
||||
}
|
||||
|
||||
private function ensureSignalingTokenKeys(string $alg): void {
|
||||
$secret = $this->config->getAppValue('spreed', 'signaling_token_privkey_' . strtolower($alg));
|
||||
if ($secret) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_starts_with($alg, 'ES')) {
|
||||
$privKey = openssl_pkey_new([
|
||||
'curve_name' => $alg === 'ES384' ? 'secp384r1' : 'prime256v1',
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||
]);
|
||||
$pubKey = openssl_pkey_get_details($privKey);
|
||||
$public = $pubKey['key'];
|
||||
if (!openssl_pkey_export($privKey, $secret)) {
|
||||
throw new \Exception('Could not export private key');
|
||||
}
|
||||
} elseif (str_starts_with($alg, 'RS')) {
|
||||
$privKey = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
$pubKey = openssl_pkey_get_details($privKey);
|
||||
$public = $pubKey['key'];
|
||||
if (!openssl_pkey_export($privKey, $secret)) {
|
||||
throw new \Exception('Could not export private key');
|
||||
}
|
||||
} elseif ($alg === 'EdDSA') {
|
||||
$privKey = sodium_crypto_sign_keypair();
|
||||
$public = base64_encode(sodium_crypto_sign_publickey($privKey));
|
||||
$secret = base64_encode(sodium_crypto_sign_secretkey($privKey));
|
||||
} else {
|
||||
throw new \Exception('Unsupported algorithm ' . $alg);
|
||||
}
|
||||
|
||||
$this->config->setAppValue('spreed', 'signaling_token_privkey_' . strtolower($alg), $secret);
|
||||
$this->config->setAppValue('spreed', 'signaling_token_pubkey_' . strtolower($alg), $public);
|
||||
}
|
||||
|
||||
public function getSignalingTokenAlgorithm(): string {
|
||||
return $this->config->getAppValue('spreed', 'signaling_token_alg', 'ES256');
|
||||
}
|
||||
|
||||
public function getSignalingTokenPrivateKey(?string $alg = null): string {
|
||||
if (!$alg) {
|
||||
$alg = $this->getSignalingTokenAlgorithm();
|
||||
}
|
||||
$this->ensureSignalingTokenKeys($alg);
|
||||
|
||||
return $this->config->getAppValue('spreed', 'signaling_token_privkey_' . strtolower($alg));
|
||||
}
|
||||
|
||||
public function getSignalingTokenPublicKey(?string $alg = null): string {
|
||||
if (!$alg) {
|
||||
$alg = $this->getSignalingTokenAlgorithm();
|
||||
}
|
||||
$this->ensureSignalingTokenKeys($alg);
|
||||
|
||||
return $this->config->getAppValue('spreed', 'signaling_token_pubkey_' . strtolower($alg));
|
||||
}
|
||||
|
||||
public function deriveSignalingTokenPublicKey(string $privateKey, string $alg): string {
|
||||
// Clear any existing (unrelated) OpenSSL errors
|
||||
while (openssl_error_string() !== false);
|
||||
|
||||
if (str_starts_with($alg, 'ES') || str_starts_with($alg, 'RS')) {
|
||||
$opensslPrivateKey = openssl_pkey_get_private($privateKey);
|
||||
$this->throwOnOpensslError();
|
||||
|
||||
$pubKey = openssl_pkey_get_details($opensslPrivateKey);
|
||||
$this->throwOnOpensslError();
|
||||
|
||||
$public = $pubKey['key'];
|
||||
if (!openssl_pkey_export($privateKey, $secret)) {
|
||||
throw new \Exception('Could not export private key');
|
||||
}
|
||||
} elseif ($alg === 'EdDSA') {
|
||||
$public = base64_encode(sodium_crypto_sign_publickey_from_secretkey($privateKey));
|
||||
} else {
|
||||
throw new \Exception('Unsupported algorithm ' . $alg);
|
||||
}
|
||||
|
||||
return $public;
|
||||
}
|
||||
|
||||
private function throwOnOpensslError() {
|
||||
$errors = [];
|
||||
while ($error = openssl_error_string()) {
|
||||
$errors[] = $error;
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
throw new \Exception("OpenSSL error:\n" . implode("\n", $errors));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IUser $user
|
||||
* @return array
|
||||
*/
|
||||
public function getSignalingUserData(IUser $user): array {
|
||||
return [
|
||||
'displayname' => $user->getDisplayName(),
|
||||
];
|
||||
}
|
||||
|
||||
public function getSignalingFederatedUserData(): array {
|
||||
/** @var Authenticator $authenticator */
|
||||
$authenticator = \OCP\Server::get(Authenticator::class);
|
||||
if (!$authenticator->isFederationRequest()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'displayname' => $authenticator->getParticipant()->getAttendee()->getDisplayName(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $userId if given, the id of a user in this instance or
|
||||
* a cloud id.
|
||||
* @return string
|
||||
*/
|
||||
private function getSignalingTicketV2(?string $userId, ?string $cloudId): string {
|
||||
$timestamp = $this->timeFactory->getTime();
|
||||
$data = [
|
||||
'iss' => $this->urlGenerator->getAbsoluteURL(''),
|
||||
'iat' => $timestamp,
|
||||
'exp' => $timestamp + 60, // Valid for 1 minute.
|
||||
];
|
||||
$user = $userId !== null ? $this->userManager->get($userId) : null;
|
||||
if ($user instanceof IUser) {
|
||||
$data['sub'] = $user->getUID();
|
||||
$data['userdata'] = $this->getSignalingUserData($user);
|
||||
} elseif ($cloudId !== null && $cloudId !== '') {
|
||||
$data['sub'] = $cloudId;
|
||||
$extendedData = $this->getSignalingFederatedUserData();
|
||||
if (!empty($extendedData)) {
|
||||
$data['userdata'] = $extendedData;
|
||||
}
|
||||
}
|
||||
|
||||
$alg = $this->getSignalingTokenAlgorithm();
|
||||
$secret = $this->getSignalingTokenPrivateKey($alg);
|
||||
$token = JWT::encode($data, $secret, $alg);
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $userId
|
||||
* @param string $ticket
|
||||
* @return bool
|
||||
*/
|
||||
public function validateSignalingTicket(?string $userId, string $ticket): bool {
|
||||
if (empty($userId)) {
|
||||
$secret = $this->config->getAppValue('spreed', 'signaling_ticket_secret');
|
||||
} else {
|
||||
$secret = $this->config->getUserValue($userId, 'spreed', 'signaling_ticket_secret');
|
||||
}
|
||||
if (empty($secret)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lastColon = strrpos($ticket, ':');
|
||||
if ($lastColon === false) {
|
||||
// Immediately reject invalid formats.
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO(fancycode): Should we reject tickets that are too old?
|
||||
$data = substr($ticket, 0, $lastColon);
|
||||
$hash = hash_hmac('sha256', $data, $secret);
|
||||
return hash_equals($hash, substr($ticket, $lastColon + 1));
|
||||
}
|
||||
|
||||
public function getGridVideosLimit(): int {
|
||||
return (int)$this->config->getAppValue('spreed', 'grid_videos_limit', '19'); // 5*4 - self
|
||||
}
|
||||
|
||||
public function getGridVideosLimitEnforced(): bool {
|
||||
return $this->config->getAppValue('spreed', 'grid_videos_limit_enforced', 'no') === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
* User setting falling back to admin defined app config
|
||||
*
|
||||
* @param ?string $userId
|
||||
* @return bool
|
||||
*/
|
||||
public function getCallsStartWithoutMedia(?string $userId): bool {
|
||||
if ($userId !== null) {
|
||||
$userSetting = $this->config->getUserValue($userId, 'spreed', UserPreference::CALLS_START_WITHOUT_MEDIA);
|
||||
if ($userSetting === 'yes' || $userSetting === 'no') {
|
||||
return $userSetting === 'yes';
|
||||
}
|
||||
}
|
||||
|
||||
return $this->appConfig->getAppValueBool('calls_start_without_media');
|
||||
}
|
||||
|
||||
/**
|
||||
* User setting for blur background
|
||||
*
|
||||
* @param ?string $userId
|
||||
* @return bool
|
||||
*/
|
||||
public function getBlurVirtualBackground(?string $userId): bool {
|
||||
if ($userId !== null) {
|
||||
$userSetting = $this->config->getUserValue($userId, 'spreed', UserPreference::BLUR_VIRTUAL_BACKGROUND);
|
||||
return $userSetting === 'yes';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* User setting for conversations list style
|
||||
*
|
||||
* @param ?string $userId
|
||||
* @return UserPreference::CONVERSATION_LIST_STYLE_*
|
||||
*/
|
||||
public function getConversationsListStyle(?string $userId): string {
|
||||
if ($userId !== null) {
|
||||
$userSetting = $this->config->getUserValue(
|
||||
$userId,
|
||||
'spreed',
|
||||
UserPreference::CONVERSATIONS_LIST_STYLE,
|
||||
UserPreference::CONVERSATION_LIST_STYLE_TWO_LINES
|
||||
);
|
||||
|
||||
if (in_array($userSetting, [UserPreference::CONVERSATION_LIST_STYLE_TWO_LINES, UserPreference::CONVERSATION_LIST_STYLE_COMPACT], true)) {
|
||||
return $userSetting;
|
||||
}
|
||||
}
|
||||
return UserPreference::CONVERSATION_LIST_STYLE_TWO_LINES;
|
||||
}
|
||||
|
||||
/**
|
||||
* User setting falling back to admin defined app config
|
||||
*/
|
||||
public function getInactiveLockTime(): int {
|
||||
return $this->appConfig->getAppValueInt('inactivity_lock_after_days');
|
||||
}
|
||||
|
||||
public function enableLobbyOnLockedRooms(): bool {
|
||||
return $this->appConfig->getAppValueBool('inactivity_enable_lobby');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param self::EXPERIMENTAL_* $experiment
|
||||
*/
|
||||
public function hasExperiment(int $experiment): bool {
|
||||
return $this->appConfig->getAppValueInt('experiments_users') & $experiment
|
||||
|| $this->appConfig->getAppValueInt('experiments_guests') & $experiment;
|
||||
}
|
||||
|
||||
public function isPasswordEnforced(): bool {
|
||||
return $this->appConfig->getAppValueBool('force_passwords');
|
||||
}
|
||||
|
||||
public function isCallEndToEndEncryptionEnabled(): bool {
|
||||
if ($this->getSignalingMode() !== self::SIGNALING_EXTERNAL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO Default value will be set to true, once all mobile clients support it.
|
||||
return $this->appConfig->getAppValueBool('call_end_to_end_encryption');
|
||||
}
|
||||
|
||||
public function isChatRelayEnabled(): bool {
|
||||
$isEnabled
|
||||
= (max(0, $this->appConfig->getAppValueInt('experiments_users'))
|
||||
| max(0, $this->appConfig->getAppValueInt('experiments_guests'))
|
||||
)
|
||||
& self::EXPERIMENT_CHAT_RELAY;
|
||||
return $isEnabled === self::EXPERIMENT_CHAT_RELAY;
|
||||
}
|
||||
|
||||
public function getPlaySoundsForUser(?IUser $user): bool {
|
||||
if (!$user instanceof IUser) {
|
||||
return $this->getPlaySoundsDefaultForGuests();
|
||||
}
|
||||
return $this->userConfig->getValueBool($user->getUID(), Application::APP_ID, UserPreference::PLAY_SOUNDS);
|
||||
}
|
||||
|
||||
public function getPlaySoundsDefaultForGuests(): bool {
|
||||
return $this->appConfig->getAppValueBool('guests_play_sounds', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\ContactsMenu\Providers;
|
||||
|
||||
use OCA\Talk\AppInfo\Application;
|
||||
use OCA\Talk\Config;
|
||||
use OCP\Contacts\ContactsMenu\IActionFactory;
|
||||
use OCP\Contacts\ContactsMenu\IEntry;
|
||||
use OCP\Contacts\ContactsMenu\IProvider;
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
|
||||
class CallProvider implements IProvider {
|
||||
|
||||
public function __construct(
|
||||
private IActionFactory $actionFactory,
|
||||
private IURLGenerator $urlGenerator,
|
||||
private IL10N $l10n,
|
||||
private IUserManager $userManager,
|
||||
private Config $config,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function process(IEntry $entry): void {
|
||||
$uid = $entry->getProperty('UID');
|
||||
|
||||
if ($uid === null) {
|
||||
// Nothing to do
|
||||
return;
|
||||
}
|
||||
|
||||
if ($entry->getProperty('isLocalSystemBook') !== true) {
|
||||
// Not internal user
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->userManager->get($uid);
|
||||
if (!$user instanceof IUser) {
|
||||
// No valid user object
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->config->isDisabledForUser($user)) {
|
||||
// User can not use Talk
|
||||
return;
|
||||
}
|
||||
|
||||
$talkAction = $this->l10n->t('Talk to %s', [$user->getDisplayName()]);
|
||||
$iconUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('spreed', 'app-dark.svg'));
|
||||
$callUrl = $this->urlGenerator->linkToRouteAbsolute('spreed.Page.index') . '?callUser=' . $user->getUID();
|
||||
$action = $this->actionFactory->newLinkAction($iconUrl, $talkAction, $callUrl, Application::APP_ID);
|
||||
$entry->addAction($action);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use OC\AppFramework\Http\Dispatcher;
|
||||
use OCA\Talk\Model\Invitation;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Room;
|
||||
use OCP\AppFramework\OCSController;
|
||||
|
||||
abstract class AEnvironmentAwareOCSController extends OCSController {
|
||||
protected int $apiVersion = 1;
|
||||
protected ?Room $room = null;
|
||||
protected ?Participant $participant = null;
|
||||
protected ?Invitation $invitation = null;
|
||||
|
||||
public function setAPIVersion(int $apiVersion): void {
|
||||
$this->apiVersion = $apiVersion;
|
||||
}
|
||||
|
||||
public function getAPIVersion(): int {
|
||||
return $this->apiVersion;
|
||||
}
|
||||
|
||||
public function setRoom(Room $room): void {
|
||||
$this->room = $room;
|
||||
}
|
||||
|
||||
public function getRoom(): ?Room {
|
||||
return $this->room;
|
||||
}
|
||||
|
||||
public function setParticipant(Participant $participant): void {
|
||||
$this->participant = $participant;
|
||||
}
|
||||
|
||||
public function getParticipant(): ?Participant {
|
||||
return $this->participant;
|
||||
}
|
||||
|
||||
public function setInvitation(Invitation $invitation): void {
|
||||
$this->invitation = $invitation;
|
||||
}
|
||||
|
||||
public function getInvitation(): ?Invitation {
|
||||
return $this->invitation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Following the logic of {@see Dispatcher::executeController}
|
||||
* @return string Either 'json' or 'xml'
|
||||
* @psalm-return 'json'|'xml'
|
||||
*/
|
||||
public function getResponseFormat(): string {
|
||||
// get format from the url format or request format parameter
|
||||
$format = $this->request->getParam('format');
|
||||
|
||||
// if none is given try the first Accept header
|
||||
if ($format === null) {
|
||||
$headers = $this->request->getHeader('accept');
|
||||
/**
|
||||
* Default value of
|
||||
* @see OCSController::buildResponse()
|
||||
*/
|
||||
$format = $this->getResponderByHTTPHeader($headers, 'xml');
|
||||
}
|
||||
|
||||
return $format;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use OCA\Talk\Exceptions\CannotReachRemoteException;
|
||||
use OCA\Talk\Middleware\Attribute\AllowWithoutParticipantWhenPendingInvitation;
|
||||
use OCA\Talk\Middleware\Attribute\FederationSupported;
|
||||
use OCA\Talk\Middleware\Attribute\RequireLoggedInParticipant;
|
||||
use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant;
|
||||
use OCA\Talk\Middleware\Attribute\RequireParticipantOrLoggedInAndListedConversation;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Service\AvatarService;
|
||||
use OCA\Talk\Service\RoomFormatter;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||||
use OCP\AppFramework\Http\Attribute\RequestHeader;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Http\FileDisplayResponse;
|
||||
use OCP\Federation\ICloudIdManager;
|
||||
use OCP\IAvatarManager;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkRoom from ResponseDefinitions
|
||||
*/
|
||||
class AvatarController extends AEnvironmentAwareOCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
protected RoomFormatter $roomFormatter,
|
||||
protected AvatarService $avatarService,
|
||||
protected IUserSession $userSession,
|
||||
protected IL10N $l,
|
||||
protected LoggerInterface $logger,
|
||||
protected ICloudIdManager $cloudIdManager,
|
||||
protected IAvatarManager $avatarManager,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Upload an avatar for a room
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{message: string}, array{}>
|
||||
*
|
||||
* 200: Avatar uploaded successfully
|
||||
* 400: Avatar invalid
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[RequireModeratorParticipant]
|
||||
public function uploadAvatar(): DataResponse {
|
||||
try {
|
||||
$file = $this->request->getUploadedFile('file');
|
||||
$this->avatarService->setAvatarFromRequest($this->getRoom(), $file);
|
||||
return new DataResponse($this->roomFormatter->formatRoom(
|
||||
$this->getResponseFormat(),
|
||||
[],
|
||||
$this->getRoom(),
|
||||
$this->participant,
|
||||
));
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to post avatar', [
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return new DataResponse(['message' => $this->l->t('An error occurred. Please contact your administrator.')], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an emoji as avatar
|
||||
*
|
||||
* @param string $emoji Emoji
|
||||
* @param ?string $color Color of the emoji
|
||||
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{message: string}, array{}>
|
||||
*
|
||||
* 200: Avatar set successfully
|
||||
* 400: Setting emoji avatar is not possible
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[RequireModeratorParticipant]
|
||||
public function emojiAvatar(string $emoji, ?string $color): DataResponse {
|
||||
try {
|
||||
$this->avatarService->setAvatarFromEmoji($this->getRoom(), $emoji, $color);
|
||||
return new DataResponse($this->roomFormatter->formatRoom(
|
||||
$this->getResponseFormat(),
|
||||
[],
|
||||
$this->getRoom(),
|
||||
$this->participant,
|
||||
));
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to post avatar', [
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return new DataResponse(['message' => $this->l->t('An error occurred. Please contact your administrator.')], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the avatar of a room
|
||||
*
|
||||
* @param bool $darkTheme Theme used for background
|
||||
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
|
||||
*
|
||||
* 200: Room avatar returned
|
||||
*/
|
||||
#[FederationSupported]
|
||||
#[PublicPage]
|
||||
#[NoCSRFRequired]
|
||||
#[AllowWithoutParticipantWhenPendingInvitation]
|
||||
#[RequireParticipantOrLoggedInAndListedConversation]
|
||||
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
|
||||
public function getAvatar(bool $darkTheme = false): FileDisplayResponse {
|
||||
// Cache for 1 day
|
||||
$cacheDuration = 60 * 60 * 24;
|
||||
if ($this->room->isFederatedConversation()) {
|
||||
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\AvatarController $proxy */
|
||||
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\AvatarController::class);
|
||||
try {
|
||||
return $proxy->getAvatar($this->room, $this->participant, $this->invitation, $darkTheme);
|
||||
} catch (CannotReachRemoteException) {
|
||||
// Falling back to a local "globe" avatar for indicating the federation
|
||||
// Cache for 15 minutes only
|
||||
$cacheDuration = 15 * 60;
|
||||
}
|
||||
}
|
||||
$file = $this->avatarService->getAvatar($this->getRoom(), $this->userSession->getUser(), $darkTheme);
|
||||
|
||||
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $file->getMimeType()]);
|
||||
$response->cacheFor($cacheDuration, false, true);
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the dark mode avatar of a room
|
||||
*
|
||||
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
|
||||
*
|
||||
* 200: Room avatar returned
|
||||
*/
|
||||
#[FederationSupported]
|
||||
#[PublicPage]
|
||||
#[NoCSRFRequired]
|
||||
#[AllowWithoutParticipantWhenPendingInvitation]
|
||||
#[RequireParticipantOrLoggedInAndListedConversation]
|
||||
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
|
||||
public function getAvatarDark(): FileDisplayResponse {
|
||||
return $this->getAvatar(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the avatar of a cloudId user when inviting users while creating a conversation
|
||||
*
|
||||
* @param int $size Avatar size
|
||||
* @psalm-param 64|512 $size
|
||||
* @param string $cloudId Federation CloudID to get the avatar for
|
||||
* @param bool $darkTheme Theme used for background
|
||||
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
|
||||
*
|
||||
* 200: User avatar returned
|
||||
*/
|
||||
#[FederationSupported]
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
|
||||
public function getUserProxyAvatarWithoutRoom(int $size, string $cloudId, bool $darkTheme = false): FileDisplayResponse {
|
||||
return $this->getUserProxyAvatar($size, $cloudId, $darkTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the dark mode avatar of a cloudId user when inviting users while creating a conversation
|
||||
*
|
||||
* @param int $size Avatar size
|
||||
* @psalm-param 64|512 $size
|
||||
* @param string $cloudId Federation CloudID to get the avatar for
|
||||
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
|
||||
*
|
||||
* 200: User avatar returned
|
||||
*/
|
||||
#[FederationSupported]
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
|
||||
public function getUserProxyAvatarDarkWithoutRoom(int $size, string $cloudId): FileDisplayResponse {
|
||||
return $this->getUserProxyAvatar($size, $cloudId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the avatar of a cloudId user
|
||||
*
|
||||
* @param int $size Avatar size
|
||||
* @psalm-param 64|512 $size
|
||||
* @param string $cloudId Federation CloudID to get the avatar for
|
||||
* @param bool $darkTheme Theme used for background
|
||||
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
|
||||
*
|
||||
* 200: User avatar returned
|
||||
*/
|
||||
#[FederationSupported]
|
||||
#[BruteForceProtection(action: 'talkRoomToken')]
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
|
||||
#[PublicPage]
|
||||
#[NoCSRFRequired]
|
||||
#[AllowWithoutParticipantWhenPendingInvitation]
|
||||
#[RequireLoggedInParticipant]
|
||||
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
|
||||
public function getUserProxyAvatar(int $size, string $cloudId, bool $darkTheme = false): FileDisplayResponse {
|
||||
try {
|
||||
$resolvedCloudId = $this->cloudIdManager->resolveCloudId($cloudId);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return $this->getPlaceholderResponse($darkTheme);
|
||||
}
|
||||
|
||||
$ownId = $this->cloudIdManager->getCloudId($this->userSession->getUser()->getCloudId(), null);
|
||||
|
||||
/**
|
||||
* Reach out to the remote server to get the avatar
|
||||
*/
|
||||
if ($ownId->getRemote() !== $resolvedCloudId->getRemote()) {
|
||||
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\AvatarController $proxy */
|
||||
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\AvatarController::class);
|
||||
try {
|
||||
return $proxy->getUserProxyAvatar($resolvedCloudId->getRemote(), $resolvedCloudId->getUser(), $size, $darkTheme);
|
||||
} catch (CannotReachRemoteException) {
|
||||
// Falling back to a local "user" avatar
|
||||
return $this->getPlaceholderResponse($darkTheme);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We are the server that hosts the user, so getting it from the avatar manager
|
||||
*/
|
||||
try {
|
||||
$avatar = $this->avatarManager->getAvatar($resolvedCloudId->getUser());
|
||||
$avatarFile = $avatar->getFile($size, $darkTheme);
|
||||
} catch (\Exception) {
|
||||
return $this->getPlaceholderResponse($darkTheme);
|
||||
}
|
||||
|
||||
$response = new FileDisplayResponse(
|
||||
$avatarFile,
|
||||
Http::STATUS_OK,
|
||||
['Content-Type' => $avatarFile->getMimeType()],
|
||||
);
|
||||
// Cache for 1 day
|
||||
$response->cacheFor(60 * 60 * 24, false, true);
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the dark mode avatar of a cloudId user
|
||||
*
|
||||
* @param int $size Avatar size
|
||||
* @psalm-param 64|512 $size
|
||||
* @param string $cloudId Federation CloudID to get the avatar for
|
||||
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
|
||||
*
|
||||
* 200: User avatar returned
|
||||
*/
|
||||
#[FederationSupported]
|
||||
#[BruteForceProtection(action: 'talkRoomToken')]
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
|
||||
#[PublicPage]
|
||||
#[NoCSRFRequired]
|
||||
#[AllowWithoutParticipantWhenPendingInvitation]
|
||||
#[RequireLoggedInParticipant]
|
||||
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
|
||||
public function getUserProxyAvatarDark(int $size, string $cloudId): FileDisplayResponse {
|
||||
return $this->getUserProxyAvatar($size, $cloudId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the placeholder avatar
|
||||
*
|
||||
* @param bool $darkTheme Theme used for background
|
||||
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
|
||||
*
|
||||
* 200: User avatar returned
|
||||
*/
|
||||
protected function getPlaceholderResponse(bool $darkTheme): FileDisplayResponse {
|
||||
$file = $this->avatarService->getPersonPlaceholder($darkTheme);
|
||||
$response = new FileDisplayResponse(
|
||||
$file,
|
||||
Http::STATUS_OK,
|
||||
['Content-Type' => $file->getMimeType()],
|
||||
);
|
||||
$response->cacheFor(60 * 15, false, true);
|
||||
return $response;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the avatar of a room
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>
|
||||
*
|
||||
* 200: Avatar removed successfully
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[RequireModeratorParticipant]
|
||||
public function deleteAvatar(): DataResponse {
|
||||
$this->avatarService->deleteAvatar($this->getRoom());
|
||||
return new DataResponse($this->roomFormatter->formatRoom(
|
||||
$this->getResponseFormat(),
|
||||
[],
|
||||
$this->getRoom(),
|
||||
$this->participant,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant;
|
||||
use OCA\Talk\Model\Ban;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Service\BanService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\IRequest;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkBan from ResponseDefinitions
|
||||
*/
|
||||
class BanController extends AEnvironmentAwareOCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
protected BanService $banService,
|
||||
protected ITimeFactory $timeFactory,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ban an actor or IP address
|
||||
*
|
||||
* Required capability: `ban-v1`
|
||||
*
|
||||
* @param 'users'|'guests'|'emails'|'ip' $actorType Type of actor to ban, or `ip` when banning a clients remote address
|
||||
* @param string $actorId Actor ID or the IP address or range in case of type `ip`
|
||||
* @param string $internalNote Optional internal note (max. 4000 characters)
|
||||
* @return DataResponse<Http::STATUS_OK, TalkBan, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'bannedActor'|'internalNote'|'moderator'|'self'|'room'}, array{}>
|
||||
*
|
||||
* 200: Ban successfully
|
||||
* 400: Actor information is invalid
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[RequireModeratorParticipant]
|
||||
public function banActor(string $actorType, string $actorId, string $internalNote = ''): DataResponse {
|
||||
try {
|
||||
$moderator = $this->participant->getAttendee();
|
||||
|
||||
$ban = $this->banService->createBan(
|
||||
$this->room,
|
||||
$moderator->getActorType(),
|
||||
$moderator->getActorId(),
|
||||
$moderator->getDisplayName(),
|
||||
$actorType,
|
||||
$actorId,
|
||||
$this->timeFactory->getDateTime(),
|
||||
$internalNote
|
||||
);
|
||||
|
||||
return new DataResponse($ban->jsonSerialize(), Http::STATUS_OK);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
/** @var 'bannedActor'|'internalNote'|'moderator'|'self' $message */
|
||||
$message = $e->getMessage();
|
||||
return new DataResponse([
|
||||
'error' => $message,
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List the bans of a conversation
|
||||
*
|
||||
* Required capability: `ban-v1`
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkBan>, array{}>
|
||||
*
|
||||
* 200: List all bans
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[RequireModeratorParticipant]
|
||||
public function listBans(): DataResponse {
|
||||
$bans = $this->banService->getBansForRoom($this->room->getId());
|
||||
$result = array_map(static fn (Ban $ban): array => $ban->jsonSerialize(), $bans);
|
||||
return new DataResponse($result, Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unban an actor or IP address
|
||||
*
|
||||
* Required capability: `ban-v1`
|
||||
*
|
||||
* @param int $banId ID of the ban to be removed
|
||||
* @return DataResponse<Http::STATUS_OK, null, array{}>
|
||||
*
|
||||
* 200: Unban successfully or not found
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[RequireModeratorParticipant]
|
||||
public function unbanActor(int $banId): DataResponse {
|
||||
$this->banService->findAndDeleteBanByIdForRoom($banId, $this->room->getId());
|
||||
return new DataResponse(null, Http::STATUS_OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use OCA\Talk\Chat\ChatManager;
|
||||
use OCA\Talk\Chat\ReactionManager;
|
||||
use OCA\Talk\Events\BotDisabledEvent;
|
||||
use OCA\Talk\Events\BotEnabledEvent;
|
||||
use OCA\Talk\Exceptions\ReactionAlreadyExistsException;
|
||||
use OCA\Talk\Exceptions\ReactionNotSupportedException;
|
||||
use OCA\Talk\Exceptions\ReactionOutOfContextException;
|
||||
use OCA\Talk\Exceptions\UnauthorizedException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Middleware\Attribute\RequireLoggedInModeratorParticipant;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Model\Bot;
|
||||
use OCA\Talk\Model\BotConversation;
|
||||
use OCA\Talk\Model\BotConversationMapper;
|
||||
use OCA\Talk\Model\BotServer;
|
||||
use OCA\Talk\Model\BotServerMapper;
|
||||
use OCA\Talk\Model\Thread;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\BotService;
|
||||
use OCA\Talk\Service\ChecksumVerificationService;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\ThreadService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||||
use OCP\AppFramework\Http\Attribute\RequestHeader;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\Comments\MessageTooLongException;
|
||||
use OCP\Comments\NotFoundException;
|
||||
use OCP\EventDispatcher\IEventDispatcher;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkBot from ResponseDefinitions
|
||||
* @psalm-import-type TalkBotWithDetails from ResponseDefinitions
|
||||
*/
|
||||
class BotController extends AEnvironmentAwareOCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
protected ChatManager $chatManager,
|
||||
protected ParticipantService $participantService,
|
||||
protected ITimeFactory $timeFactory,
|
||||
protected ChecksumVerificationService $checksumVerificationService,
|
||||
protected BotConversationMapper $botConversationMapper,
|
||||
protected BotServerMapper $botServerMapper,
|
||||
protected BotService $botService,
|
||||
protected Manager $manager,
|
||||
protected ReactionManager $reactionManager,
|
||||
protected ThreadService $threadService,
|
||||
protected IL10N $l,
|
||||
protected LoggerInterface $logger,
|
||||
private IEventDispatcher $dispatcher,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $token
|
||||
* @param string $message
|
||||
* @return Bot
|
||||
* @throws \InvalidArgumentException When the request could not be linked with a bot
|
||||
*/
|
||||
#[RequestHeader(name: 'x-nextcloud-talk-bot-random', description: 'Random seed used to generate the request signature')]
|
||||
#[RequestHeader(name: 'x-nextcloud-talk-bot-signature', description: 'Signature over the request body to verify authenticity')]
|
||||
protected function getBotFromHeaders(string $token, string $message): Bot {
|
||||
$random = $this->request->getHeader('x-nextcloud-talk-bot-random');
|
||||
if (empty($random) || strlen($random) < 32) {
|
||||
$this->logger->error('Invalid Random received from bot response');
|
||||
throw new \InvalidArgumentException('Invalid Random received from bot response', Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
$checksum = $this->request->getHeader('x-nextcloud-talk-bot-signature');
|
||||
if (empty($checksum)) {
|
||||
$this->logger->error('Invalid Signature received from bot response');
|
||||
throw new \InvalidArgumentException('Invalid Signature received from bot response', Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$bots = $this->botService->getBotsForToken($token, Bot::FEATURE_RESPONSE);
|
||||
foreach ($bots as $botAttempt) {
|
||||
try {
|
||||
$this->checksumVerificationService->validateRequest(
|
||||
$random,
|
||||
$checksum,
|
||||
$botAttempt->getBotServer()->getSecret(),
|
||||
$message
|
||||
);
|
||||
|
||||
if (!($botAttempt->getBotServer()->getFeatures() & Bot::FEATURE_RESPONSE)) {
|
||||
$this->logger->debug('Not accepting response from bot ID ' . $botAttempt->getBotServer()->getId() . ' because the feature is disabled for it');
|
||||
throw new \InvalidArgumentException('Feature not enabled for bot', Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return $botAttempt;
|
||||
} catch (UnauthorizedException) {
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->debug('No valid Bot entry found');
|
||||
throw new \InvalidArgumentException('No valid Bot entry found', Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a new chat message to the given room
|
||||
*
|
||||
* The author and timestamp are automatically set to the current user/guest
|
||||
* and time.
|
||||
*
|
||||
* @param string $token Conversation token
|
||||
* @param string $message The message to send
|
||||
* @param string $referenceId For the message to be able to later identify it again
|
||||
* @param int $replyTo Parent id which this message is a reply to
|
||||
* @param bool $silent If sent silent the chat message will not create any notifications
|
||||
* @param string $threadTitle Only supported when not replying, when given will create a thread (requires `threads` capability)
|
||||
* @param int $threadId Thread id which this message is a reply to without quoting a specific message (ignored when $replyTo is given, also requires `threads` capability)
|
||||
* @return DataResponse<Http::STATUS_CREATED|Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED|Http::STATUS_REQUEST_ENTITY_TOO_LARGE, null, array{}>
|
||||
*
|
||||
* 201: Message sent successfully
|
||||
* 400: When the replyTo is invalid or message is empty
|
||||
* 401: Sending message is not allowed
|
||||
* 413: Message too long
|
||||
*/
|
||||
#[BruteForceProtection(action: 'bot')]
|
||||
#[OpenAPI(scope: 'bots')]
|
||||
#[PublicPage]
|
||||
public function sendMessage(string $token, string $message, string $referenceId = '', int $replyTo = 0, bool $silent = false, string $threadTitle = '', int $threadId = 0): DataResponse {
|
||||
if (trim($message) === '') {
|
||||
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$bot = $this->getBotFromHeaders($token, $message);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
/** @var Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED $status */
|
||||
$status = $e->getCode();
|
||||
$response = new DataResponse(null, $status);
|
||||
if ($e->getCode() === Http::STATUS_UNAUTHORIZED) {
|
||||
$response->throttle(['action' => 'bot']);
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
$room = $this->manager->getRoomByToken($token);
|
||||
|
||||
$actorType = Attendee::ACTOR_BOTS;
|
||||
$actorId = Attendee::ACTOR_BOT_PREFIX . $bot->getBotServer()->getUrlHash();
|
||||
|
||||
$parent = null;
|
||||
if ($replyTo !== 0) {
|
||||
try {
|
||||
$parent = $this->chatManager->getParentComment($room, (string)$replyTo);
|
||||
} catch (NotFoundException $e) {
|
||||
// Someone is trying to reply cross-rooms or to a non-existing message
|
||||
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
} elseif ($threadId !== Thread::THREAD_NONE && $threadId !== Thread::THREAD_CREATE) {
|
||||
if (!$this->threadService->validateThread($room->getId(), $threadId)) {
|
||||
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
$this->participantService->ensureOneToOneRoomIsFilled($room);
|
||||
$creationDateTime = $this->timeFactory->getDateTime('now', new \DateTimeZone('UTC'));
|
||||
|
||||
try {
|
||||
$createThread = $replyTo === 0 && $threadId === Thread::THREAD_NONE && $threadTitle !== '';
|
||||
$threadId = $createThread ? Thread::THREAD_CREATE : $threadId;
|
||||
$comment = $this->chatManager->sendMessage($room, null, $actorType, $actorId, $message, $creationDateTime, $parent, $referenceId, $silent, false, $threadId);
|
||||
if ($createThread) {
|
||||
$thread = $this->threadService->createThread($room, (int)$comment->getId(), $threadTitle);
|
||||
|
||||
$this->chatManager->addSystemMessage(
|
||||
$room,
|
||||
null,
|
||||
$actorType,
|
||||
$actorId,
|
||||
json_encode(['message' => 'thread_created', 'parameters' => ['thread' => (int)$comment->getId(), 'title' => $thread->getName()]]),
|
||||
$this->timeFactory->getDateTime(),
|
||||
false,
|
||||
null,
|
||||
$comment,
|
||||
true,
|
||||
true
|
||||
);
|
||||
}
|
||||
} catch (MessageTooLongException) {
|
||||
return new DataResponse(null, Http::STATUS_REQUEST_ENTITY_TOO_LARGE);
|
||||
} catch (\Exception) {
|
||||
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new DataResponse(null, Http::STATUS_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a reaction to a chat message
|
||||
*
|
||||
* @param string $token Conversation token
|
||||
* @param int $messageId ID of the message
|
||||
* @param string $reaction Reaction to add
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_CREATED|Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND, null, array{}>
|
||||
*
|
||||
* 200: Reaction already exists
|
||||
* 201: Reacted successfully
|
||||
* 400: Reacting is not possible
|
||||
* 401: Reacting is not allowed
|
||||
* 404: Reaction not found
|
||||
*/
|
||||
#[BruteForceProtection(action: 'bot')]
|
||||
#[OpenAPI(scope: 'bots')]
|
||||
#[PublicPage]
|
||||
public function react(string $token, int $messageId, string $reaction): DataResponse {
|
||||
try {
|
||||
$bot = $this->getBotFromHeaders($token, $reaction);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
/** @var Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED $status */
|
||||
$status = $e->getCode();
|
||||
$response = new DataResponse(null, $status);
|
||||
if ($e->getCode() === Http::STATUS_UNAUTHORIZED) {
|
||||
$response->throttle(['action' => 'bot']);
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
$room = $this->manager->getRoomByToken($token);
|
||||
|
||||
$actorType = Attendee::ACTOR_BOTS;
|
||||
$actorId = Attendee::ACTOR_BOT_PREFIX . $bot->getBotServer()->getUrlHash();
|
||||
|
||||
try {
|
||||
$this->reactionManager->addReactionMessage(
|
||||
$room,
|
||||
$actorType,
|
||||
$actorId,
|
||||
$bot->getBotServer()->getName(),
|
||||
$messageId,
|
||||
$reaction
|
||||
);
|
||||
} catch (NotFoundException) {
|
||||
return new DataResponse(null, Http::STATUS_NOT_FOUND);
|
||||
} catch (ReactionAlreadyExistsException) {
|
||||
return new DataResponse(null, Http::STATUS_OK);
|
||||
} catch (ReactionNotSupportedException|ReactionOutOfContextException|\Exception) {
|
||||
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new DataResponse(null, Http::STATUS_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a reaction from a chat message
|
||||
*
|
||||
* @param string $token Conversation token
|
||||
* @param int $messageId ID of the message
|
||||
* @param string $reaction Reaction to delete
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_UNAUTHORIZED, null, array{}>
|
||||
*
|
||||
* 200: Reaction deleted successfully
|
||||
* 400: Reacting is not possible
|
||||
* 401: Reacting is not allowed
|
||||
* 404: Reaction not found
|
||||
*/
|
||||
#[BruteForceProtection(action: 'bot')]
|
||||
#[OpenAPI(scope: 'bots')]
|
||||
#[PublicPage]
|
||||
public function deleteReaction(string $token, int $messageId, string $reaction): DataResponse {
|
||||
try {
|
||||
$bot = $this->getBotFromHeaders($token, $reaction);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
/** @var Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED $status */
|
||||
$status = $e->getCode();
|
||||
$response = new DataResponse(null, $status);
|
||||
if ($e->getCode() === Http::STATUS_UNAUTHORIZED) {
|
||||
$response->throttle(['action' => 'bot']);
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
$room = $this->manager->getRoomByToken($token);
|
||||
|
||||
$actorType = Attendee::ACTOR_BOTS;
|
||||
$actorId = Attendee::ACTOR_BOT_PREFIX . $bot->getBotServer()->getUrlHash();
|
||||
|
||||
try {
|
||||
$this->reactionManager->deleteReactionMessage(
|
||||
$room,
|
||||
$actorType,
|
||||
$actorId,
|
||||
$bot->getBotServer()->getName(),
|
||||
$messageId,
|
||||
$reaction
|
||||
);
|
||||
} catch (ReactionNotSupportedException|ReactionOutOfContextException|NotFoundException) {
|
||||
return new DataResponse(null, Http::STATUS_NOT_FOUND);
|
||||
} catch (\Exception) {
|
||||
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new DataResponse(null, Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* List admin bots
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkBotWithDetails>, array{}>
|
||||
*
|
||||
* 200: Bot list returned
|
||||
*/
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['settings'])]
|
||||
public function adminListBots(): DataResponse {
|
||||
$data = [];
|
||||
$bots = $this->botServerMapper->getAllBots();
|
||||
foreach ($bots as $bot) {
|
||||
$botData = $bot->jsonSerialize();
|
||||
unset($botData['secret']);
|
||||
|
||||
if (!$this->botService->isAppForBotEnabled($bot)) {
|
||||
$botData['state'] = Bot::STATE_UNAVAILABLE;
|
||||
$botData['error_count'] = 1;
|
||||
$botData['last_error_date'] = $this->timeFactory->getTime();
|
||||
$botData['last_error_message'] = $this->l->t('App disabled');
|
||||
}
|
||||
|
||||
$data[] = $botData;
|
||||
}
|
||||
|
||||
return new DataResponse($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* List bots
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkBot>, array{}>
|
||||
*
|
||||
* 200: Bot list returned
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInModeratorParticipant]
|
||||
public function listBots(): DataResponse {
|
||||
$alreadyInstalled = array_map(static function (BotConversation $bot): int {
|
||||
return $bot->getBotId();
|
||||
}, $this->botConversationMapper->findForToken($this->room->getToken()));
|
||||
|
||||
$data = [];
|
||||
$bots = $this->botServerMapper->getAllBots();
|
||||
foreach ($bots as $bot) {
|
||||
$botData = $this->formatBot($bot, in_array($bot->getId(), $alreadyInstalled, true));
|
||||
|
||||
if (!$this->botService->isAppForBotEnabled($bot)) {
|
||||
if ($botData['state'] !== Bot::STATE_DISABLED) {
|
||||
$botData['state'] = Bot::STATE_UNAVAILABLE;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($botData !== null) {
|
||||
$data[] = $botData;
|
||||
}
|
||||
}
|
||||
|
||||
return new DataResponse($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables a bot
|
||||
*
|
||||
* @param int $botId ID of the bot
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_CREATED, ?TalkBot, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Bot already enabled
|
||||
* 201: Bot enabled successfully
|
||||
* 400: Enabling bot errored
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInModeratorParticipant]
|
||||
public function enableBot(int $botId): DataResponse {
|
||||
if ($this->room->isFederatedConversation() || $this->room->getType() === ROOM::TYPE_ONE_TO_ONE_FORMER) {
|
||||
return new DataResponse([
|
||||
'error' => 'room',
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$bot = $this->botServerMapper->findById($botId);
|
||||
} catch (DoesNotExistException) {
|
||||
return new DataResponse([
|
||||
'error' => 'bot',
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($bot->getState() !== Bot::STATE_ENABLED || !$this->botService->isAppForBotEnabled($bot)) {
|
||||
return new DataResponse([
|
||||
'error' => 'bot',
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$alreadyInstalled = array_map(static function (BotConversation $bot): int {
|
||||
return $bot->getBotId();
|
||||
}, $this->botConversationMapper->findForToken($this->room->getToken()));
|
||||
|
||||
if (in_array($botId, $alreadyInstalled)) {
|
||||
return new DataResponse($this->formatBot($bot, true), Http::STATUS_OK);
|
||||
}
|
||||
|
||||
$conversationBot = new BotConversation();
|
||||
$conversationBot->setBotId($botId);
|
||||
$conversationBot->setToken($this->room->getToken());
|
||||
$conversationBot->setState(Bot::STATE_ENABLED);
|
||||
|
||||
$this->botConversationMapper->insert($conversationBot);
|
||||
|
||||
$event = new BotEnabledEvent($this->room, $bot);
|
||||
$this->dispatcher->dispatchTyped($event);
|
||||
|
||||
return new DataResponse($this->formatBot($bot, true), Http::STATUS_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables a bot
|
||||
*
|
||||
* @param int $botId ID of the bot
|
||||
* @return DataResponse<Http::STATUS_OK, ?TalkBot, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Bot disabled successfully
|
||||
* 400: Disabling bot errored
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInModeratorParticipant]
|
||||
public function disableBot(int $botId): DataResponse {
|
||||
try {
|
||||
$bot = $this->botServerMapper->findById($botId);
|
||||
} catch (DoesNotExistException) {
|
||||
return new DataResponse([
|
||||
'error' => 'bot',
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($bot->getState() !== Bot::STATE_ENABLED) {
|
||||
return new DataResponse([
|
||||
'error' => 'bot',
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$this->botConversationMapper->deleteByBotIdAndTokens($botId, [$this->room->getToken()]);
|
||||
|
||||
$event = new BotDisabledEvent($this->room, $bot);
|
||||
$this->dispatcher->dispatchTyped($event);
|
||||
|
||||
return new DataResponse($this->formatBot($bot, false), Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param BotServer $bot
|
||||
* @param bool $conversationEnabled
|
||||
* @return array|null
|
||||
* @psalm-return ?TalkBot
|
||||
*/
|
||||
protected function formatBot(BotServer $bot, bool $conversationEnabled): ?array {
|
||||
$state = $conversationEnabled ? Bot::STATE_ENABLED : Bot::STATE_DISABLED;
|
||||
|
||||
if ($bot->getState() === Bot::STATE_NO_SETUP) {
|
||||
if ($state === Bot::STATE_DISABLED) {
|
||||
return null;
|
||||
}
|
||||
$state = Bot::STATE_NO_SETUP;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $bot->getId(),
|
||||
'name' => $bot->getName(),
|
||||
'description' => $bot->getDescription(),
|
||||
'state' => $state,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Middleware\Attribute\RequireLoggedInModeratorParticipant;
|
||||
use OCA\Talk\Middleware\Attribute\RequireLoggedInParticipant;
|
||||
use OCA\Talk\Model\BreakoutRoom;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Service\BreakoutRoomService;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\RoomFormatter;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\Comments\MessageTooLongException;
|
||||
use OCP\IRequest;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkRoom from ResponseDefinitions
|
||||
*/
|
||||
class BreakoutRoomController extends AEnvironmentAwareOCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
protected BreakoutRoomService $breakoutRoomService,
|
||||
protected ParticipantService $participantService,
|
||||
protected RoomFormatter $roomFormatter,
|
||||
protected ?string $userId,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the breakout rooms
|
||||
*
|
||||
* @param 0|1|2|3 $mode Mode of the breakout rooms
|
||||
* @psalm-param BreakoutRoom::MODE_* $mode
|
||||
* @param int<1, 20> $amount Number of breakout rooms - Constants {@see BreakoutRoom::MINIMUM_ROOM_AMOUNT} and {@see BreakoutRoom::MAXIMUM_ROOM_AMOUNT}
|
||||
* @param string $attendeeMap Mapping of the attendees to breakout rooms
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkRoom>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Breakout rooms configured successfully
|
||||
* 400: Configuring breakout rooms errored
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInModeratorParticipant]
|
||||
public function configureBreakoutRooms(int $mode, int $amount, string $attendeeMap = '[]'): DataResponse {
|
||||
try {
|
||||
$rooms = $this->breakoutRoomService->setupBreakoutRooms($this->room, $mode, $amount, $attendeeMap);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rooms[] = $this->room;
|
||||
return new DataResponse($this->formatMultipleRooms($rooms), Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the breakout rooms
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>
|
||||
*
|
||||
* 200: Breakout rooms removed successfully
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInModeratorParticipant]
|
||||
public function removeBreakoutRooms(): DataResponse {
|
||||
$this->breakoutRoomService->removeBreakoutRooms($this->room);
|
||||
|
||||
return new DataResponse($this->roomFormatter->formatRoom(
|
||||
$this->getResponseFormat(),
|
||||
[],
|
||||
$this->room,
|
||||
$this->participant,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a chat message to all breakout rooms
|
||||
*
|
||||
* @param string $message Message to broadcast
|
||||
* @return DataResponse<Http::STATUS_CREATED, list<TalkRoom>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_REQUEST_ENTITY_TOO_LARGE, array{error: string}, array{}>
|
||||
*
|
||||
* 201: Chat message broadcasted successfully
|
||||
* 400: Broadcasting chat message is not possible
|
||||
* 413: Chat message too long
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInModeratorParticipant]
|
||||
public function broadcastChatMessage(string $message): DataResponse {
|
||||
try {
|
||||
$rooms = $this->breakoutRoomService->broadcastChatMessage($this->room, $this->participant, $message);
|
||||
} catch (MessageTooLongException $e) {
|
||||
return new DataResponse(['error' => 'message'], Http::STATUS_REQUEST_ENTITY_TOO_LARGE);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
$rooms[] = $this->room;
|
||||
return new DataResponse($this->formatMultipleRooms($rooms), Http::STATUS_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an attendee map to the breakout rooms
|
||||
*
|
||||
* @param string $attendeeMap JSON encoded mapping of the attendees to breakout rooms `array<int, int>`
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkRoom>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Attendee map applied successfully
|
||||
* 400: Applying attendee map is not possible
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInModeratorParticipant]
|
||||
public function applyAttendeeMap(string $attendeeMap): DataResponse {
|
||||
try {
|
||||
$rooms = $this->breakoutRoomService->applyAttendeeMap($this->room, $attendeeMap);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
$rooms[] = $this->room;
|
||||
return new DataResponse($this->formatMultipleRooms($rooms), Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request assistance
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Assistance requested successfully
|
||||
* 400: Requesting assistance is not possible
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInParticipant]
|
||||
public function requestAssistance(): DataResponse {
|
||||
try {
|
||||
$this->breakoutRoomService->requestAssistance($this->room);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new DataResponse($this->roomFormatter->formatRoom(
|
||||
$this->getResponseFormat(),
|
||||
[],
|
||||
$this->room,
|
||||
$this->participant,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the request for assistance
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Request for assistance reset successfully
|
||||
* 400: Resetting the request for assistance is not possible
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInParticipant]
|
||||
public function resetRequestForAssistance(): DataResponse {
|
||||
try {
|
||||
$this->breakoutRoomService->resetRequestForAssistance($this->room);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new DataResponse($this->roomFormatter->formatRoom(
|
||||
$this->getResponseFormat(),
|
||||
[],
|
||||
$this->room,
|
||||
$this->participant,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the breakout rooms
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkRoom>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Breakout rooms started successfully
|
||||
* 400: Starting breakout rooms is not possible
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInModeratorParticipant]
|
||||
public function startBreakoutRooms(): DataResponse {
|
||||
try {
|
||||
$rooms = $this->breakoutRoomService->startBreakoutRooms($this->room);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rooms[] = $this->room;
|
||||
return new DataResponse($this->formatMultipleRooms($rooms), Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the breakout rooms
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkRoom>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Breakout rooms stopped successfully
|
||||
* 400: Stopping breakout rooms is not possible
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInModeratorParticipant]
|
||||
public function stopBreakoutRooms(): DataResponse {
|
||||
try {
|
||||
$rooms = $this->breakoutRoomService->stopBreakoutRooms($this->room);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$rooms[] = $this->room;
|
||||
return new DataResponse($this->formatMultipleRooms($rooms), Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to another breakout room
|
||||
*
|
||||
* @param string $target Target breakout room
|
||||
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Switched to breakout room successfully
|
||||
* 400: Switching to breakout room is not possible
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInParticipant]
|
||||
public function switchBreakoutRoom(string $target): DataResponse {
|
||||
try {
|
||||
$room = $this->breakoutRoomService->switchBreakoutRoom($this->room, $this->participant, $target);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new DataResponse($this->roomFormatter->formatRoom(
|
||||
$this->getResponseFormat(),
|
||||
[],
|
||||
$room,
|
||||
$this->participant,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<TalkRoom>
|
||||
*/
|
||||
protected function formatMultipleRooms(array $rooms): array {
|
||||
$return = [];
|
||||
foreach ($rooms as $room) {
|
||||
try {
|
||||
$return[] = $this->roomFormatter->formatRoom(
|
||||
$this->getResponseFormat(),
|
||||
[],
|
||||
$room,
|
||||
$this->participantService->getParticipant($room, $this->userId),
|
||||
[],
|
||||
false,
|
||||
true
|
||||
);
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
}
|
||||
}
|
||||
return $return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use OCA\Talk\Exceptions\InvalidRoomException;
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Middleware\Attribute\RequireParticipant;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Service\CalendarIntegrationService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkDashboardEvent from ResponseDefinitions
|
||||
*/
|
||||
class CalendarIntegrationController extends AEnvironmentAwareOCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
protected IUserSession $userSession,
|
||||
protected LoggerInterface $logger,
|
||||
protected CalendarIntegrationService $service,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get up to 10 rooms that have events in the next 7 days
|
||||
* sorted by their start timestamp ascending
|
||||
*
|
||||
* Required capability: `dashboard-event-rooms`
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkDashboardEvent>, array{}>
|
||||
*
|
||||
* 200: A list of dashboard entries or an empty array
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function getDashboardEvents(): DataResponse {
|
||||
$userId = $this->userSession->getUser()?->getUID();
|
||||
$entries = $this->service->getDashboardEvents($userId);
|
||||
return new DataResponse($entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get up to 3 events in the next 7 days
|
||||
* sorted by their start timestamp ascending
|
||||
*
|
||||
* Required capability: `mutual-calendar-events`
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkDashboardEvent>, array{}>|DataResponse<Http::STATUS_FORBIDDEN, null, array{}>
|
||||
*
|
||||
* 200: A list of dashboard entries or an empty array
|
||||
* 403: Room is not a 1 to 1 room, room is invalid, or user is not participant
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireParticipant]
|
||||
public function getMutualEvents(): DataResponse {
|
||||
$userId = $this->userSession->getUser()?->getUID();
|
||||
try {
|
||||
$entries = $this->service->getMutualEvents($userId, $this->room);
|
||||
} catch (InvalidRoomException|ParticipantNotFoundException) {
|
||||
return new DataResponse(null, Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
return new DataResponse($entries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use OCA\Talk\Config;
|
||||
use OCA\Talk\Exceptions\DialOutFailedException;
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Federation\Authenticator;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Middleware\Attribute\FederationSupported;
|
||||
use OCA\Talk\Middleware\Attribute\RequireCallEnabled;
|
||||
use OCA\Talk\Middleware\Attribute\RequireFederatedParticipant;
|
||||
use OCA\Talk\Middleware\Attribute\RequireModeratorOrNoLobby;
|
||||
use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant;
|
||||
use OCA\Talk\Middleware\Attribute\RequireParticipant;
|
||||
use OCA\Talk\Middleware\Attribute\RequirePermission;
|
||||
use OCA\Talk\Middleware\Attribute\RequireReadWriteConversation;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Model\PhoneNumberMapper;
|
||||
use OCA\Talk\Model\Session;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Service\ConsentService;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\RecordingService;
|
||||
use OCA\Talk\Service\RoomService;
|
||||
use OCA\Talk\Service\SIPDialOutService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||||
use OCP\AppFramework\Http\Attribute\RequestHeader;
|
||||
use OCP\AppFramework\Http\DataDownloadResponse;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Http\Response;
|
||||
use OCP\AppFramework\Services\IAppConfig;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\IConfig;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserManager;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkCallPeer from ResponseDefinitions
|
||||
*/
|
||||
class CallController extends AEnvironmentAwareOCSController {
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
protected Manager $manager,
|
||||
private ConsentService $consentService,
|
||||
private ParticipantService $participantService,
|
||||
private PhoneNumberMapper $phoneNumberMapper,
|
||||
private RoomService $roomService,
|
||||
private IUserManager $userManager,
|
||||
private ITimeFactory $timeFactory,
|
||||
private IConfig $serverConfig,
|
||||
private IAppConfig $appConfig,
|
||||
private Config $talkConfig,
|
||||
protected Authenticator $federationAuthenticator,
|
||||
private SIPDialOutService $dialOutService,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the peers for a call
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkCallPeer>, array{}>
|
||||
*
|
||||
* 200: List of peers in the call returned
|
||||
*/
|
||||
#[FederationSupported]
|
||||
#[PublicPage]
|
||||
#[RequireCallEnabled]
|
||||
#[RequireModeratorOrNoLobby]
|
||||
#[RequireParticipant]
|
||||
#[RequireReadWriteConversation]
|
||||
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
|
||||
public function getPeersForCall(): DataResponse {
|
||||
if ($this->room->isFederatedConversation()) {
|
||||
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController $proxy */
|
||||
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController::class);
|
||||
return $proxy->getPeersForCall($this->room, $this->participant);
|
||||
}
|
||||
|
||||
$timeout = $this->timeFactory->getTime() - Session::SESSION_TIMEOUT;
|
||||
$result = [];
|
||||
$participants = $this->participantService->getParticipantsInCall($this->room, $timeout);
|
||||
|
||||
foreach ($participants as $participant) {
|
||||
$displayName = $participant->getAttendee()->getActorId();
|
||||
if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
|
||||
if ($participant->getAttendee()->getDisplayName()) {
|
||||
$displayName = $participant->getAttendee()->getDisplayName();
|
||||
} else {
|
||||
$userDisplayName = $this->userManager->getDisplayName($participant->getAttendee()->getActorId());
|
||||
if ($userDisplayName !== null) {
|
||||
$displayName = $userDisplayName;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$displayName = $participant->getAttendee()->getDisplayName();
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'actorType' => $participant->getAttendee()->getActorType(),
|
||||
'actorId' => $participant->getAttendee()->getActorId(),
|
||||
'displayName' => $displayName,
|
||||
'token' => $this->room->getToken(),
|
||||
'lastPing' => $participant->getSession()->getLastPing(),
|
||||
'sessionId' => $participant->getSession()->getSessionId(),
|
||||
];
|
||||
}
|
||||
|
||||
return new DataResponse($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the list of current call participants
|
||||
*
|
||||
* Required capability: `download-call-participants`
|
||||
*
|
||||
* @param 'csv' $format Download format
|
||||
* @return DataDownloadResponse<Http::STATUS_OK, 'text/csv', array{}>|Response<Http::STATUS_BAD_REQUEST, array{}>
|
||||
*
|
||||
* 200: List of participants in the call downloaded in the requested format
|
||||
* 400: No call in progress
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[RequireModeratorParticipant]
|
||||
#[NoCSRFRequired]
|
||||
public function downloadParticipantsForCall(string $format = 'csv'): DataDownloadResponse|Response {
|
||||
$callStart = $this->room->getActiveSince()?->getTimestamp() ?? 0;
|
||||
if ($callStart === 0) {
|
||||
return new Response(Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
$participants = $this->participantService->getParticipantsJoinedCurrentCall($this->room, $callStart);
|
||||
|
||||
if (empty($participants)) {
|
||||
return new Response(Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($format !== 'csv') {
|
||||
// Unsupported format
|
||||
return new Response(Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$output = fopen('php://memory', 'w');
|
||||
fputcsv($output, [
|
||||
'name',
|
||||
'email',
|
||||
'type',
|
||||
'identifier',
|
||||
], escape: '');
|
||||
|
||||
foreach ($participants as $participant) {
|
||||
$email = '';
|
||||
if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_EMAILS) {
|
||||
$email = $participant->getAttendee()->getInvitedCloudId();
|
||||
} elseif ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
|
||||
$email = $this->userManager->get($participant->getAttendee()->getActorId())?->getEMailAddress() ?? '';
|
||||
}
|
||||
fputcsv($output, array_map([$this, 'escapeFormulae'], [
|
||||
$participant->getAttendee()->getDisplayName(),
|
||||
$email,
|
||||
$participant->getAttendee()->getActorType(),
|
||||
$participant->getAttendee()->getActorId(),
|
||||
]), escape: '');
|
||||
}
|
||||
|
||||
fseek($output, 0);
|
||||
|
||||
// Clean the room name
|
||||
$cleanedRoomName = preg_replace('/[\/\\\\:*?"<>|\- ]+/', '-', $this->room->getName());
|
||||
// Limit to a reasonable length
|
||||
$cleanedRoomName = substr($cleanedRoomName, 0, 100);
|
||||
|
||||
$timezone = 'UTC';
|
||||
if ($this->participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
|
||||
$timezone = $this->serverConfig->getUserValue($this->participant->getAttendee()->getActorId(), 'core', 'timezone', 'UTC');
|
||||
}
|
||||
|
||||
try {
|
||||
$dateTimeZone = new \DateTimeZone($timezone);
|
||||
} catch (\Throwable) {
|
||||
$dateTimeZone = null;
|
||||
}
|
||||
|
||||
$date = $this->timeFactory->getDateTime('now', $dateTimeZone)->format('Y-m-d');
|
||||
$fileName = $cleanedRoomName . ' ' . $date . '.csv';
|
||||
|
||||
return new DataDownloadResponse(stream_get_contents($output), $fileName, 'text/csv');
|
||||
}
|
||||
|
||||
protected function escapeFormulae(string $value): string {
|
||||
if (preg_match('/^[=+\-@\t\r]/', $value)) {
|
||||
return "'" . $value;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a call
|
||||
*
|
||||
* @param int<0, 15>|null $flags In-Call flags
|
||||
* @psalm-param int-mask-of<Participant::FLAG_*>|null $flags
|
||||
* @param bool $silent Join the call silently
|
||||
* @param bool $recordingConsent When the user ticked a checkbox and agreed with being recorded
|
||||
* (Only needed when the `config => call => recording-consent` capability is set to {@see RecordingService::CONSENT_REQUIRED_YES}
|
||||
* or the capability is {@see RecordingService::CONSENT_REQUIRED_OPTIONAL}
|
||||
* and the conversation `recordingConsent` value is {@see RecordingService::CONSENT_REQUIRED_YES} )
|
||||
* @param list<string> $silentFor Send no call notification for previous participants
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Call joined successfully
|
||||
* 400: No recording consent was given
|
||||
* 404: Call not found
|
||||
*/
|
||||
#[FederationSupported]
|
||||
#[PublicPage]
|
||||
#[RequireCallEnabled]
|
||||
#[RequireModeratorOrNoLobby]
|
||||
#[RequireParticipant]
|
||||
#[RequireReadWriteConversation]
|
||||
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
|
||||
public function joinCall(?int $flags = null, bool $silent = false, bool $recordingConsent = false, array $silentFor = []): DataResponse {
|
||||
try {
|
||||
$this->validateRecordingConsent($recordingConsent);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return new DataResponse(['error' => 'consent'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$this->participantService->ensureOneToOneRoomIsFilled($this->room);
|
||||
|
||||
$session = $this->participant->getSession();
|
||||
if (!$session instanceof Session) {
|
||||
return new DataResponse(null, Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ($flags === null) {
|
||||
// Default flags: user is in room with audio/video.
|
||||
$flags = Participant::FLAG_IN_CALL | Participant::FLAG_WITH_AUDIO | Participant::FLAG_WITH_VIDEO;
|
||||
}
|
||||
$lastJoinedCall = $this->timeFactory->getDateTime();
|
||||
|
||||
if ($this->room->isFederatedConversation()) {
|
||||
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController $proxy */
|
||||
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController::class);
|
||||
$response = $proxy->joinFederatedCall($this->room, $this->participant, $flags, $silent, $recordingConsent);
|
||||
|
||||
if ($response->getStatus() === Http::STATUS_OK) {
|
||||
$this->participantService->changeInCall($this->room, $this->participant, $flags, silent: $silent, lastJoinedCall: $lastJoinedCall->getTimestamp());
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->participantService->changeInCall($this->room, $this->participant, $flags, silent: $silent, lastJoinedCall: $lastJoinedCall->getTimestamp());
|
||||
$this->roomService->setActiveSince($this->room, $this->participant, $lastJoinedCall, $flags, silent: $silent, silentFor: $silentFor);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
return new DataResponse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and stores recording consent.
|
||||
*
|
||||
* @throws \InvalidArgumentException if recording consent is required but
|
||||
* not given
|
||||
*/
|
||||
protected function validateRecordingConsent(bool $recordingConsent): void {
|
||||
if (!$recordingConsent && $this->talkConfig->recordingConsentRequired() !== RecordingService::CONSENT_REQUIRED_NO) {
|
||||
if ($this->talkConfig->recordingConsentRequired() === RecordingService::CONSENT_REQUIRED_YES) {
|
||||
throw new \InvalidArgumentException();
|
||||
}
|
||||
if ($this->talkConfig->recordingConsentRequired() === RecordingService::CONSENT_REQUIRED_OPTIONAL
|
||||
&& $this->room->getRecordingConsent() === RecordingService::CONSENT_REQUIRED_YES) {
|
||||
throw new \InvalidArgumentException();
|
||||
}
|
||||
} elseif ($recordingConsent && $this->talkConfig->recordingConsentRequired() !== RecordingService::CONSENT_REQUIRED_NO) {
|
||||
$attendee = $this->participant->getAttendee();
|
||||
$this->consentService->storeConsent($this->room, $attendee->getActorType(), $attendee->getActorId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join call on the host server using the session id of the federated user
|
||||
*
|
||||
* @param string $sessionId Federated session id to join with
|
||||
* @param int<0, 15>|null $flags In-Call flags
|
||||
* @psalm-param int-mask-of<Participant::FLAG_*>|null $flags
|
||||
* @param bool $silent Join the call silently
|
||||
* @param bool $recordingConsent Agreement to be recorded
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Call joined successfully
|
||||
* 400: Conditions to join not met
|
||||
* 404: Call not found
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[RequireCallEnabled]
|
||||
#[RequireModeratorOrNoLobby]
|
||||
#[RequireFederatedParticipant]
|
||||
#[RequireReadWriteConversation]
|
||||
#[BruteForceProtection(action: 'talkFederationAccess')]
|
||||
#[BruteForceProtection(action: 'talkRoomToken')]
|
||||
public function joinFederatedCall(string $sessionId, ?int $flags = null, bool $silent = false, bool $recordingConsent = false): DataResponse {
|
||||
if (!$this->federationAuthenticator->isFederationRequest()) {
|
||||
$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
|
||||
$response->throttle(['token' => $this->room->getToken(), 'action' => 'talkRoomToken']);
|
||||
return $response;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->validateRecordingConsent($recordingConsent);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return new DataResponse(['error' => 'consent'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->participantService->changeInCall($this->room, $this->participant, $flags, false, $silent);
|
||||
$this->roomService->setActiveSince($this->room, $this->participant, $this->timeFactory->getDateTime(), $flags, silent: $silent);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new DataResponse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ring an attendee
|
||||
*
|
||||
* @param int $attendeeId ID of the attendee to ring
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Attendee rang successfully
|
||||
* 400: Ringing attendee is not possible
|
||||
* 404: Attendee could not be found
|
||||
*/
|
||||
#[FederationSupported]
|
||||
#[PublicPage]
|
||||
#[RequireCallEnabled]
|
||||
#[RequireParticipant]
|
||||
#[RequirePermission(permission: RequirePermission::START_CALL)]
|
||||
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
|
||||
public function ringAttendee(int $attendeeId): DataResponse {
|
||||
if ($this->room->isFederatedConversation()) {
|
||||
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController $proxy */
|
||||
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController::class);
|
||||
return $proxy->ringAttendee($this->room, $this->participant, $attendeeId);
|
||||
}
|
||||
|
||||
if ($this->room->getCallFlag() === Participant::FLAG_DISCONNECTED) {
|
||||
return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($this->participant->getSession() && $this->participant->getSession()->getInCall() === Participant::FLAG_DISCONNECTED) {
|
||||
return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->participantService->sendCallNotificationForAttendee($this->room, $this->participant, $attendeeId);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
} catch (DoesNotExistException) {
|
||||
return new DataResponse(null, Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
return new DataResponse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a SIP dial-out attendee
|
||||
*
|
||||
* @param int $attendeeId ID of the attendee to call
|
||||
* @return DataResponse<Http::STATUS_CREATED|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, null, array{}>|DataResponse<Http::STATUS_NOT_IMPLEMENTED, array{error: string, message?: string}, array{}>
|
||||
*
|
||||
* 201: Dial-out initiated successfully
|
||||
* 400: SIP dial-out not possible
|
||||
* 404: Participant could not be found or is a wrong type
|
||||
* 501: SIP dial-out is not configured on the server
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[RequireCallEnabled]
|
||||
#[RequireParticipant]
|
||||
#[RequirePermission(permission: RequirePermission::START_CALL)]
|
||||
public function sipDialOut(int $attendeeId): DataResponse {
|
||||
if ($this->room->getCallFlag() === Participant::FLAG_DISCONNECTED) {
|
||||
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($this->participant->getSession() && $this->participant->getSession()->getInCall() === Participant::FLAG_DISCONNECTED) {
|
||||
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$callerNumber = true;
|
||||
if ($this->appConfig->getAppValueBool('sip_bridge_dialout_anonymous')) {
|
||||
$callerNumber = false;
|
||||
} elseif ($this->appConfig->getAppValueString('sip_bridge_dialout_number') !== '') {
|
||||
$callerNumber = $this->appConfig->getAppValueString('sip_bridge_dialout_number');
|
||||
}
|
||||
|
||||
// No elseif, so we have the fallback to sip_bridge_dialout_number when the caller is no user or doesn't have a number
|
||||
if ($callerNumber !== false && $this->appConfig->getAppValueString('sip_bridge_dialout_prefix', '+') !== '') {
|
||||
$attendee = $this->participant->getAttendee();
|
||||
if ($attendee->getActorType() === Attendee::ACTOR_USERS) {
|
||||
$numbers = $this->phoneNumberMapper->findByUser($attendee->getActorId());
|
||||
if (!empty($numbers)) {
|
||||
$number = array_shift($numbers);
|
||||
$callerNumber = $this->appConfig->getAppValueString('sip_bridge_dialout_prefix', '+');
|
||||
$callerNumber .= $number->getPhoneNumber();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->participantService->startDialOutRequest($this->dialOutService, $this->room, $attendeeId, $callerNumber);
|
||||
} catch (ParticipantNotFoundException) {
|
||||
return new DataResponse(null, Http::STATUS_NOT_FOUND);
|
||||
} catch (DialOutFailedException $e) {
|
||||
return new DataResponse([
|
||||
'error' => $e->getMessage(),
|
||||
'message' => $e->getReadableError(),
|
||||
], Http::STATUS_NOT_IMPLEMENTED);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e], Http::STATUS_NOT_IMPLEMENTED);
|
||||
}
|
||||
|
||||
return new DataResponse(null, Http::STATUS_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the in-call flags
|
||||
*
|
||||
* @param int<0, 15> $flags New flags
|
||||
* @psalm-param int-mask-of<Participant::FLAG_*> $flags New flags
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, null, array{}>
|
||||
*
|
||||
* 200: In-call flags updated successfully
|
||||
* 400: Updating in-call flags is not possible
|
||||
* 404: Call session not found
|
||||
*/
|
||||
#[FederationSupported]
|
||||
#[PublicPage]
|
||||
#[RequireParticipant]
|
||||
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
|
||||
public function updateCallFlags(int $flags): DataResponse {
|
||||
$session = $this->participant->getSession();
|
||||
if (!$session instanceof Session) {
|
||||
return new DataResponse(null, Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ($this->room->isFederatedConversation()) {
|
||||
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController $proxy */
|
||||
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController::class);
|
||||
$response = $proxy->updateFederatedCallFlags($this->room, $this->participant, $flags);
|
||||
|
||||
if ($response->getStatus() === Http::STATUS_OK) {
|
||||
$this->participantService->updateCallFlags($this->room, $this->participant, $flags);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->participantService->updateCallFlags($this->room, $this->participant, $flags);
|
||||
} catch (\Exception $exception) {
|
||||
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new DataResponse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the in-call flags on the host server using the session id of the
|
||||
* federated user
|
||||
*
|
||||
* @param string $sessionId Federated session id to update the flags with
|
||||
* @param int<0, 15> $flags New flags
|
||||
* @psalm-param int-mask-of<Participant::FLAG_*> $flags New flags
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, null, array{}>
|
||||
*
|
||||
* 200: In-call flags updated successfully
|
||||
* 400: Updating in-call flags is not possible
|
||||
* 404: Call session not found
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[RequireFederatedParticipant]
|
||||
#[BruteForceProtection(action: 'talkFederationAccess')]
|
||||
#[BruteForceProtection(action: 'talkRoomToken')]
|
||||
public function updateFederatedCallFlags(string $sessionId, int $flags): DataResponse {
|
||||
if (!$this->federationAuthenticator->isFederationRequest()) {
|
||||
$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
|
||||
$response->throttle(['token' => $this->room->getToken(), 'action' => 'talkRoomToken']);
|
||||
return $response;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->participantService->updateCallFlags($this->room, $this->participant, $flags);
|
||||
} catch (\Exception) {
|
||||
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new DataResponse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a call
|
||||
*
|
||||
* @param bool $all whether to also terminate the call for all participants
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, null, array{}>
|
||||
*
|
||||
* 200: Call left successfully
|
||||
* 404: Call session not found
|
||||
*/
|
||||
#[FederationSupported]
|
||||
#[PublicPage]
|
||||
#[RequireParticipant]
|
||||
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
|
||||
public function leaveCall(bool $all = false): DataResponse {
|
||||
$session = $this->participant->getSession();
|
||||
if (!$session instanceof Session) {
|
||||
return new DataResponse(null, Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ($this->room->isFederatedConversation()) {
|
||||
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController $proxy */
|
||||
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController::class);
|
||||
$response = $proxy->leaveFederatedCall($this->room, $this->participant);
|
||||
|
||||
if ($response->getStatus() === Http::STATUS_OK) {
|
||||
$this->participantService->changeInCall($this->room, $this->participant, Participant::FLAG_DISCONNECTED);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($all && $this->participant->hasModeratorPermissions()) {
|
||||
$result = $this->roomService->resetActiveSinceInDatabaseOnly($this->room);
|
||||
if (!$result) {
|
||||
// Someone else won the race condition, make sure this user disconnects directly and then return
|
||||
$this->participantService->changeInCall($this->room, $this->participant, Participant::FLAG_DISCONNECTED);
|
||||
return new DataResponse(null);
|
||||
}
|
||||
$this->participantService->endCallForEveryone($this->room, $this->participant);
|
||||
$this->roomService->resetActiveSinceInModelOnly($this->room);
|
||||
} else {
|
||||
$this->participantService->changeInCall($this->room, $this->participant, Participant::FLAG_DISCONNECTED);
|
||||
if (!$this->participantService->hasActiveSessionsInCall($this->room)) {
|
||||
$this->roomService->resetActiveSince($this->room, $this->participant);
|
||||
}
|
||||
}
|
||||
|
||||
return new DataResponse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a call on the host server using the session id of the federated
|
||||
* user
|
||||
*
|
||||
* @param string $sessionId Federated session id to leave with
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, null, array{}>
|
||||
*
|
||||
* 200: Call left successfully
|
||||
* 404: Call session not found
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[RequireFederatedParticipant]
|
||||
#[BruteForceProtection(action: 'talkFederationAccess')]
|
||||
#[BruteForceProtection(action: 'talkRoomToken')]
|
||||
public function leaveFederatedCall(string $sessionId): DataResponse {
|
||||
if (!$this->federationAuthenticator->isFederationRequest()) {
|
||||
$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
|
||||
$response->throttle(['token' => $this->room->getToken(), 'action' => 'talkRoomToken']);
|
||||
return $response;
|
||||
}
|
||||
|
||||
$this->participantService->changeInCall($this->room, $this->participant, Participant::FLAG_DISCONNECTED);
|
||||
if (!$this->participantService->hasActiveSessionsInCall($this->room)) {
|
||||
$this->roomService->resetActiveSince($this->room, $this->participant);
|
||||
}
|
||||
|
||||
return new DataResponse(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IRequest;
|
||||
|
||||
class CallNotificationController extends OCSController {
|
||||
public const CASE_STILL_CURRENT = 0;
|
||||
public const CASE_ROOM_NOT_FOUND = 1;
|
||||
public const CASE_MISSED_CALL = 2;
|
||||
public const CASE_PARTICIPANT_JOINED = 3;
|
||||
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
protected ParticipantService $participantService,
|
||||
protected ?string $userId,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the expected state of a call notification
|
||||
*
|
||||
* Required capability: `call-notification-state-api`
|
||||
*
|
||||
* @param string $token Conversation token to check
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_CREATED|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, null, array{}>
|
||||
*
|
||||
* 200: Notification should be kept alive
|
||||
* 201: Dismiss call notification and show "Missed call"-notification instead
|
||||
* 403: Not logged in, try again with auth data sent
|
||||
* 404: Dismiss call notification
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[OpenAPI(tags: ['call'])]
|
||||
public function state(string $token): DataResponse {
|
||||
if ($this->userId === null) {
|
||||
return new DataResponse(null, Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
$status = match($this->participantService->checkIfUserIsMissingCall($token, $this->userId)) {
|
||||
self::CASE_PARTICIPANT_JOINED,
|
||||
self::CASE_ROOM_NOT_FOUND => Http::STATUS_NOT_FOUND,
|
||||
self::CASE_MISSED_CALL => Http::STATUS_CREATED,
|
||||
self::CASE_STILL_CURRENT => Http::STATUS_OK,
|
||||
};
|
||||
|
||||
return new DataResponse(null, $status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use OCA\Talk\Service\CertificateService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class CertificateController extends OCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
protected CertificateService $certificateService,
|
||||
protected IL10N $l,
|
||||
protected LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the certificate expiration for a host
|
||||
* @param string $host Host to check
|
||||
* @return DataResponse<Http::STATUS_OK, array{expiration_in_days: ?int}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{message: string}, array{}>
|
||||
*
|
||||
* 200: Certificate expiration returned
|
||||
* 400: Getting certificate expiration is not possible
|
||||
*/
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['settings'])]
|
||||
public function getCertificateExpiration(string $host): DataResponse {
|
||||
try {
|
||||
$expirationInDays = $this->certificateService->getCertificateExpirationInDays($host);
|
||||
|
||||
return new DataResponse([
|
||||
'expiration_in_days' => $expirationInDays,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed get certificate expiration', [
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return new DataResponse(['message' => $this->l->t('An error occurred. Please contact your administrator.')], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use OCA\Talk\AppInfo\Application;
|
||||
use OCA\Talk\Exceptions\CannotReachRemoteException;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Exceptions\UnauthorizedException;
|
||||
use OCA\Talk\Federation\FederationManager;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Model\Invitation;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Service\RoomFormatter;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkFederationInvite from ResponseDefinitions
|
||||
* @psalm-import-type TalkRoom from ResponseDefinitions
|
||||
*/
|
||||
class FederationController extends OCSController {
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
private FederationManager $federationManager,
|
||||
private Manager $talkManager,
|
||||
private IUserSession $userSession,
|
||||
private RoomFormatter $roomFormatter,
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Following the logic of {@see Dispatcher::executeController}
|
||||
* @return string Either 'json' or 'xml'
|
||||
* @psalm-return 'json'|'xml'
|
||||
*/
|
||||
public function getResponseFormat(): string {
|
||||
// get format from the url format or request format parameter
|
||||
$format = $this->request->getParam('format');
|
||||
|
||||
// if none is given try the first Accept header
|
||||
if ($format === null) {
|
||||
$headers = $this->request->getHeader('accept');
|
||||
/**
|
||||
* Default value of
|
||||
* @see OCSController::buildResponse()
|
||||
*/
|
||||
$format = $this->getResponderByHTTPHeader($headers, 'xml');
|
||||
}
|
||||
|
||||
return $format;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a federation invites
|
||||
*
|
||||
* 🚧 Draft: Still work in progress
|
||||
*
|
||||
* @param int $id ID of the share
|
||||
* @psalm-param non-negative-int $id
|
||||
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_GONE, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Invite accepted successfully
|
||||
* 400: Invite can not be accepted (maybe it was accepted already)
|
||||
* 404: Invite can not be found
|
||||
* 410: Remote server could not be reached to notify about the acceptance
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
|
||||
public function acceptShare(int $id): DataResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user instanceof IUser) {
|
||||
return new DataResponse(['error' => 'user'], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
try {
|
||||
$participant = $this->federationManager->acceptRemoteRoomShare($user, $id);
|
||||
} catch (CannotReachRemoteException) {
|
||||
return new DataResponse(['error' => 'remote'], Http::STATUS_GONE);
|
||||
} catch (UnauthorizedException $e) {
|
||||
return new DataResponse(['error' => 'user'], Http::STATUS_NOT_FOUND);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], $e->getMessage() === 'invitation' ? Http::STATUS_NOT_FOUND : Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
return new DataResponse($this->roomFormatter->formatRoom(
|
||||
$this->getResponseFormat(),
|
||||
[],
|
||||
$participant->getRoom(),
|
||||
$participant,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline a federation invites
|
||||
*
|
||||
* 🚧 Draft: Still work in progress
|
||||
*
|
||||
* @param int $id ID of the share
|
||||
* @psalm-param non-negative-int $id
|
||||
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Invite declined successfully
|
||||
* 400: Invite was already accepted, use the "Remove the current user from a room" endpoint instead
|
||||
* 404: Invite can not be found
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
|
||||
public function rejectShare(int $id): DataResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user instanceof IUser) {
|
||||
return new DataResponse(['error' => 'user'], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
try {
|
||||
$this->federationManager->rejectRemoteRoomShare($user, $id);
|
||||
} catch (UnauthorizedException $e) {
|
||||
return new DataResponse(['error' => 'user'], Http::STATUS_NOT_FOUND);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], $e->getMessage() === 'invitation' ? Http::STATUS_NOT_FOUND : Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
return new DataResponse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of federation invites
|
||||
*
|
||||
* 🚧 Draft: Still work in progress
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkFederationInvite>, array{}>
|
||||
*
|
||||
* 200: Get list of received federation invites successfully
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
|
||||
public function getShares(): DataResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user instanceof IUser) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
$invitations = $this->federationManager->getRemoteRoomShares($user);
|
||||
|
||||
/** @var list<TalkFederationInvite> $data */
|
||||
$data = array_values(array_filter(array_map([$this, 'enrichInvite'], $invitations)));
|
||||
|
||||
return new DataResponse($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Invitation $invitation
|
||||
* @return TalkFederationInvite|null
|
||||
*/
|
||||
protected function enrichInvite(Invitation $invitation): ?array {
|
||||
try {
|
||||
$room = $this->talkManager->getRoomById($invitation->getLocalRoomId());
|
||||
} catch (RoomNotFoundException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$federationInvite = $invitation->jsonSerialize();
|
||||
$federationInvite['roomName'] = $room->getName();
|
||||
$federationInvite['localToken'] = $room->getToken();
|
||||
return $federationInvite;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Files\Util;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\RoomService;
|
||||
use OCA\Talk\TalkSession;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||||
use OCP\AppFramework\Http\Attribute\UseSession;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCS\OCSException;
|
||||
use OCP\AppFramework\OCS\OCSNotFoundException;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\Files\FileInfo;
|
||||
use OCP\Files\NotFoundException;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use OCP\ISession;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
use OCP\Share\Exceptions\ShareNotFound;
|
||||
use OCP\Share\IManager as IShareManager;
|
||||
|
||||
class FilesIntegrationController extends OCSController {
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private Manager $manager,
|
||||
private RoomService $roomService,
|
||||
private IShareManager $shareManager,
|
||||
private ISession $session,
|
||||
private IUserSession $userSession,
|
||||
private TalkSession $talkSession,
|
||||
private Util $util,
|
||||
private IConfig $config,
|
||||
private IL10N $l,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the token of the room associated to the given file id
|
||||
*
|
||||
* This is the counterpart of self::getRoomByShareToken() for file ids
|
||||
* instead of share tokens, although both return the same room token if the
|
||||
* given file id and share token refer to the same file.
|
||||
*
|
||||
* If there is no room associated to the given file id a new room is
|
||||
* created; the new room is a public room associated with a "file" object
|
||||
* with the given file id. Unlike normal rooms in which the owner is the
|
||||
* user that created the room these are special rooms without owner
|
||||
* (although self joined users with direct access to the file become
|
||||
* persistent participants automatically when they join until they
|
||||
* explicitly leave or no longer have access to the file).
|
||||
*
|
||||
* In any case, to create or even get the token of the room, the file must
|
||||
* be shared and the user must be the owner of a public share of the file
|
||||
* (like a link share, for example) or have direct access to that file; an
|
||||
* error is returned otherwise. A user has direct access to a file if they
|
||||
* have access to it (or to an ancestor) through a user, group, circle or
|
||||
* room share (but not through a link share, for example), or if they are the
|
||||
* owner of such a file.
|
||||
*
|
||||
* @param string $fileId ID of the file
|
||||
* @return DataResponse<Http::STATUS_OK, array{token: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, null, array{}>
|
||||
* @throws OCSNotFoundException Share not found
|
||||
*
|
||||
* 200: Room token returned
|
||||
* 400: Rooms not allowed for shares
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function getRoomByFileId(string $fileId): DataResponse {
|
||||
if ($this->config->getAppValue('spreed', 'conversations_files', '1') !== '1') {
|
||||
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$currentUser = $this->userSession->getUser();
|
||||
if (!$currentUser instanceof IUser) {
|
||||
throw new OCSException($this->l->t('File is not shared, or shared but not with the user'), Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
|
||||
$node = $this->util->getAnyNodeOfFileAccessibleByUser($fileId, $currentUser->getUID());
|
||||
if ($node === null) {
|
||||
throw new OCSNotFoundException($this->l->t('File is not shared, or shared but not with the user'));
|
||||
}
|
||||
|
||||
$users = $this->util->getUsersWithAccessFile($fileId);
|
||||
if (count($users) <= 1 && !$this->util->canGuestsAccessFile($fileId)) {
|
||||
throw new OCSNotFoundException($this->l->t('File is not shared, or shared but not with the user'));
|
||||
}
|
||||
|
||||
try {
|
||||
$room = $this->manager->getRoomByObject('file', $fileId);
|
||||
} catch (RoomNotFoundException $e) {
|
||||
$name = $node->getName();
|
||||
$name = $this->roomService->prepareConversationName($name);
|
||||
$room = $this->roomService->createConversation(
|
||||
Room::TYPE_PUBLIC,
|
||||
$name,
|
||||
null,
|
||||
Room::OBJECT_TYPE_FILE,
|
||||
$fileId,
|
||||
);
|
||||
}
|
||||
|
||||
return new DataResponse([
|
||||
'token' => $room->getToken()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the token of the room associated to the file of the given
|
||||
* share token
|
||||
*
|
||||
* This is the counterpart of self::getRoomByFileId() for share tokens
|
||||
* instead of file ids, although both return the same room token if the
|
||||
* given file id and share token refer to the same file.
|
||||
*
|
||||
* If there is no room associated to the file id of the given share token a
|
||||
* new room is created; the new room is a public room associated with a
|
||||
* "file" object with the file id of the given share token. Unlike normal
|
||||
* rooms in which the owner is the user that created the room these are
|
||||
* special rooms without owner (although self joined users with direct
|
||||
* access to the file become persistent participants automatically when they
|
||||
* join until they explicitly leave or no longer have access to the file).
|
||||
*
|
||||
* In any case, to create or even get the token of the room, the file must
|
||||
* be publicly shared (like a link share, for example); an error is returned
|
||||
* otherwise.
|
||||
*
|
||||
* Besides the token of the room this also returns the current user ID and
|
||||
* display name, if any; this is needed by the Talk sidebar to know the
|
||||
* actual current user, as the public share page uses the incognito mode and
|
||||
* thus logged-in users as seen as guests.
|
||||
*
|
||||
* @param string $shareToken Token of the file share
|
||||
* @return DataResponse<Http::STATUS_OK, array{token: string, userId: string, userDisplayName: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, null, array{}>
|
||||
*
|
||||
* 200: Room token and user info returned
|
||||
* 400: Rooms not allowed for shares
|
||||
* 404: Share not found
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[UseSession]
|
||||
#[BruteForceProtection(action: 'shareinfo')]
|
||||
public function getRoomByShareToken(string $shareToken): DataResponse {
|
||||
if ($this->config->getAppValue('spreed', 'conversations_files', '1') !== '1'
|
||||
|| $this->config->getAppValue('spreed', 'conversations_files_public_shares', '1') !== '1') {
|
||||
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$share = $this->shareManager->getShareByToken($shareToken);
|
||||
if ($share->getPassword() !== null) {
|
||||
$shareId = $this->session->get('public_link_authenticated');
|
||||
if ($share->getId() !== $shareId) {
|
||||
throw new ShareNotFound();
|
||||
}
|
||||
}
|
||||
} catch (ShareNotFound $e) {
|
||||
$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
|
||||
$response->throttle(['token' => $shareToken, 'action' => 'shareinfo']);
|
||||
return $response;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($share->getNodeType() !== FileInfo::TYPE_FILE) {
|
||||
return new DataResponse(null, Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
$fileId = (string)$share->getNodeId();
|
||||
|
||||
try {
|
||||
$room = $this->manager->getRoomByObject('file', $fileId);
|
||||
} catch (RoomNotFoundException) {
|
||||
$name = $share->getNode()->getName();
|
||||
$name = $this->roomService->prepareConversationName($name);
|
||||
$room = $this->roomService->createConversation(
|
||||
Room::TYPE_PUBLIC,
|
||||
$name,
|
||||
null,
|
||||
Room::OBJECT_TYPE_FILE,
|
||||
$fileId,
|
||||
);
|
||||
}
|
||||
} catch (NotFoundException) {
|
||||
return new DataResponse(null, Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
$this->talkSession->setFileShareTokenForRoom($room->getToken(), $shareToken);
|
||||
|
||||
$currentUser = $this->userSession->getUser();
|
||||
$currentUserId = $currentUser instanceof IUser ? $currentUser->getUID() : '';
|
||||
$currentUserDisplayName = $currentUser instanceof IUser ? $currentUser->getDisplayName() : '';
|
||||
|
||||
return new DataResponse([
|
||||
'token' => $room->getToken(),
|
||||
'userId' => $currentUserId,
|
||||
'userDisplayName' => $currentUserDisplayName,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use OCA\Talk\GuestManager;
|
||||
use OCA\Talk\Middleware\Attribute\RequireParticipant;
|
||||
use OCA\Talk\Participant;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\IRequest;
|
||||
|
||||
class GuestController extends AEnvironmentAwareOCSController {
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private GuestManager $guestManager,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the display name as a guest
|
||||
*
|
||||
* @param string $displayName New display name
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, null, array{}>
|
||||
*
|
||||
* 200: Display name updated successfully
|
||||
* 403: Not a guest
|
||||
* 404: Not a participant
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[RequireParticipant]
|
||||
public function setDisplayName(string $displayName): DataResponse {
|
||||
$participant = $this->getParticipant();
|
||||
if (!$participant instanceof Participant) {
|
||||
return new DataResponse(null, Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (!$participant->isGuest()) {
|
||||
return new DataResponse(null, Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
$this->guestManager->updateName($this->getRoom(), $participant, $displayName);
|
||||
|
||||
return new DataResponse(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use OCA\Talk\DataObjects\AccountId;
|
||||
use OCA\Talk\DataObjects\RegisterAccountData;
|
||||
use OCA\Talk\Exceptions\HostedSignalingServerAPIException;
|
||||
use OCA\Talk\Exceptions\HostedSignalingServerInputException;
|
||||
use OCA\Talk\Service\HostedSignalingServerService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||||
use OCP\AppFramework\Http\Attribute\RequestHeader;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class HostedSignalingServerController extends OCSController {
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
protected IClientService $clientService,
|
||||
protected IL10N $l10n,
|
||||
protected IConfig $config,
|
||||
protected LoggerInterface $logger,
|
||||
private HostedSignalingServerService $hostedSignalingServerService,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication credentials
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array{nonce: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_PRECONDITION_FAILED, null, array{}>
|
||||
*
|
||||
* 200: Authentication credentials returned
|
||||
* 403: Provided nonce is wrong
|
||||
* 412: Getting authentication credentials is not possible
|
||||
*/
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
|
||||
#[PublicPage]
|
||||
#[BruteForceProtection(action: 'hosted-hpb-nonce')]
|
||||
#[RequestHeader(name: 'x-account-service-nonce', description: 'Random string provided to the hostedsignalingserver entity, so it can verify that it was requested')]
|
||||
public function auth(): DataResponse {
|
||||
$sentNonce = $this->request->getHeader('x-account-service-nonce');
|
||||
if ($sentNonce === '') {
|
||||
$response = new DataResponse(null, Http::STATUS_FORBIDDEN);
|
||||
$response->throttle();
|
||||
return $response;
|
||||
}
|
||||
|
||||
$storedNonce = $this->config->getAppValue('spreed', 'hosted-signaling-server-nonce', '');
|
||||
if ($storedNonce === '') {
|
||||
return new DataResponse(null, Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
|
||||
if (!hash_equals($storedNonce, $sentNonce)) {
|
||||
$response = new DataResponse(null, Http::STATUS_FORBIDDEN);
|
||||
$response->throttle();
|
||||
return $response;
|
||||
}
|
||||
|
||||
// reset nonce after one request
|
||||
$this->config->deleteAppValue('spreed', 'hosted-signaling-server-nonce');
|
||||
|
||||
return new DataResponse([
|
||||
'nonce' => $storedNonce,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a trial account
|
||||
*
|
||||
* @param string $url Server URL
|
||||
* @param string $name Display name of the user
|
||||
* @param string $email Email of the user
|
||||
* @param string $language Language of the user
|
||||
* @param string $country Country of the user
|
||||
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
|
||||
*
|
||||
* 200: Trial requested successfully
|
||||
* 400: Requesting trial is not possible
|
||||
*/
|
||||
public function requestTrial(string $url, string $name, string $email, string $language, string $country): DataResponse {
|
||||
try {
|
||||
$registerAccountData = new RegisterAccountData(
|
||||
$url,
|
||||
$name,
|
||||
$email,
|
||||
$language,
|
||||
$country
|
||||
);
|
||||
|
||||
$accountId = $this->hostedSignalingServerService->registerAccount($registerAccountData);
|
||||
$accountInfo = $this->hostedSignalingServerService->fetchAccountInfo($accountId);
|
||||
$this->config->setAppValue('spreed', 'hosted-signaling-server-account', json_encode($accountInfo));
|
||||
} catch (HostedSignalingServerAPIException $e) { // API or connection issues
|
||||
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
} catch (HostedSignalingServerInputException $e) { // user solvable issues
|
||||
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
|
||||
return new DataResponse($accountInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the account
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_NO_CONTENT, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
|
||||
*
|
||||
* 204: Account deleted successfully
|
||||
* 400: Deleting account is not possible
|
||||
*/
|
||||
public function deleteAccount(): DataResponse {
|
||||
$accountId = $this->config->getAppValue('spreed', 'hosted-signaling-server-account-id');
|
||||
|
||||
if ($accountId === null) {
|
||||
return new DataResponse(['message' => $this->l10n->t('No account available to delete.')], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->hostedSignalingServerService->deleteAccount(new AccountId($accountId));
|
||||
} catch (HostedSignalingServerAPIException $e) {
|
||||
if ($e->getCode() === Http::STATUS_NOT_FOUND) {
|
||||
// Account was deleted, so remove the information locally
|
||||
} else {
|
||||
// API or connection issues - do nothing and just try again later
|
||||
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
$this->config->deleteAppValue('spreed', 'hosted-signaling-server-account');
|
||||
$this->config->deleteAppValue('spreed', 'hosted-signaling-server-account-id');
|
||||
|
||||
// remove signaling servers if account is not active anymore
|
||||
$this->config->deleteAppValue('spreed', 'signaling_mode');
|
||||
$this->config->deleteAppValue('spreed', 'signaling_servers');
|
||||
|
||||
$this->logger->info('Deleted hosted signaling server account with ID ' . $accountId);
|
||||
|
||||
return new DataResponse(null, Http::STATUS_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use OCA\Talk\Exceptions\LiveTranscriptionAppNotEnabledException;
|
||||
use OCA\Talk\Middleware\Attribute\RequireCallEnabled;
|
||||
use OCA\Talk\Middleware\Attribute\RequireModeratorOrNoLobby;
|
||||
use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant;
|
||||
use OCA\Talk\Middleware\Attribute\RequireParticipant;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Service\LiveTranscriptionService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\IRequest;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkLiveTranscriptionLanguage from ResponseDefinitions
|
||||
*/
|
||||
class LiveTranscriptionController extends AEnvironmentAwareOCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private LiveTranscriptionService $liveTranscriptionService,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the live transcription
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'app'|'in-call'}, array{}>
|
||||
*
|
||||
* 200: Live transcription enabled successfully
|
||||
* 400: The external app "live_transcription" is not available
|
||||
* 400: The participant is not in the call
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[RequireCallEnabled]
|
||||
#[RequireModeratorOrNoLobby]
|
||||
#[RequireParticipant]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/live-transcription/{token}', requirements: [
|
||||
'apiVersion' => '(v1)',
|
||||
'token' => '[a-z0-9]{4,30}',
|
||||
])]
|
||||
public function enable(): DataResponse {
|
||||
if ($this->room->getCallFlag() === Participant::FLAG_DISCONNECTED) {
|
||||
return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($this->participant->getSession() && $this->participant->getSession()->getInCall() === Participant::FLAG_DISCONNECTED) {
|
||||
return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->liveTranscriptionService->enable($this->room, $this->participant);
|
||||
} catch (LiveTranscriptionAppNotEnabledException $e) {
|
||||
return new DataResponse(['error' => 'app'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new DataResponse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the live transcription
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'app'|'in-call'}, array{}>
|
||||
*
|
||||
* 200: Live transcription stopped successfully
|
||||
* 400: The external app "live_transcription" is not available
|
||||
* 400: The participant is not in the call
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[RequireModeratorOrNoLobby]
|
||||
#[RequireParticipant]
|
||||
#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/live-transcription/{token}', requirements: [
|
||||
'apiVersion' => '(v1)',
|
||||
'token' => '[a-z0-9]{4,30}',
|
||||
])]
|
||||
public function disable(): DataResponse {
|
||||
if ($this->room->getCallFlag() === Participant::FLAG_DISCONNECTED) {
|
||||
return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($this->participant->getSession() && $this->participant->getSession()->getInCall() === Participant::FLAG_DISCONNECTED) {
|
||||
return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->liveTranscriptionService->disable($this->room, $this->participant);
|
||||
} catch (LiveTranscriptionAppNotEnabledException $e) {
|
||||
return new DataResponse(['error' => 'app'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new DataResponse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available languages for live transcriptions
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array<string, TalkLiveTranscriptionLanguage>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'app'}, array{}>
|
||||
*
|
||||
* 200: Available languages got successfully
|
||||
* 400: The external app "live_transcription" is not available
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/live-transcription/languages', requirements: [
|
||||
'apiVersion' => '(v1)',
|
||||
])]
|
||||
public function getAvailableLanguages(): DataResponse {
|
||||
try {
|
||||
$languages = $this->liveTranscriptionService->getAvailableLanguages();
|
||||
} catch (LiveTranscriptionAppNotEnabledException $e) {
|
||||
return new DataResponse(['error' => 'app'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new DataResponse($languages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set language for live transcriptions
|
||||
*
|
||||
* @param string $languageId the ID of the language to set
|
||||
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN, array{error: 'app'}, array{}>
|
||||
*
|
||||
* 200: Language set successfully
|
||||
* 400: The external app "live_transcription" is not available
|
||||
* 403: Participant is not a moderator
|
||||
*/
|
||||
#[PublicPage]
|
||||
#[RequireModeratorParticipant]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/live-transcription/{token}/language', requirements: [
|
||||
'apiVersion' => '(v1)',
|
||||
'token' => '[a-z0-9]{4,30}',
|
||||
])]
|
||||
public function setLanguage(string $languageId): DataResponse {
|
||||
try {
|
||||
$this->liveTranscriptionService->setLanguage($this->room, $languageId);
|
||||
} catch (LiveTranscriptionAppNotEnabledException $e) {
|
||||
return new DataResponse(['error' => 'app'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new DataResponse(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use OCA\Talk\Exceptions\ImpossibleToKillException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\MatterbridgeManager;
|
||||
use OCA\Talk\Middleware\Attribute\RequireLoggedInModeratorParticipant;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\IRequest;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkMatterbridge from ResponseDefinitions
|
||||
* @psalm-import-type TalkMatterbridgeConfigFields from ResponseDefinitions
|
||||
* @psalm-import-type TalkMatterbridgeProcessState from ResponseDefinitions
|
||||
* @psalm-import-type TalkMatterbridgeWithProcessState from ResponseDefinitions
|
||||
*/
|
||||
class MatterbridgeController extends AEnvironmentAwareOCSController {
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
protected ?string $userId,
|
||||
IRequest $request,
|
||||
protected Manager $manager,
|
||||
protected MatterbridgeManager $bridgeManager,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bridge information of one room
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, TalkMatterbridgeWithProcessState, array{}>
|
||||
*
|
||||
* 200: Return list of configured bridges
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInModeratorParticipant]
|
||||
public function getBridgeOfRoom(): DataResponse {
|
||||
$pid = $this->bridgeManager->checkBridge($this->room);
|
||||
$logContent = $this->bridgeManager->getBridgeLog($this->room);
|
||||
$bridge = $this->bridgeManager->getBridgeOfRoom($this->room);
|
||||
$bridge['running'] = ($pid !== 0);
|
||||
$bridge['log'] = $logContent;
|
||||
return new DataResponse($bridge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bridge process information
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, TalkMatterbridgeProcessState, array{}>
|
||||
*
|
||||
* 200: Return list of running processes
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInModeratorParticipant]
|
||||
public function getBridgeProcessState(): DataResponse {
|
||||
$state = $this->bridgeManager->getBridgeProcessState($this->room);
|
||||
return new DataResponse($state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit bridge information of one room
|
||||
*
|
||||
* @param bool $enabled If the bridge should be enabled
|
||||
* @param TalkMatterbridgeConfigFields $parts New parts
|
||||
* @return DataResponse<Http::STATUS_OK, TalkMatterbridgeProcessState, array{}>|DataResponse<Http::STATUS_NOT_ACCEPTABLE, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Bridge edited successfully
|
||||
* 406: Editing bridge is not possible
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInModeratorParticipant]
|
||||
public function editBridgeOfRoom(bool $enabled, array $parts = []): DataResponse {
|
||||
try {
|
||||
$state = $this->bridgeManager->editBridgeOfRoom($this->room, $this->userId, $enabled, $parts);
|
||||
} catch (ImpossibleToKillException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_NOT_ACCEPTABLE);
|
||||
}
|
||||
return new DataResponse($state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete bridge of one room
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, bool, array{}>|DataResponse<Http::STATUS_NOT_ACCEPTABLE, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Bridge deleted successfully
|
||||
* 406: Deleting bridge is not possible
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequireLoggedInModeratorParticipant]
|
||||
public function deleteBridgeOfRoom(): DataResponse {
|
||||
try {
|
||||
$success = $this->bridgeManager->deleteBridgeOfRoom($this->room);
|
||||
} catch (ImpossibleToKillException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_NOT_ACCEPTABLE);
|
||||
}
|
||||
return new DataResponse($success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use OCA\Talk\Exceptions\ImpossibleToKillException;
|
||||
use OCA\Talk\Exceptions\WrongPermissionsException;
|
||||
use OCA\Talk\MatterbridgeManager;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IRequest;
|
||||
|
||||
class MatterbridgeSettingsController extends OCSController {
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
protected MatterbridgeManager $bridgeManager,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Matterbridge version
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array{version: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Bridge version returned
|
||||
* 400: Getting bridge version is not possible
|
||||
*/
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['matterbridge'])]
|
||||
public function getMatterbridgeVersion(): DataResponse {
|
||||
try {
|
||||
$version = $this->bridgeManager->getCurrentVersionFromBinary();
|
||||
if ($version === null) {
|
||||
return new DataResponse([
|
||||
'error' => 'binary',
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
} catch (WrongPermissionsException $e) {
|
||||
return new DataResponse([
|
||||
'error' => 'binary_permissions',
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new DataResponse([
|
||||
'version' => $version,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all bridges
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, bool, array{}>|DataResponse<Http::STATUS_NOT_ACCEPTABLE, array{error: string}, array{}>
|
||||
*
|
||||
* 200: All bridges stopped successfully
|
||||
* 406: Stopping all bridges is not possible
|
||||
*/
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['matterbridge'])]
|
||||
public function stopAllBridges(): DataResponse {
|
||||
try {
|
||||
$success = $this->bridgeManager->stopAllBridges();
|
||||
} catch (ImpossibleToKillException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_NOT_ACCEPTABLE);
|
||||
}
|
||||
return new DataResponse($success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Controller;
|
||||
|
||||
use OCA\Talk\AppInfo\Application;
|
||||
use OCA\Talk\Config;
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\RoomService;
|
||||
use OCA\Talk\TalkSession;
|
||||
use OCA\Talk\TInitialState;
|
||||
use OCA\Viewer\Event\LoadViewer;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||||
use OCP\AppFramework\Http\Attribute\UseSession;
|
||||
use OCP\AppFramework\Http\ContentSecurityPolicy;
|
||||
use OCP\AppFramework\Http\NotFoundResponse;
|
||||
use OCP\AppFramework\Http\RedirectResponse;
|
||||
use OCP\AppFramework\Http\Response;
|
||||
use OCP\AppFramework\Http\Template\PublicTemplateResponse;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\AppFramework\Services\IInitialState;
|
||||
use OCP\Collaboration\Reference\RenderReferenceEvent;
|
||||
use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent;
|
||||
use OCP\EventDispatcher\IEventDispatcher;
|
||||
use OCP\Files\IRootFolder;
|
||||
use OCP\HintException;
|
||||
use OCP\ICacheFactory;
|
||||
use OCP\IConfig;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\IRequest;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
use OCP\Notification\IManager as INotificationManager;
|
||||
use OCP\Security\Bruteforce\IThrottler;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use SensitiveParameter;
|
||||
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
|
||||
class PageController extends Controller {
|
||||
use TInitialState;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private IEventDispatcher $eventDispatcher,
|
||||
private RoomController $api,
|
||||
private TalkSession $talkSession,
|
||||
private IUserSession $userSession,
|
||||
private ?string $userId,
|
||||
LoggerInterface $logger,
|
||||
private Manager $manager,
|
||||
private ParticipantService $participantService,
|
||||
private RoomService $roomService,
|
||||
private IURLGenerator $url,
|
||||
private INotificationManager $notificationManager,
|
||||
private IAppManager $appManager,
|
||||
IInitialState $initialState,
|
||||
ICacheFactory $memcacheFactory,
|
||||
private IRootFolder $rootFolder,
|
||||
private IThrottler $throttler,
|
||||
Config $talkConfig,
|
||||
IConfig $serverConfig,
|
||||
IGroupManager $groupManager,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->logger = $logger;
|
||||
$this->initialState = $initialState;
|
||||
$this->memcacheFactory = $memcacheFactory;
|
||||
$this->talkConfig = $talkConfig;
|
||||
$this->serverConfig = $serverConfig;
|
||||
$this->groupManager = $groupManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $token
|
||||
* @return Response
|
||||
* @throws HintException
|
||||
*/
|
||||
#[NoCSRFRequired]
|
||||
#[PublicPage]
|
||||
#[UseSession]
|
||||
#[BruteForceProtection(action: 'talkRoomToken')]
|
||||
public function showCall(string $token, string $email = '', string $access = ''): Response {
|
||||
// This is the entry point from the `/call/{token}` URL which is hardcoded in the server.
|
||||
return $this->pageHandler($token, email: $email, accessToken: $access);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $token
|
||||
* @param string $password
|
||||
* @return Response
|
||||
* @throws HintException
|
||||
*/
|
||||
#[NoCSRFRequired]
|
||||
#[PublicPage]
|
||||
#[UseSession]
|
||||
#[BruteForceProtection(action: 'talkRoomPassword')]
|
||||
public function authenticatePassword(string $token, string $password = ''): Response {
|
||||
// This is the entry point from the `/call/{token}` URL which is hardcoded in the server.
|
||||
return $this->pageHandler($token, password: $password);
|
||||
}
|
||||
|
||||
#[NoCSRFRequired]
|
||||
#[PublicPage]
|
||||
public function notFound(): Response {
|
||||
return $this->pageHandler();
|
||||
}
|
||||
|
||||
#[NoCSRFRequired]
|
||||
#[PublicPage]
|
||||
public function duplicateSession(): Response {
|
||||
return $this->pageHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $token
|
||||
* @param string $callUser
|
||||
* @return TemplateResponse|RedirectResponse
|
||||
* @throws HintException
|
||||
*/
|
||||
#[NoCSRFRequired]
|
||||
#[PublicPage]
|
||||
#[BruteForceProtection(action: 'talkRoomToken')]
|
||||
#[UseSession]
|
||||
public function index(string $token = '', string $callUser = ''): Response {
|
||||
if ($callUser !== '') {
|
||||
$token = '';
|
||||
}
|
||||
return $this->pageHandler($token, $callUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $token
|
||||
* @param string $callUser
|
||||
* @param string $password
|
||||
* @return TemplateResponse|RedirectResponse
|
||||
* @throws HintException
|
||||
*/
|
||||
protected function pageHandler(
|
||||
string $token = '',
|
||||
string $callUser = '',
|
||||
string $password = '',
|
||||
string $email = '',
|
||||
#[SensitiveParameter]
|
||||
string $accessToken = '',
|
||||
): Response {
|
||||
$bruteForceToken = $token;
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user instanceof IUser) {
|
||||
return $this->guestEnterRoom($token, $password, $email, $accessToken);
|
||||
}
|
||||
|
||||
$throttle = false;
|
||||
if ($token !== '') {
|
||||
$room = null;
|
||||
try {
|
||||
$room = $this->manager->getRoomByToken($token, $this->userId);
|
||||
$notification = $this->notificationManager->createNotification();
|
||||
$shouldFlush = $this->notificationManager->defer();
|
||||
try {
|
||||
$notification->setApp('spreed')
|
||||
->setUser($this->userId)
|
||||
->setObject('room', $room->getToken());
|
||||
$this->notificationManager->markProcessed($notification);
|
||||
$notification->setObject('call', $room->getToken());
|
||||
$this->notificationManager->markProcessed($notification);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->logger->error($e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
|
||||
if ($shouldFlush) {
|
||||
$this->notificationManager->flush();
|
||||
}
|
||||
|
||||
// If the room is not a public room, check if the user is in the participants
|
||||
if ($room->getType() !== Room::TYPE_PUBLIC) {
|
||||
$this->manager->getRoomForUser($room->getId(), $this->userId);
|
||||
}
|
||||
} catch (RoomNotFoundException $e) {
|
||||
// Room not found, redirect to main page
|
||||
$token = '';
|
||||
$throttle = true;
|
||||
}
|
||||
|
||||
if ($room instanceof Room && $room->hasPassword()) {
|
||||
// If the user joined themselves or is not found, they need the password.
|
||||
try {
|
||||
$participant = $this->participantService->getParticipant($room, $this->userId, false);
|
||||
$requirePassword = $participant->getAttendee()->getParticipantType() === Participant::USER_SELF_JOINED;
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
$requirePassword = true;
|
||||
}
|
||||
|
||||
if ($requirePassword) {
|
||||
$password = $password !== '' ? $password : (string)$this->talkSession->getPasswordForRoom($token);
|
||||
|
||||
$passwordVerification = $this->roomService->verifyPassword($room, $password);
|
||||
|
||||
if ($passwordVerification['result']) {
|
||||
$this->talkSession->renewSessionId();
|
||||
$this->talkSession->setPasswordForRoom($token, $password);
|
||||
$this->throttler->resetDelay($this->request->getRemoteAddress(), 'talkRoomPassword', ['token' => $token, 'action' => 'talkRoomPassword']);
|
||||
} else {
|
||||
$this->talkSession->removePasswordForRoom($token);
|
||||
$showBruteForceWarning = $this->throttler->getDelay($this->request->getRemoteAddress(), 'talkRoomPassword') > 5000;
|
||||
|
||||
if ($passwordVerification['url'] === '') {
|
||||
$response = new TemplateResponse($this->appName, 'authenticate', [
|
||||
'wrongpw' => $password !== '',
|
||||
'showBruteForceWarning' => $showBruteForceWarning,
|
||||
], 'guest');
|
||||
} else {
|
||||
$response = new RedirectResponse($passwordVerification['url']);
|
||||
}
|
||||
|
||||
$this->logger->debug('User "' . ($this->userId ?? 'ANONYMOUS') . '" throttled for accessing "' . $token . '"', ['app' => 'spreed-bfp']);
|
||||
$response->throttle(['token' => $token, 'action' => 'talkRoomPassword']);
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$response = $this->api->createRoom(Room::TYPE_ONE_TO_ONE, $callUser);
|
||||
if ($response->getStatus() === Http::STATUS_OK
|
||||
|| $response->getStatus() === Http::STATUS_CREATED) {
|
||||
$data = $response->getData();
|
||||
return $this->redirectToConversation($data['token']);
|
||||
}
|
||||
}
|
||||
|
||||
$this->publishInitialStateForUser($user, $this->rootFolder, $this->appManager);
|
||||
|
||||
if (class_exists(LoadViewer::class)) {
|
||||
$this->eventDispatcher->dispatchTyped(new LoadViewer());
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatchTyped(new LoadAdditionalScriptsEvent());
|
||||
$this->eventDispatcher->dispatchTyped(new RenderReferenceEvent());
|
||||
|
||||
$response = new TemplateResponse($this->appName, 'index', [
|
||||
'app' => Application::APP_ID,
|
||||
'id-app-content' => '#content-vue',
|
||||
'id-app-navigation' => '#app-navigation-vue',
|
||||
]);
|
||||
|
||||
$csp = new ContentSecurityPolicy();
|
||||
$csp->addAllowedImageDomain('https://*.tile.openstreetmap.org');
|
||||
$csp->addAllowedMediaDomain('blob:');
|
||||
$csp->addAllowedWorkerSrcDomain('blob:');
|
||||
$csp->addAllowedWorkerSrcDomain("'self'");
|
||||
$csp->addAllowedChildSrcDomain('blob:');
|
||||
$csp->addAllowedChildSrcDomain("'self'");
|
||||
$csp->addAllowedScriptDomain('blob:');
|
||||
$csp->addAllowedScriptDomain("'self'");
|
||||
$csp->addAllowedScriptDomain("'wasm-unsafe-eval'");
|
||||
$csp->addAllowedConnectDomain('blob:');
|
||||
$csp->addAllowedConnectDomain("'self'");
|
||||
foreach ($this->talkConfig->getAllServerUrlsForCSP() as $server) {
|
||||
$csp->addAllowedConnectDomain($server);
|
||||
}
|
||||
|
||||
$response->setContentSecurityPolicy($csp);
|
||||
if ($throttle) {
|
||||
// Logged-in user tried to access a chat they can not access
|
||||
$this->logger->debug('User "' . ($this->userId ?? 'ANONYMOUS') . '" throttled for accessing "' . $bruteForceToken . '"', ['app' => 'spreed-bfp']);
|
||||
$response->throttle(['token' => $bruteForceToken, 'action' => 'talkRoomToken']);
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $token
|
||||
* @return TemplateResponse|NotFoundResponse
|
||||
*/
|
||||
#[NoCSRFRequired]
|
||||
#[PublicPage]
|
||||
#[BruteForceProtection(action: 'talkRoomToken')]
|
||||
#[BruteForceProtection(action: 'talkRecordingStatus')]
|
||||
public function recording(string $token): Response {
|
||||
try {
|
||||
$room = $this->manager->getRoomByToken($token);
|
||||
} catch (RoomNotFoundException $e) {
|
||||
$response = new NotFoundResponse();
|
||||
$this->logger->debug('Recording "' . ($this->userId ?? 'ANONYMOUS') . '" throttled for accessing "' . $token . '"', ['app' => 'spreed-bfp']);
|
||||
$response->throttle(['token' => $token, 'action' => 'talkRoomToken']);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($room->getCallRecording() !== Room::RECORDING_VIDEO_STARTING && $room->getCallRecording() !== Room::RECORDING_AUDIO_STARTING) {
|
||||
$response = new NotFoundResponse();
|
||||
$this->logger->debug('Recording "' . ($this->userId ?? 'ANONYMOUS') . '" throttled for accessing "' . $token . '"', ['app' => 'spreed-bfp']);
|
||||
$response->throttle(['token' => $token, 'action' => 'talkRecordingStatus']);
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (class_exists(LoadViewer::class)) {
|
||||
$this->eventDispatcher->dispatchTyped(new LoadViewer());
|
||||
}
|
||||
|
||||
$this->publishInitialStateForGuest();
|
||||
|
||||
$this->eventDispatcher->dispatchTyped(new LoadAdditionalScriptsEvent());
|
||||
$this->eventDispatcher->dispatchTyped(new RenderReferenceEvent());
|
||||
|
||||
$response = new PublicTemplateResponse($this->appName, 'recording', [
|
||||
'id-app-content' => '#content-vue',
|
||||
'id-app-navigation' => null,
|
||||
]);
|
||||
|
||||
$response->setFooterVisible(false);
|
||||
$csp = new ContentSecurityPolicy();
|
||||
$csp->addAllowedImageDomain('https://*.tile.openstreetmap.org');
|
||||
$csp->addAllowedMediaDomain('blob:');
|
||||
$csp->addAllowedWorkerSrcDomain('blob:');
|
||||
$csp->addAllowedWorkerSrcDomain("'self'");
|
||||
$csp->addAllowedChildSrcDomain('blob:');
|
||||
$csp->addAllowedChildSrcDomain("'self'");
|
||||
$csp->addAllowedScriptDomain('blob:');
|
||||
$csp->addAllowedScriptDomain("'self'");
|
||||
$csp->addAllowedScriptDomain("'wasm-unsafe-eval'");
|
||||
$csp->addAllowedConnectDomain('blob:');
|
||||
$csp->addAllowedConnectDomain("'self'");
|
||||
foreach ($this->talkConfig->getAllServerUrlsForCSP() as $server) {
|
||||
$csp->addAllowedConnectDomain($server);
|
||||
}
|
||||
$response->setContentSecurityPolicy($csp);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TemplateResponse|RedirectResponse
|
||||
* @throws HintException
|
||||
*/
|
||||
protected function guestEnterRoom(
|
||||
string $token,
|
||||
string $password,
|
||||
string $email,
|
||||
#[SensitiveParameter]
|
||||
string $accessToken,
|
||||
): Response {
|
||||
if ($email && $accessToken) {
|
||||
return $this->invitedEmail(
|
||||
$token,
|
||||
$email,
|
||||
$accessToken,
|
||||
);
|
||||
}
|
||||
try {
|
||||
$room = $this->manager->getRoomByToken($token);
|
||||
if ($room->getType() !== Room::TYPE_PUBLIC) {
|
||||
throw new RoomNotFoundException();
|
||||
}
|
||||
} catch (RoomNotFoundException $e) {
|
||||
$redirectUrl = $this->url->linkToRoute('spreed.Page.index');
|
||||
if ($token) {
|
||||
$redirectUrl = $this->url->linkToRoute('spreed.Page.showCall', ['token' => $token]);
|
||||
}
|
||||
$response = new RedirectResponse($this->url->linkToRoute('core.login.showLoginForm', [
|
||||
'redirect_url' => $redirectUrl,
|
||||
]));
|
||||
$response->throttle(['token' => $token, 'action' => 'talkRoomToken']);
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($room->hasPassword()) {
|
||||
$password = $password !== '' ? $password : (string)$this->talkSession->getPasswordForRoom($token);
|
||||
|
||||
$passwordVerification = $this->roomService->verifyPassword($room, $password);
|
||||
if ($passwordVerification['result']) {
|
||||
$this->talkSession->renewSessionId();
|
||||
$this->talkSession->setPasswordForRoom($token, $password);
|
||||
$this->throttler->resetDelay($this->request->getRemoteAddress(), 'talkRoomPassword', ['token' => $token, 'action' => 'talkRoomPassword']);
|
||||
} else {
|
||||
$this->talkSession->removePasswordForRoom($token);
|
||||
$showBruteForceWarning = $this->throttler->getDelay($this->request->getRemoteAddress(), 'talkRoomPassword') > 5000;
|
||||
|
||||
if ($passwordVerification['url'] === '') {
|
||||
$response = new TemplateResponse($this->appName, 'authenticate', [
|
||||
'wrongpw' => $password !== '',
|
||||
'showBruteForceWarning' => $showBruteForceWarning,
|
||||
], 'guest');
|
||||
} else {
|
||||
$response = new RedirectResponse($passwordVerification['url']);
|
||||
}
|
||||
$response->throttle(['token' => $token, 'action' => 'talkRoomPassword']);
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
$this->publishInitialStateForGuest();
|
||||
$this->eventDispatcher->dispatchTyped(new RenderReferenceEvent());
|
||||
|
||||
$response = new PublicTemplateResponse($this->appName, 'index', [
|
||||
'id-app-content' => '#content-vue',
|
||||
'id-app-navigation' => null,
|
||||
]);
|
||||
|
||||
$response->setFooterVisible(false);
|
||||
$csp = new ContentSecurityPolicy();
|
||||
$csp->addAllowedImageDomain('https://*.tile.openstreetmap.org');
|
||||
$csp->addAllowedMediaDomain('blob:');
|
||||
$csp->addAllowedWorkerSrcDomain('blob:');
|
||||
$csp->addAllowedWorkerSrcDomain("'self'");
|
||||
$csp->addAllowedChildSrcDomain('blob:');
|
||||
$csp->addAllowedChildSrcDomain("'self'");
|
||||
$csp->addAllowedScriptDomain('blob:');
|
||||
$csp->addAllowedScriptDomain("'self'");
|
||||
$csp->addAllowedScriptDomain("'wasm-unsafe-eval'");
|
||||
$csp->addAllowedConnectDomain('blob:');
|
||||
$csp->addAllowedConnectDomain("'self'");
|
||||
foreach ($this->talkConfig->getAllServerUrlsForCSP() as $server) {
|
||||
$csp->addAllowedConnectDomain($server);
|
||||
}
|
||||
$response->setContentSecurityPolicy($csp);
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TemplateResponse|RedirectResponse
|
||||
* @throws HintException
|
||||
*/
|
||||
protected function invitedEmail(
|
||||
string $token,
|
||||
string $email,
|
||||
#[SensitiveParameter]
|
||||
string $accessToken,
|
||||
): Response {
|
||||
try {
|
||||
$actorId = hash('sha256', $email);
|
||||
$this->manager->getRoomByAccessToken(
|
||||
$token,
|
||||
Attendee::ACTOR_EMAILS,
|
||||
$actorId,
|
||||
$accessToken,
|
||||
);
|
||||
$this->talkSession->renewSessionId();
|
||||
$this->talkSession->setAuthedEmailActorIdForRoom($token, $actorId);
|
||||
} catch (RoomNotFoundException) {
|
||||
$redirectUrl = $this->url->linkToRoute('spreed.Page.index');
|
||||
if ($token) {
|
||||
$redirectUrl = $this->url->linkToRoute('spreed.Page.showCall', ['token' => $token]);
|
||||
}
|
||||
$response = new RedirectResponse($this->url->linkToRoute('core.login.showLoginForm', [
|
||||
'redirect_url' => $redirectUrl,
|
||||
]));
|
||||
$response->throttle(['token' => $token, 'action' => 'talkRoomToken']);
|
||||
return $response;
|
||||
}
|
||||
|
||||
$this->publishInitialStateForGuest();
|
||||
$this->eventDispatcher->dispatchTyped(new RenderReferenceEvent());
|
||||
|
||||
$response = new PublicTemplateResponse($this->appName, 'index', [
|
||||
'id-app-content' => '#content-vue',
|
||||
'id-app-navigation' => null,
|
||||
]);
|
||||
|
||||
$response->setFooterVisible(false);
|
||||
$csp = new ContentSecurityPolicy();
|
||||
$csp->addAllowedImageDomain('https://*.tile.openstreetmap.org');
|
||||
$csp->addAllowedMediaDomain('blob:');
|
||||
$csp->addAllowedWorkerSrcDomain('blob:');
|
||||
$csp->addAllowedWorkerSrcDomain("'self'");
|
||||
$csp->addAllowedChildSrcDomain('blob:');
|
||||
$csp->addAllowedChildSrcDomain("'self'");
|
||||
$csp->addAllowedScriptDomain('blob:');
|
||||
$csp->addAllowedScriptDomain("'self'");
|
||||
$csp->addAllowedScriptDomain("'wasm-unsafe-eval'");
|
||||
$csp->addAllowedConnectDomain('blob:');
|
||||
$csp->addAllowedConnectDomain("'self'");
|
||||
foreach ($this->talkConfig->getAllServerUrlsForCSP() as $server) {
|
||||
$csp->addAllowedConnectDomain($server);
|
||||
}
|
||||
$response->setContentSecurityPolicy($csp);
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $token
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
#[NoCSRFRequired]
|
||||
#[PublicPage]
|
||||
protected function redirectToConversation(string $token): RedirectResponse {
|
||||
// These redirects are already done outside of this method
|
||||
if ($this->userId === null) {
|
||||
try {
|
||||
$room = $this->manager->getRoomByToken($token);
|
||||
if ($room->getType() !== Room::TYPE_PUBLIC) {
|
||||
throw new RoomNotFoundException();
|
||||
}
|
||||
return new RedirectResponse($this->url->linkToRoute('spreed.Page.showCall', ['token' => $token]));
|
||||
} catch (RoomNotFoundException $e) {
|
||||
return new RedirectResponse($this->url->linkToRoute('core.login.showLoginForm', [
|
||||
'redirect_url' => $this->url->linkToRoute('spreed.Page.showCall', ['token' => $token]),
|
||||
]));
|
||||
}
|
||||
}
|
||||
return new RedirectResponse($this->url->linkToRoute('spreed.Page.showCall', ['token' => $token]));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user