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

Источник: 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:
2026-07-06 14:07:50 +00:00
commit 01acfa3b40
1716 changed files with 613013 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Events\BotInvokeEvent;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\BotServer;
use OCA\Talk\Model\Message;
use OCA\Talk\Model\Thread;
use OCA\Talk\Room;
use OCP\Comments\IComment;
/**
* @psalm-import-type ChatMessageParentData from BotInvokeEvent
* @psalm-type NoteType = array{type: 'Note', id: numeric-string, name: string, content: string, mediaType: 'text/markdown'|'text/plain'}
*/
class ActivityPubHelper {
/**
* @return array{type: 'Application', id: non-falsy-string, name: string}
*/
public function generateApplicationFromBot(BotServer $bot): array {
return [
'type' => 'Application',
'id' => Attendee::ACTOR_BOTS . '/' . Attendee::ACTOR_BOT_PREFIX . $bot->getUrlHash(),
'name' => $bot->getName(),
];
}
/**
* @return array{type: 'Collection', id: non-empty-string, name: string}
*/
public function generateCollectionFromRoom(Room $room): array {
/** @var non-empty-string $token */
$token = $room->getToken();
return [
'type' => 'Collection',
'id' => $token,
'name' => $room->getName(),
];
}
/**
* @psalm-param ?ChatMessageParentData $inReplyTo
* @psalm-return NoteType&array{inReplyTo?: ChatMessageParentData, threadId?: int}
*/
public function generateNote(IComment $comment, array $messageData, string $messageType, ?array $inReplyTo = null): array {
/** @var string $content */
$content = json_encode($messageData, JSON_THROW_ON_ERROR);
/** @var numeric-string $messageId */
$messageId = $comment->getId();
/** @var 'text/markdown'|'text/plain' $mediaType */
$mediaType = 'text/markdown';// FIXME or text/plain when markdown is disabled
$note = [
'type' => 'Note',
'id' => $messageId,
'name' => $messageType,
'content' => $content,
'mediaType' => $mediaType,
];
if ($inReplyTo !== null) {
$note['inReplyTo'] = $inReplyTo;
}
$metadata = $comment->getMetaData() ?? [];
$threadId = $metadata[Message::METADATA_THREAD_ID] ?? Thread::THREAD_NONE;
if ($threadId !== Thread::THREAD_NONE) {
$note['threadId'] = (int)$threadId;
}
return $note;
}
/**
* @return array{type: 'Person', id: non-falsy-string, name: string, talkParticipantType: numeric-string}
*/
public function generatePersonFromAttendee(Attendee $attendee): array {
return [
'type' => 'Person',
'id' => $attendee->getActorType() . '/' . $attendee->getActorId(),
'name' => $attendee->getDisplayName(),
'talkParticipantType' => (string)$attendee->getParticipantType(),
];
}
/**
* @return array{type: 'Person', id: non-falsy-string, name: string}
*/
public function generatePersonFromMessageActor(Message $message): array {
return [
'type' => 'Person',
'id' => $message->getActorType() . '/' . $message->getActorId(),
'name' => $message->getActorDisplayName(),
];
}
/**
* @return array{type: 'Person', id: non-falsy-string, name: string}
*/
public function generatePerson(string $actorType, string $actorId, string $displayName): array {
return [
'type' => 'Person',
'id' => $actorType . '/' . $actorId,
'name' => $displayName,
];
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Model\Attachment;
use OCA\Talk\Model\AttachmentMapper;
use OCA\Talk\Room;
use OCP\Comments\IComment;
class AttachmentService {
public function __construct(
public AttachmentMapper $attachmentMapper,
) {
}
public function createAttachmentEntry(Room $room, IComment $comment, string $messageType, array $parameters): void {
$attachment = new Attachment();
$attachment->setRoomId($room->getId());
$attachment->setActorType($comment->getActorType());
$attachment->setActorId($comment->getActorId());
$attachment->setMessageId((int)$comment->getId());
$attachment->setMessageTime($comment->getCreationDateTime()->getTimestamp());
if ($messageType === 'object_shared') {
$objectType = $parameters['objectType'] ?? '';
if ($objectType === 'geo-location') {
$attachment->setObjectType(Attachment::TYPE_LOCATION);
} elseif ($objectType === 'deck-card') {
$attachment->setObjectType(Attachment::TYPE_DECK_CARD);
} elseif ($objectType === 'talk-poll') {
$attachment->setObjectType(Attachment::TYPE_POLL);
} else {
$attachment->setObjectType(Attachment::TYPE_OTHER);
}
} else {
$messageType = $parameters['metaData']['messageType'] ?? '';
$mimetype = $parameters['metaData']['mimeType'] ?? '';
if ($messageType === ChatManager::VERB_RECORD_AUDIO) {
$attachment->setObjectType(Attachment::TYPE_RECORDING);
} elseif ($messageType === ChatManager::VERB_RECORD_VIDEO) {
$attachment->setObjectType(Attachment::TYPE_RECORDING);
} elseif ($messageType === ChatManager::VERB_VOICE_MESSAGE) {
$attachment->setObjectType(Attachment::TYPE_VOICE);
} elseif (str_starts_with($mimetype, 'audio/')) {
$attachment->setObjectType(Attachment::TYPE_AUDIO);
} elseif (str_starts_with($mimetype, 'image/') || str_starts_with($mimetype, 'video/')) {
$attachment->setObjectType(Attachment::TYPE_MEDIA);
} else {
$attachment->setObjectType(Attachment::TYPE_FILE);
}
}
$this->attachmentMapper->insert($attachment);
}
/**
* @param Room $room
* @param string $objectType
* @param int $offset
* @param int $limit
* @return Attachment[]
*/
public function getAttachmentsByType(Room $room, string $objectType, int $offset, int $limit): array {
return $this->attachmentMapper->getAttachmentsByType($room->getId(), $objectType, $offset, $limit);
}
public function deleteAttachmentByMessageId(int $messageId): void {
$this->attachmentMapper->deleteByMessageId($messageId);
}
public function deleteAttachmentsForRoom(Room $room): void {
$this->attachmentMapper->deleteByRoomId($room->getId());
}
}
+326
View File
@@ -0,0 +1,326 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use InvalidArgumentException;
use OC\Files\Filesystem;
use OCA\Talk\Room;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\InMemoryFile;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\IAvatarManager;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\Security\ISecureRandom;
class AvatarService {
public const THEMING_PLACEHOLDER = '{{THEMING}}';
public const THEMING_DARK_BACKGROUND = '3B3B3B';
public const THEMING_BRIGHT_BACKGROUND = '6B6B6B';
public function __construct(
private IAppData $appData,
private IL10N $l,
private IURLGenerator $url,
private ISecureRandom $random,
private RoomService $roomService,
private IAvatarManager $avatarManager,
private EmojiService $emojiService,
) {
}
public function setAvatarFromRequest(Room $room, ?array $file): void {
if ($room->getType() === Room::TYPE_ONE_TO_ONE || $room->getType() === Room::TYPE_ONE_TO_ONE_FORMER) {
throw new InvalidArgumentException($this->l->t('One-to-one rooms always need to show the other users avatar'));
}
if ($file === null) {
throw new InvalidArgumentException($this->l->t('No image file provided'));
}
if (
$file['error'] !== 0
|| !is_uploaded_file($file['tmp_name'])
|| Filesystem::isFileBlacklisted($file['tmp_name'])
) {
throw new InvalidArgumentException($this->l->t('Invalid file provided'));
}
if ($file['size'] > 20 * 1024 * 1024) {
throw new InvalidArgumentException($this->l->t('File is too big'));
}
$content = file_get_contents($file['tmp_name']);
// noopengrep: php.lang.security.unlink-use.unlink-use
unlink($file['tmp_name']);
$image = new \OCP\Image();
$image->loadFromData($content);
$image->readExif($content);
$this->setAvatar($room, $image);
}
public function setAvatarFromEmoji(Room $room, string $emoji, ?string $color): void {
if ($room->getType() === Room::TYPE_ONE_TO_ONE || $room->getType() === Room::TYPE_ONE_TO_ONE_FORMER) {
throw new InvalidArgumentException($this->l->t('One-to-one rooms always need to show the other users avatar'));
}
if ($this->emojiService->getFirstCombinedEmoji($emoji) !== $emoji) {
throw new InvalidArgumentException($this->l->t('Invalid emoji character'));
}
if ($color === null) {
$color = self::THEMING_PLACEHOLDER;
} elseif (!preg_match('/^[a-fA-F0-9]{6}$/', $color)) {
throw new InvalidArgumentException($this->l->t('Invalid background color'));
}
$content = $this->getEmojiAvatar($emoji, $color);
$token = $room->getToken();
$avatarFolder = $this->getAvatarFolder($token);
// Delete previous avatars
foreach ($avatarFolder->getDirectoryListing() as $file) {
$file->delete();
}
$avatarName = $this->random->generate(16, ISecureRandom::CHAR_HUMAN_READABLE) . '.svg';
$avatarFolder->newFile($avatarName, $content);
$this->roomService->setAvatar($room, $avatarName);
}
public function setAvatar(Room $room, \OCP\Image $image): void {
if ($room->getType() === Room::TYPE_ONE_TO_ONE || $room->getType() === Room::TYPE_ONE_TO_ONE_FORMER) {
throw new InvalidArgumentException($this->l->t('One-to-one rooms always need to show the other users avatar'));
}
$image->fixOrientation();
if (!($image->height() === $image->width())) {
throw new InvalidArgumentException($this->l->t('Avatar image is not square'));
}
if (!$image->valid()) {
throw new InvalidArgumentException($this->l->t('Invalid image'));
}
$mimeType = $image->mimeType();
$allowedMimeTypes = [
'image/jpeg',
'image/png',
];
if (!in_array($mimeType, $allowedMimeTypes)) {
throw new InvalidArgumentException($this->l->t('Unknown filetype'));
}
$token = $room->getToken();
$avatarFolder = $this->getAvatarFolder($token);
// Delete previous avatars
foreach ($avatarFolder->getDirectoryListing() as $file) {
$file->delete();
}
$avatarName = $this->random->generate(16, ISecureRandom::CHAR_HUMAN_READABLE);
if ($mimeType === 'image/jpeg') {
$avatarName .= '.jpg';
} else {
$avatarName .= '.png';
}
$avatarFolder->newFile($avatarName, $image->data());
$this->roomService->setAvatar($room, $avatarName);
}
private function getAvatarFolder(string $token): ISimpleFolder {
try {
$folder = $this->appData->getFolder('room-avatar');
} catch (NotFoundException $e) {
$folder = $this->appData->newFolder('room-avatar');
}
try {
$avatarFolder = $folder->getFolder($token);
} catch (NotFoundException $e) {
$avatarFolder = $folder->newFolder($token);
}
return $avatarFolder;
}
/**
* https://github.com/sebdesign/cap-height -- for 500px height
* Automated check: https://codepen.io/skjnldsv/pen/PydLBK/
* Noto Sans cap-height is 0.715 and we want a 200px caps height size
* (0.4 letter-to-total-height ratio, 500*0.4=200), so: 200/0.715 = 280px.
* Since we start from the baseline (text-anchor) we need to
* shift the y axis by 100px (half the caps height): 500/2+100=350
*
* Copied from @see \OC\Avatar\Avatar::$svgTemplate with some changes:
* - {font} is injected
* - size fixed to 512
* - font-size reduced to 240
* - font-weight and fill color are removed as they are not applicable
*/
private string $svgTemplate = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="512" height="512" version="1.1" viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#{fill}"></rect>
<text x="50%" y="330" style="font-size:240px;font-family:{font};text-anchor:middle;">{letter}</text>
</svg>';
public function getAvatar(Room $room, ?IUser $user, bool $darkTheme = false): ISimpleFile {
$token = $room->getToken();
$avatar = $room->getAvatar();
if ($avatar) {
try {
$folder = $this->appData->getFolder('room-avatar');
if ($folder->fileExists($token)) {
$file = $folder->getFolder($token)->getFile($avatar);
if ($file->getMimeType() === 'image/svg+xml' && str_contains($file->getContent(), self::THEMING_PLACEHOLDER)) {
$color = $darkTheme ? self::THEMING_DARK_BACKGROUND : self::THEMING_BRIGHT_BACKGROUND;
return new InMemoryFile(
$file->getName(),
str_replace(self::THEMING_PLACEHOLDER, $color, $file->getContent()),
);
}
return $file;
}
} catch (NotFoundException $e) {
}
}
// Fallback
if ($room->getType() === Room::TYPE_ONE_TO_ONE) {
$users = json_decode($room->getName(), true);
foreach ($users as $participantId) {
if ($user instanceof IUser && $participantId !== $user->getUID()) {
$avatar = $this->avatarManager->getAvatar($participantId);
return $avatar->getFile(512, $darkTheme);
}
}
}
if ($this->emojiService->isValidSingleEmoji(mb_substr($room->getName(), 0, 1))) {
return new InMemoryFile(
$token,
$this->getEmojiAvatar(
$this->emojiService->getFirstCombinedEmoji($room->getName()),
$darkTheme ? self::THEMING_DARK_BACKGROUND : self::THEMING_BRIGHT_BACKGROUND
)
);
}
return new InMemoryFile($token, file_get_contents($this->getAvatarPath($room, $darkTheme)));
}
public function getPersonPlaceholder(bool $darkTheme = false): ISimpleFile {
$colorTone = $darkTheme ? 'dark' : 'bright';
return new InMemoryFile('fallback', file_get_contents(__DIR__ . '/../../img/icon-conversation-user-' . $colorTone . '.svg'));
}
protected function getEmojiAvatar(string $emoji, string $fillColor): string {
return str_replace([
'{letter}',
'{fill}',
'{font}',
], [
$emoji,
$fillColor,
implode(',', [
"'Segoe UI'",
'Roboto',
'Oxygen-Sans',
'Cantarell',
'Ubuntu',
"'Helvetica Neue'",
'Arial',
'sans-serif',
"'Noto Color Emoji'",
"'Apple Color Emoji'",
"'Segoe UI Emoji'",
"'Segoe UI Symbol'",
"'Noto Sans'",
]),
], $this->svgTemplate);
}
public function isCustomAvatar(Room $room): bool {
return $room->getAvatar() !== '';
}
private function getAvatarPath(Room $room, bool $darkTheme = false): string {
$colorTone = $darkTheme ? 'dark' : 'bright';
if ($room->getType() === Room::TYPE_CHANGELOG) {
return __DIR__ . '/../../img/changelog.svg';
}
if ($room->getObjectType() === Room::OBJECT_TYPE_FILE) {
return __DIR__ . '/../../img/icon-conversation-text-' . $colorTone . '.svg';
}
if ($room->getObjectType() === Room::OBJECT_TYPE_VIDEO_VERIFICATION) {
return __DIR__ . '/../../img/icon-conversation-password-' . $colorTone . '.svg';
}
if ($room->getObjectType() === Room::OBJECT_TYPE_EMAIL) {
return __DIR__ . '/../../img/icon-conversation-mail-' . $colorTone . '.svg';
}
if (in_array($room->getObjectType(), [Room::OBJECT_TYPE_PHONE_PERSIST, Room::OBJECT_TYPE_PHONE_TEMPORARY, Room::OBJECT_TYPE_PHONE_LEGACY], true)) {
return __DIR__ . '/../../img/icon-conversation-phone-' . $colorTone . '.svg';
}
if ($room->getObjectType() === Room::OBJECT_TYPE_EVENT) {
return __DIR__ . '/../../img/icon-conversation-event-' . $colorTone . '.svg';
}
if ($room->isFederatedConversation()) {
return __DIR__ . '/../../img/icon-conversation-federation-' . $colorTone . '.svg';
}
if ($room->getType() === Room::TYPE_PUBLIC) {
return __DIR__ . '/../../img/icon-conversation-public-' . $colorTone . '.svg';
}
if ($room->getType() === Room::TYPE_ONE_TO_ONE_FORMER
|| $room->getType() === Room::TYPE_ONE_TO_ONE
) {
return __DIR__ . '/../../img/icon-conversation-user-' . $colorTone . '.svg';
}
return __DIR__ . '/../../img/icon-conversation-group-' . $colorTone . '.svg';
}
public function deleteAvatar(Room $room): void {
try {
$folder = $this->appData->getFolder('room-avatar');
$avatarFolder = $folder->getFolder($room->getToken());
$avatarFolder->delete();
$this->roomService->setAvatar($room, '');
} catch (NotFoundException $e) {
}
}
public function getAvatarUrl(Room $room): string {
$arguments = [
'token' => $room->getToken(),
'apiVersion' => 'v1',
];
$avatarVersion = $this->getAvatarVersion($room);
if ($avatarVersion !== '') {
$arguments['v'] = $avatarVersion;
}
return $this->url->linkToOCSRouteAbsolute('spreed.Avatar.getAvatar', $arguments);
}
public function getAvatarVersion(Room $room): string {
$avatarVersion = $room->getAvatar();
if ($avatarVersion) {
[$version] = explode('.', $avatarVersion);
return $version;
}
if ($this->emojiService->isValidSingleEmoji(mb_substr($room->getName(), 0, 1))) {
return substr(md5($this->getEmojiAvatar($this->emojiService->getFirstCombinedEmoji($room->getName()), self::THEMING_BRIGHT_BACKGROUND)), 0, 8);
}
$avatarPath = $this->getAvatarPath($room);
return substr(md5($avatarPath), 0, 8);
}
}
+268
View File
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use DateTime;
use OCA\Talk\Events\AAttendeeRemovedEvent;
use OCA\Talk\Exceptions\ForbiddenException;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Manager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\Ban;
use OCA\Talk\Model\BanMapper;
use OCA\Talk\Room;
use OCA\Talk\TalkSession;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\DB\Exception;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\Security\Ip\IFactory;
use Psr\Log\LoggerInterface;
class BanService {
public function __construct(
protected BanMapper $banMapper,
protected Manager $manager,
protected ParticipantService $participantService,
protected IUserManager $userManager,
protected TalkSession $talkSession,
protected IRequest $request,
protected LoggerInterface $logger,
protected IFactory $ipFactory,
) {
}
/**
* Create a new ban
*
* @throws \InvalidArgumentException
*/
public function createBan(Room $room, string $moderatorActorType, string $moderatorActorId, string $moderatorDisplayname, string $bannedActorType, string $bannedActorId, DateTime $bannedTime, string $internalNote): Ban {
if (!in_array($room->getType(), [Room::TYPE_GROUP, Room::TYPE_PUBLIC], true)) {
throw new \InvalidArgumentException('room');
}
if (!in_array($bannedActorType, [Attendee::ACTOR_USERS, Attendee::ACTOR_GUESTS, Attendee::ACTOR_EMAILS, 'ip'], true)) {
throw new \InvalidArgumentException('bannedActor');
}
if (empty($bannedActorId)) {
throw new \InvalidArgumentException('bannedActor');
}
if ($bannedActorType === 'ip') {
try {
$this->ipFactory->addressFromString($bannedActorId);
} catch (\InvalidArgumentException) {
// Not an IP, check if it's a range
try {
$this->ipFactory->rangeFromString($bannedActorId);
} catch (\InvalidArgumentException) {
// Not an IP range either
throw new \InvalidArgumentException('bannedActor');
}
}
}
if (strlen($internalNote) > Ban::NOTE_MAX_LENGTH) {
throw new \InvalidArgumentException('internalNote');
}
if ($bannedActorType === $moderatorActorType && $bannedActorId === $moderatorActorId) {
throw new \InvalidArgumentException('self');
}
/** @var ?string $displayname */
$displayname = null;
if (in_array($bannedActorType, [Attendee::ACTOR_USERS, Attendee::ACTOR_EMAILS, Attendee::ACTOR_GUESTS], true)) {
try {
$bannedParticipant = $this->participantService->getParticipantByActor($room, $bannedActorType, $bannedActorId);
$displayname = $bannedParticipant->getAttendee()->getDisplayName();
if ($bannedParticipant->hasModeratorPermissions()) {
throw new \InvalidArgumentException('moderator');
}
} catch (ParticipantNotFoundException) {
// No failure if the banned actor is not in the room yet/anymore
if ($bannedActorType === Attendee::ACTOR_USERS) {
$displayname = $this->userManager->getDisplayName($bannedActorId);
}
}
}
if ($displayname === null || $displayname === '') {
$displayname = $bannedActorId;
}
$ban = new Ban();
$ban->setModeratorActorType($moderatorActorType);
$ban->setModeratorActorId($moderatorActorId);
$ban->setModeratorDisplayname($moderatorDisplayname);
$ban->setRoomId($room->getId());
$ban->setBannedActorType($bannedActorType);
$ban->setBannedActorId($bannedActorId);
$ban->setBannedDisplayname($displayname);
$ban->setBannedTime($bannedTime);
$ban->setInternalNote($internalNote);
//Remove the banned user from the room
if ($bannedActorType !== 'ip') {
try {
$bannedParticipant = $this->participantService->getParticipantByActor($room, $bannedActorType, $bannedActorId);
$this->participantService->removeAttendee($room, $bannedParticipant, AAttendeeRemovedEvent::REASON_REMOVED);
} catch (ParticipantNotFoundException) {
// No failure if the banned actor is not in the room yet/anymore
}
}
return $this->banMapper->insert($ban);
}
public function copyBanForRemoteAddress(Ban $ban, string $remoteAddress): void {
$this->logger->info('Banned guest detected, banning IP address: ' . $remoteAddress . ' to prevent rejoining.');
$newBan = new Ban();
$newBan->setModeratorActorType($ban->getModeratorActorType());
$newBan->setModeratorActorId($ban->getModeratorActorId());
$newBan->setModeratorDisplayname($ban->getModeratorDisplayname());
$newBan->setRoomId($ban->getRoomId());
$newBan->setBannedTime($ban->getBannedTime());
$newBan->setInternalNote($ban->getInternalNote());
$newBan->setBannedActorType('ip');
$newBan->setBannedActorId($remoteAddress);
try {
$this->banMapper->insert($newBan);
} catch (Exception $e) {
if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
return;
}
throw $e;
}
}
/**
* @throws ForbiddenException
*/
public function throwIfActorIsBanned(Room $room, ?string $userId): void {
if ($userId !== null) {
$actorType = Attendee::ACTOR_USERS;
$actorId = $userId;
} else {
$actorId = $this->talkSession->getAuthedEmailActorIdForRoom($room->getToken());
if ($actorId !== null) {
$actorType = Attendee::ACTOR_EMAILS;
} else {
$actorId = $this->talkSession->getGuestActorIdForRoom($room->getToken());
$actorType = Attendee::ACTOR_GUESTS;
}
}
if ($actorId !== null) {
try {
$ban = $this->banMapper->findForBannedActorAndRoom($actorType, $actorId, $room->getId());
if (in_array($actorType, [Attendee::ACTOR_GUESTS, Attendee::ACTOR_EMAILS], true)) {
$this->copyBanForRemoteAddress($ban, $this->request->getRemoteAddress());
}
throw new ForbiddenException('actor');
} catch (DoesNotExistException) {
}
}
if ($actorType !== Attendee::ACTOR_GUESTS) {
return;
}
$ipBans = $this->banMapper->findByRoomId($room->getId(), 'ip');
if (empty($ipBans)) {
return;
}
try {
$remoteAddress = $this->ipFactory->addressFromString($this->request->getRemoteAddress());
} catch (\InvalidArgumentException) {
return;
}
foreach ($ipBans as $ban) {
if ($ban->getBannedActorId() === $this->request->getRemoteAddress()) {
throw new ForbiddenException('ip');
}
try {
$range = $this->ipFactory->rangeFromString($ban->getBannedActorId());
if ($range->contains($remoteAddress)) {
throw new ForbiddenException('ip');
}
} catch (\InvalidArgumentException) {
}
}
}
/**
* Check if the actor is banned without logging
*
* @return bool True if the actor is banned, false otherwise
*/
public function isActorBanned(Room $room, string $actorType, string $actorId): bool {
try {
$this->banMapper->findForBannedActorAndRoom($actorType, $actorId, $room->getId());
return true;
} catch (DoesNotExistException) {
return false;
}
}
/**
* Retrieve all bans for a specific room.
*
* @return list<Ban>
*/
public function getBansForRoom(int $roomId): array {
return $this->banMapper->findByRoomId($roomId);
}
/**
* Retrieve all banned userIDs for a specific room.
*
* @return array<string, mixed> Key is the user ID
*/
public function getBannedUserIdsForRoom(int $roomId): array {
$bans = $this->banMapper->findByRoomId($roomId, Attendee::ACTOR_USERS);
return array_flip(array_map(static fn (Ban $ban) => $ban->getBannedActorId(), $bans));
}
/**
* Retrieve all room IDs a user is banned from
*
* @return array<int, mixed> Key is the room ID
*/
public function getBannedRoomsForUserId(string $userId): array {
$bans = $this->banMapper->findByUserId($userId);
return array_flip(array_map(static fn (Ban $ban) => $ban->getRoomId(), $bans));
}
/**
* Retrieve a ban by its ID and delete it.
*/
public function findAndDeleteBanByIdForRoom(int $banId, int $roomId): void {
try {
$ban = $this->banMapper->findByBanIdAndRoom($banId, $roomId);
$this->banMapper->delete($ban);
} catch (DoesNotExistException) {
// Ban does not exist
}
}
public function updateDisplayNameForActor(string $actorType, string $actorId, string $displayName): void {
$this->banMapper->updateDisplayNameForActor($actorType, $actorId, $displayName);
}
}
+503
View File
@@ -0,0 +1,503 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Chat\MessageParser;
use OCA\Talk\Chat\ReactionManager;
use OCA\Talk\Events\BotDisabledEvent;
use OCA\Talk\Events\BotEnabledEvent;
use OCA\Talk\Events\BotInvokeEvent;
use OCA\Talk\Events\ChatMessageSentEvent;
use OCA\Talk\Events\ReactionAddedEvent;
use OCA\Talk\Events\ReactionRemovedEvent;
use OCA\Talk\Events\SystemMessageSentEvent;
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\Room;
use OCA\Talk\TalkSession;
use OCP\App\IAppManager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Comments\IComment;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\ICertificateManager;
use OCP\IConfig;
use OCP\ISession;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Security\ISecureRandom;
use OCP\Server;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type InvocationData from BotInvokeEvent
*/
class BotService {
private ActivityPubHelper $activityPubHelper;
public function __construct(
protected BotServerMapper $botServerMapper,
protected BotConversationMapper $botConversationMapper,
protected ThreadService $threadService,
protected ChatManager $chatManager,
protected IClientService $clientService,
protected IConfig $serverConfig,
protected IUserSession $userSession,
protected TalkSession $talkSession,
protected ISession $session,
protected ISecureRandom $secureRandom,
protected IURLGenerator $urlGenerator,
protected IFactory $l10nFactory,
protected ITimeFactory $timeFactory,
protected LoggerInterface $logger,
protected ICertificateManager $certificateManager,
protected IEventDispatcher $dispatcher,
protected IAppManager $appManager,
) {
$this->activityPubHelper = new ActivityPubHelper();
}
public function afterBotEnabled(BotEnabledEvent $event): void {
$this->invokeBots([$event->getBotServer()], $event->getRoom(), null, [
'type' => 'Join',
'actor' => $this->activityPubHelper->generateApplicationFromBot($event->getBotServer()),
'object' => $this->activityPubHelper->generateCollectionFromRoom($event->getRoom()),
]);
}
public function afterBotDisabled(BotDisabledEvent $event): void {
$this->invokeBots([$event->getBotServer()], $event->getRoom(), null, [
'type' => 'Leave',
'actor' => $this->activityPubHelper->generateApplicationFromBot($event->getBotServer()),
'object' => $this->activityPubHelper->generateCollectionFromRoom($event->getRoom()),
]);
}
public function afterChatMessageSent(ChatMessageSentEvent $event, MessageParser $messageParser): void {
$attendee = $event->getParticipant()?->getAttendee();
if (!$attendee instanceof Attendee) {
// No bots for bots
return;
}
$bots = $this->getBotsForToken($event->getRoom()->getToken(), Bot::FEATURE_WEBHOOK | Bot::FEATURE_EVENT);
if (empty($bots)) {
return;
}
$inReplyTo = null;
$parent = $event->getParent();
if ($parent instanceof IComment) {
$parentMessage = $messageParser->createMessage(
$event->getRoom(),
$event->getParticipant(),
$parent,
$this->l10nFactory->get('spreed', 'en', 'en')
);
$messageParser->parseMessage($parentMessage, true);
$parentMessageData = [
'message' => $parentMessage->getMessage(),
'parameters' => $parentMessage->getMessageParameters(),
];
$inReplyTo = [
'type' => 'Note',
'actor' => $this->activityPubHelper->generatePersonFromMessageActor($parentMessage),
'object' => $this->activityPubHelper->generateNote($parent, $parentMessageData, 'message'),
];
}
$message = $messageParser->createMessage(
$event->getRoom(),
$event->getParticipant(),
$event->getComment(),
$this->l10nFactory->get('spreed', 'en', 'en')
);
$messageParser->parseMessage($message, true);
$messageData = [
'message' => $message->getMessage(),
'parameters' => $message->getMessageParameters(),
];
$botServers = array_map(static fn (Bot $bot): BotServer => $bot->getBotServer(), $bots);
$this->invokeBots($botServers, $event->getRoom(), $event->getComment(), [
'type' => 'Create',
'actor' => $this->activityPubHelper->generatePersonFromAttendee($attendee),
'object' => $this->activityPubHelper->generateNote($event->getComment(), $messageData, 'message', $inReplyTo),
'target' => $this->activityPubHelper->generateCollectionFromRoom($event->getRoom()),
]);
}
public function afterSystemMessageSent(SystemMessageSentEvent $event, MessageParser $messageParser): void {
$bots = $this->getBotsForToken($event->getRoom()->getToken(), Bot::FEATURE_WEBHOOK | Bot::FEATURE_EVENT);
if (empty($bots)) {
return;
}
$message = $messageParser->createMessage(
$event->getRoom(),
null,
$event->getComment(),
$this->l10nFactory->get('spreed', 'en', 'en')
);
$messageParser->parseMessage($message);
$messageData = [
'message' => $message->getMessage(),
'parameters' => $message->getMessageParameters(),
];
$botServers = array_map(static fn (Bot $bot): BotServer => $bot->getBotServer(), $bots);
$this->invokeBots($botServers, $event->getRoom(), $event->getComment(), [
'type' => 'Activity',
'actor' => $this->activityPubHelper->generatePersonFromMessageActor($message),
'object' => $this->activityPubHelper->generateNote($event->getComment(), $messageData, $message->getMessageRaw()),
'target' => $this->activityPubHelper->generateCollectionFromRoom($event->getRoom()),
]);
}
public function afterReactionAdded(ReactionAddedEvent $event, MessageParser $messageParser): void {
$bots = $this->getBotsForToken($event->getRoom()->getToken(), Bot::FEATURE_REACTION);
if (empty($bots)) {
return;
}
$message = $messageParser->createMessage(
$event->getRoom(),
null,
$event->getMessage(),
$this->l10nFactory->get('spreed', 'en', 'en')
);
$messageParser->parseMessage($message);
$messageData = [
'message' => $message->getMessage(),
'parameters' => $message->getMessageParameters(),
];
$botServers = array_map(static fn (Bot $bot): BotServer => $bot->getBotServer(), $bots);
$this->invokeBots($botServers, $event->getRoom(), $event->getMessage(), [
'type' => 'Like',
'actor' => $this->activityPubHelper->generatePerson($event->getActorType(), $event->getActorId(), $event->getActorDisplayName()),
'object' => $this->activityPubHelper->generateNote($event->getMessage(), $messageData, $message->getMessageRaw()),
'target' => $this->activityPubHelper->generateCollectionFromRoom($event->getRoom()),
'content' => $event->getReaction(),
]);
}
public function afterReactionRemoved(ReactionRemovedEvent $event, MessageParser $messageParser): void {
$bots = $this->getBotsForToken($event->getRoom()->getToken(), Bot::FEATURE_REACTION);
if (empty($bots)) {
return;
}
$message = $messageParser->createMessage(
$event->getRoom(),
null,
$event->getMessage(),
$this->l10nFactory->get('spreed', 'en', 'en')
);
$messageParser->parseMessage($message);
$messageData = [
'message' => $message->getMessage(),
'parameters' => $message->getMessageParameters(),
];
$botServers = array_map(static fn (Bot $bot): BotServer => $bot->getBotServer(), $bots);
$this->invokeBots($botServers, $event->getRoom(), $event->getMessage(), [
'type' => 'Undo',
'actor' => $this->activityPubHelper->generatePerson($event->getActorType(), $event->getActorId(), $event->getActorDisplayName()),
'object' => [
'type' => 'Like',
'actor' => $this->activityPubHelper->generatePersonFromMessageActor($message),
'object' => $this->activityPubHelper->generateNote($event->getMessage(), $messageData, $message->getMessageRaw()),
'target' => $this->activityPubHelper->generateCollectionFromRoom($event->getRoom()),
'content' => $event->getReaction(),
],
'target' => $this->activityPubHelper->generateCollectionFromRoom($event->getRoom()),
]);
}
/**
* @param BotServer[] $bots
* @param InvocationData $body
*/
protected function invokeBots(array $bots, Room $room, ?IComment $comment, array $body): void {
$jsonBody = json_encode($body, JSON_THROW_ON_ERROR);
foreach ($bots as $bot) {
if ($bot->getFeatures() & Bot::FEATURE_EVENT) {
$event = new BotInvokeEvent($bot->getUrl(), $body);
$this->dispatcher->dispatchTyped($event);
if ($comment instanceof IComment) {
if (!empty($event->getReactions())) {
$reactionManager = Server::get(ReactionManager::class);
foreach ($event->getReactions() as $reaction) {
try {
$reactionManager->addReactionMessage(
$room,
Attendee::ACTOR_BOTS,
Attendee::ACTOR_BOT_PREFIX . $bot->getUrlHash(),
$bot->getName(),
(int)$comment->getId(),
$reaction
);
} catch (\Exception $e) {
$this->logger->error('Error while trying to react as a bot: ' . $e->getMessage(), ['exception' => $e]);
}
}
}
if (!empty($event->getAnswers())) {
$chatManager = Server::get(ChatManager::class);
foreach ($event->getAnswers() as $answer) {
$creationDateTime = $this->timeFactory->getDateTime('now', new \DateTimeZone('UTC'));
try {
$replyTo = null;
$threadId = 0;
$threadTitle = '';
if ($answer['reply'] === true) {
$replyTo = $comment;
} elseif (is_int($answer['reply'])) {
$replyTo = $chatManager->getParentComment($room, (string)$answer['reply']);
} elseif ($answer['thread'] === true) {
$threadId = (int)$comment->getTopmostParentId() ?: (int)$comment->getId();
} elseif ($answer['threadTitle'] !== null) {
$threadTitle = $answer['threadTitle'];
}
$botComment = $chatManager->sendMessage(
$room,
null,
Attendee::ACTOR_BOTS,
Attendee::ACTOR_BOT_PREFIX . $bot->getUrlHash(),
$answer['message'],
$creationDateTime,
$replyTo,
$answer['referenceId'],
$answer['silent'],
rateLimitGuestMentions: false,
threadId: $threadId,
);
if ($threadTitle !== '') {
$thread = $this->threadService->createThread($room, (int)$comment->getId(), $threadTitle);
$this->chatManager->addSystemMessage(
$room,
null,
Attendee::ACTOR_BOTS,
Attendee::ACTOR_BOT_PREFIX . $bot->getUrlHash(),
json_encode(['message' => 'thread_created', 'parameters' => ['thread' => (int)$botComment->getId(), 'title' => $thread->getName()]]),
$this->timeFactory->getDateTime(),
false,
null,
$botComment,
true,
true
);
}
} catch (\Exception $e) {
$this->logger->error('Error while trying to answer as a bot: ' . $e->getMessage(), ['exception' => $e]);
}
}
}
}
} else {
$this->sendAsyncRequest($bot, $body, $jsonBody);
}
}
}
/**
* @param BotServer $botServer
* @param array $body
* #param string|null $jsonBody
*/
protected function sendAsyncRequest(BotServer $botServer, array $body, ?string $jsonBody = null): void {
$jsonBody = $jsonBody ?? json_encode($body, JSON_THROW_ON_ERROR);
$random = $this->secureRandom->generate(64);
$hash = hash_hmac('sha256', $random . $jsonBody, $botServer->getSecret());
$headers = [
'Content-Type' => 'application/json',
'X-Nextcloud-Talk-Random' => $random,
'X-Nextcloud-Talk-Signature' => $hash,
'X-Nextcloud-Talk-Backend' => rtrim($this->serverConfig->getSystemValueString('overwrite.cli.url'), '/') . '/',
'OCS-APIRequest' => 'true',
];
$data = [
'verify' => $this->certificateManager->getAbsoluteBundlePath(),
'nextcloud' => [
'allow_local_address' => true,
],
'headers' => $headers,
'timeout' => 5,
'body' => $jsonBody,
];
$client = $this->clientService->newClient();
$promise = $client->postAsync($botServer->getUrl(), $data);
$promise->then(function (IResponse $response) use ($botServer): void {
if ($response->getStatusCode() !== Http::STATUS_OK && $response->getStatusCode() !== Http::STATUS_ACCEPTED) {
$this->logger->error('Bot responded with unexpected status code (Received: ' . $response->getStatusCode() . '), increasing error count');
$botServer->setErrorCount($botServer->getErrorCount() + 1);
$botServer->setLastErrorDate($this->timeFactory->now());
$botServer->setLastErrorMessage('UnexpectedStatusCode: ' . $response->getStatusCode());
$this->botServerMapper->update($botServer);
}
}, function (\Exception $exception) use ($botServer): void {
$this->logger->error('Bot error occurred, increasing error count', ['exception' => $exception]);
$botServer->setErrorCount($botServer->getErrorCount() + 1);
$botServer->setLastErrorDate($this->timeFactory->now());
$botServer->setLastErrorMessage(get_class($exception) . ': ' . $exception->getMessage());
$this->botServerMapper->update($botServer);
});
}
/**
* @param Room $room
* @return array
* @psalm-return array{type: string, id: string, name: string}
*/
protected function getActor(Room $room): array {
if (\OC::$CLI || $this->session->exists('talk-overwrite-actor-cli')) {
return [
'type' => Attendee::ACTOR_GUESTS,
'id' => 'cli',
'name' => 'Administration',
];
}
if ($this->session->exists('talk-overwrite-actor-type')) {
return [
'type' => $this->session->get('talk-overwrite-actor-type'),
'id' => $this->session->get('talk-overwrite-actor-id'),
'name' => $this->session->get('talk-overwrite-actor-displayname'),
];
}
if ($this->session->exists('talk-overwrite-actor-id')) {
return [
'type' => Attendee::ACTOR_USERS,
'id' => $this->session->get('talk-overwrite-actor-id'),
'name' => $this->session->get('talk-overwrite-actor-displayname'),
];
}
$user = $this->userSession->getUser();
if ($user instanceof IUser) {
return [
'type' => Attendee::ACTOR_USERS,
'id' => $user->getUID(),
'name' => $user->getDisplayName(),
];
}
$sessionId = $this->talkSession->getSessionForRoom($room->getToken());
$actorId = $sessionId ? sha1($sessionId) : 'failed-to-get-session';
return [
'type' => Attendee::ACTOR_GUESTS,
'id' => $actorId,
'name' => '',
];
}
/**
* @param string $token
* @param int|null $requiredFeature
* @return Bot[]
*/
public function getBotsForToken(string $token, ?int $requiredFeature): array {
$botConversations = $this->botConversationMapper->findForToken($token);
if (empty($botConversations)) {
return [];
}
$botIds = array_map(static fn (BotConversation $bot): int => $bot->getBotId(), $botConversations);
$serversMap = [];
$botServers = $this->botServerMapper->findByIds($botIds);
foreach ($botServers as $botServer) {
$serversMap[$botServer->getId()] = $botServer;
}
$bots = [];
foreach ($botConversations as $botConversation) {
if (!isset($serversMap[$botConversation->getBotId()])) {
$this->logger->warning('Can not find bot by ID ' . $botConversation->getBotId() . ' for token ' . $botConversation->getToken());
continue;
}
$botServer = $serversMap[$botConversation->getBotId()];
if ($requiredFeature && !($botServer->getFeatures() & $requiredFeature)) {
$this->logger->debug('Ignoring bot ID ' . $botConversation->getBotId() . ' because the feature (' . $requiredFeature . ') is disabled for it');
continue;
}
$bot = new Bot(
$botServer,
$botConversation,
);
if ($bot->isEnabled()) {
$bots[] = $bot;
}
}
return $bots;
}
/**
* @throws \InvalidArgumentException
*/
public function validateBotParameters(string $name, string $secret, string $url, string $description): void {
$nameLength = strlen($name);
if ($nameLength === 0 || $nameLength > 64) {
throw new \InvalidArgumentException('The provided name is too short or too long (min. 1 char, max. 64 chars)');
}
$secretLength = strlen($secret);
if ($secretLength < 40 || $secretLength > 128) {
throw new \InvalidArgumentException('The provided secret is too short (min. 40 chars, max. 128 chars)');
}
if (!$url || strlen($url) > 4000 || !(str_starts_with($url, 'http://') || str_starts_with($url, 'https://') || str_starts_with($url, Bot::URL_APP_PREFIX) || str_starts_with($url, Bot::URL_RESPONSE_ONLY_PREFIX))) {
throw new \InvalidArgumentException('The provided URL is not a valid URL');
}
if (strlen($description) > 4000) {
throw new \InvalidArgumentException('The provided description is too long (max. 4000 chars)');
}
}
public function isAppForBotEnabled(BotServer $bot): bool {
if (!str_starts_with($bot->getUrl(), Bot::URL_APP_PREFIX)) {
return true;
}
$url = substr($bot->getUrl(), strlen(Bot::URL_APP_PREFIX));
[$appId] = explode('/', $url, 2);
return $this->appManager->isEnabledForAnyone($appId);
}
}
+572
View File
@@ -0,0 +1,572 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use InvalidArgumentException;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Config;
use OCA\Talk\Events\AAttendeeRemovedEvent;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\RoomProperty\BreakoutRoomModeException;
use OCA\Talk\Manager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\BreakoutRoom;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCA\Talk\Webinary;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IL10N;
use OCP\Notification\IManager as INotificationManager;
class BreakoutRoomService {
public function __construct(
protected Config $config,
protected Manager $manager,
protected RoomService $roomService,
protected ParticipantService $participantService,
protected ChatManager $chatManager,
protected INotificationManager $notificationManager,
protected ITimeFactory $timeFactory,
protected IEventDispatcher $dispatcher,
protected IL10N $l,
) {
}
/**
* @param string $map
* @param int $max
* @return array
*/
protected function parseAttendeeMap(string $map, int $max): array {
if ($map === '') {
return [];
}
try {
$attendeeMap = json_decode($map, true, 2, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new InvalidArgumentException('attendeeMap');
}
if (!is_array($attendeeMap)) {
throw new InvalidArgumentException('attendeeMap');
}
if (empty($attendeeMap)) {
return [];
}
try {
$attendeeMap = array_filter($attendeeMap, static fn (int $roomNumber, int $attendeeId) => true, ARRAY_FILTER_USE_BOTH);
} catch (\Throwable) {
throw new InvalidArgumentException('attendeeMap');
}
if (empty($attendeeMap)) {
return [];
}
if (max($attendeeMap) >= $max) {
throw new InvalidArgumentException('attendeeMap');
}
if (min($attendeeMap) < 0) {
throw new InvalidArgumentException('attendeeMap');
}
if (min(array_keys($attendeeMap)) <= 0) {
throw new InvalidArgumentException('attendeeMap');
}
return $attendeeMap;
}
/**
* @param Room $parent
* @param 0|1|2|3 $mode
* @psalm-param BreakoutRoom::MODE_* $mode
* @param int $amount
* @param string $attendeeMap
* @return Room[]
* @throws InvalidArgumentException When the breakout rooms are configured already
*/
public function setupBreakoutRooms(Room $parent, int $mode, int $amount, string $attendeeMap): array {
if (!$this->config->isBreakoutRoomsEnabled()) {
throw new InvalidArgumentException('config');
}
if ($parent->getBreakoutRoomMode() !== BreakoutRoom::MODE_NOT_CONFIGURED) {
throw new InvalidArgumentException('room');
}
if ($parent->getType() !== Room::TYPE_GROUP) {
// Can only do breakout rooms in group rooms
throw new InvalidArgumentException('room');
}
if ($parent->getObjectType() === BreakoutRoom::PARENT_OBJECT_TYPE) {
// Can not nest breakout rooms
throw new InvalidArgumentException('room');
}
try {
$this->roomService->setBreakoutRoomMode($parent, $mode);
} catch (BreakoutRoomModeException) {
throw new InvalidArgumentException('mode');
}
if ($amount < BreakoutRoom::MINIMUM_ROOM_AMOUNT) {
throw new InvalidArgumentException('amount');
}
if ($amount > BreakoutRoom::MAXIMUM_ROOM_AMOUNT) {
throw new InvalidArgumentException('amount');
}
if ($mode === BreakoutRoom::MODE_MANUAL) {
$cleanedMap = $this->parseAttendeeMap($attendeeMap, $amount);
}
$breakoutRooms = $this->createBreakoutRooms($parent, $amount);
$participants = $this->participantService->getParticipantsForRoom($parent);
// TODO Removing any non-users here as breakout rooms only support logged in users in version 1
$participants = array_filter($participants, static fn (Participant $participant) => $participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS);
$moderators = array_filter($participants, static fn (Participant $participant) => $participant->hasModeratorPermissions());
$this->addModeratorsToBreakoutRooms($breakoutRooms, $moderators);
$others = array_filter($participants, static fn (Participant $participant) => !$participant->hasModeratorPermissions());
if ($mode === BreakoutRoom::MODE_AUTOMATIC) {
// Shuffle the attendees, so they are not always distributed in the same way
shuffle($others);
$map = [];
foreach ($others as $index => $participant) {
$map[$index % $amount] ??= [];
$map[$index % $amount][] = $participant;
}
$this->addOthersToBreakoutRooms($breakoutRooms, $map);
} elseif ($mode === BreakoutRoom::MODE_MANUAL) {
$map = [];
foreach ($others as $participant) {
if (!isset($cleanedMap[$participant->getAttendee()->getId()])) {
continue;
}
$roomNumber = (int)$cleanedMap[$participant->getAttendee()->getId()];
$map[$roomNumber] ??= [];
$map[$roomNumber][] = $participant;
}
$this->addOthersToBreakoutRooms($breakoutRooms, $map);
}
return $breakoutRooms;
}
/**
* @param Room $parent
* @param string $attendeeMap
* @return Room[]
* @throws InvalidArgumentException When the map was invalid, breakout rooms are disabled or not configured for this conversation
*/
public function applyAttendeeMap(Room $parent, string $attendeeMap): array {
if (!$this->config->isBreakoutRoomsEnabled()) {
throw new InvalidArgumentException('config');
}
if ($parent->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
throw new InvalidArgumentException('mode');
}
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());
$amount = count($breakoutRooms);
usort($breakoutRooms, static function (Room $roomA, Room $roomB) {
return $roomA->getId() - $roomB->getId();
});
$cleanedMap = $this->parseAttendeeMap($attendeeMap, $amount);
$attendeeIds = array_keys($cleanedMap);
$participants = $this->participantService->getParticipantsForRoom($parent);
$participants = array_filter($participants, static fn (Participant $participant) => in_array($participant->getAttendee()->getId(), $attendeeIds, true));
// TODO Removing any non-users here as breakout rooms only support logged in users in version 1
$participants = array_filter($participants, static fn (Participant $participant) => $participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS);
$userIds = array_map(static fn (Participant $participant) => $participant->getAttendee()->getActorId(), $participants);
$removals = [];
foreach ($breakoutRooms as $breakoutRoom) {
$breakoutRoomParticipants = $this->participantService->getParticipantsForRoom($breakoutRoom);
foreach ($breakoutRoomParticipants as $participant) {
$attendee = $participant->getAttendee();
if ($attendee->getActorType() === Attendee::ACTOR_USERS && in_array($attendee->getActorId(), $userIds, true)) {
if ($participant->hasModeratorPermissions()) {
// Can not remove moderators with this method
throw new InvalidArgumentException('moderator');
}
$removals[] = [
'room' => $breakoutRoom,
'participant' => $participant,
];
}
}
}
foreach ($removals as $removal) {
$this->participantService->removeAttendee($removal['room'], $removal['participant'], AAttendeeRemovedEvent::REASON_REMOVED);
}
$map = [];
foreach ($participants as $participant) {
if (!isset($cleanedMap[$participant->getAttendee()->getId()])) {
continue;
}
$roomNumber = (int)$cleanedMap[$participant->getAttendee()->getId()];
$map[$roomNumber] ??= [];
$map[$roomNumber][] = $participant;
}
$this->addOthersToBreakoutRooms($breakoutRooms, $map);
return $breakoutRooms;
}
/**
* @param Room[] $rooms
* @param Participant[] $moderators
*/
public function addModeratorsToBreakoutRooms(array $rooms, array $moderators): void {
$moderatorsToAdd = [];
foreach ($moderators as $moderator) {
$attendee = $moderator->getAttendee();
$moderatorsToAdd[] = [
'actorType' => $attendee->getActorType(),
'actorId' => $attendee->getActorId(),
'displayName' => $attendee->getDisplayName(),
'participantType' => $attendee->getParticipantType(),
];
}
foreach ($rooms as $room) {
$this->participantService->addUsers($room, $moderatorsToAdd);
}
}
/**
* @param array $rooms
* @param Participant[][] $participantsMap
*/
protected function addOthersToBreakoutRooms(array $rooms, array $participantsMap): void {
foreach ($rooms as $roomNumber => $room) {
$toAdd = [];
$participants = $participantsMap[$roomNumber] ?? [];
foreach ($participants as $participant) {
$attendee = $participant->getAttendee();
$toAdd[] = [
'actorType' => $attendee->getActorType(),
'actorId' => $attendee->getActorId(),
'displayName' => $attendee->getDisplayName(),
'participantType' => $attendee->getParticipantType(),
];
}
if (empty($toAdd)) {
continue;
}
$this->participantService->addUsers($room, $toAdd);
}
}
protected function createBreakoutRooms(Room $parent, int $amount): array {
// Safety caution cleaning up potential orphan rooms
$this->deleteBreakoutRooms($parent);
// TRANSLATORS Label for the breakout rooms, this is not a plural! The result will be "Room 1", "Room 2", "Room 3", ...
$label = $this->l->t('Room {number}');
$rooms = [];
for ($i = 1; $i <= $amount; $i++) {
$breakoutRoom = $this->roomService->createConversation(
$parent->getType(),
str_replace('{number}', (string)$i, $label),
null,
BreakoutRoom::PARENT_OBJECT_TYPE,
$parent->getToken()
);
$this->roomService->setLobby($breakoutRoom, Webinary::LOBBY_NON_MODERATORS, null, false, false);
$rooms[] = $breakoutRoom;
}
return $rooms;
}
public function removeBreakoutRooms(Room $parent): void {
$this->deleteBreakoutRooms($parent);
$this->roomService->setBreakoutRoomMode($parent, BreakoutRoom::MODE_NOT_CONFIGURED);
$this->roomService->setBreakoutRoomStatus($parent, BreakoutRoom::STATUS_STOPPED);
}
protected function deleteBreakoutRooms(Room $parent): void {
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());
foreach ($breakoutRooms as $breakoutRoom) {
$this->roomService->deleteRoom($breakoutRoom);
}
}
/**
* @param Room $parent
* @param Participant $participant
* @param string $message
* @return Room[]
*/
public function broadcastChatMessage(Room $parent, Participant $participant, string $message): array {
if ($parent->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
throw new InvalidArgumentException('mode');
}
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());
$attendeeType = $participant->getAttendee()->getActorType();
$attendeeId = $participant->getAttendee()->getActorId();
$creationDateTime = new \DateTime();
$shouldFlush = $this->notificationManager->defer();
try {
foreach ($breakoutRooms as $breakoutRoom) {
$breakoutParticipant = $this->participantService->getParticipantByActor($breakoutRoom, $attendeeType, $attendeeId);
$comment = $this->chatManager->sendMessage($breakoutRoom, $breakoutParticipant, $attendeeType, $attendeeId, $message, $creationDateTime, rateLimitGuestMentions: false);
$breakoutRoom->setLastMessage($comment);
}
} finally {
if ($shouldFlush) {
$this->notificationManager->flush();
}
}
return $breakoutRooms;
}
public function requestAssistance(Room $breakoutRoom): void {
$this->setAssistanceRequest($breakoutRoom, BreakoutRoom::STATUS_ASSISTANCE_REQUESTED);
}
public function resetRequestForAssistance(Room $breakoutRoom): void {
$this->setAssistanceRequest($breakoutRoom, BreakoutRoom::STATUS_ASSISTANCE_RESET);
}
protected function setAssistanceRequest(Room $breakoutRoom, int $status): void {
if ($breakoutRoom->getObjectType() !== BreakoutRoom::PARENT_OBJECT_TYPE) {
throw new InvalidArgumentException('room');
}
if ($breakoutRoom->getLobbyState() !== Webinary::LOBBY_NONE) {
throw new InvalidArgumentException('room');
}
if (!in_array($status, [
BreakoutRoom::STATUS_ASSISTANCE_RESET,
BreakoutRoom::STATUS_ASSISTANCE_REQUESTED,
], true)) {
throw new InvalidArgumentException('status');
}
$this->roomService->setBreakoutRoomStatus($breakoutRoom, $status);
$this->roomService->setLastActivity($breakoutRoom, $this->timeFactory->getDateTime());
}
/**
* @param Room $parent
* @return Room[]
*/
public function startBreakoutRooms(Room $parent): array {
if ($parent->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
throw new InvalidArgumentException('mode');
}
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken(), true);
foreach ($breakoutRooms as $breakoutRoom) {
$this->roomService->setLobby($breakoutRoom, Webinary::LOBBY_NONE, null);
}
$this->roomService->setBreakoutRoomStatus($parent, BreakoutRoom::STATUS_STARTED);
return $breakoutRooms;
}
/**
* @param Room $parent
* @return Room[]
*/
public function stopBreakoutRooms(Room $parent): array {
if ($parent->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
throw new InvalidArgumentException('mode');
}
$this->roomService->setBreakoutRoomStatus($parent, BreakoutRoom::STATUS_STOPPED);
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken(), true);
foreach ($breakoutRooms as $breakoutRoom) {
$this->roomService->setLobby($breakoutRoom, Webinary::LOBBY_NON_MODERATORS, null);
if ($breakoutRoom->getBreakoutRoomStatus() === BreakoutRoom::STATUS_ASSISTANCE_REQUESTED) {
$this->roomService->setBreakoutRoomStatus($breakoutRoom, BreakoutRoom::STATUS_ASSISTANCE_RESET);
}
}
return $breakoutRooms;
}
public function switchBreakoutRoom(Room $parent, Participant $participant, string $targetToken): Room {
if ($parent->getBreakoutRoomMode() !== BreakoutRoom::MODE_FREE) {
throw new InvalidArgumentException('mode');
}
if ($parent->getBreakoutRoomStatus() !== BreakoutRoom::STATUS_STARTED) {
throw new InvalidArgumentException('status');
}
if ($participant->hasModeratorPermissions()) {
// Moderators don't switch, they are part of all breakout rooms
throw new InvalidArgumentException('moderator');
}
$attendee = $participant->getAttendee();
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());
$target = null;
foreach ($breakoutRooms as $breakoutRoom) {
if ($targetToken === $breakoutRoom->getToken()) {
$target = $breakoutRoom;
break;
}
}
if ($target === null) {
throw new InvalidArgumentException('target');
}
foreach ($breakoutRooms as $breakoutRoom) {
try {
$removeParticipant = $this->participantService->getParticipantByActor(
$breakoutRoom,
$attendee->getActorType(),
$attendee->getActorId()
);
if ($targetToken !== $breakoutRoom->getToken()) {
// Remove from all other breakout rooms
$this->participantService->removeAttendee(
$breakoutRoom,
$removeParticipant,
AAttendeeRemovedEvent::REASON_LEFT
);
}
} catch (ParticipantNotFoundException $e) {
if ($targetToken === $breakoutRoom->getToken()) {
// Join the target breakout room
$this->participantService->addUsers(
$breakoutRoom,
[
[
'actorType' => $attendee->getActorType(),
'actorId' => $attendee->getActorId(),
'displayName' => $attendee->getDisplayName(),
'participantType' => $attendee->getParticipantType(),
]
]
);
}
}
}
return $target;
}
/**
* @param Room $parent
* @param Participant $participant
* @return Room[]
*/
public function getBreakoutRooms(Room $parent, Participant $participant): array {
if ($parent->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
throw new InvalidArgumentException('mode');
}
if (!$participant->hasModeratorPermissions() && $parent->getBreakoutRoomStatus() !== BreakoutRoom::STATUS_STARTED) {
throw new InvalidArgumentException('status');
}
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken(), true);
$returnAll = $participant->hasModeratorPermissions() || $parent->getBreakoutRoomMode() === BreakoutRoom::MODE_FREE;
if (!$returnAll) {
$rooms = [];
foreach ($breakoutRooms as $breakoutRoom) {
try {
$this->participantService->getParticipantByActor(
$breakoutRoom,
$participant->getAttendee()->getActorType(),
$participant->getAttendee()->getActorId()
);
$rooms[] = $breakoutRoom;
} catch (ParticipantNotFoundException $e) {
// Skip this room
}
}
return $rooms;
}
return $breakoutRooms;
}
/**
* @param Room $parent
* @param string $actorType
* @param string $actorId
* @param bool $throwOnModerator
* @return void
* @throws InvalidArgumentException When being used for a moderator
*/
public function removeAttendeeFromBreakoutRoom(Room $parent, string $actorType, string $actorId, bool $throwOnModerator = true): void {
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());
foreach ($breakoutRooms as $breakoutRoom) {
try {
$participant = $this->participantService->getParticipantByActor(
$breakoutRoom,
$actorType,
$actorId
);
if ($throwOnModerator && $participant->hasModeratorPermissions()) {
throw new InvalidArgumentException('moderator');
}
$this->participantService->removeAttendee($breakoutRoom, $participant, AAttendeeRemovedEvent::REASON_REMOVED);
} catch (ParticipantNotFoundException $e) {
// Skip this room
}
}
}
}
+357
View File
@@ -0,0 +1,357 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Dashboard\Event;
use OCA\Talk\Exceptions\InvalidRoomException;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Manager;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Room;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Calendar\ICalendar;
use OCP\Calendar\IManager;
use OCP\IDateTimeZone;
use OCP\IURLGenerator;
use OCP\IUserManager;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type TalkDashboardEvent from ResponseDefinitions
*/
class CalendarIntegrationService {
public function __construct(
private Manager $manager,
private IManager $calendarManager,
private ITimeFactory $timeFactory,
private LoggerInterface $logger,
private RoomService $roomService,
private IDateTimeZone $dateTimeZone,
private AvatarService $avatarService,
private IURLGenerator $urlGenerator,
private IUserManager $userManager,
) {
}
/**
* @param string $userId
* @return list<TalkDashboardEvent>
*/
public function getDashboardEvents(string $userId): array {
$principaluri = 'principals/users/' . $userId;
$calendars = $this->calendarManager->getCalendarsForPrincipal($principaluri);
if (count($calendars) === 0) {
return [];
}
// Only use personal calendars
// Events for shared calendars where you are an ATTENDEE will be in your personal calendar
$calendars = array_filter($calendars, static function (ICalendar $calendar) {
if ($calendar->getUri() === 'contact_birthdays') {
// The birthday calendar does not contain events with a location matching a talk room.
return false;
}
if (method_exists($calendar, 'isShared')) {
return $calendar->isShared() === false;
}
return true;
});
$userTimezone = $this->dateTimeZone->getTimezone();
// Midnight for the current user so we also include ongoing events (might be all day events)
$start = $this->timeFactory->getDateTime()->setTimezone($userTimezone)->setTime(0, 0);
$start = $start->setTimezone(new \DateTimeZone('UTC'));
$end = clone($start);
$end = $end->add(\DateInterval::createFromDateString('1 week'));
$options = [
'timerange' => [
'start' => $start,
'end' => $end,
],
];
$pattern = '/call/';
$searchProperties = ['LOCATION'];
$events = [];
/** @var ICalendar $calendar */
foreach ($calendars as $calendar) {
$searchResult = $calendar->search($pattern, $searchProperties, $options, 100);
foreach ($searchResult as $calendarEvent) {
// Find first recurrence in the future
$event = null;
$dashboardEvent = new Event();
foreach ($calendarEvent['objects'] as $object) {
if (!isset($object['DTEND'][0])) {
// Don't show events without end since they should not take up any time
// @link https://www.kanzaki.com/docs/ical/vevent.html
continue;
}
$dashboardEvent->setStart(\DateTime::createFromImmutable($object['DTSTART'][0])->setTimezone($userTimezone)->getTimestamp());
$dashboardEvent->setEnd(\DateTime::createFromImmutable($object['DTEND'][0])->setTimezone($userTimezone)->getTimestamp());
// Filter out events in the past
if ($dashboardEvent->getEnd() <= $this->timeFactory->getDateTime('now', $userTimezone)->getTimestamp()) {
continue;
}
$event = $object;
break;
}
$location = $event['LOCATION'][0] ?? null;
if ($event === null || $location === null) {
continue;
}
if (isset($event['STATUS']) && $event['STATUS'][0] === 'CANCELLED') {
continue;
}
try {
$token = $this->roomService->parseRoomTokenFromUrl($location);
// Already returns public / open conversations
$room = $this->manager->getRoomForUserByToken($token, $userId);
} catch (RoomNotFoundException) {
$this->logger->debug("Room for url $location not found in dashboard service");
continue;
}
$dashboardEvent->setRoomToken($token);
$dashboardEvent->setRoomType($room->getType());
$dashboardEvent->setRoomName($room->getName());
$dashboardEvent->setRoomDisplayName($room->getDisplayName($userId));
if (isset($event['ATTENDEE'])) {
$dashboardEvent->generateAttendance($event['ATTENDEE']);
}
$dashboardEvent->setEventName($event['SUMMARY'][0] ?? '');
$dashboardEvent->setEventDescription($event['DESCRIPTION'][0] ?? null);
if (isset($event['ATTACH'])) {
$dashboardEvent->handleCalendarAttachments($calendar->getUri(), $event['ATTACH']);
}
if (isset($events[$dashboardEvent->generateEventIdentifier()])) {
/** @var Event $existing */
$existing = $events[$dashboardEvent->generateEventIdentifier()];
$existing->addCalendar($calendar->getUri(), $calendar->getDisplayName(), $calendar->getDisplayColor());
// Merge attachments
$existing->mergeAttachments($dashboardEvent);
// If original SUMMARY is empty, use the duplicate content if it exists
if ($existing->getEventDescription() === null) {
$existing->setEventDescription($dashboardEvent->getEventDescription() ?? '');
}
// We continue here as the same event already exists in a different calendar
$events[$existing->generateEventIdentifier()] = $existing;
continue;
}
$dashboardEvent->addCalendar($calendar->getUri(), $calendar->getDisplayName(), $calendar->getDisplayColor());
$dashboardEvent->setRoomAvatarVersion($this->avatarService->getAvatarVersion($room));
$dashboardEvent->setRoomActiveSince($room->getActiveSince()?->getTimestamp());
$objectId = base64_encode($this->urlGenerator->getWebroot() . '/remote.php/dav/calendars/' . $userId . '/' . $calendar->getUri() . '/' . $calendarEvent['uri']);
if (isset($event['RECURRENCE-ID'])) {
$dashboardEvent->setEventLink(
$this->urlGenerator->linkToRouteAbsolute(
'calendar.view.indexdirect.edit',
[
'objectId' => $objectId,
'recurrenceId' => $event['RECURRENCE-ID'][0],
]
)
);
} else {
$dashboardEvent->setEventLink(
$this->urlGenerator->linkToRouteAbsolute('calendar.view.indexdirect.edit', ['objectId' => $objectId])
);
}
$events[$dashboardEvent->generateEventIdentifier()] = $dashboardEvent;
if (count($events) === 10) {
break;
}
}
}
if (empty($events)) {
return $events;
}
usort($events, static function (Event $a, Event $b) {
return $a->getStart() - $b->getStart();
});
return array_map(static function (Event $event) {
return $event->jsonSerialize();
}, array_slice($events, 0, 10));
}
/**
* @param string $userId
* @param Room $room
* @return list<TalkDashboardEvent>
*/
public function getMutualEvents(string $userId, Room $room): array {
if ($room->getType() !== Room::TYPE_ONE_TO_ONE) {
throw new InvalidRoomException();
}
try {
$userIds = json_decode($room->getName(), false, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new InvalidRoomException();
}
$participants = array_filter($userIds, static function (string $participantId) use ($userId) {
return $participantId !== $userId;
});
if (count($participants) !== 1) {
throw new InvalidRoomException();
}
$otherParticipant = $this->userManager->get(array_pop($participants));
if ($otherParticipant === null) {
// Change to correct exception
throw new ParticipantNotFoundException();
}
$pattern = $otherParticipant->getEMailAddress();
if ($pattern === null) {
return [];
}
$principaluri = 'principals/users/' . $userId;
$calendars = $this->calendarManager->getCalendarsForPrincipal($principaluri);
if (count($calendars) === 0) {
return [];
}
// Only use personal calendars
$calendars = array_filter($calendars, static function (ICalendar $calendar) {
if (method_exists($calendar, 'isShared')) {
return $calendar->isShared() === false;
}
return true;
});
$start = $this->timeFactory->getDateTime();
$end = clone($start);
$end = $end->add(\DateInterval::createFromDateString('1 week'));
$options = [
'timerange' => [
'start' => $start,
'end' => $end,
],
];
$userTimezone = $this->dateTimeZone->getTimezone();
$searchProperties = ['ATTENDEE', 'ORGANIZER'];
$events = [];
/** @var ICalendar $calendar */
foreach ($calendars as $calendar) {
$searchResult = $calendar->search($pattern, $searchProperties, $options);
foreach ($searchResult as $calendarEvent) {
// Find first recurrence in the future
$event = null;
$dashboardEvent = new Event();
foreach ($calendarEvent['objects'] as $object) {
$dashboardEvent->setStart(\DateTime::createFromImmutable($object['DTSTART'][0])->setTimezone($userTimezone)->getTimestamp());
$dashboardEvent->setEnd(\DateTime::createFromImmutable($object['DTEND'][0])->setTimezone($userTimezone)->getTimestamp());
if ($dashboardEvent->getStart() >= $start->getTimestamp()) {
$event = $object;
break;
}
}
if ($event === null) {
continue;
}
if (!isset($event['ORGANIZER']) && !isset($event['ATTENDEE'])) {
// Don't show events without attendees
continue;
}
if (!$dashboardEvent->isOrganizer($event['ORGANIZER'], $otherParticipant->getEMailAddress()) && !$dashboardEvent->isAttendee($event['ATTENDEE'], $otherParticipant->getEMailAddress())) {
// Due to a bug in the caldav search, we will get a search result for recurring events
// even if the pattern does not match the current recurrence
// So make sure that $otherParticipant is an attendee on the current event
continue;
}
$dashboardEvent->generateAttendance($event['ATTENDEE']);
$dashboardEvent->setEventName($event['SUMMARY'][0] ?? '');
$dashboardEvent->setEventDescription($event['DESCRIPTION'][0] ?? null);
$dashboardEvent->addCalendar($calendar->getUri(), $calendar->getDisplayName(), $calendar->getDisplayColor());
$location = $event['LOCATION'][0] ?? null;
if ($location !== null && str_contains($location, '/call/') === true) {
try {
$token = $this->roomService->parseRoomTokenFromUrl($location);
// Already returns public / open conversations
$eventRoom = $this->manager->getRoomForUserByToken($token, $userId);
} catch (RoomNotFoundException) {
$this->logger->debug("Room for url $location not found in dashboard service");
continue;
}
$dashboardEvent->setRoomType($eventRoom->getType());
$dashboardEvent->setRoomName($eventRoom->getName());
$dashboardEvent->setRoomToken($eventRoom->getToken());
$dashboardEvent->setRoomDisplayName($eventRoom->getDisplayName($userId));
$dashboardEvent->setRoomAvatarVersion($this->avatarService->getAvatarVersion($eventRoom));
$dashboardEvent->setRoomActiveSince($eventRoom->getActiveSince()?->getTimestamp());
}
if (isset($event['ATTACH'])) {
$dashboardEvent->handleCalendarAttachments($calendar->getUri(), $event['ATTACH']);
}
$objectId = base64_encode($this->urlGenerator->getWebroot() . '/remote.php/dav/calendars/' . $userId . '/' . $calendar->getUri() . '/' . $calendarEvent['uri']);
if (isset($event['RECURRENCE-ID'])) {
$dashboardEvent->setEventLink(
$this->urlGenerator->linkToRouteAbsolute(
'calendar.view.indexdirect.edit',
[
'objectId' => $objectId,
'recurrenceId' => $event['RECURRENCE-ID'][0],
]
)
);
} else {
$dashboardEvent->setEventLink(
$this->urlGenerator->linkToRouteAbsolute('calendar.view.indexdirect.edit', ['objectId' => $objectId])
);
}
$events[] = $dashboardEvent;
}
}
if (empty($events)) {
return $events;
}
usort($events, static function (Event $a, Event $b) {
return $a->getStart() - $b->getStart();
});
return array_map(static function (Event $event) {
return $event->jsonSerialize();
}, array_slice($events, 0, 3));
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use Psr\Log\LoggerInterface;
class CertificateService {
public function __construct(
private LoggerInterface $logger,
) {
}
/**
* Parse a url and only returns the host and optionally the port
*
* @param string $host The url to parse (e.g. 'https://hostname:port/directory')
* @return string|null null if the url has a non-tls scheme, otherwise the host and optionally the port (e.g. 'hostname:port')
*/
public function getParsedTlsHost(string $host): ?string {
$parsedUrl = parse_url($host);
// parse_url failed, $host is a seriously malformed URL
if ($parsedUrl === false) {
return null;
}
if (isset($parsedUrl['scheme'])) {
$scheme = strtolower($parsedUrl['scheme']);
// When we have a scheme specified which is different than https/wss, there's no tls host
if ($scheme !== 'https' && $scheme !== 'wss') {
return null;
}
}
// When we are unable to retrieve a host from the URL, just return the original host
if (!isset($parsedUrl['host'])) {
return $host;
}
$parsedHost = $parsedUrl['host'];
if (isset($parsedUrl['port'])) {
$parsedHost .= ':' . $parsedUrl['port'];
}
return $parsedHost;
}
/**
* Retrieve the hosts certificate expiration in days
*
* @param string $host The host to check the certificate of without scheme
* @return int|null Days until the certificate expires (negative when it's already expired)
*/
public function getCertificateExpirationInDays(string $host): ?int {
$parsedHost = $this->getParsedTlsHost($host);
if ($parsedHost === null) {
// Unable to parse the specified host
$this->logger->debug('Ignoring certificate check of non-tls host ' . $host);
return null;
}
// We need to disable verification here to also get an expired certificate
$streamContext = stream_context_create([
'ssl' => [
'capture_peer_cert' => true,
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
],
]);
// In case no port was specified, use port 443 for the check
if (!str_contains($parsedHost, ':')) {
$parsedHost .= ':443';
}
$this->logger->debug('Checking certificate of ' . $parsedHost);
$streamClient = stream_socket_client('ssl://' . $parsedHost, $errorNumber, $errorString, 30, STREAM_CLIENT_CONNECT, $streamContext);
if ($streamClient === false || $errorNumber !== 0) {
// Unable to connect or invalid server address
$this->logger->debug('Unable to check certificate of ' . $parsedHost);
return null;
}
$streamCertificate = stream_context_get_params($streamClient);
$certificateInfo = openssl_x509_parse($streamCertificate['options']['ssl']['peer_certificate']);
$certificateValidTo = new \DateTime('@' . $certificateInfo['validTo_time_t']);
$now = new \DateTime();
$diff = $now->diff($certificateValidTo);
$days = $diff->days;
if ($days === false) {
return null;
}
// $days will always be positive -> invert it, when the end date of the certificate is in the past
if ($diff->invert) {
$days *= -1;
}
return $days;
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Exceptions\UnauthorizedException;
class ChecksumVerificationService {
/**
* Check if the current request is coming from an allowed backend.
*
* The backend servers are sending custom headers "Talk-{{FEATURE}}-Random"
* containing at least 32 bytes random data, and the header
* "Talk-{{FEATURE}}-Checksum", which is the SHA256-HMAC of the random data
* and the body of the request, calculated with the shared secret from the
* configuration.
*
* @param string $random
* @param string $checksum
* @param string $secret
* @param string $data
* @return bool True if the request is from the backend and valid, false if not from SIP bridge
* @throws UnauthorizedException when the request tried to authenticate as backend but is not valid
*/
public function validateRequest(string $random, string $checksum, string $secret, string $data): bool {
if ($random === '' && $checksum === '') {
return false;
}
if (strlen($random) < 32) {
throw new UnauthorizedException('Invalid random provided');
}
if ($checksum === '') {
throw new UnauthorizedException('Invalid checksum provided');
}
if ($secret === '') {
throw new UnauthorizedException('No secret provided');
}
$hash = hash_hmac('sha256', $random . $data, $secret);
if (hash_equals($hash, strtolower($checksum))) {
return true;
}
throw new UnauthorizedException('Invalid HMAC provided');
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\Consent;
use OCA\Talk\Model\ConsentMapper;
use OCA\Talk\Room;
use OCP\AppFramework\Utility\ITimeFactory;
class ConsentService {
public function __construct(
protected ITimeFactory $timeFactory,
protected ConsentMapper $consentMapper,
) {
}
public function storeConsent(Room $room, string $actorType, string $actorId): Consent {
$consent = new Consent();
$consent->setToken($room->getToken());
$consent->setActorType($actorType);
$consent->setActorId($actorId);
$consent->setDateTime($this->timeFactory->getDateTime());
$this->consentMapper->insert($consent);
return $consent;
}
/**
* @return Consent[]
*/
public function getConsentForRoom(Room $room): array {
return $this->consentMapper->findForToken($room->getToken());
}
/**
* @param Attendee::ACTOR_* $actorType
* @return Consent[]
*/
public function getConsentForActor(string $actorType, string $actorId): array {
return $this->consentMapper->findForActor($actorType, $actorId);
}
/**
* @param Attendee::ACTOR_* $actorType
* @return Consent[]
*/
public function getConsentForRoomByActor(Room $room, string $actorType, string $actorId): array {
return $this->consentMapper->findForTokenByActor($room->getToken(), $actorType, $actorId);
}
public function deleteByActor(string $actorType, string $actorId): void {
$this->consentMapper->deleteByActor($actorType, $actorId);
}
public function deleteByRoom(Room $room): void {
$this->consentMapper->deleteByToken($room->getToken());
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCP\IEmojiHelper;
class EmojiService {
public function __construct(
protected IEmojiHelper $emojiHelper,
) {
}
/**
* Get the first combined full emoji (including gender, skin tone, job, …)
*
* @param string $roomName
* @param int $length
* @return string
*/
public function getFirstCombinedEmoji(string $roomName, int $length = 0): string {
if (!$this->emojiHelper->doesPlatformSupportEmoji() || mb_strlen($roomName) === $length) {
return '';
}
$attempt = mb_substr($roomName, 0, $length + 1);
if ($this->emojiHelper->isValidSingleEmoji($attempt)) {
$longerAttempt = $this->getFirstCombinedEmoji($roomName, $length + 1);
return $longerAttempt ?: $attempt;
}
return '';
}
public function isValidSingleEmoji(string $string): bool {
return $this->emojiHelper->doesPlatformSupportEmoji() && $this->emojiHelper->isValidSingleEmoji(mb_substr($string, 0, 1));
}
}
@@ -0,0 +1,487 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ServerException;
use OCA\Talk\DataObjects\AccountId;
use OCA\Talk\DataObjects\RegisterAccountData;
use OCA\Talk\Exceptions\HostedSignalingServerAPIException;
use OCA\Talk\Exceptions\HostedSignalingServerInputException;
use OCP\AppFramework\Http;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IL10N;
use OCP\Security\ISecureRandom;
use Psr\Log\LoggerInterface;
/**
* API documentation at https://gitlab.com/strukturag/spreed-hpbservice/-/blob/master/doc/API.md
*/
class HostedSignalingServerService {
/** @var mixed */
private $apiServerUrl;
public function __construct(
private IConfig $config,
private IClientService $clientService,
private LoggerInterface $logger,
private IL10N $l10n,
private ISecureRandom $secureRandom,
) {
$this->apiServerUrl = $this->config->getSystemValue('talk_hardcoded_hpb_service', 'https://api.spreed.cloud');
}
/**
* @throws HostedSignalingServerAPIException
* @throws HostedSignalingServerInputException
*/
public function registerAccount(RegisterAccountData $registerAccountData): AccountId {
try {
$nonce = $this->secureRandom->generate(32);
$this->config->setAppValue('spreed', 'hosted-signaling-server-nonce', $nonce);
$client = $this->clientService->newClient();
$response = $client->post($this->apiServerUrl . '/v1/account', [
'json' => [
'url' => $registerAccountData->getUrl(),
'name' => $registerAccountData->getName(),
'email' => $registerAccountData->getEmail(),
'language' => $registerAccountData->getLanguage(),
'country' => $registerAccountData->getCountry(),
],
'headers' => [
'X-Account-Service-Nonce' => $nonce,
],
'timeout' => 10,
]);
} catch (ClientException $e) {
$response = $e->getResponse();
if ($response === null) {
$this->logger->error('Failed to request hosted signaling server trial', ['exception' => $e]);
$message = $this->l10n->t('Failed to request trial because the trial server is unreachable. Please try again later.');
throw new HostedSignalingServerAPIException($message, Http::STATUS_INTERNAL_SERVER_ERROR);
}
$status = $response->getStatusCode();
switch ($status) {
case Http::STATUS_UNAUTHORIZED:
$body = $response->getBody()->getContents();
$this->logger->error('Requesting hosted signaling server trial failed: unauthorized - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('There is a problem with the authentication of this instance. Maybe it is not reachable from the outside to verify it\'s URL.');
throw new HostedSignalingServerAPIException($message, $status);
case Http::STATUS_BAD_REQUEST:
$body = $response->getBody()->getContents();
if ($body) {
$parsedBody = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->logger->error('Requesting hosted signaling server trial failed: cannot parse JSON response - JSON error: ' . json_last_error() . ' ' . json_last_error_msg() . ' HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Something unexpected happened.');
throw new HostedSignalingServerAPIException($message, $status);
}
if ($parsedBody['reason']) {
$message = '';
switch ($parsedBody['reason']) {
case 'invalid_content_type':
$log = 'The content type is invalid.';
break;
case 'invalid_json':
$log = 'The JSON is invalid.';
break;
case 'missing_url':
$log = 'The URL is missing.';
break;
case 'missing_name':
$log = 'The name is missing.';
break;
case 'missing_email':
$log = 'The email address is missing';
break;
case 'missing_language':
$log = 'The language code is missing.';
break;
case 'missing_country':
$log = 'The country code is missing.';
break;
case 'invalid_url':
$message = $this->l10n->t('The URL is invalid.');
$log = 'The entered URL is invalid.';
break;
case 'https_required':
$message = $this->l10n->t('An HTTPS URL is required.');
$log = 'An HTTPS URL is required.';
break;
case 'invalid_email':
$message = $this->l10n->t('The email address is invalid.');
$log = 'The email address is invalid.';
break;
case 'invalid_language':
$message = $this->l10n->t('The language is invalid.');
$log = 'The language is invalid.';
break;
case 'invalid_country':
$message = $this->l10n->t('The country is invalid.');
$log = 'The country is invalid.';
break;
}
// user error
if ($message !== '') {
$this->logger->warning('Requesting hosted signaling server trial failed: bad request - reason: ' . $parsedBody['reason'] . ' ' . $log);
throw new HostedSignalingServerAPIException($message, $status);
}
$this->logger->error('Requesting hosted signaling server trial failed: bad request - reason: ' . $parsedBody['reason'] . ' ' . $log);
$message = $this->l10n->t('There is a problem with the request of the trial. Please check your logs for further information.');
throw new HostedSignalingServerAPIException($message, $status);
}
}
$message = $this->l10n->t('Something unexpected happened.');
throw new HostedSignalingServerAPIException($message, $status);
case Http::STATUS_TOO_MANY_REQUESTS:
$body = $response->getBody()->getContents();
$this->logger->error('Requesting hosted signaling server trial failed: too many requests - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Too many requests are send from your servers address. Please try again later.');
throw new HostedSignalingServerInputException($message, $status);
case Http::STATUS_CONFLICT:
$body = $response->getBody()->getContents();
$this->logger->error('Requesting hosted signaling server trial failed: already registered - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('There is already a trial registered for this Nextcloud instance.');
throw new HostedSignalingServerInputException($message, $status);
case Http::STATUS_INTERNAL_SERVER_ERROR:
$body = $response->getBody()->getContents();
$this->logger->error('Requesting hosted signaling server trial failed: internal server error - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Something unexpected happened. Please try again later.');
throw new HostedSignalingServerAPIException($message, $status);
default:
$body = $response->getBody()->getContents();
$this->logger->error('Requesting hosted signaling server trial failed: something else happened - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Failed to request trial because the trial server behaved wrongly. Please try again later.');
throw new HostedSignalingServerAPIException($message, $status);
}
} catch (\Exception $e) {
$this->logger->error('Failed to request hosted signaling server trial', ['exception' => $e]);
$message = $this->l10n->t('Failed to request trial because the trial server is unreachable. Please try again later.');
throw new HostedSignalingServerAPIException($message, ($e instanceof ServerException ? $e->getResponse()?->getStatusCode() : null) ?? Http::STATUS_INTERNAL_SERVER_ERROR);
} finally {
// this is needed here because the deletion happens in a concurrent request
// and thus the cached value in the config object would trigger an UPDATE
// instead of an INSERT if there is another request to the API server
$this->config->deleteAppValue('spreed', 'hosted-signaling-server-nonce');
}
$status = $response->getStatusCode();
if ($status !== Http::STATUS_CREATED) {
$body = $response->getBody();
$this->logger->error('Requesting hosted signaling server trial failed: something else happened - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Something unexpected happened.');
throw new HostedSignalingServerAPIException($message, $status);
}
$body = $response->getBody();
$data = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->logger->error('Requesting hosted signaling server trial failed: cannot parse JSON response - JSON error: ' . json_last_error() . ' ' . json_last_error_msg() . ' HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Something unexpected happened.');
throw new HostedSignalingServerAPIException($message, Http::STATUS_INTERNAL_SERVER_ERROR);
}
if (!isset($data['account_id'])) {
$this->logger->error('Requesting hosted signaling server trial failed: no account ID transfered - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Something unexpected happened.');
throw new HostedSignalingServerAPIException($message, Http::STATUS_INTERNAL_SERVER_ERROR);
}
$accountId = (string)$data['account_id'];
$this->config->setAppValue('spreed', 'hosted-signaling-server-account-id', $accountId);
return new AccountId($accountId);
}
/**
* @throws HostedSignalingServerAPIException
*
* @return \ArrayAccess|array{created: mixed, owner: \ArrayAccess|array{country: mixed, email: mixed, language: mixed, name: mixed, url: mixed, ...<array-key, mixed>}, status: mixed, signaling?: array, ...<array-key, mixed>}
*/
public function fetchAccountInfo(AccountId $accountId) {
try {
$nonce = $this->secureRandom->generate(32);
$this->config->setAppValue('spreed', 'hosted-signaling-server-nonce', $nonce);
$client = $this->clientService->newClient();
$response = $client->get($this->apiServerUrl . '/v1/account/' . $accountId->get(), [
'headers' => [
'X-Account-Service-Nonce' => $nonce,
],
'timeout' => 10,
]);
} catch (ClientException $e) {
$response = $e->getResponse();
if ($response === null) {
$this->logger->error('Trial requested but failed to get account information', ['exception' => $e]);
$message = $this->l10n->t('Trial requested but failed to get account information. Please check back later.');
throw new HostedSignalingServerAPIException($message, Http::STATUS_INTERNAL_SERVER_ERROR);
}
$status = $response->getStatusCode();
switch ($status) {
case Http::STATUS_UNAUTHORIZED:
$body = $response->getBody()->getContents();
$this->logger->error('Getting the account information failed: unauthorized - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('There is a problem with the authentication of this request. Maybe it is not reachable from the outside to verify it\'s URL.');
throw new HostedSignalingServerAPIException($message, $status);
case Http::STATUS_BAD_REQUEST:
$body = $response->getBody()->getContents();
if ($body) {
$parsedBody = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->logger->error('Getting the account information failed: cannot parse JSON response - JSON error: ' . json_last_error() . ' ' . json_last_error_msg() . ' HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Something unexpected happened.');
throw new HostedSignalingServerAPIException($message, $status);
}
if ($parsedBody['reason']) {
switch ($parsedBody['reason']) {
case 'missing_account_id':
$log = 'The account ID is missing.';
break;
default:
$body = $response->getBody()->getContents();
$this->logger->error('Getting the account information failed: something else happened - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Failed to fetch account information because the trial server behaved wrongly. Please check back later.');
throw new HostedSignalingServerAPIException($message, $status);
}
$this->logger->error('Getting the account information failed: bad request - reason: ' . $parsedBody['reason'] . ' ' . $log);
$message = $this->l10n->t('There is a problem with fetching the account information. Please check your logs for further information.');
throw new HostedSignalingServerAPIException($message, $status);
}
}
$message = $this->l10n->t('Something unexpected happened.');
throw new HostedSignalingServerAPIException($message, $status);
case Http::STATUS_TOO_MANY_REQUESTS:
$body = $response->getBody()->getContents();
$this->logger->error('Getting the account information failed: too many requests - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Too many requests are send from your servers address. Please try again later.');
throw new HostedSignalingServerAPIException($message, $status);
case Http::STATUS_NOT_FOUND:
$body = $response->getBody()->getContents();
$this->logger->error('Getting the account information failed: account not found - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('There is no such account registered.');
throw new HostedSignalingServerAPIException($message, $status);
case Http::STATUS_INTERNAL_SERVER_ERROR:
$body = $response->getBody()->getContents();
$this->logger->error('Getting the account information failed: internal server error - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Something unexpected happened. Please try again later.');
throw new HostedSignalingServerAPIException($message, $status);
default:
$body = $response->getBody()->getContents();
$this->logger->error('Getting the account information failed: something else happened - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Failed to fetch account information because the trial server behaved wrongly. Please check back later.');
throw new HostedSignalingServerAPIException($message, $status);
}
} catch (\Exception $e) {
$this->logger->error('Failed to request hosted signaling server trial', ['exception' => $e]);
$message = $this->l10n->t('Failed to fetch account information because the trial server is unreachable. Please check back later.');
throw new HostedSignalingServerAPIException($message, ($e instanceof ServerException ? $e->getResponse()?->getStatusCode() : null) ?? Http::STATUS_INTERNAL_SERVER_ERROR);
} finally {
// this is needed here because the delete happens in a concurrent request
// and thus the cached value in the config object would trigger an UPDATE
// instead of an INSERT if there is another request to the API server
$this->config->deleteAppValue('spreed', 'hosted-signaling-server-nonce');
}
$status = $response->getStatusCode();
if ($status !== Http::STATUS_OK) {
$body = $response->getBody();
$this->logger->error('Getting the account information failed: something else happened - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Something unexpected happened.');
throw new HostedSignalingServerAPIException($message, $status);
}
$body = $response->getBody();
$data = (array)json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->logger->error('Getting the account information failed: cannot parse JSON response - JSON error: ' . json_last_error() . ' ' . json_last_error_msg() . ' HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Something unexpected happened.');
throw new HostedSignalingServerAPIException($message, Http::STATUS_INTERNAL_SERVER_ERROR);
}
if (!isset($data['status'])
|| !isset($data['created'])
|| ($data['status'] === 'active' && (
!isset($data['signaling'])
|| !isset($data['signaling']['url'])
|| !isset($data['signaling']['secret'])
)
)
|| !isset($data['owner'])
|| !isset($data['owner']['url'])
|| !isset($data['owner']['name'])
|| !isset($data['owner']['email'])
|| !isset($data['owner']['language'])
|| !isset($data['owner']['country'])
/* TODO they are not yet returned
|| ($data['status'] === 'active' && (
!isset($data['limits'])
|| !isset($data['limits']['users'])
)
)
*/
|| (in_array($data['status'], ['error', 'blocked']) && !isset($data['reason']))
|| !in_array($data['status'], ['error', 'blocked', 'pending', 'active', 'expired'])
) {
$this->logger->error('Getting the account information failed: response is missing mandatory field - data: ' . json_encode($data));
$message = $this->l10n->t('Something unexpected happened.');
throw new HostedSignalingServerAPIException($message, Http::STATUS_INTERNAL_SERVER_ERROR);
}
return $data;
}
/**
* @throws HostedSignalingServerAPIException
*/
public function deleteAccount(AccountId $accountId): void {
try {
$nonce = $this->secureRandom->generate(32);
$this->config->setAppValue('spreed', 'hosted-signaling-server-nonce', $nonce);
$client = $this->clientService->newClient();
$response = $client->delete($this->apiServerUrl . '/v1/account/' . $accountId->get(), [
'headers' => [
'X-Account-Service-Nonce' => $nonce,
],
'timeout' => 10,
]);
} catch (ClientException $e) {
$response = $e->getResponse();
if ($response === null) {
$this->logger->error('Deleting the hosted signaling server account failed', ['exception' => $e]);
$message = $this->l10n->t('Deleting the hosted signaling server account failed. Please check back later.');
throw new HostedSignalingServerAPIException($message, Http::STATUS_INTERNAL_SERVER_ERROR);
}
$status = $response->getStatusCode();
switch ($status) {
case Http::STATUS_UNAUTHORIZED:
$body = $response->getBody()->getContents();
$this->logger->error('Deleting the hosted signaling server account failed: unauthorized - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('There is a problem with the authentication of this request. Maybe it is not reachable from the outside to verify it\'s URL.');
throw new HostedSignalingServerAPIException($message, $status);
case Http::STATUS_BAD_REQUEST:
$body = $response->getBody()->getContents();
if ($body) {
$parsedBody = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->logger->error('Deleting the hosted signaling server account failed: cannot parse JSON response - JSON error: ' . json_last_error() . ' ' . json_last_error_msg() . ' HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Something unexpected happened.');
throw new HostedSignalingServerAPIException($message, $status);
}
if ($parsedBody['reason']) {
switch ($parsedBody['reason']) {
case 'missing_account_id':
$log = 'The account ID is missing.';
break;
default:
$body = $response->getBody()->getContents();
$this->logger->error('Deleting the hosted signaling server account failed: something else happened - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Failed to delete the account because the trial server behaved wrongly. Please check back later.');
throw new HostedSignalingServerAPIException($message, $status);
}
$this->logger->error('Deleting the hosted signaling server account failed: bad request - reason: ' . $parsedBody['reason'] . ' ' . $log);
$message = $this->l10n->t('There is a problem with deleting the account. Please check your logs for further information.');
throw new HostedSignalingServerAPIException($message, $status);
}
}
$message = $this->l10n->t('Something unexpected happened.');
throw new HostedSignalingServerAPIException($message, $status);
case Http::STATUS_TOO_MANY_REQUESTS:
$body = $response->getBody()->getContents();
$this->logger->error('Deleting the hosted signaling server account failed: too many requests - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Too many requests are sent from your servers address. Please try again later.');
throw new HostedSignalingServerAPIException($message, $status);
case Http::STATUS_NOT_FOUND:
$body = $response->getBody()->getContents();
$this->logger->error('Deleting the hosted signaling server account failed: account not found - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('There is no such account registered.');
throw new HostedSignalingServerAPIException($message, $status);
case Http::STATUS_INTERNAL_SERVER_ERROR:
$body = $response->getBody()->getContents();
$this->logger->error('Deleting the hosted signaling server account failed: internal server error - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Something unexpected happened. Please try again later.');
throw new HostedSignalingServerAPIException($message, $status);
default:
$body = $response->getBody()->getContents();
$this->logger->error('Deleting the hosted signaling server account failed: something else happened - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Failed to delete the account because the trial server behaved wrongly. Please check back later.');
throw new HostedSignalingServerAPIException($message, $status);
}
} catch (\Exception $e) {
$this->logger->error('Deleting the hosted signaling server account failed', ['exception' => $e]);
$message = $this->l10n->t('Failed to delete the account because the trial server is unreachable. Please check back later.');
throw new HostedSignalingServerAPIException($message, ($e instanceof ServerException ? $e->getResponse()?->getStatusCode() : null) ?? Http::STATUS_INTERNAL_SERVER_ERROR);
} finally {
// this is needed here because the delete happens in a concurrent request
// and thus the cached value in the config object would trigger an UPDATE
// instead of an INSERT if there is another request to the API server
$this->config->deleteAppValue('spreed', 'hosted-signaling-server-nonce');
}
$status = $response->getStatusCode();
if ($status !== Http::STATUS_NO_CONTENT) {
$body = $response->getBody();
$this->logger->error('Deleting the hosted signaling server account failed: something else happened - HTTP status: ' . $status . ' Response body: ' . $body);
$message = $this->l10n->t('Something unexpected happened.');
throw new HostedSignalingServerAPIException($message, $status);
}
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Config;
use OCA\Talk\Exceptions\FederationRestrictionException;
use OCA\Talk\Federation\FederationManager;
use OCA\Talk\MatterbridgeManager;
use OCA\Talk\Model\InvitationList;
use OCA\Talk\Room;
use OCP\App\IAppManager;
use OCP\Federation\ICloudIdManager;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IPhoneNumberUtil;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Mail\IMailer;
class InvitationService {
public function __construct(
protected IAppManager $appManager,
protected ICloudIdManager $cloudIdManager,
protected IGroupManager $groupManager,
protected IPhoneNumberUtil $phoneNumberUtil,
protected IUserManager $userManager,
protected FederationManager $federationManager,
protected ParticipantService $participantService,
protected IConfig $serverConfig,
protected Config $talkConfig,
protected IMailer $mailer,
) {
}
public function validateInvitations(array $participants, IUser $currentUser, ?Room $room = null): InvitationList {
$invitationList = new InvitationList();
if (!empty($participants['users'])) {
$this->validateUserInvitations($invitationList, $participants['users']);
}
if (!empty($participants['emails'])) {
$this->validateEmailInvitations($invitationList, $participants['emails']);
}
if (!empty($participants['groups'])) {
$this->validateGroupInvitations($invitationList, $participants['groups']);
}
if (!empty($participants['teams'])) {
$this->validateTeamInvitations($invitationList, $participants['teams'], $currentUser);
}
if (!empty($participants['federated_users'])) {
$this->validateFederatedUserInvitations($invitationList, $participants['federated_users'], $currentUser);
}
if (!empty($participants['phones'])) {
$this->validatePhoneInvitations($invitationList, $participants['phones'], $currentUser, $room);
}
return $invitationList;
}
/**
* @param list<string> $userIds
*/
protected function validateUserInvitations(InvitationList $invitationList, array $userIds): void {
$invalidUsers = $validUsers = [];
foreach ($userIds as $userId) {
if ($userId === MatterbridgeManager::BRIDGE_BOT_USERID) {
$invalidUsers[] = $userId;
continue;
}
$user = $this->userManager->get($userId);
if ($user instanceof IUser) {
$validUsers[$userId] = $user;
} else {
$invalidUsers[] = $userId;
}
}
$invitationList->setUserResults($validUsers, $invalidUsers);
}
/**
* @param list<string> $emails
*/
protected function validateEmailInvitations(InvitationList $invitationList, array $emails): void {
$invalidEmails = $validEmails = [];
foreach ($emails as $email) {
if ($this->mailer->validateMailAddress($email)) {
$validEmails[$email] = strtolower($email);
} else {
$invalidEmails[] = $email;
}
}
$invitationList->setEmailResults($validEmails, $invalidEmails);
}
/**
* @param list<string> $groupIds
*/
protected function validateGroupInvitations(InvitationList $invitationList, array $groupIds): void {
$invalidGroups = $validGroups = [];
foreach ($groupIds as $groupId) {
$group = $this->groupManager->get($groupId);
if ($group instanceof IGroup) {
$validGroups[$groupId] = $group;
} else {
$invalidGroups[] = $groupId;
}
}
$invitationList->setGroupResults($validGroups, $invalidGroups);
}
/**
* @param list<string> $teamIds
*/
protected function validateTeamInvitations(InvitationList $invitationList, array $teamIds, IUser $currentUser): void {
if (!$this->appManager->isEnabledForUser('circles')) {
$invitationList->setTeamResults([], $teamIds);
return;
}
$invalidTeams = $validTeams = [];
foreach ($teamIds as $teamId) {
try {
$team = $this->participantService->getCircle($teamId, $currentUser->getUID());
$validTeams[$teamId] = $team;
} catch (\Exception) {
$invalidTeams[] = $teamId;
}
}
$invitationList->setTeamResults($validTeams, $invalidTeams);
}
/**
* @param list<string> $cloudIds
*/
protected function validateFederatedUserInvitations(InvitationList $invitationList, array $cloudIds, IUser $currentUser): void {
if (!$this->talkConfig->isFederationEnabled()) {
$invitationList->setFederatedUserResults([], $cloudIds);
return;
}
$invalidCloudIds = $validCloudIds = [];
foreach ($cloudIds as $cloudIdString) {
try {
$cloudId = $this->cloudIdManager->resolveCloudId($cloudIdString);
$this->federationManager->isAllowedToInvite($currentUser, $cloudId);
$validCloudIds[$cloudIdString] = $cloudId;
} catch (\InvalidArgumentException|FederationRestrictionException) {
$invalidCloudIds[] = $cloudIdString;
}
}
$invitationList->setFederatedUserResults($validCloudIds, $invalidCloudIds);
}
/**
* @param list<string> $phoneNumbers
*/
protected function validatePhoneInvitations(InvitationList $invitationList, array $phoneNumbers, IUser $currentUser, ?Room $room): void {
if (!$this->talkConfig->isSIPConfigured() || !$this->talkConfig->canUserDialOutSIP($currentUser)) {
$invitationList->setPhoneNumberResults([], $phoneNumbers);
return;
}
if ($room instanceof Room
&& (preg_match(Room::SIP_INCOMPATIBLE_REGEX, $room->getToken())
|| !in_array($room->getType(), [Room::TYPE_GROUP, Room::TYPE_PUBLIC], true))) {
$invitationList->setPhoneNumberResults([], $phoneNumbers);
return;
}
$phoneRegion = $this->serverConfig->getSystemValueString('default_phone_region');
if ($phoneRegion === '') {
$phoneRegion = null;
}
$invalidPhoneNumbers = [];
$validPhoneNumbers = [];
foreach ($phoneNumbers as $phoneNumber) {
$formattedNumber = $this->phoneNumberUtil->convertToStandardFormat($phoneNumber, $phoneRegion);
if ($formattedNumber === null) {
$invalidPhoneNumbers[] = $phoneNumber;
} else {
$validPhoneNumbers[$phoneNumber] = $formattedNumber;
}
}
$invitationList->setPhoneNumberResults($validPhoneNumbers, $invalidPhoneNumbers);
}
}
+254
View File
@@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\AppAPI\PublicFunctions;
use OCA\Talk\Exceptions\LiveTranscriptionAppAPIException;
use OCA\Talk\Exceptions\LiveTranscriptionAppNotEnabledException;
use OCA\Talk\Exceptions\LiveTranscriptionAppResponseException;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCP\App\IAppManager;
use OCP\IUserManager;
use OCP\Server;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LoggerInterface;
class LiveTranscriptionService {
public function __construct(
private ?string $userId,
private IAppManager $appManager,
private IUserManager $userManager,
private RoomService $roomService,
protected LoggerInterface $logger,
) {
}
public function isLiveTranscriptionAppEnabled(?object $appApiPublicFunctions = null): bool {
try {
if ($appApiPublicFunctions === null) {
$appApiPublicFunctions = $this->getAppApiPublicFunctions();
}
} catch (LiveTranscriptionAppAPIException $e) {
return false;
}
$exApp = $appApiPublicFunctions->getExApp('live_transcription');
if ($exApp === null || !$exApp['enabled']) {
return false;
}
return true;
}
/**
* @throws LiveTranscriptionAppAPIException if app_api is not enabled or the
* public functions could not be
* got.
*/
private function getAppApiPublicFunctions(): object {
if (!$this->appManager->isEnabledForUser('app_api')) {
throw new LiveTranscriptionAppAPIException('app-api');
}
try {
$appApiPublicFunctions = Server::get(PublicFunctions::class);
} catch (ContainerExceptionInterface|NotFoundExceptionInterface $e) {
throw new LiveTranscriptionAppAPIException('app-api-functions');
}
return $appApiPublicFunctions;
}
/**
* @throws LiveTranscriptionAppNotEnabledException if the external app
* "live_transcription" is
* not enabled.
* @throws LiveTranscriptionAppAPIException if the request could not be sent
* to the app or the response could
* not be processed.
* @throws LiveTranscriptionAppResponseException if the request itself
* succeeded but the app
* responded with an error.
*/
public function enable(Room $room, Participant $participant): void {
$parameters = [
'roomToken' => $room->getToken(),
'ncSessionId' => $participant->getSession()->getSessionId(),
'enable' => true,
];
$languageId = $room->getLiveTranscriptionLanguageId();
if (!empty($languageId)) {
$parameters['langId'] = $languageId;
}
$this->requestToExAppLiveTranscription('POST', '/api/v1/call/transcribe', $parameters);
}
/**
* @throws LiveTranscriptionAppNotEnabledException if the external app
* "live_transcription" is
* not enabled.
* @throws LiveTranscriptionAppAPIException if the request could not be sent
* to the app or the response could
* not be processed.
* @throws LiveTranscriptionAppResponseException if the request itself
* succeeded but the app
* responded with an error.
*/
public function disable(Room $room, Participant $participant): void {
$parameters = [
'roomToken' => $room->getToken(),
'ncSessionId' => $participant->getSession()->getSessionId(),
'enable' => false,
];
$this->requestToExAppLiveTranscription('POST', '/api/v1/call/transcribe', $parameters);
}
/**
* @throws LiveTranscriptionAppNotEnabledException if the external app
* "live_transcription" is
* not enabled.
* @throws LiveTranscriptionAppAPIException if the request could not be sent
* to the app or the response could
* not be processed.
* @throws LiveTranscriptionAppResponseException if the request itself
* succeeded but the app
* responded with an error.
*/
public function getAvailableLanguages(): array {
$languages = $this->requestToExAppLiveTranscription('GET', '/api/v1/languages');
if ($languages === null) {
$this->logger->error('Request to live_transcription (ExApp) failed: list of available languages is null');
throw new LiveTranscriptionAppAPIException('response-null-language-list');
}
return $languages;
}
/**
* @throws LiveTranscriptionAppNotEnabledException if the external app
* "live_transcription" is
* not enabled.
* @throws LiveTranscriptionAppAPIException if the request could not be sent
* to the app or the response could
* not be processed.
* @throws LiveTranscriptionAppResponseException if the request itself
* succeeded but the app
* responded with an error.
*/
public function setLanguage(Room $room, string $languageId): void {
$parameters = [
'roomToken' => $room->getToken(),
'langId' => $languageId !== '' ? $languageId : 'en',
];
try {
$this->requestToExAppLiveTranscription('POST', '/api/v1/call/set-language', $parameters);
} catch (LiveTranscriptionAppResponseException $e) {
// If there is no active transcription continue setting the language
// in the room. In any other case, abort.
if ($e->getResponse()->getStatusCode() !== 404) {
throw $e;
}
}
$this->roomService->setLiveTranscriptionLanguageId($room, $languageId);
}
/**
* @throws LiveTranscriptionAppNotEnabledException if the external app
* "live_transcription" is
* not enabled.
* @throws LiveTranscriptionAppAPIException if the request could not be sent
* to the app or the response could
* not be processed.
* @throws LiveTranscriptionAppResponseException if the request itself
* succeeded but the app
* responded with an error.
*/
private function requestToExAppLiveTranscription(string $method, string $route, array $parameters = []): ?array {
try {
$appApiPublicFunctions = $this->getAppApiPublicFunctions();
} catch (LiveTranscriptionAppAPIException $e) {
if ($e->getMessage() === 'app-api') {
$this->logger->error('AppAPI is not enabled');
} elseif ($e->getMessage() === 'app-api-functions') {
$this->logger->error('Could not get AppAPI public functions', ['exception' => $e]);
}
throw new LiveTranscriptionAppNotEnabledException($e->getMessage());
}
if (!$this->isLiveTranscriptionAppEnabled($appApiPublicFunctions)) {
$this->logger->error('live_transcription (ExApp) is not enabled');
throw new LiveTranscriptionAppNotEnabledException('live-transcription-app');
}
$response = $appApiPublicFunctions->exAppRequest(
'live_transcription',
$route,
$this->userId,
$method,
$parameters,
);
if (is_array($response) && isset($response['error'])) {
$this->logger->error('Request to live_transcription (ExApp) failed: ' . $response['error']);
throw new LiveTranscriptionAppAPIException('response-error');
}
if (is_array($response)) {
// AppApi only uses array responses for errors, so this should never
// happen.
$this->logger->error('Request to live_transcription (ExApp) failed: response is not a valid response object');
throw new LiveTranscriptionAppAPIException('response-invalid-object');
}
$responseContentType = $response->getHeader('Content-Type');
if (strpos($responseContentType, 'application/json') !== false) {
$body = $response->getBody();
if (!is_string($body)) {
$this->logger->error('Request to live_transcription (ExApp) failed: response body is not a string, but content type is application/json', ['response' => $response]);
throw new LiveTranscriptionAppAPIException('response-content-type');
}
$decodedBody = json_decode($body, true);
} else {
$decodedBody = ['response' => $response->getBody()];
}
if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) {
$this->logger->error('live_transcription (ExApp) returned an error', [
'status-code' => $response->getStatusCode(),
'response' => $decodedBody,
'method' => $method,
'route' => $route,
'parameters' => $parameters,
]);
$exceptionMessage = 'response-status-code';
if (is_array($decodedBody) && isset($decodedBody['error'])) {
$exceptionMessage .= ': ' . $decodedBody['error'];
}
throw new LiveTranscriptionAppResponseException($exceptionMessage, 0, null, $response);
}
return $decodedBody;
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Circles\CirclesManager;
use OCA\Circles\Model\Member;
use OCA\Circles\Model\Membership;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\AttendeeMapper;
use OCA\Talk\Room;
use OCP\App\IAppManager;
use OCP\IGroupManager;
use OCP\IUser;
use OCP\Server;
use Psr\Log\LoggerInterface;
class MembershipService {
public function __construct(
protected IAppManager $appManager,
protected IGroupManager $groupManager,
protected AttendeeMapper $attendeeMapper,
) {
}
/**
* @param Room $room
* @param IUser[] $users
* @return IUser[]
*/
public function getUsersWithoutOtherMemberships(Room $room, array $users): array {
$users = $this->filterUsersWithOtherGroupMemberships($room, $users);
$users = $this->filterUsersWithOtherCircleMemberships($room, $users);
return $users;
}
/**
* @param Room $room
* @param IUser[] $users
* @return IUser[]
*/
protected function filterUsersWithOtherGroupMemberships(Room $room, array $users): array {
$groupAttendees = $this->attendeeMapper->getActorsByType($room->getId(), Attendee::ACTOR_GROUPS);
$groupIds = array_map(static function (Attendee $attendee) {
return $attendee->getActorId();
}, $groupAttendees);
if (empty($groupIds)) {
return $users;
}
return array_filter($users, function (IUser $user) use ($groupIds) {
// Only delete users when the user is not member via another group
$userGroups = $this->groupManager->getUserGroupIds($user);
return empty(array_intersect($userGroups, $groupIds));
});
}
/**
* @param Room $room
* @param IUser[] $users
* @return IUser[]
*/
protected function filterUsersWithOtherCircleMemberships(Room $room, array $users): array {
if (empty($users)) {
return $users;
}
$anyUser = reset($users);
if (!$this->appManager->isEnabledForUser('circles', $anyUser)) {
Server::get(LoggerInterface::class)->debug('Circles not enabled', ['app' => 'spreed']);
return $users;
}
$circleAttendees = $this->attendeeMapper->getActorsByType($room->getId(), Attendee::ACTOR_CIRCLES);
$circleIds = array_map(static function (Attendee $attendee) {
return $attendee->getActorId();
}, $circleAttendees);
if (empty($circleIds)) {
return $users;
}
$circlesManager = Server::get(CirclesManager::class);
return array_filter($users, static function (IUser $user) use ($circlesManager, $circleIds) {
// Only delete users when the user is not member via another circle
$federatedUser = $circlesManager->getFederatedUser($user->getUID(), Member::TYPE_USER);
$memberships = $federatedUser->getMemberships();
$userCircles = array_map(static function (Membership $membership) {
return $membership->getCircleId();
}, $memberships);
return empty(array_intersect($userCircles, $circleIds));
});
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Manager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Room;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IUser;
use OCP\IUserManager;
use OCP\PreConditionNotMetException;
use OCP\Security\ISecureRandom;
class NoteToSelfService {
public function __construct(
protected IConfig $config,
protected IUserManager $userManager,
protected Manager $manager,
protected RoomService $roomService,
protected AvatarService $avatarService,
protected ParticipantService $participantService,
protected ISecureRandom $secureRandom,
protected IL10N $l,
) {
}
public function ensureNoteToSelfExistsForUser(string $userId): Room {
$noteToSelfId = $this->getNoteToSelfConversationId($userId);
if ($noteToSelfId !== 0) {
try {
return $this->manager->getRoomById($noteToSelfId);
} catch (RoomNotFoundException) {
// Fall through and recreate it …
}
}
$currentUser = $this->userManager->get($userId);
if (!$currentUser instanceof IUser) {
throw new \InvalidArgumentException('User not found');
}
return $this->createNoteToSelfConversation($currentUser, $noteToSelfId);
}
public function initialCreateNoteToSelfForUser(string $userId): void {
$noteToSelfId = $this->getNoteToSelfConversationId($userId);
if ($noteToSelfId !== 0) {
return;
}
// Prefixing with zz, so that casting to int does not give a random roomId for other requests
$randomLock = 'zz' . $this->secureRandom->generate(3);
$this->config->setUserValue($userId, 'spreed', 'note_to_self', $randomLock);
$currentUser = $this->userManager->get($userId);
if (!$currentUser instanceof IUser) {
throw new \InvalidArgumentException('User not found');
}
$this->createNoteToSelfConversation($currentUser, $randomLock);
}
protected function createNoteToSelfConversation(IUser $user, string|int $previousValue): Room {
$room = $this->roomService->createConversation(
Room::TYPE_NOTE_TO_SELF,
$this->l->t('Note to self'),
$user,
Room::OBJECT_TYPE_NOTE_TO_SELF,
$user->getUID()
);
try {
$this->config->setUserValue($user->getUID(), 'spreed', 'note_to_self', (string)$room->getId(), (string)$previousValue);
} catch (PreConditionNotMetException $e) {
// This process didn't win the race for creating the conversation, so fetch the other one
$this->roomService->deleteRoom($room);
// This is a little trick to bypass local caching
$values = $this->config->getUserValueForUsers('spreed', 'note_to_self', [$user->getUID()]);
if (isset($values[$user->getUID()])) {
return $this->manager->getRoomById($values[$user->getUID()]);
}
// Failed to read parallel note-to-self creation
throw new RoomNotFoundException('Failed due to parallel requests');
}
$this->roomService->setDescription(
$room,
$this->l->t('A place for your private notes, thoughts and ideas'),
);
$this->avatarService->setAvatarFromEmoji($room, '📝', '0082c9');
$participant = $this->participantService->getParticipantByActor(
$room,
Attendee::ACTOR_USERS,
$user->getUID()
);
$this->participantService->updateFavoriteStatus($participant, true);
return $room;
}
protected function getNoteToSelfConversationId(string $userId): int {
return (int)$this->config->getUserValue($userId, 'spreed', 'note_to_self', '0');
}
}
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCP\IConfig;
use OCP\IPhoneNumberUtil;
class PhoneNumberValidation {
public function __construct(
protected IPhoneNumberUtil $phoneNumberUtil,
protected IConfig $config,
) {
}
/**
* Validate input as a phone number
*
* - Local number: allow
* - International number
* a. If valid, strip + and allow
* b. If invalid, throw
* @throws \InvalidArgumentException When the number is invalid
*/
public function validateNumber(string $phoneNumber): string {
if (
// Not an internation number
!str_starts_with($phoneNumber, '0')
// And matches a local number or dial-through
&& preg_match('/^[0-9]{1,20}$/', $phoneNumber)
) {
return $phoneNumber;
}
$defaultRegion = $this->config->getSystemValueString('default_phone_region') ?: null;
$standardPhoneNumber = $this->phoneNumberUtil->convertToStandardFormat($phoneNumber, $defaultRegion);
if ($standardPhoneNumber === null) {
throw new \InvalidArgumentException();
}
if (str_starts_with($standardPhoneNumber, '+')) {
return substr($standardPhoneNumber, 1);
}
throw new \InvalidArgumentException();
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Model\PhoneNumber;
use OCA\Talk\Model\PhoneNumberMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IUser;
use OCP\IUserManager;
use OCP\User\IAvailabilityCoordinator;
class PhoneService {
public function __construct(
protected IUserManager $userManager,
protected IAvailabilityCoordinator $availabilityCoordinator,
protected PhoneNumberMapper $mapper,
) {
}
/**
* Get user ID to call
*
* Internally falling back to the OOO replacement,
* if one is defined that has a phone number and is not OOO itself.
*
* @throws DoesNotExistException
*/
public function getAccountToCallForPhoneNumber(string $phoneNumber): PhoneNumber {
$entity = $this->mapper->findByPhoneNumber($phoneNumber);
$user = $this->userManager->get($entity->getActorId());
if (!$user instanceof IUser) {
throw new DoesNotExistException('Invalid user');
}
$outOfOffice = $this->availabilityCoordinator->getCurrentOutOfOfficeData($user);
if ($outOfOffice === null
|| $outOfOffice->getReplacementUserId() === null
|| !$this->availabilityCoordinator->isInEffect($outOfOffice)) {
return $entity;
}
$replacementUser = $this->userManager->get($outOfOffice->getReplacementUserId());
if (!$replacementUser instanceof IUser) {
// Replacement is wrong, fall back to original user
return $entity;
}
$outOfOffice = $this->availabilityCoordinator->getCurrentOutOfOfficeData($replacementUser);
if ($outOfOffice !== null
&& $this->availabilityCoordinator->isInEffect($outOfOffice)) {
// Replacement is also OOO, fall back to original user
return $entity;
}
$entities = $this->mapper->findByUser($replacementUser->getUID());
if (empty($entities)) {
// Replacement has no phone number, fall back to original user
return $entity;
}
return array_shift($entities);
}
/**
* @return list<PhoneNumber>
*/
public function findByUser(string $userId): array {
return $this->mapper->findByUser($userId);
}
/**
* @return list<PhoneNumber>
*/
public function findByPhoneNumbers(array $phoneNumbers): array {
return $this->mapper->findByPhoneNumbers($phoneNumbers);
}
public function deleteByPhoneNumber(string $phoneNumber): void {
$this->mapper->deleteByPhoneNumber($phoneNumber);
}
public function deleteByUser(string $userId): void {
$this->mapper->deleteByUser($userId);
}
}
+324
View File
@@ -0,0 +1,324 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Exceptions\PollPropertyException;
use OCA\Talk\Exceptions\WrongPermissionsException;
use OCA\Talk\Model\Poll;
use OCA\Talk\Model\PollMapper;
use OCA\Talk\Model\Vote;
use OCA\Talk\Model\VoteMapper;
use OCA\Talk\Participant;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\Comments\ICommentsManager;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
class PollService {
public function __construct(
protected IDBConnection $connection,
protected PollMapper $pollMapper,
protected VoteMapper $voteMapper,
) {
}
/**
* @throws PollPropertyException
*/
public function createPoll(int $roomId, string $actorType, string $actorId, string $displayName, string $question, array $options, int $resultMode, int $maxVotes, bool $draft): Poll {
$poll = new Poll();
$poll->setRoomId($roomId);
$poll->setActorType($actorType);
$poll->setActorId($actorId);
$poll->setDisplayName($displayName);
$poll->setQuestion($question);
$poll->setOptions($options);
$poll->setVotes(json_encode([]));
$poll->setResultMode($resultMode);
$poll->setMaxVotes($maxVotes);
if ($draft) {
$poll->setStatus(Poll::STATUS_DRAFT);
}
$this->pollMapper->insert($poll);
return $poll;
}
/**
* @param int $roomId
* @return list<Poll>
*/
public function getDraftsForRoom(int $roomId): array {
return $this->pollMapper->getDraftsByRoomId($roomId);
}
/**
* @param int $roomId
* @param int $pollId
* @return Poll
* @throws DoesNotExistException
*/
public function getPoll(int $roomId, int $pollId): Poll {
return $this->pollMapper->getPollByRoomIdAndPollId($roomId, $pollId);
}
/**
* @param Participant $participant
* @param Poll $poll
* @throws WrongPermissionsException
*/
public function updatePoll(Participant $participant, Poll $poll): void {
if (!$participant->hasModeratorPermissions()
&& ($poll->getActorType() !== $participant->getAttendee()->getActorType()
|| $poll->getActorId() !== $participant->getAttendee()->getActorId())) {
// Only moderators and the author of the poll can update it
throw new WrongPermissionsException();
}
$this->pollMapper->update($poll);
}
/**
* @throws WrongPermissionsException
*/
public function closePoll(Participant $participant, Poll $poll): void {
if (!$participant->hasModeratorPermissions()
&& ($poll->getActorType() !== $participant->getAttendee()->getActorType()
|| $poll->getActorId() !== $participant->getAttendee()->getActorId())) {
// Only moderators and the author of the poll can update it
throw new WrongPermissionsException();
}
$poll->setStatus(Poll::STATUS_CLOSED);
$this->pollMapper->update($poll);
}
/**
* @param Participant $participant
* @param Poll $poll
* @return list<Vote>
*/
public function getVotesForActor(Participant $participant, Poll $poll): array {
return $this->voteMapper->findByPollIdForActor(
$poll->getId(),
$participant->getAttendee()->getActorType(),
$participant->getAttendee()->getActorId()
);
}
/**
* @param Poll $poll
* @return list<Vote>
*/
public function getVotes(Poll $poll): array {
return $this->voteMapper->findByPollId($poll->getId());
}
/**
* @param Participant $participant
* @param Poll $poll
* @param int[] $optionIds Options the user voted for
* @return list<Vote>
* @throws \RuntimeException
*/
public function votePoll(Participant $participant, Poll $poll, array $optionIds): array {
$numVotes = count($optionIds);
if ($numVotes !== count(array_unique($optionIds))) {
throw new \UnexpectedValueException();
}
if ($poll->getMaxVotes() !== Poll::MAX_VOTES_UNLIMITED
&& $poll->getMaxVotes() < $numVotes) {
throw new \OverflowException();
}
if (!empty($optionIds)) {
foreach ($optionIds as $optionId) {
if (!is_numeric($optionId)) {
throw new \RangeException();
}
}
$maxOptionId = max(array_keys(json_decode($poll->getOptions(), true, 512, JSON_THROW_ON_ERROR)));
$maxVotedId = max($optionIds);
$minVotedId = min($optionIds);
if ($minVotedId < 0 || $maxVotedId > $maxOptionId) {
throw new \RangeException();
}
}
$votes = [];
$result = json_decode($poll->getVotes(), true);
$previousVotes = $this->voteMapper->findByPollIdForActor(
$poll->getId(),
$participant->getAttendee()->getActorType(),
$participant->getAttendee()->getActorId()
);
$numVoters = $poll->getNumVoters();
if ($previousVotes && $numVoters > 0) {
$numVoters--;
}
foreach ($previousVotes as $vote) {
$result[$vote->getOptionId()] ??= 1;
$result[$vote->getOptionId()] -= 1;
}
$this->connection->beginTransaction();
try {
$this->voteMapper->deleteVotesByActor(
$poll->getId(),
$participant->getAttendee()->getActorType(),
$participant->getAttendee()->getActorId()
);
if (!empty($optionIds)) {
$numVoters++;
}
foreach ($optionIds as $optionId) {
$vote = new Vote();
$vote->setPollId($poll->getId());
$vote->setRoomId($poll->getRoomId());
$vote->setActorType($participant->getAttendee()->getActorType());
$vote->setActorId($participant->getAttendee()->getActorId());
$vote->setDisplayName($participant->getAttendee()->getDisplayName());
$vote->setOptionId($optionId);
$this->voteMapper->insert($vote);
$result[$optionId] ??= 0;
$result[$optionId] += 1;
$votes[] = $vote;
}
} catch (\Exception $e) {
$this->connection->rollBack();
throw $e;
}
$this->connection->commit();
$this->updateResultCache($poll->getId());
$result = array_filter($result);
$poll->setVotes(json_encode($result));
$poll->setNumVoters($numVoters);
return $votes;
}
public function updateResultCache(int $pollId): void {
$resultQuery = $this->connection->getQueryBuilder();
$resultQuery->selectAlias(
$resultQuery->func()->concat(
$resultQuery->expr()->literal('"'),
'option_id',
$resultQuery->expr()->literal('":'),
$resultQuery->func()->count('id')
),
'colonseparatedvalue'
)
->from('talk_poll_votes')
->where($resultQuery->expr()->eq('poll_id', $resultQuery->createNamedParameter($pollId)))
->groupBy('option_id')
->orderBy('option_id', 'ASC');
$jsonQuery = $this->connection->getQueryBuilder();
$jsonQuery
->selectAlias(
$jsonQuery->func()->concat(
$jsonQuery->expr()->literal('{'),
$jsonQuery->func()->groupConcat('colonseparatedvalue'),
$jsonQuery->expr()->literal('}')
),
'json'
)
->from($jsonQuery->createFunction('(' . $resultQuery->getSQL() . ')'), 'json');
$subQuery = $this->connection->getQueryBuilder();
$subQuery->select('actor_type', 'actor_id')
->from('talk_poll_votes')
->where($subQuery->expr()->eq('poll_id', $subQuery->createNamedParameter($pollId)))
->groupBy('actor_type', 'actor_id');
$votersQuery = $this->connection->getQueryBuilder();
$votersQuery->select($votersQuery->func()->count('*'))
->from($votersQuery->createFunction('(' . $subQuery->getSQL() . ')'), 'voters');
$update = $this->connection->getQueryBuilder();
$update->update('talk_polls')
->set('votes', $jsonQuery->createFunction('(' . $jsonQuery->getSQL() . ')'))
->set('num_voters', $jsonQuery->createFunction('(' . $votersQuery->getSQL() . ')'))
->where($update->expr()->eq('id', $update->createNamedParameter($pollId, IQueryBuilder::PARAM_INT)));
$this->connection->beginTransaction();
try {
$update->executeStatement();
// Fix `null` being stored if the only voter revokes their vote
$updateFixNull = $this->connection->getQueryBuilder();
$updateFixNull->update('talk_polls')
->set('votes', $updateFixNull->createNamedParameter('{}'))
->where($updateFixNull->expr()->eq('id', $updateFixNull->createNamedParameter($pollId, IQueryBuilder::PARAM_INT)))
->andWhere($updateFixNull->expr()->isNull('votes'));
$updateFixNull->executeStatement();
} catch (\Exception $e) {
$this->connection->rollBack();
throw $e;
}
$this->connection->commit();
}
public function deleteByRoomId(int $roomId): void {
$this->voteMapper->deleteByRoomId($roomId);
$this->pollMapper->deleteByRoomId($roomId);
}
public function deleteByPollId(int $pollId): void {
$this->voteMapper->deleteByPollId($pollId);
$this->pollMapper->deleteByPollId($pollId);
}
public function updateDisplayNameForActor(string $actorType, string $actorId, string $displayName): void {
$update = $this->connection->getQueryBuilder();
$update->update('talk_polls')
->set('display_name', $update->createNamedParameter($displayName))
->where($update->expr()->eq('actor_type', $update->createNamedParameter($actorType)))
->andWhere($update->expr()->eq('actor_id', $update->createNamedParameter($actorId)));
$update->executeStatement();
$update = $this->connection->getQueryBuilder();
$update->update('talk_poll_votes')
->set('display_name', $update->createNamedParameter($displayName))
->where($update->expr()->eq('actor_type', $update->createNamedParameter($actorType)))
->andWhere($update->expr()->eq('actor_id', $update->createNamedParameter($actorId)));
$update->executeStatement();
}
public function neutralizeDeletedUser(string $actorType, string $actorId): void {
$update = $this->connection->getQueryBuilder();
$update->update('talk_polls')
->set('display_name', $update->createNamedParameter(''))
->set('actor_type', $update->createNamedParameter(ICommentsManager::DELETED_USER))
->set('actor_id', $update->createNamedParameter(ICommentsManager::DELETED_USER))
->where($update->expr()->eq('actor_type', $update->createNamedParameter($actorType)))
->andWhere($update->expr()->eq('actor_id', $update->createNamedParameter($actorId)));
$update->executeStatement();
$update = $this->connection->getQueryBuilder();
$update->update('talk_poll_votes')
->set('display_name', $update->createNamedParameter(''))
->set('actor_type', $update->createNamedParameter(ICommentsManager::DELETED_USER))
->set('actor_id', $update->createNamedParameter(ICommentsManager::DELETED_USER))
->where($update->expr()->eq('actor_type', $update->createNamedParameter($actorType)))
->andWhere($update->expr()->eq('actor_id', $update->createNamedParameter($actorId)));
$update->executeStatement();
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Exceptions\CannotReachRemoteException;
use OCA\Talk\Model\Message;
use OCA\Talk\Model\ProxyCacheMessage;
use OCA\Talk\Model\ProxyCacheMessageMapper;
use OCA\Talk\Participant;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Room;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\DB\Exception as DBException;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type TalkChatMessageWithParent from ResponseDefinitions
*/
class ProxyCacheMessageService {
public function __construct(
protected ProxyCacheMessageMapper $mapper,
protected LoggerInterface $logger,
protected ITimeFactory $timeFactory,
) {
}
/**
* @throws DoesNotExistException
*/
public function findByRemote(string $remoteServerUrl, string $remoteToken, int $remoteMessageId): ProxyCacheMessage {
return $this->mapper->findByRemote($remoteServerUrl, $remoteToken, $remoteMessageId);
}
public function delete(ProxyCacheMessage $message): ProxyCacheMessage {
return $this->mapper->delete($message);
}
public function deleteExpiredMessages(): void {
$this->mapper->deleteExpiredMessages($this->timeFactory->getDateTime());
}
/**
* @throws \InvalidArgumentException
* @throws CannotReachRemoteException
*/
public function syncRemoteMessage(Room $room, Participant $participant, int $messageId): ProxyCacheMessage {
if (!$room->isFederatedConversation()) {
throw new \InvalidArgumentException('room');
}
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController::class);
$ocsResponse = $proxy->getMessageContext($room, $participant, $messageId, 1);
if ($ocsResponse->getStatus() !== Http::STATUS_OK || !isset($ocsResponse->getData()[0])) {
throw new \InvalidArgumentException('message');
}
/** @var TalkChatMessageWithParent $messageData */
$messageData = $ocsResponse->getData()[0];
try {
$proxy = $this->mapper->findByRemote($room->getRemoteServer(), $room->getRemoteToken(), $messageId);
} catch (DoesNotExistException) {
$proxy = new ProxyCacheMessage();
}
$proxy->setLocalToken($room->getToken());
$proxy->setRemoteServerUrl($room->getRemoteServer());
$proxy->setRemoteToken($room->getRemoteToken());
$proxy->setRemoteMessageId($messageData['id']);
$proxy->setActorType($messageData['actorType']);
$proxy->setActorId($messageData['actorId']);
$proxy->setActorDisplayName($messageData['actorDisplayName']);
$proxy->setMessageType($messageData['messageType']);
$proxy->setSystemMessage($messageData['systemMessage']);
if ($messageData['expirationTimestamp']) {
$proxy->setExpirationDatetime(new \DateTime('@' . $messageData['expirationTimestamp']));
}
$proxy->setCreationDatetime(new \DateTime('@' . $messageData['timestamp']));
$proxy->setMessage($messageData['message']);
$proxy->setMessageParameters(json_encode($messageData['messageParameters']));
$metaData = [];
if (!empty($messageData['lastEditActorType']) && !empty($messageData['lastEditActorId'])) {
$metaData[Message::METADATA_LAST_EDITED_BY_TYPE] = $messageData['lastEditActorType'];
$metaData[Message::METADATA_LAST_EDITED_BY_ID] = $messageData['lastEditActorId'];
}
if (!empty($messageData['lastEditTimestamp'])) {
$metaData[Message::METADATA_LAST_EDITED_TIME] = $messageData['lastEditTimestamp'];
}
if (!empty($messageData['silent'])) {
$metaData[Message::METADATA_SILENT] = $messageData['silent'];
}
$proxy->setMetaData(json_encode($metaData));
if ($proxy->getId() !== null) {
$this->mapper->update($proxy);
return $proxy;
}
try {
$this->mapper->insert($proxy);
} catch (DBException $e) {
// DBException::REASON_UNIQUE_CONSTRAINT_VIOLATION happens when
// multiple users are in the same conversation. We are therefore
// informed multiple times about the same remote message.
if ($e->getReason() !== DBException::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
$this->logger->error('Error saving proxy cache message failed: ' . $e->getMessage(), ['exception' => $e]);
throw $e;
}
$proxy = $this->mapper->findByRemote(
$room->getRemoteServer(),
$room->getRemoteToken(),
$messageData['id'],
);
}
return $proxy;
}
}
+570
View File
@@ -0,0 +1,570 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use InvalidArgumentException;
use OC\User\NoUserException;
use OCA\Talk\AppInfo\Application;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Config;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\RecordingNotFoundException;
use OCA\Talk\Manager;
use OCA\Talk\Participant;
use OCA\Talk\Recording\BackendNotifier;
use OCA\Talk\Room;
use OCA\Talk\Settings\UserPreference;
use OCP\AppFramework\Services\IAppConfig;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IMimeTypeDetector;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IConfig;
use OCP\IUserManager;
use OCP\L10N\IFactory;
use OCP\Notification\IManager;
use OCP\Share\IManager as ShareManager;
use OCP\Share\IShare;
use OCP\TaskProcessing\Exception\Exception;
use OCP\TaskProcessing\IManager as ITaskProcessingManager;
use OCP\TaskProcessing\Task;
use OCP\TaskProcessing\TaskTypes\AudioToText;
use OCP\TaskProcessing\TaskTypes\TextToTextSummary;
use Psr\Log\LoggerInterface;
class RecordingService {
public const CONSENT_REQUIRED_NO = 0;
public const CONSENT_REQUIRED_YES = 1;
public const CONSENT_REQUIRED_OPTIONAL = 2;
public const APPCONFIG_PREFIX = 'recording/';
public const DEFAULT_ALLOWED_RECORDING_FORMATS = [
'audio/ogg' => ['ogg'],
'video/ogg' => ['ogv'],
'video/mp4' => ['mp4'],
'video/webm' => ['webm'],
'video/x-matroska' => ['mkv'],
];
public const UPLOAD_ERRORS = [
UPLOAD_ERR_INI_SIZE => 'The uploaded file exceeds the upload_max_filesize directive in php.ini',
UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form',
UPLOAD_ERR_PARTIAL => 'The file was only partially uploaded',
UPLOAD_ERR_NO_FILE => 'No file was uploaded',
UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder',
UPLOAD_ERR_CANT_WRITE => 'Could not write file to disk',
UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload',
];
public function __construct(
protected IMimeTypeDetector $mimeTypeDetector,
protected ParticipantService $participantService,
protected IRootFolder $rootFolder,
protected IManager $notificationManager,
protected Manager $roomManager,
protected ITimeFactory $timeFactory,
protected Config $config,
protected IConfig $serverConfig,
protected IAppConfig $appConfig,
protected RoomService $roomService,
protected ShareManager $shareManager,
protected ChatManager $chatManager,
protected LoggerInterface $logger,
protected BackendNotifier $backendNotifier,
protected ITaskProcessingManager $taskProcessingManager,
protected IFactory $l10nFactory,
protected IUserManager $userManager,
) {
}
/**
* @psalm-param Room::RECORDING_* $status
*/
public function start(Room $room, int $status, string $owner, Participant $participant): void {
$availableRecordingTypes = [Room::RECORDING_VIDEO, Room::RECORDING_AUDIO];
if (!in_array($status, $availableRecordingTypes, true)) {
throw new InvalidArgumentException('status');
}
if ($room->getCallRecording() !== Room::RECORDING_NONE && $room->getCallRecording() !== Room::RECORDING_FAILED) {
throw new InvalidArgumentException('recording');
}
if (!$room->getActiveSince() instanceof \DateTimeInterface) {
throw new InvalidArgumentException('call');
}
if (!$this->config->isRecordingEnabled()) {
throw new InvalidArgumentException('config');
}
$this->backendNotifier->start($room, $status, $owner, $participant);
$startingStatus = $status === Room::RECORDING_VIDEO ? Room::RECORDING_VIDEO_STARTING : Room::RECORDING_AUDIO_STARTING;
$this->roomService->setCallRecording($room, $startingStatus);
$this->appConfig->setAppValueString(self::APPCONFIG_PREFIX . $room->getToken(), $owner, true, true);
}
public function stop(Room $room, ?Participant $participant = null): void {
if ($room->getCallRecording() === Room::RECORDING_NONE) {
return;
}
try {
$this->backendNotifier->stop($room, $participant);
} catch (RecordingNotFoundException $e) {
// If the recording to be stopped is not known to the recording
// server it will never notify that the recording was stopped, so
// the status needs to be explicitly changed here.
$this->roomService->setCallRecording($room, Room::RECORDING_FAILED);
}
}
public function store(Room $room, string $owner, array $file): void {
$this->appConfig->deleteAppValue(self::APPCONFIG_PREFIX . $room->getToken());
try {
$participant = $this->participantService->getParticipant($room, $owner);
} catch (ParticipantNotFoundException $e) {
throw new InvalidArgumentException('owner_participant');
}
$resource = $this->getResourceFromFileArray($file, $room, $participant);
$fileName = basename($file['name']);
$fileRealPath = realpath($file['tmp_name']);
$this->validateFileFormat($fileName, $fileRealPath);
try {
$recordingFolder = $this->getRecordingFolder($owner, $room->getToken());
$fileNode = $recordingFolder->newFile($fileName, $resource);
$this->notifyStoredRecording($room, $participant, $fileNode);
} catch (NoUserException $e) {
throw new InvalidArgumentException('owner_invalid');
} catch (NotPermittedException $e) {
throw new InvalidArgumentException('owner_permission');
}
$shouldTranscribe = $this->serverConfig->getAppValue('spreed', 'call_recording_transcription', 'no') === 'yes';
$shouldSummarize = $this->serverConfig->getAppValue('spreed', 'call_recording_summary', 'yes') === 'yes';
if (!$shouldTranscribe && !$shouldSummarize) {
$this->logger->debug('Skipping transcription and summary of call recording, as both are disabled');
return;
}
$supportedTaskTypeIds = $this->taskProcessingManager->getAvailableTaskTypeIds();
if (!in_array(AudioToText::ID, $supportedTaskTypeIds, true)) {
$this->logger->error('Can not transcribe call recording as no Audio2Text task provider is available');
return;
}
$task = new Task(
AudioToText::ID,
['input' => $fileNode->getId()],
Application::APP_ID,
$owner,
'call/transcription/' . $room->getToken(),
);
try {
$this->taskProcessingManager->scheduleTask($task);
$this->logger->debug('Scheduled call recording transcript');
} catch (Exception $e) {
$this->logger->error('An error occurred while trying to transcribe the call recording', ['exception' => $e]);
}
}
/**
* @param 'transcript'|'summary' $aiTask
*/
public function storeTranscript(string $owner, string $roomToken, int $recordingFileId, string $output, string $aiTask): void {
$userFolder = $this->rootFolder->getUserFolder($owner);
$recordingNodes = $userFolder->getById($recordingFileId);
if (empty($recordingNodes)) {
$this->logger->warning("Could not save recording $aiTask as the recording could not be found", [
'owner' => $owner,
'roomToken' => $roomToken,
'recordingFileId' => $recordingFileId,
]);
throw new InvalidArgumentException('owner_participant');
}
$recording = array_pop($recordingNodes);
$recordingFolder = $recording->getParent();
if ($recordingFolder->getName() !== $roomToken) {
$this->logger->warning("Could not determinate conversation when trying to store $aiTask of call recording, as folder name did not match customId conversation token");
throw new InvalidArgumentException('owner_participant');
}
try {
$room = $this->roomManager->getRoomForUserByToken($roomToken, $owner);
$participant = $this->participantService->getParticipant($room, $owner);
} catch (ParticipantNotFoundException) {
$this->logger->warning("Could not determinate conversation when trying to store $aiTask of call recording");
throw new InvalidArgumentException('owner_participant');
}
$shouldTranscribe = $this->serverConfig->getAppValue('spreed', 'call_recording_transcription', 'no') === 'yes';
$shouldSummarize = $this->serverConfig->getAppValue('spreed', 'call_recording_summary', 'yes') === 'yes';
if ($aiTask === 'transcript') {
$transcriptFileName = pathinfo($recording->getName(), PATHINFO_FILENAME) . '.md';
if (!$shouldTranscribe) {
$this->logger->debug('Skipping saving of transcript for call recording as it is disabled');
}
} else {
$transcriptFileName = pathinfo($recording->getName(), PATHINFO_FILENAME) . ' - ' . $aiTask . '.md';
}
if (($shouldTranscribe && $aiTask === 'transcript')
|| ($shouldSummarize && $aiTask === 'summary')) {
$user = $this->userManager->get($owner);
$language = $this->l10nFactory->getUserLanguage($user);
$l = $this->l10nFactory->get(Application::APP_ID, $language);
if ($aiTask === 'transcript') {
$warning = $l->t('Transcript is AI generated and may contain mistakes');
} else {
$warning = $l->t('Summary is AI generated and may contain mistakes');
}
try {
$fileNode = $recordingFolder->newFile(
$transcriptFileName,
$output . "\n\n$warning\n",
);
$this->notifyStoredTranscript($room, $participant, $fileNode, $aiTask);
} catch (NoUserException) {
throw new InvalidArgumentException('owner_invalid');
} catch (NotPermittedException) {
throw new InvalidArgumentException('owner_permission');
}
}
if (!$shouldSummarize) {
// If summary is off skip scheduling it
$this->logger->debug('Skipping scheduling summary of call recording as it is disabled');
return;
}
if ($aiTask === 'summary') {
// After saving the summary there is nothing more to do
return;
}
$supportedTaskTypeIds = $this->taskProcessingManager->getAvailableTaskTypeIds();
if (!in_array(TextToTextSummary::ID, $supportedTaskTypeIds, true)) {
$this->logger->error('Can not summarize call recording as no TextToTextSummary task provider is available');
return;
}
$task = new Task(
TextToTextSummary::ID,
['input' => $output],
Application::APP_ID,
$owner,
'call/summary/' . $room->getToken() . '/' . $recordingFileId,
);
try {
$this->taskProcessingManager->scheduleTask($task);
$this->logger->debug('Scheduled call recording summary');
} catch (Exception $e) {
$this->logger->error('An error occurred while trying to summarize the call recording', ['exception' => $e]);
}
}
/**
* @throws InvalidArgumentException
*/
public function notifyAboutFailedStore(Room $room): void {
$owner = $this->appConfig->getAppValueString(self::APPCONFIG_PREFIX . $room->getToken(), lazy: true);
if ($owner === '') {
return;
}
try {
$participant = $this->participantService->getParticipant($room, $owner);
} catch (ParticipantNotFoundException) {
$this->logger->warning('Could not determinate conversation when trying to notify about failed upload of call recording');
throw new InvalidArgumentException('owner_participant');
}
$attendee = $participant->getAttendee();
$notification = $this->notificationManager->createNotification();
$notification
->setApp('spreed')
->setDateTime($this->timeFactory->getDateTime())
->setObject('recording_information', $room->getToken())
->setUser($attendee->getActorId())
->setSubject('record_file_store_fail');
$this->notificationManager->notify($notification);
}
public function notifyAboutFailedTranscript(string $owner, string $roomToken, int $recordingFileId, string $aiType): void {
$userFolder = $this->rootFolder->getUserFolder($owner);
$recordingNodes = $userFolder->getById($recordingFileId);
if (empty($recordingNodes)) {
$this->logger->warning("Could not trying to notify about failed $aiType as the recording could not be found", [
'owner' => $owner,
'roomToken' => $roomToken,
'recordingFileId' => $recordingFileId,
]);
throw new InvalidArgumentException('owner_participant');
}
$recording = array_pop($recordingNodes);
$recordingFolder = $recording->getParent();
if ($recordingFolder->getName() !== $roomToken) {
$this->logger->warning("Could not determinate conversation when trying to notify about failed $aiType, as folder name did not match customId conversation token");
throw new InvalidArgumentException('owner_participant');
}
try {
$room = $this->roomManager->getRoomForUserByToken($roomToken, $owner);
$participant = $this->participantService->getParticipant($room, $owner);
} catch (ParticipantNotFoundException) {
$this->logger->warning("Could not determinate conversation when trying to notify about failed $aiType of call recording");
throw new InvalidArgumentException('owner_participant');
}
$attendee = $participant->getAttendee();
$notification = $this->notificationManager->createNotification();
$notification
->setApp('spreed')
->setDateTime($this->timeFactory->getDateTime())
->setObject('recording', $room->getToken())
->setUser($attendee->getActorId())
->setSubject($aiType === 'transcript' ? 'transcript_failed' : 'summary_failed', [
'objectId' => $recording->getId(),
]);
$this->notificationManager->notify($notification);
}
/**
* Gets a resource that represents the file contents of the file array.
*
* @param array $file File array from which a resource will be returned
* @param Room $room The Talk room that requests the resource
* @param Participant $participant The Talk participant that requests the resource
* @return resource Resource representing the file contents of the file array
*/
public function getResourceFromFileArray(array $file, Room $room, Participant $participant) {
if ($file['error'] !== 0) {
$error = self::UPLOAD_ERRORS[$file['error']];
$this->logger->error($error);
$notification = $this->notificationManager->createNotification();
$notification
->setApp('spreed')
->setDateTime($this->timeFactory->getDateTime())
->setObject('recording_information', $room->getToken())
->setUser($participant->getAttendee()->getActorId())
->setSubject('record_file_store_fail');
$this->notificationManager->notify($notification);
throw new InvalidArgumentException('invalid_file');
}
$resource = fopen($file['tmp_name'], 'r');
if ($resource === false) {
throw new InvalidArgumentException('fopen_failed');
}
$resourceStat = fstat($resource);
if ($resourceStat === false) {
throw new InvalidArgumentException('fstat_failed');
}
if ($resourceStat['size'] === 0) {
throw new InvalidArgumentException('empty_file');
}
return $resource;
}
public function validateFileFormat(string $fileName, string $fileRealPath): void {
if (!is_file($fileRealPath)) {
$this->logger->warning("An invalid file path ($fileRealPath) was provided");
throw new InvalidArgumentException('file_invalid_path');
}
$mimeType = $this->mimeTypeDetector->detectContent($fileRealPath);
$allowed = self::DEFAULT_ALLOWED_RECORDING_FORMATS;
if (!array_key_exists($mimeType, $allowed)) {
$this->logger->warning("Uploaded file detected mime type ($mimeType) is not allowed");
throw new InvalidArgumentException('file_mimetype');
}
$extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
if (!$extension || !in_array($extension, $allowed[$mimeType], true)) {
$this->logger->warning("Uploaded file extensions ($extension) is not allowed for the detected mime type ($mimeType)");
throw new InvalidArgumentException('file_extension');
}
}
/**
* @throws NotPermittedException
* @throws NoUserException
*/
private function getRecordingFolder(string $owner, string $token): Folder {
$userFolder = $this->rootFolder->getUserFolder($owner);
$recordingRootFolderName = $this->config->getRecordingFolder($owner);
try {
/** @var Folder */
$recordingRootFolder = $userFolder->get($recordingRootFolderName);
if ($recordingRootFolder->isShared()) {
$this->logger->error('Talk attachment folder for user {userId} is set to a shared folder. Resetting to their root.', [
'userId' => $owner,
]);
$this->serverConfig->setUserValue($owner, 'spreed', UserPreference::ATTACHMENT_FOLDER, '/');
}
} catch (NotFoundException $e) {
/** @var Folder */
$recordingRootFolder = $userFolder->newFolder($recordingRootFolderName);
}
try {
$recordingFolder = $recordingRootFolder->get($token);
} catch (NotFoundException $e) {
$recordingFolder = $recordingRootFolder->newFolder($token);
}
return $recordingFolder;
}
public function notifyStoredRecording(Room $room, Participant $participant, File $file): void {
$attendee = $participant->getAttendee();
$notification = $this->notificationManager->createNotification();
$notification
->setApp('spreed')
->setDateTime($this->timeFactory->getDateTime())
->setObject('recording', $room->getToken())
->setUser($attendee->getActorId())
->setSubject('record_file_stored', [
'objectId' => $file->getId(),
]);
$this->notificationManager->notify($notification);
}
/**
* @param 'transcript'|'summary' $aiType
*/
public function notifyStoredTranscript(Room $room, Participant $participant, File $file, string $aiType): void {
$attendee = $participant->getAttendee();
$notification = $this->notificationManager->createNotification();
$notification
->setApp('spreed')
->setDateTime($this->timeFactory->getDateTime())
->setObject('recording', $room->getToken())
->setUser($attendee->getActorId())
->setSubject($aiType === 'transcript' ? 'transcript_file_stored' : 'summary_file_stored', [
'objectId' => $file->getId(),
]);
$this->notificationManager->notify($notification);
}
public function notificationDismiss(Room $room, Participant $participant, int $timestamp, ?string $notificationSubject): void {
$notification = $this->notificationManager->createNotification();
$notification->setApp('spreed')
->setObject('recording', $room->getToken())
->setDateTime($this->timeFactory->getDateTime('@' . $timestamp))
->setUser($participant->getAttendee()->getActorId());
if ($notificationSubject === null) {
$subjects = ['record_file_stored', 'transcript_file_stored', 'summary_file_stored'];
} else {
$subjects = [$notificationSubject];
}
foreach ($subjects as $subject) {
$notification->setSubject($subject);
$this->notificationManager->markProcessed($notification);
}
}
private function getTypeOfShare(string $mimetype): string {
if (str_starts_with($mimetype, 'video/')) {
return ChatManager::VERB_RECORD_VIDEO;
}
return ChatManager::VERB_RECORD_AUDIO;
}
public function shareToChat(Room $room, Participant $participant, int $fileId, int $timestamp): void {
try {
$userFolder = $this->rootFolder->getUserFolder(
$participant->getAttendee()->getActorId()
);
$files = $userFolder->getById($fileId);
/** @var \OCP\Files\File $file */
$file = array_shift($files);
} catch (\Throwable $th) {
throw new InvalidArgumentException('file');
}
$creationDateTime = $this->timeFactory->getDateTime();
$share = $this->shareManager->newShare();
$share->setNodeId($fileId)
->setShareTime($creationDateTime)
->setSharedBy($participant->getAttendee()->getActorId())
->setNode($file)
->setShareType(IShare::TYPE_ROOM)
->setSharedWith($room->getToken())
->setPermissions(\OCP\Constants::PERMISSION_READ);
$removeNotification = null;
if (!str_ends_with($file->getName(), '.md')) {
$removeNotification = 'record_file_stored';
} elseif (!str_ends_with($file->getName(), ' - summary.md')) {
$removeNotification = 'transcript_file_stored';
} elseif (str_ends_with($file->getName(), ' - summary.md')) {
$removeNotification = 'summary_file_stored';
}
$share = $this->shareManager->createShare($share);
$message = json_encode([
'message' => 'file_shared',
'parameters' => [
'share' => $share->getId(),
'metaData' => [
'mimeType' => $file->getMimeType(),
'messageType' => $this->getTypeOfShare($file->getMimeType()),
],
],
], JSON_THROW_ON_ERROR);
try {
$this->chatManager->addSystemMessage(
$room,
$participant,
$participant->getAttendee()->getActorType(),
$participant->getAttendee()->getActorId(),
$message,
$creationDateTime,
true
);
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
throw new InvalidArgumentException('system');
}
$this->notificationDismiss($room, $participant, $timestamp, $removeNotification);
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\AppInfo\Application;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Manager;
use OCA\Talk\Model\ProxyCacheMessage;
use OCA\Talk\Model\Reminder;
use OCA\Talk\Model\ReminderMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\Comments\IComment;
use OCP\Notification\IManager;
use Psr\Log\LoggerInterface;
class ReminderService {
public function __construct(
protected IManager $notificationManager,
protected ReminderMapper $reminderMapper,
protected ChatManager $chatManager,
protected ProxyCacheMessageService $pcmService,
protected Manager $manager,
protected LoggerInterface $logger,
) {
}
public function getUpcomingReminders(string $userId, int $limit): array {
return $this->reminderMapper->findForUser($userId, $limit);
}
public function setReminder(string $userId, string $token, int $messageId, int $timestamp): Reminder {
try {
$reminder = $this->reminderMapper->findForUserAndMessage($userId, $token, $messageId);
$reminder->setDateTime(new \DateTime('@' . $timestamp));
$this->reminderMapper->update($reminder);
} catch (DoesNotExistException) {
$reminder = new Reminder();
$reminder->setUserId($userId);
$reminder->setToken($token);
$reminder->setMessageId($messageId);
$reminder->setDateTime(new \DateTime('@' . $timestamp));
$this->reminderMapper->insert($reminder);
}
return $reminder;
}
/**
* @throws DoesNotExistException
*/
public function getReminder(string $userId, string $token, int $messageId): Reminder {
return $this->reminderMapper->findForUserAndMessage($userId, $token, $messageId);
}
public function deleteReminder(string $userId, string $token, int $messageId): void {
try {
$reminder = $this->reminderMapper->findForUserAndMessage($userId, $token, $messageId);
$this->reminderMapper->delete($reminder);
} catch (DoesNotExistException) {
// When the reminder does not exist anymore, the notification could be there
$notification = $this->notificationManager->createNotification();
$notification->setApp(Application::APP_ID)
->setUser($userId)
->setObject('reminder', $token)
->setMessage('reminder', [
'commentId' => $messageId,
]);
$this->notificationManager->markProcessed($notification);
}
}
public function executeReminders(\DateTime $executeBefore): void {
$reminders = $this->reminderMapper->findRemindersToExecute($executeBefore);
if (empty($reminders)) {
return;
}
$shouldFlush = $this->notificationManager->defer();
$roomTokens = [];
foreach ($reminders as $reminder) {
$roomTokens[] = $reminder->getToken();
}
$roomTokens = array_unique($roomTokens);
$rooms = $this->manager->getRoomsByToken($roomTokens);
/** @var array<string, ProxyCacheMessage> $proxyMessages */
$proxyMessages = [];
$messageIds = [];
foreach ($reminders as $reminder) {
if (!isset($rooms[$reminder->getToken()])) {
$this->logger->warning('Ignoring reminder for user ' . $reminder->getUserId() . ' as conversation ' . $reminder->getToken() . ' could not be found');
continue;
}
$room = $rooms[$reminder->getToken()];
if (!$room->isFederatedConversation()) {
$messageIds[] = $reminder->getMessageId();
} else {
$key = json_encode([$room->getRemoteServer(), $room->getRemoteToken(), $reminder->getMessageId()]);
if (!isset($proxyMessages[$key])) {
try {
$proxyMessages[$key] = $this->pcmService->findByRemote($room->getRemoteServer(), $room->getRemoteToken(), $reminder->getMessageId());
} catch (DoesNotExistException) {
}
}
}
}
$messageIds = array_unique($messageIds);
$messages = $this->chatManager->getMessagesById($messageIds);
foreach ($reminders as $reminder) {
if (!isset($rooms[$reminder->getToken()])) {
continue;
}
$room = $rooms[$reminder->getToken()];
if (!$room->isFederatedConversation()) {
$key = $reminder->getMessageId();
$messageList = $messages;
$messageParameters = [
'commentId' => $reminder->getMessageId(),
];
} else {
$key = json_encode([$room->getRemoteServer(), $room->getRemoteToken(), $reminder->getMessageId()]);
$messageList = $proxyMessages;
$messageParameters = [
'proxyId' => $messageList[$key]?->getId(),
];
}
if (!isset($messageList[$key])) {
$this->logger->warning('Ignoring reminder for user ' . $reminder->getUserId() . ' as messages #' . $reminder->getMessageId() . ' could not be found for conversation ' . $reminder->getToken());
continue;
}
$message = $messageList[$key];
if ($message instanceof IComment
&& ($message->getObjectType() !== 'chat'
|| $room->getId() !== (int)$message->getObjectId())) {
$this->logger->warning('Ignoring reminder for user ' . $reminder->getUserId() . ' as messages #' . $reminder->getMessageId() . ' could not be found for conversation ' . $reminder->getToken());
continue;
}
$notification = $this->notificationManager->createNotification();
$notification->setApp(Application::APP_ID)
->setUser($reminder->getUserId())
->setObject('reminder', $reminder->getToken())
->setDateTime($reminder->getDateTime())
->setSubject('reminder', [
'token' => $reminder->getToken(),
'message' => $reminder->getMessageId(),
'userType' => $message->getActorType(),
'userId' => $message->getActorId(),
])
->setMessage('reminder', $messageParameters);
$this->notificationManager->notify($notification);
}
if ($shouldFlush) {
$this->notificationManager->flush();
}
$this->reminderMapper->deleteExecutedReminders($executeBefore);
}
}
+487
View File
@@ -0,0 +1,487 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Chat\MessageParser;
use OCA\Talk\Config;
use OCA\Talk\Federation\Proxy\TalkV1\UserConverter;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\BreakoutRoom;
use OCA\Talk\Model\Session;
use OCA\Talk\Model\Thread;
use OCA\Talk\Participant;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Room;
use OCA\Talk\Webinary;
use OCP\App\IAppManager;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Services\IAppConfig;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Comments\IComment;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IUser;
use OCP\IUserManager;
use OCP\UserStatus\IManager;
use OCP\UserStatus\IUserStatus;
/**
* @psalm-import-type TalkRoomLastMessage from ResponseDefinitions
* @psalm-import-type TalkRoom from ResponseDefinitions
*/
class RoomFormatter {
public function __construct(
protected Config $talkConfig,
protected IAppConfig $appConfig,
protected AvatarService $avatarService,
protected ParticipantService $participantService,
protected ChatManager $chatManager,
protected MessageParser $messageParser,
protected IConfig $serverConfig,
protected ITimeFactory $timeFactory,
protected IAppManager $appManager,
protected IManager $userStatusManager,
protected IUserManager $userManager,
protected ProxyCacheMessageService $pcmService,
protected UserConverter $userConverter,
protected IL10N $l10n,
protected ?string $userId,
protected ThreadService $threadService,
) {
}
/**
* @return TalkRoom
*/
public function formatRoom(
string $responseFormat,
array $commonReadMessages,
Room $room,
?Participant $currentParticipant,
?array $statuses = null,
bool $isSIPBridgeRequest = false,
bool $isListingBreakoutRooms = false,
bool $skipLastMessage = false,
?Thread $thread = null,
bool $isThreadInfoComplete = false,
): array {
return $this->formatRoomV4(
$responseFormat,
$commonReadMessages,
$room,
$currentParticipant,
$statuses,
$isSIPBridgeRequest,
$isListingBreakoutRooms,
$skipLastMessage,
$thread,
$isThreadInfoComplete,
);
}
/**
* @param array<int, int> $commonReadMessages
* @return TalkRoom
*/
public function formatRoomV4(
string $responseFormat,
array $commonReadMessages,
Room $room,
?Participant $currentParticipant,
?array $statuses,
bool $isSIPBridgeRequest,
bool $isListingBreakoutRooms,
bool $skipLastMessage,
?Thread $thread = null,
bool $isThreadInfoComplete = false,
): array {
$roomData = [
'id' => $room->getId(),
'token' => $room->getToken(),
'type' => $room->getType(),
'name' => '',
'displayName' => '',
'objectType' => '',
'objectId' => '',
'participantType' => Participant::GUEST,
'participantFlags' => Participant::FLAG_DISCONNECTED,
'readOnly' => Room::READ_WRITE,
'hasPassword' => $room->hasPassword(),
'hasCall' => false,
'callStartTime' => 0,
'callRecording' => Room::RECORDING_NONE,
'canStartCall' => false,
'lastActivity' => 0,
'lastReadMessage' => 0,
'unreadMessages' => 0,
'unreadMention' => false,
'unreadMentionDirect' => false,
'isFavorite' => false,
'canLeaveConversation' => false,
'canDeleteConversation' => false,
'notificationLevel' => Participant::NOTIFY_NEVER,
'notificationCalls' => Participant::NOTIFY_CALLS_OFF,
'lobbyState' => Webinary::LOBBY_NONE,
'lobbyTimer' => 0,
'lastPing' => 0,
'sessionId' => '0',
'sipEnabled' => Webinary::SIP_DISABLED,
'actorType' => '',
'actorId' => '',
'attendeeId' => 0,
'permissions' => Attendee::PERMISSIONS_CUSTOM,
'attendeePermissions' => Attendee::PERMISSIONS_CUSTOM,
'callPermissions' => Attendee::PERMISSIONS_CUSTOM,
'defaultPermissions' => Attendee::PERMISSIONS_CUSTOM,
'canEnableSIP' => false,
'attendeePin' => '',
'description' => '',
'lastCommonReadMessage' => 0,
'listable' => Room::LISTABLE_NONE,
'callFlag' => Participant::FLAG_DISCONNECTED,
'messageExpiration' => 0,
'avatarVersion' => $this->avatarService->getAvatarVersion($room),
'isCustomAvatar' => $this->avatarService->isCustomAvatar($room),
'breakoutRoomMode' => BreakoutRoom::MODE_NOT_CONFIGURED,
'breakoutRoomStatus' => BreakoutRoom::STATUS_STOPPED,
'recordingConsent' => $this->talkConfig->recordingConsentRequired() === RecordingService::CONSENT_REQUIRED_OPTIONAL ? $room->getRecordingConsent() : $this->talkConfig->recordingConsentRequired(),
'mentionPermissions' => Room::MENTION_PERMISSIONS_EVERYONE,
'liveTranscriptionLanguageId' => '',
'isArchived' => false,
'isImportant' => false,
'isSensitive' => false,
];
if ($room->isFederatedConversation()) {
$roomData['recordingConsent'] = $room->getRecordingConsent();
}
$lastActivity = $room->getLastActivity();
if ($lastActivity instanceof \DateTimeInterface) {
$lastActivity = $lastActivity->getTimestamp();
} else {
$lastActivity = 0;
}
$lobbyTimer = $room->getLobbyTimer();
if ($lobbyTimer instanceof \DateTimeInterface) {
$lobbyTimer = $lobbyTimer->getTimestamp();
} else {
$lobbyTimer = 0;
}
if ($isSIPBridgeRequest
|| ($isListingBreakoutRooms && !$currentParticipant instanceof Participant)
|| ($room->getListable() !== Room::LISTABLE_NONE && !$currentParticipant instanceof Participant)
) {
return array_merge($roomData, [
'name' => $room->getName(),
'displayName' => $room->getDisplayName($isListingBreakoutRooms || $isSIPBridgeRequest || $this->userId === null ? '' : $this->userId, $isListingBreakoutRooms || $isSIPBridgeRequest),
'description' => $room->getListable() !== Room::LISTABLE_NONE ? $room->getDescription() : '',
'objectType' => $room->getObjectType(),
'objectId' => $room->getObjectId(),
'readOnly' => $room->getReadOnly(),
'hasCall' => $room->getActiveSince() instanceof \DateTimeInterface,
'lastActivity' => $lastActivity,
'callFlag' => $room->getCallFlag(),
'lobbyState' => $room->getLobbyState(),
'lobbyTimer' => $lobbyTimer,
'sipEnabled' => $room->getSIPEnabled(),
'listable' => $room->getListable(),
'breakoutRoomMode' => $room->getBreakoutRoomMode(),
'breakoutRoomStatus' => $room->getBreakoutRoomStatus(),
'callStartTime' => $room->getActiveSince() instanceof \DateTimeInterface ? $room->getActiveSince()->getTimestamp() : 0,
'callRecording' => $room->getCallRecording(),
]);
}
if (!$currentParticipant instanceof Participant) {
return $roomData;
}
$attendee = $currentParticipant->getAttendee();
$userId = $attendee->getActorType() === Attendee::ACTOR_USERS ? $attendee->getActorId() : '';
$roomData = array_merge($roomData, [
'name' => $room->getName(),
'displayName' => $room->getDisplayName($userId),
'objectType' => $room->getObjectType(),
'objectId' => $room->getObjectId(),
'participantType' => $attendee->getParticipantType(),
'readOnly' => $room->getReadOnly(),
'hasCall' => $room->getActiveSince() instanceof \DateTimeInterface,
'callStartTime' => $room->getActiveSince() instanceof \DateTimeInterface ? $room->getActiveSince()->getTimestamp() : 0,
'callRecording' => $room->getCallRecording(),
'recordingConsent' => $this->talkConfig->recordingConsentRequired() === RecordingService::CONSENT_REQUIRED_OPTIONAL ? $room->getRecordingConsent() : $this->talkConfig->recordingConsentRequired(),
'lastActivity' => $lastActivity,
'callFlag' => $room->getCallFlag(),
'isFavorite' => $attendee->isFavorite(),
'notificationLevel' => $attendee->getNotificationLevel(),
'notificationCalls' => $attendee->getNotificationCalls(),
'lobbyState' => $room->getLobbyState(),
'lobbyTimer' => $lobbyTimer,
'actorType' => $attendee->getActorType(),
'actorId' => $attendee->getActorId(),
'attendeeId' => $attendee->getId(),
'permissions' => $currentParticipant->getPermissions(),
'attendeePermissions' => $attendee->getPermissions(),
'callPermissions' => Attendee::PERMISSIONS_DEFAULT,
'defaultPermissions' => $room->getDefaultPermissions(),
'description' => $room->getDescription(),
'listable' => $room->getListable(),
'messageExpiration' => $room->getMessageExpiration(),
'breakoutRoomMode' => $room->getBreakoutRoomMode(),
'breakoutRoomStatus' => $room->getBreakoutRoomStatus(),
'mentionPermissions' => $room->getMentionPermissions(),
'liveTranscriptionLanguageId' => $room->getLiveTranscriptionLanguageId(),
'isArchived' => $attendee->isArchived(),
'isImportant' => $attendee->isImportant(),
'isSensitive' => $attendee->isSensitive(),
]);
if ($room->isFederatedConversation()) {
$roomData['recordingConsent'] = $room->getRecordingConsent();
}
if ($currentParticipant->getAttendee()->getReadPrivacy() === Participant::PRIVACY_PUBLIC) {
if (isset($commonReadMessages[$room->getId()])) {
$roomData['lastCommonReadMessage'] = $commonReadMessages[$room->getId()];
} else {
$roomData['lastCommonReadMessage'] = $this->chatManager->getLastCommonReadMessage($room);
}
}
if ($this->talkConfig->isSIPConfigured()) {
$roomData['sipEnabled'] = $room->getSIPEnabled();
if ($room->getSIPEnabled() !== Webinary::SIP_DISABLED) {
// Generate a PIN if the attendee is a user and doesn't have one.
$this->participantService->generatePinForParticipant($room, $currentParticipant);
$roomData['attendeePin'] = $attendee->getPin();
}
}
$session = $currentParticipant->getSession();
if ($session instanceof Session) {
$roomData = array_merge($roomData, [
'participantFlags' => $session->getInCall(),
'lastPing' => $session->getLastPing(),
'sessionId' => $session->getSessionId(),
]);
}
if ($roomData['notificationLevel'] === Participant::NOTIFY_DEFAULT) {
if ($currentParticipant->isGuest()) {
$roomData['notificationLevel'] = Participant::NOTIFY_NEVER;
} elseif ($room->getType() === Room::TYPE_ONE_TO_ONE || $room->getType() === Room::TYPE_ONE_TO_ONE_FORMER) {
$roomData['notificationLevel'] = Participant::NOTIFY_ALWAYS;
} else {
$adminSetting = (int)$this->serverConfig->getAppValue('spreed', 'default_group_notification', (string)Participant::NOTIFY_DEFAULT);
if ($adminSetting === Participant::NOTIFY_DEFAULT) {
$roomData['notificationLevel'] = Participant::NOTIFY_MENTION;
} else {
$roomData['notificationLevel'] = $adminSetting;
}
}
}
$currentUser = null;
if ($attendee->getActorType() === Attendee::ACTOR_USERS) {
$currentUser = $this->userManager->get($attendee->getActorId());
if ($room->isFederatedConversation()) {
$roomData['lastReadMessage'] = $attendee->getLastReadMessage();
$roomData['unreadMention'] = (bool)$attendee->getLastMentionMessage();
$roomData['unreadMentionDirect'] = (bool)$attendee->getLastMentionDirect();
$roomData['unreadMessages'] = $attendee->getUnreadMessages();
} elseif ($currentUser instanceof IUser) {
$lastReadMessage = $attendee->getLastReadMessage();
if ($lastReadMessage === ChatManager::UNREAD_MIGRATION) {
/*
* Because the migration from the old comment_read_markers was
* not possible in a programmatic way with a reasonable O(1) or O(n)
* but only with O(user×chat), we do the conversion here.
*/
$lastReadMessage = $this->chatManager->getLastReadMessageFromLegacy($room, $currentUser);
$this->participantService->updateLastReadMessage($currentParticipant, $lastReadMessage);
}
if ($room->getLastMessage() && $lastReadMessage === (int)$room->getLastMessage()->getId()) {
// When the last message is the last read message, there are no unread messages,
// so we can save the query.
$roomData['unreadMessages'] = 0;
} else {
$roomData['unreadMessages'] = $this->chatManager->getUnreadCount($room, $lastReadMessage);
}
$lastMention = $attendee->getLastMentionMessage();
$lastMentionDirect = $attendee->getLastMentionDirect();
$roomData['unreadMention'] = $roomData['unreadMessages'] !== 0 && $lastMention !== 0 && $lastReadMessage < $lastMention;
$roomData['unreadMentionDirect'] = $roomData['unreadMessages'] !== 0 && $lastMentionDirect !== 0 && $lastReadMessage < $lastMentionDirect;
$roomData['lastReadMessage'] = $lastReadMessage;
$roomData['canDeleteConversation'] = $room->getType() !== Room::TYPE_ONE_TO_ONE
&& $room->getType() !== Room::TYPE_ONE_TO_ONE_FORMER
&& $currentParticipant->hasModeratorPermissions(false);
$roomData['canLeaveConversation'] = $room->getType() !== Room::TYPE_NOTE_TO_SELF;
if ($this->appConfig->getAppValueBool('delete_one_to_one_conversations')
&& in_array($room->getType(), [Room::TYPE_ONE_TO_ONE, Room::TYPE_ONE_TO_ONE_FORMER], true)) {
$roomData['canDeleteConversation'] = true;
$roomData['canLeaveConversation'] = false;
}
$roomData['canEnableSIP']
= $this->talkConfig->isSIPConfigured()
&& !preg_match(Room::SIP_INCOMPATIBLE_REGEX, $room->getToken())
&& ($room->getType() === Room::TYPE_GROUP || $room->getType() === Room::TYPE_PUBLIC)
&& $currentParticipant->hasModeratorPermissions(false)
&& $this->talkConfig->canUserEnableSIP($currentUser);
}
} elseif ($attendee->getActorType() === Attendee::ACTOR_FEDERATED_USERS) {
$lastReadMessage = $attendee->getLastReadMessage();
$lastMention = $attendee->getLastMentionMessage();
$lastMentionDirect = $attendee->getLastMentionDirect();
$roomData['lastReadMessage'] = $lastReadMessage;
$roomData['unreadMessages'] = $this->chatManager->getUnreadCount($room, $lastReadMessage);
$roomData['unreadMention'] = $lastMention !== 0 && $lastReadMessage < $lastMention;
$roomData['unreadMentionDirect'] = $lastMentionDirect !== 0 && $lastReadMessage < $lastMentionDirect;
} else {
if ($attendee->getActorType() === Attendee::ACTOR_EMAILS) {
$roomData['invitedActorId'] = $attendee->getInvitedCloudId();
}
$roomData['lastReadMessage'] = $attendee->getLastReadMessage();
}
if ($room->isFederatedConversation()) {
$roomData['remoteServer'] = $room->getRemoteServer();
$roomData['remoteToken'] = $room->getRemoteToken();
}
if ($room->getLobbyState() === Webinary::LOBBY_NON_MODERATORS
&& !$currentParticipant->hasModeratorPermissions()
&& !($currentParticipant->getPermissions() & Attendee::PERMISSIONS_LOBBY_IGNORE)) {
// No participants and chat messages for users in the lobby.
$roomData['hasCall'] = false;
$roomData['unreadMessages'] = 0;
$roomData['unreadMention'] = false;
$roomData['unreadMentionDirect'] = false;
return $roomData;
}
$roomData['canStartCall'] = $currentParticipant->canStartCall($this->serverConfig)
|| ($room->getType() === Room::TYPE_PUBLIC
&& $room->getObjectType() === Room::OBJECT_TYPE_VIDEO_VERIFICATION);
// FIXME This should not be done, but currently all the clients use it to get the avatar of the user …
if ($room->getType() === Room::TYPE_ONE_TO_ONE) {
$participants = json_decode($room->getName(), true);
foreach ($participants as $participant) {
if ($participant !== $attendee->getActorId()) {
$roomData['name'] = (string)$participant;
if ($statuses === null
&& $this->userId !== null
&& $this->appManager->isEnabledForUser('user_status')) {
$statuses = $this->userStatusManager->getUserStatuses([$participant]);
}
if (isset($statuses[$participant])) {
$roomData['status'] = $statuses[$participant]->getStatus();
$roomData['statusIcon'] = $statuses[$participant]->getIcon();
$roomData['statusMessage'] = $statuses[$participant]->getMessage();
$roomData['statusClearAt'] = $statuses[$participant]->getClearAt()?->getTimestamp();
} elseif (!empty($statuses)) {
$roomData['status'] = IUserStatus::OFFLINE;
$roomData['statusIcon'] = null;
$roomData['statusMessage'] = null;
$roomData['statusClearAt'] = null;
}
}
}
}
$skipLastMessage = $skipLastMessage || $attendee->isSensitive();
$lastMessage = $skipLastMessage ? null : $room->getLastMessage();
if ($lastMessage instanceof IComment && !$room->isFederatedConversation()) {
$lastMessageData = $this->formatLastMessage(
$responseFormat,
$room,
$currentParticipant,
$lastMessage,
$thread,
$isThreadInfoComplete,
);
if ($lastMessageData !== null) {
$roomData['lastMessage'] = $lastMessageData;
}
} elseif ($room->isFederatedConversation()) {
$roomData['lastCommonReadMessage'] = 0;
try {
$cachedMessage = $this->pcmService->findByRemote(
$room->getRemoteServer(),
$room->getRemoteToken(),
$room->getLastMessageId(),
);
$roomData['lastMessage'] = $cachedMessage->jsonSerialize();
} catch (DoesNotExistException) {
}
}
if ($roomData['lastReadMessage'] === 0) {
// Guest in a fully expired chat, no history, just loading the chat from beginning for now
$roomData['lastReadMessage'] = ChatManager::UNREAD_FIRST_MESSAGE;
}
if ($currentUser instanceof IUser
&& $attendee->getActorType() === Attendee::ACTOR_USERS
&& $roomData['lastReadMessage'] === ChatManager::UNREAD_FIRST_MESSAGE
&& $roomData['unreadMessages'] === 0) {
$roomData['unreadMessages'] = 1;
}
if ($room->isFederatedConversation()) {
$roomData['attendeeId'] = (int)$attendee->getRemoteId();
$roomData['canLeaveConversation'] = true;
}
return $roomData;
}
/**
* @return TalkRoomLastMessage|null
*/
public function formatLastMessage(
string $responseFormat,
Room $room,
Participant $participant,
IComment $lastMessage,
?Thread $thread = null,
bool $isThreadInfoComplete = false,
): ?array {
$message = $this->messageParser->createMessage($room, $participant, $lastMessage, $this->l10n);
$this->messageParser->parseMessage($message, true);
if (!$message->getVisibility()) {
return null;
}
$now = $this->timeFactory->getDateTime();
$expireDate = $message->getComment()?->getExpireDate();
if ($expireDate instanceof \DateTime && $expireDate < $now) {
return null;
}
if ($thread === null && $isThreadInfoComplete === false) {
try {
$thread = $this->threadService->findByThreadId($room->getId(), (int)$lastMessage->getTopmostParentId());
} catch (DoesNotExistException) {
}
}
return $message->toArray($responseFormat, $thread);
}
}
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Room;
use OCA\Talk\Signaling\BackendNotifier;
use OCA\Talk\Signaling\Responses\Response;
use OCA\Talk\Vendor\CuyZ\Valinor\Mapper\MappingError;
use OCA\Talk\Vendor\CuyZ\Valinor\Mapper\Source\Source;
use OCA\Talk\Vendor\CuyZ\Valinor\MapperBuilder;
use Psr\Log\LoggerInterface;
class SIPDialOutService {
public function __construct(
protected BackendNotifier $backendNotifier,
protected LoggerInterface $logger,
) {
}
public function sendDialOutRequestToBackend(Room $room, Attendee $attendee, string|bool $callerNumber): ?Response {
if ($attendee->getActorType() !== Attendee::ACTOR_PHONES) {
return null;
}
$response = $this->backendNotifier->dialOutToAttendee($room, $attendee, $callerNumber);
if ($response === null) {
$this->logger->error('Received no response from signaling server on dialout request');
return null;
}
try {
return $this->validateDialOutResponse($response);
} catch (\InvalidArgumentException $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return null;
}
}
/**
* @param string $response
* @return Response
* @throws \InvalidArgumentException
*/
protected function validateDialOutResponse(string $response): Response {
try {
$dialOutResponse = (new MapperBuilder())
->mapper()
->map(
Response::class,
Source::json($response)
->map([
'dialout' => 'dialOut',
'dialout.callid' => 'callId',
])
);
} catch (MappingError $e) {
throw new \InvalidArgumentException('Not a valid dial-out response', 0, $e);
}
if ($dialOutResponse->dialOut === null) {
throw new \InvalidArgumentException('Not a valid dial-out response', 1);
}
return $dialOutResponse;
}
}
+238
View File
@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Chat\ReactionManager;
use OCA\Talk\Manager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Room;
use OCP\AppFramework\Services\IAppConfig;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\L10N\IFactory;
use OCP\Security\ISecureRandom;
use Psr\Log\LoggerInterface;
class SampleConversationsService {
public function __construct(
protected IConfig $config,
protected IAppConfig $appConfig,
protected IUserManager $userManager,
protected Manager $manager,
protected ChatManager $chatManager,
protected ReactionManager $reactionManager,
protected RoomService $roomService,
protected AvatarService $avatarService,
protected ParticipantService $participantService,
protected ISecureRandom $secureRandom,
protected IRootFolder $rootFolder,
protected IURLGenerator $url,
protected ITimeFactory $timeFactory,
protected IFactory $l10nFactory,
protected IL10N $l,
protected LoggerInterface $logger,
) {
}
public function initialCreateSamples(string $userId): void {
if (!$this->appConfig->getAppValueBool('create_samples', true)) {
return;
}
$created = $this->config->getUserValue($userId, 'spreed', 'samples_created');
if ($created !== '') {
return;
}
$this->config->setUserValue($userId, 'spreed', 'samples_created', $this->timeFactory->now()->format(\DateTime::ATOM));
$user = $this->userManager->get($userId);
if (!$user instanceof IUser) {
throw new \InvalidArgumentException('User not found');
}
$sampleDirectory = $this->appConfig->getAppValueString('samples_directory');
if ($sampleDirectory !== '') {
$this->logger->debug('Creating custom sample conversations for user ' . $userId . ' from ' . $sampleDirectory);
$this->customSampleConversations($user, $sampleDirectory);
} else {
$this->logger->debug('Creating default sample conversations for user ' . $userId);
$this->defaultSampleConversation($user);
}
}
protected function defaultSampleConversation(IUser $user): void {
$room = $this->roomService->createConversation(
Room::TYPE_GROUP,
$this->l->t('Let\'s get started!'),
$user,
Room::OBJECT_TYPE_SAMPLE,
$user->getUID()
);
$this->avatarService->setAvatarFromEmoji($room, '💡', null);
$this->roomService->setDescription($room, $this->l->t('**Nextcloud Talk** is a secure, self-hosted communication platform that integrates seamlessly with the Nextcloud ecosystem.
#### Key Features of Nextcloud Talk:
* Chat and messaging in private and group chats
* Voice and video calls
* File sharing and integration with other Nextcloud apps
* Customizable conversation settings, moderation and privacy controls
* Web, desktop and mobile (iOS and Android)
* Private & secure communication
Find out more in the [user documentation](https://docs.nextcloud.com/server/latest/user_manual/en/talk/index.html).'));
$messages = [
$this->l->t('# Welcome to Nextcloud Talk
Nextcloud Talk is a private and powerful messaging app that integrates with Nextcloud. Chat in private or group conversations, collaborate over voice and video calls, organize webinars and events, customize your conversations and more.'),
$this->l->t('## 🎨 Format texts to create rich messages
In Nextcloud Talk, you can use Markdown syntax to format your messages. For example, apply **bold** or *italic* formatting, or `highlight texts as code`. You can even create tables and add headings to your text.
Need to fix a typo or change formatting? Edit your message by clicking "Edit message" in the message menu.'),
$this->l->t('## 🔗 Add attachments and links
Attach files from your Nextcloud Hub using the "+" button. Share items from Files and various Nextcloud apps. Some apps even support interactive widgets, for example, the Text app.')
. "\n\n" . '{FILE:Readme.md}',
$this->l->t('## 💭 Let the conversations flow: mention users, react to messages and more
You can mention everybody in the conversation by using %s or mention specific participants by typing "@" and picking their name from the list.', ['@all'])
. "\n" . '{REACTION:😍}{REACTION:👍}',
'{REPLY}' . $this->l->t('You can reply to messages, forward them to other chats and people, or copy message content.'),
$this->l->t('## ✨ Do more with Smart Picker
Simply type "/" or go to the "+" menu to open the Smart Picker where you can attach various content to your messages. You can configure the Smart Picker to be able to add items from Nextcloud apps, GIFs, map locations, AI generated content and much more.'),
$this->l->t('## ⚙️ Manage conversation settings
In the conversation menu, you can access various settings to manage your conversations, such as:
* Edit conversation info
* Manage notifications
* Apply numerous moderation rules
* Configure access and security
* Enable bots
* and more!'),
];
$this->fillConversation($user, $room, $messages);
}
protected function fillConversation(IUser $user, Room $room, array $messages): void {
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$previous = null;
foreach ($messages as $message) {
$message = trim($message);
$replyTo = '';
if (str_starts_with($message, '{REPLY}')) {
$message = trim(str_replace('{REPLY}', '', $message));
$replyTo = $previous->getId();
}
if (str_contains($message, '{FILE:')) {
preg_match_all('/{FILE:([^}]*)}/', $message, $matches);
foreach ($matches[1] as $match) {
try {
$node = $userFolder->get($match);
$message = str_replace('{FILE:' . $match . '}', $this->url->linkToRouteAbsolute(
'files.view.showFile', ['fileid' => $node->getId()]
), $message);
} catch (NotFoundException|NotPermittedException) {
$message = trim(str_replace('{FILE:' . $match . '}', '', $message));
}
}
}
$reactions = [];
if (str_contains($message, '{REACTION:')) {
preg_match_all('/{REACTION:([^}]*)}/', $message, $matches);
$reactions = $matches[1];
$message = trim(preg_replace('/{REACTION:([^}]*)}/', '', $message));
}
$previous = $this->chatManager->postSampleMessage($room, $message, $replyTo);
foreach ($reactions as $reaction) {
$this->reactionManager->addReactionMessage($room, Attendee::ACTOR_GUESTS, Attendee::ACTOR_ID_SAMPLE, '', (int)$previous->getId(), $reaction);
}
}
}
protected function customSampleConversations(IUser $user, string $sampleDirectory): void {
$iterator = $this->l10nFactory->getLanguageIterator($user);
do {
$lang = $iterator->current();
if (file_exists($sampleDirectory . '/' . $lang)) {
break;
}
$iterator->next();
} while ($lang !== 'en' && $iterator->valid());
if (!file_exists($sampleDirectory . '/' . $lang)) {
return;
}
$directory = new \DirectoryIterator($sampleDirectory . '/' . $lang);
foreach ($directory as $file) {
if ($file->isDot() || $file->getExtension() !== 'md') {
continue;
}
$this->createSampleFromFile($user, $file->getPathname());
}
}
protected function createSampleFromFile(IUser $user, string $filePath): void {
$content = file_get_contents($filePath);
$messages = explode("\n---\n", $content);
$detailsBlock = array_shift($messages);
$details = explode("\n", $detailsBlock);
$name = $emoji = $color = null;
foreach ($details as $detail) {
if (str_starts_with($detail, 'NAME:')) {
$name = trim(substr($detail, strlen('NAME:')));
}
if (str_starts_with($detail, 'EMOJI:')) {
$emoji = trim(substr($detail, strlen('EMOJI:')));
}
if (str_starts_with($detail, 'COLOR:')) {
$color = substr(trim(substr($detail, strlen('COLOR:'))), 1);
}
}
if ($name === null) {
$this->logger->error('Sample conversation ' . $filePath . ' has no name defined');
return;
}
$room = $this->roomService->createConversation(Room::TYPE_GROUP, $name, $user, Room::OBJECT_TYPE_SAMPLE, $user->getUID());
if ($emoji !== null) {
$this->avatarService->setAvatarFromEmoji($room, $emoji, $color);
}
if (isset($messages[0]) && str_starts_with($messages[0], 'DESCRIPTION:')) {
$description = array_shift($messages);
$this->roomService->setDescription($room, trim(substr($description, strlen('DESCRIPTION:'))));
}
$this->fillConversation($user, $room, $messages);
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\Session;
use OCA\Talk\Model\SessionMapper;
use OCA\Talk\Participant;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\Security\ISecureRandom;
class SessionService {
public function __construct(
protected SessionMapper $sessionMapper,
protected IDBConnection $connection,
protected ISecureRandom $secureRandom,
protected ITimeFactory $timeFactory,
) {
}
/**
* Update last ping for multiple sessions
*
* Since this function is called by the HPB with potentially hundreds of
* sessions, we do not use the SessionMapper to get the entities first, as
* that would just not scale good enough.
*
* @param string[] $sessionIds
* @param int $lastPing
*/
public function updateMultipleLastPings(array $sessionIds, int $lastPing): void {
$update = $this->connection->getQueryBuilder();
$update->update('talk_sessions')
->set('last_ping', $update->createNamedParameter($lastPing, IQueryBuilder::PARAM_INT))
->where($update->expr()->in('session_id', $update->createNamedParameter($sessionIds, IQueryBuilder::PARAM_STR_ARRAY)));
$update->executeStatement();
}
public function updateLastPing(Session $session, int $lastPing): void {
$session->setLastPing($lastPing);
$this->sessionMapper->update($session);
}
/**
* @throws \InvalidArgumentException
*/
public function updateSessionState(Session $session, int $state): void {
if (!in_array($state, [Session::STATE_INACTIVE, Session::STATE_ACTIVE], true)) {
throw new \InvalidArgumentException('state');
}
$session->setState($state);
$this->sessionMapper->update($session);
}
/**
* @param int[] $ids
*/
public function deleteSessionsById(array $ids): void {
$this->sessionMapper->deleteByIds($ids);
}
/**
* @param Attendee $attendee
* @return Session[]
*/
public function getAllSessionsForAttendee(Attendee $attendee): array {
return $this->sessionMapper->findByAttendeeId($attendee->getId());
}
/**
* @param Attendee $attendee
* @param string $forceSessionId
* @return Session
* @throws Exception
*/
public function createSessionForAttendee(Attendee $attendee, string $forceSessionId = ''): Session {
$session = new Session();
$session->setAttendeeId($attendee->getId());
$session->setInCall(Participant::FLAG_DISCONNECTED);
$session->setLastPing($this->timeFactory->getTime());
if ($forceSessionId !== '') {
$session->setSessionId($forceSessionId);
$this->sessionMapper->insert($session);
} else {
while (true) {
$sessionId = $this->secureRandom->generate(255);
if (!empty($attendee->getInvitedCloudId())) {
$sessionId = $this->extendSessionIdWithCloudId($sessionId, $attendee->getInvitedCloudId());
}
$session->setSessionId($sessionId);
try {
$this->sessionMapper->insert($session);
break;
} catch (Exception $e) {
// 255 chars are not unique? Try again...
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
throw $e;
}
}
}
}
return $session;
}
/**
* Adds the given cloud id to the given session id.
*
* The session id and the cloud id are separated by '#'.
*
* If the resulting session id is longer than the column length it is
* trimmed at the end as needed.
*
* @param string $sessionId
* @param string $invitedCloudId
* @return string
*/
public function extendSessionIdWithCloudId(string $sessionId, string $invitedCloudId): string {
// Session id column length is 512, while generated session ids are 255
// characters.
$invitedCloudId = substr($invitedCloudId, 0, 256);
return $sessionId . '#' . $invitedCloudId;
}
}
+285
View File
@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\Thread;
use OCA\Talk\Model\ThreadAttendee;
use OCA\Talk\Model\ThreadAttendeeMapper;
use OCA\Talk\Model\ThreadMapper;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IDBConnection;
class ThreadService {
private ICache $cache;
private const CACHE_PREFIX = 'thread/';
public function __construct(
protected IDBConnection $connection,
protected ThreadMapper $threadMapper,
protected ThreadAttendeeMapper $threadAttendeeMapper,
protected ITimeFactory $timeFactory,
protected ICacheFactory $cacheFactory,
) {
$this->cache = $this->cacheFactory->createDistributed('talk.threads');
}
public function createThread(Room $room, int $threadId, string $title): Thread {
if (mb_strlen($title) > 203) {
$title = mb_substr($title, 0, 200) . '…';
}
$thread = new Thread();
$thread->setId($threadId);
$thread->setName($title);
$thread->setRoomId($room->getId());
$thread->setLastActivity($this->timeFactory->getDateTime());
$thread = $this->threadMapper->insert($thread);
$this->cache->set(self::CACHE_PREFIX . $room->getId() . '/' . $threadId, $thread->toJson(), 60 * 15);
return $thread;
}
/**
* @param non-negative-int $roomId
* @param non-negative-int $threadId
* @throws DoesNotExistException
*/
public function findByThreadId(int $roomId, int $threadId): Thread {
$row = $this->cache->get(self::CACHE_PREFIX . $roomId . '/' . $threadId);
if (!empty($row)) {
return Thread::fromJson($row);
}
// We already looked for a thread with this id, and we didn't find anything
if ($row === '') {
throw new DoesNotExistException('No thread found');
}
try {
$thread = $this->threadMapper->findById($roomId, $threadId);
$this->cache->set(self::CACHE_PREFIX . $roomId . '/' . $threadId, $thread->toJson(), 60 * 15);
} catch (DoesNotExistException $e) {
$this->cache->set(self::CACHE_PREFIX . $roomId . '/' . $threadId, '', 60 * 15);
throw $e;
}
return $thread;
}
/**
* @param non-negative-int $roomId
* @param list<non-negative-int> $threadIds
* @return array<int, Thread> Map with thread id as key
*/
public function findByThreadIds(int $roomId, array $threadIds): array {
$threads = $this->threadMapper->findByIds($roomId, $threadIds);
$result = [];
foreach ($threads as $thread) {
$result[$thread->getId()] = $thread;
}
return $result;
}
/**
* @internal Warning: does not check room memberships
* @param list<non-negative-int> $threadIds
* @return array<int, Thread> Map with room id as key
*/
public function preloadThreadsForConversationList(array $threadIds): array {
if (empty($threadIds)) {
return [];
}
$threads = $this->threadMapper->getForIds($threadIds);
$result = [];
foreach ($threads as $thread) {
$result[$thread->getRoomId()] = $thread;
}
return $result;
}
/**
* @throws \InvalidArgumentException When the title is empty
*/
public function renameThread(Thread $thread, string $title): Thread {
if ($title === '') {
throw new \InvalidArgumentException('name');
}
if (mb_strlen($title) > 203) {
$title = mb_substr($title, 0, 200) . '…';
}
$thread->setName($title);
$this->threadMapper->update($thread);
$this->cache->set(self::CACHE_PREFIX . $thread->getRoomId() . '/' . $thread->getId(), $thread->toJson(), 60 * 15);
return $thread;
}
/**
* @param int<1, 50> $limit
* @return list<Thread>
*/
public function getRecentByRoomId(Room $room, int $limit): array {
$limit = min(50, max(1, $limit));
return $this->threadMapper->getRecentByRoomId($room->getId(), $limit);
}
/**
* @param int<1, 100> $limit
* @param non-negative-int $offset
*/
public function getRecentByActor(string $actorType, string $actorId, int $limit, int $offset): array {
$limit = min(100, max(1, $limit));
$query = $this->connection->getQueryBuilder();
$query->select('a.*', 't.last_message_id', 't.num_replies', 't.last_activity', 't.name')
->selectAlias('t.id', 't_id')
->from('talk_thread_attendees', 'a')
->join('a', 'talk_threads', 't', $query->expr()->andX(
$query->expr()->eq('a.thread_id', 't.id'),
$query->expr()->eq('a.room_id', 't.room_id'),
))
->where($query->expr()->eq('a.actor_type', $query->createNamedParameter($actorType)))
->andWhere($query->expr()->eq('a.actor_id', $query->createNamedParameter($actorId)))
->andWhere($query->expr()->neq('a.notification_level', $query->createNamedParameter(Participant::NOTIFY_NEVER)))
// FIXME ORDER BY last_activity and subscription moment of the user for better sorting?
->orderBy('t.last_activity', 'DESC')
->setMaxResults($limit);
if ($offset > 0) {
$query->setFirstResult($offset);
}
$results = [];
$result = $query->executeQuery();
while ($row = $result->fetch()) {
$roomId = (int)$row['room_id'];
$results[$roomId][] = [
'thread' => Thread::createFromRow($row),
'attendee' => ThreadAttendee::createFromRow($row),
];
}
$result->closeCursor();
return $results;
}
/**
* @param list<int> $threadIds
* @return array<int, ThreadAttendee> Key is the thread id
*/
public function findAttendeeByThreadIds(Attendee $attendee, array $threadIds): array {
$attendees = $this->threadAttendeeMapper->findAttendeeByThreadIds($attendee->getActorType(), $attendee->getActorId(), $attendee->getRoomId(), $threadIds);
$threadAttendees = [];
foreach ($attendees as $threadAttendee) {
$threadAttendees[$threadAttendee->getThreadId()] = $threadAttendee;
}
return $threadAttendees;
}
/**
* @return array<int, ThreadAttendee> Key is the attendee id
*/
public function findAttendeesForNotificationByThreadId(int $roomId, int $threadId): array {
$attendees = $this->threadAttendeeMapper->findAttendeesForNotification($roomId, $threadId);
$threadAttendees = [];
foreach ($attendees as $threadAttendee) {
$threadAttendees[$threadAttendee->getAttendeeId()] = $threadAttendee;
}
return $threadAttendees;
}
public function setNotificationLevel(Attendee $attendee, int $threadId, int $level): ThreadAttendee {
try {
$threadAttendee = $this->threadAttendeeMapper->findAttendeeByThreadId($attendee->getActorType(), $attendee->getActorId(), $attendee->getRoomId(), $threadId);
$threadAttendee->setNotificationLevel($level);
$this->threadAttendeeMapper->update($threadAttendee);
} catch (DoesNotExistException) {
$threadAttendee = new ThreadAttendee();
$threadAttendee->setThreadId($threadId);
$threadAttendee->setRoomId($attendee->getRoomId());
$threadAttendee->setAttendeeId($attendee->getId());
$threadAttendee->setActorType($attendee->getActorType());
$threadAttendee->setActorId($attendee->getActorId());
$threadAttendee->setNotificationLevel($level);
$this->threadAttendeeMapper->insert($threadAttendee);
}
return $threadAttendee;
}
public function ensureIsThreadAttendee(Attendee $attendee, int $threadId): void {
try {
$this->threadAttendeeMapper->findAttendeeByThreadId($attendee->getActorType(), $attendee->getActorId(), $attendee->getRoomId(), $threadId);
} catch (DoesNotExistException) {
$threadAttendee = new ThreadAttendee();
$threadAttendee->setThreadId($threadId);
$threadAttendee->setRoomId($attendee->getRoomId());
$threadAttendee->setAttendeeId($attendee->getId());
$threadAttendee->setActorType($attendee->getActorType());
$threadAttendee->setActorId($attendee->getActorId());
$threadAttendee->setNotificationLevel(Participant::NOTIFY_DEFAULT);
$this->threadAttendeeMapper->insert($threadAttendee);
}
}
/**
* Used e.g. when a user or group is removed from a conversation
* @param list<int> $attendeeIds
*/
public function removeThreadAttendeesByAttendeeIds(array $attendeeIds): void {
$query = $this->connection->getQueryBuilder();
$query->delete('talk_thread_attendees')
->where($query->expr()->in(
'attendee_id',
$query->createNamedParameter($attendeeIds, IQueryBuilder::PARAM_INT_ARRAY)
));
$query->executeStatement();
}
public function updateLastMessageInfoAfterReply(int $threadId, int $lastMessageId, int $roomId): bool {
$dateTime = $this->timeFactory->getDateTime();
$query = $this->connection->getQueryBuilder();
$query->update('talk_threads')
->set('num_replies', $query->func()->add('num_replies', $query->expr()->literal(1)))
->set('last_message_id', $query->createNamedParameter($lastMessageId))
->set('last_activity', $query->createNamedParameter($dateTime, IQueryBuilder::PARAM_DATETIME_MUTABLE))
->where($query->expr()->eq('id', $query->createNamedParameter($threadId)))
->andWhere($query->expr()->eq('room_id', $query->createNamedParameter($roomId)));
$this->cache->remove(self::CACHE_PREFIX . $roomId . '/' . $threadId);
return (bool)$query->executeStatement();
}
public function deleteByRoom(Room $room): void {
$this->cache->clear(self::CACHE_PREFIX . $room->getId() . '/');
$this->threadMapper->deleteByRoomId($room->getId());
$this->threadAttendeeMapper->deleteByRoomId($room->getId());
}
public function validateThread(int $roomId, int $potentialThreadId): bool {
try {
$this->findByThreadId($roomId, $potentialThreadId);
return true;
} catch (DoesNotExistException) {
return false;
}
}
}