Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ 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.3 --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.
- 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` 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.
- 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).

Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,24 @@
[![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 and collectives.
A simple Swift library and CLI to access [Nextcloud](https://www.nextcloud.com) programmatically: files, notifications, activities, notes, collectives and Talk conversations.
For further information, see [the documentation which is built from the source code and deployed to GitHub pages](https://i2h3.github.io/rainmaker/).

## Testing

The test suite runs entirely against static fixtures committed under `Tests/RainmakerTests/Responses/`, so `swift test` needs no server and runs on every platform.
The test suite uses static fixtures committed under `Tests/RainmakerTests/Responses/` and isolated request doubles, so `swift test` needs no server and runs on every platform.

These fixtures are generated automatically by the `record-fixtures` subcommand of `rainmaker-cli` (macOS with Docker only, never run in CI). It deploys ephemeral Nextcloud containers via [NextcloudContainerManager](https://github.com/i2h3/nextcloud-container-manager), records the test suite against them, and verifies the captures replay without a server:
Most of these fixtures are generated automatically by the `record-fixtures` subcommand of `rainmaker-cli` (macOS with Docker only, never run in CI). It deploys ephemeral Nextcloud containers via [NextcloudContainerManager](https://github.com/i2h3/nextcloud-container-manager), installs the apps whose fixtures depend on them from the Nextcloud app store, records the test suite against them, and verifies the captures replay without a server:

```bash
# Regenerate fixtures for all supported versions, then review and commit the diff.
swift run rainmaker-cli record-fixtures
git diff Tests/RainmakerTests/Responses
```

Scope a run with `--version <tag>` and `--filter <substring>`. See [AGENTS.md](AGENTS.md) for details.
Scope a run with `--version <tag>` and `--filter <substring>`.
A few suites keep hand-authored fixtures instead, because a plain container cannot reproduce the responses they need; `recordableSuites` in `Sources/RainmakerCLI/Fixtures/FixtureOrchestrator.swift` lists which suites record and why the others do not.
See [AGENTS.md](AGENTS.md) for details.

## License

Expand Down
2 changes: 2 additions & 0 deletions Sources/Rainmaker/Documentation.docc/CommandLineInterface.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +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.
create-directory Create a directory on the server.
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.
Expand All @@ -31,6 +32,7 @@ SUBCOMMANDS:
notes-settings Show where 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.
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) and print each event. Runs until interrupted.

Expand Down
18 changes: 18 additions & 0 deletions Sources/Rainmaker/Documentation.docc/Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ Pages are returned flat and in the server's order; their hierarchy is reconstruc
- ``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 every other capability covered here 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``

### 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.
Expand All @@ -106,6 +123,7 @@ List the server apps, such as Files, Photos and Activity, which the server adver
- ``Notes``
- ``Notifications``
- ``PushNotifications``
- ``Talk``
- ``Theming``
- ``Trashing``
- ``Version``
Expand Down
134 changes: 134 additions & 0 deletions Sources/Rainmaker/Models/Conversation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: 2026 Iva Horn
// SPDX-License-Identifier: MIT

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)`.
///
/// 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:)``.
///
public struct Conversation: Model, Identifiable, CustomStringConvertible, CustomDebugStringConvertible, Decodable {
///
/// The server-assigned identifier of the conversation, unique per server.
///
/// This is not what addresses a conversation in the server's API; ``token`` is. It is modelled all the same because it is what makes a conversation `Identifiable` and what a client can key its own storage by.
///
public let id: Int

///
/// 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.
///
public let token: String

///
/// What kind of conversation this is.
///
public let type: ConversationType

///
/// The name to present to the user.
///
/// The server resolves this for the authenticated user, so a ``ConversationType/oneToOne`` conversation carries the other person's name even though the conversation itself is stored without one. It is localized to the account's language for the conversations the server maintains on its own, such as ``ConversationType/changelog`` and ``ConversationType/noteToSelf``.
///
public let displayName: String

///
/// The server's version marker for the conversation's avatar, e.g. `"b0135871"`.
///
/// A change invalidates a cached image, but an unchanged marker is no promise that the image is unchanged, and for a ``ConversationType/oneToOne`` conversation it promises nothing at all.
/// The server derives the marker of such a conversation from the path of a generic icon, which makes it one and the same value for every one-to-one conversation on that server, while the avatar endpoint answers with the other person's current profile picture. Nothing that person does to their picture moves this value.
///
/// So treat this as a hint to invalidate with, never as a key to cache under. Cache images between displays, refreshing them when this marker changes and periodically even when it does not, for example once a day.
/// Keep separate entries for each server, account, conversation token and appearance. The appearance belongs in the key because the marker of an emoji avatar is derived from its light variant alone, so both appearances of one conversation share a single value.
/// ``Server/conversationAvatar(_:darkTheme:)`` bypasses the local HTTP cache so that an explicit refresh can retrieve updated bytes.
///
public let avatarVersion: String

///
/// The moment of the last activity in the conversation.
///
/// This corresponds to the server's `lastActivity` field, a number of whole seconds since the Unix epoch in the UTC time zone.
/// The conversion happens in ``init(from:)`` rather than through a decoder's date strategy, so that this type decodes correctly no matter which `JSONDecoder` a downstream project passes it to.
///
/// A conversation without any activity yet is reported as `0` by the server and therefore surfaces as `Date(timeIntervalSince1970: 0)` rather than as `nil`, which still sorts as the oldest.
///
public let lastActivity: Date

///
/// The number of chat messages in the conversation the authenticated user has not read yet.
///
public let unreadMessages: Int

///
/// Whether the authenticated user was mentioned in the conversation since they last visited it.
///
/// This includes a mention of everyone rather than only a direct one, which the server reports separately in a field this type does not model.
///
public let unreadMention: Bool

///
/// The keys a conversation is decoded from, which are the names the server sends.
///
/// Unlike ``Note`` or ``Collective``, this type needs no separate set of encoding keys: no property is renamed here, so the two would be identical and the synthesized `Encodable` conformance already encodes under the property names.
///
private enum CodingKeys: String, CodingKey {
case id
case token
case type
case displayName
case avatarVersion
case lastActivity
case unreadMessages
case unreadMention
}

// MARK: - Decodable

///
/// 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.
///
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

id = try container.decode(Int.self, forKey: .id)
token = try container.decode(String.self, forKey: .token)
type = try container.decode(ConversationType.self, forKey: .type)
displayName = try container.decode(String.self, forKey: .displayName)
avatarVersion = try container.decode(String.self, forKey: .avatarVersion)
unreadMessages = try container.decode(Int.self, forKey: .unreadMessages)
unreadMention = try container.decode(Bool.self, forKey: .unreadMention)

// Decoding into a `TimeInterval` rather than an integer avoids the trap an out of range value would cause on the platforms where `Int` is only 32 bits wide.
let secondsSince1970 = try container.decode(TimeInterval.self, forKey: .lastActivity)
lastActivity = Date(timeIntervalSince1970: secondsSince1970)
}

// MARK: - CustomStringConvertible

///
/// Implementation for `CustomStringConvertible` conformance to have a concise and human-readable textual representation of a conversation.
///
public var description: String {
displayName
}

// MARK: - CustomDebugStringConvertible

///
/// Implementation for `CustomDebugStringConvertible` conformance to have a concise and human-readable textual representation of a conversation.
///
public var debugDescription: String {
"#\(id) (\(token)): \(displayName)"
}
}
27 changes: 27 additions & 0 deletions Sources/Rainmaker/Models/ConversationAvatar.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: 2026 Iva Horn
// SPDX-License-Identifier: MIT

import Foundation

///
/// The image of a Nextcloud Talk conversation as the server resolved it.
///
/// This is what ``Server/conversationAvatar(_:darkTheme:)`` returns. The server decides which image a conversation has, so a client renders whatever comes back rather than deciding anything itself: it is a picture a moderator uploaded, an emoji picked in the web interface, the other person's avatar in a ``ConversationType/oneToOne`` conversation, or an icon the server generates from the kind of conversation it is.
///
/// > Important: The image is commonly an SVG document rather than a bitmap, because every generated icon and every emoji avatar is one. Anything turning these bytes into a platform image has to handle that, which is what ``contentType`` is for.
///
/// Whether the image was set by a user or generated by the server is deliberately not reported: the response carries no such signal, and the only field which does is the conversation's `isCustomAvatar`, which ``Conversation`` does not model.
///
public struct ConversationAvatar: Model {
///
/// The bytes of the image exactly as the server sent them.
///
public let data: Data

///
/// The MIME type of ``data``, e.g. `"image/svg+xml"` or `"image/png"`.
///
/// This is the response's `Content-Type` and the only description of what the bytes are, which is why ``Server/conversationAvatar(_:darkTheme:)`` treats its absence as a failure rather than guessing.
///
public let contentType: String
}
Loading
Loading