Skip to main content

synaptic_lark/tools/
message.rs

1use async_trait::async_trait;
2use serde_json::{json, Value};
3use synaptic_core::{SynapticError, Tool};
4
5use crate::{api::message::MessageApi, LarkConfig};
6
7/// Send, update, or delete Feishu/Lark messages as an Agent tool.
8///
9/// # Actions
10///
11/// | Action   | Description                                         |
12/// |----------|-----------------------------------------------------|
13/// | `send`   | Send a new message (default when `action` omitted)  |
14/// | `update` | Update the content of an existing message           |
15/// | `delete` | Delete (recall) a message                          |
16///
17/// # Tool call format — send
18///
19/// ```json
20/// {
21///   "action": "send",
22///   "receive_id_type": "chat_id",
23///   "receive_id": "oc_xxx",
24///   "msg_type": "text",
25///   "content": "Hello!"
26/// }
27/// ```
28///
29/// # Tool call format — update
30///
31/// ```json
32/// {
33///   "action": "update",
34///   "message_id": "om_xxx",
35///   "msg_type": "interactive",
36///   "content": "{\"config\":{...}}"
37/// }
38/// ```
39///
40/// # Tool call format — delete
41///
42/// ```json
43/// {
44///   "action": "delete",
45///   "message_id": "om_xxx"
46/// }
47/// ```
48///
49/// `receive_id_type` can be `"chat_id"`, `"user_id"`, `"email"`, or `"open_id"`.
50/// `msg_type` can be `"text"`, `"post"`, or `"interactive"`.
51/// For `"text"` the `content` field is a plain string; for other types it must be valid JSON.
52pub struct LarkMessageTool {
53    api: MessageApi,
54}
55
56impl LarkMessageTool {
57    /// Create a new message tool.
58    pub fn new(config: LarkConfig) -> Self {
59        Self {
60            api: MessageApi::new(config),
61        }
62    }
63}
64
65#[async_trait]
66impl Tool for LarkMessageTool {
67    fn name(&self) -> &'static str {
68        "lark_send_message"
69    }
70
71    fn description(&self) -> &'static str {
72        "Send, update, or delete a Feishu/Lark message. \
73         Use action='send' (default) to send to a chat or user; \
74         action='update' to patch an existing message; \
75         action='delete' to recall a message."
76    }
77
78    fn parameters(&self) -> Option<Value> {
79        Some(json!({
80            "type": "object",
81            "properties": {
82                "action": {
83                    "type": "string",
84                    "description": "Operation: send (default) | update | delete",
85                    "enum": ["send", "update", "delete"]
86                },
87                "receive_id_type": {
88                    "type": "string",
89                    "description": "For 'send': type of receiver ID: chat_id | user_id | email | open_id",
90                    "enum": ["chat_id", "user_id", "email", "open_id"]
91                },
92                "receive_id": {
93                    "type": "string",
94                    "description": "For 'send': the receiver ID matching receive_id_type"
95                },
96                "msg_type": {
97                    "type": "string",
98                    "description": "For 'send'/'update': message type: text | post | interactive",
99                    "enum": ["text", "post", "interactive"]
100                },
101                "content": {
102                    "type": "string",
103                    "description": "For 'send'/'update': message content. For text: plain string. For post/interactive: JSON string."
104                },
105                "message_id": {
106                    "type": "string",
107                    "description": "For 'update'/'delete': the message ID (om_xxx)"
108                }
109            },
110            "required": ["action"]
111        }))
112    }
113
114    async fn call(&self, args: Value) -> Result<Value, SynapticError> {
115        let action = args
116            .get("action")
117            .and_then(|v| v.as_str())
118            .unwrap_or("send");
119
120        match action {
121            "send" => {
122                let receive_id_type = args["receive_id_type"]
123                    .as_str()
124                    .ok_or_else(|| SynapticError::Tool("missing 'receive_id_type'".to_string()))?;
125                let receive_id = args["receive_id"]
126                    .as_str()
127                    .ok_or_else(|| SynapticError::Tool("missing 'receive_id'".to_string()))?;
128                let msg_type = args["msg_type"]
129                    .as_str()
130                    .ok_or_else(|| SynapticError::Tool("missing 'msg_type'".to_string()))?;
131                let content = args["content"]
132                    .as_str()
133                    .ok_or_else(|| SynapticError::Tool("missing 'content'".to_string()))?;
134
135                let content_json = build_content_json(msg_type, content)?;
136                let message_id = self
137                    .api
138                    .send(receive_id_type, receive_id, msg_type, &content_json)
139                    .await?;
140                tracing::debug!("Lark message sent: {message_id}");
141                Ok(json!({ "message_id": message_id, "status": "sent" }))
142            }
143
144            "update" => {
145                let message_id = args["message_id"]
146                    .as_str()
147                    .ok_or_else(|| SynapticError::Tool("missing 'message_id'".to_string()))?;
148                let msg_type = args["msg_type"]
149                    .as_str()
150                    .ok_or_else(|| SynapticError::Tool("missing 'msg_type'".to_string()))?;
151                let content = args["content"]
152                    .as_str()
153                    .ok_or_else(|| SynapticError::Tool("missing 'content'".to_string()))?;
154
155                let content_json = build_content_json(msg_type, content)?;
156                self.api.update(message_id, msg_type, &content_json).await?;
157                Ok(json!({ "message_id": message_id, "status": "updated" }))
158            }
159
160            "delete" => {
161                let message_id = args["message_id"]
162                    .as_str()
163                    .ok_or_else(|| SynapticError::Tool("missing 'message_id'".to_string()))?;
164                self.api.delete(message_id).await?;
165                Ok(json!({ "message_id": message_id, "status": "deleted" }))
166            }
167
168            other => Err(SynapticError::Tool(format!(
169                "unknown action '{other}': expected send | update | delete"
170            ))),
171        }
172    }
173}
174
175/// Serialise message content into the JSON string that Feishu expects.
176///
177/// For `"text"` the content is a plain string; for `"post"`/`"interactive"` the
178/// caller must provide a valid JSON string which is validated here.
179fn build_content_json(msg_type: &str, content: &str) -> Result<String, SynapticError> {
180    match msg_type {
181        "text" => Ok(json!({"text": content}).to_string()),
182        _ => {
183            serde_json::from_str::<Value>(content)
184                .map_err(|e| {
185                    SynapticError::Tool(format!(
186                        "content is not valid JSON for msg_type='{msg_type}': {e}"
187                    ))
188                })?
189                .to_string();
190            Ok(content.to_string())
191        }
192    }
193}