chasm_cli/providers/cloud/
m365copilot.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Microsoft 365 Copilot cloud provider
4//!
5//! Fetches conversation history from Microsoft 365 Copilot using the Microsoft Graph API.
6//!
7//! ## Authentication
8//!
9//! Requires an Azure AD/Entra ID access token with the `AiEnterpriseInteraction.Read.All`
10//! permission. This is typically obtained through:
11//! - Azure AD application registration with admin consent
12//! - MSAL (Microsoft Authentication Library) token acquisition
13//!
14//! ## API Documentation
15//!
16//! - [AI Interaction History API](https://learn.microsoft.com/en-us/microsoft-365-copilot/extensibility/api/ai-services/interaction-export/resources/aiinteractionhistory)
17//! - [Get All Enterprise Interactions](https://learn.microsoft.com/en-us/microsoft-365-copilot/extensibility/api/ai-services/interaction-export/aiinteractionhistory-getallenterpriseinteractions)
18//!
19//! ## App Classes
20//!
21//! Microsoft 365 Copilot interactions are categorized by app class:
22//! - `IPM.SkypeTeams.Message.Copilot.BizChat` - Microsoft 365 Copilot Chat (formerly Bing Chat Enterprise)
23//! - `IPM.SkypeTeams.Message.Copilot.Teams` - Copilot in Teams
24//! - `IPM.SkypeTeams.Message.Copilot.Word` - Copilot in Word
25//! - `IPM.SkypeTeams.Message.Copilot.Excel` - Copilot in Excel
26//! - `IPM.SkypeTeams.Message.Copilot.PowerPoint` - Copilot in PowerPoint
27//! - `IPM.SkypeTeams.Message.Copilot.Outlook` - Copilot in Outlook
28//! - `IPM.SkypeTeams.Message.Copilot.Loop` - Copilot in Loop
29//!
30//! ## Example Usage
31//!
32//! ```rust,ignore
33//! use csm::providers::cloud::m365copilot::M365CopilotProvider;
34//! use csm::providers::cloud::CloudProvider;
35//!
36//! // Create provider with Azure AD access token
37//! let mut provider = M365CopilotProvider::new(Some(access_token));
38//! provider.set_user_id("user-uuid".to_string());
39//!
40//! // List conversations
41//! let options = FetchOptions::default();
42//! let conversations = provider.list_conversations(&options)?;
43//! ```
44
45use 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
56/// Microsoft 365 Copilot provider for fetching AI interaction history
57pub struct M365CopilotProvider {
58    /// Azure AD access token (Bearer token)
59    access_token: Option<String>,
60    /// User ID (GUID) for querying interactions
61    user_id: Option<String>,
62    /// Optional app class filter (e.g., "IPM.SkypeTeams.Message.Copilot.BizChat")
63    app_class_filter: Option<String>,
64    /// HTTP client
65    client: Option<reqwest::blocking::Client>,
66}
67
68impl M365CopilotProvider {
69    /// Create a new M365 Copilot provider
70    ///
71    /// # Arguments
72    /// * `access_token` - Azure AD access token with `AiEnterpriseInteraction.Read.All` permission
73    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    /// Set the user ID for querying interactions
83    pub fn set_user_id(&mut self, user_id: String) {
84        self.user_id = Some(user_id);
85    }
86
87    /// Set an app class filter to narrow down results
88    ///
89    /// Common app classes:
90    /// - `IPM.SkypeTeams.Message.Copilot.BizChat` - Microsoft 365 Copilot Chat
91    /// - `IPM.SkypeTeams.Message.Copilot.Teams` - Copilot in Teams
92    /// - `IPM.SkypeTeams.Message.Copilot.Word` - Copilot in Word
93    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    /// Build the API URL for fetching interactions
106    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        // Add app class filter if specified
118        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/// Microsoft Graph API response wrapper
127#[derive(Debug, Deserialize)]
128struct GraphResponse<T> {
129    value: Vec<T>,
130    #[serde(rename = "@odata.nextLink")]
131    next_link: Option<String>,
132}
133
134/// AI Interaction from Microsoft Graph API
135#[derive(Debug, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub(crate) struct AiInteraction {
138    /// Unique identifier
139    id: String,
140    /// Session ID (conversation thread)
141    session_id: Option<String>,
142    /// Request ID that links prompts to responses
143    request_id: Option<String>,
144    /// App class (e.g., IPM.SkypeTeams.Message.Copilot.BizChat)
145    app_class: Option<String>,
146    /// Type: userPrompt or aiResponse
147    interaction_type: Option<String>,
148    /// Conversation type (e.g., bizchat, appchat)
149    conversation_type: Option<String>,
150    /// Creation timestamp
151    created_date_time: Option<String>,
152    /// Locale
153    locale: Option<String>,
154    /// Message body
155    body: Option<AiInteractionBody>,
156    /// Source identity
157    from: Option<AiInteractionFrom>,
158    /// Attachments
159    #[serde(default)]
160    attachments: Vec<AiInteractionAttachment>,
161    /// Links in the response
162    #[serde(default)]
163    links: Vec<AiInteractionLink>,
164    /// Contexts
165    #[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        // For M365 Copilot, the "api_key" is actually an Azure AD access token
236        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        // Note: This is a placeholder since we need mutable self for ensure_client
254        // The actual implementation would need to be adjusted for the trait bounds
255        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        // Return empty for now - real implementation would make the API call
264        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        // The M365 Copilot API doesn't have a direct "get single conversation" endpoint
273        // Conversations are identified by session_id and must be filtered from the full list
274        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
286/// Group AI interactions by session ID into conversations
287pub(crate) fn group_interactions_into_conversations(
288    interactions: Vec<AiInteraction>,
289) -> Vec<CloudConversation> {
290    // Group by session_id
291    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    // Convert each session to a CloudConversation
302    sessions
303        .into_iter()
304        .map(|(session_id, mut interactions)| {
305            // Sort by created_date_time
306            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            // Determine app class and conversation type from first interaction
313            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            // Parse timestamps
323            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            // Convert interactions to messages
337            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            // Generate title from app class
371            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
390/// Get a friendly name for the M365 Copilot app class
391pub 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
406/// Parse Microsoft 365 Copilot export data (JSON format from Graph API)
407pub fn parse_m365_copilot_export(json_data: &str) -> Result<Vec<CloudConversation>> {
408    // Try to parse as a Graph API response
409    if let Ok(response) = serde_json::from_str::<GraphResponse<AiInteraction>>(json_data) {
410        return Ok(group_interactions_into_conversations(response.value));
411    }
412
413    // Try to parse as a direct array of interactions
414    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); // One conversation (grouped by session_id)
576
577        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); // Two separate conversations
607    }
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        // Should be sorted by timestamp
656        assert_eq!(conv.messages[0].content, "Question 1");
657        assert_eq!(conv.messages[1].content, "Response 1");
658    }
659}