Skip to main content

iris_chat_protocol/
direct_messages.rs

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}