Skip to main content

nysiris_chat_core/
lib.rs

1#![warn(missing_docs)]
2//! P2P chat engine for native Nysiris clients.
3//!
4//! Supports sealed 1:1 DMs and capability-key rooms with client-side fan-out.
5//! There is no chat service and no server history; every message is stored in
6//! the local encrypted history journal.
7
8use std::collections::BTreeMap;
9use std::sync::Arc;
10
11use nysiris_chat_history::History;
12use nysiris_core::contact::Contact;
13use nysiris_core::conversation::{ConversationSummary, GroupSummary};
14use nysiris_core::crypto::{SealedDm, DM_AAD};
15use nysiris_core::error::{CoreError, Result};
16use nysiris_core::group::{
17    open_group, pack_group_inner, seal_group, unpack_group_inner, Group, GroupMember,
18    SealedGroupMessage,
19};
20use nysiris_core::identity::ChatIdentity;
21use nysiris_core::ids::{dm_conversation_id, now_ms, random_hex};
22use nysiris_core::message::{pack_dm_inner, unpack_dm_inner, ChatMessage};
23use nysiris_core::transport::Transport;
24use serde::Serialize;
25
26/// Outcome of a room send: what went out and who accepted it.
27#[derive(Clone, Debug, Serialize)]
28pub struct GroupSendReport {
29    /// The message recorded in local history.
30    pub message: ChatMessage,
31    /// Number of members the transport accepted the payload for.
32    pub delivered: usize,
33    /// Per-member failures, formatted as `<petname>: <error>`.
34    pub failed: Vec<String>,
35}
36
37/// The chat engine: contacts, rooms, history, and a pluggable transport.
38pub struct ChatEngine {
39    identity: ChatIdentity,
40    contacts: Vec<Contact>,
41    groups: Vec<Group>,
42    history: History,
43    transport: Arc<dyn Transport>,
44    own_nym_address: String,
45    status: String,
46}
47
48impl ChatEngine {
49    /// Build an engine from already-loaded state.
50    ///
51    /// `own_nym_address` is this device's Nym address; an empty string means the
52    /// engine is running against a mock transport.
53    pub fn new(
54        identity: ChatIdentity,
55        contacts: Vec<Contact>,
56        groups: Vec<Group>,
57        history: History,
58        transport: Arc<dyn Transport>,
59        own_nym_address: impl Into<String>,
60    ) -> Self {
61        Self {
62            identity,
63            contacts,
64            groups,
65            history,
66            transport,
67            own_nym_address: own_nym_address.into(),
68            status: "online".into(),
69        }
70    }
71
72    /// This device's chat identity.
73    pub fn identity(&self) -> &ChatIdentity {
74        &self.identity
75    }
76
77    /// Saved contacts, sorted by petname.
78    pub fn contacts(&self) -> &[Contact] {
79        &self.contacts
80    }
81
82    /// Rooms known on this device.
83    pub fn groups(&self) -> &[Group] {
84        &self.groups
85    }
86
87    /// Look up a room by id.
88    pub fn group(&self, group_id: &str) -> Option<&Group> {
89        let id = group_id.trim().to_lowercase();
90        self.groups.iter().find(|g| g.id == id)
91    }
92
93    /// The local encrypted history store.
94    pub fn history(&self) -> &History {
95        &self.history
96    }
97
98    /// Short name of the transport in use.
99    pub fn transport_name(&self) -> &'static str {
100        self.transport.name()
101    }
102
103    /// Current self status: `online`, `away`, `dnd`, or `invisible`.
104    pub fn status(&self) -> &str {
105        &self.status
106    }
107
108    /// Set self status. Status is local; it is not broadcast to contacts.
109    pub fn set_status(&mut self, status: &str) -> Result<()> {
110        let status = status.trim();
111        if !matches!(status, "online" | "away" | "dnd" | "invisible") {
112            return Err(CoreError::InvalidInput(
113                "status must be online, away, dnd, or invisible".into(),
114            ));
115        }
116        self.status = status.to_string();
117        Ok(())
118    }
119
120    /// Look up a saved contact by public key.
121    pub fn contact(&self, pubkey: &str) -> Option<&Contact> {
122        let key = pubkey.trim().to_lowercase();
123        self.contacts.iter().find(|c| c.pubkey == key)
124    }
125
126    /// Add a contact, or update its petname/Nym address when the key exists.
127    pub fn add_contact(&mut self, contact: Contact) -> Contact {
128        if let Some(existing) = self
129            .contacts
130            .iter_mut()
131            .find(|c| c.pubkey == contact.pubkey)
132        {
133            existing.petname = contact.petname;
134            existing.nym_address = contact.nym_address;
135            return existing.clone();
136        }
137        self.contacts.push(contact.clone());
138        self.contacts.sort_by(|a, b| a.petname.cmp(&b.petname));
139        contact
140    }
141
142    /// Remove a contact by public key. Returns whether anything was removed.
143    pub fn remove_contact(&mut self, pubkey: &str) -> bool {
144        let key = pubkey.trim().to_lowercase();
145        let before = self.contacts.len();
146        self.contacts.retain(|c| c.pubkey != key);
147        self.contacts.len() != before
148    }
149
150    fn own_member(&self) -> Result<GroupMember> {
151        let address = if self.own_nym_address.trim().is_empty() {
152            format!("mock:{}", self.identity.public_hex())
153        } else {
154            self.own_nym_address.clone()
155        };
156        let pubkey = self.identity.public_hex();
157        let label = format!("{}…", &pubkey[..8]);
158        GroupMember::new(&label, &pubkey, &address)
159    }
160
161    /// Create a room, including the selected saved contacts and this identity.
162    /// Returns the room and the signed invite JSON for the other members.
163    pub fn create_group(
164        &mut self,
165        name: &str,
166        member_pubkeys: &[String],
167    ) -> Result<(Group, String)> {
168        let mut members = Vec::new();
169        for pubkey in member_pubkeys {
170            let contact = self
171                .contact(pubkey)
172                .ok_or_else(|| CoreError::NotFound(format!("contact {pubkey} is not saved")))?;
173            members.push(GroupMember::from_contact(contact)?);
174        }
175        let group = Group::create(name, members, self.own_member()?)?;
176        let invite = group.invite(&self.identity)?;
177        self.groups.push(group.clone());
178        Ok((group, invite))
179    }
180
181    /// Join a room from an invite. The invite must include this chat identity.
182    pub fn join_group(&mut self, invite_json: &str) -> Result<Group> {
183        let group = Group::from_invite(invite_json, &self.identity.public_hex())?;
184        if let Some(existing) = self.group(&group.id) {
185            return Ok(existing.clone());
186        }
187        self.groups.push(group.clone());
188        Ok(group)
189    }
190
191    /// Forget a room locally. Returns whether anything was removed. This does
192    /// not notify other members and does not rotate the room key.
193    pub fn remove_group(&mut self, group_id: &str) -> bool {
194        let id = group_id.trim().to_lowercase();
195        let before = self.groups.len();
196        self.groups.retain(|g| g.id != id);
197        self.groups.len() != before
198    }
199
200    /// Re-export the signed invite for a saved room, to hand to a new member.
201    pub fn group_invite(&self, group_id: &str) -> Result<String> {
202        let group = self
203            .group(group_id)
204            .ok_or_else(|| CoreError::NotFound("room is not saved".into()))?;
205        group.invite(&self.identity)
206    }
207
208    /// Send a 1:1 message. The transport receives the sealed envelope; the
209    /// plaintext only exists in memory and in the encrypted history journal.
210    pub fn send_dm(&mut self, peer_pub: &str, body: &str) -> Result<ChatMessage> {
211        let contact = self
212            .contact(peer_pub)
213            .cloned()
214            .ok_or_else(|| CoreError::NotFound("contact is not saved".into()))?;
215
216        let msg_id = random_hex(16);
217        let ts = now_ms();
218        let inner = pack_dm_inner(&self.identity, &contact.pubkey, &msg_id, ts, body)?;
219        let sealed = self
220            .identity
221            .seal_to(&contact.pubkey, DM_AAD, inner.as_bytes())?;
222        let payload = serde_json::to_vec(&sealed)?;
223
224        let message = ChatMessage::outgoing(
225            &self.identity.public_hex(),
226            &contact.pubkey,
227            &msg_id,
228            body,
229            ts,
230        );
231
232        // Send first: a failed transport must not look like a delivered message.
233        self.transport.send(&contact.nym_address, payload)?;
234        self.history.append(&message)?;
235        Ok(message)
236    }
237
238    /// Fan out one sealed room message to every member except this identity.
239    pub fn send_group(&mut self, group_id: &str, body: &str) -> Result<GroupSendReport> {
240        let group = self
241            .group(group_id)
242            .cloned()
243            .ok_or_else(|| CoreError::NotFound("room is not saved".into()))?;
244        let msg_id = random_hex(16);
245        let ts = now_ms();
246        let inner = pack_group_inner(&self.identity, &group.id, &msg_id, ts, body)?;
247        let sealed = seal_group(&group.key, &group.id, &msg_id, &inner)?;
248        let payload = serde_json::to_vec(&sealed)?;
249
250        let own_pub = self.identity.public_hex();
251        let mut delivered = 0usize;
252        let mut failed = Vec::new();
253        for member in &group.members {
254            if member.pubkey == own_pub {
255                continue;
256            }
257            match self.transport.send(&member.nym_address, payload.clone()) {
258                Ok(()) => delivered += 1,
259                Err(err) => failed.push(format!("{}: {err}", member.petname)),
260            }
261        }
262        if delivered == 0 {
263            return Err(CoreError::Transport(format!(
264                "room message was not delivered to any member ({})",
265                failed.join("; ")
266            )));
267        }
268
269        let message = ChatMessage::outgoing_group(&own_pub, &group.id, &msg_id, body, ts);
270        self.history.append(&message)?;
271        Ok(GroupSendReport {
272            message,
273            delivered,
274            failed,
275        })
276    }
277
278    /// Open and verify an inbound sealed DM. Returns `None` for a duplicate.
279    pub fn receive(&mut self, payload: &[u8]) -> Result<Option<ChatMessage>> {
280        let sealed: SealedDm = serde_json::from_slice(payload)?;
281        let plain = self.identity.open_sealed(&sealed, DM_AAD)?;
282        let inner_json = String::from_utf8(plain)
283            .map_err(|_| CoreError::InvalidInput("DM inner is not UTF-8".into()))?;
284        let inner = unpack_dm_inner(&inner_json, &self.identity.public_hex())?;
285
286        let message =
287            ChatMessage::incoming(&inner.from, &inner.to, &inner.msg_id, &inner.body, inner.ts);
288        if !self.history.append(&message)? {
289            return Ok(None);
290        }
291        Ok(Some(message))
292    }
293
294    /// Dispatch an inbound payload by envelope shape: room messages carry a
295    /// `gid`, DMs carry the sealed-box `epub`.
296    pub fn receive_any(&mut self, payload: &[u8]) -> Result<Option<ChatMessage>> {
297        let value: serde_json::Value = serde_json::from_slice(payload)?;
298        if value.get("gid").is_some() {
299            self.receive_group(payload)
300        } else {
301            self.receive(payload)
302        }
303    }
304
305    /// Open and verify an inbound sealed room message. Returns `None` for a
306    /// duplicate.
307    pub fn receive_group(&mut self, payload: &[u8]) -> Result<Option<ChatMessage>> {
308        let sealed: SealedGroupMessage = serde_json::from_slice(payload)?;
309        let group = self
310            .group(&sealed.gid)
311            .cloned()
312            .ok_or_else(|| CoreError::NotFound("room is not known on this device".into()))?;
313        let inner_json = open_group(&group.key, &sealed)?;
314        let inner = unpack_group_inner(&inner_json, &group.id)?;
315        let message = ChatMessage::incoming_group(
316            &inner.from,
317            &group.id,
318            &inner.msg_id,
319            &inner.body,
320            inner.ts,
321        );
322        if !self.history.append(&message)? {
323            return Ok(None);
324        }
325        Ok(Some(message))
326    }
327
328    /// Test/demo helper: seal a message from `sender` to this engine and feed it
329    /// through the normal DM receive path.
330    pub fn deliver_demo(
331        &mut self,
332        sender: &ChatIdentity,
333        body: &str,
334    ) -> Result<Option<ChatMessage>> {
335        let msg_id = random_hex(16);
336        let ts = now_ms();
337        let inner = pack_dm_inner(sender, &self.identity.public_hex(), &msg_id, ts, body)?;
338        let sealed = sender.seal_to(&self.identity.public_hex(), DM_AAD, inner.as_bytes())?;
339        let payload = serde_json::to_vec(&sealed)?;
340        self.receive(&payload)
341    }
342
343    /// Test/demo helper for rooms: sign with `sender`, seal with the room key,
344    /// then run the normal group receive path.
345    pub fn deliver_demo_group(
346        &mut self,
347        sender: &ChatIdentity,
348        group_id: &str,
349        body: &str,
350    ) -> Result<Option<ChatMessage>> {
351        let group = self
352            .group(group_id)
353            .cloned()
354            .ok_or_else(|| CoreError::NotFound("room is not saved".into()))?;
355        let msg_id = random_hex(16);
356        let ts = now_ms();
357        let inner = pack_group_inner(sender, &group.id, &msg_id, ts, body)?;
358        let sealed = seal_group(&group.key, &group.id, &msg_id, &inner)?;
359        let payload = serde_json::to_vec(&sealed)?;
360        self.receive_group(&payload)
361    }
362
363    /// Newest-last messages for the DM with `peer_pub`. `limit` of `0` returns
364    /// everything.
365    pub fn history_for(&self, peer_pub: &str, limit: usize) -> Vec<ChatMessage> {
366        self.history.list(&dm_conversation_id(peer_pub), limit)
367    }
368
369    /// Newest-last messages for a room. `limit` of `0` returns everything.
370    pub fn group_history(&self, group_id: &str, limit: usize) -> Vec<ChatMessage> {
371        let id = group_id.trim().to_lowercase();
372        self.history.list(&format!("group:{id}"), limit)
373    }
374
375    /// Direct-message conversations, most recent first. Rooms are excluded;
376    /// use [`ChatEngine::group_summaries`] for those.
377    pub fn conversations(&self) -> Vec<ConversationSummary> {
378        let mut grouped: BTreeMap<String, Vec<&ChatMessage>> = BTreeMap::new();
379        for message in self.history.all() {
380            if message.conversation_id.starts_with("group:") {
381                continue;
382            }
383            grouped
384                .entry(message.conversation_id.clone())
385                .or_default()
386                .push(message);
387        }
388
389        let mut out = Vec::with_capacity(grouped.len());
390        for (id, messages) in grouped {
391            let Some(last) = messages.last() else {
392                continue;
393            };
394            let peer_pub = last.peer_pub().to_string();
395            out.push(ConversationSummary {
396                id,
397                petname: self.contact(&peer_pub).map(|c| c.petname.clone()),
398                peer_pub,
399                last_message: Some((*last).clone()),
400                message_count: messages.len(),
401            });
402        }
403        out.sort_by(|a, b| {
404            let a_at = a.last_message.as_ref().map(|m| m.at).unwrap_or(0);
405            let b_at = b.last_message.as_ref().map(|m| m.at).unwrap_or(0);
406            b_at.cmp(&a_at)
407        });
408        out
409    }
410
411    /// Rooms with their local member list and last message, most recent first.
412    pub fn group_summaries(&self) -> Vec<GroupSummary> {
413        let mut out: Vec<GroupSummary> = self
414            .groups
415            .iter()
416            .map(|group| {
417                let messages = self.history.list(&group.conversation_id(), 0);
418                GroupSummary {
419                    id: group.id.clone(),
420                    name: group.name.clone(),
421                    member_count: group.members.len(),
422                    members: group.members.clone(),
423                    last_message: messages.last().cloned(),
424                    message_count: messages.len(),
425                }
426            })
427            .collect();
428        out.sort_by(|a, b| {
429            let a_at = a.last_message.as_ref().map(|m| m.at).unwrap_or(0);
430            let b_at = b.last_message.as_ref().map(|m| m.at).unwrap_or(0);
431            b_at.cmp(&a_at)
432        });
433        out
434    }
435
436    /// Delete every stored message from the history journals.
437    pub fn clear_history(&mut self) -> Result<()> {
438        self.history.clear_all()
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use nysiris_core::contact::Contact;
446    use nysiris_testkit::MemoryTransport;
447    use tempfile::TempDir;
448
449    fn engine(seed: u8, dir: &TempDir) -> (ChatEngine, MemoryTransport) {
450        let identity = ChatIdentity::from_secret_bytes([seed; 32]);
451        let history = History::open(dir.path(), [seed; 32]).unwrap();
452        let transport = MemoryTransport::new();
453        let own_address = format!("peer{seed}.enc@gw");
454        (
455            ChatEngine::new(
456                identity,
457                vec![],
458                vec![],
459                history,
460                Arc::new(transport.clone()),
461                own_address,
462            ),
463            transport,
464        )
465    }
466
467    #[test]
468    fn two_engines_exchange_a_sealed_dm() {
469        let a_dir = TempDir::new().unwrap();
470        let b_dir = TempDir::new().unwrap();
471        let (mut alice, alice_tx) = engine(1, &a_dir);
472        let (mut bob, _bob_tx) = engine(2, &b_dir);
473
474        alice.add_contact(Contact::new("Bob", &bob.identity().public_hex(), "bob.enc@gw").unwrap());
475        bob.add_contact(
476            Contact::new("Alice", &alice.identity().public_hex(), "alice.enc@gw").unwrap(),
477        );
478
479        let sent = alice
480            .send_dm(&bob.identity().public_hex(), "hello bob")
481            .unwrap();
482        assert_eq!(sent.direction, nysiris_core::message::Direction::Outgoing);
483        let outbound = alice_tx.take();
484        assert_eq!(outbound.len(), 1);
485
486        let received = bob.receive(&outbound[0].payload).unwrap().unwrap();
487        assert_eq!(received.body, "hello bob");
488        assert_eq!(
489            received.direction,
490            nysiris_core::message::Direction::Incoming
491        );
492        assert_eq!(bob.history_for(&alice.identity().public_hex(), 0).len(), 1);
493        assert_eq!(bob.conversations().len(), 1);
494    }
495
496    #[test]
497    fn duplicate_delivery_is_dropped() {
498        let dir = TempDir::new().unwrap();
499        let (mut bob, _) = engine(2, &dir);
500        let alice = ChatIdentity::from_secret_bytes([1u8; 32]);
501        bob.deliver_demo(&alice, "once").unwrap();
502
503        let msg = bob.history_for(&alice.public_hex(), 0).remove(0);
504        assert!(bob.history().contains(&msg.msg_id));
505    }
506
507    #[test]
508    fn wrong_recipient_cannot_open_the_dm() {
509        let a_dir = TempDir::new().unwrap();
510        let b_dir = TempDir::new().unwrap();
511        let (mut alice, alice_tx) = engine(1, &a_dir);
512        let (mut bob, _) = engine(2, &b_dir);
513
514        let alice_id = alice.identity().public_hex();
515        let bob_id = bob.identity().public_hex();
516        alice.add_contact(Contact::new("Bob", &bob_id, "bob.enc@gw").unwrap());
517        bob.add_contact(Contact::new("Alice", &alice_id, "alice.enc@gw").unwrap());
518
519        alice.send_dm(&bob_id, "for bob").unwrap();
520        let outbound = alice_tx.take();
521        let carol = ChatIdentity::from_secret_bytes([3u8; 32]);
522        let sealed: SealedDm = serde_json::from_slice(&outbound[0].payload).unwrap();
523        assert!(carol.open_sealed(&sealed, DM_AAD).is_err());
524    }
525
526    #[test]
527    fn room_invite_fanout_and_history_round_trip() {
528        let a_dir = TempDir::new().unwrap();
529        let b_dir = TempDir::new().unwrap();
530        let (mut alice, alice_tx) = engine(1, &a_dir);
531        let (mut bob, bob_tx) = engine(2, &b_dir);
532        let alice_pub = alice.identity().public_hex();
533        let bob_pub = bob.identity().public_hex();
534        alice.add_contact(Contact::new("Bob", &bob_pub, "bob.enc@gw").unwrap());
535        bob.add_contact(Contact::new("Alice", &alice_pub, "alice.enc@gw").unwrap());
536
537        let (group, invite) = alice
538            .create_group("Room", std::slice::from_ref(&bob_pub))
539            .unwrap();
540        let joined = bob.join_group(&invite).unwrap();
541        assert_eq!(joined.id, group.id);
542        assert_eq!(bob.group_summaries().len(), 1);
543
544        let report = alice.send_group(&group.id, "hello room").unwrap();
545        assert_eq!(report.delivered, 1);
546        assert!(report.failed.is_empty());
547        let outbound = alice_tx.take();
548        assert_eq!(outbound.len(), 1);
549
550        let received = bob.receive_any(&outbound[0].payload).unwrap().unwrap();
551        assert_eq!(received.body, "hello room");
552        assert_eq!(received.conversation_id, format!("group:{}", group.id));
553        assert_eq!(bob.group_history(&group.id, 0).len(), 1);
554
555        let reply = bob.send_group(&group.id, "hello back").unwrap();
556        assert_eq!(reply.delivered, 1);
557        let outbound = bob_tx.take();
558        let received = alice.receive_any(&outbound[0].payload).unwrap().unwrap();
559        assert_eq!(received.body, "hello back");
560        assert_eq!(alice.group_history(&group.id, 0).len(), 2);
561        assert_eq!(alice.conversations().len(), 0);
562    }
563}