1use super::common::{
46 build_http_client, CloudConversation, CloudMessage, CloudProvider, FetchOptions,
47 HttpClientConfig,
48};
49use anyhow::{anyhow, Result};
50use chrono::{DateTime, Utc};
51use serde::Deserialize;
52use std::collections::HashMap;
53
54const GRAPH_API_BASE: &str = "https://graph.microsoft.com/v1.0";
55
56pub struct M365CopilotProvider {
58 access_token: Option<String>,
60 user_id: Option<String>,
62 app_class_filter: Option<String>,
64 client: Option<reqwest::blocking::Client>,
66}
67
68impl M365CopilotProvider {
69 pub fn new(access_token: Option<String>) -> Self {
74 Self {
75 access_token,
76 user_id: None,
77 app_class_filter: None,
78 client: None,
79 }
80 }
81
82 pub fn set_user_id(&mut self, user_id: String) {
84 self.user_id = Some(user_id);
85 }
86
87 pub fn set_app_class_filter(&mut self, app_class: String) {
94 self.app_class_filter = Some(app_class);
95 }
96
97 fn ensure_client(&mut self) -> Result<&reqwest::blocking::Client> {
98 if self.client.is_none() {
99 let config = HttpClientConfig::default();
100 self.client = Some(build_http_client(&config)?);
101 }
102 Ok(self.client.as_ref().unwrap())
103 }
104
105 fn build_interactions_url(&self) -> Result<String> {
107 let user_id = self
108 .user_id
109 .as_ref()
110 .ok_or_else(|| anyhow!("User ID is required. Call set_user_id() first."))?;
111
112 let mut url = format!(
113 "{}/copilot/users/{}/interactionHistory/getAllEnterpriseInteractions",
114 GRAPH_API_BASE, user_id
115 );
116
117 if let Some(ref app_class) = self.app_class_filter {
119 url.push_str(&format!("?$filter=appClass eq '{}'", app_class));
120 }
121
122 Ok(url)
123 }
124}
125
126#[derive(Debug, Deserialize)]
128struct GraphResponse<T> {
129 value: Vec<T>,
130 #[serde(rename = "@odata.nextLink")]
131 next_link: Option<String>,
132}
133
134#[derive(Debug, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub(crate) struct AiInteraction {
138 id: String,
140 session_id: Option<String>,
142 request_id: Option<String>,
144 app_class: Option<String>,
146 interaction_type: Option<String>,
148 conversation_type: Option<String>,
150 created_date_time: Option<String>,
152 locale: Option<String>,
154 body: Option<AiInteractionBody>,
156 from: Option<AiInteractionFrom>,
158 #[serde(default)]
160 attachments: Vec<AiInteractionAttachment>,
161 #[serde(default)]
163 links: Vec<AiInteractionLink>,
164 #[serde(default)]
166 contexts: Vec<AiInteractionContext>,
167}
168
169#[derive(Debug, Deserialize)]
170#[serde(rename_all = "camelCase")]
171struct AiInteractionBody {
172 content_type: Option<String>,
173 content: Option<String>,
174}
175
176#[derive(Debug, Deserialize)]
177struct AiInteractionFrom {
178 user: Option<AiInteractionUser>,
179 application: Option<AiInteractionApplication>,
180}
181
182#[derive(Debug, Deserialize)]
183#[serde(rename_all = "camelCase")]
184struct AiInteractionUser {
185 id: Option<String>,
186 display_name: Option<String>,
187}
188
189#[derive(Debug, Deserialize)]
190#[serde(rename_all = "camelCase")]
191struct AiInteractionApplication {
192 id: Option<String>,
193 display_name: Option<String>,
194}
195
196#[derive(Debug, Deserialize)]
197#[serde(rename_all = "camelCase")]
198struct AiInteractionAttachment {
199 attachment_id: Option<String>,
200 content_type: Option<String>,
201 content_url: Option<String>,
202 name: Option<String>,
203}
204
205#[derive(Debug, Deserialize)]
206#[serde(rename_all = "camelCase")]
207struct AiInteractionLink {
208 link_url: Option<String>,
209 display_name: Option<String>,
210 link_type: Option<String>,
211}
212
213#[derive(Debug, Deserialize)]
214#[serde(rename_all = "camelCase")]
215struct AiInteractionContext {
216 context_reference: Option<String>,
217 display_name: Option<String>,
218 context_type: Option<String>,
219}
220
221impl CloudProvider for M365CopilotProvider {
222 fn name(&self) -> &'static str {
223 "Microsoft 365 Copilot"
224 }
225
226 fn api_base_url(&self) -> &str {
227 GRAPH_API_BASE
228 }
229
230 fn is_authenticated(&self) -> bool {
231 self.access_token.is_some()
232 }
233
234 fn set_credentials(&mut self, api_key: Option<String>, _session_token: Option<String>) {
235 self.access_token = api_key;
237 }
238
239 fn list_conversations(&self, _options: &FetchOptions) -> Result<Vec<CloudConversation>> {
240 if !self.is_authenticated() {
241 return Err(anyhow!(
242 "Microsoft 365 Copilot requires authentication. Provide an Azure AD access token \
243 with AiEnterpriseInteraction.Read.All permission."
244 ));
245 }
246
247 if self.user_id.is_none() {
248 return Err(anyhow!(
249 "User ID is required. Call set_user_id() with the user's Azure AD object ID."
250 ));
251 }
252
253 eprintln!("Note: Microsoft 365 Copilot requires:");
256 eprintln!(
257 " 1. Azure AD app registration with AiEnterpriseInteraction.Read.All permission"
258 );
259 eprintln!(" 2. Admin consent for the permission");
260 eprintln!(" 3. A valid access token");
261 eprintln!(" 4. The target user's Azure AD object ID");
262
263 Ok(vec![])
265 }
266
267 fn fetch_conversation(&self, id: &str) -> Result<CloudConversation> {
268 if !self.is_authenticated() {
269 return Err(anyhow!("Microsoft 365 Copilot requires authentication"));
270 }
271
272 Err(anyhow!(
275 "Microsoft 365 Copilot doesn't support fetching individual conversations by ID. \
276 Use list_conversations() and filter by session_id: {}",
277 id
278 ))
279 }
280
281 fn api_key_env_var(&self) -> &'static str {
282 "M365_COPILOT_ACCESS_TOKEN"
283 }
284}
285
286pub(crate) fn group_interactions_into_conversations(
288 interactions: Vec<AiInteraction>,
289) -> Vec<CloudConversation> {
290 let mut sessions: HashMap<String, Vec<AiInteraction>> = HashMap::new();
292
293 for interaction in interactions {
294 let session_id = interaction
295 .session_id
296 .clone()
297 .unwrap_or_else(|| "unknown".to_string());
298 sessions.entry(session_id).or_default().push(interaction);
299 }
300
301 sessions
303 .into_iter()
304 .map(|(session_id, mut interactions)| {
305 interactions.sort_by(|a, b| {
307 let a_time = a.created_date_time.as_deref().unwrap_or("");
308 let b_time = b.created_date_time.as_deref().unwrap_or("");
309 a_time.cmp(b_time)
310 });
311
312 let app_class = interactions
314 .first()
315 .and_then(|i| i.app_class.clone())
316 .unwrap_or_else(|| "Unknown".to_string());
317
318 let conversation_type = interactions
319 .first()
320 .and_then(|i| i.conversation_type.clone());
321
322 let created_at = interactions
324 .first()
325 .and_then(|i| i.created_date_time.as_ref())
326 .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok())
327 .map(|dt| dt.with_timezone(&Utc))
328 .unwrap_or_else(Utc::now);
329
330 let updated_at = interactions
331 .last()
332 .and_then(|i| i.created_date_time.as_ref())
333 .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok())
334 .map(|dt| dt.with_timezone(&Utc));
335
336 let messages: Vec<CloudMessage> = interactions
338 .into_iter()
339 .filter_map(|interaction| {
340 let content = interaction.body.as_ref()?.content.clone()?;
341
342 let role = match interaction.interaction_type.as_deref() {
343 Some("userPrompt") => "user",
344 Some("aiResponse") => "assistant",
345 _ => "unknown",
346 };
347
348 let timestamp = interaction
349 .created_date_time
350 .as_ref()
351 .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok())
352 .map(|dt| dt.with_timezone(&Utc));
353
354 let model = interaction
355 .from
356 .as_ref()
357 .and_then(|f| f.application.as_ref())
358 .and_then(|app| app.display_name.clone());
359
360 Some(CloudMessage {
361 id: Some(interaction.id),
362 role: role.to_string(),
363 content,
364 timestamp,
365 model,
366 })
367 })
368 .collect();
369
370 let title = Some(format!(
372 "{} - {}",
373 get_friendly_app_name(&app_class),
374 created_at.format("%Y-%m-%d %H:%M")
375 ));
376
377 CloudConversation {
378 id: session_id,
379 title,
380 created_at,
381 updated_at,
382 model: Some(app_class),
383 messages,
384 metadata: conversation_type.map(|ct| serde_json::json!({ "conversationType": ct })),
385 }
386 })
387 .collect()
388}
389
390pub fn get_friendly_app_name(app_class: &str) -> &'static str {
392 match app_class {
393 "IPM.SkypeTeams.Message.Copilot.BizChat" => "Microsoft 365 Copilot Chat",
394 "IPM.SkypeTeams.Message.Copilot.Teams" => "Copilot in Teams",
395 "IPM.SkypeTeams.Message.Copilot.Word" => "Copilot in Word",
396 "IPM.SkypeTeams.Message.Copilot.Excel" => "Copilot in Excel",
397 "IPM.SkypeTeams.Message.Copilot.PowerPoint" => "Copilot in PowerPoint",
398 "IPM.SkypeTeams.Message.Copilot.Outlook" => "Copilot in Outlook",
399 "IPM.SkypeTeams.Message.Copilot.Loop" => "Copilot in Loop",
400 "IPM.SkypeTeams.Message.Copilot.OneNote" => "Copilot in OneNote",
401 "IPM.SkypeTeams.Message.Copilot.Whiteboard" => "Copilot in Whiteboard",
402 _ => "Microsoft 365 Copilot",
403 }
404}
405
406pub fn parse_m365_copilot_export(json_data: &str) -> Result<Vec<CloudConversation>> {
408 if let Ok(response) = serde_json::from_str::<GraphResponse<AiInteraction>>(json_data) {
410 return Ok(group_interactions_into_conversations(response.value));
411 }
412
413 if let Ok(interactions) = serde_json::from_str::<Vec<AiInteraction>>(json_data) {
415 return Ok(group_interactions_into_conversations(interactions));
416 }
417
418 Err(anyhow!(
419 "Failed to parse Microsoft 365 Copilot export data. \
420 Expected Graph API response format or array of aiInteraction objects."
421 ))
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn test_m365_copilot_provider_new() {
430 let provider = M365CopilotProvider::new(Some("test-token".to_string()));
431 assert_eq!(provider.name(), "Microsoft 365 Copilot");
432 assert!(provider.is_authenticated());
433 }
434
435 #[test]
436 fn test_m365_copilot_provider_unauthenticated() {
437 let provider = M365CopilotProvider::new(None);
438 assert!(!provider.is_authenticated());
439 }
440
441 #[test]
442 fn test_api_key_env_var() {
443 let provider = M365CopilotProvider::new(None);
444 assert_eq!(provider.api_key_env_var(), "M365_COPILOT_ACCESS_TOKEN");
445 }
446
447 #[test]
448 fn test_set_user_id() {
449 let mut provider = M365CopilotProvider::new(Some("token".to_string()));
450 provider.set_user_id("user-123".to_string());
451 assert_eq!(provider.user_id, Some("user-123".to_string()));
452 }
453
454 #[test]
455 fn test_set_app_class_filter() {
456 let mut provider = M365CopilotProvider::new(Some("token".to_string()));
457 provider.set_app_class_filter("IPM.SkypeTeams.Message.Copilot.BizChat".to_string());
458 assert_eq!(
459 provider.app_class_filter,
460 Some("IPM.SkypeTeams.Message.Copilot.BizChat".to_string())
461 );
462 }
463
464 #[test]
465 fn test_build_interactions_url_no_filter() {
466 let mut provider = M365CopilotProvider::new(Some("token".to_string()));
467 provider.set_user_id("test-user-id".to_string());
468
469 let url = provider.build_interactions_url().unwrap();
470 assert_eq!(
471 url,
472 "https://graph.microsoft.com/v1.0/copilot/users/test-user-id/interactionHistory/getAllEnterpriseInteractions"
473 );
474 }
475
476 #[test]
477 fn test_build_interactions_url_with_filter() {
478 let mut provider = M365CopilotProvider::new(Some("token".to_string()));
479 provider.set_user_id("test-user-id".to_string());
480 provider.set_app_class_filter("IPM.SkypeTeams.Message.Copilot.BizChat".to_string());
481
482 let url = provider.build_interactions_url().unwrap();
483 assert!(url.contains("$filter=appClass eq 'IPM.SkypeTeams.Message.Copilot.BizChat'"));
484 }
485
486 #[test]
487 fn test_build_interactions_url_no_user_id() {
488 let provider = M365CopilotProvider::new(Some("token".to_string()));
489 let result = provider.build_interactions_url();
490 assert!(result.is_err());
491 }
492
493 #[test]
494 fn test_get_friendly_app_name() {
495 assert_eq!(
496 get_friendly_app_name("IPM.SkypeTeams.Message.Copilot.BizChat"),
497 "Microsoft 365 Copilot Chat"
498 );
499 assert_eq!(
500 get_friendly_app_name("IPM.SkypeTeams.Message.Copilot.Teams"),
501 "Copilot in Teams"
502 );
503 assert_eq!(
504 get_friendly_app_name("IPM.SkypeTeams.Message.Copilot.Word"),
505 "Copilot in Word"
506 );
507 assert_eq!(
508 get_friendly_app_name("IPM.SkypeTeams.Message.Copilot.Excel"),
509 "Copilot in Excel"
510 );
511 assert_eq!(get_friendly_app_name("unknown"), "Microsoft 365 Copilot");
512 }
513
514 #[test]
515 fn test_parse_m365_copilot_export_empty_array() {
516 let json = "[]";
517 let result = parse_m365_copilot_export(json).unwrap();
518 assert!(result.is_empty());
519 }
520
521 #[test]
522 fn test_parse_m365_copilot_export_graph_response() {
523 let json = r#"{
524 "value": [
525 {
526 "id": "1731701801008",
527 "sessionId": "session-123",
528 "requestId": "req-123",
529 "appClass": "IPM.SkypeTeams.Message.Copilot.BizChat",
530 "interactionType": "userPrompt",
531 "conversationType": "bizchat",
532 "createdDateTime": "2024-11-15T20:16:41.008Z",
533 "locale": "en-us",
534 "body": {
535 "contentType": "text",
536 "content": "What should be on my radar from emails last week?"
537 },
538 "from": {
539 "user": {
540 "id": "user-123",
541 "displayName": "Test User"
542 }
543 },
544 "attachments": [],
545 "links": [],
546 "contexts": []
547 },
548 {
549 "id": "1731701801009",
550 "sessionId": "session-123",
551 "requestId": "req-123",
552 "appClass": "IPM.SkypeTeams.Message.Copilot.BizChat",
553 "interactionType": "aiResponse",
554 "conversationType": "bizchat",
555 "createdDateTime": "2024-11-15T20:16:42.008Z",
556 "locale": "en-us",
557 "body": {
558 "contentType": "text",
559 "content": "Based on your emails from last week, here are the key items..."
560 },
561 "from": {
562 "application": {
563 "id": "copilot-app",
564 "displayName": "Microsoft 365 Chat"
565 }
566 },
567 "attachments": [],
568 "links": [],
569 "contexts": []
570 }
571 ]
572 }"#;
573
574 let result = parse_m365_copilot_export(json).unwrap();
575 assert_eq!(result.len(), 1); let conv = &result[0];
578 assert_eq!(conv.id, "session-123");
579 assert_eq!(conv.messages.len(), 2);
580 assert_eq!(conv.messages[0].role, "user");
581 assert_eq!(conv.messages[1].role, "assistant");
582 }
583
584 #[test]
585 fn test_parse_m365_copilot_export_multiple_sessions() {
586 let json = r#"[
587 {
588 "id": "1",
589 "sessionId": "session-a",
590 "interactionType": "userPrompt",
591 "appClass": "IPM.SkypeTeams.Message.Copilot.Word",
592 "createdDateTime": "2024-11-15T10:00:00Z",
593 "body": { "content": "Draft an email" }
594 },
595 {
596 "id": "2",
597 "sessionId": "session-b",
598 "interactionType": "userPrompt",
599 "appClass": "IPM.SkypeTeams.Message.Copilot.Excel",
600 "createdDateTime": "2024-11-15T11:00:00Z",
601 "body": { "content": "Create a formula" }
602 }
603 ]"#;
604
605 let result = parse_m365_copilot_export(json).unwrap();
606 assert_eq!(result.len(), 2); }
608
609 #[test]
610 fn test_group_interactions_preserves_order() {
611 let interactions = vec![
612 AiInteraction {
613 id: "3".to_string(),
614 session_id: Some("session-1".to_string()),
615 request_id: None,
616 app_class: Some("IPM.SkypeTeams.Message.Copilot.BizChat".to_string()),
617 interaction_type: Some("aiResponse".to_string()),
618 conversation_type: None,
619 created_date_time: Some("2024-11-15T10:00:02Z".to_string()),
620 locale: None,
621 body: Some(AiInteractionBody {
622 content_type: Some("text".to_string()),
623 content: Some("Response 1".to_string()),
624 }),
625 from: None,
626 attachments: vec![],
627 links: vec![],
628 contexts: vec![],
629 },
630 AiInteraction {
631 id: "1".to_string(),
632 session_id: Some("session-1".to_string()),
633 request_id: None,
634 app_class: Some("IPM.SkypeTeams.Message.Copilot.BizChat".to_string()),
635 interaction_type: Some("userPrompt".to_string()),
636 conversation_type: None,
637 created_date_time: Some("2024-11-15T10:00:00Z".to_string()),
638 locale: None,
639 body: Some(AiInteractionBody {
640 content_type: Some("text".to_string()),
641 content: Some("Question 1".to_string()),
642 }),
643 from: None,
644 attachments: vec![],
645 links: vec![],
646 contexts: vec![],
647 },
648 ];
649
650 let result = group_interactions_into_conversations(interactions);
651 assert_eq!(result.len(), 1);
652
653 let conv = &result[0];
654 assert_eq!(conv.messages.len(), 2);
655 assert_eq!(conv.messages[0].content, "Question 1");
657 assert_eq!(conv.messages[1].content, "Response 1");
658 }
659}