From 1d8a8f94b81cccee6750525da07b4f23b01b16c3 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Fri, 23 Oct 2026 17:14:18 +0100 Subject: [PATCH 1/9] feat: Each recording knows its meeting, and an assistant lists the recordings of a meeting - EXO-91998 The recording upload gets its own size limit (exo.webconferencing.recording.uploadLimitMB, 0 = unlimited by default) instead of the platform's generic one, which refuses the recording of a long meeting. Every recording saved from now on carries the new exo:callRecording mixin (call id, provider, owner, title, scheduled start, recording date) and is titled after its meeting and date; its file name is unchanged. CallProvider gains an optional findCallId(url). WebConferencingService.getCallRecordings lists a call's recordings with a JCR-SQL query in the viewer's own session, so the repository's permissions decide what comes back, after checking that the viewer is a member of the call's space (space and space-event calls) or a participant (other calls). A recording saved before the mixin is found by its Jibri file name, and merged with the stamped ones. The end of a range is extended by exo.webconferencing.recording.occurrenceGraceHours (12). Spring MVC REST: GET /webconferencing/rest/calls/{callId}/recordings and GET /webconferencing/rest/recordings/byConference?url=, both with optional from/to. MCP tool get_meeting_recordings (read-only), the add-on's first, with its own ai-tool-definitions.json and the -parameters compiler flag. Co-Authored-By: Claude Opus 5.5 --- services/pom.xml | 16 + .../webconferencing/CallProvider.java | 17 + .../WebConferencingService.java | 331 +++++++++- .../mcp/MeetingRecordingMcpTool.java | 117 ++++ .../mcp/model/MeetingRecordingModel.java | 40 ++ .../mcp/model/MeetingRecordingsModel.java | 33 + .../webconferencing/model/ConferenceCall.java | 26 + .../webconferencing/model/RecordingInfo.java | 126 ++++ .../rest/CallRecordingRest.java | 180 ++++++ .../rest/model/RecordingEntity.java | 36 ++ .../rest/util/RecordingEntityBuilder.java | 49 ++ .../rest/util/RecordingsLookup.java | 40 ++ .../storage/CallRecordingStorage.java | 541 ++++++++++++++++ .../util/RecordingDateUtils.java | 90 +++ .../main/resources/ai-tool-definitions.json | 36 ++ .../resources/conf/portal/configuration.xml | 15 + .../conf/webconferencing-nodetypes.xml | 98 +++ .../WebConferencingServiceRecordingsTest.java | 585 ++++++++++++++++++ .../mcp/MeetingRecordingMcpToolTest.java | 255 ++++++++ .../rest/CallRecordingRestTest.java | 177 ++++++ .../storage/CallRecordingStorageTest.java | 443 +++++++++++++ .../util/RecordingDateUtilsTest.java | 66 ++ .../WebConferencingApplication.java | 27 +- .../conf/webconferencing/configuration.xml | 6 + 24 files changed, 3314 insertions(+), 36 deletions(-) create mode 100644 services/src/main/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpTool.java create mode 100644 services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingModel.java create mode 100644 services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingsModel.java create mode 100644 services/src/main/java/org/exoplatform/webconferencing/model/ConferenceCall.java create mode 100644 services/src/main/java/org/exoplatform/webconferencing/model/RecordingInfo.java create mode 100644 services/src/main/java/org/exoplatform/webconferencing/rest/CallRecordingRest.java create mode 100644 services/src/main/java/org/exoplatform/webconferencing/rest/model/RecordingEntity.java create mode 100644 services/src/main/java/org/exoplatform/webconferencing/rest/util/RecordingEntityBuilder.java create mode 100644 services/src/main/java/org/exoplatform/webconferencing/rest/util/RecordingsLookup.java create mode 100644 services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java create mode 100644 services/src/main/java/org/exoplatform/webconferencing/util/RecordingDateUtils.java create mode 100644 services/src/main/resources/ai-tool-definitions.json create mode 100644 services/src/main/resources/conf/webconferencing-nodetypes.xml create mode 100644 services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java create mode 100644 services/src/test/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpToolTest.java create mode 100644 services/src/test/java/org/exoplatform/webconferencing/rest/CallRecordingRestTest.java create mode 100644 services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java create mode 100644 services/src/test/java/org/exoplatform/webconferencing/util/RecordingDateUtilsTest.java diff --git a/services/pom.xml b/services/pom.xml index 47b692875..a69df7426 100644 --- a/services/pom.xml +++ b/services/pom.xml @@ -100,6 +100,12 @@ app-center-services provided + + + io.meeds.mcp-server + mcp-server-tools + provided + @@ -141,6 +147,16 @@ + + org.apache.maven.plugins + maven-compiler-plugin + + + + -parameters + + + io.openapitools.swagger swagger-maven-plugin diff --git a/services/src/main/java/org/exoplatform/webconferencing/CallProvider.java b/services/src/main/java/org/exoplatform/webconferencing/CallProvider.java index e5629b120..3330ecb1e 100644 --- a/services/src/main/java/org/exoplatform/webconferencing/CallProvider.java +++ b/services/src/main/java/org/exoplatform/webconferencing/CallProvider.java @@ -301,4 +301,21 @@ public boolean isConfiguredForIdentity(String identityId) { public void setConfiguredForIdentity(String identityId, boolean configured) { } + /** + * Reads the call id out of a meeting link this provider generated, the + * server-side counterpart of the {@code findCallId(url)} of the provider's + * JavaScript module. The recordings of a meeting are found through it, from + * the conference link an agenda event stores. + *

+ * Optional: a provider that does not implement it keeps this default, and its + * meetings read as held on another provider. + * + * @param url a meeting link, possibly carrying a query string + * @return the call id the link points to, or null when the link + * is not one of this provider's meetings + */ + public String findCallId(String url) { + return null; + } + } diff --git a/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java b/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java index f175cfc00..598ee12e6 100644 --- a/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java +++ b/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java @@ -54,6 +54,7 @@ import org.apache.commons.fileupload2.core.FileUploadException; import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; import org.json.JSONException; import org.json.JSONObject; import org.picocontainer.Startable; @@ -63,6 +64,7 @@ import org.exoplatform.commons.api.settings.SettingValue; import org.exoplatform.commons.api.settings.data.Context; import org.exoplatform.commons.api.settings.data.Scope; +import org.exoplatform.commons.exception.ObjectNotFoundException; import org.exoplatform.commons.utils.CommonsUtils; import org.exoplatform.commons.utils.ListAccess; import org.exoplatform.container.ExoContainerContext; @@ -119,6 +121,9 @@ import org.exoplatform.webconferencing.domain.OriginEntity; import org.exoplatform.webconferencing.domain.ParticipantEntity; import org.exoplatform.webconferencing.domain.ParticipantId; +import org.exoplatform.webconferencing.model.ConferenceCall; +import org.exoplatform.webconferencing.model.RecordingInfo; +import org.exoplatform.webconferencing.storage.CallRecordingStorage; import jakarta.servlet.http.HttpServletRequest; @@ -209,6 +214,51 @@ public class WebConferencingService implements Startable { protected static final String VIDEO_CONFERENCE_IS_ENABLED = "videoConferenceEnabled"; + /** The init params naming the recording settings. */ + protected static final String RECORDING_CONFIGURATION_PROPERTIES = "recording-configuration"; + + /** + * The size limit of a recording upload, in MB, 0 for none + * ({@code exo.webconferencing.recording.uploadLimitMB}). + */ + protected static final String RECORDING_UPLOAD_LIMIT_MB = "upload-limit-mb"; + + /** + * How long after the end of a range a recording still counts as made in it, + * in hours ({@code exo.webconferencing.recording.occurrenceGraceHours}). + */ + protected static final String RECORDING_OCCURRENCE_GRACE_HOURS = "occurrence-grace-hours"; + + /** The default grace after the end of a range, in hours. */ + protected static final int DEFAULT_RECORDING_GRACE_HOURS = 12; + + /** The message code of an invalid call id. */ + public static final String RECORDINGS_INVALID_CALL_ID = "webconferencing.recordings.invalidCallId"; + + /** The message code of an invalid range of recording dates. */ + public static final String RECORDINGS_INVALID_RANGE = "webconferencing.recordings.invalidRange"; + + /** The message code of a missing meeting link. */ + public static final String RECORDINGS_INVALID_URL = "webconferencing.recordings.invalidUrl"; + + /** The message code of an unknown call. */ + public static final String RECORDINGS_CALL_NOT_FOUND = "webconferencing.recordings.callNotFound"; + + /** The message code of a link no active provider recognises. */ + public static final String RECORDINGS_OTHER_PROVIDER = "webconferencing.recordings.otherProvider"; + + /** The message code of a viewer who is not a member of the call's space. */ + public static final String RECORDINGS_NOT_SPACE_MEMBER = "webconferencing.recordings.notSpaceMember"; + + /** The message code of a viewer who is not a participant of the call. */ + public static final String RECORDINGS_NOT_PARTICIPANT = "webconferencing.recordings.notParticipant"; + + /** The message code of a viewer with no usable identity. */ + public static final String RECORDINGS_NO_IDENTITY = "webconferencing.recordings.noIdentity"; + + /** The message code of a repository failure while reading recordings. */ + public static final String RECORDINGS_STORAGE_ERROR = "webconferencing.recordings.storageError"; + /** * Represent Space in calls. */ @@ -425,6 +475,15 @@ public String getType() { private AbstractCodec codec; + /** The size limit of a recording upload, in MB, 0 for none. */ + protected final int recordingUploadLimitMB; + + /** The grace added to the end of a range of recording dates, in hours. */ + protected final int recordingGraceHours; + + /** Reads and stamps the recordings in JCR. */ + protected final CallRecordingStorage recordingStorage; + /** * Checks is ID valid (not null, not empty and not longer of {@value #ID_MAX_LENGTH} chars). * @@ -495,8 +554,10 @@ public static boolean isValidData(String data) throws UnsupportedEncodingExcepti * @param identityRegistry the identity registry * @param authenticator the authenticator * @param shareService the share service - * @param initParams the initParams + * @param initParams the init params: the JWT secret and the recording + * settings (upload limit, grace after a range's end) * @param linkManager the link manager + * @param codecInitializer the codec initializer */ public WebConferencingService(OrganizationService organization, IdentityManager socialIdentityManager, @@ -534,6 +595,10 @@ public WebConferencingService(OrganizationService organization, this.secretKey = jwtSecretParam.getProperty(SECRET_KEY); this.shareService = shareService; this.linkManager = linkManager; + PropertiesParam recordingParam = initParams.getPropertiesParam(RECORDING_CONFIGURATION_PROPERTIES); + this.recordingUploadLimitMB = readNonNegativeInt(recordingParam, RECORDING_UPLOAD_LIMIT_MB, 0); + this.recordingGraceHours = readNonNegativeInt(recordingParam, RECORDING_OCCURRENCE_GRACE_HOURS, DEFAULT_RECORDING_GRACE_HOURS); + this.recordingStorage = new CallRecordingStorage(repositoryService, nodeCreator, linkManager); try { this.codec = codecInitializer.getCodec(); } catch (Exception e) { @@ -2123,7 +2188,9 @@ protected List findGroups(String name, int count) throws Exception } /** - * Upload recording of the call. + * Upload recording of the call. The upload is bounded by + * {@code exo.webconferencing.recording.uploadLimitMB} only, unlimited by + * default, never by the platform's generic upload limit. * * @param uploadInfo the upload info * @param request the request @@ -2134,11 +2201,18 @@ public void uploadFile(UploadFileInfo uploadInfo, HttpServletRequest request) th final long opStart = System.currentTimeMillis(); // final long opStart = System.currentTimeMillis(); String uploadId = String.valueOf((long) (Math.random() * 100000L)); + // The recording upload has its own limit, unlimited by default: the + // platform's generic one refuses a long meeting's recording + uploadService.addUploadLimit(uploadId, recordingUploadLimitMB); try { uploadService.createUploadResource(uploadId, request); } catch (FileUploadException e) { + uploadService.removeUploadLimit(uploadId); LOG.error("Cannot create upload resource: " + e.getMessage()); throw new UploadFileException("Cannot create upload resource", e); + } catch (RuntimeException e) { + uploadService.removeUploadLimit(uploadId); + throw e; } UploadResource resource = uploadService.getUploadResource(uploadId); CallInfo call = null; @@ -2157,9 +2231,9 @@ public void uploadFile(UploadFileInfo uploadInfo, HttpServletRequest request) th // If it's 1-1 or chat-room call, we pass participants to share the file. // Otherwise we just upload to the space docs if (uploadInfo.getType().equals(OWNER_TYPE_CHATROOM) || uploadInfo.getType().equals(USER)) { - saveFile(rootNode, resource, uploadingUser, uploadInfo.getParticipants()); + saveFile(rootNode, resource, uploadingUser, uploadInfo.getParticipants(), call, uploadInfo); } else { - saveFile(rootNode, resource, uploadingUser, null); + saveFile(rootNode, resource, uploadingUser, null, call, uploadInfo); } broacastCallEvent(EVENT_CALL_RECORDED, call, uploadingUser, resource.getUploadedSize(),resource.getFileName(), uploadInfo, STATUS_OK); LOG.info(metricMessage(uploadingUser, call, OPERATION_CALL_RECORDED, STATUS_OK, System.currentTimeMillis() - opStart, null, resource.getUploadedSize())); @@ -2195,21 +2269,30 @@ public void uploadFile(UploadFileInfo uploadInfo, HttpServletRequest request) th LOG.error("Failed while saving the uploaded file " + e.getMessage(), e); } finally { uploadService.removeUploadResource(uploadId); + uploadService.removeUploadLimit(uploadId); } } /** - * Save recording to JCR. + * Save recording to JCR, stamped with the call it belongs to and titled + * after it. * * @param parent the parent * @param resource the recording * @param user the user * @param shareToUsers the users to share file with + * @param call the call recorded, or null when it could not be + * read + * @param uploadInfo the upload info the upload token carries * @throws RepositoryException the repository exception * @throws UploadFileException the upload file exception */ - private void saveFile(Node parent, UploadResource resource, String user, List shareToUsers) throws RepositoryException, - UploadFileException { + private void saveFile(Node parent, + UploadResource resource, + String user, + List shareToUsers, + CallInfo call, + UploadFileInfo uploadInfo) throws RepositoryException, UploadFileException { if (parent == null) { throw new UploadFileException("Cannot save the file because parent node empty for user: " + user); } @@ -2254,7 +2337,9 @@ private void saveFile(Node parent, UploadResource resource, String user, Listnull when no active + * provider recognises the link + */ + public ConferenceCall findCallByUrl(String url) { + if (StringUtils.isBlank(url)) { + return null; + } + for (CallProvider registeredProvider : providers.values()) { + CallProvider provider = getProvider(registeredProvider.getType()); + if (provider != null && provider.isActive()) { + String callId = provider.findCallId(url); + if (isValidId(callId)) { + return new ConferenceCall(provider.getType(), callId); + } + } + } + return null; + } + + /** + * Lists the recordings of the call a meeting link points to that the viewer + * can open, newest first. + * + * @param url the meeting link + * @param viewer the user name of the viewer, from the authenticated request + * @param from the earliest recording date, inclusive, or null + * @param to the end of the range, or null; the configured grace + * is added to it + * @return the recordings, newest first + * @throws ObjectNotFoundException when no active provider recognises the link + * ({@value #RECORDINGS_OTHER_PROVIDER}) or the call does not exist + * ({@value #RECORDINGS_CALL_NOT_FOUND}) + * @throws IllegalAccessException when the viewer may not read the call's + * recordings + */ + public List getCallRecordingsByConference(String url, + String viewer, + Date from, + Date to) throws ObjectNotFoundException, IllegalAccessException { + if (StringUtils.isBlank(url)) { + throw new IllegalArgumentException(RECORDINGS_INVALID_URL); + } + ConferenceCall conferenceCall = findCallByUrl(url); + if (conferenceCall == null) { + throw new ObjectNotFoundException(RECORDINGS_OTHER_PROVIDER); + } + return getCallRecordings(conferenceCall.callId(), viewer, from, to); + } + + /** + * Lists the recordings of a call that the viewer can open, newest first. + *

+ * The viewer must be a member of the space owning a space or space-event + * call, or a participant of any other call. Within that, the repository + * decides: the recordings are read in the viewer's own JCR session, so only + * the files the viewer can open in Documents come back. + * + * @param callId the call id + * @param viewer the user name of the viewer, from the authenticated request + * @param from the earliest recording date, inclusive, or null + * @param to the end of the range, or null; the configured grace + * ({@code exo.webconferencing.recording.occurrenceGraceHours}) is + * added to it, since a recording is saved after its meeting ends + * @return the recordings, newest first + * @throws ObjectNotFoundException when the call does not exist + * @throws IllegalAccessException when the viewer may not read the call's + * recordings + */ + public List getCallRecordings(String callId, + String viewer, + Date from, + Date to) throws ObjectNotFoundException, IllegalAccessException { + if (!isValidId(callId)) { + throw new IllegalArgumentException(RECORDINGS_INVALID_CALL_ID); + } + if (from != null && to != null && from.after(to)) { + throw new IllegalArgumentException(RECORDINGS_INVALID_RANGE); + } + org.exoplatform.services.security.Identity viewerIdentity = StringUtils.isBlank(viewer) ? null : userIdentity(viewer); + if (viewerIdentity == null) { + throw new IllegalAccessException(RECORDINGS_NO_IDENTITY); + } + CallInfo call; + try { + call = getCall(callId); + } catch (InvalidCallException e) { + LOG.debug("Call {} cannot be read for its recordings", callId, e); + call = null; + } + if (call == null) { + throw new ObjectNotFoundException(RECORDINGS_CALL_NOT_FOUND); + } + String spaceGroupId = checkCanReadRecordings(call, viewer); + Date rangeEnd = to == null ? null : new Date(to.getTime() + recordingGraceHours * 3600000L); + try { + List recordings = recordingStorage.findRecordings(viewerIdentity, call.getId(), spaceGroupId, from, rangeEnd); + recordings.forEach(recording -> recording.setPreviewUrl(buildDocumentPreviewUrl(recording.getDocumentId()))); + return recordings; + } catch (RepositoryException e) { + throw new IllegalStateException(RECORDINGS_STORAGE_ERROR, e); + } + } + + /** + * Gets the grace added to the end of a range of recording dates. + * + * @return the grace, in hours + */ + public int getRecordingGraceHours() { + return recordingGraceHours; + } + + /** + * Checks that a viewer may read the recordings of a call: a member of the + * space owning a space or space-event call (or a platform space + * administrator), a participant or the owner of any other call. + * + * @param call the call + * @param viewer the viewer's user name + * @return the group id of the space owning the call, or null + * when a user or a chat room owns it + * @throws ObjectNotFoundException when the call's space no longer exists + * @throws IllegalAccessException when the viewer may not read them + */ + protected String checkCanReadRecordings(CallInfo call, String viewer) throws ObjectNotFoundException, IllegalAccessException { + IdentityInfo owner = call.getOwner(); + String spaceGroupId = null; + if (owner instanceof SpaceEventInfo spaceEventOwner) { + spaceGroupId = spaceEventOwner.getGroupId(); + } else if (owner instanceof SpaceInfo spaceOwner) { + spaceGroupId = spaceOwner.getGroupId(); + } + if (spaceGroupId != null) { + Space space = spaceService.getSpaceByGroupId(spaceGroupId); + if (space == null) { + throw new ObjectNotFoundException(RECORDINGS_CALL_NOT_FOUND); + } + if (!spaceService.isMember(space, viewer) && !spaceService.isSuperManager(viewer)) { + throw new IllegalAccessException(RECORDINGS_NOT_SPACE_MEMBER); + } + return spaceGroupId; + } + boolean isOwner = owner != null && viewer.equals(owner.getId()); + boolean isParticipant = call.getParticipants().stream().anyMatch(participant -> viewer.equals(participant.getId())); + if (!isOwner && !isParticipant) { + throw new IllegalAccessException(RECORDINGS_NOT_PARTICIPANT); + } + return null; + } + + /** + * Builds the link opening a document in the Documents preview. + * + * @param documentId the JCR identifier of the document + * @return the preview link + */ + protected String buildDocumentPreviewUrl(String documentId) { + return new StringBuilder(CommonsUtils.getCurrentDomain()).append("/") + .append(LinkProvider.getPortalName(null)) + .append("/") + .append(CommonsUtils.getCurrentPortalOwner()) + .append("/documents?documentPreviewId=") + .append(documentId) + .toString(); + } + + /** + * Reads a non-negative integer setting, falling back to its default when it + * is absent, malformed or negative. + * + * @param params the init params holding it, or null + * @param name the setting's name + * @param defaultValue the default value + * @return the value + */ + protected static int readNonNegativeInt(PropertiesParam params, String name, int defaultValue) { + String value = params == null ? null : params.getProperty(name); + if (StringUtils.isBlank(value)) { + return defaultValue; + } + try { + int parsed = Integer.parseInt(value.trim()); + if (parsed >= 0) { + return parsed; + } + } catch (NumberFormatException e) { + LOG.debug("Setting {} is not a number: {}", name, value, e); + } + LOG.warn("Setting {} must be a non-negative integer, {} is ignored and {} is used", name, value, defaultValue); + return defaultValue; + } + // <<<<<<< Call storage: wrappers to catch JPA exceptions public List getActiveProvidersForSpace(String spaceId) { List allProviders = new ArrayList<>(); diff --git a/services/src/main/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpTool.java b/services/src/main/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpTool.java new file mode 100644 index 000000000..8afb3ed2e --- /dev/null +++ b/services/src/main/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpTool.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing.mcp; + +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import org.exoplatform.commons.exception.ObjectNotFoundException; +import org.exoplatform.webconferencing.WebConferencingService; +import org.exoplatform.webconferencing.mcp.model.MeetingRecordingModel; +import org.exoplatform.webconferencing.mcp.model.MeetingRecordingsModel; +import org.exoplatform.webconferencing.model.RecordingInfo; +import org.exoplatform.webconferencing.util.RecordingDateUtils; + +import io.meeds.mcp.server.plugin.McpToolPlugin; + +/** + * The assistant tools of the web conferencing add-on: one public method per + * tool, named in snake case, each described in this add-on's + * {@code ai-tool-definitions.json}. + *

+ * The acting user is the authenticated request's, never an argument: the + * recordings come back with that user's own rights. + */ +@Service +@Profile("mcp-server") +public class MeetingRecordingMcpTool implements McpToolPlugin { + + /** The reason of an empty list: the meeting was not recorded, or not by this platform. */ + public static final String NO_RECORDING = "NO_RECORDING"; + + /** The reason of an empty list: the link is not one of an active provider's meetings. */ + public static final String OTHER_PROVIDER = "OTHER_PROVIDER"; + + private final WebConferencingService webConferencingService; + + /** + * Builds the tool. + * + * @param webConferencingService the service holding the recordings lookup + */ + @Autowired + public MeetingRecordingMcpTool(WebConferencingService webConferencingService) { + this.webConferencingService = webConferencingService; + } + + /** + * Lists the recordings of the meeting behind a conference link that the + * current user can open, newest first ({@code get_meeting_recordings}). + * + * @param conferenceUrl the meeting link, as {@code get_event_conference} + * returns it + * @param recordedFrom the earliest recording date, ISO 8601, optional + * @param recordedTo the end of the range, ISO 8601, optional: the server adds + * its grace, since a recording is saved after its meeting ends + * @return the recordings, or an empty list with its reason + * @throws IllegalAccessException when the user may not read the meeting's + * recordings: not a member of its space, or not a participant + */ + public MeetingRecordingsModel getMeetingRecordings(String conferenceUrl, + String recordedFrom, + String recordedTo) throws IllegalAccessException { + if (StringUtils.isBlank(conferenceUrl)) { + throw new IllegalArgumentException("conference_url is mandatory: pass the url get_event_conference returns"); + } + try { + List recordings = + webConferencingService.getCallRecordingsByConference(conferenceUrl, + getCurrentUserName(), + RecordingDateUtils.parseDate(recordedFrom, + false), + RecordingDateUtils.parseDate(recordedTo, + true)) + .stream() + .map(MeetingRecordingMcpTool::toModel) + .toList(); + return new MeetingRecordingsModel(recordings, recordings.isEmpty() ? NO_RECORDING : null); + } catch (ObjectNotFoundException e) { + String reason = WebConferencingService.RECORDINGS_OTHER_PROVIDER.equals(e.getMessage()) ? OTHER_PROVIDER : NO_RECORDING; + return new MeetingRecordingsModel(List.of(), reason); + } + } + + /** + * Maps a recording to the tool's answer. + * + * @param recording the recording + * @return its model + */ + private static MeetingRecordingModel toModel(RecordingInfo recording) { + return new MeetingRecordingModel(recording.getDocumentId(), + recording.getTitle(), + RecordingDateUtils.formatDate(recording.getRecordedDate()), + recording.getSizeBytes(), + recording.getMimeType(), + recording.getPreviewUrl()); + } + +} diff --git a/services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingModel.java b/services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingModel.java new file mode 100644 index 000000000..6a334de92 --- /dev/null +++ b/services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingModel.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing.mcp.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +/** + * A recording of a meeting, as the assistant tool answers it. + * + * @param documentId the id of the recording's document, the one + * {@code get_document_transcription_by_id} takes + * @param title the title displayed in Documents: the meeting's title and date + * @param recordedDate when the recording was saved, an ISO 8601 instant in UTC + * @param sizeBytes the size of the recording in bytes, -1 when unknown + * @param mimeType the media type of the recording + * @param previewUrl the link opening the recording in Documents + */ +@JsonInclude(value = Include.NON_EMPTY) +public record MeetingRecordingModel(String documentId, + String title, + String recordedDate, + long sizeBytes, + String mimeType, + String previewUrl) { +} diff --git a/services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingsModel.java b/services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingsModel.java new file mode 100644 index 000000000..3471a7ea6 --- /dev/null +++ b/services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingsModel.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing.mcp.model; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +/** + * The recordings of a meeting, newest first, or the reason there are none. + * + * @param recordings the recordings the user can open, newest first + * @param reason when the list is empty: {@code NO_RECORDING} or + * {@code OTHER_PROVIDER}; absent otherwise + */ +@JsonInclude(value = Include.NON_NULL) +public record MeetingRecordingsModel(List recordings, String reason) { +} diff --git a/services/src/main/java/org/exoplatform/webconferencing/model/ConferenceCall.java b/services/src/main/java/org/exoplatform/webconferencing/model/ConferenceCall.java new file mode 100644 index 000000000..89a23c1af --- /dev/null +++ b/services/src/main/java/org/exoplatform/webconferencing/model/ConferenceCall.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing.model; + +/** + * The call a meeting link points to, as one of the active providers reads it. + * + * @param providerType the type of the provider that recognised the link + * @param callId the id of the call the link points to + */ +public record ConferenceCall(String providerType, String callId) { +} diff --git a/services/src/main/java/org/exoplatform/webconferencing/model/RecordingInfo.java b/services/src/main/java/org/exoplatform/webconferencing/model/RecordingInfo.java new file mode 100644 index 000000000..b3bd1f225 --- /dev/null +++ b/services/src/main/java/org/exoplatform/webconferencing/model/RecordingInfo.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing.model; + +import java.util.Date; + +/** + * A recording of a call, as the viewer who asked for it can open it in + * Documents. The transcript state is not carried here: it belongs to the + * documents add-on, which the client asks per recording. + */ +public class RecordingInfo { + + /** The JCR identifier of the recording's file. */ + private final String documentId; + + /** The title displayed in Documents. */ + private final String title; + + /** When the recording was saved. */ + private final Date recordedDate; + + /** The size of the recording, in bytes, or -1 when unknown. */ + private final long sizeBytes; + + /** The media type of the recording. */ + private final String mimeType; + + /** The link opening the recording in the Documents preview. */ + private String previewUrl; + + /** + * Builds a recording. + * + * @param documentId the JCR identifier of the recording's file + * @param title the title displayed in Documents + * @param recordedDate when the recording was saved + * @param sizeBytes the size of the recording, in bytes, or -1 when unknown + * @param mimeType the media type of the recording + */ + public RecordingInfo(String documentId, String title, Date recordedDate, long sizeBytes, String mimeType) { + this.documentId = documentId; + this.title = title; + this.recordedDate = recordedDate == null ? null : new Date(recordedDate.getTime()); + this.sizeBytes = sizeBytes; + this.mimeType = mimeType; + } + + /** + * Gets the JCR identifier of the recording's file. + * + * @return the document id + */ + public String getDocumentId() { + return documentId; + } + + /** + * Gets the title displayed in Documents. + * + * @return the title + */ + public String getTitle() { + return title; + } + + /** + * Gets when the recording was saved. + * + * @return a copy of the recorded date, or null when unknown + */ + public Date getRecordedDate() { + return recordedDate == null ? null : new Date(recordedDate.getTime()); + } + + /** + * Gets the size of the recording. + * + * @return the size in bytes, or -1 when unknown + */ + public long getSizeBytes() { + return sizeBytes; + } + + /** + * Gets the media type of the recording. + * + * @return the mime type + */ + public String getMimeType() { + return mimeType; + } + + /** + * Gets the link opening the recording in the Documents preview. + * + * @return the preview url + */ + public String getPreviewUrl() { + return previewUrl; + } + + /** + * Sets the link opening the recording in the Documents preview. + * + * @param previewUrl the preview url + */ + public void setPreviewUrl(String previewUrl) { + this.previewUrl = previewUrl; + } + +} diff --git a/services/src/main/java/org/exoplatform/webconferencing/rest/CallRecordingRest.java b/services/src/main/java/org/exoplatform/webconferencing/rest/CallRecordingRest.java new file mode 100644 index 000000000..9605b826c --- /dev/null +++ b/services/src/main/java/org/exoplatform/webconferencing/rest/CallRecordingRest.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing.rest; + +import java.util.List; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import org.exoplatform.commons.exception.ObjectNotFoundException; +import org.exoplatform.services.log.ExoLogger; +import org.exoplatform.services.log.Log; +import org.exoplatform.webconferencing.WebConferencingService; +import org.exoplatform.webconferencing.rest.model.RecordingEntity; +import org.exoplatform.webconferencing.rest.util.RecordingEntityBuilder; +import org.exoplatform.webconferencing.rest.util.RecordingsLookup; +import org.exoplatform.webconferencing.util.RecordingDateUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; + +/** + * The recordings of a call, as the viewer can open them in Documents. + * Served under {@code /webconferencing/rest/}. + *

+ * Every refusal answers a JSON body {@code {"message": ""}}, the code + * being one of the {@code webconferencing.recordings.*} message codes of + * {@link WebConferencingService} and {@link RecordingDateUtils}. + */ +@RestController +@Tag(name = "call-recordings", description = "The recordings of a call that the user can open") +public class CallRecordingRest { + + /** The prefix of the message codes a refusal may carry. */ + public static final String CODE_PREFIX = "webconferencing.recordings."; + + /** The message code of a request refused for a reason that has no code. */ + public static final String INVALID_REQUEST = "webconferencing.recordings.invalidRequest"; + + private static final Log LOG = ExoLogger.getLogger(CallRecordingRest.class); + + private final WebConferencingService webConferencingService; + + /** + * Builds the resource. + * + * @param webConferencingService the service holding the recordings lookup + */ + @Autowired + public CallRecordingRest(WebConferencingService webConferencingService) { + this.webConferencingService = webConferencingService; + } + + /** + * Lists the recordings of a call that the user can open, newest first. + * + * @param request the authenticated request + * @param callId the call id + * @param from the earliest recording date, ISO 8601 + * @param to the end of the range, ISO 8601: the server adds its grace + * @return the recordings + */ + @GetMapping("calls/{callId}/recordings") + @Secured("users") + @Operation(summary = "List the recordings of a call", method = "GET", + description = "Answers the recordings of the call that the user can open in Documents, newest first." + + " The user must be a member of the space owning a space or space-event call, or a participant" + + " of any other call. The server adds its configured grace to the end of the range.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Request fulfilled"), + @ApiResponse(responseCode = "400", description = "Invalid call id or date"), + @ApiResponse(responseCode = "403", description = "The user may not read the recordings of this call"), + @ApiResponse(responseCode = "404", description = "Call not found"), + }) + public ResponseEntity getCallRecordings(HttpServletRequest request, + @Parameter(description = "Call id") @PathVariable("callId") String callId, + @Parameter(description = "Earliest recording date, ISO 8601") @RequestParam(name = "from", required = false) String from, + @Parameter(description = "End of the range, ISO 8601") @RequestParam(name = "to", required = false) String to) { + return answer(() -> webConferencingService.getCallRecordings(callId, + request.getRemoteUser(), + RecordingDateUtils.parseDate(from, false), + RecordingDateUtils.parseDate(to, true))); + } + + /** + * Lists the recordings of the call a meeting link points to that the user + * can open, newest first. + * + * @param request the authenticated request + * @param url the meeting link, as an agenda event's conference stores it + * @param from the earliest recording date, ISO 8601 + * @param to the end of the range, ISO 8601: the server adds its grace + * @return the recordings + */ + @GetMapping("recordings/byConference") + @Secured("users") + @Operation(summary = "List the recordings of the call behind a meeting link", method = "GET", + description = "Reads the call id out of the link with the active providers, then answers as the call's" + + " recordings list does. A link no active provider recognises answers 404 with the code" + + " webconferencing.recordings.otherProvider.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Request fulfilled"), + @ApiResponse(responseCode = "400", description = "Missing link or invalid date"), + @ApiResponse(responseCode = "403", description = "The user may not read the recordings of this call"), + @ApiResponse(responseCode = "404", description = "Call not found, or the link is held on another provider"), + }) + public ResponseEntity getRecordingsByConference(HttpServletRequest request, + @Parameter(description = "Meeting link") @RequestParam(name = "url", required = false) String url, + @Parameter(description = "Earliest recording date, ISO 8601") @RequestParam(name = "from", required = false) String from, + @Parameter(description = "End of the range, ISO 8601") @RequestParam(name = "to", required = false) String to) { + return answer(() -> webConferencingService.getCallRecordingsByConference(url, + request.getRemoteUser(), + RecordingDateUtils.parseDate(from, false), + RecordingDateUtils.parseDate(to, true))); + } + + /** + * Runs a lookup and maps its outcome to the response: the list, or the + * refusal's status with its message code. Never cached: the list depends on + * the viewer. + * + * @param lookup the lookup + * @return the response + */ + private ResponseEntity answer(RecordingsLookup lookup) { + try { + List recordings = lookup.get().stream().map(RecordingEntityBuilder::toEntity).toList(); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(recordings); + } catch (ObjectNotFoundException e) { + return refusal(HttpStatus.NOT_FOUND, e.getMessage()); + } catch (IllegalAccessException e) { + return refusal(HttpStatus.FORBIDDEN, e.getMessage()); + } catch (IllegalArgumentException e) { + LOG.debug("Recordings request refused", e); + return refusal(HttpStatus.BAD_REQUEST, StringUtils.startsWith(e.getMessage(), CODE_PREFIX) ? e.getMessage() : INVALID_REQUEST); + } catch (RuntimeException e) { + LOG.warn("Cannot list the recordings of a call", e); + return refusal(HttpStatus.INTERNAL_SERVER_ERROR, WebConferencingService.RECORDINGS_STORAGE_ERROR); + } + } + + /** + * A refusal carrying its message code, never cached. + * + * @param status the status + * @param code the message code + * @return the response + */ + private static ResponseEntity> refusal(HttpStatus status, String code) { + return ResponseEntity.status(status).cacheControl(CacheControl.noStore()).body(Map.of("message", code)); + } + +} diff --git a/services/src/main/java/org/exoplatform/webconferencing/rest/model/RecordingEntity.java b/services/src/main/java/org/exoplatform/webconferencing/rest/model/RecordingEntity.java new file mode 100644 index 000000000..2c1989ba1 --- /dev/null +++ b/services/src/main/java/org/exoplatform/webconferencing/rest/model/RecordingEntity.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing.rest.model; + +/** + * A recording of a call, as the recordings REST answers it. + * + * @param documentId the JCR identifier of the recording's file, the id the + * documents add-on and its transcription REST take + * @param title the title displayed in Documents + * @param recordedDate when the recording was saved, an ISO 8601 instant in UTC + * @param sizeBytes the size of the recording in bytes, -1 when unknown + * @param mimeType the media type of the recording + * @param previewUrl the link opening the recording in the Documents preview + */ +public record RecordingEntity(String documentId, + String title, + String recordedDate, + long sizeBytes, + String mimeType, + String previewUrl) { +} diff --git a/services/src/main/java/org/exoplatform/webconferencing/rest/util/RecordingEntityBuilder.java b/services/src/main/java/org/exoplatform/webconferencing/rest/util/RecordingEntityBuilder.java new file mode 100644 index 000000000..7b042b366 --- /dev/null +++ b/services/src/main/java/org/exoplatform/webconferencing/rest/util/RecordingEntityBuilder.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing.rest.util; + +import org.exoplatform.webconferencing.model.RecordingInfo; +import org.exoplatform.webconferencing.rest.model.RecordingEntity; +import org.exoplatform.webconferencing.util.RecordingDateUtils; + +/** + * Maps the recordings of a call to their REST form. + */ +public final class RecordingEntityBuilder { + + /** + * No instance: static helpers only. + */ + private RecordingEntityBuilder() { + } + + /** + * Maps a recording to its REST form. + * + * @param recording the recording + * @return its REST form + */ + public static RecordingEntity toEntity(RecordingInfo recording) { + return new RecordingEntity(recording.getDocumentId(), + recording.getTitle(), + RecordingDateUtils.formatDate(recording.getRecordedDate()), + recording.getSizeBytes(), + recording.getMimeType(), + recording.getPreviewUrl()); + } + +} diff --git a/services/src/main/java/org/exoplatform/webconferencing/rest/util/RecordingsLookup.java b/services/src/main/java/org/exoplatform/webconferencing/rest/util/RecordingsLookup.java new file mode 100644 index 000000000..f51c6ecf8 --- /dev/null +++ b/services/src/main/java/org/exoplatform/webconferencing/rest/util/RecordingsLookup.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing.rest.util; + +import java.util.List; + +import org.exoplatform.commons.exception.ObjectNotFoundException; +import org.exoplatform.webconferencing.model.RecordingInfo; + +/** + * A recordings lookup, run by the REST layer that maps its refusals to + * statuses. + */ +@FunctionalInterface +public interface RecordingsLookup { + + /** + * Runs the lookup. + * + * @return the recordings + * @throws ObjectNotFoundException when the call or its provider is unknown + * @throws IllegalAccessException when the viewer may not read them + */ + List get() throws ObjectNotFoundException, IllegalAccessException; + +} diff --git a/services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java b/services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java new file mode 100644 index 000000000..1980b725f --- /dev/null +++ b/services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java @@ -0,0 +1,541 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing.storage; + +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Comparator; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.List; +import java.util.regex.Pattern; + +import javax.jcr.AccessDeniedException; +import javax.jcr.ItemNotFoundException; +import javax.jcr.Node; +import javax.jcr.NodeIterator; +import javax.jcr.PathNotFoundException; +import javax.jcr.RepositoryException; +import javax.jcr.Session; +import javax.jcr.query.Query; + +import org.apache.commons.lang3.StringUtils; + +import org.exoplatform.commons.utils.ISO8601; +import org.exoplatform.services.cms.link.LinkManager; +import org.exoplatform.services.jcr.RepositoryService; +import org.exoplatform.services.jcr.core.ExtendedNode; +import org.exoplatform.services.jcr.core.ManageableRepository; +import org.exoplatform.services.jcr.ext.common.SessionProvider; +import org.exoplatform.services.jcr.ext.hierarchy.NodeHierarchyCreator; +import org.exoplatform.services.jcr.impl.core.query.QueryImpl; +import org.exoplatform.services.log.ExoLogger; +import org.exoplatform.services.log.Log; +import org.exoplatform.services.security.ConversationState; +import org.exoplatform.services.security.Identity; +import org.exoplatform.webconferencing.CallInfo; +import org.exoplatform.webconferencing.UploadFileInfo; +import org.exoplatform.webconferencing.model.RecordingInfo; + +/** + * Reads and stamps the recordings of calls in JCR. + *

+ * A recording saved by this add-on carries the {@value #EXO_CALL_RECORDING} + * mixin, which names the call it belongs to. The lookup runs a JCR-SQL query in + * the viewer's own session, so the repository's permissions decide which + * recordings come back: a viewer gets exactly the files they can open in + * Documents. A recording saved before the mixin existed is still found by its + * file name, which Jibri builds as the call id, an underscore and a timestamp, + * and both kinds are merged. + */ +public class CallRecordingStorage { + + /** The mixin naming the call a recording belongs to. */ + public static final String EXO_CALL_RECORDING = "exo:callRecording"; + + /** The id of the call. */ + public static final String EXO_CALL_ID = "exo:callId"; + + /** The type of the call provider. */ + public static final String EXO_CALL_PROVIDER = "exo:callProvider"; + + /** The owner type of the call: space_event, space, user or chat_room. */ + public static final String EXO_CALL_OWNER_TYPE = "exo:callOwnerType"; + + /** The owner id of the call. */ + public static final String EXO_CALL_OWNER_ID = "exo:callOwnerId"; + + /** The title of the call. */ + public static final String EXO_CALL_TITLE = "exo:callTitle"; + + /** The scheduled start of the call, when it has one. */ + public static final String EXO_CALL_START_DATE = "exo:callStartDate"; + + /** When the recording was saved. */ + public static final String EXO_RECORDED_DATE = "exo:recordedDate"; + + /** The folder that holds the recordings of a space or of a user. */ + public static final String RECORDINGS_FOLDER = "recordings"; + + /** The most recordings one lookup returns. */ + public static final int MAX_RECORDINGS = 100; + + /** The separator between the call title and the date in a recording title. */ + public static final String TITLE_SEPARATOR = " – "; + + private static final String EXO_TITLE = "exo:title"; + + private static final String EXO_RESTORE_LOCATION = "exo:restoreLocation"; + + private static final String EXO_SYMLINK = "exo:symlink"; + + private static final String NT_FILE = "nt:file"; + + private static final String JCR_CONTENT = "jcr:content"; + + private static final String JCR_DATA = "jcr:data"; + + private static final String JCR_MIME_TYPE = "jcr:mimeType"; + + private static final String JCR_LAST_MODIFIED = "jcr:lastModified"; + + private static final String CMS_GROUPS_PATH = "groupsPath"; + + private static final String USER_PRIVATE_PATH = "userPrivate"; + + private static final String SPACE_DOCUMENTS = "/Documents/"; + + /** + * The call ids the file-name fallback accepts: the characters a slugified + * Jitsi call id is made of. Anything else would be read as a JCR name + * pattern ({@code *}, {@code |}) or is not a legal JCR name character. + */ + private static final Pattern NAME_PATTERN_SAFE_ID = Pattern.compile("[\\w.@-]+"); + + private static final DateTimeFormatter TITLE_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + private static final Comparator NEWEST_FIRST = + Comparator.comparing(RecordingInfo::getRecordedDate, + Comparator.nullsLast(Comparator.reverseOrder())); + + private static final Log LOG = ExoLogger.getLogger(CallRecordingStorage.class); + + private final RepositoryService repositoryService; + + private final NodeHierarchyCreator nodeCreator; + + private final LinkManager linkManager; + + /** + * Builds the storage. + * + * @param repositoryService the repository service + * @param nodeCreator resolves the spaces' and users' folders + * @param linkManager resolves the links shared to the participants of a call + */ + public CallRecordingStorage(RepositoryService repositoryService, NodeHierarchyCreator nodeCreator, LinkManager linkManager) { + this.repositoryService = repositoryService; + this.nodeCreator = nodeCreator; + this.linkManager = linkManager; + } + + /** + * Builds the JCR-SQL statement listing the stamped recordings of a call, + * newest first, optionally within a range of recording dates. + * + * @param callId the call id, quoted as a string literal + * @param from the earliest recording date, inclusive, or null + * @param to the latest recording date, inclusive, or null + * @return the statement + */ + public static String buildRecordingsQuery(String callId, Date from, Date to) { + StringBuilder statement = new StringBuilder("SELECT * FROM ").append(EXO_CALL_RECORDING) + .append(" WHERE ") + .append(EXO_CALL_ID) + .append(" = '") + .append(StringUtils.replace(callId, "'", "''")) + .append("'"); + if (from != null) { + statement.append(" AND ").append(EXO_RECORDED_DATE).append(" >= TIMESTAMP '").append(format(from)).append("'"); + } + if (to != null) { + statement.append(" AND ").append(EXO_RECORDED_DATE).append(" <= TIMESTAMP '").append(format(to)).append("'"); + } + return statement.append(" ORDER BY ").append(EXO_RECORDED_DATE).append(" DESC").toString(); + } + + /** + * Tells whether a file name is the one Jibri gives a recording of a call: + * the call id, an underscore, a timestamp and an extension. The match is on + * the whole id, so a call id that prefixes another's (a space and a space + * whose name starts with it) does not pick the other call's recordings. + * + * @param fileName the file name + * @param callId the call id + * @return true when the file is a recording of that call + */ + public static boolean isRecordingFileOf(String fileName, String callId) { + if (StringUtils.isBlank(fileName) || StringUtils.isBlank(callId)) { + return false; + } + String baseName = fileName; + int extension = baseName.lastIndexOf('.'); + if (extension > 0) { + baseName = baseName.substring(0, extension); + } + int timestamp = baseName.lastIndexOf('_'); + return timestamp > 0 && baseName.substring(0, timestamp).equals(callId); + } + + /** + * Builds the title a recording shows in Documents: the call's title and the + * moment it was recorded, in the server's time zone. + * + * @param callTitle the title of the call + * @param recordedDate when the recording was saved + * @return the title, or null when the call has no title + */ + public static String recordingTitle(String callTitle, Calendar recordedDate) { + if (StringUtils.isBlank(callTitle) || recordedDate == null) { + return null; + } + String date = recordedDate.toInstant().atZone(ZoneId.systemDefault()).format(TITLE_DATE_FORMAT); + return callTitle.trim() + TITLE_SEPARATOR + date; + } + + /** + * Stamps a recording's file with the call it belongs to and gives it the + * call's title. A failure is logged and leaves the file as it is: the + * recording itself must never be lost to its stamp. + * + * @param fileNode the recording's file, not saved yet + * @param callId the call id the upload token carries + * @param call the call, or null when it could not be read + * @param uploadInfo the upload info, for the owner when the call is unknown + * @param recordedDate when the recording is saved + * @return true when the file is stamped + */ + public boolean stamp(Node fileNode, String callId, CallInfo call, UploadFileInfo uploadInfo, Calendar recordedDate) { + try { + fileNode.addMixin(EXO_CALL_RECORDING); + fileNode.setProperty(EXO_CALL_ID, callId); + if (call != null && StringUtils.isNotBlank(call.getProviderType())) { + fileNode.setProperty(EXO_CALL_PROVIDER, call.getProviderType()); + } + String ownerType = call != null && call.getOwner() != null ? call.getOwner().getType() : uploadInfo.getType(); + String ownerId = call != null && call.getOwner() != null ? call.getOwner().getId() : uploadInfo.getIdentity(); + if (StringUtils.isNotBlank(ownerType)) { + fileNode.setProperty(EXO_CALL_OWNER_TYPE, ownerType); + } + if (StringUtils.isNotBlank(ownerId)) { + fileNode.setProperty(EXO_CALL_OWNER_ID, ownerId); + } + String callTitle = call == null ? null : call.getTitle(); + if (StringUtils.isNotBlank(callTitle)) { + fileNode.setProperty(EXO_CALL_TITLE, callTitle); + } + if (call != null && call.getStartDate() != null) { + fileNode.setProperty(EXO_CALL_START_DATE, toCalendar(call.getStartDate())); + } + fileNode.setProperty(EXO_RECORDED_DATE, recordedDate); + String title = recordingTitle(callTitle, recordedDate); + if (title != null) { + fileNode.setProperty(EXO_TITLE, title); + } + return true; + } catch (RepositoryException e) { + LOG.warn("Cannot stamp the recording of call {} with its call, it is saved without it", callId, e); + return false; + } + } + + /** + * Lists the recordings of a call the viewer can open, newest first. + *

+ * The stamped recordings are read by a query in the viewer's session. The + * recordings saved before the stamp existed are found by listing the + * recordings folder the call saves into for unstamped files named after the + * call: the space's for a space or space-event call, the viewer's own + * private folder otherwise, where the other participants' recordings arrive + * as links. + * + * @param viewer the viewer's security identity, whose session reads JCR + * @param callId the call id + * @param spaceGroupId the group id of the space owning the call, or + * null for a user or chat room call + * @param from the earliest recording date, inclusive, or null + * @param to the latest recording date, inclusive, or null + * @return the recordings, newest first, at most {@value #MAX_RECORDINGS} + * @throws RepositoryException when the repository fails + */ + public List findRecordings(Identity viewer, + String callId, + String spaceGroupId, + Date from, + Date to) throws RepositoryException { + SessionProvider sessionProvider = new SessionProvider(new ConversationState(viewer)); + try { + ManageableRepository repository = repositoryService.getCurrentRepository(); + Session session = sessionProvider.getSession(repository.getConfiguration().getDefaultWorkspaceName(), repository); + return findRecordings(sessionProvider, session, viewer.getUserId(), callId, spaceGroupId, from, to); + } finally { + sessionProvider.close(); + } + } + + /** + * Lists the recordings of a call in an open viewer session: the stamped + * ones, and the unstamped files named after the call in the recordings + * folder the call saves into. Both are merged, so the recordings of a series + * made before and after the stamp existed all come back. + * + * @param sessionProvider the viewer's session provider + * @param session the viewer's session + * @param userId the viewer's user id + * @param callId the call id + * @param spaceGroupId the group id of the space owning the call, or + * null + * @param from the earliest recording date, inclusive, or null + * @param to the latest recording date, inclusive, or null + * @return the recordings, newest first, at most {@value #MAX_RECORDINGS} + * @throws RepositoryException when the repository fails + */ + List findRecordings(SessionProvider sessionProvider, + Session session, + String userId, + String callId, + String spaceGroupId, + Date from, + Date to) throws RepositoryException { + List recordings = queryRecordings(session, callId, from, to); + recordings.addAll(listRecordingsByName(sessionProvider, session, userId, callId, spaceGroupId, from, to)); + recordings.sort(NEWEST_FIRST); + return recordings.size() > MAX_RECORDINGS ? new ArrayList<>(recordings.subList(0, MAX_RECORDINGS)) : recordings; + } + + /** + * Runs the stamped-recordings query in the viewer's session. + * + * @param session the viewer's session + * @param callId the call id + * @param from the earliest recording date, or null + * @param to the latest recording date, or null + * @return the recordings found, outside the trash + * @throws RepositoryException when the repository fails + */ + private List queryRecordings(Session session, String callId, Date from, Date to) throws RepositoryException { + Query query = session.getWorkspace() + .getQueryManager() + .createQuery(buildRecordingsQuery(callId, from, to), Query.SQL); + if (query instanceof QueryImpl queryImpl) { + queryImpl.setLimit(MAX_RECORDINGS); + } + List recordings = new ArrayList<>(); + NodeIterator nodes = query.execute().getNodes(); + while (nodes.hasNext()) { + Node node = nodes.nextNode(); + if (!isInTrash(node)) { + recordings.add(toRecordingInfo(node)); + } + } + return recordings; + } + + /** + * Lists, in the recordings folder the call saves into, the unstamped files + * named after the call: the recordings saved before the stamp existed. A + * stamped file is left out, the query has already read it. + * + * @param sessionProvider the viewer's session provider + * @param session the viewer's session + * @param userId the viewer's user id + * @param callId the call id + * @param spaceGroupId the group id of the space owning the call, or + * null + * @param from the earliest recording date, or null + * @param to the latest recording date, or null + * @return the recordings found, possibly empty + * @throws RepositoryException when the repository fails + */ + private List listRecordingsByName(SessionProvider sessionProvider, + Session session, + String userId, + String callId, + String spaceGroupId, + Date from, + Date to) throws RepositoryException { + if (!NAME_PATTERN_SAFE_ID.matcher(callId).matches()) { + return new ArrayList<>(); + } + Node folder = getRecordingsFolder(sessionProvider, session, userId, spaceGroupId); + if (folder == null) { + return new ArrayList<>(); + } + List recordings = new ArrayList<>(); + NodeIterator children = folder.getNodes(callId + "_*"); + while (children.hasNext()) { + Node child = children.nextNode(); + if (!isRecordingFileOf(child.getName(), callId)) { + continue; + } + Node file = resolveFile(child); + if (file != null && !file.isNodeType(EXO_CALL_RECORDING) && !isInTrash(file)) { + RecordingInfo recording = toRecordingInfo(file); + if (isInRange(recording.getRecordedDate(), from, to)) { + recordings.add(recording); + } + } + } + return recordings; + } + + /** + * Gets the recordings folder a call saves into, as the viewer sees it. + * + * @param sessionProvider the viewer's session provider + * @param session the viewer's session + * @param userId the viewer's user id + * @param spaceGroupId the group id of the space owning the call, or + * null for the viewer's private folder + * @return the folder, or null when it does not exist or the + * viewer cannot read it + * @throws RepositoryException when the repository fails + */ + private Node getRecordingsFolder(SessionProvider sessionProvider, + Session session, + String userId, + String spaceGroupId) throws RepositoryException { + try { + if (spaceGroupId != null) { + String path = nodeCreator.getJcrPath(CMS_GROUPS_PATH) + spaceGroupId + SPACE_DOCUMENTS + RECORDINGS_FOLDER; + return (Node) session.getItem(path); + } + Node userNode = nodeCreator.getUserNode(sessionProvider, userId); + return userNode.getNode(nodeCreator.getJcrPath(USER_PRIVATE_PATH)).getNode(RECORDINGS_FOLDER); + } catch (PathNotFoundException | AccessDeniedException e) { + LOG.debug("No readable recordings folder for user {} and space group {}", userId, spaceGroupId, e); + return null; + } catch (RepositoryException e) { + throw e; + } catch (Exception e) { + throw new RepositoryException("Cannot read the private folder of user " + userId, e); + } + } + + /** + * Resolves a recordings folder entry to the recording's file: itself, or the + * target of the link a shared recording arrives as. + * + * @param child the folder entry + * @return the file, or null when the entry is not a readable + * file + * @throws RepositoryException when the repository fails + */ + private Node resolveFile(Node child) throws RepositoryException { + Node file = child; + if (child.isNodeType(EXO_SYMLINK)) { + try { + file = linkManager.getTarget(child); + } catch (ItemNotFoundException | AccessDeniedException e) { + LOG.debug("The target of recording link {} is gone or not readable", child.getName(), e); + return null; + } + } + return file != null && file.isNodeType(NT_FILE) ? file : null; + } + + /** + * Reads a recording's file. + * + * @param file the recording's file + * @return the recording + * @throws RepositoryException when the repository fails + */ + private RecordingInfo toRecordingInfo(Node file) throws RepositoryException { + String title = file.hasProperty(EXO_TITLE) ? file.getProperty(EXO_TITLE).getString() : file.getName(); + Date recordedDate = null; + long size = -1; + String mimeType = null; + Node content = file.hasNode(JCR_CONTENT) ? file.getNode(JCR_CONTENT) : null; + if (file.hasProperty(EXO_RECORDED_DATE)) { + recordedDate = file.getProperty(EXO_RECORDED_DATE).getDate().getTime(); + } else if (content != null && content.hasProperty(JCR_LAST_MODIFIED)) { + recordedDate = content.getProperty(JCR_LAST_MODIFIED).getDate().getTime(); + } + if (content != null) { + if (content.hasProperty(JCR_DATA)) { + size = content.getProperty(JCR_DATA).getLength(); + } + if (content.hasProperty(JCR_MIME_TYPE)) { + mimeType = content.getProperty(JCR_MIME_TYPE).getString(); + } + } + return new RecordingInfo(((ExtendedNode) file).getIdentifier(), title, recordedDate, size, mimeType); + } + + /** + * Tells whether a node is in the trash. + * + * @param node the node + * @return true when the node carries the trash's restore location + * @throws RepositoryException when the repository fails + */ + private static boolean isInTrash(Node node) throws RepositoryException { + return node.isNodeType(EXO_RESTORE_LOCATION); + } + + /** + * Tells whether a date is within a range, each bound optional and + * inclusive. An unknown date is in range only when no bound is given. + * + * @param date the date + * @param from the lower bound, or null + * @param to the upper bound, or null + * @return true when the date is in range + */ + private static boolean isInRange(Date date, Date from, Date to) { + if (date == null) { + return from == null && to == null; + } + return (from == null || !date.before(from)) && (to == null || !date.after(to)); + } + + /** + * Formats a date as a JCR-SQL timestamp literal. + * + * @param date the date + * @return its ISO 8601 form + */ + private static String format(Date date) { + return ISO8601.format(toCalendar(date)); + } + + /** + * Converts a date to a calendar. + * + * @param date the date + * @return the calendar + */ + private static Calendar toCalendar(Date date) { + Calendar calendar = new GregorianCalendar(); + calendar.setTime(date); + return calendar; + } + +} diff --git a/services/src/main/java/org/exoplatform/webconferencing/util/RecordingDateUtils.java b/services/src/main/java/org/exoplatform/webconferencing/util/RecordingDateUtils.java new file mode 100644 index 000000000..7d7f5140d --- /dev/null +++ b/services/src/main/java/org/exoplatform/webconferencing/util/RecordingDateUtils.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing.util; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.util.Date; + +import org.apache.commons.lang3.StringUtils; + +/** + * Reads and writes the dates a client gives and gets when it lists the + * recordings of a call. + */ +public final class RecordingDateUtils { + + /** The message code of a date that cannot be read. */ + public static final String RECORDINGS_INVALID_DATE = "webconferencing.recordings.invalidDate"; + + /** + * No instance: static helpers only. + */ + private RecordingDateUtils() { + } + + /** + * Reads a date: an ISO 8601 date-time with an offset ({@code 2026-10-23T10:00:00Z}, + * {@code 2026-10-23T10:00:00+02:00}), a date-time without one, read in UTC, + * or a bare date, which stands for the start of that day in UTC, or for its + * end when it closes a range. + * + * @param value the date, or a blank value for none + * @param endOfRange true when the date closes a range + * @return the date, or null when the value is blank + * @throws IllegalArgumentException with {@value #RECORDINGS_INVALID_DATE} + * when the value is not a date + */ + public static Date parseDate(String value, boolean endOfRange) { + if (StringUtils.isBlank(value)) { + return null; + } + String trimmed = value.trim(); + try { + return Date.from(OffsetDateTime.parse(trimmed).toInstant()); + } catch (DateTimeParseException e) { + // Not a date-time with an offset: try the other accepted forms + } + try { + return Date.from(LocalDateTime.parse(trimmed).toInstant(ZoneOffset.UTC)); + } catch (DateTimeParseException e) { + // Not a local date-time: try a bare date + } + try { + LocalDate date = LocalDate.parse(trimmed); + Instant start = date.atStartOfDay(ZoneOffset.UTC).toInstant(); + return Date.from(endOfRange ? date.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant().minusMillis(1) : start); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException(RECORDINGS_INVALID_DATE, e); + } + } + + /** + * Writes a date as an ISO 8601 instant in UTC. + * + * @param date the date, or null + * @return the instant, or null + */ + public static String formatDate(Date date) { + return date == null ? null : date.toInstant().toString(); + } + +} diff --git a/services/src/main/resources/ai-tool-definitions.json b/services/src/main/resources/ai-tool-definitions.json new file mode 100644 index 000000000..d09a6cc1a --- /dev/null +++ b/services/src/main/resources/ai-tool-definitions.json @@ -0,0 +1,36 @@ +{ + "icon": "fa-video", + "tools": [ + { + "name": "get_meeting_recordings", + "title": "Get meeting recordings", + "description": "List the recordings of the video meeting behind a conference link (the url get_event_conference returns), newest first, among those the current user can open. For one occurrence of a recurring event, pass the occurrence's start as recorded_from and its end as recorded_to; leave both out for every recording of the series. Pass a recording's documentId as the document id of get_document_transcription_by_id to read its transcript. An empty list comes with a reason: NO_RECORDING (the meeting was not recorded, or its recording is not saved yet) or OTHER_PROVIDER (the link is not an eXo video meeting, e.g. Teams or Zoom).", + "input_schema": { + "type": "object", + "properties": { + "conference_url": { + "type": "string", + "description": "The meeting link, as get_event_conference returns it." + }, + "recorded_from": { + "type": "string", + "description": "Optional. Earliest recording date, RFC3339 (2026-10-23T09:00:00Z) or a date (2026-10-23)." + }, + "recorded_to": { + "type": "string", + "description": "Optional. End of the meeting or occurrence, RFC3339 or a date; recordings saved shortly after it still count." + } + }, + "required": [ + "conference_url" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + } + ] +} diff --git a/services/src/main/resources/conf/portal/configuration.xml b/services/src/main/resources/conf/portal/configuration.xml index 06ab42f4f..3e69e5aa6 100644 --- a/services/src/main/resources/conf/portal/configuration.xml +++ b/services/src/main/resources/conf/portal/configuration.xml @@ -15,4 +15,19 @@ org.exoplatform.webconferencing.listeners.CallRecordingNotificationListener + + org.exoplatform.services.jcr.RepositoryService + + add.webconferencing.nodeTypes + addPlugin + org.exoplatform.services.jcr.impl.AddNodeTypePlugin + + + autoCreatedInNewRepository + Web Conferencing node types: the call a recording belongs to + jar:/conf/webconferencing-nodetypes.xml + + + + diff --git a/services/src/main/resources/conf/webconferencing-nodetypes.xml b/services/src/main/resources/conf/webconferencing-nodetypes.xml new file mode 100644 index 000000000..de9108c94 --- /dev/null +++ b/services/src/main/resources/conf/webconferencing-nodetypes.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java b/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java new file mode 100644 index 000000000..26ae74063 --- /dev/null +++ b/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java @@ -0,0 +1,585 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.time.Instant; +import java.util.Calendar; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import org.apache.commons.fileupload2.core.FileUploadException; +import javax.jcr.Node; +import javax.jcr.Session; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import org.exoplatform.commons.api.settings.SettingService; +import org.exoplatform.commons.exception.ObjectNotFoundException; +import org.exoplatform.container.xml.InitParams; +import org.exoplatform.container.xml.PropertiesParam; +import org.exoplatform.services.cms.link.LinkManager; +import org.exoplatform.services.jcr.RepositoryService; +import org.exoplatform.services.jcr.core.ManageableRepository; +import org.exoplatform.services.jcr.ext.common.SessionProvider; +import org.exoplatform.services.jcr.ext.app.SessionProviderService; +import org.exoplatform.services.jcr.ext.hierarchy.NodeHierarchyCreator; +import org.exoplatform.services.listener.ListenerService; +import org.exoplatform.services.organization.OrganizationService; +import org.exoplatform.services.security.Authenticator; +import org.exoplatform.services.security.Identity; +import org.exoplatform.services.security.IdentityRegistry; +import org.exoplatform.social.core.manager.IdentityManager; +import org.exoplatform.social.core.space.model.Space; +import org.exoplatform.social.core.space.spi.SpaceService; +import org.exoplatform.upload.UploadResource; +import org.exoplatform.upload.UploadService; +import org.exoplatform.wcm.ext.component.document.service.ShareDocumentService; +import org.exoplatform.web.security.codec.CodecInitializer; +import org.exoplatform.webconferencing.dao.CallDAO; +import org.exoplatform.webconferencing.dao.InviteDAO; +import org.exoplatform.webconferencing.dao.OriginDAO; +import org.exoplatform.webconferencing.dao.ParticipantDAO; +import org.exoplatform.webconferencing.model.ConferenceCall; +import org.exoplatform.webconferencing.model.RecordingInfo; +import org.exoplatform.webconferencing.storage.CallRecordingStorage; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * The recordings side of the service: the recording upload's own limit, the + * lookup of a call from its meeting link, and who may list a call's + * recordings. + */ +@ExtendWith(MockitoExtension.class) +class WebConferencingServiceRecordingsTest { + + private static final String CALL_ID = "g_42-0b6e1f0c9d3a4e2f"; + + private static final String GROUP_ID = "/spaces/sales"; + + private static final String VIEWER = "john"; + + @Mock + private UploadService uploadService; + + @Mock + private IdentityRegistry identityRegistry; + + @Mock + private SpaceService spaceService; + + @Mock + private CallRecordingStorage recordingStorage; + + @Mock + private ListenerService listenerService; + + @Mock + private RepositoryService repositoryService; + + @Mock + private SessionProviderService sessionProviders; + + @Mock + private NodeHierarchyCreator nodeCreator; + + @Mock + private IdentityManager identityManager; + + private WebConferencingService service; + + /** + * A service over mocked collaborators, with its recording settings. + */ + @BeforeEach + void setUp() { + service = spy(newService(null, null)); + } + + /** + * The recording upload registers its own limit, unlimited by default, + * before the upload is read, and removes it once done. + * + * @throws Exception never + */ + @Test + void theRecordingUploadIsUnlimitedByDefault() throws Exception { + uploadAndCheckLimit(service, 0); + } + + /** + * An administrator's limit is the one the recording upload registers. + * + * @throws Exception never + */ + @Test + void theRecordingUploadTakesTheConfiguredLimit() throws Exception { + uploadAndCheckLimit(spy(newService("500", null)), 500); + } + + /** + * An upload that cannot even be read still removes its limit. + * + * @throws Exception never + */ + @Test + void aFailedUploadRemovesItsLimit() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + doThrow(new FileUploadException("broken")).when(uploadService).createUploadResource(anyString(), eq(request)); + + assertThrows(UploadFileException.class, () -> service.uploadFile(uploadInfo(), request)); + + ArgumentCaptor uploadId = ArgumentCaptor.forClass(String.class); + verify(uploadService).addUploadLimit(uploadId.capture(), eq(0)); + verify(uploadService).removeUploadLimit(uploadId.getValue()); + } + + /** + * A saved recording is stamped with the call the upload token names, before + * the folder is saved. + * + * @throws Exception never + */ + @Test + void aSavedRecordingIsStampedWithItsCall() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + File stored = File.createTempFile("recording", ".mp4"); + stored.deleteOnExit(); + UploadResource resource = mock(UploadResource.class); + when(uploadService.getUploadResource(anyString())).thenReturn(resource); + when(resource.getStatus()).thenReturn(UploadResource.UPLOADED_STATUS); + when(resource.getFileName()).thenReturn(CALL_ID + "_2026-10-23-10-00-00.mp4"); + when(resource.getStoreLocation()).thenReturn(stored.getAbsolutePath()); + CallInfo call = spaceEventCall(); + doReturn(call).when(service).getCall(CALL_ID); + viewer(); + org.exoplatform.social.core.identity.model.Identity spaceIdentity = new org.exoplatform.social.core.identity.model.Identity("space", + "sales"); + when(identityManager.getIdentity("42")).thenReturn(spaceIdentity); + Space space = space(); + space.setPrettyName("sales"); + when(spaceService.getSpaceByPrettyName("sales")).thenReturn(space); + when(spaceService.isMember(space, VIEWER)).thenReturn(true); + ManageableRepository repository = mock(ManageableRepository.class, RETURNS_DEEP_STUBS); + when(repositoryService.getCurrentRepository()).thenReturn(repository); + when(repository.getConfiguration().getDefaultWorkspaceName()).thenReturn("collaboration"); + SessionProvider sessionProvider = mock(SessionProvider.class); + Session session = mock(Session.class); + when(sessionProviders.getSystemSessionProvider(null)).thenReturn(sessionProvider); + when(sessionProviders.getSessionProvider(any())).thenReturn(sessionProvider); + when(sessionProvider.getSession("collaboration", repository)).thenReturn(session); + when(nodeCreator.getJcrPath("groupsPath")).thenReturn("/Groups"); + Node spaceRoot = mock(Node.class); + Node documents = mock(Node.class); + Node recordingsFolder = mock(Node.class); + Node fileNode = mock(Node.class); + when(session.getItem("/Groups" + GROUP_ID)).thenReturn(spaceRoot); + when(spaceRoot.getNode("Documents")).thenReturn(documents); + when(documents.getPath()).thenReturn("/Groups" + GROUP_ID + "/Documents"); + when(session.getItem("/Groups" + GROUP_ID + "/Documents")).thenReturn(documents); + when(documents.hasNode("recordings")).thenReturn(true); + when(documents.getNode("recordings")).thenReturn(recordingsFolder); + when(recordingsFolder.isNodeType("nt:unstructured")).thenReturn(true); + when(recordingsFolder.addNode(CALL_ID + "_2026-10-23-10-00-00.mp4", "nt:file")).thenReturn(fileNode); + when(fileNode.addNode("jcr:content", "nt:resource")).thenReturn(mock(Node.class)); + + service.uploadFile(uploadInfo(), request); + + InOrder order = inOrder(recordingStorage, documents); + order.verify(recordingStorage).stamp(eq(fileNode), eq(CALL_ID), eq(call), any(UploadFileInfo.class), any(Calendar.class)); + order.verify(documents).save(); + } + + /** + * A malformed or negative setting falls back to its default. + */ + @Test + void aMalformedSettingFallsBackToItsDefault() { + PropertiesParam params = new PropertiesParam(); + params.setProperty("a", "-3"); + params.setProperty("b", "abc"); + params.setProperty("c", " 7 "); + assertEquals(12, WebConferencingService.readNonNegativeInt(params, "a", 12)); + assertEquals(12, WebConferencingService.readNonNegativeInt(params, "b", 12)); + assertEquals(7, WebConferencingService.readNonNegativeInt(params, "c", 12)); + assertEquals(12, WebConferencingService.readNonNegativeInt(params, "missing", 12)); + assertEquals(12, WebConferencingService.readNonNegativeInt(null, "a", 12)); + } + + /** + * A meeting link is read by the active provider that recognises it; an + * inactive provider is not asked. + */ + @Test + void aMeetingLinkIsReadByAnActiveProvider() { + CallProvider inactive = provider("other", false, CALL_ID); + CallProvider jitsi = provider("jitsi", true, CALL_ID); + service.providers.put("other", inactive); + service.providers.put("jitsi", jitsi); + + ConferenceCall conferenceCall = service.findCallByUrl("https://host/jitsi/meet/" + CALL_ID + "?inviteId=x"); + + assertEquals(new ConferenceCall("jitsi", CALL_ID), conferenceCall); + verify(inactive, never()).findCallId(anyString()); + } + + /** + * A link no active provider recognises reads as held on another provider. + */ + @Test + void aLinkNoProviderRecognisesIsAnotherProvider() { + service.providers.put("jitsi", provider("jitsi", true, null)); + + assertNull(service.findCallByUrl("https://teams.microsoft.com/l/meetup-join/x")); + ObjectNotFoundException e = assertThrows(ObjectNotFoundException.class, + () -> service.getCallRecordingsByConference("https://teams.microsoft.com/x", + VIEWER, + null, + null)); + assertEquals(WebConferencingService.RECORDINGS_OTHER_PROVIDER, e.getMessage()); + } + + /** + * A space-event call's recordings are listed for a member, in the space's + * folder, the grace added to the end of the range only. + * + * @throws Exception never + */ + @Test + void aMemberListsTheSpaceRecordingsWithTheGraceAdded() throws Exception { + Identity viewer = viewer(); + Space space = space(); + when(spaceService.isMember(space, VIEWER)).thenReturn(true); + doReturn(spaceEventCall()).when(service).getCall(CALL_ID); + doReturn("preview").when(service).buildDocumentPreviewUrl(anyString()); + List found = new ArrayList<>(List.of(new RecordingInfo("doc1", "t", new Date(), 1, "video/mp4"))); + Date from = Date.from(Instant.parse("2026-10-23T09:00:00Z")); + Date to = Date.from(Instant.parse("2026-10-23T10:00:00Z")); + when(recordingStorage.findRecordings(viewer, CALL_ID, GROUP_ID, from, Date.from(Instant.parse("2026-10-23T22:00:00Z")))) + .thenReturn(found); + + List recordings = service.getCallRecordings(CALL_ID, VIEWER, from, to); + + assertSame(found, recordings); + assertEquals("preview", recordings.get(0).getPreviewUrl()); + } + + /** + * With no range, nothing is added. + * + * @throws Exception never + */ + @Test + void noRangeStaysOpen() throws Exception { + Identity viewer = viewer(); + when(spaceService.isMember(space(), VIEWER)).thenReturn(true); + doReturn(spaceEventCall()).when(service).getCall(CALL_ID); + when(recordingStorage.findRecordings(viewer, CALL_ID, GROUP_ID, null, null)).thenReturn(new ArrayList<>()); + + assertEquals(List.of(), service.getCallRecordings(CALL_ID, VIEWER, null, null)); + } + + /** + * A non-member of the call's space, invited directly, is refused before + * any read, with the code the Meeting block shows. + * + * @throws Exception never + */ + @Test + void aNonMemberOfTheSpaceIsRefused() throws Exception { + viewer(); + Space space = space(); + when(spaceService.isMember(space, VIEWER)).thenReturn(false); + when(spaceService.isSuperManager(VIEWER)).thenReturn(false); + CallInfo call = spaceEventCall(); + call.addParticipant(new UserInfo(VIEWER, "John", "Smith")); + doReturn(call).when(service).getCall(CALL_ID); + + IllegalAccessException e = assertThrows(IllegalAccessException.class, + () -> service.getCallRecordings(CALL_ID, VIEWER, null, null)); + + assertEquals(WebConferencingService.RECORDINGS_NOT_SPACE_MEMBER, e.getMessage()); + verify(recordingStorage, never()).findRecordings(any(), anyString(), any(), any(), any()); + } + + /** + * A platform space administrator is let through to the repository, whose + * permissions still decide. + * + * @throws Exception never + */ + @Test + void aSpaceAdministratorIsLetThrough() throws Exception { + Identity viewer = viewer(); + Space space = space(); + when(spaceService.isMember(space, VIEWER)).thenReturn(false); + when(spaceService.isSuperManager(VIEWER)).thenReturn(true); + doReturn(spaceEventCall()).when(service).getCall(CALL_ID); + when(recordingStorage.findRecordings(viewer, CALL_ID, GROUP_ID, null, null)).thenReturn(new ArrayList<>()); + + assertEquals(List.of(), service.getCallRecordings(CALL_ID, VIEWER, null, null)); + } + + /** + * A space call (not an event's) is checked against its space as well. + * + * @throws Exception never + */ + @Test + void aSpaceCallIsCheckedAgainstItsSpace() throws Exception { + viewer(); + Space space = space(); + when(spaceService.isMember(space, VIEWER)).thenReturn(false); + WebConferencingService.SpaceInfo owner = mock(WebConferencingService.SpaceInfo.class); + when(owner.getGroupId()).thenReturn(GROUP_ID); + doReturn(new CallInfo(CALL_ID, "Sales", owner, "jitsi")).when(service).getCall(CALL_ID); + + assertThrows(IllegalAccessException.class, () -> service.getCallRecordings(CALL_ID, VIEWER, null, null)); + } + + /** + * A user or chat-room call's recordings are listed for a participant, in + * the viewer's own folder, and refused to anyone else. + * + * @throws Exception never + */ + @Test + void aUserCallIsListedForItsParticipantsOnly() throws Exception { + Identity viewer = viewer(); + UserInfo owner = new UserInfo("mary", "Mary", "Doe"); + CallInfo call = new CallInfo("p_john-mary", "John and Mary", owner, "jitsi"); + call.addParticipant(new UserInfo(VIEWER, "John", "Smith")); + doReturn(call).when(service).getCall("p_john-mary"); + when(recordingStorage.findRecordings(eq(viewer), eq("p_john-mary"), isNull(), isNull(), isNull())).thenReturn(new ArrayList<>()); + + assertEquals(List.of(), service.getCallRecordings("p_john-mary", VIEWER, null, null)); + + CallInfo strangers = new CallInfo("p_ann-mary", "Ann and Mary", owner, "jitsi"); + strangers.addParticipant(new UserInfo("ann", "Ann", "Lee")); + doReturn(strangers).when(service).getCall("p_ann-mary"); + IllegalAccessException e = assertThrows(IllegalAccessException.class, + () -> service.getCallRecordings("p_ann-mary", VIEWER, null, null)); + assertEquals(WebConferencingService.RECORDINGS_NOT_PARTICIPANT, e.getMessage()); + } + + /** + * The owner of a call is let through even when not listed as a + * participant. + * + * @throws Exception never + */ + @Test + void theOwnerOfAUserCallIsLetThrough() throws Exception { + Identity viewer = viewer(); + CallInfo call = new CallInfo("p_john-mary", "John and Mary", new UserInfo(VIEWER, "John", "Smith"), "jitsi"); + doReturn(call).when(service).getCall("p_john-mary"); + when(recordingStorage.findRecordings(eq(viewer), eq("p_john-mary"), isNull(), isNull(), isNull())).thenReturn(new ArrayList<>()); + + assertEquals(List.of(), service.getCallRecordings("p_john-mary", VIEWER, null, null)); + } + + /** + * An unknown call, an unknown viewer, an invalid id and an inverted range + * are refused with their codes. + * + * @throws Exception never + */ + @Test + void refusalsCarryTheirCodes() throws Exception { + viewer(); + doReturn(null).when(service).getCall(CALL_ID); + assertEquals(WebConferencingService.RECORDINGS_CALL_NOT_FOUND, + assertThrows(ObjectNotFoundException.class, + () -> service.getCallRecordings(CALL_ID, VIEWER, null, null)).getMessage()); + + doThrow(new InvalidCallException("broken")).when(service).getCall("broken"); + assertThrows(ObjectNotFoundException.class, () -> service.getCallRecordings("broken", VIEWER, null, null)); + + assertEquals(WebConferencingService.RECORDINGS_NO_IDENTITY, + assertThrows(IllegalAccessException.class, + () -> service.getCallRecordings(CALL_ID, null, null, null)).getMessage()); + + assertEquals(WebConferencingService.RECORDINGS_INVALID_CALL_ID, + assertThrows(IllegalArgumentException.class, + () -> service.getCallRecordings("", VIEWER, null, null)).getMessage()); + + Date later = Date.from(Instant.parse("2026-10-24T00:00:00Z")); + Date earlier = Date.from(Instant.parse("2026-10-23T00:00:00Z")); + assertEquals(WebConferencingService.RECORDINGS_INVALID_RANGE, + assertThrows(IllegalArgumentException.class, + () -> service.getCallRecordings(CALL_ID, VIEWER, later, earlier)).getMessage()); + } + + /** + * Uploads a recording that the platform refuses and checks the limit the + * upload registered, removed once done, and before the upload is read. + * + * @param webConferencing the service + * @param expectedLimit the limit expected + * @throws Exception never + */ + private void uploadAndCheckLimit(WebConferencingService webConferencing, int expectedLimit) throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + UploadResource resource = mock(UploadResource.class); + when(uploadService.getUploadResource(anyString())).thenReturn(resource); + when(resource.getStatus()).thenReturn(UploadResource.FAILED_STATUS); + doReturn(new CallInfo(CALL_ID, "ACME demo", new UserInfo(VIEWER, "John", "Smith"), "jitsi")).when(webConferencing) + .getCall(CALL_ID); + + webConferencing.uploadFile(uploadInfo(), request); + + ArgumentCaptor uploadId = ArgumentCaptor.forClass(String.class); + InOrder order = inOrder(uploadService); + order.verify(uploadService).addUploadLimit(uploadId.capture(), eq(expectedLimit)); + order.verify(uploadService).createUploadResource(uploadId.getValue(), request); + order.verify(uploadService).removeUploadLimit(uploadId.getValue()); + } + + /** + * Builds a service with its recording settings. + * + * @param uploadLimit the upload limit setting, or null + * @param graceHours the grace setting, or null + * @return the service + */ + private WebConferencingService newService(String uploadLimit, String graceHours) { + InitParams params = new InitParams(); + PropertiesParam jwt = new PropertiesParam(); + jwt.setName("jwt-configuration"); + jwt.setProperty("secret-key", "secret"); + params.addParameter(jwt); + PropertiesParam recording = new PropertiesParam(); + recording.setName("recording-configuration"); + if (uploadLimit != null) { + recording.setProperty("upload-limit-mb", uploadLimit); + } + if (graceHours != null) { + recording.setProperty("occurrence-grace-hours", graceHours); + } + params.addParameter(recording); + WebConferencingService webConferencing = new WebConferencingService(mock(OrganizationService.class), + identityManager, + listenerService, + mock(SettingService.class), + mock(CallDAO.class), + mock(ParticipantDAO.class), + mock(OriginDAO.class), + mock(InviteDAO.class), + uploadService, + repositoryService, + sessionProviders, + nodeCreator, + identityRegistry, + mock(Authenticator.class), + mock(ShareDocumentService.class), + params, + mock(LinkManager.class), + mock(CodecInitializer.class)); + ReflectionTestUtils.setField(webConferencing, "recordingStorage", recordingStorage); + ReflectionTestUtils.setField(webConferencing, "spaceService", spaceService); + return webConferencing; + } + + /** + * Registers the viewer's security identity. + * + * @return the identity + */ + private Identity viewer() { + Identity viewer = new Identity(VIEWER); + lenient().when(identityRegistry.getIdentity(VIEWER)).thenReturn(viewer); + return viewer; + } + + /** + * The space owning the space-event call. + * + * @return the space + */ + private Space space() { + Space space = new Space(); + space.setGroupId(GROUP_ID); + when(spaceService.getSpaceByGroupId(GROUP_ID)).thenReturn(space); + return space; + } + + /** + * A space-event call of the sales space. + * + * @return the call + */ + private CallInfo spaceEventCall() { + WebConferencingService.SpaceEventInfo owner = mock(WebConferencingService.SpaceEventInfo.class); + lenient().when(owner.getGroupId()).thenReturn(GROUP_ID); + return new CallInfo(CALL_ID, "ACME demo", owner, "jitsi"); + } + + /** + * A provider of a type, active or not, reading a given call id. + * + * @param type the type + * @param active whether it is active + * @param callId the call id it reads out of any link + * @return the provider + */ + private CallProvider provider(String type, boolean active, String callId) { + CallProvider provider = mock(CallProvider.class); + lenient().when(provider.getType()).thenReturn(type); + lenient().when(provider.isActive()).thenReturn(active); + lenient().when(provider.findCallId(anyString())).thenReturn(callId); + doReturn(provider).when(service).getProvider(type); + return provider; + } + + /** + * The upload info of a space-event recording. + * + * @return the upload info + */ + private static UploadFileInfo uploadInfo() { + return new UploadFileInfo(CALL_ID, "42", "space_event", VIEWER, List.of(VIEWER)); + } + +} diff --git a/services/src/test/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpToolTest.java b/services/src/test/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpToolTest.java new file mode 100644 index 000000000..7af3198ab --- /dev/null +++ b/services/src/test/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpToolTest.java @@ -0,0 +1,255 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.exoplatform.commons.exception.ObjectNotFoundException; +import org.exoplatform.services.security.ConversationState; +import org.exoplatform.services.security.Identity; +import org.exoplatform.webconferencing.WebConferencingService; +import org.exoplatform.webconferencing.mcp.model.MeetingRecordingsModel; +import org.exoplatform.webconferencing.model.RecordingInfo; + +import tools.jackson.databind.ObjectMapper; + +/** + * The {@code get_meeting_recordings} tool: the acting user comes from the + * authenticated request, an empty answer carries its reason, and the tool is + * declared as the read it is. + */ +@ExtendWith(MockitoExtension.class) +class MeetingRecordingMcpToolTest { + + private static final String URL = "https://host/jitsi/meet/g_42-abc?inviteId=x"; + + private static final String DEFINITIONS = "ai-tool-definitions.json"; + + @Mock + private WebConferencingService webConferencingService; + + private MeetingRecordingMcpTool tool; + + /** + * The tool, with an authenticated request of john. + */ + @BeforeEach + void setUp() { + tool = new MeetingRecordingMcpTool(webConferencingService); + ConversationState.setCurrent(new ConversationState(new Identity("john"))); + } + + /** + * Cleans the request's identity. + */ + @AfterEach + void tearDown() { + ConversationState.setCurrent(null); + } + + /** + * The recordings are listed with the authenticated user's rights and the + * range read from the arguments; a bare date ends at the end of that day. + * + * @throws Exception never + */ + @Test + void listsTheRecordingsAsTheAuthenticatedUser() throws Exception { + RecordingInfo recording = new RecordingInfo("doc1", "ACME demo – 2026-10-23 10:05", + Date.from(Instant.parse("2026-10-23T10:05:00Z")), 42, "video/mp4"); + recording.setPreviewUrl("https://host/portal/dw/documents?documentPreviewId=doc1"); + when(webConferencingService.getCallRecordingsByConference(URL, + "john", + Date.from(Instant.parse("2026-10-23T09:00:00Z")), + Date.from(Instant.parse("2026-10-23T23:59:59.999Z")))) + .thenReturn(List.of(recording)); + + MeetingRecordingsModel answer = tool.getMeetingRecordings(URL, "2026-10-23T09:00:00Z", "2026-10-23"); + + assertNull(answer.reason()); + assertEquals(1, answer.recordings().size()); + assertEquals("doc1", answer.recordings().get(0).documentId()); + assertEquals("2026-10-23T10:05:00Z", answer.recordings().get(0).recordedDate()); + assertEquals("https://host/portal/dw/documents?documentPreviewId=doc1", answer.recordings().get(0).previewUrl()); + } + + /** + * A meeting without a recording answers NO_RECORDING. + * + * @throws Exception never + */ + @Test + void anEmptyListSaysNoRecording() throws Exception { + when(webConferencingService.getCallRecordingsByConference(eq(URL), eq("john"), isNull(), isNull())).thenReturn(List.of()); + + MeetingRecordingsModel answer = tool.getMeetingRecordings(URL, null, null); + + assertTrue(answer.recordings().isEmpty()); + assertEquals(MeetingRecordingMcpTool.NO_RECORDING, answer.reason()); + } + + /** + * A link of another provider answers OTHER_PROVIDER, an unknown call + * NO_RECORDING. + * + * @throws Exception never + */ + @Test + void anUnknownLinkOrCallSaysWhy() throws Exception { + when(webConferencingService.getCallRecordingsByConference(eq("https://teams/x"), anyString(), any(), any())) + .thenThrow(new ObjectNotFoundException(WebConferencingService.RECORDINGS_OTHER_PROVIDER)); + when(webConferencingService.getCallRecordingsByConference(eq(URL), anyString(), any(), any())) + .thenThrow(new ObjectNotFoundException(WebConferencingService.RECORDINGS_CALL_NOT_FOUND)); + + assertEquals(MeetingRecordingMcpTool.OTHER_PROVIDER, tool.getMeetingRecordings("https://teams/x", null, null).reason()); + assertEquals(MeetingRecordingMcpTool.NO_RECORDING, tool.getMeetingRecordings(URL, null, null).reason()); + } + + /** + * A user who may not read the meeting's recordings is refused, never told + * there are none. + * + * @throws Exception never + */ + @Test + void aRefusalIsNotAnEmptyList() throws Exception { + when(webConferencingService.getCallRecordingsByConference(eq(URL), eq("john"), any(), any())) + .thenThrow(new IllegalAccessException(WebConferencingService.RECORDINGS_NOT_SPACE_MEMBER)); + + assertThrows(IllegalAccessException.class, () -> tool.getMeetingRecordings(URL, null, null)); + } + + /** + * A missing link or an unreadable date is refused before any lookup. + * + * @throws Exception never + */ + @Test + void invalidArgumentsAreRefused() throws Exception { + assertThrows(IllegalArgumentException.class, () -> tool.getMeetingRecordings(" ", null, null)); + assertThrows(IllegalArgumentException.class, () -> tool.getMeetingRecordings(URL, "yesterday", null)); + verify(webConferencingService, never()).getCallRecordingsByConference(anyString(), anyString(), any(), any()); + } + + /** + * Every public method of the tool has an entry in the definitions file, and + * every entry a method: the server drops a tool without one silently. + */ + @Test + void everyToolHasADefinitionAndEveryDefinitionATool() { + Set declared = Stream.of(MeetingRecordingMcpTool.class.getDeclaredMethods()) + .filter(method -> Modifier.isPublic(method.getModifiers())) + .filter(method -> !method.isSynthetic()) + .map(Method::getName) + .map(MeetingRecordingMcpToolTest::toSnakeCase) + .collect(Collectors.toCollection(TreeSet::new)); + Set defined = definitions().stream() + .map(definition -> (String) definition.get("name")) + .collect(Collectors.toCollection(TreeSet::new)); + assertEquals(declared, defined); + } + + /** + * The tool is a read: no approval, read-only annotations, and its arguments + * are the method's, the link mandatory. + */ + @Test + void theToolIsDeclaredAsARead() { + Map definition = definitions().stream() + .filter(tool -> "get_meeting_recordings".equals(tool.get("name"))) + .findFirst() + .orElseThrow(); + @SuppressWarnings("unchecked") + Map annotations = (Map) definition.get("annotations"); + assertEquals(Boolean.TRUE, annotations.get("readOnlyHint")); + assertEquals(Boolean.FALSE, annotations.get("destructiveHint")); + assertTrue(definition.get("require_approval") == null || Boolean.FALSE.equals(definition.get("require_approval"))); + @SuppressWarnings("unchecked") + Map schema = (Map) definition.get("input_schema"); + @SuppressWarnings("unchecked") + Map properties = (Map) schema.get("properties"); + assertEquals(Set.of("conference_url", "recorded_from", "recorded_to"), properties.keySet()); + assertEquals(List.of("conference_url"), schema.get("required")); + } + + /** + * The parameter names the server binds the arguments to survive + * compilation: the module compiles with {@code -parameters}. + * + * @throws Exception never + */ + @Test + void parameterNamesAreKept() throws Exception { + Method method = MeetingRecordingMcpTool.class.getMethod("getMeetingRecordings", String.class, String.class, String.class); + assertEquals(List.of("conferenceUrl", "recordedFrom", "recordedTo"), + Stream.of(method.getParameters()).map(java.lang.reflect.Parameter::getName).toList()); + } + + /** + * Reads this add-on's definitions file. + * + * @return the tool entries + */ + @SuppressWarnings("unchecked") + private static List> definitions() { + try (var input = MeetingRecordingMcpToolTest.class.getClassLoader().getResourceAsStream(DEFINITIONS)) { + Map root = new ObjectMapper().readValue(input, Map.class); + return (List>) root.get("tools"); + } catch (java.io.IOException e) { + throw new IllegalStateException(e); + } + } + + /** + * The snake-case name the server gives a tool method. + * + * @param name the method name + * @return its snake-case form + */ + private static String toSnakeCase(String name) { + return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(); + } + +} diff --git a/services/src/test/java/org/exoplatform/webconferencing/rest/CallRecordingRestTest.java b/services/src/test/java/org/exoplatform/webconferencing/rest/CallRecordingRestTest.java new file mode 100644 index 000000000..cdda1c3ff --- /dev/null +++ b/services/src/test/java/org/exoplatform/webconferencing/rest/CallRecordingRestTest.java @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing.rest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +import org.exoplatform.commons.exception.ObjectNotFoundException; +import org.exoplatform.webconferencing.WebConferencingService; +import org.exoplatform.webconferencing.model.RecordingInfo; +import org.exoplatform.webconferencing.rest.model.RecordingEntity; +import org.exoplatform.webconferencing.util.RecordingDateUtils; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * The recordings REST: the viewer is the authenticated request's, and each + * refusal maps to its status with its message code, never an engine's text. + */ +@ExtendWith(MockitoExtension.class) +class CallRecordingRestTest { + + private static final String CALL_ID = "g_42-abc"; + + @Mock + private WebConferencingService webConferencingService; + + @Mock + private HttpServletRequest request; + + private CallRecordingRest rest; + + /** + * The resource, with an authenticated request of john. + */ + @BeforeEach + void setUp() { + rest = new CallRecordingRest(webConferencingService); + when(request.getRemoteUser()).thenReturn("john"); + } + + /** + * The list is answered newest first as the service gives it, never cached. + * + * @throws Exception never + */ + @Test + void answersTheRecordingsOfTheAuthenticatedUser() throws Exception { + RecordingInfo recording = new RecordingInfo("doc1", "ACME demo", Date.from(Instant.parse("2026-10-23T10:05:00Z")), 42, + "video/mp4"); + recording.setPreviewUrl("preview"); + when(webConferencingService.getCallRecordings(CALL_ID, + "john", + Date.from(Instant.parse("2026-10-23T09:00:00Z")), + Date.from(Instant.parse("2026-10-23T10:00:00Z")))) + .thenReturn(List.of(recording)); + + ResponseEntity response = rest.getCallRecordings(request, CALL_ID, "2026-10-23T09:00:00Z", "2026-10-23T11:00:00+01:00"); + + assertEquals(200, response.getStatusCode().value()); + assertEquals(List.of(new RecordingEntity("doc1", "ACME demo", "2026-10-23T10:05:00Z", 42, "video/mp4", "preview")), + response.getBody()); + assertEquals("no-store", response.getHeaders().getCacheControl()); + } + + /** + * The lookup by meeting link answers the same list. + * + * @throws Exception never + */ + @Test + void answersTheRecordingsOfAMeetingLink() throws Exception { + when(webConferencingService.getCallRecordingsByConference(eq("https://host/jitsi/meet/" + CALL_ID), eq("john"), isNull(), isNull())) + .thenReturn(List.of()); + + ResponseEntity response = rest.getRecordingsByConference(request, "https://host/jitsi/meet/" + CALL_ID, null, null); + + assertEquals(200, response.getStatusCode().value()); + assertEquals(List.of(), response.getBody()); + } + + /** + * An unknown call or another provider is a 404, a refused viewer a 403, + * each with its code. + * + * @throws Exception never + */ + @Test + void refusalsMapToTheirStatus() throws Exception { + when(webConferencingService.getCallRecordings(eq("unknown"), anyString(), any(), any())) + .thenThrow(new ObjectNotFoundException(WebConferencingService.RECORDINGS_CALL_NOT_FOUND)); + when(webConferencingService.getCallRecordings(eq(CALL_ID), anyString(), any(), any())) + .thenThrow(new IllegalAccessException(WebConferencingService.RECORDINGS_NOT_SPACE_MEMBER)); + when(webConferencingService.getCallRecordingsByConference(eq("https://teams/x"), anyString(), any(), any())) + .thenThrow(new ObjectNotFoundException(WebConferencingService.RECORDINGS_OTHER_PROVIDER)); + + assertRefusal(rest.getCallRecordings(request, "unknown", null, null), 404, WebConferencingService.RECORDINGS_CALL_NOT_FOUND); + assertRefusal(rest.getCallRecordings(request, CALL_ID, null, null), 403, WebConferencingService.RECORDINGS_NOT_SPACE_MEMBER); + assertRefusal(rest.getRecordingsByConference(request, "https://teams/x", null, null), + 404, + WebConferencingService.RECORDINGS_OTHER_PROVIDER); + } + + /** + * An unreadable date is a 400 with its code, before any lookup; an + * argument refused with a text that is not a code answers the generic code. + * + * @throws Exception never + */ + @Test + void badRequestsCarryACode() throws Exception { + assertRefusal(rest.getCallRecordings(request, CALL_ID, "yesterday", null), 400, RecordingDateUtils.RECORDINGS_INVALID_DATE); + verify(webConferencingService, never()).getCallRecordings(anyString(), anyString(), any(), any()); + + when(webConferencingService.getCallRecordings(eq("x"), anyString(), any(), any())) + .thenThrow(new IllegalArgumentException("/Groups/spaces/sales is broken")); + assertRefusal(rest.getCallRecordings(request, "x", null, null), 400, CallRecordingRest.INVALID_REQUEST); + } + + /** + * A repository failure is a 500 with a fixed code. + * + * @throws Exception never + */ + @Test + void aStorageFailureIsAFixedServerError() throws Exception { + when(webConferencingService.getCallRecordings(eq(CALL_ID), anyString(), any(), any())) + .thenThrow(new IllegalStateException(WebConferencingService.RECORDINGS_STORAGE_ERROR)); + + assertRefusal(rest.getCallRecordings(request, CALL_ID, null, null), 500, WebConferencingService.RECORDINGS_STORAGE_ERROR); + } + + /** + * Checks a refusal's status and code. + * + * @param response the response + * @param status the expected status + * @param code the expected message code + */ + private static void assertRefusal(ResponseEntity response, int status, String code) { + assertEquals(status, response.getStatusCode().value()); + assertEquals(Map.of("message", code), response.getBody()); + } + +} diff --git a/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java b/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java new file mode 100644 index 000000000..595f349ce --- /dev/null +++ b/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java @@ -0,0 +1,443 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing.storage; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.Iterator; +import java.util.List; +import java.util.TimeZone; + +import javax.jcr.Node; +import javax.jcr.NodeIterator; +import javax.jcr.PathNotFoundException; +import javax.jcr.Property; +import javax.jcr.Session; +import javax.jcr.Workspace; +import javax.jcr.nodetype.NoSuchNodeTypeException; +import javax.jcr.query.Query; +import javax.jcr.query.QueryManager; +import javax.jcr.query.QueryResult; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.exoplatform.services.cms.link.LinkManager; +import org.exoplatform.services.jcr.RepositoryService; +import org.exoplatform.services.jcr.core.ExtendedNode; +import org.exoplatform.services.jcr.ext.common.SessionProvider; +import org.exoplatform.services.jcr.ext.hierarchy.NodeHierarchyCreator; +import org.exoplatform.services.jcr.impl.core.LocationFactory; +import org.exoplatform.services.jcr.impl.core.NamespaceRegistryImpl; +import org.exoplatform.services.jcr.impl.core.query.DefaultQueryNodeFactory; +import org.exoplatform.services.jcr.impl.core.query.sql.JCRSQLQueryBuilder; +import org.exoplatform.webconferencing.CallInfo; +import org.exoplatform.webconferencing.IdentityInfo; +import org.exoplatform.webconferencing.UploadFileInfo; +import org.exoplatform.webconferencing.model.RecordingInfo; + +@ExtendWith(MockitoExtension.class) +class CallRecordingStorageTest { + + private static final String CALL_ID = "g_42-0b6e1f0c9d3a4e2f"; + + private static final String SPACE_GROUP_ID = "/spaces/sales"; + + private static final String RECORDINGS_PATH = "/Groups/spaces/sales/Documents/recordings"; + + @Mock + private RepositoryService repositoryService; + + @Mock + private NodeHierarchyCreator nodeCreator; + + @Mock + private LinkManager linkManager; + + @Mock + private SessionProvider sessionProvider; + + @Mock + private Session session; + + @Mock + private QueryManager queryManager; + + @Mock + private Query query; + + private CallRecordingStorage storage; + + /** + * A storage over mocked JCR collaborators, its query manager answering the + * stamped-recordings query. + * + * @throws Exception never + */ + @BeforeEach + void setUp() throws Exception { + storage = new CallRecordingStorage(repositoryService, nodeCreator, linkManager); + Workspace workspace = mock(Workspace.class); + lenient().when(session.getWorkspace()).thenReturn(workspace); + lenient().when(workspace.getQueryManager()).thenReturn(queryManager); + lenient().when(queryManager.createQuery(anyString(), eq(Query.SQL))).thenReturn(query); + lenient().when(nodeCreator.getJcrPath("groupsPath")).thenReturn("/Groups"); + } + + /** + * The statement must be accepted by the repository's own JCR-SQL parser, + * with and without a range, and with a quote in the id. + * + * @throws Exception when the parser refuses it + */ + @Test + void theQueryIsParsedByTheRepositoryEngine() throws Exception { + Date from = Date.from(Instant.parse("2026-10-23T09:00:00Z")); + Date to = Date.from(Instant.parse("2026-10-23T22:00:00Z")); + for (String statement : List.of(CallRecordingStorage.buildRecordingsQuery(CALL_ID, null, null), + CallRecordingStorage.buildRecordingsQuery(CALL_ID, from, to), + CallRecordingStorage.buildRecordingsQuery("it's", from, null))) { + assertNotNull(JCRSQLQueryBuilder.createQuery(statement, + new LocationFactory(new NamespaceRegistryImpl()), + new DefaultQueryNodeFactory()), + statement); + } + } + + /** + * The statement selects the mixin by call id, bounds the recording date + * from below and from above with the right operators, and sorts newest + * first; a quote in the id is doubled, never left to close the literal. + */ + @Test + void theQuerySelectsTheCallWithinTheRange() { + Date from = Date.from(Instant.parse("2026-10-23T09:00:00Z")); + Date to = Date.from(Instant.parse("2026-10-23T22:00:00Z")); + String statement = CallRecordingStorage.buildRecordingsQuery(CALL_ID, from, to); + assertTrue(statement.startsWith("SELECT * FROM exo:callRecording WHERE exo:callId = '" + CALL_ID + "'"), statement); + assertTrue(statement.contains(" AND exo:recordedDate >= TIMESTAMP '"), statement); + assertTrue(statement.contains(" AND exo:recordedDate <= TIMESTAMP '"), statement); + assertTrue(statement.indexOf(">=") < statement.indexOf("<="), "the lower bound is the from date: " + statement); + assertTrue(statement.endsWith(" ORDER BY exo:recordedDate DESC"), statement); + String unbounded = CallRecordingStorage.buildRecordingsQuery(CALL_ID, null, null); + assertFalse(unbounded.contains("exo:recordedDate >="), unbounded); + assertFalse(unbounded.contains("exo:recordedDate <="), unbounded); + assertTrue(CallRecordingStorage.buildRecordingsQuery("it's", null, null).contains("exo:callId = 'it''s'")); + } + + /** + * A Jibri file name is the call id, an underscore and a timestamp: the + * match is on the whole id, so a call whose id prefixes another's does not + * pick the other's recordings. + */ + @Test + void aFileNameMatchesItsWholeCallIdOnly() { + assertTrue(CallRecordingStorage.isRecordingFileOf("g_sales_2026-10-23-10-00-00.mp4", "g_sales")); + assertTrue(CallRecordingStorage.isRecordingFileOf(CALL_ID + "_2026-10-23-10-00-00.mp4", CALL_ID)); + assertTrue(CallRecordingStorage.isRecordingFileOf("g_sales_2026-10-23-10-00-00", "g_sales")); + assertFalse(CallRecordingStorage.isRecordingFileOf("g_sales_team_2026-10-23-10-00-00.mp4", "g_sales")); + assertFalse(CallRecordingStorage.isRecordingFileOf("g_sales.mp4", "g_sales")); + assertFalse(CallRecordingStorage.isRecordingFileOf("other_2026.mp4", "g_sales")); + assertFalse(CallRecordingStorage.isRecordingFileOf(null, "g_sales")); + } + + /** + * The displayed title is the call's title and the recording's date; a call + * with no title keeps the file's name. + */ + @Test + void theTitleNamesTheMeetingAndItsDate() { + Calendar recordedDate = new GregorianCalendar(TimeZone.getDefault()); + recordedDate.set(2026, Calendar.OCTOBER, 23, 10, 5, 0); + assertEquals("ACME demo – 2026-10-23 10:05", CallRecordingStorage.recordingTitle(" ACME demo ", recordedDate)); + assertNull(CallRecordingStorage.recordingTitle(" ", recordedDate)); + assertNull(CallRecordingStorage.recordingTitle(null, recordedDate)); + } + + /** + * A saved recording records its call, provider, owner, title, start and + * recording date, and shows the meeting's title. + * + * @throws Exception never + */ + @Test + void stampRecordsTheCall() throws Exception { + Node fileNode = mock(Node.class); + IdentityInfo owner = mock(IdentityInfo.class); + when(owner.getType()).thenReturn("space_event"); + when(owner.getId()).thenReturn("42"); + CallInfo call = new CallInfo(CALL_ID, "ACME demo", owner, "jitsi"); + Date start = Date.from(Instant.parse("2026-10-23T09:00:00Z")); + call.setStartDate(start); + Calendar recordedDate = new GregorianCalendar(); + + assertTrue(storage.stamp(fileNode, CALL_ID, call, uploadInfo(), recordedDate)); + + verify(fileNode).addMixin("exo:callRecording"); + verify(fileNode).setProperty("exo:callId", CALL_ID); + verify(fileNode).setProperty("exo:callProvider", "jitsi"); + verify(fileNode).setProperty("exo:callOwnerType", "space_event"); + verify(fileNode).setProperty("exo:callOwnerId", "42"); + verify(fileNode).setProperty("exo:callTitle", "ACME demo"); + verify(fileNode).setProperty(eq("exo:callStartDate"), any(Calendar.class)); + verify(fileNode).setProperty("exo:recordedDate", recordedDate); + verify(fileNode).setProperty("exo:title", CallRecordingStorage.recordingTitle("ACME demo", recordedDate)); + } + + /** + * When the call cannot be read, the stamp still records the call id and the + * owner the upload token names, and keeps the file's own title. + * + * @throws Exception never + */ + @Test + void stampWithoutTheCallUsesTheUploadToken() throws Exception { + Node fileNode = mock(Node.class); + Calendar recordedDate = new GregorianCalendar(); + + assertTrue(storage.stamp(fileNode, CALL_ID, null, uploadInfo(), recordedDate)); + + verify(fileNode).setProperty("exo:callId", CALL_ID); + verify(fileNode).setProperty("exo:callOwnerType", "space_event"); + verify(fileNode).setProperty("exo:callOwnerId", "42"); + verify(fileNode).setProperty("exo:recordedDate", recordedDate); + verify(fileNode, never()).setProperty(eq("exo:title"), anyString()); + verify(fileNode, never()).setProperty(eq("exo:callProvider"), anyString()); + } + + /** + * A repository without the mixin must not lose the recording: the stamp + * fails quietly and the caller saves the file. + * + * @throws Exception never + */ + @Test + void aStampFailureNeverThrows() throws Exception { + Node fileNode = mock(Node.class); + doThrow(new NoSuchNodeTypeException("exo:callRecording")).when(fileNode).addMixin("exo:callRecording"); + + assertFalse(storage.stamp(fileNode, CALL_ID, null, uploadInfo(), new GregorianCalendar())); + } + + /** + * Stamped recordings come from the query, the trashed one left out; the + * folder adds the unstamped ones of a series recorded before the stamp, + * never a stamped one a second time; all newest first. + * + * @throws Exception never + */ + @Test + void stampedAndOldRecordingsAreMergedOnce() throws Exception { + ExtendedNode older = file("id-older", CALL_ID + "_2026-10-22-10-00-00.mp4", "2026-10-22T10:00:00Z", true, false); + ExtendedNode newer = file("id-newer", CALL_ID + "_2026-10-23-10-00-00.mp4", "2026-10-23T10:00:00Z", true, false); + ExtendedNode trashed = file("id-trashed", "trashed.mp4", "2026-10-24T10:00:00Z", true, true); + queryAnswers(older, trashed, newer); + ExtendedNode old = file("id-old", CALL_ID + "_2026-10-01-10-00-00.mp4", "2026-10-01T11:00:00Z", false, false); + Node folder = mock(Node.class); + when(session.getItem(RECORDINGS_PATH)).thenReturn(folder); + NodeIterator children = iterator(older, old, newer); + when(folder.getNodes(CALL_ID + "_*")).thenReturn(children); + + List recordings = storage.findRecordings(sessionProvider, session, "john", CALL_ID, SPACE_GROUP_ID, null, null); + + assertEquals(List.of("id-newer", "id-older", "id-old"), recordings.stream().map(RecordingInfo::getDocumentId).toList()); + assertEquals(CALL_ID + "_2026-10-23-10-00-00.mp4 title", recordings.get(0).getTitle()); + assertEquals(1234L, recordings.get(0).getSizeBytes()); + assertEquals("video/mp4", recordings.get(0).getMimeType()); + } + + /** + * With no stamped recording, the space's recordings folder is listed for + * the files of that call only, within the range, links resolved. + * + * @throws Exception never + */ + @Test + void oldRecordingsAreFoundByTheirFileName() throws Exception { + queryAnswers(); + Node folder = mock(Node.class); + when(session.getItem(RECORDINGS_PATH)).thenReturn(folder); + ExtendedNode inRange = file("id-in", CALL_ID + "_2026-10-23-10-00-00.mp4", "2026-10-23T11:00:00Z", false, false); + ExtendedNode outOfRange = file("id-out", CALL_ID + "_2026-10-20-10-00-00.mp4", "2026-10-20T11:00:00Z", false, false); + ExtendedNode otherCall = file("id-other", CALL_ID + "x_2026-10-23-10-00-00.mp4", "2026-10-23T11:00:00Z", false, false); + Node link = mock(Node.class); + when(link.getName()).thenReturn(CALL_ID + "_2026-10-23-12-00-00.mp4"); + when(link.isNodeType("exo:symlink")).thenReturn(true); + ExtendedNode linked = file("id-linked", "target.mp4", "2026-10-23T13:00:00Z", false, false); + when(linkManager.getTarget(link)).thenReturn(linked); + NodeIterator children = iterator(inRange, outOfRange, otherCall, link); + when(folder.getNodes(CALL_ID + "_*")).thenReturn(children); + + List recordings = storage.findRecordings(sessionProvider, + session, + "john", + CALL_ID, + SPACE_GROUP_ID, + Date.from(Instant.parse("2026-10-23T09:00:00Z")), + Date.from(Instant.parse("2026-10-23T23:00:00Z"))); + + assertEquals(List.of("id-linked", "id-in"), recordings.stream().map(RecordingInfo::getDocumentId).toList()); + } + + /** + * A user or chat-room call's old recordings are in the viewer's own private + * recordings folder, where the shared ones arrive as links. + * + * @throws Exception never + */ + @Test + void oldRecordingsOfAUserCallAreInTheViewersPrivateFolder() throws Exception { + queryAnswers(); + Node userNode = mock(Node.class); + Node privateNode = mock(Node.class); + Node folder = mock(Node.class); + when(nodeCreator.getUserNode(sessionProvider, "john")).thenReturn(userNode); + when(nodeCreator.getJcrPath("userPrivate")).thenReturn("Private"); + when(userNode.getNode("Private")).thenReturn(privateNode); + when(privateNode.getNode("recordings")).thenReturn(folder); + ExtendedNode recording = file("id-p2p", "p_john-mary_2026-10-23-10-00-00.mp4", "2026-10-23T11:00:00Z", false, false); + NodeIterator children = iterator(recording); + when(folder.getNodes("p_john-mary_*")).thenReturn(children); + + List recordings = storage.findRecordings(sessionProvider, session, "john", "p_john-mary", null, null, null); + + assertEquals(List.of("id-p2p"), recordings.stream().map(RecordingInfo::getDocumentId).toList()); + } + + /** + * A missing or unreadable folder, or an id that is not a safe name pattern, + * answers no recording rather than an error. + * + * @throws Exception never + */ + @Test + void noFolderOrAnUnsafeIdAnswersNothing() throws Exception { + queryAnswers(); + when(session.getItem(RECORDINGS_PATH)).thenThrow(new PathNotFoundException(RECORDINGS_PATH)); + assertTrue(storage.findRecordings(sessionProvider, session, "john", CALL_ID, SPACE_GROUP_ID, null, null).isEmpty()); + + queryAnswers(); + assertTrue(storage.findRecordings(sessionProvider, session, "john", "a*|b", SPACE_GROUP_ID, null, null).isEmpty()); + verify(session, never()).getItem("/Groups/spaces/sales/Documents/recordings/a*|b"); + } + + /** + * The upload info of a space-event recording. + * + * @return the upload info + */ + private static UploadFileInfo uploadInfo() { + return new UploadFileInfo(CALL_ID, "42", "space_event", "john", List.of("john", "mary")); + } + + /** + * Makes the stamped-recordings query answer the given nodes. + * + * @param nodes the nodes + * @throws Exception never + */ + private void queryAnswers(Node... nodes) throws Exception { + QueryResult result = mock(QueryResult.class); + NodeIterator iterator = iterator(nodes); + when(query.execute()).thenReturn(result); + when(result.getNodes()).thenReturn(iterator); + } + + /** + * A node iterator over the given nodes. + * + * @param nodes the nodes + * @return the iterator + */ + private static NodeIterator iterator(Node... nodes) { + Iterator delegate = Arrays.asList(nodes).iterator(); + NodeIterator iterator = mock(NodeIterator.class); + lenient().when(iterator.hasNext()).thenAnswer(invocation -> delegate.hasNext()); + lenient().when(iterator.nextNode()).thenAnswer(invocation -> delegate.next()); + return iterator; + } + + /** + * A recording's file. + * + * @param id its identifier + * @param name its name + * @param date its recording date, ISO 8601 + * @param stamped true when the date is the stamp's, false when it is the + * content's last modification + * @param trashed true when the file is in the trash + * @return the file + * @throws Exception never + */ + private static ExtendedNode file(String id, String name, String date, boolean stamped, boolean trashed) throws Exception { + ExtendedNode file = mock(ExtendedNode.class); + Calendar calendar = new GregorianCalendar(); + calendar.setTime(Date.from(Instant.parse(date))); + lenient().when(file.getIdentifier()).thenReturn(id); + lenient().when(file.getName()).thenReturn(name); + lenient().when(file.isNodeType("nt:file")).thenReturn(true); + lenient().when(file.isNodeType("exo:restoreLocation")).thenReturn(trashed); + lenient().when(file.hasProperty("exo:title")).thenReturn(true); + Property title = mock(Property.class); + lenient().when(title.getString()).thenReturn(name + " title"); + lenient().when(file.getProperty("exo:title")).thenReturn(title); + Node content = mock(Node.class); + lenient().when(file.hasNode("jcr:content")).thenReturn(true); + lenient().when(file.getNode("jcr:content")).thenReturn(content); + Property dateProperty = mock(Property.class); + lenient().when(dateProperty.getDate()).thenReturn(calendar); + lenient().when(file.isNodeType("exo:callRecording")).thenReturn(stamped); + if (stamped) { + lenient().when(file.hasProperty("exo:recordedDate")).thenReturn(true); + lenient().when(file.getProperty("exo:recordedDate")).thenReturn(dateProperty); + } else { + lenient().when(content.hasProperty("jcr:lastModified")).thenReturn(true); + lenient().when(content.getProperty("jcr:lastModified")).thenReturn(dateProperty); + } + Property data = mock(Property.class); + lenient().when(data.getLength()).thenReturn(1234L); + lenient().when(content.hasProperty("jcr:data")).thenReturn(true); + lenient().when(content.getProperty("jcr:data")).thenReturn(data); + Property mimeType = mock(Property.class); + lenient().when(mimeType.getString()).thenReturn("video/mp4"); + lenient().when(content.hasProperty("jcr:mimeType")).thenReturn(true); + lenient().when(content.getProperty("jcr:mimeType")).thenReturn(mimeType); + return file; + } + +} diff --git a/services/src/test/java/org/exoplatform/webconferencing/util/RecordingDateUtilsTest.java b/services/src/test/java/org/exoplatform/webconferencing/util/RecordingDateUtilsTest.java new file mode 100644 index 000000000..ee0309b2d --- /dev/null +++ b/services/src/test/java/org/exoplatform/webconferencing/util/RecordingDateUtilsTest.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.webconferencing.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Instant; +import java.util.Date; + +import org.junit.jupiter.api.Test; + +/** + * The dates a client gives to list recordings. + */ +class RecordingDateUtilsTest { + + /** + * A date-time with an offset, without one (UTC), and a bare date, which + * starts or ends its UTC day depending on the bound it is. + */ + @Test + void readsTheAcceptedForms() { + assertEquals(Date.from(Instant.parse("2026-10-23T08:00:00Z")), RecordingDateUtils.parseDate("2026-10-23T10:00:00+02:00", false)); + assertEquals(Date.from(Instant.parse("2026-10-23T10:00:00Z")), RecordingDateUtils.parseDate("2026-10-23T10:00:00Z", true)); + assertEquals(Date.from(Instant.parse("2026-10-23T10:00:00Z")), RecordingDateUtils.parseDate("2026-10-23T10:00:00", false)); + assertEquals(Date.from(Instant.parse("2026-10-23T00:00:00Z")), RecordingDateUtils.parseDate("2026-10-23", false)); + assertEquals(Date.from(Instant.parse("2026-10-23T23:59:59.999Z")), RecordingDateUtils.parseDate("2026-10-23", true)); + assertNull(RecordingDateUtils.parseDate(" ", false)); + assertNull(RecordingDateUtils.parseDate(null, true)); + } + + /** + * Anything else is refused with the code the REST answers. + */ + @Test + void refusesAnythingElse() { + assertEquals(RecordingDateUtils.RECORDINGS_INVALID_DATE, + assertThrows(IllegalArgumentException.class, () -> RecordingDateUtils.parseDate("yesterday", false)).getMessage()); + } + + /** + * A date is written as a UTC instant. + */ + @Test + void writesAnInstant() { + assertEquals("2026-10-23T10:05:00Z", RecordingDateUtils.formatDate(Date.from(Instant.parse("2026-10-23T10:05:00Z")))); + assertNull(RecordingDateUtils.formatDate(null)); + } + +} diff --git a/webapp/src/main/java/org/exoplatform/webconferencing/WebConferencingApplication.java b/webapp/src/main/java/org/exoplatform/webconferencing/WebConferencingApplication.java index dfaa0c518..4956405bd 100644 --- a/webapp/src/main/java/org/exoplatform/webconferencing/WebConferencingApplication.java +++ b/webapp/src/main/java/org/exoplatform/webconferencing/WebConferencingApplication.java @@ -27,29 +27,30 @@ * Spring Boot bootstrap of the Web Conferencing webapp. *

* Web Conferencing has historically been a pure eXo Kernel add-on with no - * Spring context at all. It gets one here for a single reason: contributing an - * Application Center badge means contributing a Spring bean, and a bean needs a - * context to live in. Extending {@link PortalApplicationContextInitializer} + * Spring context at all. It gets one here for the beans the platform's newer + * extension points take: the Application Center badge, the recordings REST + * ({@code CallRecordingRest}, under {@code /webconferencing/rest/}) and the + * assistant tools ({@code MeetingRecordingMcpTool}). Extending {@link PortalApplicationContextInitializer} * registers this WAR's context with the Kernel before the portal container * boots, so beans of this add-on and of the platform become mutually injectable * — which is how {@code VisioApplicationBadgePlugin} can {@code @Autowired} the * Kernel-side {@link WebConferencingService}. *

* The scan is deliberately narrow. Only {@link #MODULE_NAME} is scanned, and - * every class currently shipped under that package by this add-on, by - * {@code web-conferencing-jitsi} and by {@code external-visio-connector} - * carries no Spring annotation whatsoever — so this context registers the badge - * plugin and its listener, and nothing else. No JPA and no Liquibase + * the classes shipped under that package by {@code web-conferencing-jitsi} and + * by {@code external-visio-connector} carry no Spring annotation whatsoever — + * so this context registers the badge plugin and its listener, the recordings + * REST and the assistant tools, and nothing else. No JPA and no Liquibase * integration is declared: this add-on keeps managing its own schema through * the Kernel, and pulling those in would put a second, competing persistence * bootstrap in front of it. *

- * {@code AvailableIntegration.WEB_MODULE} is kept even though no Spring MVC - * endpoint is exposed: it carries the platform's own web security, locale and - * transaction filter configuration, and every legacy Kernel WAR that took a - * Spring context before this one ({@code agenda}, {@code email-connector}) - * declares it. Dropping it would leave Spring Boot's default security - * auto-configuration unadjusted over this WAR's existing url space. + * {@code AvailableIntegration.WEB_MODULE} mounts the Spring MVC endpoints + * under {@code /rest/} and carries the platform's own web security, locale and + * transaction filter configuration, as in every legacy Kernel WAR that took a + * Spring context before this one ({@code agenda}, {@code email-connector}). + * Dropping it would leave Spring Boot's default security auto-configuration + * unadjusted over this WAR's existing url space. *

* {@code LiquibaseAutoConfiguration} is excluded, and the exclusion is what * actually enforces the "no second schema bootstrap" intent. Declining to list diff --git a/webapp/src/main/webapp/WEB-INF/conf/webconferencing/configuration.xml b/webapp/src/main/webapp/WEB-INF/conf/webconferencing/configuration.xml index c269a1920..879523022 100644 --- a/webapp/src/main/webapp/WEB-INF/conf/webconferencing/configuration.xml +++ b/webapp/src/main/webapp/WEB-INF/conf/webconferencing/configuration.xml @@ -31,6 +31,12 @@ jwt-configuration + + recording-configuration + Size limit of a recording upload in MB (0: unlimited), and how long after the end of an occurrence a recording still counts as that occurrence's, in hours + + + From 29d4fb2041da6715fa7a3fadd9a6e5ff6c633c93 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Fri, 23 Oct 2026 18:13:22 +0100 Subject: [PATCH 2/9] fix: A recording refused for another reason than its size notifies its failure - EXO-91998 Co-Authored-By: Claude Opus 5.5 --- .../WebConferencingService.java | 2 +- .../WebConferencingServiceRecordingsTest.java | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java b/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java index 598ee12e6..9d9d21362 100644 --- a/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java +++ b/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java @@ -2258,7 +2258,7 @@ public void uploadFile(UploadFileInfo uploadInfo, HttpServletRequest request) th } catch (InvalidCallException e) { LOG.warn("Failed to build metric for " + OPERATION_CALL_RECORDED, e); } catch (Exception e) { - broacastCallEvent(EVENT_CALL_RECORDED, call, uploadInfo.getUser(), resource.getEstimatedSize(), null, null, STATUS_NOT_OK); + broacastCallEvent(EVENT_CALL_RECORDED, call, uploadInfo.getUser(), resource.getEstimatedSize(), null, uploadInfo, STATUS_NOT_OK); LOG.info(metricMessage(uploadInfo.getUser(), call, OPERATION_CALL_RECORDED, diff --git a/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java b/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java index 26ae74063..76369a395 100644 --- a/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java +++ b/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java @@ -41,6 +41,7 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; +import java.util.Map; import org.apache.commons.fileupload2.core.FileUploadException; import javax.jcr.Node; @@ -232,6 +233,32 @@ void aSavedRecordingIsStampedWithItsCall() throws Exception { order.verify(documents).save(); } + /** + * A recording the platform refuses for another reason than its size still + * notifies the failure, with the owner the upload token names. + * + * @throws Exception never + */ + @Test + @SuppressWarnings("unchecked") + void aFailedUploadNotifiesItsFailure() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + UploadResource resource = mock(UploadResource.class); + when(uploadService.getUploadResource(anyString())).thenReturn(resource); + when(resource.getStatus()).thenReturn(UploadResource.FAILED_STATUS); + when(resource.getEstimatedSize()).thenReturn(1048576d); + CallInfo call = new CallInfo(CALL_ID, "ACME demo", new UserInfo(VIEWER, "John", "Smith"), "jitsi"); + doReturn(call).when(service).getCall(CALL_ID); + + service.uploadFile(uploadInfo(), request); + + ArgumentCaptor> info = ArgumentCaptor.forClass(Map.class); + verify(listenerService).broadcast(eq(WebConferencingService.EVENT_CALL_RECORDED), eq(call), info.capture()); + assertEquals(WebConferencingService.STATUS_NOT_OK, info.getValue().get("status")); + assertEquals("space_event", info.getValue().get("type")); + assertEquals("42", info.getValue().get("identity")); + } + /** * A malformed or negative setting falls back to its default. */ From af3a183111f1bb9918b01af4ba2f5b19c900ff2d Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Fri, 23 Oct 2026 18:54:47 +0100 Subject: [PATCH 3/9] feat: A recording tells when it started, read from the name the recorder gives its file - EXO-91998 Co-Authored-By: Claude Opus 5.5 --- .../mcp/MeetingRecordingMcpTool.java | 1 + .../mcp/model/MeetingRecordingModel.java | 1 + .../webconferencing/model/RecordingInfo.java | 32 +++++++++++++++ .../rest/model/RecordingEntity.java | 3 ++ .../rest/util/RecordingEntityBuilder.java | 1 + .../storage/CallRecordingStorage.java | 41 ++++++++++++++++++- .../rest/CallRecordingRestTest.java | 2 +- .../storage/CallRecordingStorageTest.java | 16 ++++++++ 8 files changed, 95 insertions(+), 2 deletions(-) diff --git a/services/src/main/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpTool.java b/services/src/main/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpTool.java index 8afb3ed2e..1ac94b735 100644 --- a/services/src/main/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpTool.java +++ b/services/src/main/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpTool.java @@ -109,6 +109,7 @@ private static MeetingRecordingModel toModel(RecordingInfo recording) { return new MeetingRecordingModel(recording.getDocumentId(), recording.getTitle(), RecordingDateUtils.formatDate(recording.getRecordedDate()), + RecordingDateUtils.formatDate(recording.getStartedDate()), recording.getSizeBytes(), recording.getMimeType(), recording.getPreviewUrl()); diff --git a/services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingModel.java b/services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingModel.java index 6a334de92..96c06826e 100644 --- a/services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingModel.java +++ b/services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingModel.java @@ -34,6 +34,7 @@ public record MeetingRecordingModel(String documentId, String title, String recordedDate, + String startedDate, long sizeBytes, String mimeType, String previewUrl) { diff --git a/services/src/main/java/org/exoplatform/webconferencing/model/RecordingInfo.java b/services/src/main/java/org/exoplatform/webconferencing/model/RecordingInfo.java index b3bd1f225..d2ef5d804 100644 --- a/services/src/main/java/org/exoplatform/webconferencing/model/RecordingInfo.java +++ b/services/src/main/java/org/exoplatform/webconferencing/model/RecordingInfo.java @@ -34,6 +34,9 @@ public class RecordingInfo { /** When the recording was saved. */ private final Date recordedDate; + /** When the recording started, as its file name tells, or null. */ + private final Date startedDate; + /** The size of the recording, in bytes, or -1 when unknown. */ private final long sizeBytes; @@ -53,9 +56,29 @@ public class RecordingInfo { * @param mimeType the media type of the recording */ public RecordingInfo(String documentId, String title, Date recordedDate, long sizeBytes, String mimeType) { + this(documentId, title, recordedDate, null, sizeBytes, mimeType); + } + + /** + * Builds a recording that knows when it started. + * + * @param documentId the JCR identifier of the recording's file + * @param title the title displayed in Documents + * @param recordedDate when the recording was saved + * @param startedDate when the recording started, or null when unknown + * @param sizeBytes the size of the recording, in bytes, or -1 when unknown + * @param mimeType the media type of the recording + */ + public RecordingInfo(String documentId, + String title, + Date recordedDate, + Date startedDate, + long sizeBytes, + String mimeType) { this.documentId = documentId; this.title = title; this.recordedDate = recordedDate == null ? null : new Date(recordedDate.getTime()); + this.startedDate = startedDate == null ? null : new Date(startedDate.getTime()); this.sizeBytes = sizeBytes; this.mimeType = mimeType; } @@ -87,6 +110,15 @@ public Date getRecordedDate() { return recordedDate == null ? null : new Date(recordedDate.getTime()); } + /** + * Gets when the recording started, as the recorder named its file. + * + * @return the start, or null when the file name does not tell it + */ + public Date getStartedDate() { + return startedDate == null ? null : new Date(startedDate.getTime()); + } + /** * Gets the size of the recording. * diff --git a/services/src/main/java/org/exoplatform/webconferencing/rest/model/RecordingEntity.java b/services/src/main/java/org/exoplatform/webconferencing/rest/model/RecordingEntity.java index 2c1989ba1..10a463828 100644 --- a/services/src/main/java/org/exoplatform/webconferencing/rest/model/RecordingEntity.java +++ b/services/src/main/java/org/exoplatform/webconferencing/rest/model/RecordingEntity.java @@ -23,6 +23,8 @@ * documents add-on and its transcription REST take * @param title the title displayed in Documents * @param recordedDate when the recording was saved, an ISO 8601 instant in UTC + * @param startedDate when the recording started, an ISO 8601 instant in UTC, + * or null when its file name does not tell it * @param sizeBytes the size of the recording in bytes, -1 when unknown * @param mimeType the media type of the recording * @param previewUrl the link opening the recording in the Documents preview @@ -30,6 +32,7 @@ public record RecordingEntity(String documentId, String title, String recordedDate, + String startedDate, long sizeBytes, String mimeType, String previewUrl) { diff --git a/services/src/main/java/org/exoplatform/webconferencing/rest/util/RecordingEntityBuilder.java b/services/src/main/java/org/exoplatform/webconferencing/rest/util/RecordingEntityBuilder.java index 7b042b366..b85a82774 100644 --- a/services/src/main/java/org/exoplatform/webconferencing/rest/util/RecordingEntityBuilder.java +++ b/services/src/main/java/org/exoplatform/webconferencing/rest/util/RecordingEntityBuilder.java @@ -41,6 +41,7 @@ public static RecordingEntity toEntity(RecordingInfo recording) { return new RecordingEntity(recording.getDocumentId(), recording.getTitle(), RecordingDateUtils.formatDate(recording.getRecordedDate()), + RecordingDateUtils.formatDate(recording.getStartedDate()), recording.getSizeBytes(), recording.getMimeType(), recording.getPreviewUrl()); diff --git a/services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java b/services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java index 1980b725f..9e114ca31 100644 --- a/services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java +++ b/services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java @@ -16,7 +16,10 @@ */ package org.exoplatform.webconferencing.storage; +import java.time.Instant; import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Calendar; @@ -24,6 +27,7 @@ import java.util.Date; import java.util.GregorianCalendar; import java.util.List; +import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.jcr.AccessDeniedException; @@ -130,6 +134,15 @@ public class CallRecordingStorage { private static final DateTimeFormatter TITLE_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + /** The start Jibri writes in a recording's file name, after the call id. */ + private static final Pattern FILE_NAME_START = + Pattern.compile("_(\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}-\\d{2})\\.[^.]+$"); + + /** How Jibri writes that start: UTC, the recorder's clock. */ + private static final DateTimeFormatter FILE_NAME_START_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss") + .withZone(ZoneOffset.UTC); + private static final Comparator NEWEST_FIRST = Comparator.comparing(RecordingInfo::getRecordedDate, Comparator.nullsLast(Comparator.reverseOrder())); @@ -486,7 +499,33 @@ private RecordingInfo toRecordingInfo(Node file) throws RepositoryException { mimeType = content.getProperty(JCR_MIME_TYPE).getString(); } } - return new RecordingInfo(((ExtendedNode) file).getIdentifier(), title, recordedDate, size, mimeType); + return new RecordingInfo(((ExtendedNode) file).getIdentifier(), + title, + recordedDate, + recordingStart(file.getName()), + size, + mimeType); + } + + /** + * Reads when a recording started from its file name, which Jibri writes as + * {@code _.} in UTC. The node keeps that + * name when its displayed title changes. + * + * @param fileName the recording's file name + * @return the start, or null when the name does not carry one + */ + static Date recordingStart(String fileName) { + Matcher matcher = fileName == null ? null : FILE_NAME_START.matcher(fileName); + if (matcher == null || !matcher.find()) { + return null; + } + try { + return Date.from(Instant.from(FILE_NAME_START_FORMAT.parse(matcher.group(1)))); + } catch (DateTimeParseException e) { + LOG.debug("The recording file name {} carries no readable start", fileName, e); + return null; + } } /** diff --git a/services/src/test/java/org/exoplatform/webconferencing/rest/CallRecordingRestTest.java b/services/src/test/java/org/exoplatform/webconferencing/rest/CallRecordingRestTest.java index cdda1c3ff..c9427080e 100644 --- a/services/src/test/java/org/exoplatform/webconferencing/rest/CallRecordingRestTest.java +++ b/services/src/test/java/org/exoplatform/webconferencing/rest/CallRecordingRestTest.java @@ -90,7 +90,7 @@ void answersTheRecordingsOfTheAuthenticatedUser() throws Exception { ResponseEntity response = rest.getCallRecordings(request, CALL_ID, "2026-10-23T09:00:00Z", "2026-10-23T11:00:00+01:00"); assertEquals(200, response.getStatusCode().value()); - assertEquals(List.of(new RecordingEntity("doc1", "ACME demo", "2026-10-23T10:05:00Z", 42, "video/mp4", "preview")), + assertEquals(List.of(new RecordingEntity("doc1", "ACME demo", "2026-10-23T10:05:00Z", null, 42, "video/mp4", "preview")), response.getBody()); assertEquals("no-store", response.getHeaders().getCacheControl()); } diff --git a/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java b/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java index 595f349ce..8ca661700 100644 --- a/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java +++ b/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java @@ -144,6 +144,22 @@ void theQueryIsParsedByTheRepositoryEngine() throws Exception { * from below and from above with the right operators, and sorts newest * first; a quote in the id is doubled, never left to close the literal. */ + /** + * A recording's start is read from the name Jibri gives its file, in UTC, + * and is unknown for any other name. + */ + @Test + void aRecordingStartIsReadFromItsFileName() { + assertEquals(Date.from(Instant.parse("2026-10-23T14:05:00Z")), + CallRecordingStorage.recordingStart("g_15-41126d_2026-10-23-14-05-00.mp4")); + assertEquals(Date.from(Instant.parse("2026-10-23T14:05:00Z")), + CallRecordingStorage.recordingStart("call_with_underscores_2026-10-23-14-05-00.webm")); + assertNull(CallRecordingStorage.recordingStart("ACME demo.mp4")); + assertNull(CallRecordingStorage.recordingStart("g_15_2026-13-45-99-99-99.mp4")); + assertNull(CallRecordingStorage.recordingStart("g_15_2026-10-23-14-05-00.mp4.bak.txt")); + assertNull(CallRecordingStorage.recordingStart(null)); + } + @Test void theQuerySelectsTheCallWithinTheRange() { Date from = Date.from(Instant.parse("2026-10-23T09:00:00Z")); From 680409fc7fc866c44c8cadec3ca4c5bf19422f93 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Fri, 23 Oct 2026 19:04:06 +0100 Subject: [PATCH 4/9] fix: A recording upload registers its limit under an unguessable id - EXO-91998 Co-Authored-By: Claude Opus 5.5 --- .../exoplatform/webconferencing/WebConferencingService.java | 5 ++++- .../WebConferencingServiceRecordingsTest.java | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java b/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java index 9d9d21362..b115ec270 100644 --- a/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java +++ b/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java @@ -42,6 +42,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -2200,7 +2201,9 @@ protected List findGroups(String name, int count) throws Exception public void uploadFile(UploadFileInfo uploadInfo, HttpServletRequest request) throws UploadFileException, RepositoryException { final long opStart = System.currentTimeMillis(); // final long opStart = System.currentTimeMillis(); - String uploadId = String.valueOf((long) (Math.random() * 100000L)); + // Unguessable: the unlimited recording limit is registered under this id in + // the platform's shared upload-limit map, which any upload can name + String uploadId = UUID.randomUUID().toString(); // The recording upload has its own limit, unlimited by default: the // platform's generic one refuses a long meeting's recording uploadService.addUploadLimit(uploadId, recordingUploadLimitMB); diff --git a/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java b/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java index 76369a395..7fa77c6d4 100644 --- a/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java +++ b/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java @@ -42,6 +42,7 @@ import java.util.Date; import java.util.List; import java.util.Map; +import java.util.UUID; import org.apache.commons.fileupload2.core.FileUploadException; import javax.jcr.Node; @@ -500,6 +501,7 @@ private void uploadAndCheckLimit(WebConferencingService webConferencing, int exp ArgumentCaptor uploadId = ArgumentCaptor.forClass(String.class); InOrder order = inOrder(uploadService); order.verify(uploadService).addUploadLimit(uploadId.capture(), eq(expectedLimit)); + assertEquals(uploadId.getValue(), UUID.fromString(uploadId.getValue()).toString(), "the upload id is a random UUID"); order.verify(uploadService).createUploadResource(uploadId.getValue(), request); order.verify(uploadService).removeUploadLimit(uploadId.getValue()); } From 37aef69ebb4ec672ce05efeae02f2cf163f84257 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Fri, 23 Oct 2026 19:06:31 +0100 Subject: [PATCH 5/9] fix: The recordings are read with the viewer's own rights, whoever the calling thread runs as - EXO-91998 Co-Authored-By: Claude Opus 5.5 --- .../storage/CallRecordingStorage.java | 14 +++++++- .../storage/CallRecordingStorageTest.java | 34 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java b/services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java index 9e114ca31..625c916d1 100644 --- a/services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java +++ b/services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java @@ -288,6 +288,13 @@ public boolean stamp(Node fileNode, String callId, CallInfo call, UploadFileInfo * private folder otherwise, where the other participants' recordings arrive * as links. * + *

+ * A JCR session logs in as the thread's current conversation state, whatever + * the session provider was built with: the viewer is made current for the + * read, the way ecms {@code WCMCoreUtils.invokeUserSession} does, and the + * previous state restored, so the repository applies the viewer's own + * rights whoever calls. + * * @param viewer the viewer's security identity, whose session reads JCR * @param callId the call id * @param spaceGroupId the group id of the space owning the call, or @@ -302,13 +309,18 @@ public List findRecordings(Identity viewer, String spaceGroupId, Date from, Date to) throws RepositoryException { - SessionProvider sessionProvider = new SessionProvider(new ConversationState(viewer)); + ConversationState previousState = ConversationState.getCurrent(); + ConversationState viewerState = new ConversationState(viewer); + viewerState.setAttribute(ConversationState.SUBJECT, viewer.getSubject()); + SessionProvider sessionProvider = new SessionProvider(viewerState); + ConversationState.setCurrent(viewerState); try { ManageableRepository repository = repositoryService.getCurrentRepository(); Session session = sessionProvider.getSession(repository.getConfiguration().getDefaultWorkspaceName(), repository); return findRecordings(sessionProvider, session, viewer.getUserId(), callId, spaceGroupId, from, to); } finally { sessionProvider.close(); + ConversationState.setCurrent(previousState); } } diff --git a/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java b/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java index 8ca661700..b6c593638 100644 --- a/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java +++ b/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java @@ -60,12 +60,14 @@ import org.exoplatform.services.cms.link.LinkManager; import org.exoplatform.services.jcr.RepositoryService; import org.exoplatform.services.jcr.core.ExtendedNode; +import org.exoplatform.services.jcr.core.ManageableRepository; import org.exoplatform.services.jcr.ext.common.SessionProvider; import org.exoplatform.services.jcr.ext.hierarchy.NodeHierarchyCreator; import org.exoplatform.services.jcr.impl.core.LocationFactory; import org.exoplatform.services.jcr.impl.core.NamespaceRegistryImpl; import org.exoplatform.services.jcr.impl.core.query.DefaultQueryNodeFactory; import org.exoplatform.services.jcr.impl.core.query.sql.JCRSQLQueryBuilder; +import org.exoplatform.services.security.ConversationState; import org.exoplatform.webconferencing.CallInfo; import org.exoplatform.webconferencing.IdentityInfo; import org.exoplatform.webconferencing.UploadFileInfo; @@ -144,6 +146,38 @@ void theQueryIsParsedByTheRepositoryEngine() throws Exception { * from below and from above with the right operators, and sorts newest * first; a quote in the id is doubled, never left to close the literal. */ + /** + * The repository reads as the viewer whoever the calling thread runs as: + * the viewer is the current state while the session logs in, and the + * caller's state is back afterwards. + * + * @throws Exception never + */ + @Test + void theRepositoryReadsAsTheViewerWhateverTheCaller() throws Exception { + org.exoplatform.services.security.Identity viewer = new org.exoplatform.services.security.Identity("mary"); + ConversationState caller = new ConversationState(new org.exoplatform.services.security.Identity("john")); + ConversationState.setCurrent(caller); + try { + ManageableRepository repository = mock(ManageableRepository.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(repositoryService.getCurrentRepository()).thenReturn(repository); + when(repository.getConfiguration().getDefaultWorkspaceName()).thenReturn("collaboration"); + String[] loggedInAs = new String[1]; + when(repository.login("collaboration")).thenAnswer(invocation -> { + loggedInAs[0] = ConversationState.getCurrent().getIdentity().getUserId(); + throw new javax.jcr.RepositoryException("stop"); + }); + + org.junit.jupiter.api.Assertions.assertThrows(javax.jcr.RepositoryException.class, + () -> storage.findRecordings(viewer, CALL_ID, SPACE_GROUP_ID, null, null)); + + assertEquals("mary", loggedInAs[0]); + assertEquals(caller, ConversationState.getCurrent()); + } finally { + ConversationState.setCurrent(null); + } + } + /** * A recording's start is read from the name Jibri gives its file, in UTC, * and is unknown for any other name. From ccefae9ae8429cd3b041ed818983119ffd5cfad2 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Fri, 23 Oct 2026 22:30:19 +0100 Subject: [PATCH 6/9] fix: A recording's start is read in the recorder's time zone, a setting that defaults to UTC - EXO-91998 Jibri names a recording after its JVM's local time, with no zone. exo.webconferencing.recording.recorderTimeZone names that zone. Co-Authored-By: Claude Opus 5.5 --- .../WebConferencingService.java | 35 ++++++++++++++++- .../storage/CallRecordingStorage.java | 34 ++++++++++++----- .../WebConferencingServiceRecordingsTest.java | 17 +++++++++ .../storage/CallRecordingStorageTest.java | 38 +++++++++++++++---- .../conf/webconferencing/configuration.xml | 3 +- 5 files changed, 107 insertions(+), 20 deletions(-) diff --git a/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java b/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java index b115ec270..9e5a914c4 100644 --- a/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java +++ b/services/src/main/java/org/exoplatform/webconferencing/WebConferencingService.java @@ -25,6 +25,9 @@ import java.sql.SQLException; import java.sql.SQLIntegrityConstraintViolationException; import java.text.DecimalFormat; +import java.time.DateTimeException; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; @@ -230,6 +233,12 @@ public class WebConferencingService implements Startable { */ protected static final String RECORDING_OCCURRENCE_GRACE_HOURS = "occurrence-grace-hours"; + /** + * The time zone the recorder writes a recording's start in, in its file + * name ({@code exo.webconferencing.recording.recorderTimeZone}). + */ + protected static final String RECORDING_RECORDER_TIME_ZONE = "recorder-time-zone"; + /** The default grace after the end of a range, in hours. */ protected static final int DEFAULT_RECORDING_GRACE_HOURS = 12; @@ -599,7 +608,10 @@ public WebConferencingService(OrganizationService organization, PropertiesParam recordingParam = initParams.getPropertiesParam(RECORDING_CONFIGURATION_PROPERTIES); this.recordingUploadLimitMB = readNonNegativeInt(recordingParam, RECORDING_UPLOAD_LIMIT_MB, 0); this.recordingGraceHours = readNonNegativeInt(recordingParam, RECORDING_OCCURRENCE_GRACE_HOURS, DEFAULT_RECORDING_GRACE_HOURS); - this.recordingStorage = new CallRecordingStorage(repositoryService, nodeCreator, linkManager); + this.recordingStorage = new CallRecordingStorage(repositoryService, + nodeCreator, + linkManager, + readZone(recordingParam, RECORDING_RECORDER_TIME_ZONE)); try { this.codec = codecInitializer.getCodec(); } catch (Exception e) { @@ -4330,6 +4342,27 @@ protected static int readNonNegativeInt(PropertiesParam params, String name, int return defaultValue; } + /** + * Reads a time zone setting, falling back to UTC when it is absent or not a + * time zone. + * + * @param params the init params holding it, or null + * @param name the setting's name + * @return the time zone + */ + protected static ZoneId readZone(PropertiesParam params, String name) { + String value = params == null ? null : params.getProperty(name); + if (StringUtils.isBlank(value)) { + return ZoneOffset.UTC; + } + try { + return ZoneId.of(value.trim()); + } catch (DateTimeException e) { + LOG.warn("Setting {} must be a time zone, {} is ignored and UTC is used", name, value); + return ZoneOffset.UTC; + } + } + // <<<<<<< Call storage: wrappers to catch JPA exceptions public List getActiveProvidersForSpace(String spaceId) { List allProviders = new ArrayList<>(); diff --git a/services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java b/services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java index 625c916d1..b7b46b3a5 100644 --- a/services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java +++ b/services/src/main/java/org/exoplatform/webconferencing/storage/CallRecordingStorage.java @@ -16,7 +16,7 @@ */ package org.exoplatform.webconferencing.storage; -import java.time.Instant; +import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZoneOffset; import java.time.format.DateTimeParseException; @@ -138,10 +138,13 @@ public class CallRecordingStorage { private static final Pattern FILE_NAME_START = Pattern.compile("_(\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}-\\d{2})\\.[^.]+$"); - /** How Jibri writes that start: UTC, the recorder's clock. */ + /** + * How Jibri writes that start: the local time of the recorder's JVM, with + * no zone, which {@code exo.webconferencing.recording.recorderTimeZone} + * names. + */ private static final DateTimeFormatter FILE_NAME_START_FORMAT = - DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss") - .withZone(ZoneOffset.UTC); + DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss"); private static final Comparator NEWEST_FIRST = Comparator.comparing(RecordingInfo::getRecordedDate, @@ -155,17 +158,25 @@ public class CallRecordingStorage { private final LinkManager linkManager; + private final ZoneId recorderZone; + /** * Builds the storage. * * @param repositoryService the repository service * @param nodeCreator resolves the spaces' and users' folders * @param linkManager resolves the links shared to the participants of a call + * @param recorderZone the time zone the recorder writes a recording's start + * in, UTC when null */ - public CallRecordingStorage(RepositoryService repositoryService, NodeHierarchyCreator nodeCreator, LinkManager linkManager) { + public CallRecordingStorage(RepositoryService repositoryService, + NodeHierarchyCreator nodeCreator, + LinkManager linkManager, + ZoneId recorderZone) { this.repositoryService = repositoryService; this.nodeCreator = nodeCreator; this.linkManager = linkManager; + this.recorderZone = recorderZone == null ? ZoneOffset.UTC : recorderZone; } /** @@ -514,26 +525,29 @@ private RecordingInfo toRecordingInfo(Node file) throws RepositoryException { return new RecordingInfo(((ExtendedNode) file).getIdentifier(), title, recordedDate, - recordingStart(file.getName()), + recordingStart(file.getName(), recorderZone), size, mimeType); } /** * Reads when a recording started from its file name, which Jibri writes as - * {@code _.} in UTC. The node keeps that - * name when its displayed title changes. + * {@code _.} in its own local time. The + * node keeps that name when its displayed title changes. A local time the + * zone repeats when its clocks go back is ambiguous and reads as the earlier + * of its two instants. * * @param fileName the recording's file name + * @param zone the time zone the recorder writes that time in * @return the start, or null when the name does not carry one */ - static Date recordingStart(String fileName) { + static Date recordingStart(String fileName, ZoneId zone) { Matcher matcher = fileName == null ? null : FILE_NAME_START.matcher(fileName); if (matcher == null || !matcher.find()) { return null; } try { - return Date.from(Instant.from(FILE_NAME_START_FORMAT.parse(matcher.group(1)))); + return Date.from(LocalDateTime.parse(matcher.group(1), FILE_NAME_START_FORMAT).atZone(zone).toInstant()); } catch (DateTimeParseException e) { LOG.debug("The recording file name {} carries no readable start", fileName, e); return null; diff --git a/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java b/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java index 7fa77c6d4..fadeded0b 100644 --- a/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java +++ b/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceRecordingsTest.java @@ -37,6 +37,8 @@ import java.io.File; import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.Calendar; import java.util.ArrayList; import java.util.Date; @@ -276,6 +278,21 @@ void aMalformedSettingFallsBackToItsDefault() { assertEquals(12, WebConferencingService.readNonNegativeInt(null, "a", 12)); } + /** + * The recorder's time zone is read as a zone id, UTC when it is absent or + * not a zone. + */ + @Test + void theRecordersTimeZoneFallsBackToUtc() { + PropertiesParam params = new PropertiesParam(); + params.setProperty("paris", " Europe/Paris "); + params.setProperty("bad", "Mars/Olympus"); + assertEquals(ZoneId.of("Europe/Paris"), WebConferencingService.readZone(params, "paris")); + assertEquals(ZoneOffset.UTC, WebConferencingService.readZone(params, "bad")); + assertEquals(ZoneOffset.UTC, WebConferencingService.readZone(params, "missing")); + assertEquals(ZoneOffset.UTC, WebConferencingService.readZone(null, "paris")); + } + /** * A meeting link is read by the active provider that recognises it; an * inactive provider is not asked. diff --git a/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java b/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java index b6c593638..020f25270 100644 --- a/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java +++ b/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java @@ -32,6 +32,8 @@ import static org.mockito.Mockito.when; import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.Arrays; import java.util.Calendar; import java.util.Date; @@ -113,7 +115,7 @@ class CallRecordingStorageTest { */ @BeforeEach void setUp() throws Exception { - storage = new CallRecordingStorage(repositoryService, nodeCreator, linkManager); + storage = new CallRecordingStorage(repositoryService, nodeCreator, linkManager, null); Workspace workspace = mock(Workspace.class); lenient().when(session.getWorkspace()).thenReturn(workspace); lenient().when(workspace.getQueryManager()).thenReturn(queryManager); @@ -179,19 +181,39 @@ void theRepositoryReadsAsTheViewerWhateverTheCaller() throws Exception { } /** - * A recording's start is read from the name Jibri gives its file, in UTC, + * A recording's start is read from the name Jibri gives its file, here in UTC, * and is unknown for any other name. */ @Test void aRecordingStartIsReadFromItsFileName() { assertEquals(Date.from(Instant.parse("2026-10-23T14:05:00Z")), - CallRecordingStorage.recordingStart("g_15-41126d_2026-10-23-14-05-00.mp4")); + CallRecordingStorage.recordingStart("g_15-41126d_2026-10-23-14-05-00.mp4", ZoneOffset.UTC)); assertEquals(Date.from(Instant.parse("2026-10-23T14:05:00Z")), - CallRecordingStorage.recordingStart("call_with_underscores_2026-10-23-14-05-00.webm")); - assertNull(CallRecordingStorage.recordingStart("ACME demo.mp4")); - assertNull(CallRecordingStorage.recordingStart("g_15_2026-13-45-99-99-99.mp4")); - assertNull(CallRecordingStorage.recordingStart("g_15_2026-10-23-14-05-00.mp4.bak.txt")); - assertNull(CallRecordingStorage.recordingStart(null)); + CallRecordingStorage.recordingStart("call_with_underscores_2026-10-23-14-05-00.webm", ZoneOffset.UTC)); + assertNull(CallRecordingStorage.recordingStart("ACME demo.mp4", ZoneOffset.UTC)); + assertNull(CallRecordingStorage.recordingStart("g_15_2026-13-45-99-99-99.mp4", ZoneOffset.UTC)); + assertNull(CallRecordingStorage.recordingStart("g_15_2026-10-23-14-05-00.mp4.bak.txt", ZoneOffset.UTC)); + assertNull(CallRecordingStorage.recordingStart(null, ZoneOffset.UTC)); + } + + /** + * The recorder writes its own local time, so a recorder in Paris names a + * recording started at 14:05 UTC in October after 16:05. + */ + @Test + void aRecordingStartIsReadInTheRecordersTimeZone() { + assertEquals(Date.from(Instant.parse("2026-10-23T14:05:00Z")), + CallRecordingStorage.recordingStart("g_15-41126d_2026-10-23-16-05-00.mp4", ZoneId.of("Europe/Paris"))); + } + + /** + * Paris writes 02:30 twice on the night its clocks go back, at 00:30 and + * 01:30 UTC; the name reads as the earlier one. + */ + @Test + void aRepeatedLocalTimeReadsAsItsEarlierInstant() { + assertEquals(Date.from(Instant.parse("2026-10-25T00:30:00Z")), + CallRecordingStorage.recordingStart("g_15-41126d_2026-10-25-02-30-00.mp4", ZoneId.of("Europe/Paris"))); } @Test diff --git a/webapp/src/main/webapp/WEB-INF/conf/webconferencing/configuration.xml b/webapp/src/main/webapp/WEB-INF/conf/webconferencing/configuration.xml index 879523022..ec794c4ee 100644 --- a/webapp/src/main/webapp/WEB-INF/conf/webconferencing/configuration.xml +++ b/webapp/src/main/webapp/WEB-INF/conf/webconferencing/configuration.xml @@ -33,9 +33,10 @@ recording-configuration - Size limit of a recording upload in MB (0: unlimited), and how long after the end of an occurrence a recording still counts as that occurrence's, in hours + Size limit of a recording upload in MB (0: unlimited), and how long after the end of an occurrence a recording still counts as that occurrence's, in hours, and the time zone the recorder writes a recording's start in + From 335242c5a5a930c6356f01f7d510ddc953549778 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Fri, 23 Oct 2026 22:40:16 +0100 Subject: [PATCH 7/9] test: A listed recording carries its start, read in the recorder's time zone - EXO-91998 Co-Authored-By: Claude Opus 5.5 --- .../storage/CallRecordingStorageTest.java | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java b/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java index 020f25270..035243408 100644 --- a/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java +++ b/services/src/test/java/org/exoplatform/webconferencing/storage/CallRecordingStorageTest.java @@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -28,6 +29,7 @@ import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -46,6 +48,7 @@ import javax.jcr.NodeIterator; import javax.jcr.PathNotFoundException; import javax.jcr.Property; +import javax.jcr.RepositoryException; import javax.jcr.Session; import javax.jcr.Workspace; import javax.jcr.nodetype.NoSuchNodeTypeException; @@ -70,6 +73,7 @@ import org.exoplatform.services.jcr.impl.core.query.DefaultQueryNodeFactory; import org.exoplatform.services.jcr.impl.core.query.sql.JCRSQLQueryBuilder; import org.exoplatform.services.security.ConversationState; +import org.exoplatform.services.security.Identity; import org.exoplatform.webconferencing.CallInfo; import org.exoplatform.webconferencing.IdentityInfo; import org.exoplatform.webconferencing.UploadFileInfo; @@ -143,11 +147,6 @@ void theQueryIsParsedByTheRepositoryEngine() throws Exception { } } - /** - * The statement selects the mixin by call id, bounds the recording date - * from below and from above with the right operators, and sorts newest - * first; a quote in the id is doubled, never left to close the literal. - */ /** * The repository reads as the viewer whoever the calling thread runs as: * the viewer is the current state while the session logs in, and the @@ -157,21 +156,21 @@ void theQueryIsParsedByTheRepositoryEngine() throws Exception { */ @Test void theRepositoryReadsAsTheViewerWhateverTheCaller() throws Exception { - org.exoplatform.services.security.Identity viewer = new org.exoplatform.services.security.Identity("mary"); - ConversationState caller = new ConversationState(new org.exoplatform.services.security.Identity("john")); + Identity viewer = new Identity("mary"); + ConversationState caller = new ConversationState(new Identity("john")); ConversationState.setCurrent(caller); try { - ManageableRepository repository = mock(ManageableRepository.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ManageableRepository repository = mock(ManageableRepository.class, RETURNS_DEEP_STUBS); when(repositoryService.getCurrentRepository()).thenReturn(repository); when(repository.getConfiguration().getDefaultWorkspaceName()).thenReturn("collaboration"); String[] loggedInAs = new String[1]; when(repository.login("collaboration")).thenAnswer(invocation -> { loggedInAs[0] = ConversationState.getCurrent().getIdentity().getUserId(); - throw new javax.jcr.RepositoryException("stop"); + throw new RepositoryException("stop"); }); - org.junit.jupiter.api.Assertions.assertThrows(javax.jcr.RepositoryException.class, - () -> storage.findRecordings(viewer, CALL_ID, SPACE_GROUP_ID, null, null)); + assertThrows(RepositoryException.class, + () -> storage.findRecordings(viewer, CALL_ID, SPACE_GROUP_ID, null, null)); assertEquals("mary", loggedInAs[0]); assertEquals(caller, ConversationState.getCurrent()); @@ -216,6 +215,11 @@ void aRepeatedLocalTimeReadsAsItsEarlierInstant() { CallRecordingStorage.recordingStart("g_15-41126d_2026-10-25-02-30-00.mp4", ZoneId.of("Europe/Paris"))); } + /** + * The statement selects the mixin by call id, bounds the recording date + * from below and from above with the right operators, and sorts newest + * first; a quote in the id is doubled, never left to close the literal. + */ @Test void theQuerySelectsTheCallWithinTheRange() { Date from = Date.from(Instant.parse("2026-10-23T09:00:00Z")); @@ -351,6 +355,25 @@ void stampedAndOldRecordingsAreMergedOnce() throws Exception { assertEquals(CALL_ID + "_2026-10-23-10-00-00.mp4 title", recordings.get(0).getTitle()); assertEquals(1234L, recordings.get(0).getSizeBytes()); assertEquals("video/mp4", recordings.get(0).getMimeType()); + assertEquals(Date.from(Instant.parse("2026-10-23T10:00:00Z")), recordings.get(0).getStartedDate()); + } + + /** + * A storage told the recorder runs in Paris reads a listed recording's + * start in that zone. + * + * @throws Exception never + */ + @Test + void aListedRecordingStartsInTheRecordersTimeZone() throws Exception { + storage = new CallRecordingStorage(repositoryService, nodeCreator, linkManager, ZoneId.of("Europe/Paris")); + ExtendedNode newer = file("id-newer", CALL_ID + "_2026-10-23-12-00-00.mp4", "2026-10-23T10:30:00Z", true, false); + queryAnswers(newer); + when(session.getItem(RECORDINGS_PATH)).thenThrow(new PathNotFoundException()); + + List recordings = storage.findRecordings(sessionProvider, session, "john", CALL_ID, SPACE_GROUP_ID, null, null); + + assertEquals(Date.from(Instant.parse("2026-10-23T10:00:00Z")), recordings.get(0).getStartedDate()); } /** From 388ac29d9befbabc1bf3a9b574d0a9dcf7dac767 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Fri, 23 Oct 2026 22:53:53 +0100 Subject: [PATCH 8/9] fix: The assistant's recording model documents when a recording started - EXO-91998 Co-Authored-By: Claude Opus 5.5 --- .../webconferencing/mcp/model/MeetingRecordingModel.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingModel.java b/services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingModel.java index 96c06826e..e7cd023bd 100644 --- a/services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingModel.java +++ b/services/src/main/java/org/exoplatform/webconferencing/mcp/model/MeetingRecordingModel.java @@ -26,6 +26,8 @@ * {@code get_document_transcription_by_id} takes * @param title the title displayed in Documents: the meeting's title and date * @param recordedDate when the recording was saved, an ISO 8601 instant in UTC + * @param startedDate when the recording started, an ISO 8601 instant in UTC, + * absent when its file name does not tell it * @param sizeBytes the size of the recording in bytes, -1 when unknown * @param mimeType the media type of the recording * @param previewUrl the link opening the recording in Documents From 842b1090176613643b324e2d9c83c9c486efb794 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Fri, 23 Oct 2026 23:00:18 +0100 Subject: [PATCH 9/9] fix: The assistant is told the accepted date format when it asks for recordings of a relative date - EXO-91998 get_meeting_recordings answered a date it could not read, such as "yesterday", with the REST layer's bare message code, so the model had nothing to correct its call with. The tool now refuses it with a message naming the argument, the accepted formats with an example, and asking the model to resolve the relative expression itself; the date arguments' descriptions state the same. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 9118d3e2e69bc598b8bd7167ccab65c2f7c04b34) --- .../mcp/MeetingRecordingMcpTool.java | 51 ++++++++++++--- .../main/resources/ai-tool-definitions.json | 4 +- .../mcp/MeetingRecordingMcpToolTest.java | 62 +++++++++++++++++++ 3 files changed, 108 insertions(+), 9 deletions(-) diff --git a/services/src/main/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpTool.java b/services/src/main/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpTool.java index 1ac94b735..27954f230 100644 --- a/services/src/main/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpTool.java +++ b/services/src/main/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpTool.java @@ -16,6 +16,7 @@ */ package org.exoplatform.webconferencing.mcp; +import java.util.Date; import java.util.List; import org.apache.commons.lang3.StringUtils; @@ -50,6 +51,17 @@ public class MeetingRecordingMcpTool implements McpToolPlugin { /** The reason of an empty list: the link is not one of an active provider's meetings. */ public static final String OTHER_PROVIDER = "OTHER_PROVIDER"; + /** + * The answer to a date argument that cannot be read: it names the argument, + * the value, the accepted formats with an example, and asks the model to + * resolve a relative expression itself. + */ + public static final String INVALID_DATE = + "Invalid '%s' value '%s'. Provide an RFC3339 date-time (e.g. 2026-10-23T09:00:00Z) " + + "or a date yyyy-MM-dd (e.g. 2026-10-23), in UTC. Resolve a relative " + + "expression such as 'yesterday' or 'last week' into such a date yourself " + + "before calling the tool again."; + private final WebConferencingService webConferencingService; /** @@ -68,10 +80,15 @@ public MeetingRecordingMcpTool(WebConferencingService webConferencingService) { * * @param conferenceUrl the meeting link, as {@code get_event_conference} * returns it - * @param recordedFrom the earliest recording date, ISO 8601, optional - * @param recordedTo the end of the range, ISO 8601, optional: the server adds - * its grace, since a recording is saved after its meeting ends + * @param recordedFrom the earliest recording date, RFC3339 or + * {@code yyyy-MM-dd}, optional + * @param recordedTo the end of the range, RFC3339 or {@code yyyy-MM-dd}, + * optional: the server adds its grace, since a recording is saved + * after its meeting ends * @return the recordings, or an empty list with its reason + * @throws IllegalArgumentException when the link is missing, or a date is not + * in an accepted format: its message, {@link #INVALID_DATE}, tells + * the model how to call again * @throws IllegalAccessException when the user may not read the meeting's * recordings: not a member of its space, or not a participant */ @@ -81,14 +98,14 @@ public MeetingRecordingsModel getMeetingRecordings(String conferenceUrl, if (StringUtils.isBlank(conferenceUrl)) { throw new IllegalArgumentException("conference_url is mandatory: pass the url get_event_conference returns"); } + Date from = parseDateArgument("recorded_from", recordedFrom, false); + Date to = parseDateArgument("recorded_to", recordedTo, true); try { List recordings = webConferencingService.getCallRecordingsByConference(conferenceUrl, getCurrentUserName(), - RecordingDateUtils.parseDate(recordedFrom, - false), - RecordingDateUtils.parseDate(recordedTo, - true)) + from, + to) .stream() .map(MeetingRecordingMcpTool::toModel) .toList(); @@ -99,6 +116,26 @@ public MeetingRecordingsModel getMeetingRecordings(String conferenceUrl, } } + /** + * Reads a date argument of the tool. An unreadable value is refused with a + * message the model can act on, the shape the platform's own tools use for + * bad input, rather than the REST layer's message code. + * + * @param name the argument's name, as the model sends it + * @param value the argument's value, or a blank value for none + * @param endOfRange true when the date closes the range + * @return the date, or null when the value is blank + * @throws IllegalArgumentException with {@link #INVALID_DATE} when the value + * is not in an accepted format + */ + private static Date parseDateArgument(String name, String value, boolean endOfRange) { + try { + return RecordingDateUtils.parseDate(value, endOfRange); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(INVALID_DATE.formatted(name, value), e); + } + } + /** * Maps a recording to the tool's answer. * diff --git a/services/src/main/resources/ai-tool-definitions.json b/services/src/main/resources/ai-tool-definitions.json index d09a6cc1a..042e1144b 100644 --- a/services/src/main/resources/ai-tool-definitions.json +++ b/services/src/main/resources/ai-tool-definitions.json @@ -14,11 +14,11 @@ }, "recorded_from": { "type": "string", - "description": "Optional. Earliest recording date, RFC3339 (2026-10-23T09:00:00Z) or a date (2026-10-23)." + "description": "Optional. Earliest recording date: an RFC3339 date-time (2026-10-23T09:00:00Z) or a date yyyy-MM-dd (2026-10-23, the start of that day in UTC). Nothing else is accepted: resolve a relative expression such as 'yesterday' or 'last week' into such a date yourself." }, "recorded_to": { "type": "string", - "description": "Optional. End of the meeting or occurrence, RFC3339 or a date; recordings saved shortly after it still count." + "description": "Optional. End of the meeting or occurrence: an RFC3339 date-time (2026-10-23T10:00:00Z) or a date yyyy-MM-dd (2026-10-23, the end of that day in UTC); recordings saved shortly after it still count. Nothing else is accepted: resolve a relative expression such as 'yesterday' into such a date yourself." } }, "required": [ diff --git a/services/src/test/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpToolTest.java b/services/src/test/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpToolTest.java index 7af3198ab..b8a3cf4e3 100644 --- a/services/src/test/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpToolTest.java +++ b/services/src/test/java/org/exoplatform/webconferencing/mcp/MeetingRecordingMcpToolTest.java @@ -173,6 +173,68 @@ void invalidArgumentsAreRefused() throws Exception { verify(webConferencingService, never()).getCallRecordingsByConference(anyString(), anyString(), any(), any()); } + /** + * A relative date is not read: the refusal names the argument and its value, + * the accepted formats with an example, and asks the model to resolve the + * expression itself, instead of the REST layer's message code. + */ + @Test + void anUnreadableDateTellsTheModelHowToCallAgain() { + String fromMessage = assertThrows(IllegalArgumentException.class, + () -> tool.getMeetingRecordings(URL, "yesterday", null)).getMessage(); + assertEquals("Invalid 'recorded_from' value 'yesterday'. Provide an RFC3339 date-time (e.g. 2026-10-23T09:00:00Z) " + + "or a date yyyy-MM-dd (e.g. 2026-10-23), in UTC. Resolve a relative expression such as 'yesterday' or " + + "'last week' into such a date yourself before calling the tool again.", fromMessage); + String toMessage = assertThrows(IllegalArgumentException.class, + () -> tool.getMeetingRecordings(URL, "2026-10-22", "last week")).getMessage(); + assertTrue(toMessage.startsWith("Invalid 'recorded_to' value 'last week'."), toMessage); + } + + /** + * The accepted formats: an RFC3339 date-time with an offset, a date-time + * without one read in UTC, and a bare date standing for the start of its day, + * or for its end when it closes the range. + * + * @throws Exception never + */ + @Test + void theAcceptedDateFormatsAreRead() throws Exception { + when(webConferencingService.getCallRecordingsByConference(any(), any(), any(), any())).thenReturn(List.of()); + + tool.getMeetingRecordings(URL, "2026-10-22T09:00:00+02:00", "2026-10-22T10:00:00"); + verify(webConferencingService).getCallRecordingsByConference(URL, + "john", + Date.from(Instant.parse("2026-10-22T07:00:00Z")), + Date.from(Instant.parse("2026-10-22T10:00:00Z"))); + + tool.getMeetingRecordings(URL, "2026-10-22", "2026-10-22"); + verify(webConferencingService).getCallRecordingsByConference(URL, + "john", + Date.from(Instant.parse("2026-10-22T00:00:00Z")), + Date.from(Instant.parse("2026-10-22T23:59:59.999Z"))); + } + + /** + * The date arguments' descriptions name the accepted formats with an + * example, and tell the model to resolve relative expressions itself. + */ + @Test + void theDateArgumentsDescribeTheirFormat() { + Map definition = definitions().stream() + .filter(tool -> "get_meeting_recordings".equals(tool.get("name"))) + .findFirst() + .orElseThrow(); + @SuppressWarnings("unchecked") + Map> properties = + (Map>) ((Map) definition.get("input_schema")).get("properties"); + for (String argument : List.of("recorded_from", "recorded_to")) { + String description = (String) properties.get(argument).get("description"); + assertTrue(description.contains("RFC3339 date-time (2026-10-23T"), description); + assertTrue(description.contains("yyyy-MM-dd (2026-10-23"), description); + assertTrue(description.contains("resolve a relative expression such as 'yesterday'"), description); + } + } + /** * Every public method of the tool has an entry in the definitions file, and * every entry a method: the server drops a tool without one silently.