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        if self.contact(&inner.from).is_none() {
286            return Err(CoreError::InvalidInput(
287                "DM sender is not a saved contact".into(),
288            ));
289        }
290
291        let message =
292            ChatMessage::incoming(&inner.from, &inner.to, &inner.msg_id, &inner.body, inner.ts);
293        if !self.history.append(&message)? {
294            return Ok(None);
295        }
296        Ok(Some(message))
297    }
298
299    /// Dispatch an inbound payload by envelope shape: room messages carry a
300    /// `gid`, DMs carry the sealed-box `epub`.
301    pub fn receive_any(&mut self, payload: &[u8]) -> Result<Option<ChatMessage>> {
302        let value: serde_json::Value = serde_json::from_slice(payload)?;
303        if value.get("gid").is_some() {
304            self.receive_group(payload)
305        } else {
306            self.receive(payload)
307        }
308    }
309
310    /// Open and verify an inbound sealed room message. Returns `None` for a
311    /// duplicate.
312    pub fn receive_group(&mut self, payload: &[u8]) -> Result<Option<ChatMessage>> {
313        let sealed: SealedGroupMessage = serde_json::from_slice(payload)?;
314        let group = self
315            .group(&sealed.gid)
316            .cloned()
317            .ok_or_else(|| CoreError::NotFound("room is not known on this device".into()))?;
318        let inner_json = open_group(&group.key, &sealed)?;
319        let inner = unpack_group_inner(&inner_json, &group.id)?;
320        if !group
321            .members
322            .iter()
323            .any(|member| member.pubkey == inner.from)
324        {
325            return Err(CoreError::InvalidInput(
326                "room sender is not in the local roster".into(),
327            ));
328        }
329        let message = ChatMessage::incoming_group(
330            &inner.from,
331            &group.id,
332            &inner.msg_id,
333            &inner.body,
334            inner.ts,
335        );
336        if !self.history.append(&message)? {
337            return Ok(None);
338        }
339        Ok(Some(message))
340    }
341
342    /// Test/demo helper: seal a message from `sender` to this engine and feed it
343    /// through the normal DM receive path.
344    pub fn deliver_demo(
345        &mut self,
346        sender: &ChatIdentity,
347        body: &str,
348    ) -> Result<Option<ChatMessage>> {
349        let msg_id = random_hex(16);
350        let ts = now_ms();
351        let inner = pack_dm_inner(sender, &self.identity.public_hex(), &msg_id, ts, body)?;
352        let sealed = sender.seal_to(&self.identity.public_hex(), DM_AAD, inner.as_bytes())?;
353        let payload = serde_json::to_vec(&sealed)?;
354        self.receive(&payload)
355    }
356
357    /// Test/demo helper for rooms: sign with `sender`, seal with the room key,
358    /// then run the normal group receive path.
359    pub fn deliver_demo_group(
360        &mut self,
361        sender: &ChatIdentity,
362        group_id: &str,
363        body: &str,
364    ) -> Result<Option<ChatMessage>> {
365        let group = self
366            .group(group_id)
367            .cloned()
368            .ok_or_else(|| CoreError::NotFound("room is not saved".into()))?;
369        let msg_id = random_hex(16);
370        let ts = now_ms();
371        let inner = pack_group_inner(sender, &group.id, &msg_id, ts, body)?;
372        let sealed = seal_group(&group.key, &group.id, &msg_id, &inner)?;
373        let payload = serde_json::to_vec(&sealed)?;
374        self.receive_group(&payload)
375    }
376
377    /// Newest-last messages for the DM with `peer_pub`. `limit` of `0` returns
378    /// everything.
379    pub fn history_for(&self, peer_pub: &str, limit: usize) -> Vec<ChatMessage> {
380        self.history.list(&dm_conversation_id(peer_pub), limit)
381    }
382
383    /// Newest-last messages for a room. `limit` of `0` returns everything.
384    pub fn group_history(&self, group_id: &str, limit: usize) -> Vec<ChatMessage> {
385        let id = group_id.trim().to_lowercase();
386        self.history.list(&format!("group:{id}"), limit)
387    }
388
389    /// Direct-message conversations, most recent first. Rooms are excluded;
390    /// use [`ChatEngine::group_summaries`] for those.
391    pub fn conversations(&self) -> Vec<ConversationSummary> {
392        let mut grouped: BTreeMap<String, Vec<&ChatMessage>> = BTreeMap::new();
393        for message in self.history.all() {
394            if message.conversation_id.starts_with("group:") {
395                continue;
396            }
397            grouped
398                .entry(message.conversation_id.clone())
399                .or_default()
400                .push(message);
401        }
402
403        let mut out = Vec::with_capacity(grouped.len());
404        for (id, messages) in grouped {
405            let Some(last) = messages.last() else {
406                continue;
407            };
408            let peer_pub = last.peer_pub().to_string();
409            out.push(ConversationSummary {
410                id,
411                petname: self.contact(&peer_pub).map(|c| c.petname.clone()),
412                peer_pub,
413                last_message: Some((*last).clone()),
414                message_count: messages.len(),
415            });
416        }
417        out.sort_by(|a, b| {
418            let a_at = a.last_message.as_ref().map(|m| m.at).unwrap_or(0);
419            let b_at = b.last_message.as_ref().map(|m| m.at).unwrap_or(0);
420            b_at.cmp(&a_at)
421        });
422        out
423    }
424
425    /// Rooms with their local member list and last message, most recent first.
426    pub fn group_summaries(&self) -> Vec<GroupSummary> {
427        let mut out: Vec<GroupSummary> = self
428            .groups
429            .iter()
430            .map(|group| {
431                let messages = self.history.list(&group.conversation_id(), 0);
432                GroupSummary {
433                    id: group.id.clone(),
434                    name: group.name.clone(),
435                    member_count: group.members.len(),
436                    members: group.members.clone(),
437                    last_message: messages.last().cloned(),
438                    message_count: messages.len(),
439                }
440            })
441            .collect();
442        out.sort_by(|a, b| {
443            let a_at = a.last_message.as_ref().map(|m| m.at).unwrap_or(0);
444            let b_at = b.last_message.as_ref().map(|m| m.at).unwrap_or(0);
445            b_at.cmp(&a_at)
446        });
447        out
448    }
449
450    /// Delete every stored message from the history journals.
451    pub fn clear_history(&mut self) -> Result<()> {
452        self.history.clear_all()
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use nysiris_core::contact::Contact;
460    use nysiris_testkit::MemoryTransport;
461    use tempfile::TempDir;
462
463    fn engine(seed: u8, dir: &TempDir) -> (ChatEngine, MemoryTransport) {
464        let identity = ChatIdentity::from_secret_bytes([seed; 32]);
465        let history = History::open(dir.path(), [seed; 32]).unwrap();
466        let transport = MemoryTransport::new();
467        let own_address = format!("peer{seed}.enc@gw");
468        (
469            ChatEngine::new(
470                identity,
471                vec![],
472                vec![],
473                history,
474                Arc::new(transport.clone()),
475                own_address,
476            ),
477            transport,
478        )
479    }
480
481    #[test]
482    fn two_engines_exchange_a_sealed_dm() {
483        let a_dir = TempDir::new().unwrap();
484        let b_dir = TempDir::new().unwrap();
485        let (mut alice, alice_tx) = engine(1, &a_dir);
486        let (mut bob, _bob_tx) = engine(2, &b_dir);
487
488        alice.add_contact(Contact::new("Bob", &bob.identity().public_hex(), "bob.enc@gw").unwrap());
489        bob.add_contact(
490            Contact::new("Alice", &alice.identity().public_hex(), "alice.enc@gw").unwrap(),
491        );
492
493        let sent = alice
494            .send_dm(&bob.identity().public_hex(), "hello bob")
495            .unwrap();
496        assert_eq!(sent.direction, nysiris_core::message::Direction::Outgoing);
497        let outbound = alice_tx.take();
498        assert_eq!(outbound.len(), 1);
499
500        let received = bob.receive(&outbound[0].payload).unwrap().unwrap();
501        assert_eq!(received.body, "hello bob");
502        assert_eq!(
503            received.direction,
504            nysiris_core::message::Direction::Incoming
505        );
506        assert_eq!(bob.history_for(&alice.identity().public_hex(), 0).len(), 1);
507        assert_eq!(bob.conversations().len(), 1);
508    }
509
510    #[test]
511    fn duplicate_delivery_is_dropped() {
512        let dir = TempDir::new().unwrap();
513        let (mut bob, _) = engine(2, &dir);
514        let alice = ChatIdentity::from_secret_bytes([1u8; 32]);
515        bob.add_contact(Contact::new("Alice", &alice.public_hex(), "alice.enc@gw").unwrap());
516        bob.deliver_demo(&alice, "once").unwrap();
517
518        let msg = bob.history_for(&alice.public_hex(), 0).remove(0);
519        assert!(bob.history().contains(&msg.msg_id));
520    }
521
522    #[test]
523    fn unknown_dm_senders_are_rejected() {
524        let dir = TempDir::new().unwrap();
525        let (mut bob, _) = engine(2, &dir);
526        let alice = ChatIdentity::from_secret_bytes([1u8; 32]);
527
528        assert!(bob.deliver_demo(&alice, "unsolicited").is_err());
529        assert!(bob.history_for(&alice.public_hex(), 0).is_empty());
530
531        bob.add_contact(Contact::new("Alice", &alice.public_hex(), "alice.enc@gw").unwrap());
532        assert!(bob.deliver_demo(&alice, "hello").unwrap().is_some());
533    }
534
535    #[test]
536    fn wrong_recipient_cannot_open_the_dm() {
537        let a_dir = TempDir::new().unwrap();
538        let b_dir = TempDir::new().unwrap();
539        let (mut alice, alice_tx) = engine(1, &a_dir);
540        let (mut bob, _) = engine(2, &b_dir);
541
542        let alice_id = alice.identity().public_hex();
543        let bob_id = bob.identity().public_hex();
544        alice.add_contact(Contact::new("Bob", &bob_id, "bob.enc@gw").unwrap());
545        bob.add_contact(Contact::new("Alice", &alice_id, "alice.enc@gw").unwrap());
546
547        alice.send_dm(&bob_id, "for bob").unwrap();
548        let outbound = alice_tx.take();
549        let carol = ChatIdentity::from_secret_bytes([3u8; 32]);
550        let sealed: SealedDm = serde_json::from_slice(&outbound[0].payload).unwrap();
551        assert!(carol.open_sealed(&sealed, DM_AAD).is_err());
552    }
553
554    #[test]
555    fn room_invite_fanout_and_history_round_trip() {
556        let a_dir = TempDir::new().unwrap();
557        let b_dir = TempDir::new().unwrap();
558        let (mut alice, alice_tx) = engine(1, &a_dir);
559        let (mut bob, bob_tx) = engine(2, &b_dir);
560        let alice_pub = alice.identity().public_hex();
561        let bob_pub = bob.identity().public_hex();
562        alice.add_contact(Contact::new("Bob", &bob_pub, "bob.enc@gw").unwrap());
563        bob.add_contact(Contact::new("Alice", &alice_pub, "alice.enc@gw").unwrap());
564
565        let (group, invite) = alice
566            .create_group("Room", std::slice::from_ref(&bob_pub))
567            .unwrap();
568        let joined = bob.join_group(&invite).unwrap();
569        assert_eq!(joined.id, group.id);
570        assert_eq!(bob.group_summaries().len(), 1);
571
572        let carol = ChatIdentity::from_secret_bytes([3u8; 32]);
573        assert!(bob.deliver_demo_group(&carol, &group.id, "spoof").is_err());
574        assert!(bob.group_history(&group.id, 0).is_empty());
575
576        let report = alice.send_group(&group.id, "hello room").unwrap();
577        assert_eq!(report.delivered, 1);
578        assert!(report.failed.is_empty());
579        let outbound = alice_tx.take();
580        assert_eq!(outbound.len(), 1);
581
582        let received = bob.receive_any(&outbound[0].payload).unwrap().unwrap();
583        assert_eq!(received.body, "hello room");
584        assert_eq!(received.conversation_id, format!("group:{}", group.id));
585        assert_eq!(bob.group_history(&group.id, 0).len(), 1);
586
587        let reply = bob.send_group(&group.id, "hello back").unwrap();
588        assert_eq!(reply.delivered, 1);
589        let outbound = bob_tx.take();
590        let received = alice.receive_any(&outbound[0].payload).unwrap().unwrap();
591        assert_eq!(received.body, "hello back");
592        assert_eq!(alice.group_history(&group.id, 0).len(), 2);
593        assert_eq!(alice.conversations().len(), 0);
594    }
595}