55using System . Text . Json . Nodes ;
66
77using AIShell . Abstraction ;
8- using Azure . Core ;
9- using Azure . Identity ;
108using Serilog ;
119
1210namespace Microsoft . Azure . Agent ;
1311
1412internal class ChatSession : IDisposable
1513{
14+ private const string ACCESS_URL = "https://copilotweb.production.portalrp.azure.com/api/access?api-version=2024-09-01" ;
1615 private const string DL_TOKEN_URL = "https://copilotweb.production.portalrp.azure.com/api/conversations/start?api-version=2024-11-15" ;
17- private const string REFRESH_TOKEN_URL = "https://directline.botframework.com/v3/directline/tokens/refresh" ;
1816 private const string CONVERSATION_URL = "https://directline.botframework.com/v3/directline/conversations" ;
1917
20- private string _token ;
18+ internal bool UserAuthorized { get ; private set ; }
19+
2120 private string _streamUrl ;
2221 private string _conversationId ;
2322 private string _conversationUrl ;
24- private DateTime _expireOn ;
23+ private UserDirectLineToken _directLineToken ;
2524 private AzureCopilotReceiver _copilotReceiver ;
2625
2726 private readonly HttpClient _httpClient ;
27+ private readonly UserAccessToken _accessToken ;
2828 private readonly Dictionary < string , object > _flights ;
2929
3030 internal ChatSession ( HttpClient httpClient )
3131 {
3232 _httpClient = httpClient ;
33+ _accessToken = new UserAccessToken ( ) ;
3334
3435 // Keys and values for flights are from the portal request.
3536 _flights = new Dictionary < string , object > ( )
@@ -63,179 +64,146 @@ internal ChatSession(HttpClient httpClient)
6364
6465 internal async Task < string > RefreshAsync ( IStatusContext context , bool force , CancellationToken cancellationToken )
6566 {
66- if ( _token is not null )
67+ if ( _directLineToken is not null )
6768 {
6869 if ( force )
6970 {
7071 // End the existing conversation.
7172 context . Status ( "Ending current chat ..." ) ;
72- EndConversation ( ) ;
73- Reset ( ) ;
73+ EndCurrentConversation ( ) ;
7474 }
7575 else
7676 {
7777 try
7878 {
79- context . Status ( "Refreshing token ..." ) ;
80- await RenewTokenAsync ( cancellationToken ) ;
79+ context . Status ( "Refreshing access token ..." ) ;
80+ await _accessToken . CreateOrRenewTokenAsync ( cancellationToken ) ;
81+
82+ context . Status ( "Refreshing DirectLine token ..." ) ;
83+ await _directLineToken . RenewTokenAsync ( _httpClient , cancellationToken ) ;
84+
85+ // Tokens successfully refreshed.
8186 return null ;
8287 }
88+ catch ( OperationCanceledException )
89+ {
90+ throw ;
91+ }
8392 catch ( Exception )
8493 {
8594 // Refreshing failed. We will create a new chat session.
8695 }
8796 }
8897 }
8998
90- _token = await GenerateTokenAsync ( context , cancellationToken ) ;
91- return await OpenConversationAsync ( context , cancellationToken ) ;
99+ return await SetupNewChat ( context , cancellationToken ) ;
92100 }
93101
94102 private void Reset ( )
95103 {
96- _token = null ;
97104 _streamUrl = null ;
98105 _conversationId = null ;
99106 _conversationUrl = null ;
100- _expireOn = DateTime . MinValue ;
107+ _directLineToken = null ;
101108
109+ _accessToken . Reset ( ) ;
102110 _copilotReceiver ? . Dispose ( ) ;
103111 _copilotReceiver = null ;
104112 }
105113
106- private async Task < string > GenerateTokenAsync ( IStatusContext context , CancellationToken cancellationToken )
114+ private async Task < string > SetupNewChat ( IStatusContext context , CancellationToken cancellationToken )
107115 {
108116 try
109117 {
110118 context . Status ( "Get Azure CLI login token ..." ) ;
111119 // Get an access token from the AzCLI login, using the specific audience guid.
112- AccessToken accessToken = await new AzureCliCredential ( )
113- . GetTokenAsync (
114- new TokenRequestContext ( [ "7000789f-b583-4714-ab18-aef39213018a/.default" ] ) ,
115- cancellationToken ) ;
120+ await _accessToken . CreateOrRenewTokenAsync ( cancellationToken ) ;
116121
117- context . Status ( "Request for DirectLine token ..." ) ;
118- StringContent content = new ( "{\" conversationType\" : \" Chat\" }" , Encoding . UTF8 , Utils . JsonContentType ) ;
119- HttpRequestMessage request = new ( HttpMethod . Post , DL_TOKEN_URL ) { Content = content } ;
120- request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , accessToken . Token ) ;
122+ context . Status ( "Check Copilot authorization ..." ) ;
123+ await CheckAuthorizationAsync ( cancellationToken ) ;
121124
122- HttpResponseMessage response = await _httpClient . SendAsync ( request , cancellationToken ) ;
123- response . EnsureSuccessStatusCode ( ) ;
125+ context . Status ( "Request for DirectLine token ..." ) ;
126+ await GetInitialDLTokenAsync ( cancellationToken ) ;
124127
125- using Stream stream = await response . Content . ReadAsStreamAsync ( cancellationToken ) ;
126- var dlToken = JsonSerializer . Deserialize < DirectLineToken > ( stream , Utils . JsonOptions ) ;
127- return dlToken . DirectLine . Token ;
128+ context . Status ( "Start a new chat session ..." ) ;
129+ return await OpenConversationAsync ( cancellationToken ) ;
128130 }
129131 catch ( Exception e )
130132 {
131- if ( e is not OperationCanceledException )
133+ if ( e is not OperationCanceledException and TokenRequestException )
132134 {
133- Telemetry . Trace ( AzTrace . Exception ( "Failed to generate the initial DL token ." ) , e ) ;
135+ Telemetry . Trace ( AzTrace . Exception ( "Failed to setup a new chat session ." ) , e ) ;
134136 }
135137
136138 Reset ( ) ;
137139 throw ;
138140 }
139141 }
140142
141- private async Task < string > OpenConversationAsync ( IStatusContext context , CancellationToken cancellationToken )
143+ private async Task CheckAuthorizationAsync ( CancellationToken cancellationToken )
142144 {
143- try
144- {
145- context . Status ( "Start a new chat session ..." ) ;
146- HttpRequestMessage request = new ( HttpMethod . Post , CONVERSATION_URL ) ;
147- request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , _token ) ;
145+ HttpRequestMessage request = new ( HttpMethod . Get , ACCESS_URL ) ;
146+ request . Headers . Accept . Add ( new MediaTypeWithQualityHeaderValue ( "application/json" ) ) ;
147+ request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , _accessToken . Token ) ;
148148
149- HttpResponseMessage response = await _httpClient . SendAsync ( request , cancellationToken ) ;
150- response . EnsureSuccessStatusCode ( ) ;
151-
152- using Stream content = await response . Content . ReadAsStreamAsync ( cancellationToken ) ;
153- SessionPayload spl = JsonSerializer . Deserialize < SessionPayload > ( content , Utils . JsonOptions ) ;
154-
155- _token = spl . Token ;
156- _conversationId = spl . ConversationId ;
157- _conversationUrl = $ "{ CONVERSATION_URL } /{ _conversationId } /activities";
158- _streamUrl = spl . StreamUrl ;
159- _expireOn = DateTime . UtcNow . AddSeconds ( spl . ExpiresIn ) ;
160- _copilotReceiver = await AzureCopilotReceiver . CreateAsync ( _streamUrl ) ;
149+ HttpResponseMessage response = await _httpClient . SendAsync ( request , cancellationToken ) ;
150+ await response . EnsureSuccessStatusCodeForTokenRequest ( "Failed to check Copilot authorization." ) ;
161151
162- Log . Debug ( "[ChatSession] Conversation started. Id: {0}" , _conversationId ) ;
152+ using Stream stream = await response . Content . ReadAsStreamAsync ( cancellationToken ) ;
153+ var permission = JsonSerializer . Deserialize < CopilotPermission > ( stream , Utils . JsonOptions ) ;
154+ UserAuthorized = permission . Authorized ;
163155
164- while ( true )
165- {
166- CopilotActivity activity = _copilotReceiver . Take ( cancellationToken ) ;
167- if ( activity . IsMessage && activity . IsFromCopilot && _copilotReceiver . Watermark is 0 )
168- {
169- activity . ExtractMetadata ( out _ , out ConversationState conversationState ) ;
170- int chatNumber = conversationState . DailyConversationNumber ;
171- int requestNumber = conversationState . TurnNumber ;
172- return $ "{ activity . Text } \n This is chat #{ chatNumber } , request #{ requestNumber } .\n ";
173- }
174- }
175- }
176- catch ( Exception e )
156+ if ( ! UserAuthorized )
177157 {
178- if ( e is not OperationCanceledException )
179- {
180- Telemetry . Trace ( AzTrace . Exception ( "Failed to open conversation with the initial DL token." ) , e ) ;
181- }
182-
183- Reset ( ) ;
184- throw ;
158+ string message = $ "Access token not authorized to access Azure Copilot. { permission . Message } ";
159+ Telemetry . Trace ( AzTrace . Exception ( message ) ) ;
160+ throw new TokenRequestException ( message ) { UserUnauthorized = true } ;
185161 }
186162 }
187163
188- private TokenHealth CheckDLTokenHealth ( )
164+ private async Task GetInitialDLTokenAsync ( CancellationToken cancellationToken )
189165 {
190- ArgumentNullException . ThrowIfNull ( _token , nameof ( _token ) ) ;
166+ StringContent content = new ( "{\" conversationType\" : \" Chat\" }" , Encoding . UTF8 , Utils . JsonContentType ) ;
167+ HttpRequestMessage request = new ( HttpMethod . Post , DL_TOKEN_URL ) { Content = content } ;
168+ request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , _accessToken . Token ) ;
191169
192- var now = DateTime . UtcNow ;
193- if ( now > _expireOn || now . AddMinutes ( 2 ) >= _expireOn )
194- {
195- return TokenHealth . Expired ;
196- }
197-
198- if ( now . AddMinutes ( 10 ) < _expireOn )
199- {
200- return TokenHealth . Good ;
201- }
170+ HttpResponseMessage response = await _httpClient . SendAsync ( request , cancellationToken ) ;
171+ await response . EnsureSuccessStatusCodeForTokenRequest ( "Failed to generate the initial DL token." ) ;
202172
203- return TokenHealth . TimeToRefresh ;
173+ using Stream stream = await response . Content . ReadAsStreamAsync ( cancellationToken ) ;
174+ var dlToken = JsonSerializer . Deserialize < DirectLineToken > ( stream , Utils . JsonOptions ) ;
175+ _directLineToken = new UserDirectLineToken ( dlToken . DirectLine . Token , dlToken . DirectLine . TokenExpiryTimeInSeconds ) ;
204176 }
205177
206- private async Task RenewTokenAsync ( CancellationToken cancellationToken )
178+ private async Task < string > OpenConversationAsync ( CancellationToken cancellationToken )
207179 {
208- TokenHealth health = CheckDLTokenHealth ( ) ;
209- if ( health is TokenHealth . Expired )
210- {
211- Reset ( ) ;
212- throw new TokenRequestException ( "The chat session has expired. Please start a new chat session." ) ;
213- }
180+ HttpRequestMessage request = new ( HttpMethod . Post , CONVERSATION_URL ) ;
181+ request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , _directLineToken . Token ) ;
214182
215- if ( health is TokenHealth . Good )
216- {
217- return ;
218- }
183+ HttpResponseMessage response = await _httpClient . SendAsync ( request , cancellationToken ) ;
184+ await response . EnsureSuccessStatusCodeForTokenRequest ( "Failed to open an conversation." ) ;
219185
220- try
221- {
222- HttpRequestMessage request = new ( HttpMethod . Post , REFRESH_TOKEN_URL ) ;
223- request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , _token ) ;
186+ using Stream content = await response . Content . ReadAsStreamAsync ( cancellationToken ) ;
187+ SessionPayload spl = JsonSerializer . Deserialize < SessionPayload > ( content , Utils . JsonOptions ) ;
224188
225- var response = await _httpClient . SendAsync ( request , cancellationToken ) ;
226- response . EnsureSuccessStatusCode ( ) ;
189+ _conversationId = spl . ConversationId ;
190+ _conversationUrl = $ "{ CONVERSATION_URL } /{ _conversationId } /activities";
191+ _directLineToken = new UserDirectLineToken ( spl . Token , spl . ExpiresIn ) ;
192+ _streamUrl = spl . StreamUrl ;
193+ _copilotReceiver = await AzureCopilotReceiver . CreateAsync ( _streamUrl ) ;
227194
228- using Stream content = await response . Content . ReadAsStreamAsync ( cancellationToken ) ;
229- RefreshDLToken dlToken = JsonSerializer . Deserialize < RefreshDLToken > ( content , Utils . JsonOptions ) ;
195+ Log . Debug ( "[ChatSession] Conversation started. Id: {0}" , _conversationId ) ;
230196
231- _token = dlToken . Token ;
232- _expireOn = DateTime . UtcNow . AddSeconds ( dlToken . ExpiresIn ) ;
233- }
234- catch ( Exception e ) when ( e is not OperationCanceledException )
197+ while ( true )
235198 {
236- Reset ( ) ;
237- Telemetry . Trace ( AzTrace . Exception ( "Failed to refresh the DL token." ) , e ) ;
238- throw new TokenRequestException ( $ "Failed to refresh the 'DirectLine' token: { e . Message } .", e ) ;
199+ CopilotActivity activity = _copilotReceiver . Take ( cancellationToken ) ;
200+ if ( activity . IsMessage && activity . IsFromCopilot && _copilotReceiver . Watermark is 0 )
201+ {
202+ activity . ExtractMetadata ( out _ , out ConversationState conversationState ) ;
203+ int chatNumber = conversationState . DailyConversationNumber ;
204+ int requestNumber = conversationState . TurnNumber ;
205+ return $ "{ activity . Text } \n This is chat #{ chatNumber } , request #{ requestNumber } .\n ";
206+ }
239207 }
240208 }
241209
@@ -280,7 +248,7 @@ private HttpRequestMessage PrepareForChat(string input)
280248 var content = new StringContent ( json , Encoding . UTF8 , Utils . JsonContentType ) ;
281249 var request = new HttpRequestMessage ( HttpMethod . Post , _conversationUrl ) { Content = content } ;
282250
283- request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , _token ) ;
251+ request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , _directLineToken . Token ) ;
284252 // This header is for server side telemetry to identify where the request comes from.
285253 request . Headers . Add ( "ClientType" , "AIShell" ) ;
286254 return request ;
@@ -299,9 +267,9 @@ private async Task<string> SendQueryToCopilot(string input, CancellationToken ca
299267 return contentObj [ "id" ] . ToString ( ) ;
300268 }
301269
302- private void EndConversation ( )
270+ private void EndCurrentConversation ( )
303271 {
304- if ( _token is null || CheckDLTokenHealth ( ) is TokenHealth . Expired )
272+ if ( _directLineToken is null || _directLineToken . CheckTokenHealth ( ) is TokenHealth . Expired )
305273 {
306274 // Chat session already expired, no need to send request to end the conversation.
307275 return ;
@@ -310,16 +278,24 @@ private void EndConversation()
310278 var content = new StringContent ( "{\" type\" :\" endOfConversation\" ,\" from\" :{\" id\" :\" user\" }}" , Encoding . UTF8 , Utils . JsonContentType ) ;
311279 var request = new HttpRequestMessage ( HttpMethod . Post , _conversationUrl ) { Content = content } ;
312280
313- request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , _token ) ;
281+ request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , _directLineToken . Token ) ;
314282 _httpClient . Send ( request , HttpCompletionOption . ResponseHeadersRead , CancellationToken . None ) ;
315283 }
316284
317285 internal async Task < CopilotResponse > GetChatResponseAsync ( string input , IStatusContext context , CancellationToken cancellationToken )
318286 {
287+ if ( _directLineToken is null )
288+ {
289+ throw new TokenRequestException ( "A chat session hasn't been setup yet." ) ;
290+ }
291+
319292 try
320293 {
321- context ? . Status ( "Refreshing Token ..." ) ;
322- await RenewTokenAsync ( cancellationToken ) ;
294+ context . Status ( "Refreshing access token ..." ) ;
295+ await _accessToken . CreateOrRenewTokenAsync ( cancellationToken ) ;
296+
297+ context . Status ( "Refreshing DirectLine token ..." ) ;
298+ await _directLineToken . RenewTokenAsync ( _httpClient , cancellationToken ) ;
323299
324300 context ? . Status ( "Sending query ..." ) ;
325301 string activityId = await SendQueryToCopilot ( input , cancellationToken ) ;
@@ -370,11 +346,16 @@ internal async Task<CopilotResponse> GetChatResponseAsync(string input, IStatusC
370346 // TODO: we may need to notify azure copilot somehow about the cancellation.
371347 return null ;
372348 }
349+ catch ( TokenRequestException )
350+ {
351+ Reset ( ) ;
352+ throw ;
353+ }
373354 }
374355
375356 public void Dispose ( )
376357 {
377- EndConversation ( ) ;
358+ EndCurrentConversation ( ) ;
378359 _copilotReceiver ? . Dispose ( ) ;
379360 }
380361}
0 commit comments