From 9229d5d2cc53518e81902d33e6f44899440f5bb3 Mon Sep 17 00:00:00 2001 From: Iva Horn Date: Mon, 2 Nov 2026 16:47:32 +0100 Subject: [PATCH 1/2] Closes #77: Extended Talk Support --- AGENTS.md | 20 +- README.md | 3 +- .../Documentation.docc/ActivityStream.md | 24 + .../Documentation.docc/Authenticating.md | 22 + .../Documentation.docc/Collectives.md | 18 + .../CommandLineInterface.md | 96 ++- .../Documentation.docc/CustomRequests.md | 28 + .../DiscoveringServerFeatures.md | 33 + .../Documentation.docc/Documentation.md | 265 +------- .../Documentation.docc/ManagingFiles.md | 46 ++ .../Documentation.docc/ObservingChanges.md | 22 + .../Documentation.docc/UserNotifications.md | 15 + Sources/Rainmaker/Documentation.docc/Users.md | 30 + .../Documentation.docc/WorkingWithNotes.md | 106 +++ .../Documentation.docc/WorkingWithTalk.md | 44 ++ Sources/Rainmaker/Models/Conversation.swift | 8 +- Sources/Rainmaker/Models/Talk.swift | 47 +- Sources/Rainmaker/Models/User.swift | 4 +- Sources/Rainmaker/RainmakerError.swift | 3 + .../Models/ChatAttachmentRequest.swift | 33 + .../Requests/Models/ChatMessageRequest.swift | 16 + .../Models/ConversationCreationRequest.swift | 21 + .../Models/AttachmentFolderResponse.swift | 37 ++ .../Models/ConversationResponse.swift | 51 ++ .../Responses/Models/UserSearchResponse.swift | 67 ++ Sources/Rainmaker/Server.swift | 268 ++++++++ Sources/Rainmaker/Serving.swift | 20 + .../RainmakerCLI/Commands/Conversations.swift | 162 ++++- Sources/RainmakerCLI/Commands/Rainmaker.swift | 2 +- .../RainmakerCLI/Commands/SearchUsers.swift | 66 ++ .../Fixtures/FixtureOrchestrator.swift | 5 +- .../Fixtures/FixtureProvisioner.swift | 47 +- .../Fixtures/FixtureRecordingError.swift | 7 + Tests/RainmakerTests/CapabilitiesTests.swift | 8 + .../ConversationMutationTests.swift | 103 +++ .../ConversationWritingRequestTests.swift | 604 ++++++++++++++++++ .../room/w2tqyhnp/participants/self/Body.json | 1 + .../w2tqyhnp/participants/self/Headers.txt | 2 + .../v2.php/apps/spreed/api/v4/room/Body.json | 1 + .../apps/spreed/api/v4/room/Headers.txt | 2 + .../v2.php/core/autocomplete/get/Body.json | 1 + .../v2.php/core/autocomplete/get/Headers.txt | 2 + .../v2.php/apps/spreed/api/v4/room/Body.json | 1 + .../apps/spreed/api/v4/room/Headers.txt | 2 + .../ocs/v2.php/cloud/capabilities/Body.json | 1 + .../ocs/v2.php/cloud/capabilities/Headers.txt | 2 + .../chat/7raa4vr8/attachment/folder/Body.json | 10 + .../7raa4vr8/attachment/folder/Headers.txt | 2 + .../v2.php/apps/spreed/api/v4/room/Body.json | 1 + .../apps/spreed/api/v4/room/Headers.txt | 2 + .../spreed/api/v1/chat/7raa4vr8/Body.json | 1 + .../spreed/api/v1/chat/7raa4vr8/Headers.txt | 2 + .../room/svq33xdq/participants/self/Body.json | 1 + .../svq33xdq/participants/self/Headers.txt | 2 + .../v2.php/apps/spreed/api/v4/room/Body.json | 1 + .../apps/spreed/api/v4/room/Headers.txt | 2 + .../v2.php/core/autocomplete/get/Body.json | 1 + .../v2.php/core/autocomplete/get/Headers.txt | 2 + .../v2.php/apps/spreed/api/v4/room/Body.json | 1 + .../apps/spreed/api/v4/room/Headers.txt | 2 + .../ocs/v2.php/cloud/capabilities/Body.json | 1 + .../ocs/v2.php/cloud/capabilities/Headers.txt | 2 + .../chat/fevjqik2/attachment/folder/Body.json | 1 + .../fevjqik2/attachment/folder/Headers.txt | 2 + .../v2.php/apps/spreed/api/v4/room/Body.json | 1 + .../apps/spreed/api/v4/room/Headers.txt | 2 + .../spreed/api/v1/chat/fevjqik2/Body.json | 1 + .../spreed/api/v1/chat/fevjqik2/Headers.txt | 2 + .../room/pkkdxo52/participants/self/Body.json | 1 + .../pkkdxo52/participants/self/Headers.txt | 2 + .../v2.php/apps/spreed/api/v4/room/Body.json | 1 + .../apps/spreed/api/v4/room/Headers.txt | 2 + .../v2.php/core/autocomplete/get/Body.json | 1 + .../v2.php/core/autocomplete/get/Headers.txt | 2 + .../v2.php/apps/spreed/api/v4/room/Body.json | 1 + .../apps/spreed/api/v4/room/Headers.txt | 2 + .../ocs/v2.php/cloud/capabilities/Body.json | 1 + .../ocs/v2.php/cloud/capabilities/Headers.txt | 2 + .../chat/tmfkmc2b/attachment/folder/Body.json | 1 + .../tmfkmc2b/attachment/folder/Headers.txt | 2 + .../v2.php/apps/spreed/api/v4/room/Body.json | 1 + .../apps/spreed/api/v4/room/Headers.txt | 2 + .../spreed/api/v1/chat/tmfkmc2b/Body.json | 1 + .../spreed/api/v1/chat/tmfkmc2b/Headers.txt | 2 + .../room/3uz93rwy/participants/self/Body.json | 1 + .../3uz93rwy/participants/self/Headers.txt | 2 + .../v2.php/apps/spreed/api/v4/room/Body.json | 1 + .../apps/spreed/api/v4/room/Headers.txt | 2 + .../v2.php/core/autocomplete/get/Body.json | 1 + .../v2.php/core/autocomplete/get/Headers.txt | 2 + .../v2.php/apps/spreed/api/v4/room/Body.json | 1 + .../apps/spreed/api/v4/room/Headers.txt | 2 + .../ocs/v2.php/cloud/capabilities/Body.json | 1 + .../ocs/v2.php/cloud/capabilities/Headers.txt | 2 + .../api/v1/chat/a54y8nsx/attachment/Body.json | 1 + .../v1/chat/a54y8nsx/attachment/Headers.txt | 2 + .../chat/a54y8nsx/attachment/folder/Body.json | 1 + .../a54y8nsx/attachment/folder/Headers.txt | 2 + .../Body.xml | 5 + .../Headers.txt | 2 + .../Headers.txt | 2 + .../v2.php/apps/spreed/api/v4/room/Body.json | 1 + .../apps/spreed/api/v4/room/Headers.txt | 2 + .../spreed/api/v1/chat/a54y8nsx/Body.json | 1 + .../spreed/api/v1/chat/a54y8nsx/Headers.txt | 2 + 105 files changed, 2194 insertions(+), 274 deletions(-) create mode 100644 Sources/Rainmaker/Documentation.docc/ActivityStream.md create mode 100644 Sources/Rainmaker/Documentation.docc/Authenticating.md create mode 100644 Sources/Rainmaker/Documentation.docc/Collectives.md create mode 100644 Sources/Rainmaker/Documentation.docc/CustomRequests.md create mode 100644 Sources/Rainmaker/Documentation.docc/DiscoveringServerFeatures.md create mode 100644 Sources/Rainmaker/Documentation.docc/ManagingFiles.md create mode 100644 Sources/Rainmaker/Documentation.docc/ObservingChanges.md create mode 100644 Sources/Rainmaker/Documentation.docc/UserNotifications.md create mode 100644 Sources/Rainmaker/Documentation.docc/Users.md create mode 100644 Sources/Rainmaker/Documentation.docc/WorkingWithNotes.md create mode 100644 Sources/Rainmaker/Documentation.docc/WorkingWithTalk.md create mode 100644 Sources/Rainmaker/Requests/Models/ChatAttachmentRequest.swift create mode 100644 Sources/Rainmaker/Requests/Models/ChatMessageRequest.swift create mode 100644 Sources/Rainmaker/Requests/Models/ConversationCreationRequest.swift create mode 100644 Sources/Rainmaker/Responses/Models/AttachmentFolderResponse.swift create mode 100644 Sources/Rainmaker/Responses/Models/ConversationResponse.swift create mode 100644 Sources/Rainmaker/Responses/Models/UserSearchResponse.swift create mode 100644 Sources/RainmakerCLI/Commands/SearchUsers.swift create mode 100644 Tests/RainmakerTests/ConversationMutationTests.swift create mode 100644 Tests/RainmakerTests/ConversationWritingRequestTests.swift create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/w2tqyhnp/participants/self/Body.json create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/w2tqyhnp/participants/self/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/attachment/folder/Body.json create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/attachment/folder/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/Body.json create mode 100644 Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/svq33xdq/participants/self/Body.json create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/svq33xdq/participants/self/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/attachment/folder/Body.json create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/attachment/folder/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/Body.json create mode 100644 Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/pkkdxo52/participants/self/Body.json create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/pkkdxo52/participants/self/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/attachment/folder/Body.json create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/attachment/folder/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/Body.json create mode 100644 Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/3uz93rwy/participants/self/Body.json create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/3uz93rwy/participants/self/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/Body.json create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/folder/Body.json create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/folder/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/PROPFIND/remote.php/dav/files/admin/Talk/Rainmaker-a54y8nsx/Draft/46fc6e52147fb69783fec2e4e7beab3b.txt/Body.xml create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/PROPFIND/remote.php/dav/files/admin/Talk/Rainmaker-a54y8nsx/Draft/46fc6e52147fb69783fec2e4e7beab3b.txt/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/PUT/remote.php/dav/files/admin/Talk/Rainmaker-a54y8nsx/Draft/46fc6e52147fb69783fec2e4e7beab3b.txt/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/Body.json create mode 100644 Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/Headers.txt diff --git a/AGENTS.md b/AGENTS.md index 316a548..fe66ee2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,26 +6,27 @@ The human readable introduction of this Swift Package is in [README.md](README.m - `Sources/` contains the Swift source code per target. - `Sources/Rainmaker/` contains the Swift source code for the main static library provided by this package. -- `Sources/Rainmaker/Server.swift` declares `Server` with the files, OCS and app features, while `Sources/Rainmaker/Server+Events.swift` and `Sources/Rainmaker/Server+Notes.swift` extend it with the event stream and the notes features respectively. The notes extension also holds the private helpers every notes feature is built on: the request factory for the notes API roots (`Server.notesAPIRoot`, and `Server.notesAttachmentAPIRoot` because older notes releases route attachments only below `v1.4`), the mapping of the notes app's statuses onto `RainmakerError` gated by its `X-Notes-API-Versions` header (read through `Extensions/HTTPURLResponse+notesAPIVersions.swift`), and the JSON decoding and encoding of payloads. The listing of changed notes reads its `Last-Modified`, `ETag` and chunk headers into `NoteChanges` through `Extensions/Date+httpDate.swift` and `Extensions/String+entityTag.swift`, the latter also quoting the entity tag a conditional request sends in `If-None-Match`. A chunked listing sends `chunkSize` and `chunkCursor` but never `category`, and `Sources/Rainmaker/NoteChunkPass.swift` is the actor `Server.noteChunks(changedSince:chunkSize:)` unfolds its stream from, one chunk per request, so that a chunk is only requested when the consumer asks for it and the request runs within the consumer's task. The summary listings (`Server.noteSummaries(changedSince:)`, its conditional and chunked counterparts and `Server.noteSummaryChunks(changedSince:chunkSize:)`) are the same requests with `exclude=content` added, never excluding `title`, because its presence is what tells a note sent in full from one reduced to its identifier. Both families share one private implementation, which is generic over the internal protocol `Sources/Rainmaker/NoteChangeSet.swift`: `NoteChanges` and `NoteSummaryChanges` conform to it through `Extensions/NoteChanges+NoteChangeSet.swift` and `Extensions/NoteSummaryChanges+NoteChangeSet.swift`, which also decide whether the content is excluded, the response DTO `NoteEntry` and `NoteChunkPass` are generic over the type of a note sent in full and of a chunk respectively, and the public methods of both families stay concrete and documented one by one. Creating, changing and deleting a note and changing the settings of the notes app are single requests as well, and a change of a note sends the entity tag it is based on in `If-Match`, normalized like `If-None-Match`, so that a note changed elsewhere surfaces as `RainmakerError.noteConflict(current:)` carrying the note the notes app sent along with its `412`. The attachment features live there as well and all go to `Server.notesAttachmentAPIRoot` with the path of the attachment as the `path` query parameter. Uploading and deleting go through the shared helpers, while retrieving an attachment into memory or into a file does not, because the notes app answers it without its version header and with a bare `404` for every failure, so `validateAttachmentRetrieval(_:)` maps its statuses instead. +- `Sources/Rainmaker/Server.swift` declares `Server` with the files, OCS and app features, while `Sources/Rainmaker/Server+Events.swift` and `Sources/Rainmaker/Server+Notes.swift` extend it with the event stream and the notes features respectively. The Talk features stay in `Server.swift` itself: listing conversations and retrieving their images, searching users through the server's autocompletion with `Server.searchUsers(_:)`, starting or finding a one-to-one conversation with `Server.createConversation(with:)` and sending into a conversation with `Server.sendMessage(_:toConversation:)` and `Server.sendFile(_:toConversation:caption:)`. The latter three post JSON bodies through the private `makeOCSRequest(for:method:jsonBody:)`, and the two sending methods map the chat's statuses through the private `validateChatResponse(_:expecting:)`. Sending a file asks Talk for the account's draft folder of the conversation, uploads the file there through the same private `uploadFile(_:to:force:chunkSize:)` as `Server.upload(_:to:force:chunkSize:)` under a name derived through `Server.uploadTransferName(for:size:modification:)`, so that a retry overwrites the draft left behind instead of adding another one and a recording replays, and then posts it, deleting the draft again when posting fails before Talk moved the file out of the draft folder. The notes extension also holds the private helpers every notes feature is built on: the request factory for the notes API roots (`Server.notesAPIRoot`, and `Server.notesAttachmentAPIRoot` because older notes releases route attachments only below `v1.4`), the mapping of the notes app's statuses onto `RainmakerError` gated by its `X-Notes-API-Versions` header (read through `Extensions/HTTPURLResponse+notesAPIVersions.swift`), and the JSON decoding and encoding of payloads. The listing of changed notes reads its `Last-Modified`, `ETag` and chunk headers into `NoteChanges` through `Extensions/Date+httpDate.swift` and `Extensions/String+entityTag.swift`, the latter also quoting the entity tag a conditional request sends in `If-None-Match`. A chunked listing sends `chunkSize` and `chunkCursor` but never `category`, and `Sources/Rainmaker/NoteChunkPass.swift` is the actor `Server.noteChunks(changedSince:chunkSize:)` unfolds its stream from, one chunk per request, so that a chunk is only requested when the consumer asks for it and the request runs within the consumer's task. The summary listings (`Server.noteSummaries(changedSince:)`, its conditional and chunked counterparts and `Server.noteSummaryChunks(changedSince:chunkSize:)`) are the same requests with `exclude=content` added, never excluding `title`, because its presence is what tells a note sent in full from one reduced to its identifier. Both families share one private implementation, which is generic over the internal protocol `Sources/Rainmaker/NoteChangeSet.swift`: `NoteChanges` and `NoteSummaryChanges` conform to it through `Extensions/NoteChanges+NoteChangeSet.swift` and `Extensions/NoteSummaryChanges+NoteChangeSet.swift`, which also decide whether the content is excluded, the response DTO `NoteEntry` and `NoteChunkPass` are generic over the type of a note sent in full and of a chunk respectively, and the public methods of both families stay concrete and documented one by one. Creating, changing and deleting a note and changing the settings of the notes app are single requests as well, and a change of a note sends the entity tag it is based on in `If-Match`, normalized like `If-None-Match`, so that a note changed elsewhere surfaces as `RainmakerError.noteConflict(current:)` carrying the note the notes app sent along with its `412`. The attachment features live there as well and all go to `Server.notesAttachmentAPIRoot` with the path of the attachment as the `path` query parameter. Uploading and deleting go through the shared helpers, while retrieving an attachment into memory or into a file does not, because the notes app answers it without its version header and with a bare `404` for every failure, so `validateAttachmentRetrieval(_:)` maps its statuses instead. - `Sources/Rainmaker/Requests/MultipartFormData.swift` writes the `multipart/form-data` body with a single file field that `Server.addAttachment(_:toNote:fileName:)` sends for either a local file or bytes in memory. The body is staged in a temporary file, copied through `Server.stagingBufferSize`, sent through an upload task and removed in a `defer`, so neither the file nor the body is held in memory. -- `Sources/Rainmaker/Requests/Models/` contains the internal `Encodable` data transfer objects for request bodies, the counterpart of `Sources/Rainmaker/Responses/Models` for what is sent rather than what is received. They carry the server's field names and leave out `nil` values, because the notes app only changes what a body names. `NoteCreationRequest` and `NoteUpdateRequest` are the bodies `Server.createNote(title:category:content:modification:isFavorite:)` and `Server.updateNote(_:title:category:content:modification:isFavorite:ifMatching:)` send, with moments converted to whole seconds through `Extensions/Date+wholeSecondsSince1970.swift`. `NotesSettingsUpdateRequest` is the body `Server.updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)` sends, for which leaving out `nil` matters even more, because the notes app resets a setting sent as `null` to its default. +- `Sources/Rainmaker/Requests/Models/` contains the internal `Encodable` data transfer objects for request bodies, the counterpart of `Sources/Rainmaker/Responses/Models` for what is sent rather than what is received. They carry the server's field names and leave out `nil` values, because the notes app only changes what a body names. `NoteCreationRequest` and `NoteUpdateRequest` are the bodies `Server.createNote(title:category:content:modification:isFavorite:)` and `Server.updateNote(_:title:category:content:modification:isFavorite:ifMatching:)` send, with moments converted to whole seconds through `Extensions/Date+wholeSecondsSince1970.swift`. `NotesSettingsUpdateRequest` is the body `Server.updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)` sends, for which leaving out `nil` matters even more, because the notes app resets a setting sent as `null` to its default. `ConversationCreationRequest`, `ChatMessageRequest` and `ChatAttachmentRequest` are the bodies `Server.createConversation(with:)`, `Server.sendMessage(_:toConversation:)` and `Server.sendFile(_:toConversation:caption:)` send to the Talk app, the last one carrying its caption as a JSON document in a string in `talkMetaData`, because Talk expects it encoded twice. - `Sources/Rainmaker/Requests/Bodies` contains static HTTP bodies for requests sent to a Nextcloud server. For example the uniform XML document when retrieving information about a WebDAV resource from the server. The reasoning is simplicity by having a plain file, ease of maintenance by making it editable like a standard XML document and performance by not always assembling it programmatically. - `Sources/Rainmaker/Extensions/` is for implementations of extensions of first-party or platform types. One source code file per extended type and added feature. `URLComponents+encodedQueryItems.swift` is what `Server.makeAppRequest(for:method:queryItems:)` and `Server.makeOCSRequest(for:method:queryItems:)` set their query with, because `URLComponents.queryItems` leaves `+`, `&` and `=` unencoded and the server would read a `+` in a value as a space; `RequestFactoryTests` proves it. -- `Sources/Rainmaker/Models` contains Swift source code for data models which are also publicly available types. They do not necessarily mirror the structure and types as returned by the server in responses. They are meant to be as elegant and plausible as possible from a Swift client developer perspective, not necessarily mirroring the server responses exactly. Models a downstream project may want to fake, such as `Note`, `NotesSettings`, `LoginFlow`, `LoginResult` and `User`, offer a public memberwise initializer. `ShareType` is a struct wrapping the server's number with named constants rather than an enum, so that a kind of share a future server introduces still decodes, while `NoteMode` and `ServerEventTransport` are enums backed by the strings the server and the CLI use. `NoteSummary` is a `Note` without its content and without `hasError` and `errorType`, because the notes app only notices a failure to read a note while reading the content a summary listing asks it to leave out, and `NoteSummaryChanges` mirrors `NoteChanges` with summaries in place of notes. The `Notes` capability also compares releases of the notes app (`Notes.isAppVersion(atLeast:)`), because some of its behaviours, like where uploaded attachments land, are tied to a release rather than to the notes API version; `Tests/RainmakerTests/NotesCapabilityVersionTests.swift` covers that comparison. `NoteAttachment` and `NoteAttachmentFile` are the results of retrieving an attachment into memory and into a local file respectively, and like `ConversationAvatar` they carry the MIME type the server states but deliberately no file name. +- `Sources/Rainmaker/Models` contains Swift source code for data models which are also publicly available types. They do not necessarily mirror the structure and types as returned by the server in responses. They are meant to be as elegant and plausible as possible from a Swift client developer perspective, not necessarily mirroring the server responses exactly. Models a downstream project may want to fake, such as `Note`, `NotesSettings`, `LoginFlow`, `LoginResult` and `User`, offer a public memberwise initializer. `ShareType` is a struct wrapping the server's number with named constants rather than an enum, so that a kind of share a future server introduces still decodes, while `NoteMode` and `ServerEventTransport` are enums backed by the strings the server and the CLI use. `NoteSummary` is a `Note` without its content and without `hasError` and `errorType`, because the notes app only notices a failure to read a note while reading the content a summary listing asks it to leave out, and `NoteSummaryChanges` mirrors `NoteChanges` with summaries in place of notes. The `Notes` capability also compares releases of the notes app (`Notes.isAppVersion(atLeast:)`), because some of its behaviours, like where uploaded attachments land, are tied to a release rather than to the notes API version; `Tests/RainmakerTests/NotesCapabilityVersionTests.swift` covers that comparison. The `Talk` capability decodes a single flag of the app's configuration, `storesAttachmentsPerConversation` from `config` → `attachments` → `conversation-subfolders`, which only Talk 24 and newer advertise and which `Server.sendFile(_:toConversation:caption:)` requires, so that it decodes as `false` when absent; of the recorded capabilities only those of 34.0.0 carry it. `NoteAttachment` and `NoteAttachmentFile` are the results of retrieving an attachment into memory and into a local file respectively, and like `ConversationAvatar` they carry the MIME type the server states but deliberately no file name. - `Sources/Rainmaker/NoteContent/` contains pure helpers which need no `Server`, so that downstream apps such as Shortcuts actions do not reimplement conventions of the notes app and of the Nextcloud Text app: `NoteTitle` derives the title the notes app's web interface gives a note from its content and sanitizes a typed title, `NoteCategory` sanitizes a category, `NoteAttachmentReference` encodes, decodes and builds the Markdown references to attachments the way Text writes them, and `NoteAttachmentPath` resolves the path of an attachment relative to a note's category as the notes app does when serving it. They are public caseless enums used as namespaces, and the internal `NoteNameSanitizer` and `NoteMarkdownStripper` hold the rules the title and the category share and the removal of Markdown syntax before deriving a title. They work on Unicode scalars, because the notes app counts and matches code points, and the white space they recognize is the fixed list of the notes app's pattern matching rather than a Unicode property. They were re-implemented from observed behaviour and checked against it, never copied from the AGPL-licensed sources, and every pass runs in time proportional to the length of the content except the removal of emphasis, whose worst case, a line of ever shorter runs of delimiters, grows with the length of the line times its square root, so that pathological content cannot stall a caller. -- `Sources/Rainmaker/Responses/Models` contains Swift source code for data models which actually enable the use of Swift's `Decodable` for server response data. They are not meant to be exposed outside the Swift package module but only as an intermediate representation to simplify deserialization. `AttachmentUploadResponse` is the one the notes app answers an uploaded attachment with, which is reduced to the stored path it carries. `CurrentUserResponse` decodes only the identifier and the display name of the account details `Server.currentUser()` maps onto the public `User`, leaving out the many other details the server sends, so that a change to any of them cannot break the lookup. +- `Sources/Rainmaker/Responses/Models` contains Swift source code for data models which actually enable the use of Swift's `Decodable` for server response data. They are not meant to be exposed outside the Swift package module but only as an intermediate representation to simplify deserialization. `AttachmentUploadResponse` is the one the notes app answers an uploaded attachment with, which is reduced to the stored path it carries. `CurrentUserResponse` decodes only the identifier and the display name of the account details `Server.currentUser()` maps onto the public `User`, leaving out the many other details the server sends, so that a change to any of them cannot break the lookup. `UserSearchResponse` likewise decodes only the identifier and the label of every match of the autocompletion `Server.searchUsers(_:)` maps onto `User`, `ConversationResponse` wraps the single `Conversation` `Server.createConversation(with:)` returns in the OCS envelope, as `ConversationsResponse` wraps the listing, and `AttachmentFolderResponse` is reduced to the draft folder `Server.sendFile(_:toConversation:caption:)` uploads into. - `Sources/Rainmaker/Push/` contains the `notify_push` WebSocket transport and the coordinator behind `Server.events(_:)`. With the default `ServerEventTransport.automatic` (`Sources/Rainmaker/Models/ServerEventTransport.swift`, chosen through `ServerEventOptions.transport`) the coordinator prefers the WebSocket when the server advertises the capability and falls back to polling otherwise, and it also polls for `rediscoverInterval` after `maximumAuthenticationAttempts` consecutive authentication rejections or `maximumConnectionFailures` consecutive connections which ended before authenticating, a connection which authenticated resetting the latter count. With `ServerEventTransport.polling`, and on watchOS where `ServerEventCoordinator.platformSupportsWebSocket` is `false`, it only polls and never requests the capabilities or opens a socket. `SessionOutcome.swift` is what one WebSocket session reduces to for those decisions and `PushTarget.swift` the endpoint and subjects derived from the capability, each in a file of its own beside `ServerEventCoordinator.swift`. The mockable WebSocket abstraction protocols (`WebSocketConnecting`, `WebSocketChannel`, `WebSocketFrame`) live alongside `Requesting` in `Sources/Rainmaker/Requests/`, mirroring how the HTTP session is abstracted. `PendingPing` (with its `PendingPingState`) is what `URLSessionWebSocketChannel.sendPing()` waits through: it resumes the waiting task exactly once, because `URLSessionWebSocketTask.sendPing(pongReceiveHandler:)` has been seen calling its handler more than once for one ping, concurrently, when a connection is torn down, and also never calling it at all, which is why the waiting task's cancellation competes for the same outcome. Neither URLSession's `receive()` nor its pong handler observes task cancellation, so `PushNotificationsConnection` ends a session by closing its channel rather than by cancelling its tasks, and bounds every ping with a pong timeout. -- `Sources/RainmakerCLI/` contains the Swift source code for the accompanying command line utility which enables the usage of the library in a terminal environment without any additional upstream project. Each top-level command lives in a file of its own in `Sources/RainmakerCLI/Commands/` and is registered alphabetically in `Rainmaker.swift`, such as `CurrentUser.swift` for `current-user` and `Poll.swift`, which polls a login flow through `Server.poll(_:)` until it is no longer pending. +- `Sources/RainmakerCLI/` contains the Swift source code for the accompanying command line utility which enables the usage of the library in a terminal environment without any additional upstream project. Each top-level command lives in a file of its own in `Sources/RainmakerCLI/Commands/` and is registered alphabetically in `Rainmaker.swift`, such as `CurrentUser.swift` for `current-user`, `SearchUsers.swift` for `search-users`, which prints the identifiers `conversations create` takes, and `Poll.swift`, which polls a login flow through `Server.poll(_:)` until it is no longer pending. `Conversations.swift` declares the `conversations` group together with all of its subcommands, `list` as the default, `avatar`, `create`, `send` and `send-file`, of which `create` takes the other user as `--user-id`, because `--user` is the account the CLI authenticates as. - `Sources/RainmakerCLI/Commands/Notes/` contains the `notes` command group: `Notes.swift` declares the group with the helpers its subcommands share, and each subcommand lives in a file of its own as an extension of it (`Notes+List.swift`, the default subcommand which keeps `notes --changed-since` working and whose `--summaries` flag lists through the summary listings instead, `Notes+Get.swift`, `Notes+Create.swift`, `Notes+Update.swift` and `Notes+Delete.swift`). `Notes+Attachment.swift` declares the nested `notes attachment` group, whose `get`, `add` and `delete` subcommands live in `Notes+Attachment+Get.swift`, `Notes+Attachment+Add.swift` and `Notes+Attachment+Delete.swift`, with `get` streaming into a file through `Server.downloadAttachment(at:ofNote:to:force:)` when given `--output`, as `conversations avatar` writes its image. The settings of the notes app stay with the top-level `notes-settings` command in `Sources/RainmakerCLI/Commands/NotesSettings.swift`, which changes them when any setting is given and looks them up otherwise, and whose `--note-mode` option takes `NoteMode` values through `Sources/RainmakerCLI/NoteMode+ExpressibleByArgument.swift`, as `ActivitySort+ExpressibleByArgument.swift` does for the activities and `ServerEventTransport+ExpressibleByArgument.swift` for the `--transport` option of `watch`. The group shadows the library's `Notes` capability within the CLI module, and `Rainmaker.Notes` does not reach it either because the CLI's root command is called `Rainmaker`, so the CLI does not refer to that capability. - `Sources/RainmakerCLI/Commands/RecordFixtures.swift` and `Sources/RainmakerCLI/Fixtures/` implement the `record-fixtures` subcommand which automates the creation of test fixtures. It is macOS-only: it deploys ephemeral Nextcloud containers via the `NextcloudContainerManager` package, runs the test suite against them in recording mode to capture real responses into `Tests/RainmakerTests/Responses/`, and verifies the captures replay without a server. Containers are deployed with the apps a suite's fixtures depend on but which are not part of a Nextcloud installation, currently the notes app and the Talk app, which makes a recording run depend on the app store being reachable. All of its Docker-facing code is guarded with `#if os(macOS)` so the simulator builds compile the CLI without it. - `Sources/RainmakerTestServerTags/` is a small internal module holding `ServerVersion`, the single source of truth for the supported Nextcloud versions. Both the test target and the CLI's fixture recorder depend on it, so the version list is declared once. -- `Sources/Rainmaker/Documentation.docc/` is a DocC documentation catalog to provide additional documentation the one automatically derived from source code comments and symbol documentation in Swift source code. This is the place for documentation articles targeting developers which are using this library and package. `Documentation.md` curates every public symbol into topics, `SynchronizingNotes.md` explains how a client keeps its own copy of the notes in step with the server and `UsingNotesFromAppIntents.md` how single actions such as those of Shortcuts use the notes features, both linked from the Notes topic. `CommandLineInterface.md` holds verbatim help listings of the CLI, which are regenerated with `COLUMNS=1000 swift run rainmaker-cli --help` so that they carry no line wrapping, whenever a command or its help text changes. +- `Sources/Rainmaker/Documentation.docc/` is a DocC documentation catalog to provide additional documentation the one automatically derived from source code comments and symbol documentation in Swift source code. This is the place for documentation articles targeting developers which are using this library and package. `Documentation.md` is the front page and lists only `Server`, `RainmakerError` and one article per feature, which curates the `Server` methods and the types of that feature into topics of its own and explains them in its overview: `ManagingFiles.md` (files and trash bin), `Authenticating.md`, `Users.md` (current user, user search and avatars), `DiscoveringServerFeatures.md` (capabilities and apps navigation), `ObservingChanges.md`, `ActivityStream.md`, `UserNotifications.md`, `WorkingWithNotes.md`, `WorkingWithTalk.md`, `Collectives.md` and `CustomRequests.md` (request factories and transport abstractions). Every public symbol is curated in exactly one of those articles, because DocC dumps a symbol curated nowhere into a generated group, and the file names differ from the symbols `Notes`, `Talk`, `Activity` and `Notifications` so that a `` link never resolves to the capability of the same name. A topic group's description must stay a single paragraph, because a blank line makes DocC start a Discussion section in the middle of the topics, so longer prose belongs in an article's overview. `SynchronizingNotes.md` explains how a client keeps its own copy of the notes in step with the server and `UsingNotesFromAppIntents.md` how single actions such as those of Shortcuts use the notes features, both curated by `WorkingWithNotes.md`. `CommandLineInterface.md` holds verbatim help listings of the CLI, which are regenerated with `COLUMNS=1000 swift run rainmaker-cli --help` so that they carry no line wrapping, whenever a command or its help text changes. - `Tests/` contains the automated tests per target. - `Tests/RainmakerTests/Responses/` contains static test fixtures which are the HTTP response bodies of actual server responses. They either are in JSON or XML format. - `Tests/RainmakerTests/URLTestSession.swift` replays those fixtures during normal test runs, while `Tests/RainmakerTests/URLRecordingSession.swift` is its recording counterpart used by the `record-fixtures` subcommand. An upload whose response has a body, such as that of an attachment of a note, has the body recorded under the extension its `Accept` header maps to and replayed from there, while uploads answered without a body, like those over WebDAV, keep having no body file. Both derive fixture paths through the shared `Tests/RainmakerTests/FixtureLocator.swift` so recording and replay can never diverge, and `Tests/RainmakerTests/FixtureCanonicalizer.swift` normalizes recorded responses (canonical host, redacted volatile fields) so fixtures stay stable. Like its body rules, the response header fields it keeps depend on the request path: responses of the notes app additionally keep `ETag`, `Last-Modified`, `X-Notes-Chunk-Cursor` (all three with fixed canonical values) and `X-Notes-Chunk-Pending`, which every other response drops. `Tests/RainmakerTests/NoteChangesRequestTests.swift` covers reading those headers, the conditional request and the chunked listing with its stream with `MockRequesting`, because a replayed fixture neither carries live header values nor proves which request headers were sent and cannot answer a loop of chunks which share method and path, and it proves with `SuspendingRequesting.swift`, a double keeping a request in flight until its task is cancelled, that cancelling the stream's task cancels its request, while `EntityTagTests.swift` and `HTTPDateTests.swift` cover the two conversions. `Tests/RainmakerTests/NoteSummaryRequestTests.swift` covers the `exclude=content` query item of every summary listing, the decoding of summaries alongside identifiers, `nil` for `304` and the stream of chunks of summaries in the same way, while `NotesTests.fetchSummaries` and `NotesTests.fetchSummariesFirstChunk` record a summary listing and its first chunk. `Tests/RainmakerTests/SingleNoteRequestTests.swift` does the same for the lookup of a single note through `Server.note(_:)` and `Server.note(_:ifChangedFrom:)`, whose recorded counterparts in `NotesTests` take the identifier from a listing recorded in the same test, because the fixture of a lookup is filed under the identifier the server assigned. `Tests/RainmakerTests/NoteMutationTests.swift` records creating, changing and deleting notes, taking the identifier of every request after the creation from the recorded creation, as well as changing the settings of the notes app to the value they already have, while `Tests/RainmakerTests/NoteWritingRequestTests.swift` covers the request bodies, `If-Match`, the conflict and the other statuses of those calls with `MockRequesting`. `Tests/RainmakerTests/NoteAttachmentTests.swift` records adding, retrieving, downloading and deleting attachments, branching on the recorded capabilities because notes 5.0.2 and 6.1.0 store and delete them differently, and `Tests/RainmakerTests/NoteAttachmentRequestTests.swift` covers the encoding of their paths, the multipart body, the removal of the staged body and the statuses with `MockRequesting`, which answers downloads too and records the temporary files it hands them over in as well as the files uploads are sent from, so that the removal of both can be checked, and with `SuspendingRequesting`, which suspends uploads as it suspends data requests and downloads when asked to, so that cancelling a download is covered as well. `Tests/RainmakerTests/ServerTesting.swift` selects between the two sessions based on the `RAINMAKER_FIXTURE_RECORD` environment variable. - `Tests/RainmakerTests/NoteTitleTests.swift`, `NoteCategoryTests.swift`, `NoteAttachmentReferenceTests.swift` and `NoteAttachmentPathTests.swift` cover the helpers in `Sources/Rainmaker/NoteContent/` with table-driven Swift Testing arguments and neither fixtures nor request doubles, comparing strings scalar by scalar so that a decomposed letter does not pass for its composed form. - `Tests/RainmakerTests/LoginRequestTests.swift` covers how `Server.login()` and `Server.poll(_:)` map statuses and bodies onto their results with `MockRequesting`, including a pending flow as `nil`, a page which is not Nextcloud as `RainmakerError.responseDecodingFailed(reason:)` and the cancellation of a poll with `SuspendingRequesting`, while `LoginTests` and `PollTests` replay fixtures, those of `PollTests` hand-authored because a successful poll needs a flow completed in a browser. The tests of the deprecated `Server.poll(_:token:)` call it through `Serving`, whose requirement is deliberately not deprecated, so that they keep proving its unchanged behaviour without deprecation warnings. - `Tests/RainmakerTests/CurrentUserTests.swift` records `Server.currentUser()` against the account `admin`, while `Tests/RainmakerTests/CurrentUserRequestTests.swift` covers its request, an account whose identifier differs from its login name, rejected credentials, undecodable bodies and the cancellation with `MockRequesting` and `SuspendingRequesting`. The account details carry the moments of the first and the last login and the quota, which `FixtureCanonicalizer` replaces with fixed numbers for responses below `/cloud/user`, and `FixtureCanonicalizerTests` proves that those rules apply nowhere else. +- `Tests/RainmakerTests/ConversationMutationTests.swift` records searching users, starting a one-to-one conversation and sending a message and a file into the conversation `FixtureProvisioner` seeds, taking every token from a listing or a creation recorded in the same test. It leaves the one-to-one conversation it starts rather than deleting it, because Talk refuses to delete one unless an administrator allowed it, and the messages it sends stay in the seeded conversation, because a message cannot be taken back. Sending a file records the capabilities first and branches on `Talk.storesAttachmentsPerConversation`, expecting `RainmakerError.notFound` from releases before Talk 24, and pins the modification date of the file, because the name of its draft is derived from it. `Tests/RainmakerTests/ConversationWritingRequestTests.swift` covers the query of the search, the request bodies, the order of the requests of sending a file, the deletion of its draft when posting fails and the mapping of the statuses with `MockRequesting`, and the cancellation of sending a message with `SuspendingRequesting`. - The `Server.events(_:)` tests do not use fixtures: they drive hand-authored WebSocket and request doubles (`Tests/RainmakerTests/MockWebSocketConnecting.swift`, `MockWebSocketChannel.swift`, `MockRequesting.swift`) so the WebSocket handshake, frame mapping, polling fallback, reconnection, and teardown are exercised deterministically without a server. `MockWebSocketConnecting.openedChannelCount` proves how often a socket was opened, which is none at all with the polling transport and on watchOS, and the tests which need a socket are enabled only where `ServerEventCoordinator.platformSupportsWebSocket` is `true`, while one compiled only for watchOS proves that the automatic transport polls there. `MockPingBehavior` scripts whether a mock channel answers pings, and `MockWebSocketClosure` records that a channel was closed and lets its receiving ignore task cancellation until then, as `URLSessionWebSocketTask` does, so the teardown is tested against the behaviour it actually has to cope with. - `Tests/RainmakerTests/PendingPingTests.swift` drives `PendingPing` directly, a `URLSessionWebSocketTask` being impossible to substitute: it stores the pong handler a ping hands out and calls it the way the framework has been seen to, later, repeatedly, concurrently, or never. `Eventually.swift` is the polling wait those tests and the teardown tests use instead of `WithTimeout.swift`, which cannot bound an operation that ignores cancellation, and `LockedValue.swift` is the lock-protected box they share state with `@Sendable` closures through. - `Rainmaker.png` is a static artwork file for presentation on the web and can be ignored. @@ -54,11 +55,12 @@ The human readable introduction of this Swift Package is in [README.md](README.m - The fixtures are regenerated by the `record-fixtures` subcommand of `rainmaker-cli`, which requires macOS and a running Docker daemon. It is never run in continuous integration. - Run `swift run rainmaker-cli record-fixtures` to record every supported version, or scope it, e.g. `swift run rainmaker-cli record-fixtures --version 32.0.11 --filter ListingTests`. - The subcommand deploys a Nextcloud container per version, provisions a baseline, records each test against it (writing into `Tests/RainmakerTests/Responses/`), and finally replays the captures with `swift test` to prove they work without a server. Review the result with `git diff` before committing. -- Each test is recorded by a `swift test` invocation of its own, so a full run takes long, about 25 minutes per version for the 80 recordable tests and well over an hour and a half for all four versions, and a run of a single version can be recorded with `--no-verify` and checked with `swift test` once every version is done. `--filter` is a plain substring of the `Suite/method` identifier, `--no-provision` skips the baseline for suites which need none, such as `CapabilitiesTests` and `CurrentUserTests`, and `--keep-running` leaves the container up for inspection. Build the tests with `swift build --build-tests` first: a test target which does not compile makes the recorder log `Recording 0 test(s)` and still exit successfully. Do not change Swift sources while a recording runs, because every test's invocation builds the package anew. -- Two apps the fixtures depend on are not part of a Nextcloud installation and are installed into every container from the app store, declared as `FixtureOrchestrator.installedApps`: the notes app for `NotesTests`, `NoteMutationTests` and `NoteAttachmentTests` and the Talk app (`spreed`) for `ConversationsTests`. That is why the recorded capabilities and navigation fixtures advertise `notes` and `spreed`, and it means recording needs the app store to be reachable. It also means the fixtures of those two suites track a release of their app rather than of the server, which is why their assertions rest on state `FixtureProvisioner` seeds rather than on whatever the app creates by itself. The app store currently hands Nextcloud 31 and 32 the notes app 5.0.2 and Nextcloud 33 and newer the notes app 6.1.0. A test whose expectations differ between those releases records the capabilities alongside its own requests and branches on `Notes.isAppVersion(atLeast:)` rather than on the server version, as `NotesTests.settings` does. +- Each test is recorded by a `swift test` invocation of its own, so a full run takes long, about 25 minutes per version for the 84 recordable tests and well over an hour and a half for all four versions, and a run of a single version can be recorded with `--no-verify` and checked with `swift test` once every version is done. `--filter` is a plain substring of the `Suite/method` identifier, `--no-provision` skips the baseline for suites which need none, such as `CapabilitiesTests` and `CurrentUserTests`, and `--keep-running` leaves the container up for inspection. Build the tests with `swift build --build-tests` first: a test target which does not compile makes the recorder log `Recording 0 test(s)` and still exit successfully. Do not change Swift sources while a recording runs, because every test's invocation builds the package anew. +- Two apps the fixtures depend on are not part of a Nextcloud installation and are installed into every container from the app store, declared as `FixtureOrchestrator.installedApps`: the notes app for `NotesTests`, `NoteMutationTests` and `NoteAttachmentTests` and the Talk app (`spreed`) for `ConversationsTests` and `ConversationMutationTests`. That is why the recorded capabilities and navigation fixtures advertise `notes` and `spreed`, and it means recording needs the app store to be reachable. It also means the fixtures of those two suites track a release of their app rather than of the server, which is why their assertions rest on state `FixtureProvisioner` seeds rather than on whatever the app creates by itself. The app store currently hands Nextcloud 31 and 32 the notes app 5.0.2 and Nextcloud 33 and newer the notes app 6.1.0. A test whose expectations differ between those releases records the capabilities alongside its own requests and branches on `Notes.isAppVersion(atLeast:)` rather than on the server version, as `NotesTests.settings` does. - The chunked upload tests in `UploadTests` write files just above `Server.minimumChunkSize` (5 MiB), because the library raises smaller chunk sizes to it, and they pin the local modification date: the folder a chunked upload is staged in is named deterministically from the remote path, the size and the modification date of the file, which is what keeps its `MKCOL`, `PUT` and `MOVE` fixtures replayable. -- Fixtures of the notes app keep the headers `NoteChanges` is read from, with `ETag`, `Last-Modified` and `X-Notes-Chunk-Cursor` replaced by canonical values (see `FixtureCanonicalizer.notesHeaderFields`), so tests only assert that those values are present. Every recording of `NotesTests` also reassigns note identifiers and may reorder the listings, and so does every other recording in its own way: WebDAV listings come in another order and carry new upload times, the Talk conversations and login flows carry new tokens, the notes created by `NoteMutationTests` and `NoteAttachmentTests` get new identifiers and therefore new fixture folders, and the capabilities pick up the releases the app store currently hands out. Keep such changes only when a test needs them or the same fixture changed for a reason anyway, and revert them otherwise, so that a re-recording only shows what the server actually answers differently. A test which sends the same method and path twice, such as a conditional request answered with `304 Not Modified` after a full one, cannot be served by the fixture tree, because `FixtureLocator` ignores query strings and request headers; such tests use `MockRequesting` instead. +- Fixtures of the notes app keep the headers `NoteChanges` is read from, with `ETag`, `Last-Modified` and `X-Notes-Chunk-Cursor` replaced by canonical values (see `FixtureCanonicalizer.notesHeaderFields`), so tests only assert that those values are present. Every recording of `NotesTests` also reassigns note identifiers and may reorder the listings, and so does every other recording in its own way: WebDAV listings come in another order and carry new upload times, the Talk conversations, including the one-to-one conversation `ConversationMutationTests` starts, and login flows carry new tokens, the notes created by `NoteMutationTests` and `NoteAttachmentTests` get new identifiers and therefore new fixture folders, and the capabilities pick up the releases the app store currently hands out. Keep such changes only when a test needs them or the same fixture changed for a reason anyway, and revert them otherwise, so that a re-recording only shows what the server actually answers differently. A test which sends the same method and path twice, such as a conditional request answered with `304 Not Modified` after a full one, cannot be served by the fixture tree, because `FixtureLocator` ignores query strings and request headers; such tests use `MockRequesting` instead. - Tests which change notes, such as those of `NoteMutationTests` and `NoteAttachmentTests`, delete what they create and never change the notes `FixtureProvisioner` seeds. The notes app 5.0.2 cannot delete attachments and leaves them behind when their note is deleted, so `NoteAttachmentTests` deletes them over WebDAV through the folder of the note's `path` there, which records a WebDAV `DELETE` with the random name the server chose. The response body of an upload is recorded when it is not empty, so the path the notes app answers an uploaded attachment with replays. `FixtureProvisioner.reset()` is a forced upload of the baseline, which removes notes and category folders a test left behind but does not restore a seeded note which is newer on the server than in the baseline. A note created without a modification moment records the moment of the recording as its `modified`, which is why `NoteMutationTests` passes a fixed one for every note it creates. The settings of the notes app are kept per account rather than in files, so `reset()` does not restore them either, and a test which changes a setting sets it back to the baseline value itself. +- Besides the files of the baseline, `FixtureProvisioner` seeds a group conversation named `Rainmaker` for `ConversationsTests` and `ConversationMutationTests` and a second account, `alice` with the display name `Alice`, for `ConversationMutationTests`, whose search needs somebody to find and whose one-to-one conversation needs somebody to talk to. Conversations and accounts live outside the account's files, so `reset()` neither removes nor restores them and both survive for the rest of the container's life, which is why the seeding is idempotent and requested by every test of those suites. The account is created through the provisioning API as the administrator the recording authenticates as, and its random password is never used. - A handful of tests assume a server state different from the baseline. Those preconditions live in `FixtureProvisioner.applyPrecondition(forTest:)`. When the verification pass reports a test failing after recording, add or adjust its precondition there. - Adding a server version is a single edit: add a case to `ServerVersion` in the `RainmakerTestServerTags` module. Both the test parameterization and the recorder's default version list derive from it (or pass `--version` to scope a run). diff --git a/README.md b/README.md index ceaf0a5..8a4984a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![Tests](https://github.com/i2h3/rainmaker/actions/workflows/test.yml/badge.svg)](https://github.com/i2h3/rainmaker/actions/workflows/test.yml) [![REUSE](https://api.reuse.software/badge/github.com/i2h3/rainmaker)](https://api.reuse.software/info/github.com/i2h3/rainmaker) -A simple Swift library and CLI to access [Nextcloud](https://www.nextcloud.com) programmatically: files, notifications, activities, notes (including incremental and chunked synchronization, summaries without their text, conflict detection, attachments, and helpers which predict a note's title and category and reference attachments from its content the way the notes and Text apps do), collectives, Talk conversations and the current user. +A simple Swift library and CLI to access [Nextcloud](https://www.nextcloud.com) programmatically: files, notifications, activities, notes (including incremental and chunked synchronization, summaries without their text, conflict detection, attachments, and helpers which predict a note's title and category and reference attachments from its content the way the notes and Text apps do), collectives, Talk conversations (listing them, starting one-to-one conversations with users found by a search, and sending messages and files into them) and the current user. The library keeps no state between calls and needs no synchronization engine, so it suits a single action such as one of Shortcuts as well as a client keeping its own copy of an account's data. For further information, see [the documentation which is built from the source code and deployed to GitHub pages](https://i2h3.github.io/rainmaker/). @@ -19,6 +19,7 @@ For further information, see [the documentation which is built from the source c - iOS 15, macOS 12, tvOS 15, visionOS 1 or watchOS 8 or newer. - A Nextcloud server. Every release is verified against the versions listed in `ServerVersion` in `Sources/RainmakerTestServerTags/`, currently Nextcloud 31, 32, 33 and 34. - For the notes features, the notes app serving its API in version 1.4 or newer, which it does since release 4.12.3. Deleting attachments and keeping them in a folder per note require release 6.1.0 or newer. +- For sending files into a Talk conversation, Talk 24 or newer with its folders per conversation enabled, which `Talk.storesAttachmentsPerConversation` tells. ## Testing diff --git a/Sources/Rainmaker/Documentation.docc/ActivityStream.md b/Sources/Rainmaker/Documentation.docc/ActivityStream.md new file mode 100644 index 0000000..82a416d --- /dev/null +++ b/Sources/Rainmaker/Documentation.docc/ActivityStream.md @@ -0,0 +1,24 @@ +# Activity Stream + +Retrieve what the server recorded about an account: files being created, changed and shared, calendar events being scheduled, and whatever else an installed app contributes. + +## Overview + +Whether the activity app is installed is advertised through the ``Activity`` capability. + +## Topics + +### Retrieving Activities + +- ``Server/activities(filter:since:limit:sort:previews:objectType:objectId:)`` +- ``Server/activityFilters()`` +- ``ActivityFilter`` +- ``ActivitySort`` + +### Activities + +- ``ActivityPage`` +- ``ActivityItem`` +- ``ActivityRichText`` +- ``ActivityRichObject`` +- ``ActivityPreview`` diff --git a/Sources/Rainmaker/Documentation.docc/Authenticating.md b/Sources/Rainmaker/Documentation.docc/Authenticating.md new file mode 100644 index 0000000..6e2ab69 --- /dev/null +++ b/Sources/Rainmaker/Documentation.docc/Authenticating.md @@ -0,0 +1,22 @@ +# Authentication + +Obtain an app password through the server's login flow and revoke it again once it is no longer needed. + +## Overview + +``Server/login()`` begins a login flow, which the user completes in a browser, while the client polls for its result. +``Server/poll(_:)`` returns `nil` while the user has not completed the flow and throws only for real failures, so a client polling it can tell an unreachable server from a pending flow, while the deprecated ``Server/poll(_:token:)`` throws in both cases. + +## Topics + +### Logging In + +- ``Server/login()`` +- ``Server/poll(_:)`` +- ``Server/poll(_:token:)`` +- ``LoginFlow`` +- ``LoginResult`` + +### Logging Out + +- ``Server/deleteAppPassword()`` diff --git a/Sources/Rainmaker/Documentation.docc/Collectives.md b/Sources/Rainmaker/Documentation.docc/Collectives.md new file mode 100644 index 0000000..2529c9d --- /dev/null +++ b/Sources/Rainmaker/Documentation.docc/Collectives.md @@ -0,0 +1,18 @@ +# Collectives + +Retrieve the collectives of an account and the pages within one of them. + +## Overview + +The Collectives app is not part of a Nextcloud installation, and unlike every other app covered here it advertises no capability at all, so whether it is available is answered by looking for the entry with the identifier `collectives` in ``Server/navigation()`` rather than through ``Server/capabilities()``. +Pages are returned flat and in the server's order; their hierarchy is reconstructed from ``CollectivePage/parentId``. + +## Topics + +### Retrieving Collectives + +- ``Server/collectives()`` +- ``Server/pages(inCollective:)`` +- ``Collective`` +- ``CollectivePage`` +- ``MembershipLevel`` diff --git a/Sources/Rainmaker/Documentation.docc/CommandLineInterface.md b/Sources/Rainmaker/Documentation.docc/CommandLineInterface.md index dc17866..0f250d4 100644 --- a/Sources/Rainmaker/Documentation.docc/CommandLineInterface.md +++ b/Sources/Rainmaker/Documentation.docc/CommandLineInterface.md @@ -18,7 +18,7 @@ SUBCOMMANDS: activity-filters List the filters the server offers to narrow the activity stream down with. Requires authentication and the server's activity app. capabilities Fetch the capabilities advertised by a server. Authentication is optional. collectives List the collectives of the authenticated user and their pages. Requires authentication and the server's collectives app. - conversations List the Talk conversations of the authenticated user and retrieve their images. Requires authentication and the server's Talk app. + conversations List the Talk conversations of the authenticated user, retrieve their images, start one-to-one conversations and send messages and files into them. Requires authentication and the server's Talk app. create-directory Create a directory on the server. current-user Show the identifier and the display name of the authenticated account. Requires authentication. delete Delete a file or directory from the server. @@ -33,6 +33,7 @@ SUBCOMMANDS: notes-settings Show or change where and how the notes app stores the notes of the authenticated user. Requires authentication and the server's notes app. notifications List the notifications queued for the authenticated user. Requires authentication and the server's notifications app. poll Poll the status of a previously initiated login flow. + search-users Search the users of the server by name or identifier, for example to start a Talk conversation with one. Requires authentication. trash Manage the server trash bin. upload Upload a file or directory to a folder on the server. watch Observe server-side changes over notify_push (or polling when unavailable or asked to) and print each event. Runs until interrupted. @@ -109,6 +110,99 @@ OPTIONS: -h, --help Show help information. ``` +## Talk + +The `conversations` command is a group of subcommands, of which `list` is the default. +Sending something to a person takes three steps: `search-users` prints the identifier and the display name of every matching user, `conversations create` takes such an identifier as `--user-id` and prints the token and the name of the one-to-one conversation with that user, creating it unless it already exists, and `conversations send` and `conversations send-file` take that token. +The option is called `--user-id` because `--user` names the account to authenticate with. +`conversations send` and `conversations send-file` print nothing when they succeed. + +```plaintext +$ swift run rainmaker-cli conversations --help +OVERVIEW: List the Talk conversations of the authenticated user, retrieve their images, start one-to-one conversations and send messages and files into them. Requires authentication and the server's Talk app. + +USAGE: rainmaker conversations + +OPTIONS: + -h, --help Show help information. + +SUBCOMMANDS: + list (default) List the Talk conversations the authenticated user takes part in. + avatar Retrieve the image of a Talk conversation. + create Start a one-to-one Talk conversation with a user, or find the existing one, and print it. Requires the server's Talk app. + send Send a chat message into a Talk conversation. Requires the server's Talk app. + send-file Send a local file into a Talk conversation, optionally with a caption. Requires Talk 24 or later on the server. + + See 'rainmaker help conversations ' for detailed help. +``` + +```plaintext +$ swift run rainmaker-cli search-users --help +OVERVIEW: Search the users of the server by name or identifier, for example to start a Talk conversation with one. Requires authentication. + +USAGE: rainmaker search-users [--user ] [--password ] [--output-format ] [--host ] --term + +OPTIONS: + -u, --user The user account name to authenticate with. Can also be set via RAINMAKER_USER environment variable. + -p, --password + The password to authenticate with. Can also be set via RAINMAKER_PASSWORD environment variable. + --output-format + In what form to render the output. (default: plain) + -h, --host The Nextcloud instance to connect to. Can also be set via RAINMAKER_HOST environment variable. + --term What to search for, such as part of a display name or an identifier. + -h, --help Show help information. +``` + +```plaintext +$ swift run rainmaker-cli conversations create --help +OVERVIEW: Start a one-to-one Talk conversation with a user, or find the existing one, and print it. Requires the server's Talk app. + +USAGE: rainmaker conversations create [--user ] [--password ] [--output-format ] [--host ] --user-id + +OPTIONS: + -u, --user The user account name to authenticate with. Can also be set via RAINMAKER_USER environment variable. + -p, --password + The password to authenticate with. Can also be set via RAINMAKER_PASSWORD environment variable. + --output-format + In what form to render the output. (default: plain) + -h, --host The Nextcloud instance to connect to. Can also be set via RAINMAKER_HOST environment variable. + --user-id The identifier of the user to start the conversation with, as shown by `search-users`. + -h, --help Show help information. +``` + +```plaintext +$ swift run rainmaker-cli conversations send --help +OVERVIEW: Send a chat message into a Talk conversation. Requires the server's Talk app. + +USAGE: rainmaker conversations send [--user ] [--password ] [--host ] --token --message + +OPTIONS: + -u, --user The user account name to authenticate with. Can also be set via RAINMAKER_USER environment variable. + -p, --password + The password to authenticate with. Can also be set via RAINMAKER_PASSWORD environment variable. + -h, --host The Nextcloud instance to connect to. Can also be set via RAINMAKER_HOST environment variable. + --token The token of the conversation to send to, as shown by `conversations list` or `conversations create`. + --message The text to send. + -h, --help Show help information. +``` + +```plaintext +$ swift run rainmaker-cli conversations send-file --help +OVERVIEW: Send a local file into a Talk conversation, optionally with a caption. Requires Talk 24 or later on the server. + +USAGE: rainmaker conversations send-file [--user ] [--password ] [--host ] --token --file [--caption ] + +OPTIONS: + -u, --user The user account name to authenticate with. Can also be set via RAINMAKER_USER environment variable. + -p, --password + The password to authenticate with. Can also be set via RAINMAKER_PASSWORD environment variable. + -h, --host The Nextcloud instance to connect to. Can also be set via RAINMAKER_HOST environment variable. + --token The token of the conversation to send to, as shown by `conversations list` or `conversations create`. + --file The local file to send. Its name becomes the name of the file on the server. + --caption A text to show together with the file. + -h, --help Show help information. +``` + ## Current User ```plaintext diff --git a/Sources/Rainmaker/Documentation.docc/CustomRequests.md b/Sources/Rainmaker/Documentation.docc/CustomRequests.md new file mode 100644 index 0000000..933fa22 --- /dev/null +++ b/Sources/Rainmaker/Documentation.docc/CustomRequests.md @@ -0,0 +1,28 @@ +# Custom Requests and Transport + +Build requests for endpoints Rainmaker does not cover, and supply your own transport to intercept, record or replay requests. + +## Overview + +If the built in features of Rainmaker do not suffice for your use case, the request factories of ``Server`` build your own requests on top, for example for the API of a third-party Nextcloud server app. +The names and values of query items are passed as they are and percent-encoded by the factories, including `+`, `&`, `=` and `#`, so a value such as `a+b` reaches the server as `a+b` rather than as `a b`. + +The factories return a plain `URLRequest`, and a ``Server`` performs it through the abstractions below rather than through `URLSession` directly. +Supplying your own implementations is how requests are intercepted, recorded or replayed, which is exactly what this package's own test suite does. +The HTTP and the WebSocket sides are separate because a `URLSession` typed as ``Requesting`` does not expose its WebSocket features. + +## Topics + +### Building Requests + +- ``Server/makeAppRequest(for:method:queryItems:)`` +- ``Server/makeOCSRequest(for:method:queryItems:)`` +- ``Server/makeWebDAVRequest(for:method:)`` +- ``Method`` + +### Customizing Transport + +- ``Requesting`` +- ``WebSocketConnecting`` +- ``WebSocketChannel`` +- ``WebSocketFrame`` diff --git a/Sources/Rainmaker/Documentation.docc/DiscoveringServerFeatures.md b/Sources/Rainmaker/Documentation.docc/DiscoveringServerFeatures.md new file mode 100644 index 0000000..967b6c9 --- /dev/null +++ b/Sources/Rainmaker/Documentation.docc/DiscoveringServerFeatures.md @@ -0,0 +1,33 @@ +# Capabilities and Navigation + +Find out what a server offers before relying on it, and which apps it advertises to the authenticated user. + +## Overview + +``Server/capabilities()`` returns what the server and its apps advertise, from which a client looks up the ``Capability`` it is interested in, such as ``Notes`` before using the notes features or ``Talk`` before sending into a conversation. +A downstream project can declare a ``Capability`` of its own for anything this library does not model. + +``Server/navigation()`` lists the server apps, such as Files, Photos and Activity, which the server advertises to the authenticated user so that a client can surface them in its own navigation. +It is also how a client finds out whether an app without a capability of its own, such as Collectives, is available. + +## Topics + +### Capabilities + +- ``Server/capabilities()`` +- ``CapabilitySet`` +- ``Capability`` +- ``Activity`` +- ``ChunkedUpload`` +- ``Notes`` +- ``Notifications`` +- ``PushNotifications`` +- ``Talk`` +- ``Theming`` +- ``Trashing`` +- ``Version`` + +### Navigation + +- ``Server/navigation()`` +- ``NavigationItem`` diff --git a/Sources/Rainmaker/Documentation.docc/Documentation.md b/Sources/Rainmaker/Documentation.docc/Documentation.md index 90d73fc..59d9c4d 100644 --- a/Sources/Rainmaker/Documentation.docc/Documentation.md +++ b/Sources/Rainmaker/Documentation.docc/Documentation.md @@ -8,6 +8,9 @@ Rainmaker intentionally sticks to the basics and does not attempt to cover all t It is stateless and is built using first-party frameworks like Foundation. For the simplest use cases, you might prefer this over [NextcloudKit](https://github.com/nextcloud/NextcloudKit). +Every feature is a method of ``Server``, which needs nothing but the address of the server and, for most features, the credentials of an account. +The articles below group those methods by the feature they belong to, together with the types they return. + ## Installation Add this repository to your Xcode project package dependencies or to your Swift package dependencies in the package manifest. @@ -15,258 +18,30 @@ This is the only method currently supported. ## Topics -### Command Line Interface - -- - -### Services +### Essentials - ``Server`` +- -### Files - -Browse, download, upload and reorganize the files of an account over WebDAV. -A file larger than the chunk size is uploaded in chunks the server assembles, which keeps large uploads within the request limits of the server and any reverse proxy in front of it; the ``ChunkedUpload`` capability advertises the limits a server suggests for that. - -- ``Server/enumerate(at:recursively:)->AsyncThrowingStream`` -- ``Server/enumerate(at:recursively:)->[Item]`` -- ``Server/info(_:)`` -- ``Server/download(_:to:force:)`` -- ``Server/upload(_:to:force:chunkSize:)`` -- ``Server/createDirectory(_:)`` -- ``Server/delete(_:)`` -- ``Server/move(_:to:overwrite:)`` - -### Trash Bin - -List, restore and permanently remove deleted items, which the server keeps in a per-account trash bin whose availability is advertised through the ``Trashing`` capability. - -- ``Server/trash()`` -- ``Server/restore(_:)-(String)`` -- ``Server/restore(_:)-(TrashItem)`` -- ``Server/emptyTrash()`` - -### Authentication - -Obtain an app password through the server's login flow and revoke it again once it is no longer needed. -``Server/poll(_:)`` returns `nil` while the user has not completed the flow and throws only for real failures, so a client polling it can tell an unreachable server from a pending flow, while the deprecated ``Server/poll(_:token:)`` throws in both cases. - -- ``Server/login()`` -- ``Server/poll(_:)`` -- ``Server/poll(_:token:)`` -- ``Server/deleteAppPassword()`` - -### Data Models - -- ``AvailableQuota`` -- ``Item`` -- ``Lock`` -- ``LoginFlow`` -- ``LoginResult`` -- ``Permission`` -- ``Quota`` -- ``TrashItem`` -- ``User`` - -### Observing Changes - -Observe server-side changes over the `notify_push` WebSocket when available, falling back to polling otherwise, through a single stream of re-fetch hints. -The ``ServerEventTransport/polling`` transport polls only and never opens a socket, and watchOS always polls. - -- ``Server/events(_:)`` -- ``Server/events(_:pollInterval:)`` -- ``ServerEvent`` -- ``ServerSubject`` -- ``ServerEventOptions`` -- ``ServerEventTransport`` - -### Activity Stream - -Retrieve what the server recorded about an account: files being created, changed and shared, calendar events being scheduled, and whatever else an installed app contributes. - -- ``Server/activities(filter:since:limit:sort:previews:objectType:objectId:)`` -- ``Server/activityFilters()`` -- ``ActivityPage`` -- ``ActivityItem`` -- ``ActivityRichText`` -- ``ActivityRichObject`` -- ``ActivityPreview`` -- ``ActivityFilter`` -- ``ActivitySort`` - -### User Notifications - -List what the server currently has queued for the authenticated user, which is the other thing worth re-fetching in response to ``ServerEvent/notifications``. -Whether and how many notifications are pending follows from the returned array, and whether the app providing them is installed at all is advertised through the ``Notifications`` capability. - -- ``Server/notifications()`` -- ``NotificationItem`` - -### User Avatars - -Retrieve the picture a Nextcloud user is represented by, which is what a client needs to put a face beside the people an activity stream or a conversation names. - -The server answers with an image for every user it knows, drawing one from their initials when that user uploaded none, so an absent picture is not reported as an absent response. -``UserAvatar/isCustom`` is the only thing that distinguishes the two, and a client with a monogram style of its own has to consult it or it will draw over the server's placeholder rather than in place of it. -Only two sizes are served, which is what ``AvatarSize`` models: the endpoint rounds any other value to one of them. -Each fetch bypasses the local HTTP cache, and the endpoint publishes no version marker, so cache images on a bounded lifetime and key entries by server, account, user, size and appearance. -The avatar of the authenticated account is keyed by its identifier, which ``Server/currentUser()`` returns, rather than by the name it logs in with. - -- ``Server/userAvatar(_:size:darkTheme:)`` -- ``UserAvatar`` -- ``AvatarSize`` - -### Notes - -Retrieve the notes of an account, either all of them at once or, for a client keeping its own copy, only those the server recorded a change for since a given moment. -Such an incremental retrieval also reports the server's own moment to continue from next time as ``NoteChanges/lastModified`` and an entity tag with which ``Server/notes(changedSince:ifChangedFrom:)`` learns without a transfer that nothing changed. -A client which must not hold every changed note at once retrieves them in chunks of a bounded size instead, one at a time or as a stream of the chunks of one pass, of which only the last one says which notes still exist. -A client which does not need the text of the notes lists them as summaries instead, which carry everything else and spare the transfer of every note's text, in the same incremental, conditional and chunked ways. -A single note is retrieved by its identifier, also conditionally on its entity tag, which spares the transfer of its content while it did not change. -Notes are created, changed and deleted one at a time, and a change can be made conditional on the entity tag of the copy it is based on, so that a note changed elsewhere in the meantime is reported as ``RainmakerError/noteConflict(current:)`` together with its current state rather than overwritten. -Whether the app providing them is installed at all is advertised through the ``Notes`` capability, which matters more here than elsewhere because the notes app is not part of a Nextcloud installation, and which also reports whether it is new enough to be usable. -Notes are ordinary files, so ``NotesSettings`` says where to find them when reaching for them over WebDAV instead, and ``Note/path`` says where exactly the file of each note is. -Those settings can be changed as well, which may change which notes the server lists, so a client keeping its own copy retrieves them anew afterwards. -Files such as images are attached to a note by uploading them, which returns the path a note's content references them by, relative to the folder of the note's category, and they are retrieved by that path into memory or streamed into a local file. -Some behaviours of the notes app are tied to its release rather than to its API version, which ``Notes/isAppVersion(atLeast:)`` and the helpers built on it, such as ``Notes/supportsAttachmentDeletion``, tell apart: only release 6.1.0 and newer keep the attachments of a note in a folder of their own and can delete them. -An attachment is retrieved bypassing the local HTTP cache, but a session still stores the response in its `URLCache`, so a session shared by several accounts or handling private files is best configured without one, see ``Server/init(address:password:user:session:webSocket:userAgent:)``. -An absent notes app is reported as ``RainmakerError/appUnavailable(app:)`` rather than as ``RainmakerError/notFound``, which is reserved for a note that does not exist, so a client keeping its own copy never mistakes a missing app for deleted notes, except when retrieving an attachment, which reports every failure including an absent app as ``RainmakerError/notFound``. -The two articles below put these calls together, one for a client keeping its own copy of the notes and one for single actions such as those of Shortcuts. - -- -- -- ``Server/notes()`` -- ``Server/notes(changedSince:)`` -- ``Server/notes(changedSince:ifChangedFrom:)`` -- ``Server/notes(changedSince:chunkSize:continuingAfter:)`` -- ``Server/notes(changedSince:chunkSize:ifChangedFrom:)`` -- ``Server/noteChunks(changedSince:chunkSize:)`` -- ``Server/noteSummaries(changedSince:)`` -- ``Server/noteSummaries(changedSince:ifChangedFrom:)`` -- ``Server/noteSummaries(changedSince:chunkSize:continuingAfter:)`` -- ``Server/noteSummaries(changedSince:chunkSize:ifChangedFrom:)`` -- ``Server/noteSummaryChunks(changedSince:chunkSize:)`` -- ``Server/note(_:)`` -- ``Server/note(_:ifChangedFrom:)`` -- ``Server/createNote(title:category:content:modification:isFavorite:)`` -- ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` -- ``Server/deleteNote(_:)`` -- ``Server/attachment(at:ofNote:)`` -- ``Server/downloadAttachment(at:ofNote:to:force:)`` -- ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` -- ``Server/addAttachment(_:toNote:fileName:)-(Data,Int,String)`` -- ``Server/deleteAttachment(at:ofNote:)`` -- ``Server/notesSettings()`` -- ``Server/updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)`` -- ``Note`` -- ``NoteChanges`` -- ``NoteSummary`` -- ``NoteSummaryChanges`` -- ``ShareType`` -- ``NotesSettings`` -- ``NoteMode`` -- ``NoteAttachment`` -- ``NoteAttachmentFile`` - -### Note Content - -Predict what the notes app makes of a note's title and category, and reference attachments from a note's Markdown content the way the Nextcloud Text app does, without asking the server. -These are pure functions which need neither a ``Server`` nor a connection, so a single action such as one of Shortcuts can prepare a request with them or interpret a note's content. -The notes API never derives a title from the content, so ``NoteTitle/derive(fromContent:)`` is how a client gives a note the title the web interface would give it. -The server still has the last word, so a caller adopts the ``Note/title`` and ``Note/category`` it returns, which may for example carry a number appended to avoid a clash. -An attachment added through ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` only becomes part of the note once its content references it, and the Text app deletes files in the note's attachment folder which the content does not reference, so the reference has to be encoded exactly as ``NoteAttachmentReference/markdown(alt:path:)`` encodes it. - -- ``NoteTitle`` -- ``NoteCategory`` -- ``NoteAttachmentReference`` -- ``NoteAttachmentPath`` - -### Collectives - -Retrieve the collectives of an account and the pages within one of them. -The Collectives app is not part of a Nextcloud installation, and unlike every other app covered here it advertises no capability at all, so whether it is available is answered by looking for the entry with the identifier `collectives` in ``Server/navigation()`` rather than through ``Server/capabilities()``. -Pages are returned flat and in the server's order; their hierarchy is reconstructed from ``CollectivePage/parentId``. - -- ``Server/collectives()`` -- ``Server/pages(inCollective:)`` -- ``Collective`` -- ``CollectivePage`` -- ``MembershipLevel`` - -### Talk - -Retrieve the conversations of an account and the image of a single one of them, which is what a client needs to surface conversations without taking part in them. -The Talk app is not part of a Nextcloud installation, and whether it is available is advertised through the ``Talk`` capability, which, unlike the ``Notes``, ``Notifications`` and ``Activity`` capabilities, is visible to anonymous clients as well. -Conversations come back in the server's own order, which is no order at all, so a client presenting a list sorts them by ``Conversation/lastActivity`` itself. -The image of a conversation is whatever the server resolved it to, from an uploaded picture to an icon it generated, and is commonly an SVG document rather than a bitmap. -Each explicit avatar fetch bypasses the local HTTP cache. -Cache images between displays, refreshing when ``Conversation/avatarVersion`` changes and periodically even if it does not, for example once a day. -In one-to-one conversations that version says nothing at all: it is the same value for every such conversation and never moves when the other person changes their profile picture. -Keep separate cached images for each server, account, conversation token and appearance. - -- ``Server/conversations()`` -- ``Server/conversationAvatar(_:darkTheme:)`` -- ``Conversation`` -- ``ConversationType`` -- ``ConversationAvatar`` - -### Current User - -Look up the account a ``Server`` authenticates as. -Its identifier is not necessarily the name it logs in with, because a server can accept an email address or a login attribute of an LDAP directory as the login name, which is what ``Server/user`` and ``LoginResult/name`` hold. -``User/id`` is the identifier the server keys the account by, and it is what ``Server/userAvatar(_:size:darkTheme:)`` expects for the authenticated account. -The WebDAV paths of this library are still built from ``Server/user``, so they only work for accounts whose login name is their identifier. - -- ``Server/currentUser()`` -- ``User`` - -### Apps Navigation - -List the server apps, such as Files, Photos and Activity, which the server advertises to the authenticated user so that a client can surface them in its own navigation. - -- ``Server/navigation()`` -- ``NavigationItem`` - -### Capabilities +### Features -- ``Server/capabilities()`` -- ``CapabilitySet`` -- ``Capability`` -- ``Activity`` -- ``ChunkedUpload`` -- ``Notes`` -- ``Notifications`` -- ``PushNotifications`` -- ``Talk`` -- ``Theming`` -- ``Trashing`` -- ``Version`` +- +- +- +- +- +- +- +- +- +- -### Handling Errors +### Errors -Every error this library raises on its own is a ``RainmakerError``. -The notes features map the statuses the notes app answers with onto dedicated cases, such as ``RainmakerError/readOnly``, ``RainmakerError/locked``, ``RainmakerError/insufficientStorage`` and ``RainmakerError/noteConflict(current:)``, but only when the response actually comes from that app, while anything a proxy or the server answers on its behalf stays ``RainmakerError/unexpectedStatus(code:)``. -An absent notes app is ``RainmakerError/appUnavailable(app:)``, an outdated one ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)``, and a request the installed release does not offer at all, such as deleting an attachment before release 6.1.0, ``RainmakerError/methodNotAllowed``. +Every error this library raises on its own is a ``RainmakerError``, and the article of each feature says which of its cases that feature reports. - ``RainmakerError`` -### Building Custom Requests - -If the built in features of Rainmaker do not suffice for your use case, you can use the following methods to build your own on top. -This is useful for API endpoints not covered by Rainmaker. -In example a third-party Nextcloud server app. -The names and values of query items are passed as they are and percent-encoded by the factories, including `+`, `&`, `=` and `#`, so a value such as `a+b` reaches the server as `a+b` rather than as `a b`. - -- ``Server/makeAppRequest(for:method:queryItems:)`` -- ``Server/makeOCSRequest(for:method:queryItems:)`` -- ``Server/makeWebDAVRequest(for:method:)`` -- ``Method`` - -### Customizing Transport - -Both request factories above return a plain `URLRequest`, and a ``Server`` performs it through the abstractions below rather than through `URLSession` directly. Supplying your own implementations is how requests are intercepted, recorded or replayed, which is exactly what this package's own test suite does. The HTTP and the WebSocket sides are separate because a `URLSession` typed as ``Requesting`` does not expose its WebSocket features. +### Extending Rainmaker -- ``Requesting`` -- ``WebSocketConnecting`` -- ``WebSocketChannel`` -- ``WebSocketFrame`` +- diff --git a/Sources/Rainmaker/Documentation.docc/ManagingFiles.md b/Sources/Rainmaker/Documentation.docc/ManagingFiles.md new file mode 100644 index 0000000..cb5ed80 --- /dev/null +++ b/Sources/Rainmaker/Documentation.docc/ManagingFiles.md @@ -0,0 +1,46 @@ +# Files + +Browse, download, upload and reorganize the files of an account over WebDAV, and restore them from its trash bin. + +## Overview + +A file larger than the chunk size is uploaded in chunks the server assembles, which keeps large uploads within the request limits of the server and any reverse proxy in front of it. +The ``ChunkedUpload`` capability advertises the limits a server suggests for that. + +Deleted items are kept in a per-account trash bin, from which they can be listed, restored and permanently removed. +Whether the server offers one is advertised through the ``Trashing`` capability. + +## Topics + +### Browsing + +- ``Server/enumerate(at:recursively:)->AsyncThrowingStream`` +- ``Server/enumerate(at:recursively:)->[Item]`` +- ``Server/info(_:)`` + +### Transferring + +- ``Server/download(_:to:force:)`` +- ``Server/upload(_:to:force:chunkSize:)`` + +### Reorganizing + +- ``Server/createDirectory(_:)`` +- ``Server/delete(_:)`` +- ``Server/move(_:to:overwrite:)`` + +### Trash Bin + +- ``Server/trash()`` +- ``Server/restore(_:)-(String)`` +- ``Server/restore(_:)-(TrashItem)`` +- ``Server/emptyTrash()`` + +### Data Models + +- ``Item`` +- ``Lock`` +- ``Permission`` +- ``Quota`` +- ``AvailableQuota`` +- ``TrashItem`` diff --git a/Sources/Rainmaker/Documentation.docc/ObservingChanges.md b/Sources/Rainmaker/Documentation.docc/ObservingChanges.md new file mode 100644 index 0000000..e9d49b3 --- /dev/null +++ b/Sources/Rainmaker/Documentation.docc/ObservingChanges.md @@ -0,0 +1,22 @@ +# Observing Changes + +Observe server-side changes through a single stream of hints about what to retrieve again. + +## Overview + +The stream uses the `notify_push` WebSocket when the server offers it and falls back to polling otherwise. +The ``ServerEventTransport/polling`` transport polls only and never opens a socket, and watchOS always polls. + +## Topics + +### Streaming Events + +- ``Server/events(_:)`` +- ``Server/events(_:pollInterval:)`` +- ``ServerEventOptions`` +- ``ServerEventTransport`` + +### Events + +- ``ServerEvent`` +- ``ServerSubject`` diff --git a/Sources/Rainmaker/Documentation.docc/UserNotifications.md b/Sources/Rainmaker/Documentation.docc/UserNotifications.md new file mode 100644 index 0000000..521cf2f --- /dev/null +++ b/Sources/Rainmaker/Documentation.docc/UserNotifications.md @@ -0,0 +1,15 @@ +# Notifications + +List what the server currently has queued for the authenticated user. + +## Overview + +These are what is worth retrieving again in response to ``ServerEvent/notifications``. +Whether and how many notifications are pending follows from the returned array, and whether the app providing them is installed at all is advertised through the ``Notifications`` capability. + +## Topics + +### Retrieving Notifications + +- ``Server/notifications()`` +- ``NotificationItem`` diff --git a/Sources/Rainmaker/Documentation.docc/Users.md b/Sources/Rainmaker/Documentation.docc/Users.md new file mode 100644 index 0000000..7aecb14 --- /dev/null +++ b/Sources/Rainmaker/Documentation.docc/Users.md @@ -0,0 +1,30 @@ +# Users + +Look up the account a server authenticates as, search other users and retrieve the pictures they are represented by. + +## Overview + +The identifier of an account is not necessarily the name it logs in with, because a server can accept an email address or a login attribute of an LDAP directory as the login name, which is what ``Server/user`` and ``LoginResult/name`` hold. +``User/id`` is the identifier the server keys the account by, and it is what ``Server/userAvatar(_:size:darkTheme:)`` and ``Server/createConversation(with:)`` expect. +The WebDAV paths of this library are still built from ``Server/user``, so they only work for accounts whose login name is their identifier. + +``Server/searchUsers(_:)`` finds other users by name or identifier, for example to pick the person to start a Talk conversation with, as far as the sharing settings of the server let the account see them. + +The server answers with a picture for every user it knows, drawing one from their initials when that user uploaded none, so an absent picture is not reported as an absent response. +``UserAvatar/isCustom`` is the only thing that distinguishes the two, and a client with a monogram style of its own has to consult it or it will draw over the server's placeholder rather than in place of it. +Only two sizes are served, which is what ``AvatarSize`` models: the endpoint rounds any other value to one of them. +Each fetch bypasses the local HTTP cache, and the endpoint publishes no version marker, so cache images on a bounded lifetime and key entries by server, account, user, size and appearance. + +## Topics + +### Accounts + +- ``Server/currentUser()`` +- ``Server/searchUsers(_:)`` +- ``User`` + +### Avatars + +- ``Server/userAvatar(_:size:darkTheme:)`` +- ``UserAvatar`` +- ``AvatarSize`` diff --git a/Sources/Rainmaker/Documentation.docc/WorkingWithNotes.md b/Sources/Rainmaker/Documentation.docc/WorkingWithNotes.md new file mode 100644 index 0000000..51f3341 --- /dev/null +++ b/Sources/Rainmaker/Documentation.docc/WorkingWithNotes.md @@ -0,0 +1,106 @@ +# Notes + +Retrieve, create, change and delete the notes of an account, attach files to them and change the settings of the notes app. + +## Overview + +The notes app is not part of a Nextcloud installation, so whether it is installed at all is advertised through the ``Notes`` capability, which also reports whether it is new enough to be usable. +The two articles below put the calls of this page together, one for a client keeping its own copy of the notes and one for single actions such as those of Shortcuts. + +### Retrieving Notes + +The notes of an account are retrieved either all at once or, for a client keeping its own copy, only those the server recorded a change for since a given moment. +Such an incremental retrieval also reports the server's own moment to continue from next time as ``NoteChanges/lastModified`` and an entity tag with which ``Server/notes(changedSince:ifChangedFrom:)`` learns without a transfer that nothing changed. +A client which must not hold every changed note at once retrieves them in chunks of a bounded size instead, one at a time or as a stream of the chunks of one pass, of which only the last one says which notes still exist. +A client which does not need the text of the notes lists them as summaries instead, which carry everything else and spare the transfer of every note's text, in the same incremental, conditional and chunked ways. +A single note is retrieved by its identifier, also conditionally on its entity tag, which spares the transfer of its content while it did not change. + +### Changing Notes + +Notes are created, changed and deleted one at a time, and a change can be made conditional on the entity tag of the copy it is based on, so that a note changed elsewhere in the meantime is reported as ``RainmakerError/noteConflict(current:)`` together with its current state rather than overwritten. + +### Attachments + +Files such as images are attached to a note by uploading them, which returns the path a note's content references them by, relative to the folder of the note's category, and they are retrieved by that path into memory or streamed into a local file. +Some behaviours of the notes app are tied to its release rather than to its API version, which ``Notes/isAppVersion(atLeast:)`` and the helpers built on it, such as ``Notes/supportsAttachmentDeletion``, tell apart: only release 6.1.0 and newer keep the attachments of a note in a folder of their own and can delete them. +An attachment is retrieved bypassing the local HTTP cache, but a session still stores the response in its `URLCache`, so a session shared by several accounts or handling private files is best configured without one, see ``Server/init(address:password:user:session:webSocket:userAgent:)``. + +### Settings + +Notes are ordinary files, so ``NotesSettings`` says where to find them when reaching for them over WebDAV instead, and ``Note/path`` says where exactly the file of each note is. +Those settings can be changed as well, which may change which notes the server lists, so a client keeping its own copy retrieves them anew afterwards. + +### Note Content + +The helpers listed under Note Content predict what the notes app makes of a note's title and category, and reference attachments from a note's Markdown content the way the Nextcloud Text app does, without asking the server. +They are pure functions which need neither a ``Server`` nor a connection, so a single action such as one of Shortcuts can prepare a request with them or interpret a note's content. +The notes API never derives a title from the content, so ``NoteTitle/derive(fromContent:)`` is how a client gives a note the title the web interface would give it. +The server still has the last word, so a caller adopts the ``Note/title`` and ``Note/category`` it returns, which may for example carry a number appended to avoid a clash. +An attachment added through ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` only becomes part of the note once its content references it, and the Text app deletes files in the note's attachment folder which the content does not reference, so the reference has to be encoded exactly as ``NoteAttachmentReference/markdown(alt:path:)`` encodes it. + +### Errors + +The notes features map the statuses the notes app answers with onto dedicated cases, such as ``RainmakerError/readOnly``, ``RainmakerError/locked``, ``RainmakerError/insufficientStorage`` and ``RainmakerError/noteConflict(current:)``, but only when the response actually comes from that app, while anything a proxy or the server answers on its behalf stays ``RainmakerError/unexpectedStatus(code:)``. +An absent notes app is reported as ``RainmakerError/appUnavailable(app:)`` rather than as ``RainmakerError/notFound``, which is reserved for a note that does not exist, so a client keeping its own copy never mistakes a missing app for deleted notes, except when retrieving an attachment, which reports every failure including an absent app as ``RainmakerError/notFound``. +An outdated notes app is reported as ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)``, and a request the installed release does not offer at all, such as deleting an attachment before release 6.1.0, as ``RainmakerError/methodNotAllowed``. + +## Topics + +### Articles + +- +- + +### Retrieving Notes + +- ``Server/notes()`` +- ``Server/notes(changedSince:)`` +- ``Server/notes(changedSince:ifChangedFrom:)`` +- ``Server/notes(changedSince:chunkSize:continuingAfter:)`` +- ``Server/notes(changedSince:chunkSize:ifChangedFrom:)`` +- ``Server/noteChunks(changedSince:chunkSize:)`` +- ``Server/note(_:)`` +- ``Server/note(_:ifChangedFrom:)`` +- ``Note`` +- ``NoteChanges`` +- ``ShareType`` + +### Retrieving Summaries + +- ``Server/noteSummaries(changedSince:)`` +- ``Server/noteSummaries(changedSince:ifChangedFrom:)`` +- ``Server/noteSummaries(changedSince:chunkSize:continuingAfter:)`` +- ``Server/noteSummaries(changedSince:chunkSize:ifChangedFrom:)`` +- ``Server/noteSummaryChunks(changedSince:chunkSize:)`` +- ``NoteSummary`` +- ``NoteSummaryChanges`` + +### Changing Notes + +- ``Server/createNote(title:category:content:modification:isFavorite:)`` +- ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` +- ``Server/deleteNote(_:)`` + +### Attachments + +- ``Server/attachment(at:ofNote:)`` +- ``Server/downloadAttachment(at:ofNote:to:force:)`` +- ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` +- ``Server/addAttachment(_:toNote:fileName:)-(Data,Int,String)`` +- ``Server/deleteAttachment(at:ofNote:)`` +- ``NoteAttachment`` +- ``NoteAttachmentFile`` + +### Settings + +- ``Server/notesSettings()`` +- ``Server/updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)`` +- ``NotesSettings`` +- ``NoteMode`` + +### Note Content + +- ``NoteTitle`` +- ``NoteCategory`` +- ``NoteAttachmentReference`` +- ``NoteAttachmentPath`` diff --git a/Sources/Rainmaker/Documentation.docc/WorkingWithTalk.md b/Sources/Rainmaker/Documentation.docc/WorkingWithTalk.md new file mode 100644 index 0000000..5223a8e --- /dev/null +++ b/Sources/Rainmaker/Documentation.docc/WorkingWithTalk.md @@ -0,0 +1,44 @@ +# Talk + +Retrieve the conversations of an account, start a one-to-one conversation with another user, and send text and files into a conversation. + +## Overview + +These calls are what a client such as a share extension or a single action of Shortcuts needs to hand content over to Talk without being a Talk client itself. +Reading the messages of a conversation, calling and moderating are out of scope. +The Talk app is not part of a Nextcloud installation, and whether it is available is advertised through the ``Talk`` capability, which, unlike the ``Notes``, ``Notifications`` and ``Activity`` capabilities, is visible to anonymous clients as well. + +### Listing Conversations + +Conversations come back in the server's own order, which is no order at all, so a client presenting a list sorts them by ``Conversation/lastActivity`` itself. +The image of a conversation is whatever the server resolved it to, from an uploaded picture to an icon it generated, and is commonly an SVG document rather than a bitmap. +Each explicit avatar fetch bypasses the local HTTP cache. +Cache images between displays, refreshing when ``Conversation/avatarVersion`` changes and periodically even if it does not, for example once a day. +In one-to-one conversations that version says nothing at all: it is the same value for every such conversation and never moves when the other person changes their profile picture. +Keep separate cached images for each server, account, conversation token and appearance. + +### Sending + +The conversation with a person is found by searching the users with ``Server/searchUsers(_:)`` and passing the identifier of the chosen one to ``Server/createConversation(with:)``, which returns the one-to-one conversation that already exists rather than creating a second one, so calling it before every sending is safe. +Messages and files are sent as the authenticated account into the conversation a ``Conversation/token`` addresses, and none of these calls keeps any state between two of them. +Sending a file requires Talk 24 or later with its folders per conversation enabled, which ``Talk/storesAttachmentsPerConversation`` tells in advance, and the file is uploaded into the account's files first, where it stays and counts towards the account's quota. + +### Errors + +A missing Talk app, an unknown conversation and, when sending a file, a release older than Talk 24 are all reported as ``RainmakerError/notFound``, because the server answers each of them the same way, while a conversation the account may not write to is reported as ``RainmakerError/readOnly``. + +## Topics + +### Listing Conversations + +- ``Server/conversations()`` +- ``Server/conversationAvatar(_:darkTheme:)`` +- ``Conversation`` +- ``ConversationType`` +- ``ConversationAvatar`` + +### Sending + +- ``Server/createConversation(with:)`` +- ``Server/sendMessage(_:toConversation:)`` +- ``Server/sendFile(_:toConversation:caption:)`` diff --git a/Sources/Rainmaker/Models/Conversation.swift b/Sources/Rainmaker/Models/Conversation.swift index bdd0c80..e329b86 100644 --- a/Sources/Rainmaker/Models/Conversation.swift +++ b/Sources/Rainmaker/Models/Conversation.swift @@ -6,13 +6,13 @@ import Foundation /// /// A single Nextcloud Talk conversation the authenticated user takes part in. /// -/// Conversations are listed through ``Server/conversations()`` and their image is retrieved through ``Server/conversationAvatar(_:darkTheme:)``. They are provided by the Talk app (`spreed`) which, like the notes app, is not part of a Nextcloud installation and has to be installed separately. Whether it is available can be checked in advance via the ``Talk`` capability, e.g. `try await capabilities().contains(Talk.self)`. +/// Conversations are listed through ``Server/conversations()`` and their image is retrieved through ``Server/conversationAvatar(_:darkTheme:)``. A one-to-one conversation is started or found through ``Server/createConversation(with:)``, which returns it as this type as well, and messages and files are sent into a conversation by its ``token`` through ``Server/sendMessage(_:toConversation:)`` and ``Server/sendFile(_:toConversation:caption:)``, which take no instance of this type because sending needs nothing but the token. They are provided by the Talk app (`spreed`) which, like the notes app, is not part of a Nextcloud installation and has to be installed separately. Whether it is available can be checked in advance via the ``Talk`` capability, e.g. `try await capabilities().contains(Talk.self)`. /// /// The server's own API calls a conversation a "room", which is why the endpoint behind ``Server/conversations()`` is `apps/spreed/api/v4/room`, while its user interface calls it a conversation. This type follows the latter. /// /// Only ``token`` addresses a conversation in the server's API. ``id`` is a plain number unique per server and is what makes this type `Identifiable`, but every other Talk endpoint takes the token in its path instead. /// -/// The server sends far more about a conversation than is modelled here, because this type exists to list conversations rather than to take part in one. The last message, the read markers (`lastReadMessage`, `lastCommonReadMessage`), the permission bit fields (`permissions`, `attendeePermissions`, `defaultPermissions`, `callPermissions`), the attendee and session details (`attendeeId`, `actorId`, `actorType`, `sessionId`, `lastPing`), the call state (`hasCall`, `callFlag`, `callStartTime`, `callRecording`, `canStartCall`), `isCustomAvatar`, the lobby, breakout room, message expiration, SIP and federation fields as well as `name`, `description`, `listable`, `notificationLevel`, `participantType`, `hasPassword`, `isFavorite`, `isArchived`, `isImportant`, `isSensitive` and `tagIds` are intentionally not modelled, as chatting, calling and moderating are out of scope. A client needing any of them can build its own request with ``Server/makeOCSRequest(for:method:queryItems:)``. +/// The server sends far more about a conversation than is modelled here, because this type exists to list conversations and to address them, while what is sent into one is never read back. The last message, the read markers (`lastReadMessage`, `lastCommonReadMessage`), the permission bit fields (`permissions`, `attendeePermissions`, `defaultPermissions`, `callPermissions`), the attendee and session details (`attendeeId`, `actorId`, `actorType`, `sessionId`, `lastPing`), the call state (`hasCall`, `callFlag`, `callStartTime`, `callRecording`, `canStartCall`), `isCustomAvatar`, the lobby, breakout room, message expiration, SIP and federation fields as well as `name`, `description`, `listable`, `notificationLevel`, `participantType`, `hasPassword`, `isFavorite`, `isArchived`, `isImportant`, `isSensitive` and `tagIds` are intentionally not modelled, as reading the messages of a conversation, calling and moderating are out of scope. A client needing any of them can build its own request with ``Server/makeOCSRequest(for:method:queryItems:)``. /// public struct Conversation: Model, Hashable, Identifiable, CustomStringConvertible, CustomDebugStringConvertible, Decodable { /// @@ -25,7 +25,7 @@ public struct Conversation: Model, Hashable, Identifiable, CustomStringConvertib /// /// The opaque token identifying the conversation, e.g. `"oypsiy6j"`. /// - /// Every Talk endpoint beyond the listing takes this in its path, which is why it is also what ``Server/conversationAvatar(_:darkTheme:)`` and any request built with ``Server/makeOCSRequest(for:method:queryItems:)`` are given. + /// Every Talk endpoint which addresses a single conversation takes this in its path, which is why it is also what ``Server/conversationAvatar(_:darkTheme:)``, ``Server/sendMessage(_:toConversation:)``, ``Server/sendFile(_:toConversation:caption:)`` and any request built with ``Server/makeOCSRequest(for:method:queryItems:)`` are given. /// public let token: String @@ -96,7 +96,7 @@ public struct Conversation: Model, Hashable, Identifiable, CustomStringConvertib /// /// Decode a conversation from the server's payload. /// - /// Every modelled field is required: the listing endpoint declares all of them for every server version this library supports, so a missing one means a response this type cannot describe. Whatever else the payload carries is ignored, which is what lets one client read the conversations of servers running different releases of the Talk app. + /// Every modelled field is required: the listing endpoint and the endpoint ``Server/createConversation(with:)`` uses declare all of them for every server version this library supports, so a missing one means a response this type cannot describe. Whatever else the payload carries is ignored, which is what lets one client read the conversations of servers running different releases of the Talk app. /// public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) diff --git a/Sources/Rainmaker/Models/Talk.swift b/Sources/Rainmaker/Models/Talk.swift index bab73e7..2145d81 100644 --- a/Sources/Rainmaker/Models/Talk.swift +++ b/Sources/Rainmaker/Models/Talk.swift @@ -11,9 +11,9 @@ import Foundation /// Unlike the ``Notes``, ``Notifications`` and ``Activity`` capabilities, this object is advertised to anonymous clients as well, so its presence says that the app is installed on the server rather than that the authenticated user may use it. /// /// Talk versions its endpoints in their paths rather than advertising an API version, so there is nothing to check here the way ``Notes/isSupported`` checks the notes API. What a given server can do is described by named feature flags instead, which ``supports(_:)`` looks up. -/// All fields are kept optional so that a server which omits one of them still decodes successfully. +/// The lists of features and the version are kept optional so that a server which omits one of them still decodes successfully, and ``storesAttachmentsPerConversation`` is `false` when the server omits it. /// -/// The app's configuration, which the server advertises under `config` and `config-local`, is deliberately not modelled: it is deeply nested, it is reshaped between Talk releases, and none of it is needed to list conversations. A downstream project which needs part of it can declare its own ``Capability`` with the same ``key`` modelling just that subtree, which is exactly the extension point ``Capability`` offers. +/// The app's configuration, which the server advertises under `config` and `config-local`, is deliberately not modelled beyond the single flag ``storesAttachmentsPerConversation`` reads: it is deeply nested, it is reshaped between Talk releases, and nothing else of it is needed to list conversations or to send to them. A downstream project which needs another part of it can declare its own ``Capability`` with the same ``key`` modelling just that subtree, which is exactly the extension point ``Capability`` offers. /// public struct Talk: Capability { /// @@ -42,6 +42,14 @@ public struct Talk: Capability { /// public let version: String? + /// + /// Whether the server keeps the files sent into a conversation in a folder per conversation, which is what ``Server/sendFile(_:toConversation:caption:)`` requires. + /// + /// This corresponds to the server's `config` → `attachments` → `conversation-subfolders` field, which Talk 24 introduced together with the endpoints that sending a file is built on. It is `false` when the server does not advertise the field, as every older release does, and when an administrator turned the folders off, in both of which cases sending a file fails. + /// The name mirrors ``Notes/storesAttachmentsPerNote``, and like that one it is a behaviour of the app which no feature flag in ``features`` announces. + /// + public let storesAttachmentsPerConversation: Bool + /// /// Whether the server advertises the given Talk feature. /// @@ -63,5 +71,40 @@ public struct Talk: Capability { case features case localFeatures = "features-local" case version + case configuration = "config" + } + + /// + /// The keys of the app's configuration below `config`, of which only the section ``storesAttachmentsPerConversation`` is read from is decoded. + /// + private enum ConfigurationKeys: String, CodingKey { + case attachments + } + + /// + /// The keys of the `attachments` section of the app's configuration, of which only the flag behind ``storesAttachmentsPerConversation`` is decoded. + /// + private enum AttachmentsKeys: String, CodingKey { + case conversationSubfolders = "conversation-subfolders" + } + + // MARK: - Decodable + + /// + /// Decode the capability from the object the server advertises under ``key``. + /// + /// The lists of features and the version are optional, so that a server which omits one of them still decodes, and ``storesAttachmentsPerConversation`` is read from two levels below `config` without modelling anything else there and is `false` when any level is missing. + /// + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + features = try container.decodeIfPresent([String].self, forKey: .features) + localFeatures = try container.decodeIfPresent([String].self, forKey: .localFeatures) + version = try container.decodeIfPresent(String.self, forKey: .version) + + // A missing level anywhere on the way down means a release which predates the flag, so it decodes as `false` rather than failing the whole capability. + let configuration = try? container.nestedContainer(keyedBy: ConfigurationKeys.self, forKey: .configuration) + let attachments = try? configuration?.nestedContainer(keyedBy: AttachmentsKeys.self, forKey: .attachments) + storesAttachmentsPerConversation = try attachments?.decodeIfPresent(Bool.self, forKey: .conversationSubfolders) ?? false } } diff --git a/Sources/Rainmaker/Models/User.swift b/Sources/Rainmaker/Models/User.swift index 16b34c6..35713f8 100644 --- a/Sources/Rainmaker/Models/User.swift +++ b/Sources/Rainmaker/Models/User.swift @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT /// -/// A simple user description as used in file system information and returned by ``Server/currentUser()``. +/// A simple user description as used in file system information and returned by ``Server/currentUser()`` and ``Server/searchUsers(_:)``. /// /// ``Item/owner`` and the owners of a ``Lock`` are users, and ``Server/currentUser()`` describes the account a ``Server`` authenticates as. /// @@ -11,7 +11,7 @@ public struct User: Model, Hashable, Identifiable { /// The user account identifier unique on the server. /// /// It is not necessarily the name the account logs in with, which ``Server/user`` and ``LoginResult/name`` hold, because a server can accept an email address or a login attribute of an LDAP directory as the login name. - /// This identifier is what ``Server/userAvatar(_:size:darkTheme:)`` expects. + /// This identifier is what ``Server/userAvatar(_:size:darkTheme:)`` and ``Server/createConversation(with:)`` expect. /// public let id: String diff --git a/Sources/Rainmaker/RainmakerError.swift b/Sources/Rainmaker/RainmakerError.swift index dc3e464..7ae81b6 100644 --- a/Sources/Rainmaker/RainmakerError.swift +++ b/Sources/Rainmaker/RainmakerError.swift @@ -45,6 +45,7 @@ public enum RainmakerError: Error, Equatable, CustomStringConvertible { /// The server does not have enough storage left for the account to save what was sent. /// /// The notes features report this when the notes app answers with the status `507` itself, for example when a note or an attachment would exceed the account's quota. + /// ``Server/sendFile(_:toConversation:caption:)`` reports this whenever the server answers `507` while it prepares, uploads or posts the file. /// case insufficientStorage @@ -75,6 +76,7 @@ public enum RainmakerError: Error, Equatable, CustomStringConvertible { /// Whatever you were looking for is not there. /// /// The notes features report this when the notes app itself answers that a note or an attachment does not exist, and ``appUnavailable(app:)`` when the app is not there at all, except for ``Server/attachment(at:ofNote:)`` and ``Server/downloadAttachment(at:ofNote:to:force:)``, which report every failure including an absent app as this case, see there. + /// The Talk features ``Server/createConversation(with:)``, ``Server/sendMessage(_:toConversation:)`` and ``Server/sendFile(_:toConversation:caption:)`` report this for every `404`, which the server answers alike for a user or conversation which does not exist and for an absent Talk app. /// case notFound @@ -82,6 +84,7 @@ public enum RainmakerError: Error, Equatable, CustomStringConvertible { /// The intended change was refused because the subject is read-only for the authenticated user. /// /// The notes features report this when the notes app answers with the status `403` itself, for example for a note shared without write access, see ``Note/isReadOnly``. + /// ``Server/sendMessage(_:toConversation:)`` and ``Server/sendFile(_:toConversation:caption:)`` report this for every `403`, which the server answers for a read-only conversation or one the account may not chat in. /// case readOnly diff --git a/Sources/Rainmaker/Requests/Models/ChatAttachmentRequest.swift b/Sources/Rainmaker/Requests/Models/ChatAttachmentRequest.swift new file mode 100644 index 0000000..52ccf49 --- /dev/null +++ b/Sources/Rainmaker/Requests/Models/ChatAttachmentRequest.swift @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// The JSON body ``Server/sendFile(_:toConversation:caption:)`` sends to `POST /ocs/v2.php/apps/spreed/api/v1/chat/{token}/attachment` once the file is uploaded into the draft folder ``AttachmentFolderResponse`` names. +/// +/// This is a data transfer object shaped as the Talk app expects it, which is why its properties carry the server's names. It is not exposed outside this module. +/// +/// The synthesized encoding leaves out ``talkMetaData`` when it is `nil`, which posts the file without a caption. +/// +struct ChatAttachmentRequest: Encodable { + /// + /// The path of the uploaded file relative to the account's files, without a leading slash, which has to be inside the draft folder of the conversation. + /// + let filePath: String + + /// + /// The name the file is given when the server moves it out of the draft folder, to which the server appends a number when the name is taken. + /// + let fileName: String + + /// + /// A random value the server stores with the message, which it requires although nothing here looks the message up by it. + /// + let referenceId: String + + /// + /// The metadata of the message as a JSON document in a string, e.g. `{"caption":"Hello"}`, because the server expects it encoded twice. + /// + let talkMetaData: String? +} diff --git a/Sources/Rainmaker/Requests/Models/ChatMessageRequest.swift b/Sources/Rainmaker/Requests/Models/ChatMessageRequest.swift new file mode 100644 index 0000000..c33368c --- /dev/null +++ b/Sources/Rainmaker/Requests/Models/ChatMessageRequest.swift @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// The JSON body ``Server/sendMessage(_:toConversation:)`` sends to `POST /ocs/v2.php/apps/spreed/api/v1/chat/{token}`. +/// +/// This is a data transfer object shaped as the Talk app expects it and is not exposed outside this module. +/// +struct ChatMessageRequest: Encodable { + /// + /// The text of the message. + /// + let message: String +} diff --git a/Sources/Rainmaker/Requests/Models/ConversationCreationRequest.swift b/Sources/Rainmaker/Requests/Models/ConversationCreationRequest.swift new file mode 100644 index 0000000..322525a --- /dev/null +++ b/Sources/Rainmaker/Requests/Models/ConversationCreationRequest.swift @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// The JSON body ``Server/createConversation(with:)`` sends to `POST /ocs/v2.php/apps/spreed/api/v4/room`. +/// +/// This is a data transfer object shaped as the Talk app expects it, which is why its properties carry the server's names. It is the request counterpart of ``ConversationResponse`` and is not exposed outside this module. +/// +struct ConversationCreationRequest: Encodable { + /// + /// The kind of conversation to create as the number the server uses for it, which is always `1`, the number the server uses for a ``ConversationType/oneToOne`` conversation. + /// + let roomType: Int + + /// + /// The identifier of the other user of the one-to-one conversation. + /// + let invite: String +} diff --git a/Sources/Rainmaker/Responses/Models/AttachmentFolderResponse.swift b/Sources/Rainmaker/Responses/Models/AttachmentFolderResponse.swift new file mode 100644 index 0000000..4faa963 --- /dev/null +++ b/Sources/Rainmaker/Responses/Models/AttachmentFolderResponse.swift @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// This is the JSON response as returned by the server for `POST /ocs/v2.php/apps/spreed/api/v1/chat/{token}/attachment/folder`. +/// +/// This is a data transfer object covering the OCS envelope and the draft folder ``Server/sendFile(_:toConversation:caption:)`` uploads a file into before it posts the file to the conversation. +/// The server also predicts the names the files it was asked about would end up with, which is deliberately not decoded, because nothing asks it about any. +/// +struct AttachmentFolderResponse: Decodable { + /// + /// The OCS envelope wrapping the draft folder. + /// + struct OCS: Decodable { + /// + /// The part of the answer ``Server/sendFile(_:toConversation:caption:)`` needs. + /// + struct Data: Decodable { + /// + /// The path of the draft folder relative to the account's files, without a leading slash, e.g. `"Talk/Rainmaker-abcd1234/Draft"`. + /// + let folder: String + } + + /// + /// The draft folder. + /// + let data: Data + } + + /// + /// The OCS envelope. + /// + let ocs: OCS +} diff --git a/Sources/Rainmaker/Responses/Models/ConversationResponse.swift b/Sources/Rainmaker/Responses/Models/ConversationResponse.swift new file mode 100644 index 0000000..9174ab0 --- /dev/null +++ b/Sources/Rainmaker/Responses/Models/ConversationResponse.swift @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// This is the JSON response as returned by the server for `POST /ocs/v2.php/apps/spreed/api/v4/room`. +/// +/// This is a data transfer object covering the OCS envelope around a single conversation, which ``Server/createConversation(with:)`` returns. It is the counterpart of ``ConversationsResponse``, which wraps the list of conversations in the same envelope, and decodes the conversation directly into the public ``Conversation``. +/// +struct ConversationResponse: Decodable { + /// + /// The OCS envelope wrapping the status of the request and the conversation. + /// + struct OCS: Decodable { + /// + /// The status of the OCS request, which ``Server/createConversation(with:)`` checks before it reads ``OCS/data``. + /// + struct Meta: Decodable { + /// + /// The textual status, which is `"ok"` for a successful request. + /// + let status: String + + /// + /// The numeric OCS status code, which is reported in the error when ``status`` is not `"ok"`. + /// + let statuscode: Int + + /// + /// The optional message explaining a failure, which is reported in the error when ``status`` is not `"ok"`. + /// + let message: String? + } + + /// + /// The status of the request. + /// + let meta: Meta + + /// + /// The conversation which was created or which already existed. + /// + let data: Conversation + } + + /// + /// The OCS envelope. + /// + let ocs: OCS +} diff --git a/Sources/Rainmaker/Responses/Models/UserSearchResponse.swift b/Sources/Rainmaker/Responses/Models/UserSearchResponse.swift new file mode 100644 index 0000000..ce60d9b --- /dev/null +++ b/Sources/Rainmaker/Responses/Models/UserSearchResponse.swift @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// This is the JSON response as returned by the server for `GET /ocs/v2.php/core/autocomplete/get`. +/// +/// This is a data transfer object covering the OCS envelope and the two fields of every match ``Server/searchUsers(_:)`` maps onto the public ``User``. +/// The server sends more about a match, such as its icon, its user status and the kind of account it is, which are deliberately not decoded, so that a change to any of them cannot break the search. +/// +struct UserSearchResponse: Decodable { + /// + /// The OCS envelope wrapping the status of the request and the matches. + /// + struct OCS: Decodable { + /// + /// The status of the OCS request, which ``Server/searchUsers(_:)`` checks before it reads ``OCS/data``. + /// + struct Meta: Decodable { + /// + /// The textual status, which is `"ok"` for a successful request. + /// + let status: String + + /// + /// The numeric OCS status code, which is reported in the error when ``status`` is not `"ok"`. + /// + let statuscode: Int + + /// + /// The optional message explaining a failure, which is reported in the error when ``status`` is not `"ok"`. + /// + let message: String? + } + + /// + /// A single user matching the search. + /// + struct Match: Decodable { + /// + /// The identifier the server keys the account by, which becomes ``User/id``. + /// + let id: String + + /// + /// The name the server presents the account under, which becomes ``User/displayName``. + /// + let label: String + } + + /// + /// The status of the request. + /// + let meta: Meta + + /// + /// The matching users, exact matches first. + /// + let data: [Match] + } + + /// + /// The OCS envelope. + /// + let ocs: OCS +} diff --git a/Sources/Rainmaker/Server.swift b/Sources/Rainmaker/Server.swift index ed03863..df8e563 100644 --- a/Sources/Rainmaker/Server.swift +++ b/Sources/Rainmaker/Server.swift @@ -178,6 +178,34 @@ public final class Server { } } + /// + /// Map the status of a response to one of the Talk app's chat endpoints onto ``RainmakerError``, which is what ``sendMessage(_:toConversation:)`` and ``sendFile(_:toConversation:caption:)`` share. + /// + /// The chat endpoints answer `403` for a conversation the account may not write to, `404` for one it cannot reach as well as for a route the installed release does not offer, and `507` when the account's quota is exhausted, while every other status than the expected one surfaces as such. + /// + /// - Parameters: + /// - urlResponse: The response to check. + /// - expected: The status the endpoint answers with on success, which differs between them. + /// + private func validateChatResponse(_ urlResponse: URLResponse, expecting expected: HTTPStatus) throws { + guard let response = urlResponse as? HTTPURLResponse else { + throw RainmakerError.responseDecodingFailed(reason: "Failed to cast URLResponse to HTTPURLResponse.") + } + + switch response.status { + case expected: + return + case .forbidden: + throw RainmakerError.readOnly + case .notFound: + throw RainmakerError.notFound + case .insufficientStorage: + throw RainmakerError.insufficientStorage + default: + throw RainmakerError.unexpectedStatus(code: response.statusCode) + } + } + /// /// List the content of the remote directory. /// @@ -836,6 +864,19 @@ public final class Server { return request } + /// + /// Set up a request for an OCS endpoint which takes its parameters as a JSON body, such as those of the Talk app which create or send something. + /// + /// This is ``makeOCSRequest(for:method:queryItems:)`` with the body encoded by ``jsonEncoder`` and announced as such, which the server reads the parameters of an OCS endpoint from just as it reads them from a query. A body is used rather than a query because what is sent, a chat message for one, can be far longer than an address may be. + /// + private func makeOCSRequest(for path: String, method: Method, jsonBody: some Encodable) throws -> URLRequest { + var request = try makeOCSRequest(for: path, method: method) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try jsonEncoder.encode(jsonBody) + + return request + } + /// /// Set up a URL request specifically for WebDAV interaction with an absolute URL. /// @@ -1712,6 +1753,233 @@ extension Server: Serving { return ConversationAvatar(data: data, contentType: contentType) } + /// + /// Search the users of the server whose name or identifier matches the given term, for example to pick the person ``createConversation(with:)`` starts a conversation with. + /// + /// This asks the server's autocompletion the way the Talk clients do when a new conversation is started, so that the server ranks and filters the results for that purpose. Only user accounts are searched, which is the kind the server searches for unless told otherwise, so groups, teams and email addresses never appear. + /// + /// What the server reveals is up to its sharing settings: an administrator can turn the enumeration of users off, in which case only a full match of a name, an identifier or an email address is found, or limit it to people sharing a group with the account or in its phone book. The server returns at most ten users, exact matches first. + /// The display name is the label the server shows for a match, which can carry a disambiguating suffix such as an email address in parentheses when the term matched one of the user's additional email addresses. + /// + /// This is a single request without any state, and cancelling the calling task cancels it. + /// + /// - Parameters: + /// - term: What to search for, such as part of a display name or an identifier. + /// + /// - Returns: The matching users with their identifiers, which ``createConversation(with:)`` and ``userAvatar(_:size:darkTheme:)`` expect, and their display names. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/unexpectedStatus(code:)`` for any non-success response. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when the server answers with something other than a list of users. + /// - Any other error that might occur during retrieval. + /// + public func searchUsers(_ term: String) async throws -> [User] { + try requireCredentials() + logger.debug("Searching users matching \"\(term)\"...") + + // A new Talk conversation is the context the Talk clients search in when they start one, and naming it lets the server tailor its results to that. + let queryItems = [ + URLQueryItem(name: "search", value: term), + URLQueryItem(name: "itemType", value: "call"), + URLQueryItem(name: "itemId", value: "new"), + ] + + let request = try makeOCSRequest(for: "core/autocomplete/get", method: .get, queryItems: queryItems) + let (data, urlResponse) = try await session.data(for: request) + + guard let response = urlResponse as? HTTPURLResponse else { + throw RainmakerError.responseDecodingFailed(reason: "Failed to cast URLResponse to HTTPURLResponse.") + } + + // The autocompletion is part of every installation, so a missing route is not a condition of its own. + guard response.status == .ok else { + throw RainmakerError.unexpectedStatus(code: response.statusCode) + } + + let envelope: UserSearchResponse + + do { + envelope = try jsonDecoder.decode(UserSearchResponse.self, from: data) + } catch { + throw RainmakerError.responseDecodingFailed(reason: "The server did not answer with the users matching the search.") + } + + guard envelope.ocs.meta.status == "ok" else { + throw RainmakerError.responseDecodingFailed(reason: "OCS request failed (\(envelope.ocs.meta.statuscode)): \(envelope.ocs.meta.message ?? "No message.")") + } + + return envelope.ocs.data.map { User(id: $0.id, displayName: $0.label) } + } + + /// + /// Start a one-to-one Nextcloud Talk conversation with the given user, or find the one which already exists. + /// + /// The server keeps a single one-to-one conversation per pair of users and answers with that one when it exists, so calling this repeatedly is safe and is the way to find the conversation with a person before ``sendMessage(_:toConversation:)`` or ``sendFile(_:toConversation:caption:)`` is used, for example in a single action which sends something to a person. + /// Find the identifier of the user with ``searchUsers(_:)``. + /// + /// Group conversations are deliberately not offered: a client sending something to a person needs the one-to-one conversation, while naming and populating a group is the work of a Talk client. + /// + /// Credentials are required, and the same availability considerations as for ``conversations()`` apply. + /// + /// - Parameters: + /// - userId: The identifier of the other user, which is neither their display name nor necessarily the name they log in with, see ``User/id``. + /// + /// - Returns: The conversation, whose ``Conversation/token`` addresses it in every further request and whose ``Conversation/type`` is ``ConversationType/oneToOne``. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/notFound`` when no such user exists or the Talk app is not available on the server. The server answers `404` for both, so they are deliberately not told apart. + /// - ``RainmakerError/unexpectedStatus(code:)`` for any other non-success response, such as `403` when the account names itself, when the server's sharing settings do not let it find the other user, or when the account may not use Talk. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when the server answers with something other than a conversation. + /// - Any other error that might occur during creation. + /// + public func createConversation(with userId: String) async throws -> Conversation { + try requireCredentials() + logger.debug("Starting a Talk conversation with \(userId)...") + + // A room type of 1 is a one-to-one conversation, for which the server takes the other user from `invite` and ignores a name. + let request = try makeOCSRequest(for: "apps/spreed/api/v4/room", method: .post, jsonBody: ConversationCreationRequest(roomType: 1, invite: userId)) + let (data, urlResponse) = try await session.data(for: request) + + guard let response = urlResponse as? HTTPURLResponse else { + throw RainmakerError.responseDecodingFailed(reason: "Failed to cast URLResponse to HTTPURLResponse.") + } + + if response.status == .notFound { + throw RainmakerError.notFound + } + + // The server answers with `200` for the conversation which already existed and with `201` for one it created, and both carry the conversation. + guard response.status == .ok || response.status == .created else { + throw RainmakerError.unexpectedStatus(code: response.statusCode) + } + + let envelope: ConversationResponse + + do { + envelope = try jsonDecoder.decode(ConversationResponse.self, from: data) + } catch { + throw RainmakerError.responseDecodingFailed(reason: "The server did not answer with the conversation: \(error)") + } + + guard envelope.ocs.meta.status == "ok" else { + throw RainmakerError.responseDecodingFailed(reason: "OCS request failed (\(envelope.ocs.meta.statuscode)): \(envelope.ocs.meta.message ?? "No message.")") + } + + return envelope.ocs.data + } + + /// + /// Send a chat message into a Nextcloud Talk conversation as the authenticated user. + /// + /// The message is sent as given. Talk clients render Markdown and show previews of links, so text or an address shared from another app needs no preparation, while mentions require the server's own syntax and are not added here. + /// The server refuses a message longer than 32000 characters. + /// + /// This is a single request without any state, and cancelling the calling task cancels it. A message which reached the server before the cancellation is sent all the same. + /// + /// - Parameters: + /// - message: The text to send. + /// - token: The ``Conversation/token`` of the conversation to send to, as listed by ``conversations()`` or returned by ``createConversation(with:)``. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/readOnly`` when the conversation is read-only or the account may not chat in it, such as the ``ConversationType/changelog`` conversation. + /// - ``RainmakerError/notFound`` when no such conversation exists, the account does not take part in it, or the Talk app is not available on the server. The server answers `404` in all of those cases, so they are deliberately not told apart. + /// - ``RainmakerError/unexpectedStatus(code:)`` for any other non-success response, such as `412` while the conversation's lobby keeps the account out or `413` for a message which is too long. + /// - Any other error that might occur during sending. + /// + public func sendMessage(_ message: String, toConversation token: String) async throws { + try requireCredentials() + logger.debug("Sending a message to Talk conversation \(token)...") + + let request = try makeOCSRequest(for: "apps/spreed/api/v1/chat/\(token)", method: .post, jsonBody: ChatMessageRequest(message: message)) + let (_, urlResponse) = try await session.data(for: request) + + // The server answers with the message as it stored it, which nothing here needs, so only the status is looked at. + try validateChatResponse(urlResponse, expecting: .created) + } + + /// + /// Send a local file into a Nextcloud Talk conversation as the authenticated user, optionally with a caption. + /// + /// The file is uploaded into the account's files first, into the folder Talk keeps for what the account sends into this conversation, and then posted as a message which other participants can open the file from. That folder is shared with the conversation, so the file stays in the account's files and counts towards its quota. + /// The server keeps the name of the file and appends a number to it when the folder already holds one of the same name. + /// + /// This requires Talk 24 or later with its folders per conversation enabled, which ``Talk/storesAttachmentsPerConversation`` tells in advance, e.g. `try await capabilities().get(Talk.self)?.storesAttachmentsPerConversation == true`. Older releases only offer an earlier way of sending a file, which this method deliberately does not fall back to. + /// + /// A conversation federated from another server cannot receive files this way, which its server answers with `406`. + /// + /// Three steps run in turn, each within the calling task: a request which asks the server for a private draft folder, the upload of the file into it, and a request which moves the file from there into the shared folder and posts the message. Nobody but the account sees the draft folder, and the file is deleted from it again when posting the message fails before the server moved it, while a file the server moved before failing stays in the shared folder without a message. That is what happens to a caption which makes the stored message exceed 32000 characters, a limit the server counts after escaping the caption, which turns every character beyond plain ASCII into six characters or more, so a caption should stay well below it. Cancelling the calling task while the message is posted can leave the file in the draft folder as well. + /// The name of the file in the draft folder is derived from the conversation, the file name, the size and the modification date, so that sending the same file again after a failure overwrites the draft left behind rather than adding another one, while two sends of the same file into the same conversation at the same time share one draft and one of them can fail with ``RainmakerError/notFound``. + /// A file larger than ``defaultChunkSize`` is uploaded in chunks, exactly as ``upload(_:to:force:chunkSize:)`` does. + /// + /// - Parameters: + /// - source: The local file to send, whose last path component becomes the name of the file on the server. + /// - token: The ``Conversation/token`` of the conversation to send to, as listed by ``conversations()`` or returned by ``createConversation(with:)``. + /// - caption: A text the message shows together with the file, or `nil` for none, which has to stay well below the 32000 characters a message may have. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/notFound`` when `source` is not a file, when no such conversation exists or the account does not take part in it, when the Talk app is not available on the server, or when its release is older than Talk 24. The server answers `404` in all of the latter cases, so they are deliberately not told apart. + /// - ``RainmakerError/readOnly`` when the conversation is read-only or the account may not chat in it. + /// - ``RainmakerError/insufficientStorage`` when the account's quota does not leave room for the file. + /// - ``RainmakerError/sourceChanged(_:)`` when the file changes while it is uploaded in chunks. + /// - ``RainmakerError/unexpectedStatus(code:)`` for any other non-success response, such as `501` when an administrator turned off the folders per conversation, `412` while the conversation's lobby keeps the account out, or `406` for a federated conversation. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when the server does not answer with the draft folder. + /// - Any other error that might occur during the upload or the sending. + /// + public func sendFile(_ source: URL, toConversation token: String, caption: String? = nil) async throws { + try requireCredentials() + logger.debug("Sending \"\(source.compatibilityPath(percentEncoded: false))\" to Talk conversation \(token)...") + + var isDirectory: ObjCBool = false + + guard fileManager.fileExists(atPath: source.compatibilityPath(percentEncoded: false), isDirectory: &isDirectory), isDirectory.boolValue == false else { + throw RainmakerError.notFound + } + + let folderRequest = try makeOCSRequest(for: "apps/spreed/api/v1/chat/\(token)/attachment/folder", method: .post) + let (folderData, folderResponse) = try await session.data(for: folderRequest) + try validateChatResponse(folderResponse, expecting: .ok) + + let folder: String + + do { + folder = try jsonDecoder.decode(AttachmentFolderResponse.self, from: folderData).ocs.data.folder + } catch { + throw RainmakerError.responseDecodingFailed(reason: "The server did not answer with the draft folder of the conversation.") + } + + let attributes = try fileManager.attributesOfItem(atPath: source.compatibilityPath(percentEncoded: false)) + let size = (attributes[.size] as? NSNumber)?.int64Value ?? 0 + let fileName = source.lastPathComponent + + // The draft name only has to be unique within the draft folder. Deriving it from the file rather than drawing it at random makes a retry of the same file overwrite its earlier draft instead of piling up copies, and keeps the requests of a recording replayable. The extension is kept because the server derives the type of the file from it. + let transferName = Self.uploadTransferName(for: "\(token)/\(fileName)", size: size, modification: attributes[.modificationDate] as? Date) + let draftPath = source.pathExtension.isEmpty ? "\(folder)/\(transferName)" : "\(folder)/\(transferName).\(source.pathExtension)" + + do { + try await uploadFile(source, to: "/\(draftPath)", force: true, chunkSize: Self.defaultChunkSize) + } catch RainmakerError.unexpectedStatus(code: HTTPStatus.insufficientStorage.rawValue) { + // The upload reports an exhausted quota as a plain status, which would make the same condition surface differently depending on the step it hit. + throw RainmakerError.insufficientStorage + } + + let talkMetaData = try caption.map { try String(decoding: jsonEncoder.encode(["caption": $0]), as: UTF8.self) } + let attachment = ChatAttachmentRequest(filePath: draftPath, fileName: fileName, referenceId: UUID().uuidString, talkMetaData: talkMetaData) + + do { + let request = try makeOCSRequest(for: "apps/spreed/api/v1/chat/\(token)/attachment", method: .post, jsonBody: attachment) + let (_, urlResponse) = try await session.data(for: request) + try validateChatResponse(urlResponse, expecting: .ok) + } catch { + // Only the account sees the draft folder, so a file left there is clutter rather than a leak, which is why a failure to delete it is not reported over the error which matters. + try? await delete("/\(draftPath)") + throw error + } + } + /// /// Retrieve the avatar of a single Nextcloud user. /// diff --git a/Sources/Rainmaker/Serving.swift b/Sources/Rainmaker/Serving.swift index 4a0d55d..135089c 100644 --- a/Sources/Rainmaker/Serving.swift +++ b/Sources/Rainmaker/Serving.swift @@ -116,6 +116,26 @@ protocol Serving: Sendable { /// func conversationAvatar(_ token: String, darkTheme: Bool) async throws -> ConversationAvatar + /// + /// Search the users of the server whose name or identifier matches the given term. + /// + func searchUsers(_ term: String) async throws -> [User] + + /// + /// Start a one-to-one Nextcloud Talk conversation with the given user, or find the one which already exists. + /// + func createConversation(with userId: String) async throws -> Conversation + + /// + /// Send a chat message into a Nextcloud Talk conversation as the authenticated user. + /// + func sendMessage(_ message: String, toConversation token: String) async throws + + /// + /// Send a local file into a Nextcloud Talk conversation as the authenticated user, optionally with a caption. + /// + func sendFile(_ source: URL, toConversation token: String, caption: String?) async throws + /// /// Retrieve the avatar of a single Nextcloud user. /// diff --git a/Sources/RainmakerCLI/Commands/Conversations.swift b/Sources/RainmakerCLI/Commands/Conversations.swift index fe1f713..787335d 100644 --- a/Sources/RainmakerCLI/Commands/Conversations.swift +++ b/Sources/RainmakerCLI/Commands/Conversations.swift @@ -6,14 +6,14 @@ import Foundation import Rainmaker /// -/// Talk command grouping the conversation listing and the image retrieval of a single conversation. +/// Talk command grouping the conversation listing, the image retrieval of a single conversation, starting a one-to-one conversation and sending a message or a file into a conversation. /// -/// This is a group rather than two top-level commands because both read the same app through the same credentials, mirroring how ``Collectives`` groups its listing and its pages. It is named after the library methods it calls rather than after the app providing them, matching every other subcommand, and calling it `talk` would additionally shadow the library's `Talk` capability inside this module. +/// This is a group rather than several top-level commands because all of them use the same app through the same credentials, mirroring how ``Collectives`` groups its listing and its pages. The identifier `conversations create` takes is the one `search-users` prints, and the token `conversations send` and `conversations send-file` take is the one `conversations list` and `conversations create` print. It is named after the library methods it calls rather than after the app providing them, matching every other subcommand, and calling it `talk` would additionally shadow the library's `Talk` capability inside this module. /// struct Conversations: AsyncParsableCommand { static let configuration = CommandConfiguration( - abstract: "List the Talk conversations of the authenticated user and retrieve their images. Requires authentication and the server's Talk app.", - subcommands: [List.self, Avatar.self], + abstract: "List the Talk conversations of the authenticated user, retrieve their images, start one-to-one conversations and send messages and files into them. Requires authentication and the server's Talk app.", + subcommands: [List.self, Avatar.self, Create.self, Send.self, SendFile.self], defaultSubcommand: List.self ) @@ -105,4 +105,158 @@ struct Conversations: AsyncParsableCommand { print("\(avatar.contentType)\t\(expandedOutput)") } } + + /// + /// One-to-one conversation subcommand, which calls `Server.createConversation(with:)`. + /// + /// The server keeps a single one-to-one conversation per pair of users, so this prints the existing conversation when there already is one. The output matches that of ``List`` for a single conversation, so that a script can cut the token to pass on to ``Send`` or ``SendFile``. + /// + struct Create: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Start a one-to-one Talk conversation with a user, or find the existing one, and print it. Requires the server's Talk app.") + + /// + /// The credentials of the account starting the conversation. + /// + @OptionGroup + var authenticatedArguments: AuthenticatedArguments + + /// + /// Whether to print the conversation as plain text or as JSON. + /// + @OptionGroup + var formatArguments: FormatArguments + + /// + /// The address of the server. + /// + @OptionGroup + var unauthenticatedArguments: UnauthenticatedArguments + + /// + /// The identifier of the other user, which is neither necessarily their display name nor the name they log in with. + /// + /// It is not called `user` because ``AuthenticatedArguments`` already takes `--user` for the account to authenticate with. + /// + @Option(help: "The identifier of the user to start the conversation with, as shown by `search-users`.") + var userId: String + + func run() async throws { + guard let address = URL(string: unauthenticatedArguments.hostValue) else { + throw RainmakerCommandError.invalidAddress + } + + let server = Server(address: address, password: authenticatedArguments.passwordValue, user: authenticatedArguments.userValue) + let conversation = try await server.createConversation(with: userId) + + switch formatArguments.outputFormat { + case .json: + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + + let data = try encoder.encode(conversation) + + guard let json = String(data: data, encoding: .utf8) else { + throw RainmakerCommandError.encodingError + } + + print(json) + case .plain: + print("\(conversation.token)\t\(conversation.displayName)") + } + } + } + + /// + /// Chat message subcommand, which calls `Server.sendMessage(_:toConversation:)`. + /// + /// Like the top-level `delete` command it prints nothing on success. + /// + struct Send: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Send a chat message into a Talk conversation. Requires the server's Talk app.") + + /// + /// The credentials of the account sending the message. + /// + @OptionGroup + var authenticatedArguments: AuthenticatedArguments + + /// + /// The address of the server. + /// + @OptionGroup + var unauthenticatedArguments: UnauthenticatedArguments + + /// + /// The token of the conversation to send the message into. + /// + @Option(help: "The token of the conversation to send to, as shown by `conversations list` or `conversations create`.") + var token: String + + /// + /// The text to send, which the server renders as Markdown. + /// + @Option(help: "The text to send.") + var message: String + + func run() async throws { + guard let address = URL(string: unauthenticatedArguments.hostValue) else { + throw RainmakerCommandError.invalidAddress + } + + let server = Server(address: address, password: authenticatedArguments.passwordValue, user: authenticatedArguments.userValue) + try await server.sendMessage(message, toConversation: token) + } + } + + /// + /// File sending subcommand, which calls `Server.sendFile(_:toConversation:caption:)`. + /// + /// Like ``Send`` it prints nothing on success. The file is uploaded into the account's files first, so it counts towards the account's quota. + /// + struct SendFile: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Send a local file into a Talk conversation, optionally with a caption. Requires Talk 24 or later on the server.") + + /// + /// The credentials of the account sending the file. + /// + @OptionGroup + var authenticatedArguments: AuthenticatedArguments + + /// + /// The address of the server. + /// + @OptionGroup + var unauthenticatedArguments: UnauthenticatedArguments + + /// + /// The token of the conversation to send the file into. + /// + @Option(help: "The token of the conversation to send to, as shown by `conversations list` or `conversations create`.") + var token: String + + /// + /// The path of the local file to send, which may start with `~`. + /// + @Option(help: "The local file to send. Its name becomes the name of the file on the server.") + var file: String + + /// + /// The text the message shows together with the file, if any. + /// + @Option(help: "A text to show together with the file.") + var caption: String? + + func run() async throws { + guard let address = URL(string: unauthenticatedArguments.hostValue) else { + throw RainmakerCommandError.invalidAddress + } + + let server = Server(address: address, password: authenticatedArguments.passwordValue, user: authenticatedArguments.userValue) + + // `URL(fileURLWithPath:)` does not expand `~`; do it ourselves so paths like `~/Desktop/photo.png` resolve to the user's home directory instead of a literal `~` folder. + let source = URL(fileURLWithPath: (file as NSString).expandingTildeInPath) + try await server.sendFile(source, toConversation: token, caption: caption) + } + } } diff --git a/Sources/RainmakerCLI/Commands/Rainmaker.swift b/Sources/RainmakerCLI/Commands/Rainmaker.swift index 502bd75..0006fd6 100644 --- a/Sources/RainmakerCLI/Commands/Rainmaker.swift +++ b/Sources/RainmakerCLI/Commands/Rainmaker.swift @@ -10,7 +10,7 @@ import Foundation @main struct Rainmaker: AsyncParsableCommand { static let configuration: CommandConfiguration = { - var subcommands: [any ParsableCommand.Type] = [Activities.self, ActivityFilters.self, Capabilities.self, Collectives.self, Conversations.self, CreateDirectory.self, CurrentUser.self, Delete.self, DeleteAppPassword.self, Download.self, Info.self, List.self, Login.self, Move.self, Navigation.self, Notes.self, NotesSettings.self, Notifications.self, Poll.self, Trash.self, Upload.self, Watch.self] + var subcommands: [any ParsableCommand.Type] = [Activities.self, ActivityFilters.self, Capabilities.self, Collectives.self, Conversations.self, CreateDirectory.self, CurrentUser.self, Delete.self, DeleteAppPassword.self, Download.self, Info.self, List.self, Login.self, Move.self, Navigation.self, Notes.self, NotesSettings.self, Notifications.self, Poll.self, SearchUsers.self, Trash.self, Upload.self, Watch.self] // The fixture recorder controls Docker and is therefore available on macOS only. #if os(macOS) diff --git a/Sources/RainmakerCLI/Commands/SearchUsers.swift b/Sources/RainmakerCLI/Commands/SearchUsers.swift new file mode 100644 index 0000000..32e6d4e --- /dev/null +++ b/Sources/RainmakerCLI/Commands/SearchUsers.swift @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import ArgumentParser +import Foundation +import Rainmaker + +/// +/// User search command, which calls `Server.searchUsers(_:)`. +/// +/// The plain output is the identifier and the display name of each user separated by a tab, one user per line, so that a script can cut the identifier to pass on to `conversations create`. +/// +struct SearchUsers: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Search the users of the server by name or identifier, for example to start a Talk conversation with one. Requires authentication.") + + /// + /// The credentials of the account searching. + /// + @OptionGroup + var authenticatedArguments: AuthenticatedArguments + + /// + /// Whether to print the users as plain text or as JSON. + /// + @OptionGroup + var formatArguments: FormatArguments + + /// + /// The address of the server. + /// + @OptionGroup + var unauthenticatedArguments: UnauthenticatedArguments + + /// + /// What to search for, such as part of a display name or an identifier. + /// + @Option(help: "What to search for, such as part of a display name or an identifier.") + var term: String + + func run() async throws { + guard let address = URL(string: unauthenticatedArguments.hostValue) else { + throw RainmakerCommandError.invalidAddress + } + + let server = Server(address: address, password: authenticatedArguments.passwordValue, user: authenticatedArguments.userValue) + let users = try await server.searchUsers(term) + + switch formatArguments.outputFormat { + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + + let data = try encoder.encode(users) + + guard let json = String(data: data, encoding: .utf8) else { + throw RainmakerCommandError.encodingError + } + + print(json) + case .plain: + for user in users { + print("\(user.id)\t\(user.displayName)") + } + } + } +} diff --git a/Sources/RainmakerCLI/Fixtures/FixtureOrchestrator.swift b/Sources/RainmakerCLI/Fixtures/FixtureOrchestrator.swift index 82f6c54..758a0e0 100644 --- a/Sources/RainmakerCLI/Fixtures/FixtureOrchestrator.swift +++ b/Sources/RainmakerCLI/Fixtures/FixtureOrchestrator.swift @@ -25,10 +25,11 @@ /// /// Excluded are suites that never touch the network (request-factory and fixture self-tests) and those whose fixtures are hand-authored with synthetic, version-independent values that a live server cannot reproduce: `TrashTests` (asserts fixed trash identifiers and deletion timestamps), `PollTests` (a successful poll needs a completed browser flow, and it targets the canonical address rather than the container), `NotificationsTests` (the queued notifications depend on server state a plain baseline does not produce) , `ActivityTests` (the recorded activities depend on the same, and the end of the stream, the previews and an absent activity app cannot be provoked on a live container) and `CollectivesTests` (the collectives app is not part of a Nextcloud installation, and installing it mounts a folder of its own into the account's files, which both changes the listings `ListingTests` records and makes the forced baseline reset of ``FixtureProvisioner/reset()`` treat that mount as an orphan to delete). Those are carried forward across versions instead. /// - /// `ConversationsTests` records although the Talk app it needs is not part of a Nextcloud installation either, because Talk keeps its conversations outside the account's files and mounts nothing into them, so neither `ListingTests` nor the baseline reset notices it. See ``installedApps``. + /// `ConversationsTests` and `ConversationMutationTests` record although the Talk app they need is not part of a Nextcloud installation either, because Talk keeps its conversations outside the account's files and mounts nothing into them, so neither `ListingTests` nor the baseline reset notices it. The file `ConversationMutationTests` sends on Talk 24 does land in the account's files, in a folder below `Talk` which the next baseline reset removes together with the share of that folder. See ``installedApps``. /// static let recordableSuites: Set = [ "CapabilitiesTests", + "ConversationMutationTests", "ConversationsTests", "CreateDirectoryTests", "CurrentUserTests", @@ -53,7 +54,7 @@ static let notesApp = "notes" /// - /// The identifier of the Talk app, which `ConversationsTests` cannot record anything without. + /// The identifier of the Talk app, which `ConversationsTests` and `ConversationMutationTests` cannot record anything without. /// /// Talk is the app's presented name while `spreed` is the identifier it has always had internally, which is also the key it advertises its ``Talk`` capability under. /// diff --git a/Sources/RainmakerCLI/Fixtures/FixtureProvisioner.swift b/Sources/RainmakerCLI/Fixtures/FixtureProvisioner.swift index 653c474..01e55bd 100644 --- a/Sources/RainmakerCLI/Fixtures/FixtureProvisioner.swift +++ b/Sources/RainmakerCLI/Fixtures/FixtureProvisioner.swift @@ -46,12 +46,19 @@ ] /// - /// The name of the Talk conversation seeded for `ConversationsTests`, which is what makes it findable without relying on a token or an identifier. + /// The name of the Talk conversation seeded for `ConversationsTests` and `ConversationMutationTests`, which is what makes it findable without relying on a token or an identifier. /// - /// A conversation with a known name is needed because a plain Talk installation only creates conversations of its own, whose names are localized to the account's language and whose unread counts follow the release of the app rather than of the server. The same literal appears in the test suite, which cannot import this module. + /// A conversation with a known name is needed because a plain Talk installation only creates conversations of its own, whose names are localized to the account's language and whose unread counts follow the release of the app rather than of the server. The same literal appears in both test suites, which cannot import this module. /// static let seededConversation = "Rainmaker" + /// + /// The identifier and the display name of the second account seeded for `ConversationMutationTests`, which its user search finds and its one-to-one conversation is started with. + /// + /// A container only has the administrator's account, and neither a search which only finds the searching account nor a conversation with oneself proves anything. The same literals appear in the test suite, which cannot import this module. + /// + static let seededUser = (id: "alice", displayName: "Alice") + /// /// Generate the baseline tree on disk and upload it to the server. /// @@ -88,6 +95,10 @@ try? await server.delete("/Documents") } else if testID.contains("ConversationsTests/") { try await seedConversation() + } else if testID.contains("ConversationMutationTests/") { + // The suite searches for and starts a conversation with a second account, and sends into the seeded conversation. + try await seedUser() + try await seedConversation() } else if testID.contains("NotesTests/fetchNone") { // The fixture records an account without a single note, so the seeded notes folder has to be gone beforehand. The baseline is restored before every following test, which brings it back. try? await server.delete("/\(notesFolder)") @@ -105,7 +116,7 @@ /// /// Create the Talk conversation named ``seededConversation`` unless it already exists. /// - /// Conversations live outside the account's files, so ``reset()`` neither removes nor restores them and the one created here survives for the rest of the container's life. That is why this is idempotent and why every test of the suite asks for it rather than only the first: the recorder runs them in whichever order `swift test list` reports, so no test can rely on another having run before it. + /// Conversations live outside the account's files, so ``reset()`` neither removes nor restores them and the one created here survives for the rest of the container's life. That is why this is idempotent and why every test of the suites relying on it asks for it rather than only the first: the recorder runs them in whichever order `swift test list` reports, so no test can rely on another having run before it. /// /// Talk has no WebDAV surface to provision through, so the request is built with ``Server/makeOCSRequest(for:method:queryItems:)`` and performed on a session of its own. That is the sanctioned route for reaching an endpoint the library itself does not cover. /// @@ -129,6 +140,36 @@ } } + /// + /// Create the account described by ``seededUser`` unless it already exists. + /// + /// Accounts live outside the account's files just like conversations, so ``reset()`` neither removes nor restores them and the one created here survives for the rest of the container's life. That is why this is idempotent and why every test of `ConversationMutationTests` asks for it rather than only the first, for the same reason ``seedConversation()`` gives. + /// + /// The library covers no administration of accounts, so the requests to the provisioning API are built with ``Server/makeOCSRequest(for:method:queryItems:)`` and performed on a session of their own, as the administrator the recording authenticates as. The server asks for the administrator's password to be confirmed before it creates an account, which a request authenticated with that password already counts as. + /// + /// The password is built from two random UUIDs, which is long and varied enough for a password policy to accept, and it is never used, because no recording logs in as this account. + /// + private func seedUser() async throws { + let session = URLSession(configuration: .ephemeral) + let lookup = try server.makeOCSRequest(for: "cloud/users/\(Self.seededUser.id)", method: .get) + let (_, lookupResponse) = try await session.data(for: lookup) + + // The server answers `404` for an account which does not exist, and any other failure surfaces when creating it. + guard (lookupResponse as? HTTPURLResponse)?.statusCode != 200 else { + return + } + + var request = try server.makeOCSRequest(for: "cloud/users", method: .post) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: ["userid": Self.seededUser.id, "password": UUID().uuidString + UUID().uuidString, "displayName": Self.seededUser.displayName]) + + let (_, urlResponse) = try await session.data(for: request) + + guard (urlResponse as? HTTPURLResponse)?.statusCode == 200 else { + throw FixtureRecordingError.userSeedingFailed + } + } + /// /// Generate the controlled baseline tree on the local file system. /// diff --git a/Sources/RainmakerCLI/Fixtures/FixtureRecordingError.swift b/Sources/RainmakerCLI/Fixtures/FixtureRecordingError.swift index e1b09e5..dc1085b 100644 --- a/Sources/RainmakerCLI/Fixtures/FixtureRecordingError.swift +++ b/Sources/RainmakerCLI/Fixtures/FixtureRecordingError.swift @@ -28,6 +28,11 @@ /// case conversationSeedingFailed + /// + /// The second account the conversation mutation fixtures are recorded against could not be created. + /// + case userSeedingFailed + var description: String { switch self { case .verificationFailed: @@ -36,6 +41,8 @@ "Gave up waiting for \(subject): \(reason)." case .conversationSeedingFailed: "The Talk conversation the conversation fixtures are recorded against could not be created. Check that the Talk app is installed and enabled for the account being recorded." + case .userSeedingFailed: + "The second account the conversation mutation fixtures are recorded against could not be created. Check that the recording authenticates as an administrator and that the provisioning API app is enabled." case let .appInstallationFailed(app): "The \"\(app)\" app could not be installed into the container. It is fetched from the Nextcloud app store, so check that the store is reachable and that it offers a release compatible with the server version being recorded." } diff --git a/Tests/RainmakerTests/CapabilitiesTests.swift b/Tests/RainmakerTests/CapabilitiesTests.swift index 9d3b967..d7cb217 100644 --- a/Tests/RainmakerTests/CapabilitiesTests.swift +++ b/Tests/RainmakerTests/CapabilitiesTests.swift @@ -85,6 +85,10 @@ import Testing #expect(talk.supports("no-such-talk-feature") == false) #expect(capabilities.contains(Talk.self)) + // The folders per conversation which sending a file needs arrived with Talk 24, and older releases do not advertise the flag at all. The release of the app rather than of the server decides, because the app store hands each server its own one. + let talkMajorVersion = try #require(talk.version.flatMap { Int($0.prefix { $0 != "." }) }) + #expect(talk.storesAttachmentsPerConversation == (talkMajorVersion >= 24)) + #expect(capabilities.version.string == serverVersion.rawValue) #expect(capabilities.version.major > 0) } @@ -111,6 +115,10 @@ import Testing #expect(talk.supports("conversation-v4")) #expect(capabilities.contains(Talk.self)) + // The flag is part of what Talk shows anonymous clients too, so it reads the same as for an authenticated one. + let talkMajorVersion = try #require(talk.version.flatMap { Int($0.prefix { $0 != "." }) }) + #expect(talk.storesAttachmentsPerConversation == (talkMajorVersion >= 24)) + #expect(capabilities.version.string == serverVersion.rawValue) } } diff --git a/Tests/RainmakerTests/ConversationMutationTests.swift b/Tests/RainmakerTests/ConversationMutationTests.swift new file mode 100644 index 0000000..04d0231 --- /dev/null +++ b/Tests/RainmakerTests/ConversationMutationTests.swift @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker +import RainmakerTestServerTags +import Testing + +/// +/// About searching the users of the server, starting a Talk conversation with one of them, and sending messages and files into a conversation as the authenticated user. +/// +/// The fixtures backing this suite are recorded like those of ``ConversationsTests``, against a container with the Talk app installed. Besides the conversation that suite asserts on, ``FixtureProvisioner`` seeds a second account for this one, because the search needs somebody to find and a one-to-one conversation needs somebody to talk to. Accounts live outside the account's files, so the baseline reset between recordings neither removes nor restores it, which is why the seeding is idempotent. +/// +/// Every test removes what it creates where Talk lets it. Talk refuses to delete a one-to-one conversation unless an administrator allowed it, so the account leaves the conversation it started instead, which takes it out of its listing while Talk keeps it for the other participant. A message cannot be taken back, because sending one reports nothing to delete, so the messages and files this suite sends stay in the seeded conversation, which is harmless because ``ConversationsTests`` asserts nothing about its messages. The file itself lands in the account's files, which the baseline reset removes again. +/// +/// The token of a conversation is taken from a listing or a creation recorded in the same test rather than pinned, because it is part of the path of every further request about that conversation and the server assigns it anew in every deployment. Every request of a test differs in method or path, which is what lets the fixture tree replay them. The file sent is stamped with a fixed modification date, because the name it is uploaded under is derived from it. +/// +/// Whether a file can be sent depends on the release of the Talk app rather than on the server, so that test records the capabilities first and branches on ``Talk/storesAttachmentsPerConversation``. How the requests are built and how the statuses Talk may answer with map onto ``RainmakerError`` is covered by ``ConversationWritingRequestTests`` instead. +/// +/// Calls go through ``Serving``, whose requirements have no default arguments, so every argument is spelled out. +/// +@Suite("Conversation Mutations") struct ConversationMutationTests: ServerTesting { + /// + /// The name of the conversation ``FixtureProvisioner`` seeds, which is what makes it findable without relying on a token, as in ``ConversationsTests``. + /// + let seededConversation = "Rainmaker" + + /// + /// The second account ``FixtureProvisioner`` seeds, which the search finds and the one-to-one conversation is started with. + /// + let seededUser = User(id: "alice", displayName: "Alice") + + /// + /// The moment the file this suite sends is stamped with, pinned because the name it is uploaded under is derived from it. + /// + let modification = Date(timeIntervalSince1970: 1_700_000_000) + + @Test("Search Users", arguments: ServerVersion.allCases) + func searchUsers(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let users = try await server.searchUsers(seededUser.id) + + // Whatever else a release finds for the term, the seeded account is among the results with the display name it was created with. + #expect(users.contains(seededUser)) + } + + @Test("Create Conversation", arguments: ServerVersion.allCases) + func createConversation(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let conversation = try await server.createConversation(with: seededUser.id) + + // A one-to-one conversation is named after the other participant from the point of view of each of them. + #expect(conversation.type == .oneToOne) + #expect(conversation.displayName == seededUser.displayName) + #expect(conversation.token.isEmpty == false) + + // Talk answers the deletion of a one-to-one conversation with `400` unless an administrator allowed it, so the account leaves the conversation instead, which takes it out of its listing just the same. The request goes through the session the server records and replays with, so that it is part of the fixtures as well. + let concrete = try #require(server as? Server) + let request = try concrete.makeOCSRequest(for: "apps/spreed/api/v4/room/\(conversation.token)/participants/self", method: .delete) + let (_, urlResponse) = try await concrete.session.data(for: request) + + #expect((urlResponse as? HTTPURLResponse)?.statusCode == 200) + } + + @Test("Send Message", arguments: ServerVersion.allCases) + func sendMessage(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let conversations = try await server.conversations() + let conversation = try #require(conversations.first { $0.displayName == seededConversation }) + + try await server.sendMessage("Hello from Rainmaker", toConversation: conversation.token) + } + + @Test("Send File", arguments: ServerVersion.allCases) + func sendFile(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let talk = try #require(try await server.capabilities().get(Talk.self)) + let conversations = try await server.conversations() + let conversation = try #require(conversations.first { $0.displayName == seededConversation }) + + let source = FileManager.default.temporaryDirectory.appendingCompatibility(component: UUID().uuidString) + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + + defer { + try? FileManager.default.removeItem(at: source) + } + + let file = source.appendingCompatibility(component: "Rainmaker.txt") + try Data("Rainmaker\n".utf8).write(to: file) + + // The name the file is uploaded under in the draft folder is derived from the conversation, the file name, the size and the modification date, so the latter is pinned to keep the recorded upload replayable. + try FileManager.default.setAttributes([.modificationDate: modification], ofItemAtPath: file.compatibilityPath()) + + if talk.storesAttachmentsPerConversation { + try await server.sendFile(file, toConversation: conversation.token, caption: "Rainmaker") + } else { + // Releases before Talk 24 route no draft folder, which the server answers with `404` and which therefore surfaces as not found before anything is uploaded. + await #expect(throws: RainmakerError.notFound) { + try await server.sendFile(file, toConversation: conversation.token, caption: "Rainmaker") + } + } + } +} diff --git a/Tests/RainmakerTests/ConversationWritingRequestTests.swift b/Tests/RainmakerTests/ConversationWritingRequestTests.swift new file mode 100644 index 0000000..7c8d90c --- /dev/null +++ b/Tests/RainmakerTests/ConversationWritingRequestTests.swift @@ -0,0 +1,604 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker +import Testing + +/// +/// About how ``Server/searchUsers(_:)``, ``Server/createConversation(with:)``, ``Server/sendMessage(_:toConversation:)`` and ``Server/sendFile(_:toConversation:caption:)`` build their requests and how the statuses the server and the Talk app answer them with map onto ``RainmakerError``. +/// +/// These tests deliberately do not use the fixture tree: ``URLTestSession`` ignores query strings, request headers and request bodies, so it cannot prove what a search asked for, what a conversation was created with or what a message carried, and a live container cannot be made to answer with a read-only conversation, an exhausted quota or a failure after an upload. A capturing ``MockRequesting`` is used instead, whose ``MockRequesting/Responder`` answers the several requests of sending a file each in its own way. +/// +@Suite("Conversation Writing Requests") struct ConversationWritingRequestTests { + /// + /// The address of the server the tests send to. + /// + let serverAddress = URL(string: "http://localhost/")! + + /// + /// The token of the conversation the tests send to. + /// + let token = "abcd1234" + + /// + /// The draft folder the Talk app names for the conversation, relative to the account's files and with a space in the name of the conversation, so that its encoding is covered as well. + /// + let folder = "Talk/Weekly Sync-abcd1234/Draft" + + /// + /// The moment the files the tests send were last modified, pinned because the name of the uploaded draft is derived from it. + /// + let modification = Date(timeIntervalSince1970: 1_700_000_000) + + /// + /// The bytes of the files the tests send, which do not need to be anything in particular for a mock. + /// + let bytes = Data("%PDF-1.7 Rainmaker".utf8) + + /// + /// The response of the server to a search, shaped as its autocompletion shapes every match, with a user status set for one match and none for the other. + /// + let searchResults = #"{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[{"id":"alice","label":"Alice Liddell","icon":"icon-user","source":"users","status":{"status":"online","message":null,"icon":null,"clearAt":null},"subline":"","shareWithDisplayNameUnique":"alice@example.com"},{"id":"5f2b9c1e-4a7d","label":"Alicia Keys","icon":"icon-user","source":"users","status":"","subline":"","shareWithDisplayNameUnique":"alicia@example.com"}]}}"# + + /// + /// The response of the Talk app to the creation of a one-to-one conversation with `alice`, shaped like the rooms a live server lists. + /// + let conversation = #"{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"id":7,"token":"q3x9pz2k","type":1,"name":"alice","displayName":"Alice Liddell","objectType":"","objectId":"","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity":1700000000,"lastReadMessage":0,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":false,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":12,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":0,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion":"00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"isArchived":false,"isImportant":false,"isSensitive":false,"tagIds":[],"attributes":0,"lastMessage":[]}}}"# + + /// + /// The response of the Talk app to a sent message, which the library does not look at beyond its status. + /// + let sentMessage = #"{"ocs":{"meta":{"status":"ok","statuscode":201,"message":"OK"},"data":{"id":42,"token":"abcd1234","actorType":"users","actorId":"admin","message":"Hello","messageType":"comment"}}}"# + + /// + /// Build a server whose session is the given mock. + /// + private func makeServer(session: any Requesting, user: String? = "admin", password: String? = "admin") -> Server { + Server(address: serverAddress, password: password, user: user, session: session, userAgent: "RainmakerTests") + } + + /// + /// Read the JSON object a captured request carries as its body. + /// + private func jsonBody(of request: URLRequest) throws -> [String: Any] { + let body = try #require(request.httpBody) + return try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + } + + /// + /// Read the decoded path of a captured request. + /// + private func path(of request: URLRequest) throws -> String { + let url = try #require(request.url) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + return components.path + } + + /// + /// Read the query items of a captured request, keyed by name. + /// + private func query(of request: URLRequest) throws -> [String: String] { + let url = try #require(request.url) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + + return (components.queryItems ?? []).reduce(into: [String: String]()) { result, item in + result[item.name] = item.value + } + } + + /// + /// Describe each captured request by its method and its decoded path, which is how the order of the requests of sending a file is compared. + /// + private func summary(of requests: [URLRequest]) -> [String] { + requests.map { "\($0.httpMethod ?? "") \($0.url?.compatibilityPath(percentEncoded: false) ?? "")" } + } + + /// + /// Write a file of ``bytes`` with the given name into a new folder in the temporary directory and pin its modification date to ``modification``, returning both so that the test removes the folder again. + /// + private func makeSourceFile(named name: String) throws -> (file: URL, folder: URL) { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("ConversationWritingRequestTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: false) + + let file = folder.appendingPathComponent(name) + try bytes.write(to: file) + try FileManager.default.setAttributes([.modificationDate: modification], ofItemAtPath: file.path) + + return (file, folder) + } + + /// + /// The path below the account's WebDAV root the draft of the given file is uploaded to, derived the way the library promises to derive it. + /// + private func draftPath(for fileName: String, pathExtension: String) -> String { + let transferName = Server.uploadTransferName(for: "\(token)/\(fileName)", size: Int64(bytes.count), modification: modification) + return pathExtension.isEmpty ? "\(folder)/\(transferName)" : "\(folder)/\(transferName).\(pathExtension)" + } + + /// + /// Build a mock answering the requests of sending a file as the server would, with the given statuses for asking for the draft folder, for uploading the draft, for posting the file and for deleting the draft, and with the draft not existing yet when it is looked up before the upload. + /// + private func makeSendFileSession(folderStatus: Int = 200, folderBody: String? = nil, uploadStatus: Int = 201, attachStatus: Int = 200, deleteStatus: Int = 204) -> MockRequesting { + let folderBody = folderBody ?? #"{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"folder":"\#(folder)","renames":[]}}}"# + let attachBody = #"{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"renames":[]}}}"# + + return MockRequesting { request in + let path = request.url?.compatibilityPath(percentEncoded: false) ?? "" + + switch request.httpMethod { + case "POST" where path.hasSuffix("/attachment/folder"): + return (Data(folderBody.utf8), folderStatus, ["Content-Type": "application/json; charset=utf-8"]) + case "POST" where path.hasSuffix("/attachment"): + return (Data(attachBody.utf8), attachStatus, ["Content-Type": "application/json; charset=utf-8"]) + case "PROPFIND": + return (Data(), 404, nil) + case "PUT": + return (Data(), uploadStatus, nil) + case "DELETE": + return (Data(), deleteStatus, nil) + default: + return (Data(), 500, nil) + } + } + } + + // MARK: - Credentials + + @Test("Every Call Requires Credentials") + func requireCredentials() async throws { + let session = MockRequesting(string: searchResults) + let server = makeServer(session: session, user: nil, password: nil) + let source = try makeSourceFile(named: "Report 1.pdf") + + defer { + try? FileManager.default.removeItem(at: source.folder) + } + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.searchUsers("ali") + } + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.createConversation(with: "alice") + } + + await #expect(throws: RainmakerError.credentialsRequired) { + try await server.sendMessage("Hello", toConversation: token) + } + + await #expect(throws: RainmakerError.credentialsRequired) { + try await server.sendFile(source.file, toConversation: token) + } + + // Credentials are required, so every call fails before any network request is made. + #expect(session.requests.isEmpty) + } + + // MARK: - Searching Users + + @Test("Searching Asks The Autocompletion For A New Conversation") + func searchRequest() async throws { + let session = MockRequesting(string: searchResults) + _ = try await makeServer(session: session).searchUsers("ali") + + let request = try #require(session.requests.first) + + #expect(session.requests.count == 1) + #expect(request.httpMethod == "GET") + #expect(try path(of: request) == "/ocs/v2.php/core/autocomplete/get") + + // Naming a new call as the context is what the Talk clients do when they start a conversation, so the server tailors its results to that. + #expect(try query(of: request) == ["search": "ali", "itemType": "call", "itemId": "new"]) + #expect(request.value(forHTTPHeaderField: "OCS-APIRequest") == "true") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Basic \(Data("admin:admin".utf8).base64EncodedString())") + } + + @Test("Searching Sends Reserved Characters Of The Term Intact") + func searchEncodesTerm() async throws { + let session = MockRequesting(string: searchResults) + let term = "Doe+Sons & Co=1" + _ = try await makeServer(session: session).searchUsers(term) + + let request = try #require(session.requests.first) + + // The server would read an unencoded `+` as a space and an unencoded `&` or `=` as delimiters of the query. + #expect(request.url?.query == "search=Doe%2BSons%20%26%20Co%3D1&itemType=call&itemId=new") + #expect(try query(of: request)["search"] == term) + } + + @Test("Searching Decodes The Matches In Order") + func searchDecodes() async throws { + let users = try await makeServer(session: MockRequesting(string: searchResults)).searchUsers("ali") + + // The identifier is what every further request expects, which for an account backed by a directory differs from anything a person would type. + #expect(users == [User(id: "alice", displayName: "Alice Liddell"), User(id: "5f2b9c1e-4a7d", displayName: "Alicia Keys")]) + } + + @Test("Searching Without Matches Returns None") + func searchWithoutMatches() async throws { + let body = #"{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[]}}"# + let users = try await makeServer(session: MockRequesting(string: body)).searchUsers("zzz") + + #expect(users.isEmpty) + } + + @Test("Searching Reports A Failure Status", arguments: [401, 404, 500]) + func searchFailureStatus(statusCode: Int) async throws { + let body = #"{"ocs":{"meta":{"status":"failure","statuscode":997,"message":"Current user is not logged in"},"data":[]}}"# + let session = MockRequesting(string: body, statusCode: statusCode) + + await #expect(throws: RainmakerError.unexpectedStatus(code: statusCode)) { + _ = try await makeServer(session: session).searchUsers("ali") + } + } + + @Test("Searching With Something Other Than Matches Fails To Decode") + func searchUndecodable() async throws { + let session = MockRequesting(string: "", headerFields: ["Content-Type": "text/html"]) + + await #expect { + _ = try await makeServer(session: session).searchUsers("ali") + } throws: { error in + guard case RainmakerError.responseDecodingFailed = error else { + return false + } + + return true + } + } + + @Test("Searching With A Failed OCS Envelope Fails To Decode") + func searchFailedEnvelope() async throws { + // A success status carrying a failed envelope must not pass for a search without matches. + let body = #"{"ocs":{"meta":{"status":"failure","statuscode":100,"message":"Broken"},"data":[]}}"# + let session = MockRequesting(string: body) + + await #expect(throws: RainmakerError.responseDecodingFailed(reason: "OCS request failed (100): Broken")) { + _ = try await makeServer(session: session).searchUsers("ali") + } + } + + // MARK: - Creating Conversations + + @Test("Creating A Conversation Posts A One-To-One Room As JSON") + func createRequest() async throws { + let session = MockRequesting(string: conversation, statusCode: 201) + _ = try await makeServer(session: session).createConversation(with: "alice") + + let request = try #require(session.requests.first) + let body = try jsonBody(of: request) + + #expect(session.requests.count == 1) + #expect(request.httpMethod == "POST") + #expect(try path(of: request) == "/ocs/v2.php/apps/spreed/api/v4/room") + #expect(request.url?.query == nil) + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(request.value(forHTTPHeaderField: "OCS-APIRequest") == "true") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Basic \(Data("admin:admin".utf8).base64EncodedString())") + + // A room type of 1 is a one-to-one conversation, and nothing else is sent, such as a name the server would ignore for it. + #expect(Set(body.keys) == ["roomType", "invite"]) + #expect(body["roomType"] as? Int == 1) + #expect(body["invite"] as? String == "alice") + } + + @Test("Creating A Conversation Returns The New Or The Existing One", arguments: [200, 201]) + func createDecodes(statusCode: Int) async throws { + // The server answers with `201` for a conversation it created and with `200` for the one which already existed. + let created = try await makeServer(session: MockRequesting(string: conversation, statusCode: statusCode)).createConversation(with: "alice") + + #expect(created.id == 7) + #expect(created.token == "q3x9pz2k") + #expect(created.type == .oneToOne) + #expect(created.displayName == "Alice Liddell") + #expect(created.avatarVersion == "00000000") + #expect(created.lastActivity == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(created.unreadMessages == 0) + #expect(created.unreadMention == false) + } + + @Test("Creating A Conversation With An Unknown User Is Not Found") + func createNotFound() async throws { + let body = #"{"ocs":{"meta":{"status":"failure","statuscode":404,"message":""},"data":[]}}"# + + await #expect(throws: RainmakerError.notFound) { + _ = try await makeServer(session: MockRequesting(string: body, statusCode: 404)).createConversation(with: "nobody") + } + } + + @Test("Creating A Conversation Reports Another Failure Status", arguments: [400, 403]) + func createFailureStatus(statusCode: Int) async throws { + // The server answers `403` for the account's own identifier, for a user the account may not see and for an account which may not use Talk, while `400` stands for any other failure status. + let body = #"{"ocs":{"meta":{"status":"failure","statuscode":400,"message":""},"data":{"error":"invite"}}}"# + + await #expect(throws: RainmakerError.unexpectedStatus(code: statusCode)) { + _ = try await makeServer(session: MockRequesting(string: body, statusCode: statusCode)).createConversation(with: "admin") + } + } + + @Test("Creating A Conversation Answered With Something Else Fails To Decode", arguments: [ + "", + #"{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[]}}"#, + #"{"ocs":{"meta":{"status":"failure","statuscode":100,"message":"Broken"},"data":{"id":7,"token":"q3x9pz2k","type":1,"displayName":"Alice Liddell","avatarVersion":"00000000","lastActivity":0,"unreadMessages":0,"unreadMention":false}}}"#, + ]) + func createUndecodable(body: String) async throws { + await #expect { + _ = try await makeServer(session: MockRequesting(string: body)).createConversation(with: "alice") + } throws: { error in + guard case RainmakerError.responseDecodingFailed = error else { + return false + } + + return true + } + } + + // MARK: - Sending Messages + + @Test("Sending A Message Posts It Unchanged As JSON") + func sendMessageRequest() async throws { + let session = MockRequesting(string: sentMessage, statusCode: 201) + let message = "# Shared from Safari\n\n**Rainmaker** is at https://example.com/a/b?c=d&e=f+g#h\n- Ä \u{1F600}" + try await makeServer(session: session).sendMessage(message, toConversation: token) + + let request = try #require(session.requests.first) + let body = try jsonBody(of: request) + + #expect(session.requests.count == 1) + #expect(request.httpMethod == "POST") + #expect(try path(of: request) == "/ocs/v2.php/apps/spreed/api/v1/chat/abcd1234") + #expect(request.url?.query == nil) + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(request.value(forHTTPHeaderField: "OCS-APIRequest") == "true") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Basic \(Data("admin:admin".utf8).base64EncodedString())") + + // The server renders the Markdown and previews the link itself, so neither is touched on the way. + #expect(Set(body.keys) == ["message"]) + #expect(body["message"] as? String == message) + } + + @Test("Sending A Message Maps The Statuses Of The Chat", arguments: zip([403, 404, 412, 413, 507, 200], [RainmakerError.readOnly, .notFound, .unexpectedStatus(code: 412), .unexpectedStatus(code: 413), .insufficientStorage, .unexpectedStatus(code: 200)])) + func sendMessageFailureStatus(statusCode: Int, expected: RainmakerError) async throws { + // A `200` is not what the chat answers a stored message with, so it is not taken for one either. + let session = MockRequesting(string: #"{"ocs":{"meta":{"status":"failure","statuscode":0,"message":""},"data":[]}}"#, statusCode: statusCode) + + await #expect(throws: expected) { + try await makeServer(session: session).sendMessage("Hello", toConversation: token) + } + } + + @Test("Cancelling The Task Cancels Sending A Message") + func sendMessageCancellation() async throws { + let session = SuspendingRequesting(answering: 0, through: MockRequesting(string: sentMessage, statusCode: 201)) + let server = makeServer(session: session) + + let sending = Task { + try await server.sendMessage("Hello", toConversation: token) + } + + // The request runs within the calling task, so a share extension which is dismissed ends the sending at once. + #expect(try await eventually { session.suspendedCount == 1 }) + sending.cancel() + + let result = await sending.result + + #expect(throws: URLError.self) { + try result.get() + } + + #expect(try await eventually { session.suspendedCount == 0 }) + } + + // MARK: - Sending Files + + @Test("Sending A File Uploads A Draft And Posts It With Its Caption") + func sendFile() async throws { + let session = makeSendFileSession() + let source = try makeSourceFile(named: "Report 1.pdf") + + defer { + try? FileManager.default.removeItem(at: source.folder) + } + + try await makeServer(session: session).sendFile(source.file, toConversation: token, caption: "Hello") + + let draftPath = draftPath(for: "Report 1.pdf", pathExtension: "pdf") + let webDAVDraftPath = "/remote.php/dav/files/admin/\(draftPath)" + let requests = session.requests + + #expect(summary(of: requests) == [ + "POST /ocs/v2.php/apps/spreed/api/v1/chat/abcd1234/attachment/folder", + "PROPFIND \(webDAVDraftPath)", + "PUT \(webDAVDraftPath)", + "POST /ocs/v2.php/apps/spreed/api/v1/chat/abcd1234/attachment", + ]) + + // Asking for the draft folder needs no parameters, so no body is sent with it. + let folderRequest = try #require(requests.first) + #expect(folderRequest.httpBody == nil) + #expect(folderRequest.value(forHTTPHeaderField: "OCS-APIRequest") == "true") + + // The draft is uploaded from the source itself, which is left in place. + let upload = try #require(requests.dropFirst(2).first) + #expect(upload.httpBody == bytes) + #expect(session.uploadSources == [source.file]) + #expect(FileManager.default.fileExists(atPath: source.file.path)) + + let attach = try #require(requests.last) + let body = try jsonBody(of: attach) + + #expect(attach.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(body["filePath"] as? String == draftPath) + #expect(body["fileName"] as? String == "Report 1.pdf") + #expect((body["referenceId"] as? String)?.isEmpty == false) + + // The Talk app expects the metadata as a JSON document within a string. + let talkMetaData = try #require(body["talkMetaData"] as? String) + let metaData = try #require(try JSONSerialization.jsonObject(with: Data(talkMetaData.utf8)) as? [String: Any]) + + #expect(Set(metaData.keys) == ["caption"]) + #expect(metaData["caption"] as? String == "Hello") + } + + @Test("Sending A File Without Caption Sends No Metadata") + func sendFileWithoutCaption() async throws { + let session = makeSendFileSession() + let source = try makeSourceFile(named: "Report 1.pdf") + + defer { + try? FileManager.default.removeItem(at: source.folder) + } + + try await makeServer(session: session).sendFile(source.file, toConversation: token) + + let body = try jsonBody(of: #require(session.requests.last)) + + #expect(Set(body.keys) == ["filePath", "fileName", "referenceId"]) + } + + @Test("Sending A File Without Extension Names Its Draft Without One") + func sendFileWithoutExtension() async throws { + let session = makeSendFileSession() + let source = try makeSourceFile(named: "README") + + defer { + try? FileManager.default.removeItem(at: source.folder) + } + + try await makeServer(session: session).sendFile(source.file, toConversation: token) + + let draftPath = draftPath(for: "README", pathExtension: "") + let upload = try #require(session.requests.first { $0.httpMethod == "PUT" }) + let body = try jsonBody(of: #require(session.requests.last)) + + #expect(draftPath.split(separator: "/").last?.contains(".") == false) + #expect(try path(of: upload) == "/remote.php/dav/files/admin/\(draftPath)") + #expect(body["filePath"] as? String == draftPath) + #expect(body["fileName"] as? String == "README") + } + + @Test("Sending A Missing File Sends Nothing") + func sendMissingFile() async throws { + let session = makeSendFileSession() + let missing = FileManager.default.temporaryDirectory.appendingPathComponent("ConversationWritingRequestTests-\(UUID().uuidString).pdf") + + await #expect(throws: RainmakerError.notFound) { + try await makeServer(session: session).sendFile(missing, toConversation: token) + } + + #expect(session.requests.isEmpty) + } + + @Test("Sending A Directory Sends Nothing") + func sendDirectory() async throws { + let session = makeSendFileSession() + let source = try makeSourceFile(named: "Report 1.pdf") + + defer { + try? FileManager.default.removeItem(at: source.folder) + } + + await #expect(throws: RainmakerError.notFound) { + try await makeServer(session: session).sendFile(source.folder, toConversation: token) + } + + #expect(session.requests.isEmpty) + } + + @Test("Sending A File Maps The Statuses Of The Draft Folder", arguments: zip([404, 403, 501, 507, 500], [RainmakerError.notFound, .readOnly, .unexpectedStatus(code: 501), .insufficientStorage, .unexpectedStatus(code: 500)])) + func sendFileFolderFailure(statusCode: Int, expected: RainmakerError) async throws { + // A `404` is also what a release older than Talk 24 answers, and a `501` what one answers whose folders per conversation are turned off. + let session = makeSendFileSession(folderStatus: statusCode, folderBody: #"{"ocs":{"meta":{"status":"failure","statuscode":0,"message":""},"data":{"error":"Conversation subfolders are disabled"}}}"#) + let source = try makeSourceFile(named: "Report 1.pdf") + + defer { + try? FileManager.default.removeItem(at: source.folder) + } + + await #expect(throws: expected) { + try await makeServer(session: session).sendFile(source.file, toConversation: token) + } + + // Nothing is uploaded without a draft folder to upload into. + #expect(summary(of: session.requests) == ["POST /ocs/v2.php/apps/spreed/api/v1/chat/abcd1234/attachment/folder"]) + } + + @Test("Sending A File Reports An Exhausted Quota During The Upload As Such") + func sendFileUploadOverQuota() async throws { + let session = makeSendFileSession(uploadStatus: 507) + let source = try makeSourceFile(named: "Report 1.pdf") + + defer { + try? FileManager.default.removeItem(at: source.folder) + } + + // The same condition surfaces as the same error whether the draft folder or the upload into it hit the quota. + await #expect(throws: RainmakerError.insufficientStorage) { + try await makeServer(session: session).sendFile(source.file, toConversation: token) + } + + // Nothing is posted without an uploaded draft. + #expect(summary(of: session.requests).last?.hasPrefix("PUT ") == true) + } + + @Test("Sending A File Without A Draft Folder Fails To Decode") + func sendFileWithoutFolder() async throws { + let session = makeSendFileSession(folderBody: #"{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"renames":[]}}}"#) + let source = try makeSourceFile(named: "Report 1.pdf") + + defer { + try? FileManager.default.removeItem(at: source.folder) + } + + await #expect { + try await makeServer(session: session).sendFile(source.file, toConversation: token) + } throws: { error in + guard case RainmakerError.responseDecodingFailed = error else { + return false + } + + return true + } + + #expect(session.requests.count == 1) + } + + @Test("Sending A File Deletes Its Draft When Posting Fails", arguments: zip([403, 500], [RainmakerError.readOnly, .unexpectedStatus(code: 500)])) + func sendFileAttachFailure(statusCode: Int, expected: RainmakerError) async throws { + let session = makeSendFileSession(attachStatus: statusCode) + let source = try makeSourceFile(named: "Report 1.pdf") + + defer { + try? FileManager.default.removeItem(at: source.folder) + } + + await #expect(throws: expected) { + try await makeServer(session: session).sendFile(source.file, toConversation: token, caption: "Hello") + } + + let webDAVDraftPath = "/remote.php/dav/files/admin/\(draftPath(for: "Report 1.pdf", pathExtension: "pdf"))" + + // The draft which was uploaded but not posted is removed again, after the posting failed. + #expect(Array(summary(of: session.requests).suffix(2)) == [ + "POST /ocs/v2.php/apps/spreed/api/v1/chat/abcd1234/attachment", + "DELETE \(webDAVDraftPath)", + ]) + } + + @Test("Sending A File Reports The Failure Of Posting Over That Of Deleting The Draft") + func sendFileAttachAndDeleteFailure() async throws { + let session = makeSendFileSession(attachStatus: 403, deleteStatus: 500) + let source = try makeSourceFile(named: "Report 1.pdf") + + defer { + try? FileManager.default.removeItem(at: source.folder) + } + + await #expect(throws: RainmakerError.readOnly) { + try await makeServer(session: session).sendFile(source.file, toConversation: token) + } + + #expect(session.requests.last?.httpMethod == "DELETE") + } +} diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/w2tqyhnp/participants/self/Body.json b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/w2tqyhnp/participants/self/Body.json new file mode 100644 index 0000000..88c2bcc --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/w2tqyhnp/participants/self/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":null}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/w2tqyhnp/participants/self/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/w2tqyhnp/participants/self/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/w2tqyhnp/participants/self/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json new file mode 100644 index 0000000..317b351 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":201,"message":"OK"},"data":{"id":5,"token":"w2tqyhnp","type":1,"name":"alice","displayName":"Alice","objectType":"","objectId":"","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":70,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":false,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":5,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":70,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":70,"token":"w2tqyhnp","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Sie haben die Unterhaltung erstellt","messageParameters":{"actor":{"type":"user","id":"admin","name":"admin","mention-id":"admin"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt new file mode 100644 index 0000000..4b62d77 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 201 Created +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json new file mode 100644 index 0000000..c86d2a1 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[{"id":"alice","label":"Alice","icon":"icon-user","source":"users","status":"","subline":"","shareWithDisplayNameUnique":"alice"}]}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json new file mode 100644 index 0000000..545d0c8 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[{"id":1,"token":"h7sndjf8","type":4,"name":"admin","displayName":"Talk Aktualisierungen \u2705","objectType":"","objectId":"","participantType":3,"participantFlags":0,"readOnly":1,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":1,"unreadMessages":56,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":false,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":1,"permissions":246,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":1,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":57,"token":"h7sndjf8","actorType":"bots","actorId":"changelog","actorDisplayName":"Talk Aktualisierungen \u2705","timestamp": 1700000000,"message":"## Neu in Talk 21.1\n- Besprechungsunterhaltungen werden jetzt mit dem Titel und der Beschreibung aus dem Kalender synchronisiert und mit einem Suchfilter ausgeblendet, bis sie kurz vor dem Beginn stehen\n- Unterhaltungen in den Benachrichtigungseinstellungen als sensibel markieren, um den Inhalt der Nachricht aus der Unterhaltungsliste und den Benachrichtigungen auszublenden\n- Um Push-Benachrichtigungen w\u00e4hrend \"Bitte nicht st\u00f6ren\" zu erhalten, markieren Sie Unterhaltungen als wichtig\n- F\u00fcgen Sie andere Teilnehmer zu einem Einzelgespr\u00e4ch hinzu, um im Handumdrehen ein neues Gruppengespr\u00e4ch zu erstellen.","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true}},{"id":2,"token":"p9kc5np2","type":6,"name":"Notiz an mich","displayName":"Notiz an mich","objectType":"note_to_self","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":false,"lastActivity": 1700000000,"lastReadMessage":58,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":true,"canLeaveConversation":false,"canDeleteConversation":true,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":2,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"Ein Platz f\u00fcr Ihre privaten Notizen, Gedanken und Ideen","lastCommonReadMessage":58,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":58,"token":"p9kc5np2","actorType":"guests","actorId":"system","actorDisplayName":"","timestamp": 1700000000,"message":"Das System hat die Unterhaltung erstellt","messageParameters":{"actor":{"type":"guest","id":"guest\/system","name":"System","mention-id":"guest\/system"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false}},{"id":3,"token":"4wq342xi","type":2,"name":"Los geht's!","displayName":"Los geht's!","objectType":"sample","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":59,"unreadMessages":7,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":3,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"**Nextcloud Talk** ist eine sichere, selbst gehostete Kommunikationsplattform, die sich nahtlos in das Nextcloud-\u00d6kosystem integriert.\n\n#### Hauptmerkmale von Nextcloud Talk:\n\n* Chat und Messaging in privaten und Gruppenchats\n* Sprach- und Videoanrufe\n* Dateifreigabe und Integration mit anderen Nextcloud-Apps\n* Anpassbare Gespr\u00e4chseinstellungen, Moderation und Datenschutzkontrollen\n* Web, Desktop und Mobile (iOS und Android)\n* Private & sichere Kommunikation\n\n Erfahren Sie mehr in der [Benutzeranleitung](https:\/\/docs.nextcloud.com\/server\/latest\/user_manual\/en\/talk\/index.html).","lastCommonReadMessage":59,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":68,"token":"4wq342xi","actorType":"bots","actorId":"sample","actorDisplayName":"Nextcloud","timestamp": 1700000000,"message":"## \u2699\ufe0f Gespr\u00e4chseinstellungen verwalten\n\nIm Men\u00fc \"Unterhaltung\" k\u00f6nnen Sie auf verschiedene Einstellungen zur Verwaltung Ihrer Unterhaltungen zugreifen, wie z. B.:\n* Unterhaltungsinformationen bearbeiten\n* Verwalten von Benachrichtigungen\n* Zahlreiche Moderationsregeln anwenden\n* Zugang und Sicherheit konfigurieren\n* Aktivieren von Bots\n* und mehr!","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true}},{"id":4,"token":"7raa4vr8","type":2,"name":"Rainmaker","displayName":"Rainmaker","objectType":"","objectId":"","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":69,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":4,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":69,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":69,"token":"7raa4vr8","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Sie haben die Unterhaltung erstellt","messageParameters":{"actor":{"type":"user","id":"admin","name":"admin","mention-id":"admin"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false}}]}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..a751c02 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":31,"minor":0,"micro":14,"string":"31.0.14","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true},"bruteforce":{"delay":0,"allow-listed":false},"files":{"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"comments":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"5.0.2","text_processing":{"task_types":["free_prompt","headline","summary","topics"]}},"circles":{"version":"31.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}]},"dav":{"chunking":"1.0","bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"5.0.2","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.21.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","reactions"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"]},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0}},"config-local":{"attachments":["allowed","folder"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold"],"conversations":["can-create","list-style","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","hello-v2-token-key"],"experiments":["enabled"]},"version":"21.1.11"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jenna-kim-the-globe.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/attachment/folder/Body.json b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/attachment/folder/Body.json new file mode 100644 index 0000000..f1710e4 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/attachment/folder/Body.json @@ -0,0 +1,10 @@ + + + + failure + 998 + Invalid query, please check the syntax. API specifications are here: http://www.freedesktop.org/wiki/Specifications/open-collaboration-services. + + + + diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/attachment/folder/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/attachment/folder/Headers.txt new file mode 100644 index 0000000..7877873 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/attachment/folder/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 404 Not Found +Content-Type: text/xml; charset=UTF-8 diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json new file mode 100644 index 0000000..545d0c8 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[{"id":1,"token":"h7sndjf8","type":4,"name":"admin","displayName":"Talk Aktualisierungen \u2705","objectType":"","objectId":"","participantType":3,"participantFlags":0,"readOnly":1,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":1,"unreadMessages":56,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":false,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":1,"permissions":246,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":1,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":57,"token":"h7sndjf8","actorType":"bots","actorId":"changelog","actorDisplayName":"Talk Aktualisierungen \u2705","timestamp": 1700000000,"message":"## Neu in Talk 21.1\n- Besprechungsunterhaltungen werden jetzt mit dem Titel und der Beschreibung aus dem Kalender synchronisiert und mit einem Suchfilter ausgeblendet, bis sie kurz vor dem Beginn stehen\n- Unterhaltungen in den Benachrichtigungseinstellungen als sensibel markieren, um den Inhalt der Nachricht aus der Unterhaltungsliste und den Benachrichtigungen auszublenden\n- Um Push-Benachrichtigungen w\u00e4hrend \"Bitte nicht st\u00f6ren\" zu erhalten, markieren Sie Unterhaltungen als wichtig\n- F\u00fcgen Sie andere Teilnehmer zu einem Einzelgespr\u00e4ch hinzu, um im Handumdrehen ein neues Gruppengespr\u00e4ch zu erstellen.","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true}},{"id":2,"token":"p9kc5np2","type":6,"name":"Notiz an mich","displayName":"Notiz an mich","objectType":"note_to_self","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":false,"lastActivity": 1700000000,"lastReadMessage":58,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":true,"canLeaveConversation":false,"canDeleteConversation":true,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":2,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"Ein Platz f\u00fcr Ihre privaten Notizen, Gedanken und Ideen","lastCommonReadMessage":58,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":58,"token":"p9kc5np2","actorType":"guests","actorId":"system","actorDisplayName":"","timestamp": 1700000000,"message":"Das System hat die Unterhaltung erstellt","messageParameters":{"actor":{"type":"guest","id":"guest\/system","name":"System","mention-id":"guest\/system"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false}},{"id":3,"token":"4wq342xi","type":2,"name":"Los geht's!","displayName":"Los geht's!","objectType":"sample","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":59,"unreadMessages":7,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":3,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"**Nextcloud Talk** ist eine sichere, selbst gehostete Kommunikationsplattform, die sich nahtlos in das Nextcloud-\u00d6kosystem integriert.\n\n#### Hauptmerkmale von Nextcloud Talk:\n\n* Chat und Messaging in privaten und Gruppenchats\n* Sprach- und Videoanrufe\n* Dateifreigabe und Integration mit anderen Nextcloud-Apps\n* Anpassbare Gespr\u00e4chseinstellungen, Moderation und Datenschutzkontrollen\n* Web, Desktop und Mobile (iOS und Android)\n* Private & sichere Kommunikation\n\n Erfahren Sie mehr in der [Benutzeranleitung](https:\/\/docs.nextcloud.com\/server\/latest\/user_manual\/en\/talk\/index.html).","lastCommonReadMessage":59,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":68,"token":"4wq342xi","actorType":"bots","actorId":"sample","actorDisplayName":"Nextcloud","timestamp": 1700000000,"message":"## \u2699\ufe0f Gespr\u00e4chseinstellungen verwalten\n\nIm Men\u00fc \"Unterhaltung\" k\u00f6nnen Sie auf verschiedene Einstellungen zur Verwaltung Ihrer Unterhaltungen zugreifen, wie z. B.:\n* Unterhaltungsinformationen bearbeiten\n* Verwalten von Benachrichtigungen\n* Zahlreiche Moderationsregeln anwenden\n* Zugang und Sicherheit konfigurieren\n* Aktivieren von Bots\n* und mehr!","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true}},{"id":4,"token":"7raa4vr8","type":2,"name":"Rainmaker","displayName":"Rainmaker","objectType":"","objectId":"","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":69,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":4,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":69,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":69,"token":"7raa4vr8","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Sie haben die Unterhaltung erstellt","messageParameters":{"actor":{"type":"user","id":"admin","name":"admin","mention-id":"admin"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false}}]}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/Body.json b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/Body.json new file mode 100644 index 0000000..22078b4 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":201,"message":"OK"},"data":{"id":71,"token":"7raa4vr8","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Hello from Rainmaker","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/Headers.txt new file mode 100644 index 0000000..4b62d77 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/7raa4vr8/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 201 Created +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/svq33xdq/participants/self/Body.json b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/svq33xdq/participants/self/Body.json new file mode 100644 index 0000000..88c2bcc --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/svq33xdq/participants/self/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":null}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/svq33xdq/participants/self/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/svq33xdq/participants/self/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/svq33xdq/participants/self/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json new file mode 100644 index 0000000..d17d006 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":201,"message":"OK"},"data":{"id":5,"token":"svq33xdq","type":1,"name":"alice","displayName":"Alice","objectType":"","objectId":"","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":71,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":false,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":5,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":71,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":71,"token":"svq33xdq","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Sie haben die Unterhaltung erstellt","messageParameters":{"actor":{"type":"user","id":"admin","name":"admin","mention-id":"admin"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false,"threadId":71}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt new file mode 100644 index 0000000..4b62d77 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 201 Created +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json new file mode 100644 index 0000000..c86d2a1 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[{"id":"alice","label":"Alice","icon":"icon-user","source":"users","status":"","subline":"","shareWithDisplayNameUnique":"alice"}]}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json new file mode 100644 index 0000000..f0b778b --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[{"id":1,"token":"awgttcsn","type":4,"name":"admin","displayName":"Talk Aktualisierungen \u2705","objectType":"","objectId":"","participantType":3,"participantFlags":0,"readOnly":1,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":1,"unreadMessages":57,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":false,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":1,"permissions":246,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":1,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":58,"token":"awgttcsn","actorType":"bots","actorId":"changelog","actorDisplayName":"Talk Aktualisierungen \u2705","timestamp": 1700000000,"message":"## Neu in Talk 22\n- Themen verwenden, um Ihre Chats und Diskussionen zu organisieren\n- Live-Transkriptionen sind jetzt w\u00e4hrend des Anrufs verf\u00fcgbar (erfordert die Live-Transkriptions-ExApp und das Hochleistungs-Backend)","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":58}},{"id":2,"token":"eqdjdkc4","type":6,"name":"Notiz an mich","displayName":"Notiz an mich","objectType":"note_to_self","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":false,"lastActivity": 1700000000,"lastReadMessage":59,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":true,"canLeaveConversation":false,"canDeleteConversation":true,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":2,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"Ein Platz f\u00fcr Ihre privaten Notizen, Gedanken und Ideen","lastCommonReadMessage":59,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":59,"token":"eqdjdkc4","actorType":"guests","actorId":"system","actorDisplayName":"","timestamp": 1700000000,"message":"Das System hat die Unterhaltung erstellt","messageParameters":{"actor":{"type":"guest","id":"guest\/system","name":"System","mention-id":"guest\/system"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false,"threadId":59}},{"id":3,"token":"o4mjsde9","type":2,"name":"Los geht's!","displayName":"Los geht's!","objectType":"sample","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":60,"unreadMessages":7,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":3,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"**Nextcloud Talk** ist eine sichere, selbst gehostete Kommunikationsplattform, die sich nahtlos in das Nextcloud-\u00d6kosystem integriert.\n\n#### Hauptmerkmale von Nextcloud Talk:\n\n* Chat und Messaging in privaten und Gruppenchats\n* Sprach- und Videoanrufe\n* Dateifreigabe und Integration mit anderen Nextcloud-Apps\n* Anpassbare Gespr\u00e4chseinstellungen, Moderation und Datenschutzkontrollen\n* Web, Desktop und Mobile (iOS und Android)\n* Private & sichere Kommunikation\n\n Erfahren Sie mehr in der [Benutzeranleitung](https:\/\/docs.nextcloud.com\/server\/latest\/user_manual\/en\/talk\/index.html).","lastCommonReadMessage":60,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":69,"token":"o4mjsde9","actorType":"bots","actorId":"sample","actorDisplayName":"Nextcloud","timestamp": 1700000000,"message":"## \u2699\ufe0f Gespr\u00e4chseinstellungen verwalten\n\nIm Men\u00fc \"Unterhaltung\" kann auf verschiedene Einstellungen zur Verwaltung der Unterhaltungen zugegriffen werden, wie z. B.:\n* Unterhaltungsinformationen bearbeiten\n* Verwalten von Benachrichtigungen\n* Zahlreiche Moderationsregeln anwenden\n* Zugang und Sicherheit konfigurieren\n* Aktivieren von Bots\n* und mehr!","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":69}},{"id":4,"token":"fevjqik2","type":2,"name":"Rainmaker","displayName":"Rainmaker","objectType":"","objectId":"","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":70,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":4,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":70,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":70,"token":"fevjqik2","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Sie haben die Unterhaltung erstellt","messageParameters":{"actor":{"type":"user","id":"admin","name":"admin","mention-id":"admin"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false,"threadId":70}}]}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..ea2c94b --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":32,"minor":0,"micro":11,"string":"32.0.11","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"}},"bruteforce":{"delay":0,"allow-listed":false},"files":{"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"comments":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"32.0.0"},"circles":{"version":"32.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 32.0.11.1","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["\/invite-accepted","\/notifications","\/shares"]},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"5.0.2","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.22.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","federated-shared-items","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","recording-chunked-upload"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"play-sounds":false,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"]},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"permissions":{"max-default":254,"max-custom":255,"default":246}},"config-local":{"attachments":["allowed","folder"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","matterbridge-enabled"],"conversations":["can-create","list-style","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","hello-v2-token-key","mode"],"experiments":["enabled"],"permissions":[]},"version":"22.0.19"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/attachment/folder/Body.json b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/attachment/folder/Body.json new file mode 100644 index 0000000..4ad2b6f --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/attachment/folder/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"failure","statuscode":998,"message":"Invalid query, please check the syntax. API specifications are here: http:\/\/www.freedesktop.org\/wiki\/Specifications\/open-collaboration-services.\n"},"data":[]}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/attachment/folder/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/attachment/folder/Headers.txt new file mode 100644 index 0000000..ee5eadf --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/attachment/folder/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json new file mode 100644 index 0000000..f0b778b --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[{"id":1,"token":"awgttcsn","type":4,"name":"admin","displayName":"Talk Aktualisierungen \u2705","objectType":"","objectId":"","participantType":3,"participantFlags":0,"readOnly":1,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":1,"unreadMessages":57,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":false,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":1,"permissions":246,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":1,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":58,"token":"awgttcsn","actorType":"bots","actorId":"changelog","actorDisplayName":"Talk Aktualisierungen \u2705","timestamp": 1700000000,"message":"## Neu in Talk 22\n- Themen verwenden, um Ihre Chats und Diskussionen zu organisieren\n- Live-Transkriptionen sind jetzt w\u00e4hrend des Anrufs verf\u00fcgbar (erfordert die Live-Transkriptions-ExApp und das Hochleistungs-Backend)","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":58}},{"id":2,"token":"eqdjdkc4","type":6,"name":"Notiz an mich","displayName":"Notiz an mich","objectType":"note_to_self","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":false,"lastActivity": 1700000000,"lastReadMessage":59,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":true,"canLeaveConversation":false,"canDeleteConversation":true,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":2,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"Ein Platz f\u00fcr Ihre privaten Notizen, Gedanken und Ideen","lastCommonReadMessage":59,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":59,"token":"eqdjdkc4","actorType":"guests","actorId":"system","actorDisplayName":"","timestamp": 1700000000,"message":"Das System hat die Unterhaltung erstellt","messageParameters":{"actor":{"type":"guest","id":"guest\/system","name":"System","mention-id":"guest\/system"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false,"threadId":59}},{"id":3,"token":"o4mjsde9","type":2,"name":"Los geht's!","displayName":"Los geht's!","objectType":"sample","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":60,"unreadMessages":7,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":3,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"**Nextcloud Talk** ist eine sichere, selbst gehostete Kommunikationsplattform, die sich nahtlos in das Nextcloud-\u00d6kosystem integriert.\n\n#### Hauptmerkmale von Nextcloud Talk:\n\n* Chat und Messaging in privaten und Gruppenchats\n* Sprach- und Videoanrufe\n* Dateifreigabe und Integration mit anderen Nextcloud-Apps\n* Anpassbare Gespr\u00e4chseinstellungen, Moderation und Datenschutzkontrollen\n* Web, Desktop und Mobile (iOS und Android)\n* Private & sichere Kommunikation\n\n Erfahren Sie mehr in der [Benutzeranleitung](https:\/\/docs.nextcloud.com\/server\/latest\/user_manual\/en\/talk\/index.html).","lastCommonReadMessage":60,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":69,"token":"o4mjsde9","actorType":"bots","actorId":"sample","actorDisplayName":"Nextcloud","timestamp": 1700000000,"message":"## \u2699\ufe0f Gespr\u00e4chseinstellungen verwalten\n\nIm Men\u00fc \"Unterhaltung\" kann auf verschiedene Einstellungen zur Verwaltung der Unterhaltungen zugegriffen werden, wie z. B.:\n* Unterhaltungsinformationen bearbeiten\n* Verwalten von Benachrichtigungen\n* Zahlreiche Moderationsregeln anwenden\n* Zugang und Sicherheit konfigurieren\n* Aktivieren von Bots\n* und mehr!","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":69}},{"id":4,"token":"fevjqik2","type":2,"name":"Rainmaker","displayName":"Rainmaker","objectType":"","objectId":"","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":70,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":2,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":4,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":70,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","isArchived":false,"isImportant":false,"isSensitive":false,"lastMessage":{"id":70,"token":"fevjqik2","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Sie haben die Unterhaltung erstellt","messageParameters":{"actor":{"type":"user","id":"admin","name":"admin","mention-id":"admin"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false,"threadId":70}}]}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/Body.json b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/Body.json new file mode 100644 index 0000000..e2dd9b9 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":201,"message":"OK"},"data":{"id":72,"token":"fevjqik2","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Hello from Rainmaker","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":72}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/Headers.txt new file mode 100644 index 0000000..4b62d77 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/fevjqik2/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 201 Created +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/pkkdxo52/participants/self/Body.json b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/pkkdxo52/participants/self/Body.json new file mode 100644 index 0000000..88c2bcc --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/pkkdxo52/participants/self/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":null}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/pkkdxo52/participants/self/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/pkkdxo52/participants/self/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/pkkdxo52/participants/self/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json new file mode 100644 index 0000000..1ea1938 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":201,"message":"OK"},"data":{"id":5,"token":"pkkdxo52","type":1,"name":"alice","displayName":"Alice","objectType":"","objectId":"","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":17,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":false,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":5,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":17,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"hasScheduledMessages":0,"lastMessage":{"id":17,"token":"pkkdxo52","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Sie haben die Unterhaltung erstellt","messageParameters":{"actor":{"type":"user","id":"admin","name":"admin","mention-id":"admin"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false,"threadId":17}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt new file mode 100644 index 0000000..4b62d77 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 201 Created +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json new file mode 100644 index 0000000..c86d2a1 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[{"id":"alice","label":"Alice","icon":"icon-user","source":"users","status":"","subline":"","shareWithDisplayNameUnique":"alice"}]}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json new file mode 100644 index 0000000..a44d127 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[{"id":1,"token":"u6szsbpw","type":4,"name":"admin","displayName":"Talk Aktualisierungen \u2705","objectType":"","objectId":"","participantType":3,"participantFlags":0,"readOnly":1,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":1,"unreadMessages":3,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":false,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":1,"permissions":246,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":1,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"hasScheduledMessages":0,"lastMessage":{"id":4,"token":"u6szsbpw","actorType":"bots","actorId":"changelog","actorDisplayName":"Talk Aktualisierungen \u2705","timestamp": 1700000000,"message":"## Neu in Talk 23\n- Die Moderation kann jetzt Nachrichten anheften, sodass diese oben bleiben\n- Den Versand einer Nachricht zu einem sp\u00e4teren Zeitpunkt planen","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":4}},{"id":2,"token":"9wozmntz","type":6,"name":"Notiz an mich","displayName":"Notiz an mich","objectType":"note_to_self","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":false,"lastActivity": 1700000000,"lastReadMessage":5,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":true,"canLeaveConversation":false,"canDeleteConversation":true,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":2,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"Ein Platz f\u00fcr Ihre privaten Notizen, Gedanken und Ideen","lastCommonReadMessage":5,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"hasScheduledMessages":0,"lastMessage":{"id":5,"token":"9wozmntz","actorType":"guests","actorId":"system","actorDisplayName":"","timestamp": 1700000000,"message":"Das System hat die Unterhaltung erstellt","messageParameters":{"actor":{"type":"guest","id":"guest\/system","name":"System","mention-id":"guest\/system"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false,"threadId":5}},{"id":3,"token":"yatb6bbf","type":2,"name":"Los geht's!","displayName":"Los geht's!","objectType":"sample","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":6,"unreadMessages":7,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":3,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"**Nextcloud Talk** ist eine sichere, selbst gehostete Kommunikationsplattform, die sich nahtlos in das Nextcloud-\u00d6kosystem integriert.\n\n#### Hauptmerkmale von Nextcloud Talk:\n\n* Chat und Messaging in privaten und Gruppenchats\n* Sprach- und Videoanrufe\n* Dateifreigabe und Integration mit anderen Nextcloud-Apps\n* Anpassbare Gespr\u00e4chseinstellungen, Moderation und Datenschutzkontrollen\n* Web, Desktop und Mobile (iOS und Android)\n* Private & sichere Kommunikation\n\n Erfahren Sie mehr in der [Benutzeranleitung](https:\/\/docs.nextcloud.com\/server\/latest\/user_manual\/en\/talk\/index.html).","lastCommonReadMessage":6,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"hasScheduledMessages":0,"lastMessage":{"id":15,"token":"yatb6bbf","actorType":"bots","actorId":"sample","actorDisplayName":"Nextcloud","timestamp": 1700000000,"message":"## \u2699\ufe0f Gespr\u00e4chseinstellungen verwalten\n\nIm Men\u00fc \"Unterhaltung\" kann auf verschiedene Einstellungen zur Verwaltung der Unterhaltungen zugegriffen werden, wie z. B.:\n* Unterhaltungsinformationen bearbeiten\n* Verwalten von Benachrichtigungen\n* Zahlreiche Moderationsregeln anwenden\n* Zugang und Sicherheit konfigurieren\n* Aktivieren von Bots\n* und mehr!","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":15}},{"id":4,"token":"tmfkmc2b","type":2,"name":"Rainmaker","displayName":"Rainmaker","objectType":"","objectId":"","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":16,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":4,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":16,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"hasScheduledMessages":0,"lastMessage":{"id":16,"token":"tmfkmc2b","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Sie haben die Unterhaltung erstellt","messageParameters":{"actor":{"type":"user","id":"admin","name":"admin","mention-id":"admin"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false,"threadId":16}}]}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..9b218c9 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":33,"minor":0,"micro":5,"string":"33.0.5","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"},"can-create-app-token":true},"bruteforce":{"delay":0,"allow-listed":false},"files":{"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"comments":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"33.0.0"},"circles":{"version":"33.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 33.0.5.1","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["invite-accepted","notifications","shares"]},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"exclude_reshare_from_edit":false,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"6.1.0","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.23.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","pinned-messages","federated-shared-items","scheduled-messages","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","scheduled-messages","recording-chunked-upload"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"live-translation":false,"play-sounds":true,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"],"live-transcription-target-language-id":""},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"style":"split","matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"permissions":{"max-default":254,"max-custom":255,"default":246}},"config-local":{"attachments":["allowed","folder"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","live-transcription-target-language-id","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","style","matterbridge-enabled"],"conversations":["can-create","list-style","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","hello-v2-token-key","mode"],"experiments":["enabled"],"permissions":[]},"version":"23.0.12"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/attachment/folder/Body.json b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/attachment/folder/Body.json new file mode 100644 index 0000000..4ad2b6f --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/attachment/folder/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"failure","statuscode":998,"message":"Invalid query, please check the syntax. API specifications are here: http:\/\/www.freedesktop.org\/wiki\/Specifications\/open-collaboration-services.\n"},"data":[]}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/attachment/folder/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/attachment/folder/Headers.txt new file mode 100644 index 0000000..ee5eadf --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/attachment/folder/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json new file mode 100644 index 0000000..a44d127 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[{"id":1,"token":"u6szsbpw","type":4,"name":"admin","displayName":"Talk Aktualisierungen \u2705","objectType":"","objectId":"","participantType":3,"participantFlags":0,"readOnly":1,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":1,"unreadMessages":3,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":false,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":1,"permissions":246,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":1,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"hasScheduledMessages":0,"lastMessage":{"id":4,"token":"u6szsbpw","actorType":"bots","actorId":"changelog","actorDisplayName":"Talk Aktualisierungen \u2705","timestamp": 1700000000,"message":"## Neu in Talk 23\n- Die Moderation kann jetzt Nachrichten anheften, sodass diese oben bleiben\n- Den Versand einer Nachricht zu einem sp\u00e4teren Zeitpunkt planen","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":4}},{"id":2,"token":"9wozmntz","type":6,"name":"Notiz an mich","displayName":"Notiz an mich","objectType":"note_to_self","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":false,"lastActivity": 1700000000,"lastReadMessage":5,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":true,"canLeaveConversation":false,"canDeleteConversation":true,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":2,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"Ein Platz f\u00fcr Ihre privaten Notizen, Gedanken und Ideen","lastCommonReadMessage":5,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"hasScheduledMessages":0,"lastMessage":{"id":5,"token":"9wozmntz","actorType":"guests","actorId":"system","actorDisplayName":"","timestamp": 1700000000,"message":"Das System hat die Unterhaltung erstellt","messageParameters":{"actor":{"type":"guest","id":"guest\/system","name":"System","mention-id":"guest\/system"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false,"threadId":5}},{"id":3,"token":"yatb6bbf","type":2,"name":"Los geht's!","displayName":"Los geht's!","objectType":"sample","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":6,"unreadMessages":7,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":3,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"**Nextcloud Talk** ist eine sichere, selbst gehostete Kommunikationsplattform, die sich nahtlos in das Nextcloud-\u00d6kosystem integriert.\n\n#### Hauptmerkmale von Nextcloud Talk:\n\n* Chat und Messaging in privaten und Gruppenchats\n* Sprach- und Videoanrufe\n* Dateifreigabe und Integration mit anderen Nextcloud-Apps\n* Anpassbare Gespr\u00e4chseinstellungen, Moderation und Datenschutzkontrollen\n* Web, Desktop und Mobile (iOS und Android)\n* Private & sichere Kommunikation\n\n Erfahren Sie mehr in der [Benutzeranleitung](https:\/\/docs.nextcloud.com\/server\/latest\/user_manual\/en\/talk\/index.html).","lastCommonReadMessage":6,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"hasScheduledMessages":0,"lastMessage":{"id":15,"token":"yatb6bbf","actorType":"bots","actorId":"sample","actorDisplayName":"Nextcloud","timestamp": 1700000000,"message":"## \u2699\ufe0f Gespr\u00e4chseinstellungen verwalten\n\nIm Men\u00fc \"Unterhaltung\" kann auf verschiedene Einstellungen zur Verwaltung der Unterhaltungen zugegriffen werden, wie z. B.:\n* Unterhaltungsinformationen bearbeiten\n* Verwalten von Benachrichtigungen\n* Zahlreiche Moderationsregeln anwenden\n* Zugang und Sicherheit konfigurieren\n* Aktivieren von Bots\n* und mehr!","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":15}},{"id":4,"token":"tmfkmc2b","type":2,"name":"Rainmaker","displayName":"Rainmaker","objectType":"","objectId":"","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":16,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":4,"permissions":254,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":16,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"hasScheduledMessages":0,"lastMessage":{"id":16,"token":"tmfkmc2b","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Sie haben die Unterhaltung erstellt","messageParameters":{"actor":{"type":"user","id":"admin","name":"admin","mention-id":"admin"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false,"threadId":16}}]}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/Body.json b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/Body.json new file mode 100644 index 0000000..a576f9e --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":201,"message":"OK"},"data":{"id":18,"token":"tmfkmc2b","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Hello from Rainmaker","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":18}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/Headers.txt new file mode 100644 index 0000000..4b62d77 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/tmfkmc2b/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 201 Created +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/3uz93rwy/participants/self/Body.json b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/3uz93rwy/participants/self/Body.json new file mode 100644 index 0000000..88c2bcc --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/3uz93rwy/participants/self/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":null}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/3uz93rwy/participants/self/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/3uz93rwy/participants/self/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/DELETE/ocs/v2.php/apps/spreed/api/v4/room/3uz93rwy/participants/self/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json new file mode 100644 index 0000000..567eb41 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":201,"message":"OK"},"data":{"id":5,"token":"3uz93rwy","type":1,"name":"alice","displayName":"Alice","objectType":"","objectId":"","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":17,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":false,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":5,"permissions":510,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":17,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"tagIds":[],"hasScheduledMessages":0,"attributes":0,"lastMessage":{"id":17,"token":"3uz93rwy","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Sie haben die Unterhaltung erstellt","messageParameters":{"actor":{"type":"user","id":"admin","name":"admin","mention-id":"admin"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false,"threadId":17}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt new file mode 100644 index 0000000..4b62d77 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/createConversation/POST/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 201 Created +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json new file mode 100644 index 0000000..c86d2a1 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[{"id":"alice","label":"Alice","icon":"icon-user","source":"users","status":"","subline":"","shareWithDisplayNameUnique":"alice"}]}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/searchUsers/GET/ocs/v2.php/core/autocomplete/get/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json new file mode 100644 index 0000000..fdd62c3 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[{"id":1,"token":"8imu3r5r","type":4,"name":"admin","displayName":"Talk Aktualisierungen \u2705","objectType":"","objectId":"","participantType":3,"participantFlags":0,"readOnly":1,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":1,"unreadMessages":3,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":false,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":1,"permissions":502,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":1,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"tagIds":[],"hasScheduledMessages":0,"attributes":0,"lastMessage":{"id":4,"token":"8imu3r5r","actorType":"bots","actorId":"changelog","actorDisplayName":"Talk Aktualisierungen \u2705","timestamp": 1700000000,"message":"## Neu in Talk 24\n- Unterhaltungs-Schlagworte, Gruppierung und Sortierung zum Organisieren der Unterhaltungsliste\n- Unterhaltungs-R\u00e4ume um sich spontan zu verbinden\n- Voreinstellungen f\u00fcr eine schnelle Unterhaltungseinrichtung w\u00e4hrend der Erstellung\n- Direkte Anrufe aus Profilmen\u00fcs f\u00fcr Anrufe innerhalb anderer Apps","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":4}},{"id":2,"token":"i7g7zdts","type":6,"name":"Notiz an mich","displayName":"Notiz an mich","objectType":"note_to_self","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":false,"lastActivity": 1700000000,"lastReadMessage":5,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":true,"canLeaveConversation":false,"canDeleteConversation":true,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":2,"permissions":510,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"Ein Platz f\u00fcr Ihre privaten Notizen, Gedanken und Ideen","lastCommonReadMessage":5,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"tagIds":[],"hasScheduledMessages":0,"attributes":0,"lastMessage":{"id":5,"token":"i7g7zdts","actorType":"guests","actorId":"system","actorDisplayName":"","timestamp": 1700000000,"message":"Das System hat die Unterhaltung erstellt","messageParameters":{"actor":{"type":"guest","id":"guest\/system","name":"System","mention-id":"guest\/system"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false,"threadId":5}},{"id":3,"token":"fv8ma8ot","type":2,"name":"Los geht's!","displayName":"Los geht's!","objectType":"sample","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":6,"unreadMessages":7,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":3,"permissions":510,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"**Nextcloud Talk** ist eine sichere, selbst gehostete Kommunikationsplattform, die sich nahtlos in das Nextcloud-\u00d6kosystem integriert.\n\n#### Hauptmerkmale von Nextcloud Talk:\n\n* Chat und Messaging in privaten und Gruppenchats\n* Sprach- und Videoanrufe\n* Dateifreigabe und Integration mit anderen Nextcloud-Apps\n* Anpassbare Gespr\u00e4chseinstellungen, Moderation und Datenschutzkontrollen\n* Web, Desktop und Mobile (iOS und Android)\n* Private & sichere Kommunikation\n\n Erfahren Sie mehr in der [Benutzeranleitung](https:\/\/docs.nextcloud.com\/server\/latest\/user_manual\/en\/talk\/index.html).","lastCommonReadMessage":6,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"tagIds":[],"hasScheduledMessages":0,"attributes":0,"lastMessage":{"id":15,"token":"fv8ma8ot","actorType":"bots","actorId":"sample","actorDisplayName":"Nextcloud","timestamp": 1700000000,"message":"## \u2699\ufe0f Gespr\u00e4chseinstellungen verwalten\n\nIm Men\u00fc \"Unterhaltung\" kann auf verschiedene Einstellungen zur Verwaltung der Unterhaltungen zugegriffen werden, wie z. B.:\n* Unterhaltungsinformationen bearbeiten\n* Verwalten von Benachrichtigungen\n* Zahlreiche Moderationsregeln anwenden\n* Zugang und Sicherheit konfigurieren\n* Aktivieren von Bots\n* und mehr!","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":15}},{"id":4,"token":"a54y8nsx","type":2,"name":"Rainmaker","displayName":"Rainmaker","objectType":"","objectId":"","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":16,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":4,"permissions":510,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":16,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"tagIds":[],"hasScheduledMessages":0,"attributes":0,"lastMessage":{"id":16,"token":"a54y8nsx","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Sie haben die Unterhaltung erstellt","messageParameters":{"actor":{"type":"user","id":"admin","name":"admin","mention-id":"admin"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false,"threadId":16}}]}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..e737ccf --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":34,"minor":0,"micro":0,"string":"34.0.0","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"},"can-create-app-token":true},"bruteforce":{"delay":0,"allow-listed":false},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"34.0.0"},"circles":{"version":"34.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 34.0.0.12","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["invite-accepted","notifications","shares"]},"files":{"comments":true,"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"locking":"1.0","api-feature-lock-type":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"exclude_reshare_from_edit":false,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"minSearchStringLength":0,"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"6.1.0","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.24.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","react-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","pinned-messages","federated-shared-items","scheduled-messages","conversation-presets","private-reply","conversation-tags","bot-features-api","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","scheduled-messages","conversation-presets","conversation-tags","recording-chunked-upload","bot-features-api"],"config":{"attachments":{"allowed":true,"conversation-subfolders":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"live-translation":false,"play-sounds":true,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"],"live-transcription-target-language-id":""},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"style":"split","matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","sort-order":"activity","group-mode":"none","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"feature-hints":{"current":34,"hidden":0},"permissions":{"max-default":510,"max-custom":511,"default":502}},"config-local":{"attachments":["allowed","folder","conversation-subfolders"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","live-transcription-target-language-id","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","style","matterbridge-enabled"],"conversations":["can-create","list-style","sort-order","group-mode","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","mode","hello-v2-token-key"],"experiments":["enabled"],"feature-hints":["current","hidden"],"permissions":[]},"version":"24.0.6"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","imprintUrl":"","privacyUrl":"","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","primaryColor":"#00679e","backgroundColor":"#00679e","defaultPrimaryColor":"#00679e","defaultBackgroundColor":"#00679e","inverted":false,"cacheBuster":"e629f55f","enabledThemes":["default"]},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/Body.json b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/Body.json new file mode 100644 index 0000000..83f35a4 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"renames":[{"Rainmaker.txt":"Rainmaker.txt"}]}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/folder/Body.json b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/folder/Body.json new file mode 100644 index 0000000..03c656a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/folder/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"folder":"Talk\/Rainmaker-a54y8nsx\/Draft","renames":[]}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/folder/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/folder/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/attachment/folder/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/PROPFIND/remote.php/dav/files/admin/Talk/Rainmaker-a54y8nsx/Draft/46fc6e52147fb69783fec2e4e7beab3b.txt/Body.xml b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/PROPFIND/remote.php/dav/files/admin/Talk/Rainmaker-a54y8nsx/Draft/46fc6e52147fb69783fec2e4e7beab3b.txt/Body.xml new file mode 100644 index 0000000..214d791 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/PROPFIND/remote.php/dav/files/admin/Talk/Rainmaker-a54y8nsx/Draft/46fc6e52147fb69783fec2e4e7beab3b.txt/Body.xml @@ -0,0 +1,5 @@ + + + Sabre\DAV\Exception\NotFound + File with name /Talk/Rainmaker-a54y8nsx/Draft/46fc6e52147fb69783fec2e4e7beab3b.txt could not be located + diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/PROPFIND/remote.php/dav/files/admin/Talk/Rainmaker-a54y8nsx/Draft/46fc6e52147fb69783fec2e4e7beab3b.txt/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/PROPFIND/remote.php/dav/files/admin/Talk/Rainmaker-a54y8nsx/Draft/46fc6e52147fb69783fec2e4e7beab3b.txt/Headers.txt new file mode 100644 index 0000000..900b919 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/PROPFIND/remote.php/dav/files/admin/Talk/Rainmaker-a54y8nsx/Draft/46fc6e52147fb69783fec2e4e7beab3b.txt/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 404 Not Found +Content-Type: application/xml; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/PUT/remote.php/dav/files/admin/Talk/Rainmaker-a54y8nsx/Draft/46fc6e52147fb69783fec2e4e7beab3b.txt/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/PUT/remote.php/dav/files/admin/Talk/Rainmaker-a54y8nsx/Draft/46fc6e52147fb69783fec2e4e7beab3b.txt/Headers.txt new file mode 100644 index 0000000..2bdb0de --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendFile/PUT/remote.php/dav/files/admin/Talk/Rainmaker-a54y8nsx/Draft/46fc6e52147fb69783fec2e4e7beab3b.txt/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 201 Created +Content-Type: text/html; charset=UTF-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json new file mode 100644 index 0000000..bc5434d --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[{"id":1,"token":"8imu3r5r","type":4,"name":"admin","displayName":"Talk Aktualisierungen \u2705","objectType":"","objectId":"","participantType":3,"participantFlags":0,"readOnly":1,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":1,"unreadMessages":3,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":false,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":1,"permissions":502,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":1,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"tagIds":[],"hasScheduledMessages":0,"attributes":0,"lastMessage":{"id":4,"token":"8imu3r5r","actorType":"bots","actorId":"changelog","actorDisplayName":"Talk Aktualisierungen \u2705","timestamp": 1700000000,"message":"## Neu in Talk 24\n- Unterhaltungs-Schlagworte, Gruppierung und Sortierung zum Organisieren der Unterhaltungsliste\n- Unterhaltungs-R\u00e4ume um sich spontan zu verbinden\n- Voreinstellungen f\u00fcr eine schnelle Unterhaltungseinrichtung w\u00e4hrend der Erstellung\n- Direkte Anrufe aus Profilmen\u00fcs f\u00fcr Anrufe innerhalb anderer Apps","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":4}},{"id":2,"token":"i7g7zdts","type":6,"name":"Notiz an mich","displayName":"Notiz an mich","objectType":"note_to_self","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":false,"lastActivity": 1700000000,"lastReadMessage":5,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":true,"canLeaveConversation":false,"canDeleteConversation":true,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":2,"permissions":510,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"Ein Platz f\u00fcr Ihre privaten Notizen, Gedanken und Ideen","lastCommonReadMessage":5,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"tagIds":[],"hasScheduledMessages":0,"attributes":0,"lastMessage":{"id":5,"token":"i7g7zdts","actorType":"guests","actorId":"system","actorDisplayName":"","timestamp": 1700000000,"message":"Das System hat die Unterhaltung erstellt","messageParameters":{"actor":{"type":"guest","id":"guest\/system","name":"System","mention-id":"guest\/system"}},"systemMessage":"conversation_created","messageType":"system","isReplyable":false,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":false,"threadId":5}},{"id":3,"token":"fv8ma8ot","type":2,"name":"Los geht's!","displayName":"Los geht's!","objectType":"sample","objectId":"admin","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":6,"unreadMessages":7,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":3,"permissions":510,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"**Nextcloud Talk** ist eine sichere, selbst gehostete Kommunikationsplattform, die sich nahtlos in das Nextcloud-\u00d6kosystem integriert.\n\n#### Hauptmerkmale von Nextcloud Talk:\n\n* Chat und Messaging in privaten und Gruppenchats\n* Sprach- und Videoanrufe\n* Dateifreigabe und Integration mit anderen Nextcloud-Apps\n* Anpassbare Gespr\u00e4chseinstellungen, Moderation und Datenschutzkontrollen\n* Web, Desktop und Mobile (iOS und Android)\n* Private & sichere Kommunikation\n\n Erfahren Sie mehr in der [Benutzeranleitung](https:\/\/docs.nextcloud.com\/server\/latest\/user_manual\/en\/talk\/index.html).","lastCommonReadMessage":6,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":true,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"tagIds":[],"hasScheduledMessages":0,"attributes":0,"lastMessage":{"id":15,"token":"fv8ma8ot","actorType":"bots","actorId":"sample","actorDisplayName":"Nextcloud","timestamp": 1700000000,"message":"## \u2699\ufe0f Gespr\u00e4chseinstellungen verwalten\n\nIm Men\u00fc \"Unterhaltung\" kann auf verschiedene Einstellungen zur Verwaltung der Unterhaltungen zugegriffen werden, wie z. B.:\n* Unterhaltungsinformationen bearbeiten\n* Verwalten von Benachrichtigungen\n* Zahlreiche Moderationsregeln anwenden\n* Zugang und Sicherheit konfigurieren\n* Aktivieren von Bots\n* und mehr!","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":15}},{"id":4,"token":"a54y8nsx","type":2,"name":"Rainmaker","displayName":"Rainmaker","objectType":"","objectId":"","participantType":1,"participantFlags":0,"readOnly":0,"hasPassword":false,"hasCall":false,"callStartTime":0,"callRecording":0,"canStartCall":true,"lastActivity": 1700000000,"lastReadMessage":18,"unreadMessages":0,"unreadMention":false,"unreadMentionDirect":false,"isFavorite":false,"canLeaveConversation":true,"canDeleteConversation":true,"notificationLevel":1,"notificationCalls":1,"lobbyState":0,"lobbyTimer":0,"lastPing":0,"sessionId":"0","sipEnabled":0,"actorType":"users","actorId":"admin","attendeeId":4,"permissions":510,"attendeePermissions":0,"callPermissions":0,"defaultPermissions":0,"canEnableSIP":false,"attendeePin":"","description":"","lastCommonReadMessage":18,"listable":0,"callFlag":0,"messageExpiration":0,"avatarVersion": "00000000","isCustomAvatar":false,"breakoutRoomMode":0,"breakoutRoomStatus":0,"recordingConsent":0,"mentionPermissions":0,"liveTranscriptionLanguageId":"","lastPinnedId":0,"hiddenPinnedId":0,"isArchived":false,"isImportant":false,"isSensitive":false,"tagIds":[],"hasScheduledMessages":0,"attributes":0,"lastMessage":{"id":18,"token":"a54y8nsx","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Rainmaker","messageParameters":{"actor":{"type":"user","id":"admin","name":"admin","mention-id":"admin"},"file":{"type":"file","id":"164","name":"Rainmaker.txt","size":"10","path":"Rainmaker.txt","link":"http:\/\/localhost\/f\/164","etag": "00000000000000000000000000000000","permissions":"27","mimetype":"text\/plain","preview-available":"yes","hide-download":"no"}},"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"92FF56E9-36A5-40E5-B3B9-0046320E239C","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":18}}]}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/GET/ocs/v2.php/apps/spreed/api/v4/room/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/Body.json b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/Body.json new file mode 100644 index 0000000..d5434cd --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":201,"message":"OK"},"data":{"id":19,"token":"a54y8nsx","actorType":"users","actorId":"admin","actorDisplayName":"admin","timestamp": 1700000000,"message":"Hello from Rainmaker","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","reactions":{},"expirationTimestamp":0,"markdown":true,"threadId":19}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/Headers.txt new file mode 100644 index 0000000..4b62d77 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/ConversationMutationTests/sendMessage/POST/ocs/v2.php/apps/spreed/api/v1/chat/a54y8nsx/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 201 Created +Content-Type: application/json; charset=utf-8 From 1c7049909077073bf16930507faaa37be849dbc7 Mon Sep 17 00:00:00 2001 From: Iva Horn Date: Mon, 2 Nov 2026 20:23:46 +0100 Subject: [PATCH 2/2] Dropped the notes guides and the copied CLI help from the documentation catalog The articles on synchronizing notes and on using them from App Intents went beyond what this library documents, so they are removed and no longer curated by the notes article. The article on the command line interface now only says that the CLI exists, how to invoke it and how to ask it for help, because a copy of its help output drifts from the commands. AGENTS.md no longer asks for regenerating that copy. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- .../CommandLineInterface.md | 280 +----------------- .../Documentation.docc/SynchronizingNotes.md | 177 ----------- .../UsingNotesFromAppIntents.md | 160 ---------- .../Documentation.docc/WorkingWithNotes.md | 6 - 5 files changed, 9 insertions(+), 616 deletions(-) delete mode 100644 Sources/Rainmaker/Documentation.docc/SynchronizingNotes.md delete mode 100644 Sources/Rainmaker/Documentation.docc/UsingNotesFromAppIntents.md diff --git a/AGENTS.md b/AGENTS.md index fe66ee2..1faecb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ The human readable introduction of this Swift Package is in [README.md](README.m - `Sources/RainmakerCLI/Commands/Notes/` contains the `notes` command group: `Notes.swift` declares the group with the helpers its subcommands share, and each subcommand lives in a file of its own as an extension of it (`Notes+List.swift`, the default subcommand which keeps `notes --changed-since` working and whose `--summaries` flag lists through the summary listings instead, `Notes+Get.swift`, `Notes+Create.swift`, `Notes+Update.swift` and `Notes+Delete.swift`). `Notes+Attachment.swift` declares the nested `notes attachment` group, whose `get`, `add` and `delete` subcommands live in `Notes+Attachment+Get.swift`, `Notes+Attachment+Add.swift` and `Notes+Attachment+Delete.swift`, with `get` streaming into a file through `Server.downloadAttachment(at:ofNote:to:force:)` when given `--output`, as `conversations avatar` writes its image. The settings of the notes app stay with the top-level `notes-settings` command in `Sources/RainmakerCLI/Commands/NotesSettings.swift`, which changes them when any setting is given and looks them up otherwise, and whose `--note-mode` option takes `NoteMode` values through `Sources/RainmakerCLI/NoteMode+ExpressibleByArgument.swift`, as `ActivitySort+ExpressibleByArgument.swift` does for the activities and `ServerEventTransport+ExpressibleByArgument.swift` for the `--transport` option of `watch`. The group shadows the library's `Notes` capability within the CLI module, and `Rainmaker.Notes` does not reach it either because the CLI's root command is called `Rainmaker`, so the CLI does not refer to that capability. - `Sources/RainmakerCLI/Commands/RecordFixtures.swift` and `Sources/RainmakerCLI/Fixtures/` implement the `record-fixtures` subcommand which automates the creation of test fixtures. It is macOS-only: it deploys ephemeral Nextcloud containers via the `NextcloudContainerManager` package, runs the test suite against them in recording mode to capture real responses into `Tests/RainmakerTests/Responses/`, and verifies the captures replay without a server. Containers are deployed with the apps a suite's fixtures depend on but which are not part of a Nextcloud installation, currently the notes app and the Talk app, which makes a recording run depend on the app store being reachable. All of its Docker-facing code is guarded with `#if os(macOS)` so the simulator builds compile the CLI without it. - `Sources/RainmakerTestServerTags/` is a small internal module holding `ServerVersion`, the single source of truth for the supported Nextcloud versions. Both the test target and the CLI's fixture recorder depend on it, so the version list is declared once. -- `Sources/Rainmaker/Documentation.docc/` is a DocC documentation catalog to provide additional documentation the one automatically derived from source code comments and symbol documentation in Swift source code. This is the place for documentation articles targeting developers which are using this library and package. `Documentation.md` is the front page and lists only `Server`, `RainmakerError` and one article per feature, which curates the `Server` methods and the types of that feature into topics of its own and explains them in its overview: `ManagingFiles.md` (files and trash bin), `Authenticating.md`, `Users.md` (current user, user search and avatars), `DiscoveringServerFeatures.md` (capabilities and apps navigation), `ObservingChanges.md`, `ActivityStream.md`, `UserNotifications.md`, `WorkingWithNotes.md`, `WorkingWithTalk.md`, `Collectives.md` and `CustomRequests.md` (request factories and transport abstractions). Every public symbol is curated in exactly one of those articles, because DocC dumps a symbol curated nowhere into a generated group, and the file names differ from the symbols `Notes`, `Talk`, `Activity` and `Notifications` so that a `` link never resolves to the capability of the same name. A topic group's description must stay a single paragraph, because a blank line makes DocC start a Discussion section in the middle of the topics, so longer prose belongs in an article's overview. `SynchronizingNotes.md` explains how a client keeps its own copy of the notes in step with the server and `UsingNotesFromAppIntents.md` how single actions such as those of Shortcuts use the notes features, both curated by `WorkingWithNotes.md`. `CommandLineInterface.md` holds verbatim help listings of the CLI, which are regenerated with `COLUMNS=1000 swift run rainmaker-cli --help` so that they carry no line wrapping, whenever a command or its help text changes. +- `Sources/Rainmaker/Documentation.docc/` is a DocC documentation catalog to provide additional documentation the one automatically derived from source code comments and symbol documentation in Swift source code. This is the place for documentation articles targeting developers which are using this library and package. `Documentation.md` is the front page and lists only `Server`, `RainmakerError` and one article per feature, which curates the `Server` methods and the types of that feature into topics of its own and explains them in its overview: `ManagingFiles.md` (files and trash bin), `Authenticating.md`, `Users.md` (current user, user search and avatars), `DiscoveringServerFeatures.md` (capabilities and apps navigation), `ObservingChanges.md`, `ActivityStream.md`, `UserNotifications.md`, `WorkingWithNotes.md`, `WorkingWithTalk.md`, `Collectives.md` and `CustomRequests.md` (request factories and transport abstractions). Every public symbol is curated in exactly one of those articles, because DocC dumps a symbol curated nowhere into a generated group, and the file names differ from the symbols `Notes`, `Talk`, `Activity` and `Notifications` so that a `` link never resolves to the capability of the same name. A topic group's description must stay a single paragraph, because a blank line makes DocC start a Discussion section in the middle of the topics, so longer prose belongs in an article's overview. The catalog documents the library rather than how to build an app on it, so it holds no guides such as for synchronizing notes or for App Intents. `CommandLineInterface.md` only says that the CLI exists, how to invoke it and how to ask it for help, and deliberately holds no copy of its help, which would drift from the commands. - `Tests/` contains the automated tests per target. - `Tests/RainmakerTests/Responses/` contains static test fixtures which are the HTTP response bodies of actual server responses. They either are in JSON or XML format. - `Tests/RainmakerTests/URLTestSession.swift` replays those fixtures during normal test runs, while `Tests/RainmakerTests/URLRecordingSession.swift` is its recording counterpart used by the `record-fixtures` subcommand. An upload whose response has a body, such as that of an attachment of a note, has the body recorded under the extension its `Accept` header maps to and replayed from there, while uploads answered without a body, like those over WebDAV, keep having no body file. Both derive fixture paths through the shared `Tests/RainmakerTests/FixtureLocator.swift` so recording and replay can never diverge, and `Tests/RainmakerTests/FixtureCanonicalizer.swift` normalizes recorded responses (canonical host, redacted volatile fields) so fixtures stay stable. Like its body rules, the response header fields it keeps depend on the request path: responses of the notes app additionally keep `ETag`, `Last-Modified`, `X-Notes-Chunk-Cursor` (all three with fixed canonical values) and `X-Notes-Chunk-Pending`, which every other response drops. `Tests/RainmakerTests/NoteChangesRequestTests.swift` covers reading those headers, the conditional request and the chunked listing with its stream with `MockRequesting`, because a replayed fixture neither carries live header values nor proves which request headers were sent and cannot answer a loop of chunks which share method and path, and it proves with `SuspendingRequesting.swift`, a double keeping a request in flight until its task is cancelled, that cancelling the stream's task cancels its request, while `EntityTagTests.swift` and `HTTPDateTests.swift` cover the two conversions. `Tests/RainmakerTests/NoteSummaryRequestTests.swift` covers the `exclude=content` query item of every summary listing, the decoding of summaries alongside identifiers, `nil` for `304` and the stream of chunks of summaries in the same way, while `NotesTests.fetchSummaries` and `NotesTests.fetchSummariesFirstChunk` record a summary listing and its first chunk. `Tests/RainmakerTests/SingleNoteRequestTests.swift` does the same for the lookup of a single note through `Server.note(_:)` and `Server.note(_:ifChangedFrom:)`, whose recorded counterparts in `NotesTests` take the identifier from a listing recorded in the same test, because the fixture of a lookup is filed under the identifier the server assigned. `Tests/RainmakerTests/NoteMutationTests.swift` records creating, changing and deleting notes, taking the identifier of every request after the creation from the recorded creation, as well as changing the settings of the notes app to the value they already have, while `Tests/RainmakerTests/NoteWritingRequestTests.swift` covers the request bodies, `If-Match`, the conflict and the other statuses of those calls with `MockRequesting`. `Tests/RainmakerTests/NoteAttachmentTests.swift` records adding, retrieving, downloading and deleting attachments, branching on the recorded capabilities because notes 5.0.2 and 6.1.0 store and delete them differently, and `Tests/RainmakerTests/NoteAttachmentRequestTests.swift` covers the encoding of their paths, the multipart body, the removal of the staged body and the statuses with `MockRequesting`, which answers downloads too and records the temporary files it hands them over in as well as the files uploads are sent from, so that the removal of both can be checked, and with `SuspendingRequesting`, which suspends uploads as it suspends data requests and downloads when asked to, so that cancelling a download is covered as well. `Tests/RainmakerTests/ServerTesting.swift` selects between the two sessions based on the `RAINMAKER_FIXTURE_RECORD` environment variable. diff --git a/Sources/Rainmaker/Documentation.docc/CommandLineInterface.md b/Sources/Rainmaker/Documentation.docc/CommandLineInterface.md index 0f250d4..7736fa8 100644 --- a/Sources/Rainmaker/Documentation.docc/CommandLineInterface.md +++ b/Sources/Rainmaker/Documentation.docc/CommandLineInterface.md @@ -1,284 +1,20 @@ # Command Line Interface -This package also provides a convenient executable to leverage the features of the library in a terminal environment. +Use the features of the library in a terminal through the executable this package also provides. ## Overview -You can get an overview by running the command line interface itself or any subcommand with the `--help` option in the package root directory like this, shown here and below without the line wrapping a terminal applies: - -```plaintext -$ swift run rainmaker-cli --help -USAGE: rainmaker - -OPTIONS: - -h, --help Show help information. - -SUBCOMMANDS: - activities List one page of the activity stream of the authenticated user. Requires authentication and the server's activity app. - activity-filters List the filters the server offers to narrow the activity stream down with. Requires authentication and the server's activity app. - capabilities Fetch the capabilities advertised by a server. Authentication is optional. - collectives List the collectives of the authenticated user and their pages. Requires authentication and the server's collectives app. - conversations List the Talk conversations of the authenticated user, retrieve their images, start one-to-one conversations and send messages and files into them. Requires authentication and the server's Talk app. - create-directory Create a directory on the server. - current-user Show the identifier and the display name of the authenticated account. Requires authentication. - delete Delete a file or directory from the server. - delete-app-password Delete the app password currently used to authenticate, ending the session on the server side. - download Download a file or directory from the server. - info Show information about a remote file or directory. - list List the content of a directory on the server by the given path. - login Fetch the login flow information from a server. - move Move or rename a remote file or directory on the server. - navigation List the apps navigation entries advertised by a server. Requires authentication. - notes List, retrieve, create, change and delete the notes of the authenticated user and their attachments. Requires authentication and the server's notes app. - notes-settings Show or change where and how the notes app stores the notes of the authenticated user. Requires authentication and the server's notes app. - notifications List the notifications queued for the authenticated user. Requires authentication and the server's notifications app. - poll Poll the status of a previously initiated login flow. - search-users Search the users of the server by name or identifier, for example to start a Talk conversation with one. Requires authentication. - trash Manage the server trash bin. - upload Upload a file or directory to a folder on the server. - watch Observe server-side changes over notify_push (or polling when unavailable or asked to) and print each event. Runs until interrupted. - record-fixtures Record test fixtures by deploying Nextcloud containers and running the test suite against them. - - See 'rainmaker help ' for detailed help. -``` - -## Notes - -The `notes` command is a group of subcommands, of which `list` is the default, so `notes --changed-since` keeps working as before. -Attachments are managed by the nested `notes attachment` group, and the settings of the notes app by the separate `notes-settings` command. -In the plain output, a listing of changes ends with a `#cursor` line while more chunks follow, an `#etag` line and a `#last-modified` line in whole seconds since the Unix epoch, which are what `--cursor`, `--if-none-match` and `--changed-since` take for the next listing, while `notes get` writes the note's `#etag` line to standard error so that standard output stays the text of the note. - -```plaintext -$ swift run rainmaker-cli notes --help -OVERVIEW: List, retrieve, create, change and delete the notes of the authenticated user and their attachments. Requires authentication and the server's notes app. - -USAGE: rainmaker notes - -OPTIONS: - -h, --help Show help information. - -SUBCOMMANDS: - list (default) List the notes of the authenticated user, all of them, those changed since a moment, or one chunk of those. - get Retrieve a single note of the authenticated user by its identifier. - create Create a note for the authenticated user and print its identifier, entity tag and title. - update Change a note of the authenticated user and print its identifier, entity tag and title. - delete Delete a note of the authenticated user. - attachment Retrieve, add and delete the files attached to a note of the authenticated user. - - See 'rainmaker help notes ' for detailed help. -``` - -```plaintext -$ swift run rainmaker-cli notes attachment --help -OVERVIEW: Retrieve, add and delete the files attached to a note of the authenticated user. - -USAGE: rainmaker notes attachment - -OPTIONS: - -h, --help Show help information. - -SUBCOMMANDS: - get Retrieve a file attached to a note of the authenticated user. - add Attach a local file to a note of the authenticated user and print the path the server stored it at. - delete Delete a file attached to a note of the authenticated user. Requires the notes app 6.1.0 or newer. - - See 'rainmaker help notes attachment ' for detailed help. -``` - -```plaintext -$ swift run rainmaker-cli notes-settings --help -OVERVIEW: Show or change where and how the notes app stores the notes of the authenticated user. Requires authentication and the server's notes app. - -USAGE: rainmaker notes-settings [--user ] [--password ] [--output-format ] [--host ] [--notes-path ] [--file-suffix ] [--note-mode ] [--show-hidden-files ] [--load-recent-note-on-start-up ] - -OPTIONS: - -u, --user The user account name to authenticate with. Can also be set via RAINMAKER_USER environment variable. - -p, --password - The password to authenticate with. Can also be set via RAINMAKER_PASSWORD environment variable. - --output-format - In what form to render the output. (default: plain) - -h, --host The Nextcloud instance to connect to. Can also be set via RAINMAKER_HOST environment variable. - --notes-path - Store the notes in this folder, relative to the account's files, with an empty string for the root folder. Existing notes are not moved. - --file-suffix - Give the notes created from now on this file extension, such as '.md' or '.txt'. - --note-mode Open notes in the web interface in this mode. (values: rich, edit, preview) - --show-hidden-files - Whether files and folders whose names start with a dot are listed as notes and categories, 'true' or 'false'. Requires notes app 6.1.0 or newer. - --load-recent-note-on-start-up - Whether the web interface opens the most recently edited note when it starts, 'true' or 'false'. Requires notes app 6.1.0 or newer. - -h, --help Show help information. -``` - -## Talk - -The `conversations` command is a group of subcommands, of which `list` is the default. -Sending something to a person takes three steps: `search-users` prints the identifier and the display name of every matching user, `conversations create` takes such an identifier as `--user-id` and prints the token and the name of the one-to-one conversation with that user, creating it unless it already exists, and `conversations send` and `conversations send-file` take that token. -The option is called `--user-id` because `--user` names the account to authenticate with. -`conversations send` and `conversations send-file` print nothing when they succeed. - -```plaintext -$ swift run rainmaker-cli conversations --help -OVERVIEW: List the Talk conversations of the authenticated user, retrieve their images, start one-to-one conversations and send messages and files into them. Requires authentication and the server's Talk app. - -USAGE: rainmaker conversations - -OPTIONS: - -h, --help Show help information. - -SUBCOMMANDS: - list (default) List the Talk conversations the authenticated user takes part in. - avatar Retrieve the image of a Talk conversation. - create Start a one-to-one Talk conversation with a user, or find the existing one, and print it. Requires the server's Talk app. - send Send a chat message into a Talk conversation. Requires the server's Talk app. - send-file Send a local file into a Talk conversation, optionally with a caption. Requires Talk 24 or later on the server. - - See 'rainmaker help conversations ' for detailed help. -``` - -```plaintext -$ swift run rainmaker-cli search-users --help -OVERVIEW: Search the users of the server by name or identifier, for example to start a Talk conversation with one. Requires authentication. - -USAGE: rainmaker search-users [--user ] [--password ] [--output-format ] [--host ] --term - -OPTIONS: - -u, --user The user account name to authenticate with. Can also be set via RAINMAKER_USER environment variable. - -p, --password - The password to authenticate with. Can also be set via RAINMAKER_PASSWORD environment variable. - --output-format - In what form to render the output. (default: plain) - -h, --host The Nextcloud instance to connect to. Can also be set via RAINMAKER_HOST environment variable. - --term What to search for, such as part of a display name or an identifier. - -h, --help Show help information. -``` - -```plaintext -$ swift run rainmaker-cli conversations create --help -OVERVIEW: Start a one-to-one Talk conversation with a user, or find the existing one, and print it. Requires the server's Talk app. - -USAGE: rainmaker conversations create [--user ] [--password ] [--output-format ] [--host ] --user-id - -OPTIONS: - -u, --user The user account name to authenticate with. Can also be set via RAINMAKER_USER environment variable. - -p, --password - The password to authenticate with. Can also be set via RAINMAKER_PASSWORD environment variable. - --output-format - In what form to render the output. (default: plain) - -h, --host The Nextcloud instance to connect to. Can also be set via RAINMAKER_HOST environment variable. - --user-id The identifier of the user to start the conversation with, as shown by `search-users`. - -h, --help Show help information. -``` - -```plaintext -$ swift run rainmaker-cli conversations send --help -OVERVIEW: Send a chat message into a Talk conversation. Requires the server's Talk app. - -USAGE: rainmaker conversations send [--user ] [--password ] [--host ] --token --message - -OPTIONS: - -u, --user The user account name to authenticate with. Can also be set via RAINMAKER_USER environment variable. - -p, --password - The password to authenticate with. Can also be set via RAINMAKER_PASSWORD environment variable. - -h, --host The Nextcloud instance to connect to. Can also be set via RAINMAKER_HOST environment variable. - --token The token of the conversation to send to, as shown by `conversations list` or `conversations create`. - --message The text to send. - -h, --help Show help information. -``` - -```plaintext -$ swift run rainmaker-cli conversations send-file --help -OVERVIEW: Send a local file into a Talk conversation, optionally with a caption. Requires Talk 24 or later on the server. - -USAGE: rainmaker conversations send-file [--user ] [--password ] [--host ] --token --file [--caption ] - -OPTIONS: - -u, --user The user account name to authenticate with. Can also be set via RAINMAKER_USER environment variable. - -p, --password - The password to authenticate with. Can also be set via RAINMAKER_PASSWORD environment variable. - -h, --host The Nextcloud instance to connect to. Can also be set via RAINMAKER_HOST environment variable. - --token The token of the conversation to send to, as shown by `conversations list` or `conversations create`. - --file The local file to send. Its name becomes the name of the file on the server. - --caption A text to show together with the file. - -h, --help Show help information. -``` - -## Current User - -```plaintext -$ swift run rainmaker-cli current-user --help -OVERVIEW: Show the identifier and the display name of the authenticated account. Requires authentication. - -USAGE: rainmaker current-user [--user ] [--password ] [--output-format ] [--host ] - -OPTIONS: - -u, --user The user account name to authenticate with. Can also be set via RAINMAKER_USER environment variable. - -p, --password - The password to authenticate with. Can also be set via RAINMAKER_PASSWORD environment variable. - --output-format - In what form to render the output. (default: plain) - -h, --host The Nextcloud instance to connect to. Can also be set via RAINMAKER_HOST environment variable. - -h, --help Show help information. -``` - -## Login - -`login` starts a login flow and prints the login page to open in a browser together with the poll address and the poll token, which `poll` takes to wait for the app password. -While the flow is pending, `poll` tries again every second, and any real failure, such as an unreachable server or an address which is not a login flow endpoint, ends it with that error. - -```plaintext -$ swift run rainmaker-cli poll --help -OVERVIEW: Poll the status of a previously initiated login flow. - -USAGE: rainmaker poll [--output-format ] [--tries ] - -ARGUMENTS: - The address which to poll for login flow status. - The token to identify the login flow. - -OPTIONS: - --output-format - In what form to render the output. (default: plain) - --tries How many times the endpoint should be polled before failing. (default: 300) - -h, --help Show help information. -``` - -## Observing Changes - -```plaintext -$ swift run rainmaker-cli watch --help -OVERVIEW: Observe server-side changes over notify_push (or polling when unavailable or asked to) and print each event. Runs until interrupted. - -USAGE: rainmaker watch [--user ] [--password ] [--host ] [--poll-interval ] [--transport ] [--listen-file-ids] - -OPTIONS: - -u, --user The user account name to authenticate with. Can also be set via RAINMAKER_USER environment variable. - -p, --password - The password to authenticate with. Can also be set via RAINMAKER_PASSWORD environment variable. - -h, --host The Nextcloud instance to connect to. Can also be set via RAINMAKER_HOST environment variable. - --poll-interval - The polling interval in seconds used when notify_push is unavailable or not used. (default: 30.0) - --transport How to learn about changes: 'automatic' prefers notify_push and polls when it is unavailable, 'polling' only polls. (values: automatic, polling; default: automatic) - --listen-file-ids Request per-file identifiers via notify_file_id. - -h, --help Show help information. -``` - -## Authentication - -You can provide credentials directly as command-line options: +The package provides the executable product `rainmaker-cli`, which offers the features of the library as subcommands. +Run it from the package root directory through the Swift Package Manager: ```bash -$ swift run rainmaker-cli list --host "http://localhost:8080" --user "myuser" --password "mypassword" +swift run rainmaker-cli ``` -To avoid password leakage and enable a default account pattern, you can also set credentials via environment variables: +The command line interface describes itself, so ask it for help rather than relying on a copy of its help in this documentation. +The `--help` option lists the subcommands, and the `help` subcommand describes a single one, including the options it takes and how they are set: ```bash -export RAINMAKER_HOST="http://localhost:8080" -export RAINMAKER_USER="myuser" -export RAINMAKER_PASSWORD="mypassword" - -$ swift run rainmaker-cli list +swift run rainmaker-cli --help +swift run rainmaker-cli help ``` - -Environment variables can be mixed with command-line options. Command-line options take precedence over environment variables. diff --git a/Sources/Rainmaker/Documentation.docc/SynchronizingNotes.md b/Sources/Rainmaker/Documentation.docc/SynchronizingNotes.md deleted file mode 100644 index 5d13084..0000000 --- a/Sources/Rainmaker/Documentation.docc/SynchronizingNotes.md +++ /dev/null @@ -1,177 +0,0 @@ -# Synchronizing Notes - -Keep a local copy of the notes of an account in step with the server, transferring as little as possible and never losing a change made elsewhere. - -## Overview - -The notes app of a Nextcloud server keeps every note as an ordinary file and offers a REST API on top which reports what changed since a given moment. -A client keeping its own copy of the notes builds on four ideas: it asks for changes since the server's own moment of the previous call, it asks conditionally so that an unchanged account costs no transfer, it splits large transfers into chunks and derives deletions only from a complete answer, and it makes every change conditional on the copy it is based on. - -Each method mentioned here is a standalone call which needs nothing but a ``Server`` with credentials, so the same calls serve a full synchronization engine as well as a single action, see . - -### Check What the Server Offers - -The notes app is not part of a Nextcloud installation, so whether it is there and new enough is worth asking before anything else. -Every notes feature requires the notes API in version ``Notes/minimumAPIVersion`` or newer, which the notes app serves since release 4.12.3, and ``Notes/isSupported`` tells whether a server advertises it. -Some behaviours are tied to a release of the app rather than to its API version, which ``Notes/isAppVersion(atLeast:)`` and the helpers built on it tell apart. - -```swift -let capabilities = try await server.capabilities() - -guard let notes = try capabilities.get(Notes.self), notes.isSupported else { - return // The notes app is absent or too old for this library. -} - -let canDeleteAttachments = notes.supportsAttachmentDeletion // Release 6.1.0 or newer. -``` - -A notes app which goes away later is reported as ``RainmakerError/appUnavailable(app:)`` by every notes call except the retrieval of an attachment (see below), never as ``RainmakerError/notFound``, which is reserved for a note that does not exist. -Where the retrieval of an attachment reports ``RainmakerError/notFound``, the ``Notes`` capability tells whether the app is still there. -A client therefore never mistakes a missing app for an account whose notes were all deleted. - -### Retrieve Changes Since the Previous Call - -``Server/notes(changedSince:)`` returns every note the server recorded a change for at or after the given moment in full, and reduces every other note to its identifier. -Both halves of the ``NoteChanges`` together are the complete set of notes the account has, which is what makes deletions detectable. - -The moment is sent as the server's `pruneBefore` parameter and compared against the server's own record of when it noticed each note change, not against ``Note/modification``. -Pass `Date.distantPast` for the first call, which prunes nothing, and from then on the ``NoteChanges/lastModified`` of the previous result, which is the server's own clock at the time it started answering. -Never pass a note's ``Note/modification`` or the device's clock, because a note written long ago can still be one the server only noticed today. - -A note the server could not read is listed with ``Note/hasError`` set and a message about the failure in place of its text. -Such a note still exists, so its identifier counts when deletions are derived, but its content must not replace a good local copy. - -```swift -let changes = try await server.notes(changedSince: store.lastModified ?? .distantPast) - -for note in changes.changed where note.hasError == false { - store.upsert(note) -} - -// Every identifier the server returned, the unreadable notes included, is a note which still exists. -store.deleteAll(exceptFor: changes.changed.map(\.id) + changes.unchanged) - -// Remembered only once everything was applied, so an interrupted synchronization repeats rather than skips what it missed. -store.lastModified = changes.lastModified -store.entityTag = changes.entityTag -``` - -### Ask Conditionally - -A client polling the server passes the ``NoteChanges/entityTag`` and the ``NoteChanges/lastModified`` of the previous result to ``Server/notes(changedSince:ifChangedFrom:)``. -The server answers with an empty `304 Not Modified` when it would send exactly what it sent along with that tag, which the method returns as `nil`. -On `nil`, keep the previous moment and tag for the next call, which never skips a change, because the server keeps every note which changed in the very second the moment names. - -The server computes the tag from the body it would send, so the first call after a result which carried notes in full still receives a complete response, reducing those notes to their identifiers, and every further call answers `nil` until something changes. -`nil` deliberately differs from an empty ``NoteChanges``, which would claim that the account has no notes at all. - -A single note is refreshed the same way through ``Server/note(_:ifChangedFrom:)`` with its ``Note/entityTag``, which spares the transfer of its content while it did not change and reports a deleted note as ``RainmakerError/notFound`` whatever the tag. - -### Retrieve Changes in Chunks - -A client which must not hold every changed note at once, such as an extension or a watch app, retrieves a pass in chunks of a bounded size. -``Server/noteChunks(changedSince:chunkSize:)`` streams the chunks of one pass and requests a chunk only when the consumer asks for the next one, so a consumer which applies each chunk before it continues needs no more memory than one chunk takes. - -Only the last chunk of a pass, which ``NoteChanges/isComplete``, lists the identifiers of the notes the earlier chunks did not send in full, so deletions are derived from it alone and never from what a pass collected along the way. -Every chunk of one pass carries the same ``NoteChanges/lastModified``, which is remembered only once the last chunk was applied. - -```swift -var lastChunk: NoteChanges? - -for try await chunk in server.noteChunks(changedSince: store.lastModified ?? .distantPast, chunkSize: 50) { - store.upsert(chunk.changed.filter { $0.hasError == false }) - lastChunk = chunk -} - -// A stream which ended early, for example because its task was cancelled, did not deliver a complete pass. -guard let lastChunk, lastChunk.isComplete else { - return -} - -store.deleteAll(exceptFor: lastChunk.changed.map(\.id) + lastChunk.unchanged) -store.lastModified = lastChunk.lastModified -store.entityTag = lastChunk.entityTag -``` - -A pass interrupted, for example because the system suspended the app, continues later through ``Server/notes(changedSince:chunkSize:continuingAfter:)`` with the ``NoteChanges/chunkCursor`` of the last chunk applied and the same moment as before. -The first chunk of a new pass can be asked for conditionally through ``Server/notes(changedSince:chunkSize:ifChangedFrom:)`` with the tag of the last chunk of the previous pass. -The notes API also offers to narrow a listing down to a category, which this library never asks for, because that filter narrows the identifiers of the last chunk as well and would make every note outside the category look deleted. - -### Leave Out the Text With Summaries - -A client which lists notes but keeps no copy of their text, for example to index titles or to show a list, asks for summaries instead. -``Server/noteSummaries(changedSince:)``, ``Server/noteSummaries(changedSince:ifChangedFrom:)``, ``Server/noteSummaries(changedSince:chunkSize:continuingAfter:)``, ``Server/noteSummaries(changedSince:chunkSize:ifChangedFrom:)`` and ``Server/noteSummaryChunks(changedSince:chunkSize:)`` mirror the listings above and return ``NoteSummaryChanges`` with ``NoteSummary`` values, so the text of the notes is never downloaded. - -The server only notices that it cannot read a note while it reads the text, which a summary listing asks it to skip, so a ``NoteSummary`` has no counterpart to ``Note/hasError``. -``NoteSummary/entityTag`` is the same tag ``Note/entityTag`` carries, so the text of a single note can be fetched when it is needed through ``Server/note(_:)`` or ``Server/note(_:ifChangedFrom:)``, and a change can be based on the summary through `ifMatching`. -The entity tag of a whole response is computed from its body, which holds no text in a summary listing, so keep the response tags of summary listings and of full listings apart: one only matches the other while neither response sends a note in full. - -### Change Notes Without Losing Changes Made Elsewhere - -``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` sends only the values it is given, and with the ``Note/entityTag`` of the copy the change is based on in `ifMatching` the server only applies it while that is still the note's tag. -Otherwise nothing is changed and the call throws ``RainmakerError/noteConflict(current:)``, which carries the note as the server has it now, so a client merges its change into that note and retries with the current tag without a further request. - -```swift -do { - let updated = try await server.updateNote(local.id, content: local.content, ifMatching: local.entityTag) - store.upsert(updated) -} catch let RainmakerError.noteConflict(current: current) { - let merged = merge(local, into: current) - let updated = try await server.updateNote(current.id, content: merged.content, ifMatching: current.entityTag) - store.upsert(updated) -} -``` - -The server applies the values of one change one after the other, so a change which fails halfway may leave the earlier values applied, and ``Server/note(_:)`` tells what the note is now. -The server sanitizes titles and categories, which become file and folder names, so adopt the ``Note/title``, ``Note/category`` and ``Note/path`` it returns, which ``NoteTitle/sanitize(_:)`` and ``NoteCategory/sanitize(_:)`` predict. - -``Server/createNote(title:category:content:modification:isFavorite:)`` is not idempotent: every call which reaches the server creates another note, and the server numbers a title already taken rather than refuse it. -A call whose response was lost may have created the note all the same, so before repeating it, list the changes since the moment before the attempt and look for the note. -Passing the moment the note was written offline as `modification` keeps it as ``Note/modification``. - -``Server/deleteNote(_:)`` cannot be made conditional, because the server checks no entity tag when it deletes, and a ``RainmakerError/notFound`` in answer to a deletion means that the note is gone already. - -### Work With Attachments - -Files such as images are attached to a note through ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` or ``Server/addAttachment(_:toNote:fileName:)-(Data,Int,String)``, which return the path the server stored the file at, relative to the folder of the note's category. -They are retrieved by that path through ``Server/attachment(at:ofNote:)`` into memory or ``Server/downloadAttachment(at:ofNote:to:force:)`` into a local file, and deleted through ``Server/deleteAttachment(at:ofNote:)``. -These requests go to the `v1.4` segment of the notes API rather than to `v1`, because releases of the notes app before 6.1.0 route attachments only there. - -Where the file lands depends on the release of the notes app, see ``Notes/storesAttachmentsPerNote``: - -- Release 6.1.0 and newer store it in a folder `.attachments.` next to the note, under the given name with a number appended when it is taken, move that folder along when the note moves and delete it with the note. -- Older releases store it right next to the note under a random name, leave it behind when the note is deleted and cannot delete it through the API at all, which ``Server/deleteAttachment(at:ofNote:)`` reports as ``RainmakerError/methodNotAllowed`` and ``Notes/supportsAttachmentDeletion`` tells in advance. - -Adding an attachment does not change the note: it becomes part of the note only once the note's content references it. -The Nextcloud Text app, which edits Markdown notes in the web interface, encodes each component of such a reference as JavaScript's `encodeURIComponent` does and additionally encodes `!`, `'`, `(`, `)` and `*`. -A background job of the Text app deletes files in the `.attachments.` folder of a Markdown note which its content does not reference in exactly that form, so a reference built any other way can cost the file. -``NoteAttachmentReference/markdown(alt:path:)`` builds the reference the way Text does, ``NoteAttachmentReference/decode(_:)`` turns a reference back into the path the server takes, and ``NoteAttachmentReference/sanitizeFileName(_:)`` removes the invisible characters from a file name before upload which Text leaves out of references. - -The retrieval of an attachment is the one notes request the app answers without its version header, and it reports every failure with a bare `404`, so ``RainmakerError/notFound`` there may also mean an absent notes app. -The notes app offers no way to list the attachments of a note, but they are ordinary files, which ``Server/enumerate(at:recursively:)->[Item]`` lists in the folder which contains the file ``Note/path`` names, or in its `.attachments.` subfolder on releases which keep attachments per note, see ``Notes/storesAttachmentsPerNote``. - -### Treat Changed Settings as a New Set of Notes - -``Server/updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)`` can change which notes the server lists, for example by pointing it at another folder or by listing hidden files. -A client keeping its own copy retrieves the notes anew afterwards, starting from `Date.distantPast`. -The settings belong to the account rather than to a client, so another client may change them as well, which a client notices by comparing what ``Server/notesSettings()`` returns with what it saw before. -The settings ``NotesSettings/showsHiddenFiles`` and ``NotesSettings/loadsRecentNoteOnStartUp`` exist since release 6.1.0 of the notes app and are `nil` on older releases. - -### Configure the Session for Several Accounts - -Every request authenticates itself and refuses cookies, so one session can serve the ``Server`` objects of several accounts. -The notes requests bypass the local HTTP cache, but a session still stores their responses in its `URLCache`, which is keyed by URL alone while the same path of an attachment can stand for different files of different accounts. -A session shared by several accounts, or one which handles private notes, should therefore keep no cache at all, as ``Server/init(address:password:user:session:webSocket:userAgent:)`` describes: - -```swift -let configuration = URLSessionConfiguration.ephemeral -configuration.urlCache = nil -configuration.httpCookieStorage = nil -configuration.httpShouldSetCookies = false - -let session = URLSession(configuration: configuration) -let work = Server(address: workAddress, password: workPassword, user: workUser, session: session) -let personal = Server(address: personalAddress, password: personalPassword, user: personalUser, session: session) -``` - -Keep the moment, the entity tags and the local copy apart per server and account as well, because neither a moment nor a tag means anything to another account. diff --git a/Sources/Rainmaker/Documentation.docc/UsingNotesFromAppIntents.md b/Sources/Rainmaker/Documentation.docc/UsingNotesFromAppIntents.md deleted file mode 100644 index 1a1ed0d..0000000 --- a/Sources/Rainmaker/Documentation.docc/UsingNotesFromAppIntents.md +++ /dev/null @@ -1,160 +0,0 @@ -# Using Notes From App Intents - -Build actions for Shortcuts, Siri and widgets on the notes features, each of which is a standalone call that needs no synchronization engine. - -## Overview - -An App Intent runs one action and ends: it creates a note from some text, appends to a note, attaches an image or looks something up. -Every notes method of ``Server`` suits that, because each one is a single request, or a short fixed sequence of requests, which needs nothing but a ``Server`` with credentials and keeps no state between calls. -Nothing has to be synchronized first, and nothing has to be torn down afterwards. - -The intents below assume a function `makeServer()` of the app's own which reads the address and the credentials of the account from the app's keychain and creates a ``Server`` with a session configured as described in . - -### Check the Notes App Before Changing Anything - -A release of the notes app older than ``Notes/minimumAPIVersion`` still carries out a creation, a change or a deletion it is asked for, and only its answer reveals that it is too old, so ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` from ``Server/createNote(title:category:content:modification:isFavorite:)``, ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)``, ``Server/deleteNote(_:)`` or ``Server/updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)`` means that the change has already been made. -An intent which changes something without retrieving anything first therefore checks the ``Notes`` capability, so that it neither changes data on such a server nor tells the user it failed after it did, which would invite running it again and creating a duplicate. - -```swift -func requireUsableNotesApp(on server: Server) async throws { - guard try await server.capabilities().get(Notes.self)?.isSupported == true else { - throw NotesIntentError.notesAppUnusable - } -} -``` - -An intent which retrieves the note first, as the one appending to a note below does, needs no such check, because the retrieval changes nothing and already fails with ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` on such a server. - -### Create a Note From Text - -The notes API never derives a title from the content, so an intent which is only handed some text derives the title the notes app's web interface would give it through ``NoteTitle/derive(fromContent:)``. -A text without anything usable as a title derives an empty title, for which the server substitutes its default title. - -```swift -struct CreateNoteIntent: AppIntent { - static let title: LocalizedStringResource = "Create Note" - - @Parameter(title: "Text") - var text: String - - func perform() async throws -> some IntentResult & ReturnsValue { - let server = try makeServer() - try await requireUsableNotesApp(on: server) - let note = try await server.createNote(title: NoteTitle.derive(fromContent: text), content: text) - - return .result(value: note.title) - } -} -``` - -The server sanitizes the title and numbers one already taken, so the intent reports the ``Note/title`` it returns rather than the one it sent. -Creating a note is not idempotent: when the response was lost, the note may have been created all the same, so an intent does not simply repeat the call. -Checking the ``Notes`` capability first costs a request, but it is what keeps an outdated notes app from creating the note while the intent reports a failure. - -### Append to a Note - -Appending is a change based on the note's current content, so the intent makes it conditional on the ``Note/entityTag`` of the copy it appended to. -When the note changed elsewhere in the meantime, ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` changes nothing and throws ``RainmakerError/noteConflict(current:)`` with the note as the server has it now, so the intent appends to that one and tries again without retrieving it first. - -```swift -func append(_ text: String, toNote id: Int, on server: Server) async throws -> Note { - var note = try await server.note(id) - - for _ in 0 ..< 3 { - // A note the server could not read carries a message about the failure in place of its text, which must not be written back. - guard note.hasError == false else { - throw AppendError.unreadable - } - - do { - return try await server.updateNote(note.id, content: note.content + "\n" + text, ifMatching: note.entityTag) - } catch let RainmakerError.noteConflict(current: current) { - note = current - } - } - - throw AppendError.keepsChanging -} -``` - -Without `ifMatching`, the last writer wins, and text typed into the note in the web interface while the intent ran would be lost. - -### Add an Image to a Note - -An image is uploaded as an attachment first and then referenced from the note's content, because adding an attachment does not change the note. -The reference has to be encoded exactly as the Nextcloud Text app encodes it, or a background job of that app deletes the file as unreferenced, which ``NoteAttachmentReference/markdown(alt:path:)`` takes care of. - -```swift -struct AddImageIntent: AppIntent { - static let title: LocalizedStringResource = "Add Image to Note" - - @Parameter(title: "Note Identifier") - var noteId: Int - - @Parameter(title: "Image") - var image: IntentFile - - func perform() async throws -> some IntentResult { - let server = try makeServer() - let name = NoteAttachmentReference.sanitizeFileName(image.filename) - let path = try await server.addAttachment(image.data, toNote: noteId, fileName: name) - - _ = try await append(NoteAttachmentReference.markdown(alt: name, path: path), toNote: noteId, on: server) - - return .result() - } -} -``` - -``NoteAttachmentReference/sanitizeFileName(_:)`` removes the invisible characters Text leaves out of references, so that the stored name matches the name the reference decodes to. -The path the server returns depends on the release of the notes app, see ``Notes/storesAttachmentsPerNote``, and the reference works with either form. -Like creating a note, adding an attachment is not idempotent, which is why a conflict only repeats the append above and never the upload. -A large file which already is on disk is better attached through ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)``, which streams it instead of holding it in memory. - -### Bound the Time an Intent Takes - -Every call runs within the task which awaits it, so cancelling that task, as the system does with an intent it ends, cancels the request in flight. -The streams ``Server/noteChunks(changedSince:chunkSize:)`` and ``Server/noteSummaryChunks(changedSince:chunkSize:)`` request each chunk within the consuming task as well, and a stream which ended early did not deliver a complete pass, which its last chunk's `isComplete` tells. - -The time a request may take is a property of the session the ``Server`` was created with, so an app gives its intents a session with timeouts short enough for an action someone waits for. -A session used from an intent also keeps no cache, because an intent runs in a process which may handle several accounts and private notes: - -```swift -func makeServer() throws -> Server { - let account = try AccountStore.current() - let configuration = URLSessionConfiguration.ephemeral - configuration.urlCache = nil - configuration.httpCookieStorage = nil - configuration.httpShouldSetCookies = false - configuration.timeoutIntervalForRequest = 15 - configuration.timeoutIntervalForResource = 30 - - return Server(address: account.address, password: account.appPassword, user: account.loginName, session: URLSession(configuration: configuration)) -} -``` - -A request which times out throws the `URLError` the session reports, which an intent surfaces like any other error. -The errors worth a message of their own are ``RainmakerError/appUnavailable(app:)`` and ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)``, which mean that the server lacks a notes app this library can use, although a change the intent asked for may have been made all the same when it did not check the ``Notes`` capability first, see , and ``RainmakerError/notFound``, which means that the note was deleted, or for an attachment that it or its note is missing, where retrieving an attachment also reports an absent notes app this way. -A success response which did not come from the notes app at all, such as the login page of an authenticating proxy, is reported as ``RainmakerError/responseDecodingFailed(reason:)``. - -### Index Notes Without Their Text - -An intent or a background task which only needs the titles of the notes, for example to offer them as suggestions of an `EntityQuery` or to index them for Spotlight, lists them as summaries, so the text of every note is never downloaded. -``Server/noteSummaryChunks(changedSince:chunkSize:)`` keeps the memory a pass takes bounded by the chunk size, which suits an extension with little memory to spare. - -```swift -for try await chunk in server.noteSummaryChunks(changedSince: .distantPast, chunkSize: 200) { - let items = chunk.changed.map { summary in - let attributes = CSSearchableItemAttributeSet(contentType: .text) - attributes.title = summary.title - attributes.contentModificationDate = summary.modification - - return CSSearchableItem(uniqueIdentifier: String(summary.id), domainIdentifier: summary.category, attributeSet: attributes) - } - - try await CSSearchableIndex.default().indexSearchableItems(items) -} -``` - -A note picked from those suggestions is retrieved in full through ``Server/note(_:)`` when the intent runs, and ``NoteSummary/entityTag`` matches ``Note/entityTag``, so a summary is as good a basis for a conditional change as the note itself. -A client which keeps such an index up to date over time follows rather than listing everything again. diff --git a/Sources/Rainmaker/Documentation.docc/WorkingWithNotes.md b/Sources/Rainmaker/Documentation.docc/WorkingWithNotes.md index 51f3341..67592b6 100644 --- a/Sources/Rainmaker/Documentation.docc/WorkingWithNotes.md +++ b/Sources/Rainmaker/Documentation.docc/WorkingWithNotes.md @@ -5,7 +5,6 @@ Retrieve, create, change and delete the notes of an account, attach files to the ## Overview The notes app is not part of a Nextcloud installation, so whether it is installed at all is advertised through the ``Notes`` capability, which also reports whether it is new enough to be usable. -The two articles below put the calls of this page together, one for a client keeping its own copy of the notes and one for single actions such as those of Shortcuts. ### Retrieving Notes @@ -46,11 +45,6 @@ An outdated notes app is reported as ``RainmakerError/unsupportedAPIVersion(app: ## Topics -### Articles - -- -- - ### Retrieving Notes - ``Server/notes()``