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 : // Values are persisted in initial commits; keep them stable.
85 : enum class ConversationMode : int {
86 : ONE_TO_ONE = 0,
87 : ADMIN_INVITES_ONLY = 1,
88 : INVITES_ONLY = 2,
89 : PUBLIC = 3,
90 : DOCUMENT = 4,
91 : FEED = 5
92 : };
93 :
94 : /*
95 : * Jami conversations are stored as git repositories. Most of the information is contained
96 : * in the commit messages. With the exception of merge commits, the commit messages are JSON
97 : * objects with a fixed set of possible fields defined in the CommitKey namespace above. The
98 : * "type" field is mandatory and determines which other fields can be present as well as their
99 : * meaning.
100 : */
101 : struct CommitMessage
102 : {
103 : std::string type {};
104 : std::string body {};
105 : std::string replyTo {};
106 : std::string reactTo {};
107 : std::string editedId {};
108 : std::string action {};
109 : std::string uri {};
110 : std::string confId {};
111 : std::string device {};
112 : std::string duration {};
113 : std::string reason {};
114 : std::string to {};
115 : std::string tid {};
116 : std::string displayName {};
117 : int64_t totalSize {-1};
118 : std::string sha3sum {};
119 : int mode {-1};
120 : std::string invited {};
121 : std::string mimeType {};
122 : std::string parent {};
123 :
124 : // User messages are stored as commits of type "text/plain". The message text is in the "body"
125 : // field. For example:
126 : //
127 : // {"body":"Hello!","type":"text/plain"}
128 : //
129 : // When a user edits a message, the new message text is stored in the "body" field of a commit
130 : // of type "text/plain" (or "application/edited-message" in old versions of Jami), with an
131 : // additional "edit" field containing the ID of the commit being edited. For example:
132 : //
133 : // {"body":"Hello, how are you?","edit":"7de8a42695da4de31f774df7040893d34de0829d","type":"text/plain"}
134 : // {"body":"Hi!","edit":"81528f849e844b6b0ded23b92ed0fc8d06bc21a2","type":"application/edited-message"}
135 : //
136 : // If the same message is edited multiple times, the ID in the "edit" field always refers to
137 : // the original message, not the previous edit.
138 : //
139 : // A deleted message is represented by an edit with an empty "body", e.g.:
140 : //
141 : // {"body":"","edit":"7de8a42695da4de31f774df7040893d34de0829d","type":"text/plain"}
142 : //
143 : // Text messages can optionally include a "reply-to" field with the ID of the message being
144 : // replied to, e.g.:
145 : //
146 : // {"body":"You're right!","reply-to":"200779c99a3f6ed7efc2a83bdfddb6a9e45a4e55","type":"text/plain"}
147 : //
148 : // The "edit" and "reply-to" fields are mutually exclusive. When replying to an edited message,
149 : // the "reply-to" field contains the ID of the original message, not the edit.
150 : //
151 : // Reactions are also encoded as messages of type "text/plain" with a "react-to" field containing
152 : // the ID of the message being reacted to and the reaction itself in the "body" field, e.g.:
153 : //
154 : // {"body":"\ud83d\udc4d","react-to":"d433e037b32314bc3de2fad2ca4a12d914ecad57","type":"text/plain"}
155 : //
156 : // Removing a reaction is done the same way as deleting a message, i.e. as an edit with an
157 : // empty "body":
158 : //
159 : // {"body":"","edit":"9f5a429073f313d3edb149c61fbd682c6e0fc704","type":"text/plain"}
160 : //
161 : // The "react-to" field is mutually exclusive with the "edit" and "reply-to" fields.
162 306 : static CommitMessage text(const std::string& body, const std::string& replyToId = "")
163 : {
164 306 : CommitMessage msg;
165 306 : msg.type = CommitType::TEXT;
166 306 : msg.body = body;
167 306 : msg.replyTo = replyToId;
168 306 : return msg;
169 0 : }
170 103 : static CommitMessage reaction(const std::string& reaction, const std::string& reactToId)
171 : {
172 103 : CommitMessage msg;
173 103 : msg.type = CommitType::TEXT;
174 103 : msg.body = reaction;
175 103 : msg.reactTo = reactToId;
176 103 : return msg;
177 0 : }
178 207 : static CommitMessage edit(const std::string& newBody, const std::string& editedId)
179 : {
180 207 : CommitMessage msg;
181 207 : msg.type = CommitType::TEXT;
182 207 : msg.body = newBody;
183 207 : msg.editedId = editedId;
184 207 : return msg;
185 0 : }
186 :
187 : // Commits of type "member" always have an "action" field and a "uri" field. The "uri" field
188 : // contains the Jami ID of the user impacted by the action, which can be one of the following:
189 : // - "add": the user was invited to join the conversation
190 : // - "join": the user joined the conversation
191 : // - "remove": the user left the conversation
192 : // - "ban": the user was banned from the conversation
193 : // - "unban": the user was unbanned from the conversation
194 : // For example:
195 : //
196 : // {"action":"join","type":"member","uri":"f32701058c69f8ad6a095c6d14650294a4ba39a3"}
197 1123 : static CommitMessage member(const std::string& action, const std::string& memberId)
198 : {
199 1123 : CommitMessage msg;
200 1123 : msg.type = CommitType::MEMBER;
201 1123 : msg.action = action;
202 1123 : msg.uri = memberId;
203 1123 : return msg;
204 0 : }
205 :
206 : // Commits of type "application/call-history+json" represent either the beginning or the end
207 : // of a call. Their format differs depending on whether the call was started in a one-to-one
208 : // conversation or in a group conversation.
209 : //
210 : // In a one-to-one conversation, the user who initiated a call creates a commit once it ends.
211 : // The "duration" field contains the duration of the call in milliseconds, and the "to" field
212 : // contains the Jami ID of the called peer. For example:
213 : //
214 : // {"duration":"80805","to":"ff114e1934db7b79e4f7ac676cb943d97ffb6a32","type":"application/call-history+json"}
215 : //
216 : // If the call failed to start, the "reason" field may provide more information about the cause
217 : // of the failure. For example, the call may have been declined by the peer:
218 : //
219 : // {"duration":"0","reason":"declined","to":"ff114e1934db7b79e4f7ac676cb943d97ffb6a32","type":"application/call-history+json"}
220 : //
221 : // In a group conversation, the host creates a commit when the call starts, and another one
222 : // when it ends. Both commits include the following fields:
223 : // - "confId": a 64-bit unsigned integer identifying the call
224 : // - "device": the host's device ID
225 : // - "uri": the host's Jami ID
226 : // The end call commit additionally includes a "duration" field with the call duration in
227 : // milliseconds. A pair of start/end commits for a group call may look like this:
228 : //
229 : // {"confId":"6342183642926168","device":"c87dc5b688c0e6a7d1cd30fe5c2b4a24aa68d6387ebba9aa7cbb487419578ea1","type":"application/call-history+json","uri":"079ddd3b04f35f6381f2516315e6aa5b98d43ef4"}
230 : // {"confId":"6342183642926168","device":"c87dc5b688c0e6a7d1cd30fe5c2b4a24aa68d6387ebba9aa7cbb487419578ea1","duration":"9142","type":"application/call-history+json","uri":"079ddd3b04f35f6381f2516315e6aa5b98d43ef4"}
231 205 : static CommitMessage outgoingCallEnd(const std::string& peer, uint64_t duration_ms, const std::string& reason = "")
232 : {
233 205 : CommitMessage msg;
234 205 : msg.type = CommitType::CALL_HISTORY;
235 205 : msg.to = peer;
236 205 : msg.duration = std::to_string(duration_ms);
237 205 : msg.reason = reason;
238 205 : return msg;
239 0 : }
240 114 : static CommitMessage conferenceHostingStart(const std::string& confId,
241 : const std::string& device,
242 : const std::string& hostId)
243 : {
244 114 : CommitMessage msg;
245 114 : msg.type = CommitType::CALL_HISTORY;
246 114 : msg.confId = confId;
247 114 : msg.device = device;
248 114 : msg.uri = hostId;
249 114 : return msg;
250 0 : }
251 110 : static CommitMessage conferenceHostingEnd(const std::string& confId,
252 : const std::string& device,
253 : const std::string& hostId,
254 : uint64_t duration_ms)
255 : {
256 110 : CommitMessage msg;
257 110 : msg.type = CommitType::CALL_HISTORY;
258 110 : msg.confId = confId;
259 110 : msg.device = device;
260 110 : msg.uri = hostId;
261 110 : msg.duration = std::to_string(duration_ms);
262 110 : return msg;
263 0 : }
264 :
265 : // When a user sends a file in a conversation, a commit of type "application/data-transfer+json"
266 : // is created with the following fields:
267 : // - "displayName": the file name
268 : // - "sha3sum": the SHA3-512 hash of the file content, encoded as a hexadecimal string
269 : // - "tid": an ID for the file transfer (currently consists of a nonzero 64-bit unsigned integer
270 : // generated randomly by the sender, encoded as a decimal string)
271 : // - "totalSize": the file size in bytes
272 : // For example:
273 : //
274 : // {"displayName":"some_image.png","sha3sum":"5ce2fb16eb16c9dc42f824218ec0b7be4927d9f9fef9860161159faee1c4236a758aeb4ed98b27bf439364ea3199fce23181be4720c79756cf714271b702efcd","tid":"6147910008623250","totalSize":"581","type":"application/data-transfer+json"}
275 : //
276 : // File transfers can optionally include a "reply-to" field with the ID of the message being
277 : // replied to, e.g.:
278 : //
279 : // {"displayName":"aang.jpg","reply-to":"160e330e417401ecdd11094a8dad1355bd734583","sha3sum":"cae439cabde1dd86e15210f1f1486e7bbe0b901e9cb40d087b9673b7827ac5c9b2bf3d5fa3872666d418f205e986e8afa9977ddaba5e4141bdb157fd754656c2","tid":"693561489759880","totalSize":"89040","type":"application/data-transfer+json"}
280 : //
281 : // A deleted file is represented by a commit of type "application/data-transfer+json" with an "edit"
282 : // field containing the ID of the original commit and an empty "tid", e.g.:
283 : //
284 : // {"edit":"7fc3b0cba7e0742b0753051a576a5d17a77a57d0","tid":"","type":"application/data-transfer+json"}
285 219 : static CommitMessage fileSent(const std::string& displayName,
286 : const std::string& sha3sum,
287 : uint64_t tid,
288 : int64_t totalSize,
289 : const std::string& replyToId = "")
290 : {
291 219 : CommitMessage msg;
292 219 : msg.type = CommitType::DATA_TRANSFER;
293 219 : msg.displayName = displayName;
294 219 : msg.sha3sum = sha3sum;
295 219 : msg.replyTo = replyToId;
296 219 : msg.tid = std::to_string(tid);
297 219 : msg.totalSize = totalSize;
298 219 : return msg;
299 0 : }
300 104 : static CommitMessage fileDeleted(const std::string& fileCommitId)
301 : {
302 104 : CommitMessage msg;
303 104 : msg.type = CommitType::DATA_TRANSFER;
304 104 : msg.editedId = fileCommitId;
305 104 : return msg;
306 0 : }
307 :
308 : // A collaborative document is announced with a commit of type "application/collab-doc+json"
309 : // carrying its initial name in "displayName" and the media type of what it holds in "mimeType".
310 : // The "uri" field contains the ID of the document's repository. For example:
311 : //
312 : // {"displayName":"Notes","mimeType":"text/html","type":"application/collab-doc+json","uri":"332ba97d150dbad022c7d780af61d28ed25bf0a9"}
313 : //
314 : // A deleted document is represented by a commit of type "application/collab-doc+json" with an "edit"
315 : // field containing the ID of the original commit, e.g.:
316 : //
317 : // {"edit":"8a3828b5d0450f69988d84baaf0ded8b4614cd14","type":"application/collab-doc+json"}
318 125 : static CommitMessage collabDocCreated(const std::string& documentId,
319 : const std::string& name,
320 : const std::string& mimeType)
321 : {
322 125 : CommitMessage msg;
323 125 : msg.type = CommitType::COLLAB_DOC;
324 125 : msg.uri = documentId;
325 125 : msg.displayName = name;
326 125 : msg.mimeType = mimeType;
327 125 : return msg;
328 0 : }
329 102 : static CommitMessage collabDocRemoved(const std::string& announcementCommitId)
330 : {
331 102 : CommitMessage msg;
332 102 : msg.type = CommitType::COLLAB_DOC;
333 102 : msg.editedId = announcementCommitId;
334 102 : return msg;
335 0 : }
336 :
337 : // Every Jami conversation starts with a commit of type "initial" containing a "mode" field indicating the
338 : // kind of conversation. In the case of one-to-one conversations (mode 0), there is an additional "invited"
339 : // field containing the Jami ID of the other participant. For example:
340 : //
341 : // {"invited":"f32701048c59f9ad6a095c6d14650294b4cf30a4","mode":0,"type":"initial"}
342 : // {"mode":2,"type":"initial"}
343 : //
344 : // Jami allows users to create one-to-one conversations with themselves. In that case, the "invited" field is
345 : // still present and contains the user's own Jami ID.
346 564 : static CommitMessage initial(ConversationMode mode, const std::string& invitedId = "")
347 : {
348 564 : CommitMessage msg;
349 564 : msg.type = CommitType::INITIAL;
350 564 : msg.mode = static_cast<int>(mode);
351 564 : if (mode == ConversationMode::ONE_TO_ONE) {
352 377 : msg.invited = invitedId;
353 : }
354 564 : return msg;
355 0 : }
356 :
357 : // A collaborative document repository is a swarm exactly like a conversation, distinguished
358 : // by its mode: 4 (ConversationMode::DOCUMENT). Its initial commit additionally records the
359 : // ID of the conversation the document was announced in ("parent") and the media type of
360 : // what the document holds ("mimeType"), e.g.:
361 : //
362 : // {"mimeType":"text/html","mode":4,"parent":"f32701048c59f9ad6a095c6d14650294b4cf30a4","type":"initial"}
363 133 : static CommitMessage initialDocument(const std::string& parentConversationId, const std::string& mimeType)
364 : {
365 133 : CommitMessage msg;
366 133 : msg.type = CommitType::INITIAL;
367 133 : msg.mode = static_cast<int>(ConversationMode::DOCUMENT);
368 133 : msg.parent = parentConversationId;
369 133 : msg.mimeType = mimeType;
370 133 : return msg;
371 0 : }
372 :
373 : // A checkpoint persists a batch of CRDT updates in a document repository. The updates are
374 : // base64-encoded, one per line, in the "body" field:
375 : //
376 : // {"body":"AQHZ...\nAQLa...","type":"application/checkpoint"}
377 : //
378 : // A checkpoint that adds an attachment (an embedded file, stored in the repository tree
379 : // rather than in the message) carries no update, so its "body" is empty:
380 : //
381 : // {"body":"","type":"application/checkpoint"}
382 122 : static CommitMessage checkpoint(const std::vector<std::string>& base64Updates)
383 : {
384 122 : CommitMessage msg;
385 122 : msg.type = CommitType::CHECKPOINT;
386 453 : for (const auto& u : base64Updates) {
387 331 : if (!msg.body.empty())
388 214 : msg.body += '\n';
389 331 : msg.body += u;
390 : }
391 122 : return msg;
392 0 : }
393 :
394 : // Commits of type "vote" are created by admins when voting to ban or unban a user from a
395 : // conversation. They have a "uri" field with the Jami ID of the user being voted on, e.g.:
396 : //
397 : // {"type":"vote","uri":"f32701048c59f9ad6a095c6d14650294b4cf30a4"}
398 117 : static CommitMessage vote(const std::string& userId)
399 : {
400 117 : CommitMessage msg;
401 117 : msg.type = CommitType::VOTE;
402 117 : msg.uri = userId;
403 117 : return msg;
404 0 : }
405 :
406 : // Commits that modify a conversation's profile (which is stored in the 'profile.vcf' file at
407 : // the root of the conversation repository) are of type "application/update-profile". They
408 : // do not contain any additional fields:
409 : //
410 : // {"type":"application/update-profile"}
411 37 : static CommitMessage updateProfile()
412 : {
413 37 : CommitMessage msg;
414 37 : msg.type = CommitType::UPDATE_PROFILE;
415 37 : return msg;
416 0 : }
417 :
418 : Json::Value toJson() const;
419 : std::string toString() const;
420 : static std::optional<CommitMessage> fromString(const std::string& str);
421 : };
422 :
423 : } // namespace jami
|