Skip to content

Commit 9f76836

Browse files
adileiadileiclaude
authored
Add Salesforce integration sample for Copilot Studio (#448)
* Add Salesforce integration sample for Copilot Studio Adds Apex classes and deployment scripts for integrating Microsoft Copilot Studio with Salesforce Einstein Bots via the DirectLine API. Companion code for the Microsoft Learn documentation. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Clarify deployment script steps in README Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add External Credential and Named Credential to deployment Now deploys the full credential configuration. User only needs to add their DirectLine secret to the Principal after deployment. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Automate External Credential Principal Access grant The grant-bot-permissions script now also grants the Directline_Principal to the Chatbot permission set's External Credential Principal Access. User only needs to add their DirectLine secret after deployment. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix Microsoft Learn documentation URL Added missing -handoff suffix to the documentation URL. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: adilei <adileibowiz@microsoft.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0b03244 commit 9f76836

43 files changed

Lines changed: 265060 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* DL_GetActivity
3+
* Retrieves bot responses from Copilot Studio via DirectLine API.
4+
* Called from Einstein Bot to get the AI response.
5+
* Supports configurable delay and retry for async responses.
6+
* Uses Named Credential for authentication (default: 'DirectLine').
7+
*/
8+
public with sharing class DL_GetActivity {
9+
10+
private static final String DEFAULT_NAMED_CREDENTIAL = 'Directline';
11+
12+
public class Input {
13+
@InvocableVariable(label='Conversation ID' description='The DirectLine conversation ID' required=true)
14+
public String conversationId;
15+
16+
@InvocableVariable(label='Watermark' description='Watermark from previous call to get only new messages')
17+
public String watermark;
18+
19+
@InvocableVariable(label='Initial Delay (seconds)' description='Seconds to wait before first attempt. Default: 5')
20+
public Integer delaySeconds;
21+
22+
@InvocableVariable(label='Max Retries' description='Number of retry attempts if no response. Default: 5')
23+
public Integer maxRetries;
24+
25+
@InvocableVariable(label='Named Credential' description='Name of the Named Credential to use (default: DirectLine)')
26+
public String namedCredential;
27+
}
28+
29+
public class Output {
30+
@InvocableVariable(label='Message' description='The bot response message')
31+
public String message;
32+
33+
@InvocableVariable(label='Watermark' description='Updated watermark for next call')
34+
public String watermark;
35+
36+
@InvocableVariable(label='Response Code' description='HTTP response code')
37+
public Integer responseCode;
38+
39+
@InvocableVariable(label='Error Message' description='Error message if failed')
40+
public String errorMessage;
41+
42+
@InvocableVariable(label='Has More Messages' description='Whether there are more messages to retrieve')
43+
public Boolean hasMoreMessages;
44+
45+
@InvocableVariable(label='Is Handoff' description='Whether the bot is requesting handoff to agent')
46+
public Boolean isHandoff;
47+
}
48+
49+
@InvocableMethod(label='Get Copilot Studio Response' description='Retrieves bot responses from Copilot Studio via DirectLine API')
50+
public static List<Output> getActivity(List<Input> inputs) {
51+
List<Output> outputs = new List<Output>();
52+
53+
for (Input input : inputs) {
54+
Output output = new Output();
55+
output.hasMoreMessages = false;
56+
output.isHandoff = false;
57+
58+
// Set defaults for delay and retries
59+
Integer initialDelay = (input.delaySeconds != null && input.delaySeconds > 0) ? input.delaySeconds : 5;
60+
Integer retries = (input.maxRetries != null && input.maxRetries > 0) ? input.maxRetries : 5;
61+
String credentialName = String.isNotBlank(input.namedCredential) ? input.namedCredential : DEFAULT_NAMED_CREDENTIAL;
62+
63+
try {
64+
// Initial delay before first attempt
65+
if (initialDelay > 0) {
66+
waitSeconds(initialDelay);
67+
}
68+
69+
String endpoint = 'callout:' + credentialName + '/v3/directline/conversations/' + input.conversationId + '/activities';
70+
if (String.isNotBlank(input.watermark) && input.watermark != '0') {
71+
endpoint += '?watermark=' + input.watermark;
72+
}
73+
74+
// Retry loop
75+
Integer attempts = 0;
76+
while (attempts <= retries) {
77+
HttpRequest req = new HttpRequest();
78+
req.setEndpoint(endpoint);
79+
req.setMethod('GET');
80+
// Authorization header automatically added by Named Credential
81+
req.setTimeout(30000);
82+
83+
Http http = new Http();
84+
HttpResponse res = http.send(req);
85+
86+
output.responseCode = res.getStatusCode();
87+
88+
if (res.getStatusCode() == 200) {
89+
Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
90+
91+
// Update watermark
92+
output.watermark = (String) responseMap.get('watermark');
93+
94+
// Process activities
95+
List<Object> activities = (List<Object>) responseMap.get('activities');
96+
Boolean foundMessage = false;
97+
98+
if (activities != null && !activities.isEmpty()) {
99+
// Find the last bot message (skip user messages)
100+
for (Integer i = activities.size() - 1; i >= 0; i--) {
101+
Map<String, Object> activity = (Map<String, Object>) activities[i];
102+
String activityType = (String) activity.get('type');
103+
Map<String, Object> fromObj = (Map<String, Object>) activity.get('from');
104+
String fromId = fromObj != null ? (String) fromObj.get('id') : '';
105+
106+
// Check for handoff event
107+
if (activityType == 'event') {
108+
String eventName = (String) activity.get('name');
109+
if (eventName == 'handoff.initiate') {
110+
output.isHandoff = true;
111+
output.message = 'Transferring to agent...';
112+
foundMessage = true;
113+
break;
114+
}
115+
}
116+
117+
// Look for bot messages (not from user)
118+
if (activityType == 'message' && !fromId.startsWith('user')) {
119+
output.message = (String) activity.get('text');
120+
output.hasMoreMessages = (i < activities.size() - 1);
121+
foundMessage = true;
122+
break;
123+
}
124+
}
125+
}
126+
127+
// If message found, exit retry loop
128+
if (foundMessage) {
129+
break;
130+
}
131+
132+
// No message yet - retry if attempts remaining
133+
attempts++;
134+
if (attempts <= retries) {
135+
waitSeconds(1); // 1 second between retries
136+
}
137+
} else {
138+
output.errorMessage = 'Failed to get activities: ' + res.getStatus() + ' - ' + res.getBody();
139+
break; // Don't retry on HTTP errors
140+
}
141+
}
142+
143+
// If no message found after all retries
144+
if (String.isBlank(output.message) && !output.isHandoff) {
145+
output.message = '';
146+
}
147+
} catch (Exception e) {
148+
output.responseCode = 500;
149+
output.errorMessage = 'Exception: ' + e.getMessage();
150+
}
151+
152+
outputs.add(output);
153+
}
154+
155+
return outputs;
156+
}
157+
158+
// Helper method to wait for specified seconds
159+
private static void waitSeconds(Integer seconds) {
160+
Long startTime = DateTime.now().getTime();
161+
Long endTime = startTime + (seconds * 1000);
162+
while (DateTime.now().getTime() < endTime) {
163+
// Busy wait - keeps CPU usage minimal by doing nothing
164+
}
165+
}
166+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* DL_GetConversation
3+
* Starts a new DirectLine conversation with Copilot Studio.
4+
* Called from Einstein Bot to initialize the connection.
5+
* Uses Named Credential for authentication (default: 'DirectLine').
6+
*/
7+
public with sharing class DL_GetConversation {
8+
9+
private static final String DEFAULT_NAMED_CREDENTIAL = 'Directline';
10+
11+
public class Input {
12+
@InvocableVariable(label='Named Credential' description='Name of the Named Credential to use (default: DirectLine)')
13+
public String namedCredential;
14+
}
15+
16+
public class Output {
17+
@InvocableVariable(label='Conversation ID' description='The DirectLine conversation ID')
18+
public String conversationId;
19+
20+
@InvocableVariable(label='Response Code' description='HTTP response code')
21+
public Integer responseCode;
22+
23+
@InvocableVariable(label='Error Message' description='Error message if failed')
24+
public String errorMessage;
25+
}
26+
27+
@InvocableMethod(label='Start DirectLine Conversation' description='Initiates a new conversation with Copilot Studio via DirectLine API')
28+
public static List<Output> getConversationID(List<Input> inputs) {
29+
List<Output> outputs = new List<Output>();
30+
31+
for (Input input : inputs) {
32+
Output output = new Output();
33+
34+
try {
35+
String credentialName = String.isNotBlank(input.namedCredential) ? input.namedCredential : DEFAULT_NAMED_CREDENTIAL;
36+
String endpoint = 'callout:' + credentialName + '/v3/directline/conversations';
37+
38+
HttpRequest req = new HttpRequest();
39+
req.setEndpoint(endpoint);
40+
req.setMethod('POST');
41+
// Authorization header automatically added by Named Credential
42+
req.setHeader('Content-Type', 'application/json');
43+
req.setBody('{}');
44+
req.setTimeout(30000);
45+
46+
Http http = new Http();
47+
HttpResponse res = http.send(req);
48+
49+
output.responseCode = res.getStatusCode();
50+
51+
if (res.getStatusCode() == 200 || res.getStatusCode() == 201) {
52+
Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
53+
output.conversationId = (String) responseMap.get('conversationId');
54+
} else {
55+
output.errorMessage = 'Failed to start conversation: ' + res.getStatus() + ' - ' + res.getBody();
56+
}
57+
} catch (Exception e) {
58+
output.responseCode = 500;
59+
output.errorMessage = 'Exception: ' + e.getMessage();
60+
}
61+
62+
outputs.add(output);
63+
}
64+
65+
return outputs;
66+
}
67+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* DL_PostActivity
3+
* Sends a user message to Copilot Studio via DirectLine API.
4+
* Called from Einstein Bot when the user sends a message.
5+
* Uses Named Credential for authentication (default: 'DirectLine').
6+
*/
7+
public with sharing class DL_PostActivity {
8+
9+
private static final String DEFAULT_NAMED_CREDENTIAL = 'Directline';
10+
11+
public class Input {
12+
@InvocableVariable(label='Conversation ID' description='The DirectLine conversation ID' required=true)
13+
public String conversationId;
14+
15+
@InvocableVariable(label='User Message' description='The message from the user' required=true)
16+
public String userMessage;
17+
18+
@InvocableVariable(label='User ID' description='Optional user identifier')
19+
public String userId;
20+
21+
@InvocableVariable(label='User Name' description='Optional user display name')
22+
public String userName;
23+
24+
@InvocableVariable(label='Named Credential' description='Name of the Named Credential to use (default: DirectLine)')
25+
public String namedCredential;
26+
}
27+
28+
public class Output {
29+
@InvocableVariable(label='Response Code' description='HTTP response code')
30+
public Integer responseCode;
31+
32+
@InvocableVariable(label='Error Message' description='Error message if failed')
33+
public String errorMessage;
34+
35+
@InvocableVariable(label='Watermark' description='Watermark for retrieving responses')
36+
public String watermark;
37+
}
38+
39+
@InvocableMethod(label='Post Message to Copilot Studio' description='Sends a user message to Copilot Studio via DirectLine API')
40+
public static List<Output> postActivity(List<Input> inputs) {
41+
List<Output> outputs = new List<Output>();
42+
43+
for (Input input : inputs) {
44+
Output output = new Output();
45+
46+
try {
47+
String credentialName = String.isNotBlank(input.namedCredential) ? input.namedCredential : DEFAULT_NAMED_CREDENTIAL;
48+
String endpoint = 'callout:' + credentialName + '/v3/directline/conversations/' + input.conversationId + '/activities';
49+
50+
// Build the activity payload
51+
Map<String, Object> activity = new Map<String, Object>();
52+
activity.put('type', 'message');
53+
activity.put('text', input.userMessage);
54+
55+
// Add from information
56+
Map<String, Object> fromObj = new Map<String, Object>();
57+
fromObj.put('id', String.isNotBlank(input.userId) ? input.userId : 'user');
58+
if (String.isNotBlank(input.userName)) {
59+
fromObj.put('name', input.userName);
60+
}
61+
activity.put('from', fromObj);
62+
63+
HttpRequest req = new HttpRequest();
64+
req.setEndpoint(endpoint);
65+
req.setMethod('POST');
66+
// Authorization header automatically added by Named Credential
67+
req.setHeader('Content-Type', 'application/json');
68+
req.setBody(JSON.serialize(activity));
69+
req.setTimeout(30000);
70+
71+
Http http = new Http();
72+
HttpResponse res = http.send(req);
73+
74+
output.responseCode = res.getStatusCode();
75+
76+
if (res.getStatusCode() == 200 || res.getStatusCode() == 204) {
77+
// Watermark will be retrieved in GetActivity call
78+
output.watermark = '0';
79+
} else {
80+
output.errorMessage = 'Failed to post activity: ' + res.getStatus() + ' - ' + res.getBody();
81+
}
82+
} catch (Exception e) {
83+
output.responseCode = 500;
84+
output.errorMessage = 'Exception: ' + e.getMessage();
85+
}
86+
87+
outputs.add(output);
88+
}
89+
90+
return outputs;
91+
}
92+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
3+
<apiVersion>62.0</apiVersion>
4+
<status>Active</status>
5+
</ApexClass>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
3+
<apiVersion>62.0</apiVersion>
4+
<status>Active</status>
5+
</ApexClass>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
3+
<apiVersion>62.0</apiVersion>
4+
<status>Active</status>
5+
</ApexClass>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<ExternalCredential xmlns="http://soap.sforce.com/2006/04/metadata">
3+
<authenticationProtocol>Custom</authenticationProtocol>
4+
<externalCredentialParameters>
5+
<parameterGroup>DefaultGroup</parameterGroup>
6+
<parameterName>Authorization</parameterName>
7+
<parameterType>AuthHeader</parameterType>
8+
<parameterValue>{!&apos;Bearer &apos; &amp; $Credential.Directline.Token}</parameterValue>
9+
<sequenceNumber>1</sequenceNumber>
10+
</externalCredentialParameters>
11+
<externalCredentialParameters>
12+
<parameterGroup>Directline_Principal</parameterGroup>
13+
<parameterName>Directline_Principal</parameterName>
14+
<parameterType>NamedPrincipal</parameterType>
15+
<sequenceNumber>1</sequenceNumber>
16+
</externalCredentialParameters>
17+
<label>Directline</label>
18+
</ExternalCredential>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<NamedCredential xmlns="http://soap.sforce.com/2006/04/metadata">
3+
<allowMergeFieldsInBody>true</allowMergeFieldsInBody>
4+
<allowMergeFieldsInHeader>true</allowMergeFieldsInHeader>
5+
<calloutStatus>Enabled</calloutStatus>
6+
<generateAuthorizationHeader>false</generateAuthorizationHeader>
7+
<label>Directline</label>
8+
<namedCredentialParameters>
9+
<parameterName>Url</parameterName>
10+
<parameterType>Url</parameterType>
11+
<parameterValue>https://directline.botframework.com</parameterValue>
12+
</namedCredentialParameters>
13+
<namedCredentialParameters>
14+
<externalCredential>Directline</externalCredential>
15+
<parameterName>ExternalCredential</parameterName>
16+
<parameterType>Authentication</parameterType>
17+
</namedCredentialParameters>
18+
<namedCredentialType>SecuredEndpoint</namedCredentialType>
19+
</NamedCredential>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<RemoteSiteSetting xmlns="http://soap.sforce.com/2006/04/metadata">
3+
<description>Bot Framework DirectLine API endpoint for Copilot Studio integration</description>
4+
<disableProtocolSecurity>false</disableProtocolSecurity>
5+
<isActive>true</isActive>
6+
<url>https://directline.botframework.com</url>
7+
</RemoteSiteSetting>

0 commit comments

Comments
 (0)