Line data Source code
1 : /*
2 : * Copyright (C) 2004-2026 Savoir-faire Linux Inc.
3 : *
4 : * This program is free software: you can redistribute it and/or modify
5 : * it under the terms of the GNU General Public License as published by
6 : * the Free Software Foundation, either version 3 of the License, or
7 : * (at your option) any later version.
8 : *
9 : * This program is distributed in the hope that it will be useful,
10 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 : * GNU General Public License for more details.
13 : *
14 : * You should have received a copy of the GNU General Public License
15 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 : */
17 :
18 : #pragma once
19 :
20 : #include "json_utils.h"
21 :
22 : #include <optional>
23 : #include <string>
24 :
25 : namespace jami {
26 :
27 : namespace CommitKey {
28 : constexpr const char* const TYPE {"type"};
29 : constexpr const char* const BODY {"body"};
30 : constexpr const char* const REPLY_TO {"reply-to"};
31 : constexpr const char* const REACT_TO {"react-to"};
32 : constexpr const char* const EDIT {"edit"};
33 : constexpr const char* const ACTION {"action"};
34 : constexpr const char* const URI {"uri"};
35 : constexpr const char* const DEVICE {"device"};
36 : constexpr const char* const CONF_ID {"confId"};
37 : constexpr const char* const TO {"to"};
38 : constexpr const char* const REASON {"reason"};
39 : constexpr const char* const DURATION {"duration"};
40 : constexpr const char* const TID {"tid"};
41 : constexpr const char* const DISPLAY_NAME {"displayName"};
42 : constexpr const char* const TOTAL_SIZE {"totalSize"};
43 : constexpr const char* const SHA3SUM {"sha3sum"};
44 : constexpr const char* const MODE {"mode"};
45 : constexpr const char* const INVITED {"invited"};
46 : constexpr const char* const MIME_TYPE {"mimeType"};
47 : constexpr const char* const PARENT {"parent"};
48 : } // namespace CommitKey
49 :
50 : namespace CommitType {
51 : constexpr const char* const TEXT {"text/plain"};
52 : constexpr const char* const MEMBER {"member"};
53 : constexpr const char* const CALL_HISTORY {"application/call-history+json"};
54 : constexpr const char* const DATA_TRANSFER {"application/data-transfer+json"};
55 : constexpr const char* const INITIAL {"initial"};
56 : constexpr const char* const VOTE {"vote"};
57 : constexpr const char* const UPDATE_PROFILE {"application/update-profile"};
58 : constexpr const char* const MERGE {"merge"};
59 : // Announces a collaborative document. The document's content does not live in
60 : // the conversation: it has its own repository, synchronized separately. This
61 : // commit only makes the document appear in the history and binds its id to this
62 : // conversation.
63 : constexpr const char* const COLLAB_DOC {"application/collab-doc+json"};
64 : // Batch of CRDT updates in a collaborative document repository. The updates
65 : // travel in the commit message itself (base64, one per line in the "body"
66 : // field); the tree is carried over untouched, so a checkpoint costs one
67 : // commit object and nothing else. The exception is a checkpoint adding an
68 : // attachment: the file enters the tree and the "body" is empty. Only valid
69 : // in document repositories.
70 : constexpr const char* const CHECKPOINT {"application/checkpoint"};
71 : // Jami no longer creates messages of type "application/edited-message", but we
72 : // still need to be able to parse them for backward compatibility.
73 : constexpr const char* const EDITED_MESSAGE {"application/edited-message"};
74 : } // namespace CommitType
75 :
76 : namespace CommitAction {
77 : constexpr const char* const ADD {"add"};
78 : constexpr const char* const JOIN {"join"};
79 : constexpr const char* const REMOVE {"remove"};
80 : constexpr const char* const BAN {"ban"};
81 : constexpr const char* const UNBAN {"unban"};
82 : } // namespace CommitAction
83 :
84 : enum class ConversationMode : int { ONE_TO_ONE = 0, ADMIN_INVITES_ONLY, INVITES_ONLY, PUBLIC, DOCUMENT };
85 :
86 : /*
87 : * Jami conversations are stored as git repositories. Most of the information is contained
88 : * in the commit messages. With the exception of merge commits, the commit messages are JSON
89 : * objects with a fixed set of possible fields defined in the CommitKey namespace above. The
90 : * "type" field is mandatory and determines which other fields can be present as well as their
91 : * meaning.
92 : */
93 : struct CommitMessage
94 : {
95 : std::string type {};
96 : std::string body {};
97 : std::string replyTo {};
98 : std::string reactTo {};
99 : std::string editedId {};
100 : std::string action {};
101 : std::string uri {};
102 : std::string confId {};
103 : std::string device {};
104 : std::string duration {};
105 : std::string reason {};
106 : std::string to {};
107 : std::string tid {};
108 : std::string displayName {};
109 : int64_t totalSize {-1};
110 : std::string sha3sum {};
111 : int mode {-1};
112 : std::string invited {};
113 : std::string mimeType {};
114 : std::string parent {};
115 :
116 : // User messages are stored as commits of type "text/plain". The message text is in the "body"
117 : // field. For example:
118 : //
119 : // {"body":"Hello!","type":"text/plain"}
120 : //
121 : // When a user edits a message, the new message text is stored in the "body" field of a commit
122 : // of type "text/plain" (or "application/edited-message" in old versions of Jami), with an
123 : // additional "edit" field containing the ID of the commit being edited. For example:
124 : //
125 : // {"body":"Hello, how are you?","edit":"7de8a42695da4de31f774df7040893d34de0829d","type":"text/plain"}
126 : // {"body":"Hi!","edit":"81528f849e844b6b0ded23b92ed0fc8d06bc21a2","type":"application/edited-message"}
127 : //
128 : // If the same message is edited multiple times, the ID in the "edit" field always refers to
129 : // the original message, not the previous edit.
130 : //
131 : // A deleted message is represented by an edit with an empty "body", e.g.:
132 : //
133 : // {"body":"","edit":"7de8a42695da4de31f774df7040893d34de0829d","type":"text/plain"}
134 : //
135 : // Text messages can optionally include a "reply-to" field with the ID of the message being
136 : // replied to, e.g.:
137 : //
138 : // {"body":"You're right!","reply-to":"200779c99a3f6ed7efc2a83bdfddb6a9e45a4e55","type":"text/plain"}
139 : //
140 : // The "edit" and "reply-to" fields are mutually exclusive. When replying to an edited message,
141 : // the "reply-to" field contains the ID of the original message, not the edit.
142 : //
143 : // Reactions are also encoded as messages of type "text/plain" with a "react-to" field containing
144 : // the ID of the message being reacted to and the reaction itself in the "body" field, e.g.:
145 : //
146 : // {"body":"\ud83d\udc4d","react-to":"d433e037b32314bc3de2fad2ca4a12d914ecad57","type":"text/plain"}
147 : //
148 : // Removing a reaction is done the same way as deleting a message, i.e. as an edit with an
149 : // empty "body":
150 : //
151 : // {"body":"","edit":"9f5a429073f313d3edb149c61fbd682c6e0fc704","type":"text/plain"}
152 : //
153 : // The "react-to" field is mutually exclusive with the "edit" and "reply-to" fields.
154 298 : static CommitMessage text(const std::string& body, const std::string& replyToId = "")
155 : {
156 298 : CommitMessage msg;
157 298 : msg.type = CommitType::TEXT;
158 298 : msg.body = body;
159 298 : msg.replyTo = replyToId;
160 298 : return msg;
161 0 : }
162 103 : static CommitMessage reaction(const std::string& reaction, const std::string& reactToId)
163 : {
164 103 : CommitMessage msg;
165 103 : msg.type = CommitType::TEXT;
166 103 : msg.body = reaction;
167 103 : msg.reactTo = reactToId;
168 103 : return msg;
169 0 : }
170 205 : static CommitMessage edit(const std::string& newBody, const std::string& editedId)
171 : {
172 205 : CommitMessage msg;
173 205 : msg.type = CommitType::TEXT;
174 205 : msg.body = newBody;
175 205 : msg.editedId = editedId;
176 205 : return msg;
177 0 : }
178 :
179 : // Commits of type "member" always have an "action" field and a "uri" field. The "uri" field
180 : // contains the Jami ID of the user impacted by the action, which can be one of the following:
181 : // - "add": the user was invited to join the conversation
182 : // - "join": the user joined the conversation
183 : // - "remove": the user left the conversation
184 : // - "ban": the user was banned from the conversation
185 : // - "unban": the user was unbanned from the conversation
186 : // For example:
187 : //
188 : // {"action":"join","type":"member","uri":"f32701058c69f8ad6a095c6d14650294a4ba39a3"}
189 883 : static CommitMessage member(const std::string& action, const std::string& memberId)
190 : {
191 883 : CommitMessage msg;
192 883 : msg.type = CommitType::MEMBER;
193 883 : msg.action = action;
194 883 : msg.uri = memberId;
195 883 : return msg;
196 0 : }
197 :
198 : // Commits of type "application/call-history+json" represent either the beginning or the end
199 : // of a call. Their format differs depending on whether the call was started in a one-to-one
200 : // conversation or in a group conversation.
201 : //
202 : // In a one-to-one conversation, the user who initiated a call creates a commit once it ends.
203 : // The "duration" field contains the duration of the call in milliseconds, and the "to" field
204 : // contains the Jami ID of the called peer. For example:
205 : //
206 : // {"duration":"80805","to":"ff114e1934db7b79e4f7ac676cb943d97ffb6a32","type":"application/call-history+json"}
207 : //
208 : // If the call failed to start, the "reason" field may provide more information about the cause
209 : // of the failure. For example, the call may have been declined by the peer:
210 : //
211 : // {"duration":"0","reason":"declined","to":"ff114e1934db7b79e4f7ac676cb943d97ffb6a32","type":"application/call-history+json"}
212 : //
213 : // In a group conversation, the host creates a commit when the call starts, and another one
214 : // when it ends. Both commits include the following fields:
215 : // - "confId": a 64-bit unsigned integer identifying the call
216 : // - "device": the host's device ID
217 : // - "uri": the host's Jami ID
218 : // The end call commit additionally includes a "duration" field with the call duration in
219 : // milliseconds. A pair of start/end commits for a group call may look like this:
220 : //
221 : // {"confId":"6342183642926168","device":"c87dc5b688c0e6a7d1cd30fe5c2b4a24aa68d6387ebba9aa7cbb487419578ea1","type":"application/call-history+json","uri":"079ddd3b04f35f6381f2516315e6aa5b98d43ef4"}
222 : // {"confId":"6342183642926168","device":"c87dc5b688c0e6a7d1cd30fe5c2b4a24aa68d6387ebba9aa7cbb487419578ea1","duration":"9142","type":"application/call-history+json","uri":"079ddd3b04f35f6381f2516315e6aa5b98d43ef4"}
223 205 : static CommitMessage outgoingCallEnd(const std::string& peer, uint64_t duration_ms, const std::string& reason = "")
224 : {
225 205 : CommitMessage msg;
226 205 : msg.type = CommitType::CALL_HISTORY;
227 205 : msg.to = peer;
228 205 : msg.duration = std::to_string(duration_ms);
229 205 : msg.reason = reason;
230 205 : return msg;
231 0 : }
232 114 : static CommitMessage conferenceHostingStart(const std::string& confId,
233 : const std::string& device,
234 : const std::string& hostId)
235 : {
236 114 : CommitMessage msg;
237 114 : msg.type = CommitType::CALL_HISTORY;
238 114 : msg.confId = confId;
239 114 : msg.device = device;
240 114 : msg.uri = hostId;
241 114 : return msg;
242 0 : }
243 111 : static CommitMessage conferenceHostingEnd(const std::string& confId,
244 : const std::string& device,
245 : const std::string& hostId,
246 : uint64_t duration_ms)
247 : {
248 111 : CommitMessage msg;
249 111 : msg.type = CommitType::CALL_HISTORY;
250 111 : msg.confId = confId;
251 111 : msg.device = device;
252 111 : msg.uri = hostId;
253 111 : msg.duration = std::to_string(duration_ms);
254 111 : return msg;
255 0 : }
256 :
257 : // When a user sends a file in a conversation, a commit of type "application/data-transfer+json"
258 : // is created with the following fields:
259 : // - "displayName": the file name
260 : // - "sha3sum": the SHA3-512 hash of the file content, encoded as a hexadecimal string
261 : // - "tid": an ID for the file transfer (currently consists of a nonzero 64-bit unsigned integer
262 : // generated randomly by the sender, encoded as a decimal string)
263 : // - "totalSize": the file size in bytes
264 : // For example:
265 : //
266 : // {"displayName":"some_image.png","sha3sum":"5ce2fb16eb16c9dc42f824218ec0b7be4927d9f9fef9860161159faee1c4236a758aeb4ed98b27bf439364ea3199fce23181be4720c79756cf714271b702efcd","tid":"6147910008623250","totalSize":"581","type":"application/data-transfer+json"}
267 : //
268 : // File transfers can optionally include a "reply-to" field with the ID of the message being
269 : // replied to, e.g.:
270 : //
271 : // {"displayName":"aang.jpg","reply-to":"160e330e417401ecdd11094a8dad1355bd734583","sha3sum":"cae439cabde1dd86e15210f1f1486e7bbe0b901e9cb40d087b9673b7827ac5c9b2bf3d5fa3872666d418f205e986e8afa9977ddaba5e4141bdb157fd754656c2","tid":"693561489759880","totalSize":"89040","type":"application/data-transfer+json"}
272 : //
273 : // A deleted file is represented by a commit of type "application/data-transfer+json" with an "edit"
274 : // field containing the ID of the original commit and an empty "tid", e.g.:
275 : //
276 : // {"edit":"7fc3b0cba7e0742b0753051a576a5d17a77a57d0","tid":"","type":"application/data-transfer+json"}
277 214 : static CommitMessage fileSent(const std::string& displayName,
278 : const std::string& sha3sum,
279 : uint64_t tid,
280 : int64_t totalSize,
281 : const std::string& replyToId = "")
282 : {
283 214 : CommitMessage msg;
284 214 : msg.type = CommitType::DATA_TRANSFER;
285 214 : msg.displayName = displayName;
286 214 : msg.sha3sum = sha3sum;
287 214 : msg.replyTo = replyToId;
288 214 : msg.tid = std::to_string(tid);
289 214 : msg.totalSize = totalSize;
290 214 : return msg;
291 0 : }
292 102 : static CommitMessage fileDeleted(const std::string& fileCommitId)
293 : {
294 102 : CommitMessage msg;
295 102 : msg.type = CommitType::DATA_TRANSFER;
296 102 : msg.editedId = fileCommitId;
297 102 : return msg;
298 0 : }
299 :
300 : // A collaborative document is announced with a commit of type "application/collab-doc+json"
301 : // carrying its initial name in "displayName" and the media type of what it holds in "mimeType".
302 : // The "uri" field contains the ID of the document's repository. For example:
303 : //
304 : // {"displayName":"Notes","mimeType":"text/html","type":"application/collab-doc+json","uri":"332ba97d150dbad022c7d780af61d28ed25bf0a9"}
305 : //
306 : // A deleted document is represented by a commit of type "application/collab-doc+json" with an "edit"
307 : // field containing the ID of the original commit, e.g.:
308 : //
309 : // {"edit":"8a3828b5d0450f69988d84baaf0ded8b4614cd14","type":"application/collab-doc+json"}
310 116 : static CommitMessage collabDocCreated(const std::string& documentId,
311 : const std::string& name,
312 : const std::string& mimeType)
313 : {
314 116 : CommitMessage msg;
315 116 : msg.type = CommitType::COLLAB_DOC;
316 116 : msg.uri = documentId;
317 116 : msg.displayName = name;
318 116 : msg.mimeType = mimeType;
319 116 : return msg;
320 0 : }
321 101 : static CommitMessage collabDocRemoved(const std::string& announcementCommitId)
322 : {
323 101 : CommitMessage msg;
324 101 : msg.type = CommitType::COLLAB_DOC;
325 101 : msg.editedId = announcementCommitId;
326 101 : return msg;
327 0 : }
328 :
329 : // Every Jami conversation starts with a commit of type "initial" containing a "mode" field indicating the
330 : // kind of conversation. There are currently two supported values for the mode: 0 (ConversationMode::ONE_TO_ONE)
331 : // and 2 (ConversationMode::INVITES_ONLY). In the case of one-to-one conversations (mode 0), there is an
332 : // additional "invited" field containing the Jami ID of the other participant. For example:
333 : //
334 : // {"invited":"f32701048c59f9ad6a095c6d14650294b4cf30a4","mode":0,"type":"initial"}
335 : // {"mode":2,"type":"initial"}
336 : //
337 : // Jami allows users to create one-to-one conversations with themselves. In that case, the "invited" field is
338 : // still present and contains the user's own Jami ID.
339 306 : static CommitMessage initial(ConversationMode mode, const std::string& invitedId = "")
340 : {
341 306 : CommitMessage msg;
342 306 : msg.type = CommitType::INITIAL;
343 306 : msg.mode = static_cast<int>(mode);
344 306 : if (mode == ConversationMode::ONE_TO_ONE) {
345 148 : msg.invited = invitedId;
346 : }
347 306 : return msg;
348 0 : }
349 :
350 : // A collaborative document repository is a swarm exactly like a conversation, distinguished
351 : // by its mode: 4 (ConversationMode::DOCUMENT). Its initial commit additionally records the
352 : // ID of the conversation the document was announced in ("parent") and the media type of
353 : // what the document holds ("mimeType"), e.g.:
354 : //
355 : // {"mimeType":"text/html","mode":4,"parent":"f32701048c59f9ad6a095c6d14650294b4cf30a4","type":"initial"}
356 122 : static CommitMessage initialDocument(const std::string& parentConversationId, const std::string& mimeType)
357 : {
358 122 : CommitMessage msg;
359 122 : msg.type = CommitType::INITIAL;
360 122 : msg.mode = static_cast<int>(ConversationMode::DOCUMENT);
361 122 : msg.parent = parentConversationId;
362 122 : msg.mimeType = mimeType;
363 122 : return msg;
364 0 : }
365 :
366 : // A checkpoint persists a batch of CRDT updates in a document repository. The updates are
367 : // base64-encoded, one per line, in the "body" field:
368 : //
369 : // {"body":"AQHZ...\nAQLa...","type":"application/checkpoint"}
370 : //
371 : // A checkpoint that adds an attachment (an embedded file, stored in the repository tree
372 : // rather than in the message) carries no update, so its "body" is empty:
373 : //
374 : // {"body":"","type":"application/checkpoint"}
375 114 : static CommitMessage checkpoint(const std::vector<std::string>& base64Updates)
376 : {
377 114 : CommitMessage msg;
378 114 : msg.type = CommitType::CHECKPOINT;
379 438 : for (const auto& u : base64Updates) {
380 324 : if (!msg.body.empty())
381 214 : msg.body += '\n';
382 324 : msg.body += u;
383 : }
384 114 : return msg;
385 0 : }
386 :
387 : // Commits of type "vote" are created by admins when voting to ban or unban a user from a
388 : // conversation. They have a "uri" field with the Jami ID of the user being voted on, e.g.:
389 : //
390 : // {"type":"vote","uri":"f32701048c59f9ad6a095c6d14650294b4cf30a4"}
391 117 : static CommitMessage vote(const std::string& userId)
392 : {
393 117 : CommitMessage msg;
394 117 : msg.type = CommitType::VOTE;
395 117 : msg.uri = userId;
396 117 : return msg;
397 0 : }
398 :
399 : // Commits that modify a conversation's profile (which is stored in the 'profile.vcf' file at
400 : // the root of the conversation repository) are of type "application/update-profile". They
401 : // do not contain any additional fields:
402 : //
403 : // {"type":"application/update-profile"}
404 27 : static CommitMessage updateProfile()
405 : {
406 27 : CommitMessage msg;
407 27 : msg.type = CommitType::UPDATE_PROFILE;
408 27 : return msg;
409 0 : }
410 :
411 : Json::Value toJson() const;
412 : std::string toString() const;
413 : static std::optional<CommitMessage> fromString(const std::string& str);
414 : };
415 :
416 : } // namespace jami
|