-
Notifications
You must be signed in to change notification settings - Fork 56
libQuotient overview
The Quotient SDK strives to provide an efficient high-performance base for a wide range of Matrix clients, be they UI-less (bots) or with command-line or graphical user interface. Below is a brief description of how libQuotient - the centrepiece of the project - is generally organised and working.
The library has two decoupled layers:
- lower-level transport layer - it's basically a wrapper around
the client-server "RESTful" API (aka CS API).
This layer consists of a
ConnectionData
class, Jobs, and Events and some smaller supplementary data structures. Jobs and supplementary data structures are (mostly) generated from the machine-readable API definition; as of this writing, standard Events are not generated from their respective definitions but that's in plans.ConnectionData
is the carrier of all API requests: it enables network interaction and queues requests when the server advises to not shoot too fast. What's important here is that there's absolutely no processing of events within this layer - they are just marshalled/unmarshalled to/from JSON, and that's it. - higher-level application layer - this primarily consists of
Connection
,Room
, andUser
objects, again with addition of some supplementary structures reflecting the higher-level abstractions in the spec. Things are more structured and convenient for application programming here: instead of ids and string references likesender
you can deal with objects and application-level containers like a room with its timeline and transferred files, a user with the avatar, and so on. This layer is responsible for taking information from Events and making sense of them as much as it can (apply room state changes, record and make available messages, redact events upon receiving) as well as invoking Jobs where needed (sending message and state events, joining/leaving rooms etc.).
The following sections describe the foundational classes of the library in a bit more detail.
(This should become Doxygen instead)
Connection
is the starting point for any client implementation as of now. It connects and logs in
to the server, holds the identity of the connected user, runs the sync loop, maintains the list of known rooms
and users on a given homeserver. It's also responsible for construction and running the network requests
as of now (read on for the possible evolution of that).
It's possible to create several Connection
objects; they can connect to the same homeserver, and they can
even be for the same user with different access tokens - this has been a prominent feature of Quotient-backed
clients since very early days, unlike most other clients. But using two Connection
objects with the same
access token set for both, while possible, is calling for trouble: at the very least, request rate limiting
will not work as intended.
ConnectionData
is the actual carrier of the server connection and scheduler of Jobs (network requests)
execution. As of this writing, Connection
encapsulates ConnectionData
and the rest of the library can only
interact with the server through Connection
. Eventually ConnectionData
will likely become Connection
,
taking over all responsibilities related to CS API and being exposed to the rest of the library;
and Connection
will be renamed to something like Account
with responsibilities focused at
user account-wide operations.
Being on the lower level as described above, ConnectionData
is not aware of any application-level entities,
such as Rooms (that may slightly change as encryption in Matrix involves rooms quite heavily), Users and even
Events (also might budge a bit once encryption is implemented) - unlike Connection
that works as a factory
for Room
and User
objects and feeds rooms with events intended for them. Application-level signals
(like Connection::joinedRoom()
) are emitted after Events have been taken from the transport layer
(e.g., from SyncJob
) into the application layer (Room
) and processed there (Room::updateData()
).
Some of these signals are made specific for specific application-level objects (Room
has quite a few signals
on timeline changes) while others are broadcast on the Connection
scope.
(There's no plan, and in general it's not a good idea, to consolidate all application-level signals under one
mediation class because that means everybody should talk to this class with no apparent benefit received from
most of its interface; this class would become a huge registry of signals (there'd likely be 100+ signals
just to deal with API calls completion) and be a major architectural bottleneck affected with any CS API
change. Basically, it would implement a well-known "God class" antipattern. Early on when the library has just
been created, Connection
usurped a part of application layer. As a result, we have Connection::newRoom()
, Connection::joinedRoom()
etc. in it while, ideally, there should be a RoomManager
class that would have
those signals - and Connection
would do session handling and general dispatching instead.)
Note that to get anything useful on rooms or users you need to first wait until the first sync is entirely
complete - use Connection::syncDone()
for that. After that you can get, e.g., the list of rooms from
Connection::rooms()
.
As the name says, this is the base for job classes that represent API requests to a Matrix homeserver (generally
speaking, BaseJob
does not have much Matrix specifics and can be used to abstract any HTTP requests with
mostly but not only JSON payloads - but I digress). BaseJob
provides a means to validate the network request before submitting it, to actually initiate the request towards the homeserver, to check out the status of
the pending or completed request, and to load and process the results upon completion. It requires
ConnectionData
(see above) to execute the request, but not before that; you can create a job object and leave
it lying around until the right moment, at which point you pass it to Connection::run()
object in order
to schedule it for execution. This is less typical though, and most of jobs are short-lived: clients or
the library itself create and immediately submit job objects for execution using Connection::callApi
template.
After completing the request, successfully or unsuccessfully, a job object emits several signals:
-
finished()
- in any outcome -
success()
orfailure()
- specific to the outcome
success()
/failure()
are emitted after finished()
- this may be important in a situation when both
finished()
and success()
/failure()
have connected signals. Changes to internal library structures in
response to the job completion is usually done in finished()
and is connected as early as possible; however,
this may not be the case when different parts of the client code connect to the job completion in different ways.
The job object queues self-destruction after all connected slots have processed these signals. An important exception to that is a class of errors when retrying a request makes sense (mostly with a 5xx
HTTP code, including rate limiting). In that case, retryScheduled()
is emitted instead of all the signals mentioned
in the previous paragraph and the same job object is reused for a new network request with the same data. This
new request is performed after a certain cool-down period that is determined depending on the situation and
is increased with each subsequent retry. The default number of retries is 3 except SyncJob
that has no limit
of retries.
At any moment in the job lifecycle you can call BaseJob::status()
to check its status. if (job->status().good())
is an idiom to check if the job did not end up with an error (assuming the job object still exists - usually in the slot for BaseJobe::finished()
). The message
field in the status structure, or the BaseJob::errorString()
shortcut will give you a human readable description of the error, if there's one.
A job can be abandoned if the client no more cares about its result. It is generally recommended to abandon
no more needed jobs to cut on unneeded processing of the result. After calling BaseJob::abandon()
the object
closes its network request and briefly enters the respective Abandoned
status so that clients interested in
job housekeeping could clean up their records; then it self-destructs.
Some of job classes are written manually but most are generated from machine-readable API files provided in matrix-doc repo. The tool used for code generation is GTAD - see the respective section of CONTRIBUTING.md for more details.
(Since it doesn't seem to have been documented elsewhere within this repo - the reason for making a specialised tool instead of Swagger Codegen provided by Swagger/OpenAPI authors has been that, historically, Swagger Codegen produced terrible Qt5 code and the Java-based codebase was hefty and somewhat difficult to adapt to "extensions" that Matrix.org imposed on the API language, as well as to the whims of libQuotient lead developer on how network request classes should look like and what they should offer. GTAD is still far from being feature-complete even for Swagger 2; but the implemented Swagger subset is enough for it to serve the purposes of libQuotient, and GTAD has been used on a project for a few years now. Although Swagger Codegen considerably improved on the code emitted for a Qt5 client, GTAD remains - arguably - more flexible in terms of what code to generate, and is also much more lightweight.)
This is the base class for all Matrix event types, standard or custom, known or unknown to Quotient. Events
are usually received from the server but clients can create some of them in order to place a request to
the homeserver. In fact, there are three base classes, the next deriving from the previous: Event
, RoomEvent
and
StateEventBase
,
and one template class, StateEvent<>
, that derives from StateEventBase
. Their functions match what you
would expect from their names after reading the Client-Server API specification. StateEvent<>
is a bit
different from others in that it actually keeps the whole event content deserialised into a C++ object
(its template parameter), while other base event types expect derived classes to provide individual converting
accessors to each field in the JSON content object. This difference is mostly due to historical reasons;
however, one corollary of it is that it's slightly easier (thanks to more rigid checking by the compiler)
to correctly create specialisations of StateEvent<>
that would be convertible both ways (to JSON and
from JSON).
The system of events is extensible: clients can create new event types and, as long as they are defined
correctly, the library will create and handle event objects for these new types by the same token as for
the predefined types. Moreover, since Event
is effectively a wrapper around QJsonObject
, even if
an event type is totally unknown the library will attempt to make the best object for it (Event
, RoomEvent
or StateEventBase
), providing a means to read the event content as a raw JSON object, placing room events
in the right room and keeping state room events in the room's state map.
As of this writing, the library does not have a way of visualisation for Events. In the meantime, maybe the most comprehensive example to borrow from is the code of Quaternion and especially the entire contents of MessageEventModel::data().
This class encapsulates both the state and the timeline of a room (as seen through a given Connection
-
see above) and provides operations to interact with it.
There are a few touch points to handle receiving new events. You may again find the code of Quaternion's MessageEventModel to be a useful reference - this time its constructor, the part where all the signals are connected.
- To merely update the view with new messages, use
Room::addedMessages()
. - If you need to catch events before adding (that is necessary to call
beginInsertRows()
in Qt models, e.g.) there'sRoom::aboutToAddNewMessages()
("newest" edge of the timeline) andRoom::aboutToAddHistoricalMessages()
("oldest" edge of the timeline). The library does not, in principle, add events in the middle of the timeline. - Events can be hidden, replaced (with editing), redacted or otherwise updated - watch for
Room::updatedEvent()
in such cases. - Things become more entangled when you start sending messages and want to track pending events - that's
where all the
Room::pendingEvent*()
signals come useful.
This class represents a Matrix user (as seen through a given Connection
- see above), maintains its display names and avatars. Connection::user()
accessors return a somewhat special User
object representing the user identified by the connection's access token (aka "local user").
TODO: turn it to a minimalistic Matrix client
#include <connection.h>
#include <room.h>
#include <QCoreApplication>
int main(int argc, char* argv[]) {
if (argc < 4)
return -1;
QCoreApplication app(argc, argv);
const auto* userMxid = argv[1];
const auto* password = argv[2];
const auto* deviceName = argv[3];
using Quotient::Connection;
// You can create as many connections (to as many servers) as you need.
// When logging in via mxid and password the connection gets a new access token.
// Connection::connectWithToken() allows to connect using an existing access token.
auto* c = new Connection(&app);
c->connectToServer(userMxid, password, deviceName); // The homeserver is resolved from the user name
app.connect(c, &Connection::connected, c [c] {
qInfo() << "Connected, server: " << c->homeserver().toDisplayString();
c->syncLoop();
});
app.connect(c, &Connection::resolveError, c, [&](const QString& error) {
qInfo() << "Failed to resolve the server: " << error;
app.exit(-2);
});
// connectSingleShot() is Qt's connect() that triggers exactly once and then disconnects
Quotient::connectSingleShot(c, &Connection::syncDone, c, [c] {
const auto& allRooms = c->allRooms();
qInfo() << "Sync done;" << allRooms.count() << "room(s) and"
<< c->users().count() << "user(s) received";
for (auto* room: allRooms) {
qInfo() << "\nRoom display name:" << room->displayName()
<< "\nRoom topic:" << room->topic()
<< "\nJoined members:" << room->joinedCount() << "\n";
}
qInfo() << "That's all, thank you";
c->logout();
});
app.connect(c, &Connection::loggedOut, &app, &QCoreApplication::quit);
return app.exec(); // Start event loop through which things actually happen
}