mirror of
https://github.com/danielmiessler/Fabric.git
synced 2026-02-13 07:25:10 -05:00
Update the SendStream interface to match the current Vendor interface
which now uses chan domain.StreamUpdate instead of chan string.
Changes:
- Update SendStream signature to use chan domain.StreamUpdate
- Update sendChatMessageStream signature accordingly
- Update parseSSEStream signature accordingly
- Wrap all channel sends with domain.StreamUpdate{Type: StreamTypeContent}
This fixes the build error introduced when the streaming interface was
updated to support metadata like token usage alongside content.
487 lines
14 KiB
Go
487 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 strings.HasPrefix(newText, lastMessageText) {
|
|
delta := strings.TrimPrefix(newText, lastMessageText)
|
|
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 []interface{} `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"`
|
|
}
|