1use std::path::Path;
2use std::sync::{Arc, Mutex};
3
4use nostr::{Event, Filter, Keys, PublicKey, UnsignedEvent};
5use nostr_double_ratchet::Invite;
6use rusqlite::{params, Connection, OptionalExtension};
7
8use crate::{
9 invite_unsigned_event, is_app_keys_event, parse_invite_url, AppKeys, ProtocolDecryptedMessage,
10 ProtocolEffect, ProtocolEngine, ProtocolRetryBatch, SharedConnection, SqliteStorageAdapter,
11 UnixSeconds, APP_KEYS_EVENT_KIND, CHAT_MESSAGE_KIND, INVITE_EVENT_KIND, INVITE_RESPONSE_KIND,
12 MESSAGE_EVENT_KIND,
13};
14
15const SCHEMA: &str = r#"
16CREATE TABLE IF NOT EXISTS private_chat_threads (
17 chat_id TEXT PRIMARY KEY,
18 display_name TEXT NOT NULL,
19 avatar_seed TEXT NOT NULL,
20 updated_at_secs INTEGER NOT NULL DEFAULT 0
21);
22
23CREATE TABLE IF NOT EXISTS private_chat_messages (
24 chat_id TEXT NOT NULL,
25 id TEXT NOT NULL,
26 body TEXT NOT NULL,
27 is_outgoing INTEGER NOT NULL,
28 created_at_secs INTEGER NOT NULL,
29 delivery TEXT NOT NULL,
30 source_event_id TEXT,
31 PRIMARY KEY (chat_id, id)
32);
33
34CREATE INDEX IF NOT EXISTS private_chat_recent_idx
35 ON private_chat_messages(chat_id, created_at_secs, id);
36
37CREATE UNIQUE INDEX IF NOT EXISTS private_chat_source_event_idx
38 ON private_chat_messages(source_event_id)
39 WHERE source_event_id IS NOT NULL;
40
41CREATE TABLE IF NOT EXISTS private_chat_seen_events (
42 event_id TEXT PRIMARY KEY
43);
44
45CREATE TABLE IF NOT EXISTS ndr_kv (
46 owner_pubkey_hex TEXT NOT NULL,
47 device_pubkey_hex TEXT NOT NULL,
48 key TEXT NOT NULL,
49 value TEXT NOT NULL,
50 PRIMARY KEY (owner_pubkey_hex, device_pubkey_hex, key)
51);
52"#;
53
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub enum DirectMessageDelivery {
56 Pending,
57 Sent,
58 Received,
59 Failed,
60}
61
62impl DirectMessageDelivery {
63 fn as_str(&self) -> &'static str {
64 match self {
65 Self::Pending => "pending",
66 Self::Sent => "sent",
67 Self::Received => "received",
68 Self::Failed => "failed",
69 }
70 }
71
72 fn from_str(value: &str) -> Self {
73 match value {
74 "sent" => Self::Sent,
75 "received" => Self::Received,
76 "failed" => Self::Failed,
77 _ => Self::Pending,
78 }
79 }
80}
81
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct DirectMessageSnapshot {
84 pub id: String,
85 pub chat_id: String,
86 pub body: String,
87 pub is_outgoing: bool,
88 pub created_at_secs: u64,
89 pub delivery: DirectMessageDelivery,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct DirectChatSnapshot {
94 pub chat_id: String,
95 pub last_message_preview: String,
96 pub last_message_at: u64,
97 pub unread_count: u32,
98}
99
100#[derive(Clone, Debug, PartialEq, Eq)]
101pub struct DirectThreadSnapshot {
102 pub chat: DirectChatSnapshot,
103 pub messages: Vec<DirectMessageSnapshot>,
104}
105
106#[derive(Clone, Debug)]
107pub enum DirectMessageCommand {
108 Publish(Event),
109 Subscribe {
110 subscription_id: String,
111 filters: Vec<Filter>,
112 durable: bool,
113 },
114}
115
116pub struct DirectMessageService {
117 conn: SharedConnection,
118 protocol_engine: Option<ProtocolEngine>,
119 owner_public_key: Option<PublicKey>,
120 relay_subscription_key: Option<String>,
121 last_error: Option<String>,
122}
123
124impl DirectMessageService {
125 pub fn memory() -> Self {
126 let service = Self {
127 conn: Arc::new(Mutex::new(Connection::open_in_memory().unwrap())),
128 protocol_engine: None,
129 owner_public_key: None,
130 relay_subscription_key: None,
131 last_error: None,
132 };
133 service.ensure_schema();
134 service
135 }
136
137 pub fn memory_for_local_device(owner_public_key: PublicKey, device_keys: &Keys) -> Self {
138 Self::memory().with_protocol_engine_for_local_device(owner_public_key, device_keys)
139 }
140
141 pub fn open(data_dir: &Path, owner_keys: Option<&Keys>) -> Self {
142 match owner_keys {
143 Some(keys) => Self::open_for_local_device(data_dir, keys.public_key(), keys),
144 None => Self::open_without_protocol_engine(data_dir),
145 }
146 }
147
148 pub fn open_for_local_device(
149 data_dir: &Path,
150 owner_public_key: PublicKey,
151 device_keys: &Keys,
152 ) -> Self {
153 Self::open_without_protocol_engine(data_dir)
154 .with_protocol_engine_for_local_device(owner_public_key, device_keys)
155 }
156
157 fn open_without_protocol_engine(data_dir: &Path) -> Self {
158 let path = data_dir.join("private-chat.sqlite3");
159 let conn = Connection::open(path).or_else(|_| Connection::open_in_memory());
160 let conn = match conn {
161 Ok(conn) => conn,
162 Err(error) => {
163 return Self {
164 conn: Arc::new(Mutex::new(Connection::open_in_memory().unwrap())),
165 protocol_engine: None,
166 owner_public_key: None,
167 relay_subscription_key: None,
168 last_error: Some(format!("Direct message store open failed: {error}")),
169 };
170 }
171 };
172 let service = Self {
173 conn: Arc::new(Mutex::new(conn)),
174 protocol_engine: None,
175 owner_public_key: None,
176 relay_subscription_key: None,
177 last_error: None,
178 };
179 service.ensure_schema();
180 service
181 }
182
183 pub fn activate(&mut self, keys: &Keys) -> Vec<DirectMessageCommand> {
184 let next = Self {
185 conn: Arc::clone(&self.conn),
186 protocol_engine: None,
187 owner_public_key: None,
188 relay_subscription_key: self.relay_subscription_key.clone(),
189 last_error: self.last_error.clone(),
190 }
191 .with_protocol_engine(keys);
192 self.protocol_engine = next.protocol_engine;
193 self.owner_public_key = next.owner_public_key;
194 self.protocol_subscription_commands()
195 }
196
197 pub fn last_error(&self) -> Option<String> {
198 self.last_error.clone()
199 }
200
201 pub fn chats(&self) -> Vec<DirectChatSnapshot> {
202 let Ok(conn) = self.conn.lock() else {
203 return Vec::new();
204 };
205 let mut stmt = match conn.prepare(
206 "SELECT t.chat_id,
207 COALESCE(m.body, ''), COALESCE(m.created_at_secs, t.updated_at_secs)
208 FROM private_chat_threads t
209 LEFT JOIN private_chat_messages m
210 ON m.chat_id = t.chat_id
211 AND m.created_at_secs = (
212 SELECT MAX(created_at_secs)
213 FROM private_chat_messages
214 WHERE chat_id = t.chat_id
215 )
216 ORDER BY COALESCE(m.created_at_secs, t.updated_at_secs) DESC, t.chat_id ASC",
217 ) {
218 Ok(stmt) => stmt,
219 Err(_) => return Vec::new(),
220 };
221 let rows = match stmt.query_map([], |row| {
222 Ok(DirectChatSnapshot {
223 chat_id: row.get(0)?,
224 last_message_preview: row.get(1)?,
225 last_message_at: row.get::<_, i64>(2)?.max(0) as u64,
226 unread_count: 0,
227 })
228 }) {
229 Ok(rows) => rows,
230 Err(_) => return Vec::new(),
231 };
232 rows.filter_map(Result::ok).collect()
233 }
234
235 pub fn thread(&self, chat_id: &str) -> Option<DirectThreadSnapshot> {
236 let chat_id = normalize_pubkey(chat_id).ok()?;
237 let chat = self
238 .chats()
239 .into_iter()
240 .find(|chat| chat.chat_id == chat_id)
241 .unwrap_or_else(|| chat_snapshot_for_pubkey(&chat_id));
242 let messages = self.messages(&chat_id, 160);
243 Some(DirectThreadSnapshot { chat, messages })
244 }
245
246 pub fn open_chat(
247 &mut self,
248 peer_input: &str,
249 keys: &Keys,
250 ) -> Result<(DirectThreadSnapshot, Vec<DirectMessageCommand>), String> {
251 let public_key = match PublicKey::parse(peer_input) {
252 Ok(public_key) => public_key,
253 Err(_) => return self.accept_invite(peer_input, keys),
254 };
255 let chat_id = public_key.to_hex();
256 self.ensure_thread(&chat_id, unix_now());
257 let commands = self.protocol_subscription_commands();
258 let thread = self
259 .thread(&chat_id)
260 .ok_or_else(|| "Chat open failed".to_string())?;
261 Ok((thread, commands))
262 }
263
264 pub fn accept_invite(
265 &mut self,
266 invite_input: &str,
267 _keys: &Keys,
268 ) -> Result<(DirectThreadSnapshot, Vec<DirectMessageCommand>), String> {
269 let invite = parse_direct_invite_input(invite_input)?;
270 let owner = invite.owner_public_key.unwrap_or(invite.inviter);
271 let chat_id = owner.to_hex();
272 self.ensure_thread(&chat_id, unix_now());
273 let engine = self
274 .protocol_engine
275 .as_mut()
276 .ok_or_else(|| "Direct message runtime is not ready".to_string())?;
277 let result = engine
278 .accept_invite(&invite, Some(owner))
279 .map_err(|error| error.to_string())?;
280 let mut commands = self.commands_from_effects(result.effects);
281 commands.extend(self.protocol_subscription_commands());
282 let thread = self
283 .thread(&chat_id)
284 .ok_or_else(|| "Invite chat open failed".to_string())?;
285 Ok((thread, commands))
286 }
287
288 pub fn send_message(
289 &mut self,
290 chat_id: &str,
291 body: &str,
292 _keys: &Keys,
293 ) -> Result<Vec<DirectMessageCommand>, String> {
294 let body = body.trim();
295 if body.is_empty() {
296 return Ok(Vec::new());
297 }
298 let public_key = PublicKey::parse(chat_id).map_err(|error| error.to_string())?;
299 let chat_id = public_key.to_hex();
300 self.ensure_thread(&chat_id, unix_now());
301 let engine = self
302 .protocol_engine
303 .as_mut()
304 .ok_or_else(|| "Direct message runtime is not ready".to_string())?;
305 let result = engine
306 .send_direct_text(public_key, &chat_id, body, None, UnixSeconds(unix_now()))
307 .map_err(|error| error.to_string())?;
308 let delivery = if result.event_ids.is_empty() {
309 DirectMessageDelivery::Pending
310 } else {
311 DirectMessageDelivery::Sent
312 };
313 self.insert_message(
314 &chat_id,
315 &result.message_id,
316 body,
317 true,
318 unix_now(),
319 delivery,
320 None,
321 );
322 Ok(self.commands_from_effects(result.effects))
323 }
324
325 pub fn process_event(&mut self, event: Event, _keys: &Keys) -> Vec<DirectMessageCommand> {
326 let event_id = event.id.to_hex();
327 if self.seen_event(&event_id) {
328 return Vec::new();
329 }
330 let Some(engine) = self.protocol_engine.as_mut() else {
331 return Vec::new();
332 };
333 let kind = event.kind.as_u16() as u32;
334 let mut effects = Vec::new();
335 let mut retry_batch = ProtocolRetryBatch::default();
336 let mut decrypted = None;
337
338 let processed = match kind {
339 APP_KEYS_EVENT_KIND if is_app_keys_event(&event) => match AppKeys::from_event(&event) {
340 Ok(app_keys) => match engine.ingest_app_keys_snapshot(
341 event.pubkey,
342 app_keys,
343 event.created_at.as_secs(),
344 ) {
345 Ok(batch) => {
346 retry_batch = batch;
347 true
348 }
349 Err(error) => {
350 self.last_error =
351 Some(format!("Direct message device roster failed: {error}"));
352 false
353 }
354 },
355 Err(_) => false,
356 },
357 INVITE_EVENT_KIND => match engine.observe_invite_event(&event) {
358 Ok(batch) => {
359 retry_batch = batch;
360 true
361 }
362 Err(_) => false,
363 },
364 INVITE_RESPONSE_KIND => match engine.observe_invite_response_event(&event) {
365 Ok(batch) => {
366 retry_batch = batch;
367 true
368 }
369 Err(_) => false,
370 },
371 MESSAGE_EVENT_KIND => match engine.process_direct_message_event(&event) {
372 Ok(message) => {
373 decrypted = message;
374 true
375 }
376 Err(_) => false,
377 },
378 _ => false,
379 };
380
381 if !processed {
382 return Vec::new();
383 }
384 self.mark_seen_event(&event_id);
385 if let Some(message) = decrypted {
386 self.apply_decrypted_protocol_message(message);
387 }
388 effects.extend(self.effects_from_retry_batch(retry_batch));
389 self.commands_from_effects(effects)
390 }
391
392 pub fn mobile_push_message_author_pubkeys(&self) -> Vec<String> {
393 let Some(engine) = self.protocol_engine.as_ref() else {
394 return Vec::new();
395 };
396 let mut authors = engine
397 .known_message_author_pubkeys()
398 .into_iter()
399 .map(|pubkey| pubkey.to_hex())
400 .collect::<Vec<_>>();
401 authors.sort();
402 authors.dedup();
403 authors
404 }
405
406 pub fn local_invite_event(&self, device_keys: &Keys) -> Option<Event> {
407 let invite = self.protocol_engine.as_ref()?.local_invite()?;
408 if invite.inviter_device_pubkey.to_bytes() != device_keys.public_key().to_bytes() {
409 return None;
410 }
411 invite_unsigned_event(&invite)
412 .ok()?
413 .sign_with_keys(device_keys)
414 .ok()
415 }
416
417 fn subscription_command(&mut self) -> Option<DirectMessageCommand> {
418 let engine = self.protocol_engine.as_ref()?;
419 let authors = engine
420 .known_message_author_pubkeys()
421 .into_iter()
422 .chain(self.owner_public_key)
423 .collect::<Vec<_>>();
424 let mut author_hexes = authors.iter().map(PublicKey::to_hex).collect::<Vec<_>>();
425 author_hexes.sort();
426 author_hexes.dedup();
427 let key = author_hexes.join(",");
428 if key.is_empty() || self.relay_subscription_key.as_deref() == Some(key.as_str()) {
429 return None;
430 }
431 self.relay_subscription_key = Some(key);
432
433 let public_keys = author_hexes
434 .iter()
435 .filter_map(|hex| PublicKey::parse(hex).ok())
436 .collect::<Vec<_>>();
437 let filter = Filter::new()
438 .authors(public_keys)
439 .kinds([
440 nostr::Kind::from(MESSAGE_EVENT_KIND as u16),
441 nostr::Kind::from(INVITE_EVENT_KIND as u16),
442 nostr::Kind::from(INVITE_RESPONSE_KIND as u16),
443 nostr::Kind::from(APP_KEYS_EVENT_KIND as u16),
444 ])
445 .limit(500);
446 Some(DirectMessageCommand::Subscribe {
447 subscription_id: "iris-native-private-chat".to_string(),
448 filters: vec![filter],
449 durable: true,
450 })
451 }
452
453 fn with_protocol_engine(self, keys: &Keys) -> Self {
454 self.with_protocol_engine_for_local_device(keys.public_key(), keys)
455 }
456
457 fn with_protocol_engine_for_local_device(
458 mut self,
459 owner: PublicKey,
460 device_keys: &Keys,
461 ) -> Self {
462 let owner_hex = owner.to_hex();
463 let device_hex = device_keys.public_key().to_hex();
464 let storage = Arc::new(SqliteStorageAdapter::new(
465 Arc::clone(&self.conn),
466 owner_hex.clone(),
467 device_hex,
468 ));
469 match ProtocolEngine::load_or_create_for_local_device(storage, owner, device_keys) {
470 Ok(engine) => {
471 self.protocol_engine = Some(engine);
472 self.owner_public_key = Some(owner);
473 }
474 Err(error) => self.last_error = Some(format!("Direct message init failed: {error}")),
475 }
476 self
477 }
478
479 fn protocol_subscription_commands(&mut self) -> Vec<DirectMessageCommand> {
480 self.subscription_command().into_iter().collect()
481 }
482
483 fn commands_from_effects(&mut self, effects: Vec<ProtocolEffect>) -> Vec<DirectMessageCommand> {
484 let mut commands = Vec::new();
485 for effect in effects {
486 match effect {
487 ProtocolEffect::Publish(publish) => {
488 commands.push(DirectMessageCommand::Publish(publish.event));
489 }
490 }
491 }
492 commands
493 }
494
495 fn effects_from_retry_batch(&mut self, batch: ProtocolRetryBatch) -> Vec<ProtocolEffect> {
496 let mut effects = batch.effects;
497 effects.extend(batch.group_result.effects);
498 for message in batch.direct_messages {
499 self.apply_decrypted_protocol_message(message);
500 }
501 effects
502 }
503
504 fn apply_decrypted_protocol_message(&mut self, message: ProtocolDecryptedMessage) {
505 self.apply_decrypted(
506 message.sender,
507 message.conversation_owner,
508 &message.content,
509 message.event_id,
510 );
511 }
512
513 fn apply_decrypted(
514 &mut self,
515 sender: PublicKey,
516 conversation_owner: Option<PublicKey>,
517 content: &str,
518 source_event_id: Option<String>,
519 ) {
520 let Some(rumor) = parse_runtime_rumor(content) else {
521 return;
522 };
523 if rumor.kind != CHAT_MESSAGE_KIND {
524 return;
525 }
526 let local_owner = self.owner_public_key;
527 let peer = if local_owner == Some(sender) {
528 conversation_owner.unwrap_or(sender)
529 } else {
530 sender
531 };
532 let chat_id = peer.to_hex();
533 self.ensure_thread(&chat_id, rumor.created_at_secs);
534 self.insert_message(
535 &chat_id,
536 &rumor.id,
537 &rumor.content,
538 local_owner == Some(sender),
539 rumor.created_at_secs,
540 if local_owner == Some(sender) {
541 DirectMessageDelivery::Sent
542 } else {
543 DirectMessageDelivery::Received
544 },
545 source_event_id.as_deref(),
546 );
547 }
548
549 fn ensure_schema(&self) {
550 if let Ok(conn) = self.conn.lock() {
551 let _ = conn.execute_batch(SCHEMA);
552 }
553 }
554
555 fn ensure_thread(&self, chat_id: &str, updated_at: u64) {
556 if let Ok(conn) = self.conn.lock() {
557 let _ = conn.execute(
558 "INSERT INTO private_chat_threads (chat_id, display_name, avatar_seed, updated_at_secs)
559 VALUES (?1, '', '', ?2)
560 ON CONFLICT(chat_id) DO UPDATE SET updated_at_secs = MAX(updated_at_secs, excluded.updated_at_secs)",
561 params![chat_id, updated_at as i64],
562 );
563 }
564 }
565
566 fn messages(&self, chat_id: &str, limit: usize) -> Vec<DirectMessageSnapshot> {
567 let Ok(conn) = self.conn.lock() else {
568 return Vec::new();
569 };
570 let mut stmt = match conn.prepare(
571 "SELECT id, body, is_outgoing, created_at_secs, delivery
572 FROM private_chat_messages
573 WHERE chat_id = ?1
574 ORDER BY created_at_secs DESC, id DESC
575 LIMIT ?2",
576 ) {
577 Ok(stmt) => stmt,
578 Err(_) => return Vec::new(),
579 };
580 let rows = match stmt.query_map(params![chat_id, limit as i64], |row| {
581 Ok(DirectMessageSnapshot {
582 id: row.get(0)?,
583 chat_id: chat_id.to_string(),
584 body: row.get(1)?,
585 is_outgoing: row.get::<_, i64>(2)? != 0,
586 created_at_secs: row.get::<_, i64>(3)?.max(0) as u64,
587 delivery: DirectMessageDelivery::from_str(&row.get::<_, String>(4)?),
588 })
589 }) {
590 Ok(rows) => rows,
591 Err(_) => return Vec::new(),
592 };
593 let mut messages = rows.filter_map(Result::ok).collect::<Vec<_>>();
594 messages.reverse();
595 messages
596 }
597
598 fn insert_message(
599 &self,
600 chat_id: &str,
601 id: &str,
602 body: &str,
603 is_outgoing: bool,
604 created_at: u64,
605 delivery: DirectMessageDelivery,
606 source_event_id: Option<&str>,
607 ) {
608 if id.is_empty() {
609 return;
610 }
611 if let Ok(conn) = self.conn.lock() {
612 let _ = conn.execute(
613 "INSERT OR IGNORE INTO private_chat_messages
614 (chat_id, id, body, is_outgoing, created_at_secs, delivery, source_event_id)
615 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
616 params![
617 chat_id,
618 id,
619 body,
620 is_outgoing as i64,
621 created_at as i64,
622 delivery.as_str(),
623 source_event_id,
624 ],
625 );
626 let _ = conn.execute(
627 "UPDATE private_chat_threads SET updated_at_secs = MAX(updated_at_secs, ?2)
628 WHERE chat_id = ?1",
629 params![chat_id, created_at as i64],
630 );
631 }
632 }
633
634 fn seen_event(&self, event_id: &str) -> bool {
635 let Ok(conn) = self.conn.lock() else {
636 return true;
637 };
638 conn.query_row(
639 "SELECT 1 FROM private_chat_seen_events WHERE event_id = ?1",
640 [event_id],
641 |_| Ok(()),
642 )
643 .optional()
644 .ok()
645 .flatten()
646 .is_some()
647 }
648
649 fn mark_seen_event(&self, event_id: &str) {
650 if let Ok(conn) = self.conn.lock() {
651 let _ = conn.execute(
652 "INSERT OR IGNORE INTO private_chat_seen_events (event_id) VALUES (?1)",
653 [event_id],
654 );
655 }
656 }
657}
658
659struct RuntimeRumor {
660 id: String,
661 kind: u32,
662 content: String,
663 created_at_secs: u64,
664}
665
666fn parse_runtime_rumor(content: &str) -> Option<RuntimeRumor> {
667 let mut event = serde_json::from_str::<UnsignedEvent>(content).ok()?;
668 event.ensure_id();
669 event.verify_id().ok()?;
670 Some(RuntimeRumor {
671 id: event.id.as_ref()?.to_string(),
672 kind: event.kind.as_u16() as u32,
673 content: event.content,
674 created_at_secs: event.created_at.as_secs(),
675 })
676}
677
678fn chat_snapshot_for_pubkey(chat_id: &str) -> DirectChatSnapshot {
679 DirectChatSnapshot {
680 chat_id: chat_id.to_string(),
681 last_message_preview: String::new(),
682 last_message_at: 0,
683 unread_count: 0,
684 }
685}
686
687fn normalize_pubkey(input: &str) -> Result<String, String> {
688 PublicKey::parse(input)
689 .map(|pubkey| pubkey.to_hex())
690 .map_err(|error| error.to_string())
691}
692
693fn parse_direct_invite_input(input: &str) -> Result<Invite, String> {
694 let trimmed = input.trim();
695 if trimmed.is_empty() {
696 return Err("Invite link is required".to_string());
697 }
698 if let Ok(invite) = parse_invite_url(trimmed) {
699 return Ok(invite);
700 }
701
702 let mut candidates = vec![trimmed.to_string()];
703 if let Some((_, fragment)) = trimmed.split_once('#') {
704 candidates.push(fragment.to_string());
705 candidates.push(fragment.trim_start_matches('/').to_string());
706 candidates.extend(
707 fragment
708 .split(['/', '?', '&', '='])
709 .filter(|part| !part.trim().is_empty())
710 .map(ToString::to_string),
711 );
712 }
713 if let Some((_, query)) = trimmed.split_once('?') {
714 candidates.extend(
715 query
716 .split(['/', '?', '&', '='])
717 .filter(|part| !part.trim().is_empty())
718 .map(ToString::to_string),
719 );
720 }
721
722 for candidate in candidates {
723 let candidate = candidate.trim().trim_start_matches('/');
724 let candidate = candidate.strip_prefix("invite/").unwrap_or(candidate);
725 if candidate.is_empty() || candidate.eq_ignore_ascii_case("invite") {
726 continue;
727 }
728 for wrapped in [
729 candidate.to_string(),
730 format!("https://chat.iris.to#{candidate}"),
731 format!("https://chat.iris.to#/{candidate}"),
732 ] {
733 if let Ok(invite) = parse_invite_url(&wrapped) {
734 return Ok(invite);
735 }
736 }
737 }
738
739 parse_invite_url(trimmed).map_err(|error| error.to_string())
740}
741
742fn unix_now() -> u64 {
743 std::time::SystemTime::now()
744 .duration_since(std::time::UNIX_EPOCH)
745 .map(|duration| duration.as_secs())
746 .unwrap_or_default()
747}
748
749#[cfg(test)]
750mod tests {
751 use super::*;
752 use crate::{invite_url, parse_invite_event};
753 use nostr::Kind;
754
755 fn publish_events(commands: Vec<DirectMessageCommand>) -> Vec<Event> {
756 commands
757 .into_iter()
758 .filter_map(|command| match command {
759 DirectMessageCommand::Publish(event) => Some(event),
760 DirectMessageCommand::Subscribe { .. } => None,
761 })
762 .collect()
763 }
764
765 fn publish_kinds(commands: &[DirectMessageCommand]) -> Vec<Kind> {
766 commands
767 .iter()
768 .filter_map(|command| match command {
769 DirectMessageCommand::Publish(event) => Some(event.kind),
770 DirectMessageCommand::Subscribe { .. } => None,
771 })
772 .collect()
773 }
774
775 fn route_wrapped_invite_url(invite: &Invite) -> String {
776 let raw = invite_url(invite, "https://chat.iris.to").expect("invite url");
777 let Some((_, fragment)) = raw.split_once('#') else {
778 return raw;
779 };
780 let payload = fragment.trim_start_matches('/');
781 if payload.starts_with("invite/") {
782 raw
783 } else {
784 format!("https://chat.iris.to/#/invite/{payload}")
785 }
786 }
787
788 #[test]
789 fn accepts_route_wrapped_invite_and_sends_direct_message() {
790 let inviter_keys = Keys::generate();
791 let accepter_keys = Keys::generate();
792 let mut inviter =
793 DirectMessageService::memory_for_local_device(inviter_keys.public_key(), &inviter_keys);
794 let mut accepter = DirectMessageService::memory_for_local_device(
795 accepter_keys.public_key(),
796 &accepter_keys,
797 );
798 let invite_event = inviter
799 .local_invite_event(&inviter_keys)
800 .expect("local invite event");
801 let invite = parse_invite_event(&invite_event).expect("invite event");
802 let invite_url = route_wrapped_invite_url(&invite);
803
804 let (thread, accept_commands) = accepter
805 .accept_invite(&invite_url, &accepter_keys)
806 .expect("accept invite");
807 assert_eq!(thread.chat.chat_id, inviter_keys.public_key().to_hex());
808 let accept_kinds = publish_kinds(&accept_commands);
809 assert!(accept_kinds.contains(&Kind::from(INVITE_RESPONSE_KIND as u16)));
810 assert!(accept_kinds.contains(&Kind::from(MESSAGE_EVENT_KIND as u16)));
811
812 for event in publish_events(accept_commands) {
813 inviter.process_event(event, &inviter_keys);
814 }
815
816 let send_commands = accepter
817 .send_message(
818 &inviter_keys.public_key().to_hex(),
819 "hello from invite accepter",
820 &accepter_keys,
821 )
822 .expect("send message");
823 assert!(publish_kinds(&send_commands).contains(&Kind::from(MESSAGE_EVENT_KIND as u16)));
824
825 for event in publish_events(send_commands) {
826 inviter.process_event(event, &inviter_keys);
827 }
828
829 let inviter_thread = inviter
830 .thread(&accepter_keys.public_key().to_hex())
831 .expect("inviter thread");
832 assert_eq!(inviter_thread.messages.len(), 1);
833 assert_eq!(
834 inviter_thread.messages[0].body,
835 "hello from invite accepter"
836 );
837 assert!(!inviter_thread.messages[0].is_outgoing);
838 assert_eq!(
839 inviter_thread.messages[0].delivery,
840 DirectMessageDelivery::Received
841 );
842 }
843}