Skip to main content

synaptic_lark/tools/
bitable.rs

1use async_trait::async_trait;
2use serde_json::{json, Value};
3use synaptic_core::{SynapticError, Tool};
4
5use crate::{api::bitable::BitableApi, LarkConfig};
6
7/// Interact with Feishu/Lark Bitable (multi-dimensional tables) as an Agent tool.
8///
9/// # Supported actions
10///
11/// | Action          | Description                                      |
12/// |-----------------|--------------------------------------------------|
13/// | `search`        | Search records with optional filter              |
14/// | `create`        | Batch-create new records                         |
15/// | `update`        | Update a single record                           |
16/// | `delete`        | Delete a single record                           |
17/// | `batch_update`  | Update multiple records in one call              |
18/// | `batch_delete`  | Delete multiple records in one call              |
19/// | `list_tables`   | List all tables in the app                       |
20/// | `list_fields`   | List fields in a table                           |
21/// | `create_table`  | Create a new table                               |
22/// | `delete_table`  | Delete a table                                   |
23/// | `create_field`  | Add a field to a table                           |
24/// | `update_field`  | Rename a field                                   |
25/// | `delete_field`  | Remove a field from a table                      |
26///
27/// # Filter operators for `search`
28///
29/// The `filter.operator` field supports: `is` (default), `is_not`,
30/// `contains`, `does_not_contain`, `is_empty`, `is_not_empty`.
31///
32/// # Tool call examples
33///
34/// **Search with operator:**
35/// ```json
36/// {
37///   "action": "search",
38///   "app_token": "bascnXxx",
39///   "table_id": "tblXxx",
40///   "filter": {"field": "Status", "operator": "contains", "value": "Pend"}
41/// }
42/// ```
43///
44/// **Batch-update:**
45/// ```json
46/// {
47///   "action": "batch_update",
48///   "app_token": "bascnXxx",
49///   "table_id": "tblXxx",
50///   "records": [{"record_id": "recXxx", "fields": {"Status": "Done"}}]
51/// }
52/// ```
53///
54/// **Create table:**
55/// ```json
56/// {
57///   "action": "create_table",
58///   "app_token": "bascnXxx",
59///   "table_name": "My New Table"
60/// }
61/// ```
62pub struct LarkBitableTool {
63    api: BitableApi,
64}
65
66impl LarkBitableTool {
67    /// Create a new Bitable tool.
68    pub fn new(config: LarkConfig) -> Self {
69        Self {
70            api: BitableApi::new(config),
71        }
72    }
73}
74
75#[async_trait]
76impl Tool for LarkBitableTool {
77    fn name(&self) -> &'static str {
78        "lark_bitable"
79    }
80
81    fn description(&self) -> &'static str {
82        "Interact with a Feishu/Lark Bitable (multi-dimensional table). \
83         Supports search (with filter operators: is/is_not/contains/does_not_contain/is_empty/is_not_empty), \
84         create, update, delete, batch_update, batch_delete, list_tables, list_fields, \
85         create_table, delete_table, create_field, update_field, delete_field."
86    }
87
88    fn parameters(&self) -> Option<Value> {
89        Some(json!({
90            "type": "object",
91            "properties": {
92                "action": {
93                    "type": "string",
94                    "description": "Operation to perform.",
95                    "enum": [
96                        "search", "create", "update", "delete",
97                        "batch_update", "batch_delete",
98                        "list_tables", "list_fields",
99                        "create_table", "delete_table",
100                        "create_field", "update_field", "delete_field"
101                    ]
102                },
103                "app_token": {
104                    "type": "string",
105                    "description": "Bitable app token (bascnXxx)"
106                },
107                "table_id": {
108                    "type": "string",
109                    "description": "Table ID (tblXxx). Required for most actions except create_table and list_tables."
110                },
111                "filter": {
112                    "type": "object",
113                    "description": "For 'search': {\"field\": \"FieldName\", \"operator\": \"is\", \"value\": \"Val\"}. operator defaults to 'is'; for is_empty/is_not_empty omit value.",
114                    "properties": {
115                        "field": { "type": "string" },
116                        "operator": {
117                            "type": "string",
118                            "enum": ["is", "is_not", "contains", "does_not_contain", "is_empty", "is_not_empty"]
119                        },
120                        "value": {}
121                    }
122                },
123                "records": {
124                    "type": "array",
125                    "description": "For 'create': [{\"FieldName\": value}]. For 'batch_update': [{\"record_id\": \"recXxx\", \"fields\": {\"FieldName\": value}}].",
126                    "items": { "type": "object" }
127                },
128                "record_id": {
129                    "type": "string",
130                    "description": "For 'update'/'delete': the record ID (recXxx)"
131                },
132                "record_ids": {
133                    "type": "array",
134                    "description": "For 'batch_delete': list of record IDs to delete",
135                    "items": { "type": "string" }
136                },
137                "fields": {
138                    "type": "object",
139                    "description": "For 'update': fields to update {\"FieldName\": newValue}"
140                },
141                "table_name": {
142                    "type": "string",
143                    "description": "For 'create_table': the name for the new table"
144                },
145                "field_name": {
146                    "type": "string",
147                    "description": "For 'create_field'/'update_field': the field name"
148                },
149                "field_type": {
150                    "type": "integer",
151                    "description": "For 'create_field': Feishu field type integer (1=text, 2=number, 3=single-select, etc.). Defaults to 1."
152                },
153                "field_id": {
154                    "type": "string",
155                    "description": "For 'update_field'/'delete_field': the field ID (fldXxx)"
156                }
157            },
158            "required": ["action", "app_token"]
159        }))
160    }
161
162    async fn call(&self, args: Value) -> Result<Value, SynapticError> {
163        let action = args["action"]
164            .as_str()
165            .ok_or_else(|| SynapticError::Tool("missing 'action'".to_string()))?;
166        let app_token = args["app_token"]
167            .as_str()
168            .ok_or_else(|| SynapticError::Tool("missing 'app_token'".to_string()))?;
169
170        // Helper: require table_id for actions that need it.
171        let require_table_id = || {
172            args["table_id"]
173                .as_str()
174                .ok_or_else(|| SynapticError::Tool("missing 'table_id'".to_string()))
175        };
176
177        match action {
178            // ── Record operations ──────────────────────────────────────────
179            "search" => {
180                let table_id = require_table_id()?;
181                let filter = args.get("filter");
182                let body = build_search_body(filter);
183                let items = self.api.search_records(app_token, table_id, body).await?;
184                Ok(json!({ "records": items }))
185            }
186
187            "create" => {
188                let table_id = require_table_id()?;
189                let raw = args["records"]
190                    .as_array()
191                    .ok_or_else(|| SynapticError::Tool("missing 'records' array".to_string()))?;
192                let records: Vec<Value> = raw.iter().map(|r| json!({ "fields": r })).collect();
193                let created = self
194                    .api
195                    .batch_create_records(app_token, table_id, records)
196                    .await?;
197                Ok(json!({ "created": created }))
198            }
199
200            "update" => {
201                let table_id = require_table_id()?;
202                let record_id = args["record_id"]
203                    .as_str()
204                    .ok_or_else(|| SynapticError::Tool("missing 'record_id'".to_string()))?;
205                let fields = args
206                    .get("fields")
207                    .cloned()
208                    .ok_or_else(|| SynapticError::Tool("missing 'fields'".to_string()))?;
209                self.api
210                    .update_record(app_token, table_id, record_id, fields)
211                    .await?;
212                Ok(json!({ "record_id": record_id, "status": "updated" }))
213            }
214
215            "delete" => {
216                let table_id = require_table_id()?;
217                let record_id = args["record_id"]
218                    .as_str()
219                    .ok_or_else(|| SynapticError::Tool("missing 'record_id'".to_string()))?;
220                self.api
221                    .delete_record(app_token, table_id, record_id)
222                    .await?;
223                Ok(json!({ "record_id": record_id, "status": "deleted" }))
224            }
225
226            "batch_update" => {
227                let table_id = require_table_id()?;
228                let records = args["records"]
229                    .as_array()
230                    .ok_or_else(|| SynapticError::Tool("missing 'records' array".to_string()))?
231                    .clone();
232                self.api
233                    .batch_update_records(app_token, table_id, records)
234                    .await?;
235                Ok(json!({ "status": "updated" }))
236            }
237
238            "batch_delete" => {
239                let table_id = require_table_id()?;
240                let ids: Vec<String> = args["record_ids"]
241                    .as_array()
242                    .ok_or_else(|| SynapticError::Tool("missing 'record_ids' array".to_string()))?
243                    .iter()
244                    .filter_map(|v| v.as_str().map(String::from))
245                    .collect();
246                if ids.is_empty() {
247                    return Err(SynapticError::Tool(
248                        "'record_ids' must be a non-empty array".to_string(),
249                    ));
250                }
251                self.api
252                    .batch_delete_records(app_token, table_id, ids)
253                    .await?;
254                Ok(json!({ "status": "deleted" }))
255            }
256
257            // ── Table management ───────────────────────────────────────────
258            "list_tables" => {
259                let tables = self.api.list_tables(app_token).await?;
260                Ok(json!({ "tables": tables }))
261            }
262
263            "create_table" => {
264                let name = args["table_name"]
265                    .as_str()
266                    .ok_or_else(|| SynapticError::Tool("missing 'table_name'".to_string()))?;
267                let table_id = self.api.create_table(app_token, name).await?;
268                Ok(json!({ "table_id": table_id, "status": "created" }))
269            }
270
271            "delete_table" => {
272                let table_id = require_table_id()?;
273                self.api.delete_table(app_token, table_id).await?;
274                Ok(json!({ "table_id": table_id, "status": "deleted" }))
275            }
276
277            // ── Field management ───────────────────────────────────────────
278            "list_fields" => {
279                let table_id = require_table_id()?;
280                let fields = self.api.list_fields(app_token, table_id).await?;
281                Ok(json!({ "fields": fields }))
282            }
283
284            "create_field" => {
285                let table_id = require_table_id()?;
286                let name = args["field_name"]
287                    .as_str()
288                    .ok_or_else(|| SynapticError::Tool("missing 'field_name'".to_string()))?;
289                let field_type = args["field_type"].as_u64().unwrap_or(1) as u32;
290                let field_id = self
291                    .api
292                    .create_field(app_token, table_id, name, field_type)
293                    .await?;
294                Ok(json!({ "field_id": field_id, "status": "created" }))
295            }
296
297            "update_field" => {
298                let table_id = require_table_id()?;
299                let field_id = args["field_id"]
300                    .as_str()
301                    .ok_or_else(|| SynapticError::Tool("missing 'field_id'".to_string()))?;
302                let name = args["field_name"]
303                    .as_str()
304                    .ok_or_else(|| SynapticError::Tool("missing 'field_name'".to_string()))?;
305                self.api
306                    .update_field(app_token, table_id, field_id, name)
307                    .await?;
308                Ok(json!({ "field_id": field_id, "status": "updated" }))
309            }
310
311            "delete_field" => {
312                let table_id = require_table_id()?;
313                let field_id = args["field_id"]
314                    .as_str()
315                    .ok_or_else(|| SynapticError::Tool("missing 'field_id'".to_string()))?;
316                self.api.delete_field(app_token, table_id, field_id).await?;
317                Ok(json!({ "field_id": field_id, "status": "deleted" }))
318            }
319
320            other => Err(SynapticError::Tool(format!(
321                "unknown action '{other}': expected search | create | update | delete | \
322                 batch_update | batch_delete | list_tables | list_fields | \
323                 create_table | delete_table | create_field | update_field | delete_field"
324            ))),
325        }
326    }
327}
328
329/// Build a Bitable search request body from an optional tool-level `filter` value.
330///
331/// Supports operators: `is` (default), `is_not`, `contains`,
332/// `does_not_contain`, `is_empty`, `is_not_empty`.
333fn build_search_body(filter: Option<&Value>) -> Value {
334    let f = match filter {
335        None => return json!({ "page_size": 20 }),
336        Some(f) => f,
337    };
338
339    let field = f["field"].as_str().unwrap_or("");
340    let operator = f.get("operator").and_then(|v| v.as_str()).unwrap_or("is");
341
342    let condition = match operator {
343        "is_empty" | "is_not_empty" => json!({
344            "field_name": field,
345            "operator": operator
346        }),
347        _ => {
348            let value = &f["value"];
349            json!({
350                "field_name": field,
351                "operator": operator,
352                "value": [value]
353            })
354        }
355    };
356
357    json!({
358        "page_size": 20,
359        "filter": {
360            "conjunction": "and",
361            "conditions": [condition]
362        }
363    })
364}