飞书消息通知要怎么声明 Rust 结构体


在企业服务这一板块,除了钉钉、企业微信,还有飞书,如果你要是在做一个与各大平台对接的产品,基本上必然少不了要对接各个平台的消息通知。

所以今天来简单看看飞书的消息通知在 Rust 中又会怎样声明数据结构。

飞书的消息格式是三大平台中形式最为丰富结构最为复杂的,无论是在技术领域还是在某些领域,后来者居上始终是一条铁律,如果一个后来者做不到超越前者,而如果仅仅只是跟随或者受到了前者的牵绊,那么就始终是坐不上头把交椅的。

而这个超越,不是说功能做的多么地复杂功能有多么地多,而是最终的客户体验的全面超越。支持做到客户体验的超越,背后是整个思维体系、产品理念、技术架构的全面换代超越,而这是平庸的部门管理者所无法真正理解的,毕竟职场里面的利益斗争真的是处处可见,几乎没有人是首先想到的是如何把事情做好,反而是首先上来就考虑自己的眼前的短期利益,而冠冕堂皇地还会被封装在一个叫做“投入产出比” “提高执行效率”的套话里面。

但始终是没有人会真正地理解:眼前最优解并不是近期最优解,近期最优解并不是长期最优解。

看似选对了每一小步的最优解,但却离全局最优解越来越远。深度学习为了防止过度拟合都需要引入随机变量,而企业内部如果只为追求完美无暇和表面上的漂亮,最终将获得的是一潭死水。

理念和方向选错了,走的再快那又如何呢?

我又废话了,扯回来飞书消息通知,它发送消息的内容格式说明的文档在 https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/im-v1/message/create_json

第一眼看上去你以为跟钉钉和企业微信也没啥差别,不就是那么几种常见的消息格式么,但是如果仔细想,每个平台都有一种消息格式是比较特殊和复杂度较高显示效果漂亮的,其实飞书也不例外:消息卡片,可以从文档跳到这里地址:https://open.feishu.cn/document/ukTMukTMukTM/uczM3QjL3MzN04yNzcDN 就能看到这个极为复杂和效果丰富的消息卡片的说明了。

这次由于格式复杂,内容量很大,就不全量贴代码了(其实是没写完 ^-^ )

先看几个简单的例子:

一开始的文本消息就有好几个例子,我们只先看一个。

// 文本消息
{
    "receive_id": "oc_xxx",
    "content": "{\"text\":\"firstline \\n second line  \"}",
    "msg_type": "text"
}
// 图片消息
{
	"receive_id": "oc_xxx",
	"content": "{\"image_key\": \"img_v2_xxx\"}",
	"msg_type": "image"
}
// 富文本消息
{
	"zh_cn": {
		"title": "我是一个标题",
		"content": [
			[
				{
					"tag": "text",
					"text": "第一行:",
					"style": ["bold", "underline"]
                  
				},
				{
					"tag": "a",
					"href": "http://www.feishu.cn",
					"text": "超链接",
					"style": ["bold", "italic"]
				},
				{
					"tag": "at",
					"user_id": "ou_1avnmsbv3k45jnk34j5",
					"user_name": "tom",
					"style": ["lineThrough"]
				}
			],
			[{
				"tag": "img",
				"image_key": "img_7ea74629-9191-4176-998c-2e603c9c5e8g"
			}],
			[	
				{
					"tag": "text",
					"text": "第二行:",
					"style": ["bold", "underline"]
				},
				{
					"tag": "text",
					"text": "文本测试"
				}
			],
			[{
				"tag": "img",
				"image_key": "img_7ea74629-9191-4176-998c-2e603c9c5e8g"
			}],
          	[{
				"tag": "media",
				"file_key": "file_v2_0dcdd7d9-fib0-4432-a519-41d25aca542j",
				"image_key": "img_7ea74629-9191-4176-998c-2e603c9c5e8g"
			}],
          	[{
				"tag": "emotion",
				"emoji_type": "SMILE"
			}]
		]
	},
	"en_us": {
		...
	}
}

这也太突然了,富文本是什么东西,这结构就复杂非常多了。

然后它还有语音、视频、文件、表情包、个人名片、群名片,这些就不看了,我们看看还有最特殊的:消息卡片。

// 消息卡片
{
    "receive_id": "oc_820faa21d7ed275b53d1727a0feaa917",
    "content": "{\"config\":{\"wide_screen_mode\":true},\"elements\":[{\"alt\":{\"content\":\"\",\"tag\":\"plain_text\"},\"img_key\":\"img_7ea74629-9191-4176-998c-2e603c9c5e8g\",\"tag\":\"img\"},{\"tag\":\"div\",\"text\":{\"content\":\"你是否曾因为一本书而产生心灵共振,开始感悟人生?\\n你有哪些想极力推荐给他人的珍藏书单?\\n\\n加入 **4·23 飞书读书节**,分享你的**挚爱书单**及**读书笔记**,**赢取千元读书礼**!\\n\\n📬 填写问卷,晒出你的珍藏好书\\n😍 想知道其他人都推荐了哪些好书?马上[入群围观](https://open.feishu.cn/)\\n📝 用[读书笔记模板](https://open.feishu.cn/)(桌面端打开),记录你的心得体会\\n🙌 更有惊喜特邀嘉宾 4月12日起带你共读\",\"tag\":\"lark_md\"}},{\"actions\":[{\"tag\":\"button\",\"text\":{\"content\":\"立即推荐好书\",\"tag\":\"plain_text\"},\"type\":\"primary\",\"url\":\"https://open.feishu.cn/\"},{\"tag\":\"button\",\"text\":{\"content\":\"查看活动指南\",\"tag\":\"plain_text\"},\"type\":\"default\",\"url\":\"https://open.feishu.cn/\"}],\"tag\":\"action\"}],\"header\":{\"template\":\"turquoise\",\"title\":{\"content\":\"📚晒挚爱好书,赢读书礼金\",\"tag\":\"plain_text\"}}}",
    "msg_type": "interactive"
}

呃这里面中间一坨大 json 序列化过后得到的字符串,我们看看它原来的样子,把 content 取出来单独看。

{
    "config":{
        "wide_screen_mode":true
    },
    "elements":[
        {
            "alt":{
                "content":"",
                "tag":"plain_text"
            },
            "img_key":"img_7ea74629-9191-4176-998c-2e603c9c5e8g",
            "tag":"img"
        },
        {
            "tag":"div",
            "text":{
                "content":"你是否曾因为一本书而产生心灵共振,开始感悟人生?
你有哪些想极力推荐给他人的珍藏书单?

加入 **4·23 飞书读书节**,分享你的**挚爱书单**及**读书笔记**,**赢取千元读书礼**!

📬 填写问卷,晒出你的珍藏好书
😍 想知道其他人都推荐了哪些好书?马上[入群围观](https://open.feishu.cn/)
📝 用[读书笔记模板](https://open.feishu.cn/)(桌面端打开),记录你的心得体会
🙌 更有惊喜特邀嘉宾 4月12日起带你共读",
                "tag":"lark_md"
            }
        },
        {
            "actions":[
                {
                    "tag":"button",
                    "text":{
                        "content":"立即推荐好书",
                        "tag":"plain_text"
                    },
                    "type":"primary",
                    "url":"https://open.feishu.cn/"
                },
                {
                    "tag":"button",
                    "text":{
                        "content":"查看活动指南",
                        "tag":"plain_text"
                    },
                    "type":"default",
                    "url":"https://open.feishu.cn/"
                }
            ],
            "tag":"action"
        }
    ],
    "header":{
        "template":"turquoise",
        "title":{
            "content":"📚晒挚爱好书,赢读书礼金",
            "tag":"plain_text"
        }
    }
}

是不是觉得已经要放弃了?根据我自己的经历,没有人会想要真正去研究它到底有哪些组件,以及它们又是如何实现组合回来的。

我们先从外层干起。

model/msg/feishu/mod.rs

pub mod interactive;
pub mod applink;

use crate::model::msg::feishu::interactive::{FeishuInteractiveMsg, Language};

/// 飞书消息类型
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Ord, PartialOrd, Eq, PartialEq, Hash)]
pub enum FeishuMsgType {
    /// 文本
    #[serde(rename = "text")]
    Text,
    /// 富文本
    #[serde(rename = "post")]
    Post,
    /// 图片
    #[serde(rename = "image")]
    Image,
    /// 消息卡片
    #[serde(rename = "interactive")]
    Interactive,
    /// 分享群名片
    #[serde(rename = "share_chat")]
    ShareChat,
    /// 分享个人名片
    #[serde(rename = "share_user")]
    ShareUser,
    /// 语音
    #[serde(rename = "audio")]
    Audio,
    /// 视频
    #[serde(rename = "media")]
    Media,
    /// 文件
    #[serde(rename = "file")]
    File,
    /// 表情包
    #[serde(rename = "sticker")]
    Sticker,
}

/// 飞书消息提交参数,把消息序列化后再用它解析出来,内层的消息体就是字符串
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuMsgReq {
    pub msg_type: FeishuMsgType,
    pub content: String,
}


/// 飞书消息 @ At
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuMsgAt {
    pub user_id: Option<String>,
    pub user_name: Option<String>,
    pub is_to_all: Option<bool>,
}

impl Display for FeishuMsgAt {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        if let Some(is_to_all) = self.is_to_all {
            if is_to_all {
                return f.write_str(format!("<at user_id=\"all\">所有人</at>").as_str());
            }
        }
        if let Some(user_id) = &self.user_id {
            if let Some(user_name) = &self.user_name {
                return f.write_str(format!("<at user_id=\"{}\">{}</at>", user_id, user_name).as_str());
            }
        }
        error!("参数错误:user_id, user_name / is_to_all 不能同时为 None");
        Err(std::fmt::Error)
    }
}


/// 飞书消息
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(tag = "msg_type", content = "content")]
pub enum FeishuMsg {
    /// 文本
    #[serde(rename = "text")]
    Text(FeishuTextMsg),
    /// 富文本
    #[serde(rename = "post")]
    Post(FeishuPostMsg),
    /// 图片
    #[serde(rename = "image")]
    Image(FeishuImageMsg),
    /// 消息卡片
    #[serde(rename = "interactive")]
    Interactive(FeishuInteractiveMsg),
    /// 分享群名片
    #[serde(rename = "share_chat")]
    ShareChat(FeishuShareChatMsg),
    /// 分享个人名片
    #[serde(rename = "share_user")]
    ShareUser(FeishuShareUserMsg),
    /// 语音
    #[serde(rename = "audio")]
    Audio(FeishuAudioMsg),
    /// 视频
    #[serde(rename = "media")]
    Media(FeishuMediaMsg),
    /// 文件
    #[serde(rename = "file")]
    File(FeishuFileMsg),
    /// 表情包
    #[serde(rename = "sticker")]
    Sticker(FeishuStickerMsg),
}

/// 文本
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuTextMsg {
    pub text: String,
}

/// 富文本
pub type FeishuPostMsg = HashMap<Language, FeishuPostMsgBody>;


/// 富文本 消息体
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuPostMsgBody {
    pub title: String,
    pub content: Vec<FeishuPostMsgBodyParagraph>,
}

/// 富文本 消息体 段落
pub type FeishuPostMsgBodyParagraph = Vec<FeishuPostMsgBodyParagraphItem>;

/// 富文本 消息体 段落 Item
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(tag = "tag")]
pub enum FeishuPostMsgBodyParagraphItem {
    #[serde(rename = "text")]
    Text(FeishuPostMsgBodyParagraphItemText),
    #[serde(rename = "a")]
    A(FeishuPostMsgBodyParagraphItemA),
    #[serde(rename = "at")]
    At(FeishuPostMsgBodyParagraphItemAt),
    #[serde(rename = "img")]
    Img(FeishuPostMsgBodyParagraphItemImg),
    #[serde(rename = "media")]
    Media(FeishuPostMsgBodyParagraphItemMedia),
    #[serde(rename = "emotion")]
    Emotion(FeishuPostMsgBodyParagraphItemEmotion),
}

/// 富文本 消息体 段落 Item text
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuPostMsgBodyParagraphItemText {
    pub text: String,
    pub un_escape: bool,
}

/// 富文本 消息体 段落 Item a
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuPostMsgBodyParagraphItemA {
    pub text: String,
    pub href: String,
}

/// 富文本 消息体 段落 Item at
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuPostMsgBodyParagraphItemAt {
    pub user_id: String,
    pub user_name: Option<String>,
}

/// 富文本 消息体 段落 Item img
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuPostMsgBodyParagraphItemImg {
    pub image_key: String,
}

/// 富文本 消息体 段落 Item media
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuPostMsgBodyParagraphItemMedia {
    pub file_key: String,
    pub image_key: Option<String>,
}

/// 富文本 消息体 段落 Item emotion
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuPostMsgBodyParagraphItemEmotion {
    pub emoji_type: String,
}

/// 图片
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuImageMsg {
    pub image_key: String,
}

/// 分享群名片
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuShareChatMsg {
    pub chat_id: String,
}

/// 分享个人名片
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuShareUserMsg {
    pub user_id: String,
}

/// 语音
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuAudioMsg {
    pub file_key: String,
}

/// 视频
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuMediaMsg {
    pub file_key: String,
    pub image_key: String,
}

/// 文件
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuFileMsg {
    pub file_key: String,
}

/// 表情包
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuStickerMsg {
    pub file_key: String,
}

会发现这次的结构稍微又有些不一样了,由于最终提交接口的数据中 content 是序列化后的结果,所以得为它单独声明一个统一的结构。

而消息本身的结构,则是同样也是通过枚举的方式进行综合声明,而这次我们用到了 #[serde(tag = "msg_type", content = "content")] 这样的形式,这样使得在既能明确声明所有支持的消息格式,限定了格式范围之外,还让 msg_typecontent 这两个平级的字段也很好地被支持了。而如果是一开始在某些强类型语言中,可能真的就会把 msg_type 声明为简单枚举,然后把 content 声明为泛型了,或者在 Golang 早起版本里面就只能声明为 interface {} 了,而近期版本也仅仅只是换了个名字叫 any 而已,本质上并没有什么区别。

至于 Python 就是反正什么类型都可以,所以这事情在它这里可能还是最简单不过的了,但也仅限一些简单处理,如果想要对这消息本身要做些什么复杂处理,就稍显乏力了。

外层的看完了,此时该看看“消息卡片”了,这次同样也把它放到了一个子模块里面,但是这个子模块里面又有更多的子模块,层层分解,非常符合人类的常规思维。

model/msg/feishu/interactive/mod.rs


use crate::model::msg::feishu::interactive::content::{Element, I18nElements};
use crate::model::msg::feishu::interactive::content::config::Config;
use crate::model::msg::feishu::interactive::content::header::Header;

mod content;
mod action;

/// 消息卡片
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct FeishuInteractiveMsg {
    /// 用于描述卡片的功能属性。
    #[serde(skip_serializing_if = "Option::is_none")]
    pub config: Option<Config>,
    /// 用于配置卡片标题内容。
    #[serde(skip_serializing_if = "Option::is_none")]
    pub header: Option<Header>,
    /// 用于定义卡片正文内容,和i18n_elements至少必填其中1个
    #[serde(skip_serializing_if = "Option::is_none")]
    pub elements: Option<Vec<Element>>,
    /// 为卡片的正文部分定义多语言内容,和elements至少必填其中1个
    #[serde(skip_serializing_if = "Option::is_none")]
    pub i18n_elements: Option<I18nElements>,
}


/// 语言代码
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Ord, PartialOrd, Eq, PartialEq, Hash)]
pub enum Language {
    #[serde(rename = "zh_cn")]
    ZhCn,
    #[serde(rename = "en_us")]
    EnUs,
}

而在整个消息卡片体系下,又分为内容 content 动作 action ,我们先来看动作的部分,谁让它字母排序靠前呢。

model/msg/feishu/interactive/action/mod.rs

use crate::model::msg::feishu::interactive::action::module::ActionModule;

pub mod module;
pub mod element;


/// 消息卡片 交互
/// https://open.feishu.cn/document/ukTMukTMukTM/uYjNwUjL2YDM14iN2ATN
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Action {
    pub tag: String,
    pub actions: Vec<ActionModule>,
    pub layout: Option<ActionLayout>,
}

/// 交互元素布局,窄版样式默认纵向排列
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum ActionLayout {
    #[serde(rename = "bisected")]
    Bisected,
    #[serde(rename = "trisection")]
    Trisection,
    #[serde(rename = "flow")]
    Flow,
}

model/msg/feishu/interactive/action/element.rs


use crate::model::msg::feishu::interactive::content::element::Text;

/// 消息卡片 可内嵌的交互元素 card_link
/// https://open.feishu.cn/document/ukTMukTMukTM/uYDN1UjL2QTN14iN0UTN
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ActionCardLink {
    /// 默认的链接地址
    pub url: String,
    /// PC 端的链接地址
    pub pc_url: Option<String>,
    /// iOS 端的链接地址
    pub ios_url: Option<String>,
    /// Android 端的链接地址
    pub android_url: Option<String>,
}

/// 消息卡片 可内嵌的交互元素 confirm
/// https://open.feishu.cn/document/ukTMukTMukTM/ukzNwUjL5cDM14SO3ATN
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ActionConfirm {
    /// 弹框标题
    pub title: Text,
    /// 弹框内容
    pub text: Text,
}

/// 消息卡片 可内嵌的交互元素 option
/// https://open.feishu.cn/document/ukTMukTMukTM/ugzNwUjL4cDM14CO3ATN
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ActionOption {
    /// 选项显示内容,非待选人员时必填
    pub text: Option<Text>,
    /// 选项选中后返回业务方的数据,与url或multi_url必填其中一个
    pub value: Option<String>,
    /// *仅支持overflow,跳转指定链接,和multi_url字段互斥
    pub url: Option<String>,
    /// *仅支持overflow,跳转对应链接,和url字段互斥
    pub multi_url: Option<ActionUrl>,
}

/// 消息卡片 可内嵌的交互元素
/// https://open.feishu.cn/document/ukTMukTMukTM/uczNwUjL3cDM14yN3ATN
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ActionUrl {
    /// 默认跳转链接,参考注意事项-2
    pub url: String,
    /// 安卓端跳转链接
    pub android_url: String,
    /// ios端跳转链接
    pub ios_url: String,
    /// pc端跳转链接
    pub pc_url: String,
}

model/msg/feishu/interactive/action/module.rs


use crate::model::msg::feishu::interactive::action::element::{ActionConfirm, ActionOption, ActionUrl};
use crate::model::msg::feishu::interactive::content::element::Text;

/// 消息卡片 交互模块
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(tag = "tag")]
pub enum ActionModule {
    #[serde(rename = "button")]
    Button(Button),
    #[serde(rename = "select_static")]
    SelectStatic(SelectMenu),
    #[serde(rename = "select_person")]
    SelectPerson(SelectMenu),
    #[serde(rename = "overflow")]
    OverFlow(OverFlow),
    #[serde(rename = "date_picker")]
    DatePicker(DatePicker),
    #[serde(rename = "picker_time")]
    PickerTime(DatePicker),
    #[serde(rename = "picker_datetime")]
    PickerDatetime(DatePicker),
}


/// 消息卡片 交互组件 datePicker
/// https://open.feishu.cn/document/ukTMukTMukTM/uQzNwUjL0cDM14CN3ATN
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct DatePicker {
    pub initial_date: Option<String>,
    pub initial_time: Option<String>,
    pub initial_datetime: Option<String>,
    pub placeholder: Option<Text>,
    pub value: Option<HashMap<String, Value>>,
    pub confirm: Option<ActionConfirm>,
}

/// 消息卡片 交互组件 overflow
/// https://open.feishu.cn/document/ukTMukTMukTM/uMzNwUjLzcDM14yM3ATN
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct OverFlow {
    pub options: Vec<ActionOption>,
    pub value: Option<HashMap<String, Value>>,
    pub confirm: Option<ActionConfirm>,
}

/// 消息卡片 交互组件 selectMenu
/// https://open.feishu.cn/document/ukTMukTMukTM/uIzNwUjLycDM14iM3ATN
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct SelectMenu {
    pub placeholder: Option<Text>,
    pub initial_option: Option<String>,
    pub options: Option<Vec<ActionOption>>,
    pub value: Option<HashMap<String, Value>>,
    pub confirm: Option<ActionConfirm>,
}


/// 消息卡片 交互组件 button
/// https://open.feishu.cn/document/ukTMukTMukTM/uEzNwUjLxcDM14SM3ATN
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Button {
    pub text: Text,
    pub url: Option<String>,
    pub multi_url: Option<ActionUrl>,
    #[serde(rename = "type")]
    pub type_: Option<ButtonType>,
    pub value: Option<HashMap<String, Value>>,
    pub confirm: Option<ActionConfirm>,
}

/// 消息卡片 交互组件 button type
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum ButtonType {
    #[serde(rename = "default")]
    Default,
    #[serde(rename = "primary")]
    Primary,
    #[serde(rename = "danger")]
    Danger,
}

这次上面这里面除了用到了常规的数据结构之外,还用到了 serde_json::Value 这个可以包罗万象的不定格式结构,而它正是描述整个 json 数据结构体系的综合性结构声明,而它也正是一个枚举。

model/msg/feishu/interactive/content/mod.rs


use crate::model::msg::feishu::interactive::content::module::ContentModule;
use crate::model::msg::feishu::interactive::Language;

pub mod element;
pub mod module;
pub mod header;
pub mod layout;
pub mod config;

/// 消息卡片 正文内容
pub type Element = ContentModule;

/// 消息卡片 多语言正文内容
pub type I18nElements = HashMap<Language, ContentModule>;

能看到内容下面又细分了 元素 element、模块 module、头部 header、布局 layout、配置 config 这五个部分,妥妥滴定义了一套几乎全新的类 HTML 呀。

model/msg/feishu/interactive/content/config.rs

/// 消息卡片 功能属性
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Config {
    /// 是否允许卡片被转发。
    pub enable_forward: Option<bool>,
    /// 是否为共享卡片。
    pub update_multi: Option<bool>,
}

model/msg/feishu/interactive/content/element.rs


use crate::model::msg::feishu::interactive::action::module::ActionModule;

/// 消息卡片 可内嵌的非交互元素 field
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Field {
    pub is_short: bool,
    pub text: Text,
}

/// 消息卡片 可内嵌的非交互元素 text
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(tag = "tag")]
pub enum Text {
    #[serde(rename = "plain_text")]
    PlainText(TextContent),
    #[serde(rename = "lark_md")]
    LarkMd(TextContent),
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct TextContent {
    pub content: String,
    pub lines: Option<u32>,
}

/// 消息卡片 可内嵌的非交互元素 image
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(tag = "tag")]
pub enum Image {
    #[serde(rename = "img")]
    Img(ImageContent),
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ImageContent {
    pub img_key: String,
    pub alt: Text,
    pub preview: Option<bool>,
}


#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum Extra {
    Image(Image),
    ActionModule(ActionModule),
}

model/msg/feishu/interactive/content/header.rs


use crate::model::msg::feishu::interactive::content::element::Text;

/// 消息卡片 卡片标题
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Header {
    /// 配置卡片标题内容 text 对象(仅支持"plain_text")
    pub title: HeaderTitle,
    /// 控制标题背景颜色,取值参考注意事项
    #[serde(skip_serializing_if = "Option::is_none")]
    pub template: Option<HeaderTemplate>,
}

pub type HeaderTitle = Text;


/// 消息卡片 卡片标题 卡片标题的主题色
///
/// 最佳实践
/// 彩色标题适合在群聊中使用,对于需高亮提醒的卡片信息,可将标题配置为应用的品牌色或表达状态的语义色,增强信息的视觉锚点。
/// 在单聊中,建议根据卡片的状态不同,配置不同的语义颜色标题
/// 绿色(Green)代表完成/成功
/// 橙色(Orange)代表警告/警示
/// 红色(Red)代表错误/异常
/// 灰色(Grey)代表失效
/// 不建议在单聊中,每张卡片无差别地使用同一种颜色的标题。这样既无法起到强调作用,也可能引起阅读者的视觉焦虑。
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum HeaderTemplate {
    #[serde(rename = "blue")]
    Blue,
    #[serde(rename = "wathet")]
    Wathet,
    #[serde(rename = "turquoise")]
    Turquoise,
    #[serde(rename = "green")]
    Green,
    #[serde(rename = "yellow")]
    Yellow,
    #[serde(rename = "orange")]
    Orange,
    #[serde(rename = "red")]
    Red,
    #[serde(rename = "carmine")]
    Carmine,
    #[serde(rename = "violet")]
    Violet,
    #[serde(rename = "purple")]
    Purple,
    #[serde(rename = "indigo")]
    Indigo,
    #[serde(rename = "grey")]
    Grey,
}

model/msg/feishu/interactive/content/layout.rs


use crate::model::msg::feishu::interactive::action::element::ActionUrl;
use crate::model::msg::feishu::interactive::content::module::ContentModule;

/// 消息卡片 布局 多列布局
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ColumnSet {
    pub tag: String,
    pub columns: Option<Vec<Column>>,
    pub flex_mode: Option<String>,
    pub background_style: Option<String>,
    pub horizontal_spacing: Option<String>,
    /// 设置点击布局容器时,响应的交互配置。当前仅支持跳转交互。如果布局容器内有交互组件,则优先响应交互组件定义的交互。
    pub action: Option<ColumnSetAction>,
}


/// 消息卡片 布局 列
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Column {
    pub tag: String,
    pub elements: Option<ColumnElements>,
    pub width: Option<String>,
    pub weight: Option<u8>,
    pub vertical_align: Option<ColumnVerticalAlign>,
}

/// 消息卡片 布局 列 卡片元素
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum ColumnElements {
    Elements(Vec<ContentModule>),
    ColumnSet(Vec<ColumnSet>),
}


/// 消息卡片 布局 多列布局 交互配置
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ColumnSetAction {
    pub multi_url: ActionUrl,
}

/// 消息卡片 布局 列 垂直对齐
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum ColumnVerticalAlign {
    #[serde(rename = "top")]
    Top,
    #[serde(rename = "center")]
    Center,
    #[serde(rename = "bottom")]
    Bottom,
}

model/msg/feishu/interactive/content/module.rs


use crate::model::msg::feishu::interactive::action::element::ActionUrl;
use crate::model::msg::feishu::interactive::content::element::{Extra, Field, Image, Text};

/// 消息卡片 正文内容
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(tag = "tag")]
pub enum ContentModule {
    #[serde(rename = "div")]
    Div(Div),
    #[serde(rename = "markdown")]
    Markdown(Markdown),
    #[serde(rename = "hr")]
    Hr(Hr),
    #[serde(rename = "img")]
    Img(Img),
    #[serde(rename = "note")]
    Note(Note),
}


/// 消息卡片 内容模块
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Div {
    pub text: Text,
    pub fields: Option<Vec<Field>>,
    pub extra: Option<Extra>,
}

/// 消息卡片 Markdown模块
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Markdown {
    pub content: String,
    pub text_align: Option<TextAlign>,
    pub href: Option<Href>,
}

/// 消息卡片 Markdown模块 text_align
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum TextAlign {
    Left,
    Center,
    Right,
}

/// 消息卡片 Markdown模块 href
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Href {
    #[serde(rename = "urlVal")]
    pub url_val: ActionUrl,
}

/// 消息卡片 分割线模块
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Hr {}

/// 消息卡片 图片模块
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Img {
    pub img_key: String,
    pub alt: Text,
    pub title: Option<Text>,
    pub custom_width: Option<u16>,
    pub compact_width: Option<bool>,
    pub mode: Option<ImgMode>,
    pub preview: Option<bool>,
}

/// 消息卡片 图片模块 mode
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum ImgMode {
    #[serde(rename = "fit_horizontal")]
    FitHorizontal,
    #[serde(rename = "crop_center")]
    CropCenter,
}

/// 消息卡片 分割线模块
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Note {
    pub elements: NoteElements,
}

/// 消息卡片 备注模块
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum NoteElements {
    Text(Vec<Text>),
    Image(Vec<Image>),
}

最后,还要额外附赠一个:AppLink 协议 https://open.feishu.cn/document/uYjL24iN/ucjN1UjL3YTN14yN2UTN

在所有平台的消息体系中,链接是必不可少的,而链接虽然最终都是一串字符串,但是它却不是简单的一串字符串,而是一个协议的定义和封装,最后交付时才呈现为一个文本字符串。

model/msg/feishu/applink.rs


/// AppLink 协议 就是一个 URL 协议。AppLink 协议可以用于打开飞书或者其中的一个功能。
/// https://open.feishu.cn/document/uYjL24iN/ucjN1UjL3YTN14yN2UTN
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct AppLink {
    pub scheme: Scheme,
    pub host: Host,
    pub path: Path,
    pub query: HashMap<String, String>,
}

impl Display for AppLink {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        let scheme = &self.scheme;
        let host = &self.host;
        let path = &self.path;
        let query = &self.query;
        todo!();
        f.write_str(format!("{:?}://{:?}{:?}?{:?}", scheme, host, path, query).as_str())
    }
}

/// 协议
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum Scheme {
    #[serde(rename = "https")]
    Https
}

/// 域名
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum Host {
    #[serde(rename = "applink.feishu.cn")]
    AppLinkFeishuCn,
}

/// 路径,不同路径打开不同功能
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum Path {
    /// 打开飞书
    #[serde(rename = "/client/op/open")]
    Feishu,
    /// 打开扫一扫
    #[serde(rename = "/client/qrcode/main")]
    QrCode,
    /// 打开小程序
    #[serde(rename = "/client/mini_program/open")]
    MiniProgram,
    /// 打开网页应用
    #[serde(rename = "/client/web_app/open")]
    WebApp,
    /// 打开聊天页面
    #[serde(rename = "/client/chat/open")]
    Chat,
    /// 打开日历
    #[serde(rename = "/client/calendar/open")]
    Calendar,
    /// 打开日历(支持定义视图和日期)
    #[serde(rename = "/client/calendar/view")]
    CalendarView,
    /// 打开日程创建页
    #[serde(rename = "/client/calendar/event/create")]
    CalendarEventCreate,
    /// 打开第三方日历账户管理页
    #[serde(rename = "/client/calendar/account")]
    CalendarAccount,
    /// 打开任务
    #[serde(rename = "/client/todo/open")]
    Todo,
    /// 创建任务
    #[serde(rename = "/client/todo/create")]
    TodoCreate,
    /// 打开任务详情页
    #[serde(rename = "/client/todo/detail")]
    TodoDetail,
    /// 打开任务Tab页
    #[serde(rename = "/client/todo/view")]
    TodoView,
    /// 打开云文档
    #[serde(rename = "/client/docs/open")]
    Docs,
    /// 打开机器人会话
    #[serde(rename = "/client/bot/open")]
    Bot,
    /// 打开SSO登录页
    #[serde(rename = "/client/passport/sso_login")]
    PassportSsoLogin,
    /// 打开PC端内web-view访问指定URL
    #[serde(rename = "/client/web_url/open")]
    WebUrl,
}

/// 打开小程序 参数
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct QueryMiniProgram {
    /// 小程序 appId(可从「开发者后台-凭证与基础信息」获取)
    #[serde(rename = "appId")]
    pub app_id: String,
    /// PC小程序启动模式 PC 建议填写
    pub mode: Option<QueryMiniProgramMode>,
    /// 自定义独立窗口高度(仅当mode为window时生效)
    pub height: Option<u32>,
    /// 自定义独立窗口宽度(仅当mode为window时生效)
    pub width: Option<u32>,
    /// 是否重新加载指定页面。该参数仅当applink中传入path参数时才会生效
    pub relaunch: Option<bool>,
    /// 需要跳转的页面路径,路径后可以带参数。也可以使用 path_android、path_ios、path_pc 参数对不同的客户端指定不同的path
    pub path: Option<MiniProgramPath>,
    /// 同 path 参数,Android 端会优先使用该参数,如果该参数不存在,则会使用 path 参数
    pub path_android: Option<MiniProgramPath>,
    /// 同 path 参数,iOS 端会优先使用该参数,如果该参数不存在,则会使用 path 参数
    pub path_ios: Option<MiniProgramPath>,
    /// 同 path 参数,PC 端会优先使用该参数,如果该参数不存在,则会使用 path 参数
    pub path_pc: Option<MiniProgramPath>,
    /// 指定 AppLink 协议能够兼容的最小飞书版本,使用三位版本号 x.y.z。如果当前飞书版本号小于min_lk_ver,打开该 AppLink 会显示为兼容页面
    pub min_lk_ver: Option<String>,
}

pub type MiniProgramPath = String;

/// PC小程序启动模式
/// 未填写的情况下,默认会优先使用sidebar-semi打开,不支持sidebar-semi模式的情况下会使用window-semi模式打开
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum QueryMiniProgramMode {
    /// 聊天的侧边栏打开
    #[serde(rename = "sidebar-semi")]
    SidebarSemi,
    /// 工作台中打开
    #[serde(rename = "appCenter")]
    AppCenter,
    /// 独立大窗口打开
    #[serde(rename = "window")]
    Window,
    /// 独立小窗口打开,飞书3.33版本开始支持此模式
    #[serde(rename = "window-semi")]
    WindowSemi,
}

/// 打开网页应用 参数
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct QueryWebApp {
    /// H5应用的 appId(可从「开发者后台-凭证与基础信息」获取)
    #[serde(rename = "appId")]
    pub app_id: String,
    /// 打开H5应用的容器模式
    pub mode: Option<QueryWebAppMode>,
    /// 自定义独立窗口高度(仅当mode为window时生效)
    pub height: Option<u32>,
    /// 自定义独立窗口宽度(仅当mode为window时生效)
    pub width: Option<u32>,
    /// 访问H5应用的具体某个页面,path参数将替换H5应用URL的path部分。如果需要携带参数,将预期的H5应用URL的query作为applink的query即可,
    pub path: Option<WebAppPath>,
    /// 同 path 参数,Android 端会优先使用该参数,如果该参数不存在,则会使用 path 参数。
    pub path_android: Option<WebAppPath>,
    /// 同 path 参数,iOS 端会优先使用该参数,如果该参数不存在,则会使用 path 参数
    pub path_ios: Option<WebAppPath>,
    /// 同 path 参数,PC 端会优先使用该参数,如果该参数不存在,则会使用 path 参数
    pub path_pc: Option<WebAppPath>,
    /// 访问H5应用的具体某个页面,针对网页path中包含#或?字符时,可使用该参数,而不使用path参数。需要填写H5应用某个具体页面的完整URL(协议名scheme和域名domain应当与开发者后台配置的应用首页相匹配),并进行URL encode后使用
    pub lk_target_url: Option<String>,
    /// 如果网页应用当前所处的页面路径和 applink 要打开的页面路径相同时:true:重新加载页面 false:保留原页面状态
    pub reload: Option<bool>,
}

pub type WebAppPath = String;
pub type WebAppQuery = HashMap<String, String>;

/// 打开H5应用的容器模式
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum QueryWebAppMode {
    /// 在工作台打开,3.20版本开始支持(缺省值)
    #[serde(rename = "appCenter")]
    AppCenter,
    /// 在独立窗口打开,3.20版本开始支持
    #[serde(rename = "window")]
    Window,
    /// 在侧边栏打开,3.40版本开始支持
    #[serde(rename = "sidebar")]
    SideBar,
    /// 在独立窗口以小屏形式打开,5.10版本开始支持
    #[serde(rename = "window-semi")]
    WindowSemi,
}

/// 打开聊天应用 参数 openID与openChatId 仅能填写其中一个参数。
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct QueryChat {
    /// 用户 openId,获取方式可以参考文档:如何获得 User ID、Open ID 和 Union ID?
    #[serde(rename = "openId")]
    pub open_id: Option<String>,
    /// 会话ID,包括单聊会话和群聊会话。是以 'oc' 开头的字段,获取方式可参考文档 群ID说明;示例值: oc_41e7bdf4877cfc316136f4ccf6c32613
    #[serde(rename = "openChatId")]
    pub open_chat_id: Option<String>,
}

/// 打开日历 参数
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct QueryCalendarView {
    /// 视图类型
    #[serde(rename = "type")]
    pub type_: Option<QueryCalendarViewType>,
    /// 日期,{unixTime}格式
    pub date: Option<u64>,
}

/// 视图类型
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum QueryCalendarViewType {
    /// 日视图
    #[serde(rename = "day")]
    Day,
    /// 三日视图,仅移动端支持
    #[serde(rename = "three_day")]
    ThreeDay,
    /// 周视图,仅PC端支持
    #[serde(rename = "week")]
    Week,
    /// 月视图
    #[serde(rename = "month")]
    Month,
    /// 会议室视图,仅PC端支持
    #[serde(rename = "meeting")]
    Meeting,
    /// 列表视图,仅移动端支持
    #[serde(rename = "list")]
    List,
}

/// 打开日程创建页 参数
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct QueryCalendarEventCreate {
    /// 开始日期,{unixTime}格式
    #[serde(rename = "startTime")]
    pub start_time: Option<u64>,
    /// 结束日期,{unixTime}格式
    #[serde(rename = "endTime")]
    pub end_time: Option<u64>,
    /// 日程主题,中文可使用encodeURIComponent方法生成
    pub summary: Option<String>,
}

/// 打开任务详情 参数
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct QueryTodoDetail {
    /// 全局唯一的taskId(global unique ID),通过飞书任务的 OpenAPI 获取
    pub guid: String,
    /// 默认在im场景下,打开任务详情页面;mode=app, 在任务tab中打开详情页面
    pub mode: Option<String>,
}

/// 打开任务tab页 参数
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct QueryTodoView {
    pub tab: QueryTodoViewTab,
}

/// 任务tab页
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum QueryTodoViewTab {
    /// 进行中
    #[serde(rename = "all")]
    All,
    /// 由我处理
    #[serde(rename = "assign_to_me")]
    AssignToMe,
    /// 我分配的
    #[serde(rename = "assign_from_me")]
    AssignFromMe,
    /// 我关注的
    #[serde(rename = "followed")]
    Followed,
    /// 已完成
    #[serde(rename = "completed")]
    Completed,
}

/// 打开云文档 参数
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct QueryDocs {
    /// 要打开的云文档URL
    #[serde(rename = "appId")]
    pub url: String,
}

/// 打开机器人会话 参数
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct QueryBot {
    /// 机器人的appId
    #[serde(rename = "appId")]
    pub app_id: String,
}

/// 打开SSO登录页 参数
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct QueryPassportSsoLogin {
    /// 租户的域名,填写的是租户在admin后台配置的租户域名信息。当在admin后台改动租户的域名时,需要同步修改applink该参数值
    pub sso_domain: String,
    /// 租户名,用于在切换租户时,客户端展示即将登录到的租户名称,一般填写公司名即可
    pub tenant_name: String,
}

/// 打开PC端内web-view访问指定URL 参数
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct QueryWebUrl {
    /// 指定需要在客户端内打开的具体链接,需要执行encodeURIComponent,4.2+版本支持lark协议
    pub url: String,
    /// PC端打开的容器模式
    pub mode: QueryWebUrlMode,
    /// PC端自定义独立窗口高度
    pub height: Option<u32>,
    /// PC端自定义独立窗口宽度
    pub width: Option<u32>,

}

/// PC端打开的容器模式
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub enum QueryWebUrlMode {
    /// 在侧边栏打开
    #[serde(rename = "sidebar-semi")]
    SidebarSemi,
    /// 在独立窗口打开
    #[serde(rename = "window")]
    Window,
}

AppLink 这部分内容并未完全准确实现,还剩下一些东西没仔细看,最近是没空看了。

到这里,三大主要平台的消息通知的格式就基本都用 Rust 声明过了,只要是通过这些个结构去生成的数据,然后通过 let message = serde_json::to_string(&msg) 所得到的数据,就一定是符合平台格式要求的。

当然这里还有很多事情没有做:例如长度问题、大小问题等等,如果要完善,就得再实现一些检查函数和数据处理函数了,那要总结规律然后设计统一的校验协议然后按规范实现才是最好,而暂时认为没有太大的必要性。

飞书的消息结构虽然看似复杂,但是其实是结构对象比较多,它通过各种组合关系,能形成极为丰富的组合效果,所以甚至是飞书官方,都提供了一个非常友好的消息卡片搭建工具来供大家使用,它就是一个 json 生成器,大多数业务,基本拿到这个 json 结果就可以去接入并发送消息了,而并不需要类似我这样去完整地声明它的全部数据结构。

但,始终有一天,你会遇到与我类似的痛苦和类似的需求的,那时候可能你会明白,我这么做的意义在哪里。

到此,你会发现,Rust 如此强大的强类型数据结构声明的能力,是多么的漂亮,哪怕是面对消息通知这种极为复杂的充满无限可能性的业务场景下,都能做到如此让人惊呼的实现,而这种体验,我在 Python 和 Goland 从未有过。

加油吧,朋友们。