Skip to main content

matrix_bot_sdk/
e2ee.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use anyhow::Context;
5use async_trait::async_trait;
6use base64::Engine;
7use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
8use hmac::{Hmac, Mac};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use sha2::{Digest, Sha256};
12use tokio::sync::RwLock;
13
14use crate::client::MatrixClient;
15
16/// Fallback encryption engine using XOR cipher
17/// For production E2EE, use matrix-sdk directly
18#[derive(Debug, Clone, Default)]
19pub struct CryptoEngine {
20    shared_secret: Arc<RwLock<Vec<u8>>>,
21}
22
23impl CryptoEngine {
24    pub fn new(shared_secret: Vec<u8>) -> Self {
25        Self {
26            shared_secret: Arc::new(RwLock::new(shared_secret)),
27        }
28    }
29
30    pub async fn encrypt(&self, plaintext: &[u8]) -> Vec<u8> {
31        let key = self.shared_secret.read().await;
32        xor_cipher(plaintext, &key)
33    }
34
35    pub async fn decrypt(&self, ciphertext: &[u8]) -> Vec<u8> {
36        let key = self.shared_secret.read().await;
37        xor_cipher(ciphertext, &key)
38    }
39
40    pub async fn encrypt_message(&self, plaintext: &str) -> String {
41        STANDARD.encode(self.encrypt(plaintext.as_bytes()).await)
42    }
43
44    pub async fn decrypt_message(&self, ciphertext: &str) -> anyhow::Result<String> {
45        let bytes = STANDARD.decode(ciphertext)?;
46        let decrypted = self.decrypt(&bytes).await;
47        Ok(String::from_utf8(decrypted)?)
48    }
49}
50
51fn xor_cipher(data: &[u8], key: &[u8]) -> Vec<u8> {
52    if key.is_empty() {
53        return data.to_vec();
54    }
55    data.iter()
56        .enumerate()
57        .map(|(idx, byte)| byte ^ key[idx % key.len()])
58        .collect()
59}
60
61#[derive(Clone)]
62pub struct CryptoClient {
63    engine: CryptoEngine,
64    client: Option<MatrixClient>,
65    device_id: Arc<RwLock<Option<String>>>,
66    device_ed25519: Arc<RwLock<Option<String>>>,
67    device_curve25519: Arc<RwLock<Option<String>>>,
68    ready: Arc<RwLock<bool>>,
69    backup_enabled: Arc<RwLock<bool>>,
70    room_tracker: RoomTracker,
71}
72
73impl CryptoClient {
74    pub fn new(engine: CryptoEngine) -> Self {
75        Self {
76            engine,
77            client: None,
78            device_id: Arc::new(RwLock::new(None)),
79            device_ed25519: Arc::new(RwLock::new(None)),
80            device_curve25519: Arc::new(RwLock::new(None)),
81            ready: Arc::new(RwLock::new(false)),
82            backup_enabled: Arc::new(RwLock::new(false)),
83            room_tracker: RoomTracker::new(),
84        }
85    }
86
87    pub fn with_client(mut self, client: MatrixClient) -> Self {
88        self.room_tracker = self.room_tracker.clone().with_client(client.clone());
89        self.client = Some(client);
90        self
91    }
92
93    pub async fn set_device_id(&self, device_id: impl Into<String>) {
94        *self.device_id.write().await = Some(device_id.into());
95    }
96
97    pub async fn device_id(&self) -> Option<String> {
98        self.device_id.read().await.clone()
99    }
100
101    pub async fn client_device_id(&self) -> Option<String> {
102        self.device_id().await
103    }
104
105    pub async fn client_device_ed25519(&self) -> Option<String> {
106        self.device_ed25519.read().await.clone()
107    }
108
109    pub async fn client_device_curve25519(&self) -> Option<String> {
110        self.device_curve25519.read().await.clone()
111    }
112
113    pub async fn is_ready(&self) -> bool {
114        *self.ready.read().await
115    }
116
117    pub async fn prepare(&self) -> anyhow::Result<()> {
118        if self.is_ready().await {
119            return Ok(());
120        }
121
122        let (user_id, device_id) = if let Some(client) = &self.client {
123            let whoami = client.get_who_am_i().await.unwrap_or_default();
124            let user_id = if whoami.user_id.is_empty() {
125                "@unknown:localhost".to_owned()
126            } else {
127                whoami.user_id
128            };
129            let device_id = whoami
130                .device_id
131                .unwrap_or_else(|| "UNKNOWNDEVICE".to_owned());
132            (user_id, device_id)
133        } else {
134            ("@unknown:localhost".to_owned(), "UNKNOWNDEVICE".to_owned())
135        };
136
137        self.set_device_id(device_id.clone()).await;
138
139        // Derive deterministic pseudo device keys from identity material. This keeps
140        // compatibility surfaces stable without requiring matrix-sdk-crypto.
141        let curve_seed = format!("{user_id}|{device_id}|curve25519");
142        let ed_seed = format!("{user_id}|{device_id}|ed25519");
143        let curve = URL_SAFE_NO_PAD.encode(Sha256::digest(curve_seed.as_bytes()));
144        let ed = URL_SAFE_NO_PAD.encode(Sha256::digest(ed_seed.as_bytes()));
145
146        *self.device_curve25519.write().await = Some(curve);
147        *self.device_ed25519.write().await = Some(ed);
148        *self.ready.write().await = true;
149        Ok(())
150    }
151
152    pub async fn encrypt_message(&self, _room_id: &str, plaintext: &str) -> anyhow::Result<String> {
153        Ok(self.engine.encrypt_message(plaintext).await)
154    }
155
156    pub async fn decrypt_message(
157        &self,
158        _room_id: &str,
159        ciphertext: &Value,
160    ) -> anyhow::Result<Value> {
161        if let Some(str) = ciphertext.as_str() {
162            let decrypted = self.engine.decrypt_message(str).await?;
163            Ok(Value::String(decrypted))
164        } else {
165            Ok(ciphertext.clone())
166        }
167    }
168
169    pub async fn is_room_encrypted(&self, room_id: &str) -> anyhow::Result<bool> {
170        if self.room_tracker.is_room_encrypted(room_id).await {
171            return Ok(true);
172        }
173        if let Some(client) = &self.client {
174            let state = client
175                .get_room_state_event(room_id, "m.room.encryption", "")
176                .await;
177            Ok(state.is_ok())
178        } else {
179            Ok(false)
180        }
181    }
182
183    pub async fn get_encryption_info(
184        &self,
185        room_id: &str,
186    ) -> anyhow::Result<Option<EncryptionInfo>> {
187        let tracked = self.room_tracker.get_room_crypto_config(room_id).await?;
188        if let Some(algorithm) = tracked.algorithm {
189            return Ok(Some(EncryptionInfo {
190                algorithm,
191                rotation_period_ms: tracked.rotation_period_ms,
192                rotation_period_msgs: tracked.rotation_period_msgs,
193            }));
194        }
195
196        if let Some(client) = &self.client {
197            match client
198                .get_room_state_event(room_id, "m.room.encryption", "")
199                .await
200            {
201                Ok(value) => {
202                    let info: EncryptionInfo = serde_json::from_value(value)?;
203                    Ok(Some(info))
204                }
205                Err(_) => Ok(None),
206            }
207        } else {
208            Ok(None)
209        }
210    }
211
212    pub async fn on_room_event(&self, room_id: &str, event: &Value) -> anyhow::Result<()> {
213        self.room_tracker.on_room_event(room_id, event).await
214    }
215
216    pub async fn on_room_join(&self, room_id: &str) -> anyhow::Result<()> {
217        self.room_tracker.on_room_join(room_id).await
218    }
219
220    pub async fn mark_room_encrypted(&self, room_id: &str, encrypted: bool) {
221        self.room_tracker.set_encrypted(room_id, encrypted).await;
222    }
223
224    pub async fn trust_device(&self, _user_id: &str, _device_id: &str) -> anyhow::Result<()> {
225        // Placeholder - real implementation needs matrix-sdk-crypto
226        Ok(())
227    }
228
229    pub async fn untrust_device(&self, _user_id: &str, _device_id: &str) -> anyhow::Result<()> {
230        // Placeholder - real implementation needs matrix-sdk-crypto
231        Ok(())
232    }
233
234    pub async fn get_user_devices(&self, user_id: &str) -> anyhow::Result<Vec<DeviceInfo>> {
235        if let Some(client) = &self.client {
236            let response = client.get_user_devices(&[user_id]).await?;
237            let devices = response
238                .get("device_keys")
239                .and_then(|d| d.get(user_id))
240                .and_then(|d| d.as_object())
241                .map(|obj| {
242                    obj.iter()
243                        .map(|(device_id, _)| DeviceInfo {
244                            device_id: device_id.clone(),
245                            display_name: None,
246                            last_seen_ts: None,
247                            trusted: false,
248                        })
249                        .collect()
250                })
251                .unwrap_or_default();
252            Ok(devices)
253        } else {
254            Ok(Vec::new())
255        }
256    }
257
258    pub async fn has_unverified_devices(&self, room_id: &str) -> anyhow::Result<bool> {
259        if let Some(client) = &self.client {
260            let members = client.get_joined_room_members(room_id).await?;
261            for user_id in members {
262                let devices = self.get_user_devices(&user_id).await?;
263                if devices.iter().any(|d| !d.trusted) {
264                    return Ok(true);
265                }
266            }
267        }
268        Ok(false)
269    }
270
271    pub async fn export_room_keys(&self, _passphrase: &str) -> anyhow::Result<Vec<u8>> {
272        // Placeholder - real implementation needs matrix-sdk-crypto
273        Ok(Vec::new())
274    }
275
276    pub async fn import_room_keys(
277        &self,
278        _encrypted_keys: &[u8],
279        _passphrase: &str,
280    ) -> anyhow::Result<RoomKeyImportResult> {
281        // Placeholder - real implementation needs matrix-sdk-crypto
282        Ok(RoomKeyImportResult {
283            imported_count: 0,
284            total_count: 0,
285            keys: HashMap::new(),
286        })
287    }
288
289    pub async fn export_room_keys_for_session(
290        &self,
291        room_id: &str,
292        session_id: &str,
293    ) -> anyhow::Result<Vec<String>> {
294        let key = format!("{room_id}:{session_id}");
295        Ok(vec![key])
296    }
297
298    pub async fn update_sync_data(
299        &self,
300        _to_device_messages: &[Value],
301        _otk_counts: &Value,
302        _unused_fallback_key_algs: &[String],
303        _changed_device_lists: &[String],
304        _left_device_lists: &[String],
305    ) -> anyhow::Result<()> {
306        // Placeholder: full Olm sync flow requires matrix-sdk-crypto integration.
307        Ok(())
308    }
309
310    pub async fn sign(&self, obj: &Value) -> anyhow::Result<Value> {
311        self.prepare().await?;
312
313        let mut cloned = obj.clone();
314        if let Some(map) = cloned.as_object_mut() {
315            map.remove("signatures");
316            map.remove("unsigned");
317        }
318
319        let payload = serde_json::to_vec(&cloned)?;
320        let key = self.engine.shared_secret.read().await.clone();
321        type HmacSha256 = Hmac<Sha256>;
322        let mut mac = HmacSha256::new_from_slice(&key)?;
323        mac.update(&payload);
324        let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
325
326        let user_id = if let Some(client) = &self.client {
327            client
328                .get_user_id()
329                .await
330                .unwrap_or_else(|_| "@unknown:localhost".to_owned())
331        } else {
332            "@unknown:localhost".to_owned()
333        };
334        let device_id = self
335            .device_id()
336            .await
337            .unwrap_or_else(|| "UNKNOWNDEVICE".to_owned());
338        let key_id = format!("ed25519:{device_id}");
339
340        Ok(serde_json::json!({
341            user_id: {
342                key_id: signature
343            }
344        }))
345    }
346
347    pub async fn encrypt_room_event(
348        &self,
349        room_id: &str,
350        event_type: &str,
351        content: &Value,
352    ) -> anyhow::Result<crate::models::events::EncryptedRoomEvent> {
353        self.prepare().await?;
354
355        if !self.is_room_encrypted(room_id).await? {
356            anyhow::bail!("Room is not encrypted");
357        }
358
359        let payload = serde_json::json!({
360            "type": event_type,
361            "content": content,
362        });
363        let ciphertext = self
364            .engine
365            .encrypt_message(&serde_json::to_string(&payload)?)
366            .await;
367
368        Ok(crate::models::events::EncryptedRoomEvent {
369            algorithm: "m.megolm.v1.aes-sha2".to_owned(),
370            sender_key: self.client_device_curve25519().await.unwrap_or_default(),
371            ciphertext,
372        })
373    }
374
375    pub async fn decrypt_room_event(
376        &self,
377        event: &crate::models::events::EncryptedRoomEvent,
378        room_id: &str,
379    ) -> anyhow::Result<crate::models::events::RoomEvent> {
380        self.prepare().await?;
381
382        let plaintext = self.engine.decrypt_message(&event.ciphertext).await?;
383        let payload: Value = serde_json::from_str(&plaintext)?;
384        let event_type = payload
385            .get("type")
386            .and_then(Value::as_str)
387            .unwrap_or("io.t2bot.unknown");
388        let content = payload
389            .get("content")
390            .cloned()
391            .unwrap_or(Value::Object(Default::default()));
392
393        let raw = serde_json::json!({
394            "type": event_type,
395            "content": content,
396        });
397        Ok(crate::models::events::converter::parse_room_event(
398            &raw, room_id,
399        )?)
400    }
401
402    pub async fn encrypt_media(
403        &self,
404        file: &[u8],
405    ) -> anyhow::Result<(Vec<u8>, crate::models::events::EncryptedFile)> {
406        self.prepare().await?;
407        let encrypted = self.engine.encrypt(file).await;
408
409        let hash = STANDARD.encode(Sha256::digest(&encrypted));
410        let mut hashes = HashMap::new();
411        hashes.insert("sha256".to_owned(), hash);
412
413        let key_material = self.engine.shared_secret.read().await.clone();
414        let key = URL_SAFE_NO_PAD.encode(key_material);
415        let iv = URL_SAFE_NO_PAD.encode(rand::random::<[u8; 16]>());
416
417        let file_info = crate::models::events::EncryptedFile {
418            url: String::new(),
419            key: crate::models::events::JsonWebKey {
420                kty: "oct".to_owned(),
421                alg: Some("A256CTR".to_owned()),
422                k: key,
423                ext: Some(true),
424            },
425            iv,
426            hashes,
427            version: "v2".to_owned(),
428        };
429        Ok((encrypted, file_info))
430    }
431
432    pub async fn decrypt_media_bytes(
433        &self,
434        file: &crate::models::events::EncryptedFile,
435        encrypted: &[u8],
436    ) -> anyhow::Result<Vec<u8>> {
437        if let Some(expected_hash) = file.hashes.get("sha256") {
438            let actual_hash = STANDARD.encode(Sha256::digest(encrypted));
439            if expected_hash != &actual_hash {
440                anyhow::bail!("encrypted media hash mismatch");
441            }
442        }
443        Ok(self.engine.decrypt(encrypted).await)
444    }
445
446    pub async fn decrypt_media(
447        &self,
448        file: &crate::models::events::EncryptedFile,
449    ) -> anyhow::Result<Vec<u8>> {
450        let client = self
451            .client
452            .as_ref()
453            .context("decrypt_media requires an attached MatrixClient")?;
454        if file.url.is_empty() {
455            anyhow::bail!("encrypted file url is empty");
456        }
457        let downloaded = client.download_content(&file.url, true).await?;
458        self.decrypt_media_bytes(file, &downloaded.data).await
459    }
460
461    pub async fn enable_key_backup(&self, _info: &Value) -> anyhow::Result<()> {
462        self.prepare().await?;
463        *self.backup_enabled.write().await = true;
464        Ok(())
465    }
466
467    pub async fn disable_key_backup(&self) -> anyhow::Result<()> {
468        *self.backup_enabled.write().await = false;
469        Ok(())
470    }
471
472    pub async fn is_key_backup_enabled(&self) -> bool {
473        *self.backup_enabled.read().await
474    }
475
476    pub fn engine(&self) -> &CryptoEngine {
477        &self.engine
478    }
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct RoomKeyImportResult {
483    pub imported_count: usize,
484    pub total_count: usize,
485    pub keys: HashMap<String, HashMap<String, Vec<String>>>,
486}
487
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct EncryptionInfo {
490    pub algorithm: String,
491    #[serde(default)]
492    pub rotation_period_ms: Option<u64>,
493    #[serde(default)]
494    pub rotation_period_msgs: Option<u64>,
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct DeviceInfo {
499    pub device_id: String,
500    #[serde(default)]
501    pub display_name: Option<String>,
502    #[serde(default)]
503    pub last_seen_ts: Option<u64>,
504    #[serde(default)]
505    pub trusted: bool,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
509pub struct CryptoRoomInformation {
510    #[serde(default)]
511    pub algorithm: Option<String>,
512    #[serde(default)]
513    pub rotation_period_ms: Option<u64>,
514    #[serde(default)]
515    pub rotation_period_msgs: Option<u64>,
516    #[serde(default)]
517    pub history_visibility: Option<String>,
518}
519
520#[async_trait]
521pub trait ICryptoRoomInformation: Send + Sync {
522    async fn is_room_encrypted(&self, room_id: &str) -> bool;
523}
524
525#[derive(Default, Clone)]
526pub struct RoomTracker {
527    rooms: Arc<RwLock<HashMap<String, CryptoRoomInformation>>>,
528    client: Option<MatrixClient>,
529}
530
531impl RoomTracker {
532    pub fn new() -> Self {
533        Self::default()
534    }
535
536    pub fn with_client(mut self, client: MatrixClient) -> Self {
537        self.client = Some(client);
538        self
539    }
540
541    pub async fn set_encrypted(&self, room_id: &str, encrypted: bool) {
542        let mut rooms = self.rooms.write().await;
543        let entry = rooms.entry(room_id.to_owned()).or_default();
544        if encrypted {
545            if entry.algorithm.is_none() {
546                entry.algorithm = Some("m.megolm.v1.aes-sha2".to_owned());
547            }
548        } else {
549            entry.algorithm = None;
550        }
551    }
552
553    pub async fn on_room_join(&self, room_id: &str) -> anyhow::Result<()> {
554        self.queue_room_check(room_id).await
555    }
556
557    pub async fn on_room_event(&self, room_id: &str, event: &Value) -> anyhow::Result<()> {
558        let state_key = event.get("state_key").and_then(Value::as_str);
559        if state_key != Some("") {
560            return Ok(());
561        }
562        let event_type = event
563            .get("type")
564            .and_then(Value::as_str)
565            .unwrap_or_default();
566        if event_type == "m.room.encryption" || event_type == "m.room.history_visibility" {
567            self.queue_room_check(room_id).await?;
568        }
569        Ok(())
570    }
571
572    pub async fn queue_room_check(&self, room_id: &str) -> anyhow::Result<()> {
573        let Some(client) = &self.client else {
574            return Ok(());
575        };
576
577        let mut info = self
578            .rooms
579            .read()
580            .await
581            .get(room_id)
582            .cloned()
583            .unwrap_or_default();
584
585        let encryption_state = client
586            .get_room_state_event(room_id, "m.room.encryption", "")
587            .await;
588
589        match encryption_state {
590            Ok(value) => {
591                info.algorithm = value
592                    .get("algorithm")
593                    .and_then(Value::as_str)
594                    .map(ToOwned::to_owned);
595                info.rotation_period_ms = value.get("rotation_period_ms").and_then(Value::as_u64);
596                info.rotation_period_msgs =
597                    value.get("rotation_period_msgs").and_then(Value::as_u64);
598            }
599            Err(_) => {
600                info.algorithm = None;
601                info.rotation_period_ms = None;
602                info.rotation_period_msgs = None;
603            }
604        }
605
606        if let Ok(value) = client
607            .get_room_state_event(room_id, "m.room.history_visibility", "")
608            .await
609        {
610            info.history_visibility = value
611                .get("history_visibility")
612                .and_then(Value::as_str)
613                .map(ToOwned::to_owned);
614        }
615
616        self.rooms.write().await.insert(room_id.to_owned(), info);
617        Ok(())
618    }
619
620    pub async fn get_room_crypto_config(
621        &self,
622        room_id: &str,
623    ) -> anyhow::Result<CryptoRoomInformation> {
624        if let Some(config) = self.rooms.read().await.get(room_id).cloned() {
625            return Ok(config);
626        }
627        self.queue_room_check(room_id).await?;
628        Ok(self
629            .rooms
630            .read()
631            .await
632            .get(room_id)
633            .cloned()
634            .unwrap_or_default())
635    }
636}
637
638#[async_trait]
639impl ICryptoRoomInformation for RoomTracker {
640    async fn is_room_encrypted(&self, room_id: &str) -> bool {
641        self.rooms
642            .read()
643            .await
644            .get(room_id)
645            .and_then(|info| info.algorithm.as_ref())
646            .is_some()
647    }
648}
649
650pub mod decorators {
651    use super::CryptoClient;
652
653    pub async fn encrypt_before_send(
654        crypto: &CryptoClient,
655        room_id: &str,
656        plaintext: &str,
657        room_encrypted: bool,
658    ) -> anyhow::Result<String> {
659        if room_encrypted {
660            crypto.encrypt_message(room_id, plaintext).await
661        } else {
662            Ok(plaintext.to_owned())
663        }
664    }
665}
666
667#[derive(Clone)]
668pub struct UnstableApis {
669    client: MatrixClient,
670}
671
672impl UnstableApis {
673    pub fn new(client: MatrixClient) -> Self {
674        Self { client }
675    }
676
677    pub async fn get_room_aliases(&self, room_id: &str) -> anyhow::Result<Vec<String>> {
678        let encoded = crate::client::encode_path_component(room_id);
679        let response = self
680            .client
681            .raw_json(
682                reqwest::Method::GET,
683                &format!("/_matrix/client/unstable/org.matrix.msc2432/rooms/{encoded}/aliases"),
684                None,
685            )
686            .await?;
687        Ok(response
688            .get("aliases")
689            .and_then(Value::as_array)
690            .map(|aliases| {
691                aliases
692                    .iter()
693                    .filter_map(|v| v.as_str().map(ToOwned::to_owned))
694                    .collect()
695            })
696            .unwrap_or_default())
697    }
698
699    pub async fn add_reaction_to_event(
700        &self,
701        room_id: &str,
702        event_id: &str,
703        emoji: &str,
704    ) -> anyhow::Result<String> {
705        let content = serde_json::json!({
706            "m.relates_to": {
707                "event_id": event_id,
708                "key": emoji,
709                "rel_type": "m.annotation",
710            },
711        });
712        self.client
713            .send_raw_event(room_id, "m.reaction", &content, None)
714            .await
715    }
716
717    pub async fn get_room_hierarchy(&self, room_id: &str) -> anyhow::Result<Value> {
718        let encoded = crate::client::encode_path_component(room_id);
719        self.client
720            .raw_json(
721                reqwest::Method::GET,
722                &format!("/_matrix/client/unstable/org.matrix.msc3266/rooms/{encoded}/hierarchy"),
723                None,
724            )
725            .await
726    }
727
728    pub async fn knock_room(
729        &self,
730        room_id_or_alias: &str,
731        reason: Option<&str>,
732    ) -> anyhow::Result<Value> {
733        let encoded = crate::client::encode_path_component(room_id_or_alias);
734        let body = reason
735            .map(|r| serde_json::json!({ "reason": r }))
736            .unwrap_or_default();
737        self.client
738            .raw_json(
739                reqwest::Method::POST,
740                &format!("/_matrix/client/unstable/knock/{encoded}"),
741                Some(body),
742            )
743            .await
744    }
745
746    pub async fn get_threads(&self, room_id: &str, include: Option<&str>) -> anyhow::Result<Value> {
747        let encoded = crate::client::encode_path_component(room_id);
748        let mut endpoint =
749            format!("/_matrix/client/unstable/org.matrix.msc3440/rooms/{encoded}/threads");
750        if let Some(inc) = include {
751            endpoint.push_str(&format!("?include={}", inc));
752        }
753        self.client
754            .raw_json(reqwest::Method::GET, &endpoint, None)
755            .await
756    }
757
758    pub async fn get_relations_for_event(
759        &self,
760        room_id: &str,
761        event_id: &str,
762        relation_type: Option<&str>,
763        event_type: Option<&str>,
764    ) -> anyhow::Result<Value> {
765        let room_enc = crate::client::encode_path_component(room_id);
766        let event_enc = crate::client::encode_path_component(event_id);
767
768        let mut endpoint =
769            format!("/_matrix/client/unstable/rooms/{room_enc}/relations/{event_enc}");
770        if let Some(rel_type) = relation_type {
771            endpoint.push('/');
772            endpoint.push_str(&crate::client::encode_path_component(rel_type));
773        }
774        if let Some(kind) = event_type {
775            endpoint.push('/');
776            endpoint.push_str(&crate::client::encode_path_component(kind));
777        }
778
779        self.client
780            .raw_json(reqwest::Method::GET, &endpoint, None)
781            .await
782    }
783
784    pub async fn get_related_events(&self, room_id: &str, event_id: &str) -> anyhow::Result<Value> {
785        let room_enc = crate::client::encode_path_component(room_id);
786        let event_enc = crate::client::encode_path_component(event_id);
787        self.client
788            .raw_json(
789                reqwest::Method::GET,
790                &format!("/_matrix/client/unstable/org.matrix.msc2675/rooms/{room_enc}/relations/{event_enc}"),
791                None,
792            )
793            .await
794    }
795
796    pub async fn space_summary(
797        &self,
798        room_id: &str,
799        max_rooms_per_space: Option<u32>,
800    ) -> anyhow::Result<Value> {
801        let encoded = crate::client::encode_path_component(room_id);
802        let mut endpoint =
803            format!("/_matrix/client/unstable/org.matrix.msc2946/rooms/{encoded}/hierarchy");
804        if let Some(max) = max_rooms_per_space {
805            endpoint.push_str(&format!("?max_rooms_per_space={}", max));
806        }
807        self.client
808            .raw_json(reqwest::Method::GET, &endpoint, None)
809            .await
810    }
811
812    pub async fn get_media_info(
813        &self,
814        mxc_url: &str,
815    ) -> anyhow::Result<crate::models::unstable::MSC2380MediaInfo> {
816        let stripped = mxc_url
817            .strip_prefix("mxc://")
818            .or_else(|| mxc_url.strip_prefix("MXC://"))
819            .ok_or_else(|| anyhow::anyhow!("'mxc_url' does not begin with mxc://"))?;
820        let (domain, media_id) = stripped
821            .split_once('/')
822            .ok_or_else(|| anyhow::anyhow!("missing domain or media ID"))?;
823        if domain.is_empty() || media_id.is_empty() {
824            anyhow::bail!("missing domain or media ID");
825        }
826
827        let endpoint = format!(
828            "/_matrix/media/unstable/info/{}/{}",
829            crate::client::encode_path_component(domain),
830            crate::client::encode_path_component(media_id)
831        );
832        let response = self
833            .client
834            .raw_json(reqwest::Method::GET, &endpoint, None)
835            .await?;
836        Ok(serde_json::from_value(response)?)
837    }
838
839    pub async fn get_media_config(&self) -> anyhow::Result<Value> {
840        self.client
841            .raw_json(reqwest::Method::GET, "/_matrix/media/unstable/config", None)
842            .await
843    }
844}