im-core 0.1.0

Rust IM SDK for Awiki clients built on Agent Network Protocol (ANP)
Documentation
use serde_json::Value;

#[cfg(feature = "sqlite")]
use rusqlite::OptionalExtension;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AttachmentManifestCacheRecord {
    pub(crate) owner_identity_id: String,
    pub(crate) owner_did: String,
    pub(crate) thread_kind: String,
    pub(crate) thread_id: String,
    pub(crate) message_id: String,
    pub(crate) sender_did: String,
    pub(crate) message_security_profile: String,
    pub(crate) content: String,
    pub(crate) stored_at: String,
}

#[cfg(feature = "sqlite")]
pub(crate) fn upsert_attachment_manifest_cache(
    connection: &rusqlite::Connection,
    record: &AttachmentManifestCacheRecord,
) -> crate::ImResult<()> {
    crate::internal::local_state::schema::ensure_schema(connection)?;
    let owner_identity_id = required("owner_identity_id", &record.owner_identity_id)?;
    let message_id = required("message_id", &record.message_id)?;
    let thread_kind = required("thread_kind", &record.thread_kind)?;
    let thread_id = required("thread_id", &record.thread_id)?;
    let content = required("content", &record.content)?;
    let stored_at = default_string(&record.stored_at, &now_utc_like());
    connection
        .execute(
            r#"
INSERT INTO attachment_manifest_cache
    (owner_identity_id, owner_did, thread_kind, thread_id, message_id, sender_did,
     message_security_profile, content, stored_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(owner_identity_id, thread_kind, thread_id, message_id) DO UPDATE SET
    owner_did = excluded.owner_did,
    sender_did = excluded.sender_did,
    message_security_profile = excluded.message_security_profile,
    content = excluded.content,
    stored_at = excluded.stored_at"#,
            rusqlite::params![
                owner_identity_id,
                record.owner_did.trim(),
                thread_kind,
                thread_id,
                message_id,
                record.sender_did.trim(),
                default_string(&record.message_security_profile, "transport-protected"),
                content,
                stored_at,
            ],
        )
        .map_err(super::local_state_unavailable)?;
    Ok(())
}

#[cfg(feature = "sqlite")]
pub(crate) fn get_attachment_manifest_cache_message(
    connection: &rusqlite::Connection,
    owner_identity_id: &str,
    thread_kind: &str,
    thread_id: &str,
    message_id: &str,
) -> crate::ImResult<Option<Value>> {
    crate::internal::local_state::schema::ensure_schema(connection)?;
    let owner_identity_id = required("owner_identity_id", owner_identity_id)?;
    let thread_kind = required("thread_kind", thread_kind)?;
    let thread_id = required("thread_id", thread_id)?;
    let message_id = required("message_id", message_id)?;
    let row = connection
        .query_row(
            r#"
SELECT message_id, sender_did, message_security_profile, content
FROM attachment_manifest_cache
WHERE owner_identity_id = ?1
  AND thread_kind = ?2
  AND thread_id = ?3
  AND message_id = ?4"#,
            rusqlite::params![owner_identity_id, thread_kind, thread_id, message_id],
            |row| {
                Ok((
                    row.get::<_, String>("message_id")?,
                    row.get::<_, Option<String>>("sender_did")?
                        .unwrap_or_default(),
                    row.get::<_, Option<String>>("message_security_profile")?
                        .unwrap_or_default(),
                    row.get::<_, String>("content")?,
                ))
            },
        )
        .optional()
        .map_err(super::local_state_unavailable)?;
    let Some((message_id, sender_did, security_profile, content)) = row else {
        return Ok(None);
    };
    let content: Value =
        serde_json::from_str(&content).map_err(|err| crate::ImError::Serialization {
            detail: err.to_string(),
        })?;
    Ok(Some(serde_json::json!({
        "id": message_id,
        "message_id": message_id,
        "sender_did": sender_did,
        "message_security_profile": default_string(&security_profile, "transport-protected"),
        "security": default_string(&security_profile, "transport-protected"),
        "content_type": crate::attachments::manifest::attachment_manifest_content_type(),
        "type": "attachment_manifest",
        "secure": security_profile.trim().ends_with("e2ee"),
        "decrypted": true,
        "decryption_state": "decrypted",
        "content": content
    })))
}

#[cfg(feature = "sqlite")]
fn required(field: &str, value: &str) -> crate::ImResult<String> {
    let value = value.trim();
    if value.is_empty() {
        return Err(crate::ImError::invalid_input(
            Some(field.to_owned()),
            format!("{field} must not be empty"),
        ));
    }
    Ok(value.to_owned())
}

#[cfg(feature = "sqlite")]
fn default_string(value: &str, fallback: &str) -> String {
    let value = value.trim();
    if value.is_empty() {
        fallback.to_owned()
    } else {
        value.to_owned()
    }
}

#[cfg(feature = "sqlite")]
fn now_utc_like() -> String {
    time::OffsetDateTime::now_utc()
        .format(&time::format_description::well_known::Rfc3339)
        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned())
}

#[cfg(all(test, feature = "sqlite"))]
mod tests {
    use super::*;
    use rusqlite::OptionalExtension;

    #[test]
    fn attachment_manifest_cache_stores_internal_full_manifest() {
        let db = rusqlite::Connection::open_in_memory().unwrap();
        let manifest = serde_json::json!({
            "attachments": [{
                "attachment_id": "att-group-secure",
                "size": "27",
                "digest": {
                    "alg": "sha-256",
                    "value_b64u": "digest"
                },
                "access_info": {
                    "object_uri": "https://objects.example/secure"
                },
                "encryption_info": {
                    "mode": "object-e2ee",
                    "object_cipher": "chacha20-poly1305",
                    "object_key_b64u": "OBJECT-KEY-SECRET",
                    "nonce_b64u": "NONCE-SECRET",
                    "plaintext_size": "11"
                }
            }]
        });

        upsert_attachment_manifest_cache(
            &db,
            &AttachmentManifestCacheRecord {
                owner_identity_id: "bob-id".to_owned(),
                owner_did: "did:example:bob".to_owned(),
                thread_kind: "group".to_owned(),
                thread_id: "did:example:group".to_owned(),
                message_id: "did:example:group:7".to_owned(),
                sender_did: "did:example:alice".to_owned(),
                message_security_profile: "group-e2ee".to_owned(),
                content: serde_json::to_string(&manifest).unwrap(),
                stored_at: "2026-06-02T00:00:00Z".to_owned(),
            },
        )
        .unwrap();

        let message = get_attachment_manifest_cache_message(
            &db,
            "bob-id",
            "group",
            "did:example:group",
            "did:example:group:7",
        )
        .unwrap()
        .unwrap();
        assert_eq!(
            message["content"]["attachments"][0]["encryption_info"]["object_key_b64u"],
            "OBJECT-KEY-SECRET"
        );
        assert_eq!(message["message_security_profile"], "group-e2ee");

        let stored = db
            .query_row(
                "SELECT content FROM attachment_manifest_cache WHERE owner_identity_id = 'bob-id'",
                [],
                |row| row.get::<_, String>(0),
            )
            .optional()
            .unwrap()
            .unwrap();
        assert!(stored.contains("OBJECT-KEY-SECRET"));
    }
}