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
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 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 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 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 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 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 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 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 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 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}