Files
Fabric/internal/plugins/ai/copilot/copilot.go

486 lines
14 KiB
Go

// Package copilot provides integration with Microsoft 365 Copilot Chat API.
// This vendor allows Fabric to interact with Microsoft 365 Copilot, which provides
// AI capabilities grounded in your organization's Microsoft 365 data.
//
// Requirements:
// - Microsoft 365 Copilot license for each user
// - Microsoft 365 E3 or E5 subscription (or equivalent)
// - Azure AD app registration with appropriate permissions
//
// The Chat API is currently in preview and requires delegated (work or school account)
// permissions. Application permissions are not supported.
package copilot
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/danielmiessler/fabric/internal/chat"
"github.com/danielmiessler/fabric/internal/domain"
debuglog "github.com/danielmiessler/fabric/internal/log"
"github.com/danielmiessler/fabric/internal/plugins"
"golang.org/x/oauth2"
)
const (
vendorName = "Copilot"
// Microsoft Graph API endpoints
defaultBaseURL = "https://graph.microsoft.com/beta/copilot"
conversationsPath = "/conversations"
// OAuth2 endpoints for Microsoft identity platform
microsoftAuthURL = "https://login.microsoftonline.com/%s/oauth2/v2.0/authorize"
microsoftTokenURL = "https://login.microsoftonline.com/%s/oauth2/v2.0/token"
// Default scopes required for Copilot Chat API
// These are the minimum required permissions
defaultScopes = "Sites.Read.All Mail.Read People.Read.All OnlineMeetingTranscript.Read.All Chat.Read ChannelMessage.Read.All ExternalItem.Read.All offline_access"
// Model name exposed by Copilot (single model)
copilotModelName = "microsoft-365-copilot"
)
// NewClient creates a new Microsoft 365 Copilot client.
func NewClient() *Client {
c := &Client{}
c.PluginBase = &plugins.PluginBase{
Name: vendorName,
EnvNamePrefix: plugins.BuildEnvVariablePrefix(vendorName),
ConfigureCustom: c.configure,
}
// Setup questions for configuration
c.TenantID = c.AddSetupQuestion("Tenant ID", true)
c.TenantID.Question = "Enter your Azure AD Tenant ID (e.g., contoso.onmicrosoft.com or GUID)"
c.ClientID = c.AddSetupQuestion("Client ID", true)
c.ClientID.Question = "Enter your Azure AD Application (Client) ID"
c.ClientSecret = c.AddSetupQuestion("Client Secret", false)
c.ClientSecret.Question = "Enter your Azure AD Client Secret (optional, for confidential clients)"
c.AccessToken = c.AddSetupQuestion("Access Token", false)
c.AccessToken.Question = "Enter a pre-obtained OAuth2 Access Token (optional, for testing)"
c.RefreshToken = c.AddSetupQuestion("Refresh Token", false)
c.RefreshToken.Question = "Enter a pre-obtained OAuth2 Refresh Token (optional)"
c.ApiBaseURL = c.AddSetupQuestion("API Base URL", false)
c.ApiBaseURL.Value = defaultBaseURL
c.TimeZone = c.AddSetupQuestion("Time Zone", false)
c.TimeZone.Value = "America/New_York"
c.TimeZone.Question = "Enter your timezone (e.g., America/New_York, Europe/London)"
return c
}
// Client represents a Microsoft 365 Copilot API client.
type Client struct {
*plugins.PluginBase
// Configuration
TenantID *plugins.SetupQuestion
ClientID *plugins.SetupQuestion
ClientSecret *plugins.SetupQuestion
AccessToken *plugins.SetupQuestion
RefreshToken *plugins.SetupQuestion
ApiBaseURL *plugins.SetupQuestion
TimeZone *plugins.SetupQuestion
// Runtime state
httpClient *http.Client
oauth2Config *oauth2.Config
token *oauth2.Token
}
// configure initializes the client with OAuth2 configuration.
func (c *Client) configure() error {
if c.TenantID.Value == "" || c.ClientID.Value == "" {
return fmt.Errorf("tenant ID and client ID are required")
}
// Build OAuth2 configuration
c.oauth2Config = &oauth2.Config{
ClientID: c.ClientID.Value,
ClientSecret: c.ClientSecret.Value,
Endpoint: oauth2.Endpoint{
AuthURL: fmt.Sprintf(microsoftAuthURL, c.TenantID.Value),
TokenURL: fmt.Sprintf(microsoftTokenURL, c.TenantID.Value),
},
Scopes: strings.Split(defaultScopes, " "),
}
// If we have pre-configured tokens, use them
if c.AccessToken.Value != "" {
c.token = &oauth2.Token{
AccessToken: c.AccessToken.Value,
RefreshToken: c.RefreshToken.Value,
TokenType: "Bearer",
}
// If we have a refresh token, set expiry in the past to trigger refresh
if c.RefreshToken.Value != "" && c.ClientSecret.Value != "" {
c.token.Expiry = time.Now().Add(-time.Hour)
}
}
// Create HTTP client with OAuth2 token source
if c.token != nil {
tokenSource := c.oauth2Config.TokenSource(context.Background(), c.token)
c.httpClient = oauth2.NewClient(context.Background(), tokenSource)
} else {
// No tokens available - will need device code flow or manual token
c.httpClient = &http.Client{Timeout: 120 * time.Second}
}
return nil
}
// IsConfigured returns true if the client has valid configuration.
func (c *Client) IsConfigured() bool {
// Minimum requirement: tenant ID and client ID
if c.TenantID.Value == "" || c.ClientID.Value == "" {
return false
}
// Must have either an access token or ability to get one
return c.AccessToken.Value != "" || (c.RefreshToken.Value != "" && c.ClientSecret.Value != "")
}
// ListModels returns the available models.
// Microsoft 365 Copilot exposes a single model - the Copilot service itself.
func (c *Client) ListModels() ([]string, error) {
// Copilot doesn't expose multiple models - it's a unified service
// We expose it as a single "model" for consistency with Fabric's architecture
return []string{copilotModelName}, nil
}
// Send sends a message to Copilot and returns the response.
func (c *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions) (string, error) {
// Create a conversation
conversationID, err := c.createConversation(ctx)
if err != nil {
return "", fmt.Errorf("failed to create conversation: %w", err)
}
// Build the message content from chat messages
messageText := c.buildMessageText(msgs)
// Send the chat message
response, err := c.sendChatMessage(ctx, conversationID, messageText)
if err != nil {
return "", fmt.Errorf("failed to send message: %w", err)
}
return response, nil
}
// SendStream sends a message to Copilot and streams the response.
func (c *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) error {
defer close(channel)
ctx := context.Background()
// Create a conversation
conversationID, err := c.createConversation(ctx)
if err != nil {
return fmt.Errorf("failed to create conversation: %w", err)
}
// Build the message content from chat messages
messageText := c.buildMessageText(msgs)
// Send the streaming chat message
if err := c.sendChatMessageStream(ctx, conversationID, messageText, channel); err != nil {
return fmt.Errorf("failed to stream message: %w", err)
}
return nil
}
// NeedsRawMode returns whether the model needs raw mode.
func (c *Client) NeedsRawMode(modelName string) bool {
return false
}
// buildMessageText combines chat messages into a single prompt for Copilot.
func (c *Client) buildMessageText(msgs []*chat.ChatCompletionMessage) string {
var parts []string
for _, msg := range msgs {
content := strings.TrimSpace(msg.Content)
if content == "" {
continue
}
switch msg.Role {
case chat.ChatMessageRoleSystem:
// Prepend system messages as context
parts = append([]string{content}, parts...)
case chat.ChatMessageRoleUser, chat.ChatMessageRoleAssistant:
parts = append(parts, content)
}
}
return strings.Join(parts, "\n\n")
}
// createConversation creates a new Copilot conversation.
func (c *Client) createConversation(ctx context.Context) (string, error) {
url := c.ApiBaseURL.Value + conversationsPath
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBufferString("{}"))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
c.addAuthHeader(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("failed to create conversation: %s - %s", resp.Status, string(body))
}
var result conversationResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
debuglog.Debug(debuglog.Detailed, "Created Copilot conversation: %s\n", result.ID)
return result.ID, nil
}
// sendChatMessage sends a message to an existing conversation (synchronous).
func (c *Client) sendChatMessage(ctx context.Context, conversationID, messageText string) (string, error) {
url := fmt.Sprintf("%s%s/%s/chat", c.ApiBaseURL.Value, conversationsPath, conversationID)
reqBody := chatRequest{
Message: messageParam{
Text: messageText,
},
LocationHint: locationHint{
TimeZone: c.TimeZone.Value,
},
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
c.addAuthHeader(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("chat request failed: %s - %s", resp.Status, string(body))
}
var result conversationResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
// Extract the assistant's response from messages
return c.extractResponseText(result.Messages), nil
}
// sendChatMessageStream sends a message and streams the response via SSE.
func (c *Client) sendChatMessageStream(ctx context.Context, conversationID, messageText string, channel chan domain.StreamUpdate) error {
url := fmt.Sprintf("%s%s/%s/chatOverStream", c.ApiBaseURL.Value, conversationsPath, conversationID)
reqBody := chatRequest{
Message: messageParam{
Text: messageText,
},
LocationHint: locationHint{
TimeZone: c.TimeZone.Value,
},
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
c.addAuthHeader(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("stream request failed: %s - %s", resp.Status, string(body))
}
// Parse SSE stream
return c.parseSSEStream(resp.Body, channel)
}
// parseSSEStream parses the Server-Sent Events stream from Copilot.
func (c *Client) parseSSEStream(reader io.Reader, channel chan domain.StreamUpdate) error {
scanner := bufio.NewScanner(reader)
var lastMessageText string
for scanner.Scan() {
line := scanner.Text()
// SSE format: "data: {...json...}"
if !strings.HasPrefix(line, "data: ") {
continue
}
jsonData := strings.TrimPrefix(line, "data: ")
if jsonData == "" {
continue
}
var event conversationResponse
if err := json.Unmarshal([]byte(jsonData), &event); err != nil {
debuglog.Debug(debuglog.Detailed, "Failed to parse SSE event: %v\n", err)
continue
}
// Extract new text from the response
newText := c.extractResponseText(event.Messages)
if newText != "" && newText != lastMessageText {
// Send only the delta (new content)
if delta, ok := strings.CutPrefix(newText, lastMessageText); ok {
if delta != "" {
channel <- domain.StreamUpdate{Type: domain.StreamTypeContent, Content: delta}
}
} else {
// Complete message replacement
channel <- domain.StreamUpdate{Type: domain.StreamTypeContent, Content: newText}
}
lastMessageText = newText
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("error reading stream: %w", err)
}
channel <- domain.StreamUpdate{Type: domain.StreamTypeContent, Content: "\n"}
return nil
}
// extractResponseText extracts the assistant's response from messages.
func (c *Client) extractResponseText(messages []responseMessage) string {
// Find the last assistant message (Copilot's response)
for i := len(messages) - 1; i >= 0; i-- {
msg := messages[i]
// Response messages from Copilot have the copilotConversationResponseMessage type
if msg.ODataType == "#microsoft.graph.copilotConversationResponseMessage" {
if msg.Text != "" {
return msg.Text
}
}
}
return ""
}
// addAuthHeader adds the authorization header to a request.
func (c *Client) addAuthHeader(req *http.Request) {
if c.token != nil && c.token.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.token.AccessToken)
} else if c.AccessToken.Value != "" {
req.Header.Set("Authorization", "Bearer "+c.AccessToken.Value)
}
}
// API request/response types
type chatRequest struct {
Message messageParam `json:"message"`
LocationHint locationHint `json:"locationHint"`
AdditionalContext []contextMessage `json:"additionalContext,omitempty"`
ContextualResources *contextualResources `json:"contextualResources,omitempty"`
}
type messageParam struct {
Text string `json:"text"`
}
type locationHint struct {
TimeZone string `json:"timeZone"`
}
type contextMessage struct {
Text string `json:"text"`
}
type contextualResources struct {
Files []fileResource `json:"files,omitempty"`
WebContext *webContext `json:"webContext,omitempty"`
}
type fileResource struct {
URI string `json:"uri"`
}
type webContext struct {
IsWebEnabled bool `json:"isWebEnabled"`
}
type conversationResponse struct {
ID string `json:"id"`
CreatedDateTime string `json:"createdDateTime"`
DisplayName string `json:"displayName"`
State string `json:"state"`
TurnCount int `json:"turnCount"`
Messages []responseMessage `json:"messages,omitempty"`
}
type responseMessage struct {
ODataType string `json:"@odata.type"`
ID string `json:"id"`
Text string `json:"text"`
CreatedDateTime string `json:"createdDateTime"`
AdaptiveCards []any `json:"adaptiveCards,omitempty"`
Attributions []attribution `json:"attributions,omitempty"`
}
type attribution struct {
AttributionType string `json:"attributionType"`
ProviderDisplayName string `json:"providerDisplayName"`
AttributionSource string `json:"attributionSource"`
SeeMoreWebURL string `json:"seeMoreWebUrl"`
}