lark-channel 0.4.0

Lark/Feishu Channel SDK for Rust
Documentation
use serde_json::Value;

use crate::media::{ResourceDescriptor, ResourceType};

use super::post::select_post_document;

pub(in crate::event) fn normalize_message_resources(
    message_id: &str,
    message_type: &str,
    content: Option<&Value>,
) -> Vec<ResourceDescriptor> {
    let Some(content) = content else {
        return Vec::new();
    };

    let mut resources = Vec::new();
    match message_type {
        "image" => push_resource(
            &mut resources,
            resource_descriptor(message_id, ResourceType::Image, None, image_key(content)),
        ),
        "file" => {
            let mut resource =
                resource_descriptor(message_id, ResourceType::File, file_key(content), None);
            resource.file_name = file_name(content);
            push_resource(&mut resources, resource);
        }
        "folder" => {
            let mut resource =
                resource_descriptor(message_id, ResourceType::Folder, file_key(content), None);
            resource.file_name = file_name(content);
            push_resource(&mut resources, resource);
        }
        "audio" => {
            let mut resource =
                resource_descriptor(message_id, ResourceType::Audio, file_key(content), None);
            resource.duration_ms = duration_ms(content);
            push_resource(&mut resources, resource);
        }
        "media" => push_resource(
            &mut resources,
            media_resource_descriptor(
                message_id,
                file_key(content),
                image_key(content),
                file_name(content),
                duration_ms(content),
            ),
        ),
        "sticker" => push_resource(
            &mut resources,
            resource_descriptor(message_id, ResourceType::Sticker, file_key(content), None),
        ),
        "post" => {
            for resource in parse_post_resources(message_id, content) {
                push_resource(&mut resources, resource);
            }
        }
        _ => {}
    }

    resources
}

fn parse_post_resources(message_id: &str, content: &Value) -> Vec<ResourceDescriptor> {
    let Some(document) = select_post_document(content) else {
        return Vec::new();
    };

    let mut resources = Vec::new();
    for content_key in ["content", "content_v2"] {
        let Some(lines) = document.get(content_key).and_then(Value::as_array) else {
            continue;
        };

        for line in lines {
            let Some(elements) = line.as_array() else {
                continue;
            };

            for element in elements {
                if let Some(resource) = post_resource(message_id, element) {
                    push_resource(&mut resources, resource);
                }
            }
        }
    }

    resources
}

fn post_resource(message_id: &str, element: &Value) -> Option<ResourceDescriptor> {
    match element.get("tag").and_then(Value::as_str) {
        Some("img") => Some(resource_descriptor(
            message_id,
            ResourceType::Image,
            None,
            image_key(element),
        )),
        Some("media") => Some(media_resource_descriptor(
            message_id,
            file_key(element),
            image_key(element),
            file_name(element),
            duration_ms(element),
        )),
        _ => None,
    }
}

fn resource_descriptor(
    message_id: &str,
    resource_type: ResourceType,
    file_key: Option<String>,
    image_key: Option<String>,
) -> ResourceDescriptor {
    ResourceDescriptor {
        message_id: message_id.to_owned(),
        resource_type,
        file_key,
        image_key,
        file_name: None,
        duration_ms: None,
    }
}

fn media_resource_descriptor(
    message_id: &str,
    file_key: Option<String>,
    image_key: Option<String>,
    file_name: Option<String>,
    duration_ms: Option<u64>,
) -> ResourceDescriptor {
    let mut resource = resource_descriptor(message_id, ResourceType::Media, file_key, image_key);
    resource.file_name = file_name;
    resource.duration_ms = duration_ms;
    resource
}

fn file_key(value: &Value) -> Option<String> {
    non_empty_string(value, "file_key")
}

fn image_key(value: &Value) -> Option<String> {
    non_empty_string(value, "image_key")
}

fn file_name(value: &Value) -> Option<String> {
    non_empty_string(value, "file_name")
}

fn non_empty_string(value: &Value, key: &str) -> Option<String> {
    value
        .get(key)
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty())
        .map(str::to_owned)
}

fn duration_ms(value: &Value) -> Option<u64> {
    value.get("duration").and_then(|duration| {
        duration
            .as_u64()
            .or_else(|| duration.as_str()?.parse().ok())
    })
}

fn push_resource(resources: &mut Vec<ResourceDescriptor>, resource: ResourceDescriptor) {
    if resource.file_key.is_none() && resource.image_key.is_none() {
        return;
    }

    if let Some(existing) = resources
        .iter_mut()
        .find(|existing| same_resource(existing, &resource))
    {
        merge_resource(existing, resource);
    } else {
        resources.push(resource);
    }
}

fn same_resource(left: &ResourceDescriptor, right: &ResourceDescriptor) -> bool {
    if left.resource_type != right.resource_type {
        return false;
    }

    match left.resource_type {
        ResourceType::Image => {
            same_resource_key(left.image_key.as_deref(), right.image_key.as_deref())
        }
        ResourceType::File | ResourceType::Folder | ResourceType::Audio | ResourceType::Sticker => {
            same_resource_key(left.file_key.as_deref(), right.file_key.as_deref())
        }
        ResourceType::Media => {
            same_resource_key(left.file_key.as_deref(), right.file_key.as_deref())
                || (left.file_key.is_none()
                    && right.file_key.is_none()
                    && same_resource_key(left.image_key.as_deref(), right.image_key.as_deref()))
        }
        ResourceType::Unknown => {
            same_resource_key(left.file_key.as_deref(), right.file_key.as_deref())
                || same_resource_key(left.image_key.as_deref(), right.image_key.as_deref())
        }
    }
}

fn same_resource_key(left: Option<&str>, right: Option<&str>) -> bool {
    matches!((left, right), (Some(left), Some(right)) if !left.is_empty() && left == right)
}

fn merge_resource(existing: &mut ResourceDescriptor, next: ResourceDescriptor) {
    if existing.file_key.is_none() {
        existing.file_key = next.file_key;
    }
    if existing.image_key.is_none() {
        existing.image_key = next.image_key;
    }
    if existing.file_name.is_none() {
        existing.file_name = next.file_name;
    }
    if existing.duration_ms.is_none() {
        existing.duration_ms = next.duration_ms;
    }
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;

    #[test]
    fn normalizes_direct_media_message_resources() {
        let image = json!({
            "image_key": "img_1"
        });
        let resources = normalize_message_resources("om_image", "image", Some(&image));
        assert_eq!(resources.len(), 1);
        assert_eq!(resources[0].message_id, "om_image");
        assert_eq!(resources[0].resource_type, ResourceType::Image);
        assert_eq!(resources[0].file_key, None);
        assert_eq!(resources[0].image_key.as_deref(), Some("img_1"));

        let file = json!({
            "file_key": "file_1",
            "file_name": "report.pdf"
        });
        let resources = normalize_message_resources("om_file", "file", Some(&file));
        assert_eq!(resources.len(), 1);
        assert_eq!(resources[0].resource_type, ResourceType::File);
        assert_eq!(resources[0].file_key.as_deref(), Some("file_1"));
        assert_eq!(resources[0].file_name.as_deref(), Some("report.pdf"));

        let audio = json!({
            "file_key": "audio_1",
            "duration": 2000
        });
        let resources = normalize_message_resources("om_audio", "audio", Some(&audio));
        assert_eq!(resources.len(), 1);
        assert_eq!(resources[0].resource_type, ResourceType::Audio);
        assert_eq!(resources[0].file_key.as_deref(), Some("audio_1"));
        assert_eq!(resources[0].duration_ms, Some(2000));

        let media = json!({
            "file_key": "video_1",
            "image_key": "img_cover_1",
            "file_name": "demo.mp4",
            "duration": "3000"
        });
        let resources = normalize_message_resources("om_media", "media", Some(&media));
        assert_eq!(resources.len(), 1);
        assert_eq!(resources[0].resource_type, ResourceType::Media);
        assert_eq!(resources[0].file_key.as_deref(), Some("video_1"));
        assert_eq!(resources[0].image_key.as_deref(), Some("img_cover_1"));
        assert_eq!(resources[0].file_name.as_deref(), Some("demo.mp4"));
        assert_eq!(resources[0].duration_ms, Some(3000));
    }

    #[test]
    fn normalizes_folder_and_sticker_resource_descriptors() {
        let folder = json!({
            "file_key": "folder_1",
            "file_name": "folder"
        });
        let resources = normalize_message_resources("om_folder", "folder", Some(&folder));
        assert_eq!(resources.len(), 1);
        assert_eq!(resources[0].resource_type, ResourceType::Folder);
        assert_eq!(resources[0].file_key.as_deref(), Some("folder_1"));
        assert_eq!(resources[0].file_name.as_deref(), Some("folder"));

        let sticker = json!({
            "file_key": "sticker_1"
        });
        let resources = normalize_message_resources("om_sticker", "sticker", Some(&sticker));
        assert_eq!(resources.len(), 1);
        assert_eq!(resources[0].resource_type, ResourceType::Sticker);
        assert_eq!(resources[0].file_key.as_deref(), Some("sticker_1"));
    }

    #[test]
    fn normalizes_post_media_resources_and_deduplicates_content_v2() {
        let content = json!({
            "title": "resources",
            "content": [
                [
                    {
                        "tag": "img",
                        "image_key": "img_1"
                    },
                    {
                        "tag": "media",
                        "file_key": "video_1"
                    }
                ]
            ],
            "content_v2": [
                [
                    {
                        "tag": "img",
                        "image_key": "img_1"
                    },
                    {
                        "tag": "media",
                        "file_key": "video_1",
                        "image_key": "img_cover_1",
                        "file_name": "demo.mp4",
                        "duration": 3000
                    }
                ]
            ]
        });

        let resources = normalize_message_resources("om_post", "post", Some(&content));
        assert_eq!(resources.len(), 2);
        assert_eq!(resources[0].resource_type, ResourceType::Image);
        assert_eq!(resources[0].image_key.as_deref(), Some("img_1"));
        assert_eq!(resources[1].resource_type, ResourceType::Media);
        assert_eq!(resources[1].file_key.as_deref(), Some("video_1"));
        assert_eq!(resources[1].image_key.as_deref(), Some("img_cover_1"));
        assert_eq!(resources[1].file_name.as_deref(), Some("demo.mp4"));
        assert_eq!(resources[1].duration_ms, Some(3000));
    }

    #[test]
    fn normalizes_content_v2_only_post_media_resources() {
        let content = json!({
            "content_v2": [
                [
                    {
                        "tag": "media",
                        "file_key": "video_1",
                        "image_key": "img_cover_1",
                        "file_name": "demo.mp4",
                        "duration": 3000
                    }
                ]
            ]
        });

        let resources = normalize_message_resources("om_post", "post", Some(&content));
        assert_eq!(resources.len(), 1);
        assert_eq!(resources[0].resource_type, ResourceType::Media);
        assert_eq!(resources[0].file_key.as_deref(), Some("video_1"));
        assert_eq!(resources[0].image_key.as_deref(), Some("img_cover_1"));
        assert_eq!(resources[0].file_name.as_deref(), Some("demo.mp4"));
        assert_eq!(resources[0].duration_ms, Some(3000));
    }

    #[test]
    fn skips_resource_descriptors_without_resource_keys() {
        let image = json!({
            "image_key": ""
        });
        assert!(normalize_message_resources("om_image", "image", Some(&image)).is_empty());

        let audio = json!({
            "duration": 2000
        });
        assert!(normalize_message_resources("om_audio", "audio", Some(&audio)).is_empty());
    }
}