lark-channel 0.4.0

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

use crate::message::{MessageMention, MessageSenderType};

use super::post::{post_text_content, select_post_document};

pub(in crate::event) fn normalize_message_mentions(
    message_type: &str,
    content: Option<&Value>,
    event_mentions: Vec<MessageMention>,
) -> Vec<MessageMention> {
    let mut mentions = Vec::new();

    for mention in event_mentions {
        push_message_mention(&mut mentions, mention);
    }

    if message_type == "post" {
        for mention in parse_post_mentions(content) {
            push_message_mention(&mut mentions, mention);
        }
    }

    mentions
}

fn parse_post_mentions(content: Option<&Value>) -> Vec<MessageMention> {
    let Some(document) = content.and_then(select_post_document) else {
        return Vec::new();
    };

    let Some(content) = post_text_content(document) else {
        return Vec::new();
    };

    let mut mentions = Vec::new();
    for line in content {
        let Some(elements) = line.as_array() else {
            continue;
        };
        for element in elements {
            if let Some(mention) = post_at_mention(element) {
                push_message_mention(&mut mentions, mention);
            }
        }
    }

    mentions
}

fn post_at_mention(element: &Value) -> Option<MessageMention> {
    if element.get("tag").and_then(Value::as_str) != Some("at") {
        return None;
    }

    let raw_user_id = element
        .get("user_id")
        .and_then(Value::as_str)
        .unwrap_or_default();
    let name = element
        .get("user_name")
        .or_else(|| element.get("text"))
        .and_then(Value::as_str)
        .filter(|name| !name.is_empty())
        .map(str::to_owned);

    if raw_user_id.is_empty() {
        return None;
    }

    if raw_user_id == "all" || raw_user_id == "all_members" {
        return Some(MessageMention {
            key: "@_all".to_owned(),
            open_id: String::new(),
            user_id: None,
            union_id: None,
            name,
            mentioned_type: MessageSenderType::Unknown,
        });
    }

    let (open_id, user_id) = if raw_user_id.starts_with("ou_") {
        (raw_user_id.to_owned(), None)
    } else {
        (String::new(), Some(raw_user_id.to_owned()))
    };

    Some(MessageMention {
        key: String::new(),
        open_id,
        user_id,
        union_id: None,
        name,
        mentioned_type: MessageSenderType::Unknown,
    })
}

fn push_message_mention(mentions: &mut Vec<MessageMention>, mention: MessageMention) {
    if mention.key.is_empty()
        && mention.open_id.is_empty()
        && mention.user_id.is_none()
        && mention.union_id.is_none()
    {
        return;
    }

    if let Some(existing) = mentions
        .iter_mut()
        .find(|existing| same_message_mention(existing, &mention))
    {
        merge_message_mention(existing, mention);
    } else {
        mentions.push(mention);
    }
}

fn same_message_mention(left: &MessageMention, right: &MessageMention) -> bool {
    (!left.key.is_empty() && left.key == right.key)
        || (!left.open_id.is_empty() && left.open_id == right.open_id)
        || same_non_empty_optional_id(left.user_id.as_deref(), right.user_id.as_deref())
        || same_non_empty_optional_id(left.union_id.as_deref(), right.union_id.as_deref())
}

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

fn merge_message_mention(existing: &mut MessageMention, next: MessageMention) {
    if existing.key.is_empty() {
        existing.key = next.key;
    }
    if existing.open_id.is_empty() {
        existing.open_id = next.open_id;
    }
    if existing.user_id.is_none() {
        existing.user_id = next.user_id;
    }
    if existing.union_id.is_none() {
        existing.union_id = next.union_id;
    }
    if existing.name.is_none() {
        existing.name = next.name;
    }
    if existing.mentioned_type == MessageSenderType::Unknown {
        existing.mentioned_type = next.mentioned_type;
    }
}

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

    use super::*;

    fn mention(key: &str, open_id: &str, name: &str) -> MessageMention {
        MessageMention {
            key: key.to_owned(),
            open_id: open_id.to_owned(),
            user_id: None,
            union_id: None,
            name: Some(name.to_owned()),
            mentioned_type: MessageSenderType::Unknown,
        }
    }

    #[test]
    fn deduplicates_duplicate_event_mentions_by_identity() {
        let content = json!({
            "text": "@_user_1 then @_user_2"
        });
        let mentions = vec![
            mention("@_user_1", "ou_bot", "Bot"),
            mention("@_user_2", "ou_bot", "Bot"),
        ];

        let public_mentions = normalize_message_mentions("text", Some(&content), mentions);
        assert_eq!(public_mentions.len(), 1);
        assert_eq!(public_mentions[0].key, "@_user_1");
        assert_eq!(public_mentions[0].open_id, "ou_bot");
    }

    #[test]
    fn normalizes_lark_post_message_mentions() {
        let content = json!({
            "title": "Post title",
            "content": [
                [
                    {
                        "tag": "text",
                        "text": "hello "
                    },
                    {
                        "tag": "at",
                        "user_id": "ou_bot",
                        "user_name": "Bot"
                    }
                ]
            ]
        });

        let mentions = normalize_message_mentions("post", Some(&content), Vec::new());
        assert_eq!(mentions.len(), 1);
        assert_eq!(mentions[0].open_id, "ou_bot");
        assert_eq!(mentions[0].name.as_deref(), Some("Bot"));
    }

    #[test]
    fn normalizes_content_v2_only_post_mentions() {
        let content = json!({
            "title": "Post title",
            "content_v2": [
                [
                    {
                        "tag": "text",
                        "text": "hello "
                    },
                    {
                        "tag": "at",
                        "user_id": "ou_bot",
                        "user_name": "Bot"
                    }
                ]
            ]
        });

        let mentions = normalize_message_mentions("post", Some(&content), Vec::new());
        assert_eq!(mentions.len(), 1);
        assert_eq!(mentions[0].open_id, "ou_bot");
        assert_eq!(mentions[0].name.as_deref(), Some("Bot"));
    }

    #[test]
    fn deduplicates_lark_post_mentions_against_event_metadata() {
        let content = json!({
            "title": "",
            "content": [[
                {
                    "tag": "at",
                    "user_id": "ou_bot",
                    "user_name": "Bot"
                }
            ]]
        });
        let event_mentions = vec![MessageMention {
            key: "@_user_1".to_owned(),
            open_id: "ou_bot".to_owned(),
            user_id: None,
            union_id: None,
            name: None,
            mentioned_type: MessageSenderType::Unknown,
        }];

        let mentions = normalize_message_mentions("post", Some(&content), event_mentions);
        assert_eq!(mentions.len(), 1);
        assert_eq!(mentions[0].key, "@_user_1");
        assert_eq!(mentions[0].open_id, "ou_bot");
        assert_eq!(mentions[0].name.as_deref(), Some("Bot"));
    }

    #[test]
    fn does_not_deduplicate_distinct_mentions_with_empty_optional_ids() {
        let content = json!({
            "text": "@_a @_b"
        });
        let mentions = vec![mention("@_a", "ou_a", "A"), mention("@_b", "ou_b", "B")];

        let mentions = normalize_message_mentions("text", Some(&content), mentions);
        assert_eq!(mentions.len(), 2);
        assert_eq!(mentions[0].open_id, "ou_a");
        assert_eq!(mentions[0].user_id, None);
        assert_eq!(mentions[0].union_id, None);
        assert_eq!(mentions[1].open_id, "ou_b");
        assert_eq!(mentions[1].user_id, None);
        assert_eq!(mentions[1].union_id, None);
    }

    #[test]
    fn skips_rich_text_at_mentions_without_identifier() {
        let content = json!({
            "title": "",
            "content": [[
                {
                    "tag": "at",
                    "user_name": "Visible"
                }
            ]]
        });

        assert!(normalize_message_mentions("post", Some(&content), Vec::new()).is_empty());
    }
}