1#![warn(missing_docs)]
2use 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#[derive(Clone, Debug, Serialize)]
28pub struct GroupSendReport {
29 pub message: ChatMessage,
31 pub delivered: usize,
33 pub failed: Vec<String>,
35}
36
37pub 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 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 pub fn identity(&self) -> &ChatIdentity {
74 &self.identity
75 }
76
77 pub fn contacts(&self) -> &[Contact] {
79 &self.contacts
80 }
81
82 pub fn groups(&self) -> &[Group] {
84 &self.groups
85 }
86
87 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 pub fn history(&self) -> &History {
95 &self.history
96 }
97
98 pub fn transport_name(&self) -> &'static str {
100 self.transport.name()
101 }
102
103 pub fn status(&self) -> &str {
105 &self.status
106 }
107
108 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 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 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 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 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 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 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 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 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 self.transport.send(&contact.nym_address, payload)?;
234 self.history.append(&message)?;
235 Ok(message)
236 }
237
238 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 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 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 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 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 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 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 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 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 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 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}