zeroclaw_tools/microsoft365/
graph_client.rs1use anyhow::Context;
2
3const GRAPH_BASE: &str = "https://graph.microsoft.com/v1.0";
4
5fn user_path(user_id: &str) -> String {
8 if user_id == "me" {
9 "/me".to_string()
10 } else {
11 format!("/users/{}", urlencoding::encode(user_id))
12 }
13}
14
15fn encode_path_segment(segment: &str) -> String {
17 urlencoding::encode(segment).into_owned()
18}
19
20pub async fn mail_list(
22 client: &reqwest::Client,
23 token: &str,
24 user_id: &str,
25 folder: Option<&str>,
26 top: u32,
27) -> anyhow::Result<serde_json::Value> {
28 let base = user_path(user_id);
29 let path = match folder {
30 Some(f) => format!(
31 "{GRAPH_BASE}{base}/mailFolders/{}/messages",
32 encode_path_segment(f)
33 ),
34 None => format!("{GRAPH_BASE}{base}/messages"),
35 };
36
37 let resp = client
38 .get(&path)
39 .bearer_auth(token)
40 .query(&[("$top", top.to_string())])
41 .send()
42 .await
43 .context("ms365: mail_list request failed")?;
44
45 handle_json_response(resp, "mail_list").await
46}
47
48pub async fn mail_send(
50 client: &reqwest::Client,
51 token: &str,
52 user_id: &str,
53 to: &[String],
54 subject: &str,
55 body: &str,
56) -> anyhow::Result<()> {
57 let base = user_path(user_id);
58 let url = format!("{GRAPH_BASE}{base}/sendMail");
59
60 let to_recipients: Vec<serde_json::Value> = to
61 .iter()
62 .map(|addr| {
63 serde_json::json!({
64 "emailAddress": { "address": addr }
65 })
66 })
67 .collect();
68
69 let payload = serde_json::json!({
70 "message": {
71 "subject": subject,
72 "body": {
73 "contentType": "Text",
74 "content": body
75 },
76 "toRecipients": to_recipients
77 }
78 });
79
80 let resp = client
81 .post(&url)
82 .bearer_auth(token)
83 .json(&payload)
84 .send()
85 .await
86 .context("ms365: mail_send request failed")?;
87
88 if !resp.status().is_success() {
89 let status = resp.status();
90 let body = resp.text().await.unwrap_or_default();
91 let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
92 ::zeroclaw_log::record!(
93 DEBUG,
94 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
95 .with_attrs(::serde_json::json!({"body": body})),
96 "ms365: mail_send raw error body"
97 );
98 anyhow::bail!("ms365: mail_send failed ({status}, code={code})");
99 }
100
101 Ok(())
102}
103
104pub async fn teams_message_list(
106 client: &reqwest::Client,
107 token: &str,
108 team_id: &str,
109 channel_id: &str,
110 top: u32,
111) -> anyhow::Result<serde_json::Value> {
112 let url = format!(
113 "{GRAPH_BASE}/teams/{}/channels/{}/messages",
114 encode_path_segment(team_id),
115 encode_path_segment(channel_id)
116 );
117
118 let resp = client
119 .get(&url)
120 .bearer_auth(token)
121 .query(&[("$top", top.to_string())])
122 .send()
123 .await
124 .context("ms365: teams_message_list request failed")?;
125
126 handle_json_response(resp, "teams_message_list").await
127}
128
129pub async fn teams_message_send(
131 client: &reqwest::Client,
132 token: &str,
133 team_id: &str,
134 channel_id: &str,
135 body: &str,
136) -> anyhow::Result<()> {
137 let url = format!(
138 "{GRAPH_BASE}/teams/{}/channels/{}/messages",
139 encode_path_segment(team_id),
140 encode_path_segment(channel_id)
141 );
142
143 let payload = serde_json::json!({
144 "body": {
145 "content": body
146 }
147 });
148
149 let resp = client
150 .post(&url)
151 .bearer_auth(token)
152 .json(&payload)
153 .send()
154 .await
155 .context("ms365: teams_message_send request failed")?;
156
157 if !resp.status().is_success() {
158 let status = resp.status();
159 let body = resp.text().await.unwrap_or_default();
160 let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
161 ::zeroclaw_log::record!(
162 DEBUG,
163 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
164 .with_attrs(::serde_json::json!({"body": body})),
165 "ms365: teams_message_send raw error body"
166 );
167 anyhow::bail!("ms365: teams_message_send failed ({status}, code={code})");
168 }
169
170 Ok(())
171}
172
173pub async fn calendar_events_list(
175 client: &reqwest::Client,
176 token: &str,
177 user_id: &str,
178 start: &str,
179 end: &str,
180 top: u32,
181) -> anyhow::Result<serde_json::Value> {
182 let base = user_path(user_id);
183 let url = format!("{GRAPH_BASE}{base}/calendarView");
184
185 let resp = client
186 .get(&url)
187 .bearer_auth(token)
188 .query(&[
189 ("startDateTime", start.to_string()),
190 ("endDateTime", end.to_string()),
191 ("$top", top.to_string()),
192 ])
193 .send()
194 .await
195 .context("ms365: calendar_events_list request failed")?;
196
197 handle_json_response(resp, "calendar_events_list").await
198}
199
200pub async fn calendar_event_create(
202 client: &reqwest::Client,
203 token: &str,
204 user_id: &str,
205 subject: &str,
206 start: &str,
207 end: &str,
208 attendees: &[String],
209 body_text: Option<&str>,
210) -> anyhow::Result<String> {
211 let base = user_path(user_id);
212 let url = format!("{GRAPH_BASE}{base}/events");
213
214 let attendee_list: Vec<serde_json::Value> = attendees
215 .iter()
216 .map(|email| {
217 serde_json::json!({
218 "emailAddress": { "address": email },
219 "type": "required"
220 })
221 })
222 .collect();
223
224 let mut payload = serde_json::json!({
225 "subject": subject,
226 "start": {
227 "dateTime": start,
228 "timeZone": "UTC"
229 },
230 "end": {
231 "dateTime": end,
232 "timeZone": "UTC"
233 },
234 "attendees": attendee_list
235 });
236
237 if let Some(text) = body_text {
238 payload["body"] = serde_json::json!({
239 "contentType": "Text",
240 "content": text
241 });
242 }
243
244 let resp = client
245 .post(&url)
246 .bearer_auth(token)
247 .json(&payload)
248 .send()
249 .await
250 .context("ms365: calendar_event_create request failed")?;
251
252 let value = handle_json_response(resp, "calendar_event_create").await?;
253 let event_id = value["id"].as_str().unwrap_or("unknown").to_string();
254 Ok(event_id)
255}
256
257pub async fn calendar_event_delete(
259 client: &reqwest::Client,
260 token: &str,
261 user_id: &str,
262 event_id: &str,
263) -> anyhow::Result<()> {
264 let base = user_path(user_id);
265 let url = format!(
266 "{GRAPH_BASE}{base}/events/{}",
267 encode_path_segment(event_id)
268 );
269
270 let resp = client
271 .delete(&url)
272 .bearer_auth(token)
273 .send()
274 .await
275 .context("ms365: calendar_event_delete request failed")?;
276
277 if !resp.status().is_success() {
278 let status = resp.status();
279 let body = resp.text().await.unwrap_or_default();
280 let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
281 ::zeroclaw_log::record!(
282 DEBUG,
283 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
284 .with_attrs(::serde_json::json!({"body": body})),
285 "ms365: calendar_event_delete raw error body"
286 );
287 anyhow::bail!("ms365: calendar_event_delete failed ({status}, code={code})");
288 }
289
290 Ok(())
291}
292
293pub async fn onedrive_list(
295 client: &reqwest::Client,
296 token: &str,
297 user_id: &str,
298 path: Option<&str>,
299) -> anyhow::Result<serde_json::Value> {
300 let base = user_path(user_id);
301 let url = match path {
302 Some(p) if !p.is_empty() => {
303 let encoded = urlencoding::encode(p);
304 format!("{GRAPH_BASE}{base}/drive/root:/{encoded}:/children")
305 }
306 _ => format!("{GRAPH_BASE}{base}/drive/root/children"),
307 };
308
309 let resp = client
310 .get(&url)
311 .bearer_auth(token)
312 .send()
313 .await
314 .context("ms365: onedrive_list request failed")?;
315
316 handle_json_response(resp, "onedrive_list").await
317}
318
319pub async fn onedrive_download(
321 client: &reqwest::Client,
322 token: &str,
323 user_id: &str,
324 item_id: &str,
325 max_size: usize,
326) -> anyhow::Result<Vec<u8>> {
327 let base = user_path(user_id);
328 let url = format!(
329 "{GRAPH_BASE}{base}/drive/items/{}/content",
330 encode_path_segment(item_id)
331 );
332
333 let resp = client
334 .get(&url)
335 .bearer_auth(token)
336 .send()
337 .await
338 .context("ms365: onedrive_download request failed")?;
339
340 if !resp.status().is_success() {
341 let status = resp.status();
342 let body = resp.text().await.unwrap_or_default();
343 let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
344 ::zeroclaw_log::record!(
345 DEBUG,
346 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
347 .with_attrs(::serde_json::json!({"body": body})),
348 "ms365: onedrive_download raw error body"
349 );
350 anyhow::bail!("ms365: onedrive_download failed ({status}, code={code})");
351 }
352
353 let bytes = resp
354 .bytes()
355 .await
356 .context("ms365: failed to read download body")?;
357 if bytes.len() > max_size {
358 anyhow::bail!(
359 "ms365: downloaded file exceeds max_size ({} > {max_size})",
360 bytes.len()
361 );
362 }
363
364 Ok(bytes.to_vec())
365}
366
367pub async fn sharepoint_search(
369 client: &reqwest::Client,
370 token: &str,
371 query: &str,
372 top: u32,
373) -> anyhow::Result<serde_json::Value> {
374 let url = format!("{GRAPH_BASE}/search/query");
375
376 let payload = serde_json::json!({
377 "requests": [{
378 "entityTypes": ["driveItem", "listItem", "site"],
379 "query": {
380 "queryString": query
381 },
382 "from": 0,
383 "size": top
384 }]
385 });
386
387 let resp = client
388 .post(&url)
389 .bearer_auth(token)
390 .json(&payload)
391 .send()
392 .await
393 .context("ms365: sharepoint_search request failed")?;
394
395 handle_json_response(resp, "sharepoint_search").await
396}
397
398fn extract_graph_error_code(body: &str) -> Option<String> {
401 let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
402 parsed
403 .get("error")
404 .and_then(|e| e.get("code"))
405 .and_then(|c| c.as_str())
406 .map(|s| s.to_string())
407}
408
409async fn handle_json_response(
414 resp: reqwest::Response,
415 operation: &str,
416) -> anyhow::Result<serde_json::Value> {
417 if !resp.status().is_success() {
418 let status = resp.status();
419 let body = resp.text().await.unwrap_or_default();
420 let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
421 ::zeroclaw_log::record!(
422 DEBUG,
423 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
424 .with_attrs(::serde_json::json!({"operation": operation, "body": body})),
425 "ms365: raw error body"
426 );
427 anyhow::bail!("ms365: {operation} failed ({status}, code={code})");
428 }
429
430 resp.json()
431 .await
432 .with_context(|| format!("ms365: failed to parse {operation} response"))
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
440 fn user_path_me() {
441 assert_eq!(user_path("me"), "/me");
442 }
443
444 #[test]
445 fn user_path_specific_user() {
446 assert_eq!(user_path("user@contoso.com"), "/users/user%40contoso.com");
447 }
448
449 #[test]
450 fn mail_list_url_no_folder() {
451 let base = user_path("me");
452 let url = format!("{GRAPH_BASE}{base}/messages");
453 assert_eq!(url, "https://graph.microsoft.com/v1.0/me/messages");
454 }
455
456 #[test]
457 fn mail_list_url_with_folder() {
458 let base = user_path("me");
459 let folder = "inbox";
460 let url = format!(
461 "{GRAPH_BASE}{base}/mailFolders/{}/messages",
462 encode_path_segment(folder)
463 );
464 assert_eq!(
465 url,
466 "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages"
467 );
468 }
469
470 #[test]
471 fn calendar_view_url() {
472 let base = user_path("user@example.com");
473 let url = format!("{GRAPH_BASE}{base}/calendarView");
474 assert_eq!(
475 url,
476 "https://graph.microsoft.com/v1.0/users/user%40example.com/calendarView"
477 );
478 }
479
480 #[test]
481 fn teams_message_url() {
482 let url = format!(
483 "{GRAPH_BASE}/teams/{}/channels/{}/messages",
484 encode_path_segment("team-123"),
485 encode_path_segment("channel-456")
486 );
487 assert_eq!(
488 url,
489 "https://graph.microsoft.com/v1.0/teams/team-123/channels/channel-456/messages"
490 );
491 }
492
493 #[test]
494 fn onedrive_root_url() {
495 let base = user_path("me");
496 let url = format!("{GRAPH_BASE}{base}/drive/root/children");
497 assert_eq!(
498 url,
499 "https://graph.microsoft.com/v1.0/me/drive/root/children"
500 );
501 }
502
503 #[test]
504 fn onedrive_path_url() {
505 let base = user_path("me");
506 let encoded = urlencoding::encode("Documents/Reports");
507 let url = format!("{GRAPH_BASE}{base}/drive/root:/{encoded}:/children");
508 assert_eq!(
509 url,
510 "https://graph.microsoft.com/v1.0/me/drive/root:/Documents%2FReports:/children"
511 );
512 }
513
514 #[test]
515 fn sharepoint_search_url() {
516 let url = format!("{GRAPH_BASE}/search/query");
517 assert_eq!(url, "https://graph.microsoft.com/v1.0/search/query");
518 }
519}