@microsoft/agents-hosting 1.1.0-alpha.9.g154c2c8a32 → 1.1.4-g8d884129e7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package.json +10 -6
- package/dist/src/activityWireCompat.d.ts +1 -1
- package/dist/src/activityWireCompat.js +11 -3
- package/dist/src/activityWireCompat.js.map +1 -1
- package/dist/src/agent-client/agentClient.js +7 -3
- package/dist/src/agent-client/agentClient.js.map +1 -1
- package/dist/src/agent-client/agentResponseHandler.js +6 -2
- package/dist/src/agent-client/agentResponseHandler.js.map +1 -1
- package/dist/src/app/agentApplication.d.ts +26 -11
- package/dist/src/app/agentApplication.js +90 -79
- package/dist/src/app/agentApplication.js.map +1 -1
- package/dist/src/app/agentApplicationBuilder.d.ts +2 -2
- package/dist/src/app/agentApplicationBuilder.js.map +1 -1
- package/dist/src/app/agentApplicationOptions.d.ts +9 -2
- package/dist/src/app/appRoute.d.ts +7 -0
- package/dist/src/app/{authorization.d.ts → auth/authorization.d.ts} +41 -139
- package/dist/src/app/auth/authorization.js +188 -0
- package/dist/src/app/auth/authorization.js.map +1 -0
- package/dist/src/app/auth/authorizationManager.d.ts +71 -0
- package/dist/src/app/auth/authorizationManager.js +170 -0
- package/dist/src/app/auth/authorizationManager.js.map +1 -0
- package/dist/src/app/auth/handlerStorage.d.ts +36 -0
- package/dist/src/app/auth/handlerStorage.js +62 -0
- package/dist/src/app/auth/handlerStorage.js.map +1 -0
- package/dist/src/app/auth/handlers/agenticAuthorization.d.ts +93 -0
- package/dist/src/app/auth/handlers/agenticAuthorization.js +134 -0
- package/dist/src/app/auth/handlers/agenticAuthorization.js.map +1 -0
- package/dist/src/app/auth/handlers/azureBotAuthorization.d.ts +226 -0
- package/dist/src/app/auth/handlers/azureBotAuthorization.js +429 -0
- package/dist/src/app/auth/handlers/azureBotAuthorization.js.map +1 -0
- package/dist/src/app/auth/handlers/index.d.ts +2 -0
- package/dist/src/app/auth/handlers/index.js +19 -0
- package/dist/src/app/auth/handlers/index.js.map +1 -0
- package/dist/src/app/auth/index.d.ts +2 -0
- package/dist/src/app/auth/index.js +19 -0
- package/dist/src/app/auth/index.js.map +1 -0
- package/dist/src/app/auth/types.d.ts +104 -0
- package/dist/src/app/auth/types.js +24 -0
- package/dist/src/app/auth/types.js.map +1 -0
- package/dist/src/app/index.d.ts +3 -3
- package/dist/src/app/index.js +2 -3
- package/dist/src/app/index.js.map +1 -1
- package/dist/src/app/routeList.d.ts +1 -1
- package/dist/src/app/routeList.js +22 -5
- package/dist/src/app/routeList.js.map +1 -1
- package/dist/src/app/streaming/streamingResponse.js +2 -1
- package/dist/src/app/streaming/streamingResponse.js.map +1 -1
- package/dist/src/auth/MemoryCache.d.ts +16 -0
- package/dist/src/auth/MemoryCache.js +58 -0
- package/dist/src/auth/MemoryCache.js.map +1 -0
- package/dist/src/auth/authConfiguration.d.ts +44 -2
- package/dist/src/auth/authConfiguration.js +209 -53
- package/dist/src/auth/authConfiguration.js.map +1 -1
- package/dist/src/auth/authConstants.d.ts +11 -0
- package/dist/src/auth/authConstants.js +15 -0
- package/dist/src/auth/authConstants.js.map +1 -0
- package/dist/src/auth/authProvider.d.ts +26 -0
- package/dist/src/auth/connections.d.ts +41 -0
- package/dist/src/auth/connections.js +7 -0
- package/dist/src/auth/connections.js.map +1 -0
- package/dist/src/auth/index.d.ts +2 -0
- package/dist/src/auth/index.js +2 -0
- package/dist/src/auth/index.js.map +1 -1
- package/dist/src/auth/jwt-middleware.js +31 -18
- package/dist/src/auth/jwt-middleware.js.map +1 -1
- package/dist/src/auth/msalConnectionManager.d.ts +64 -0
- package/dist/src/auth/msalConnectionManager.js +148 -0
- package/dist/src/auth/msalConnectionManager.js.map +1 -0
- package/dist/src/auth/msalTokenProvider.d.ts +38 -0
- package/dist/src/auth/msalTokenProvider.js +189 -16
- package/dist/src/auth/msalTokenProvider.js.map +1 -1
- package/dist/src/baseAdapter.d.ts +10 -25
- package/dist/src/baseAdapter.js +2 -15
- package/dist/src/baseAdapter.js.map +1 -1
- package/dist/src/cards/cardFactory.d.ts +2 -1
- package/dist/src/cards/cardFactory.js +3 -2
- package/dist/src/cards/cardFactory.js.map +1 -1
- package/dist/src/cloudAdapter.d.ts +40 -23
- package/dist/src/cloudAdapter.js +143 -63
- package/dist/src/cloudAdapter.js.map +1 -1
- package/dist/src/connector-client/connectorClient.d.ts +15 -0
- package/dist/src/connector-client/connectorClient.js +49 -15
- package/dist/src/connector-client/connectorClient.js.map +1 -1
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.js +0 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/oauth/customUserTokenAPI.d.ts +1 -0
- package/dist/src/oauth/customUserTokenAPI.js +11 -0
- package/dist/src/oauth/customUserTokenAPI.js.map +1 -0
- package/dist/src/oauth/index.d.ts +0 -1
- package/dist/src/oauth/index.js +0 -1
- package/dist/src/oauth/index.js.map +1 -1
- package/dist/src/oauth/userTokenClient.d.ts +30 -13
- package/dist/src/oauth/userTokenClient.js +62 -26
- package/dist/src/oauth/userTokenClient.js.map +1 -1
- package/dist/src/oauth/userTokenClient.types.d.ts +19 -6
- package/dist/src/transcript/fileTranscriptLogger.d.ts +109 -0
- package/dist/src/transcript/fileTranscriptLogger.js +398 -0
- package/dist/src/transcript/fileTranscriptLogger.js.map +1 -0
- package/dist/src/turnContext.d.ts +7 -1
- package/dist/src/turnContext.js +11 -4
- package/dist/src/turnContext.js.map +1 -1
- package/package.json +10 -6
- package/src/activityWireCompat.ts +12 -4
- package/src/agent-client/agentClient.ts +9 -3
- package/src/agent-client/agentResponseHandler.ts +5 -2
- package/src/app/agentApplication.ts +95 -74
- package/src/app/agentApplicationBuilder.ts +2 -2
- package/src/app/agentApplicationOptions.ts +10 -2
- package/src/app/appRoute.ts +8 -0
- package/src/app/auth/authorization.ts +261 -0
- package/src/app/auth/authorizationManager.ts +213 -0
- package/src/app/auth/handlerStorage.ts +61 -0
- package/src/app/auth/handlers/agenticAuthorization.ts +183 -0
- package/src/app/auth/handlers/azureBotAuthorization.ts +606 -0
- package/src/app/auth/handlers/index.ts +2 -0
- package/src/app/auth/index.ts +2 -0
- package/src/app/auth/types.ts +111 -0
- package/src/app/index.ts +3 -3
- package/src/app/routeList.ts +24 -5
- package/src/app/streaming/streamingResponse.ts +2 -1
- package/src/auth/MemoryCache.ts +59 -0
- package/src/auth/authConfiguration.ts +245 -52
- package/src/auth/authConstants.ts +11 -0
- package/src/auth/authProvider.ts +34 -0
- package/src/auth/connections.ts +47 -0
- package/src/auth/index.ts +2 -0
- package/src/auth/jwt-middleware.ts +38 -21
- package/src/auth/msalConnectionManager.ts +175 -0
- package/src/auth/msalTokenProvider.ts +231 -9
- package/src/baseAdapter.ts +10 -29
- package/src/cards/cardFactory.ts +3 -2
- package/src/cloudAdapter.ts +207 -72
- package/src/connector-client/connectorClient.ts +59 -15
- package/src/index.ts +0 -1
- package/src/oauth/customUserTokenAPI.ts +5 -0
- package/src/oauth/index.ts +0 -1
- package/src/oauth/userTokenClient.ts +76 -22
- package/src/oauth/userTokenClient.types.ts +20 -8
- package/src/transcript/fileTranscriptLogger.ts +409 -0
- package/src/turnContext.ts +16 -5
- package/dist/src/app/authorization.js +0 -387
- package/dist/src/app/authorization.js.map +0 -1
- package/dist/src/claimsIdentity.d.ts +0 -35
- package/dist/src/claimsIdentity.js +0 -43
- package/dist/src/claimsIdentity.js.map +0 -1
- package/dist/src/oauth/oAuthFlow.d.ts +0 -119
- package/dist/src/oauth/oAuthFlow.js +0 -316
- package/dist/src/oauth/oAuthFlow.js.map +0 -1
- package/src/app/authorization.ts +0 -432
- package/src/claimsIdentity.ts +0 -47
- package/src/oauth/oAuthFlow.ts +0 -378
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
// Licensed under the MIT License.
|
|
3
3
|
|
|
4
4
|
import axios, { AxiosInstance } from 'axios'
|
|
5
|
-
import { ConversationReference } from '@microsoft/agents-activity'
|
|
5
|
+
import { Activity, ConversationReference } from '@microsoft/agents-activity'
|
|
6
6
|
import { debug } from '@microsoft/agents-activity/logger'
|
|
7
7
|
import { normalizeTokenExchangeState } from '../activityWireCompat'
|
|
8
8
|
import { AadResourceUrls, SignInResource, TokenExchangeRequest, TokenOrSinginResourceResponse, TokenResponse, TokenStatus } from './userTokenClient.types'
|
|
9
9
|
import { getProductInfo } from '../getProductInfo'
|
|
10
|
+
import { AuthProvider, MsalTokenProvider } from '../auth'
|
|
11
|
+
import { HeaderPropagationCollection } from '../headerPropagation'
|
|
12
|
+
import { getTokenServiceEndpoint } from './customUserTokenAPI'
|
|
10
13
|
|
|
11
14
|
const logger = debug('agents:user-token-client')
|
|
12
15
|
|
|
@@ -15,19 +18,31 @@ const logger = debug('agents:user-token-client')
|
|
|
15
18
|
*/
|
|
16
19
|
export class UserTokenClient {
|
|
17
20
|
client: AxiosInstance
|
|
21
|
+
private msAppId: string = ''
|
|
18
22
|
/**
|
|
19
23
|
* Creates a new instance of UserTokenClient.
|
|
20
24
|
* @param msAppId The Microsoft application ID.
|
|
21
25
|
*/
|
|
22
|
-
constructor (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
constructor (msAppId: string)
|
|
27
|
+
/**
|
|
28
|
+
* Creates a new instance of UserTokenClient.
|
|
29
|
+
* @param axiosInstance The axios instance.
|
|
30
|
+
*/
|
|
31
|
+
constructor (axiosInstance: AxiosInstance)
|
|
32
|
+
|
|
33
|
+
constructor (param: string | AxiosInstance) {
|
|
34
|
+
if (typeof param === 'string') {
|
|
35
|
+
const baseURL = getTokenServiceEndpoint()
|
|
36
|
+
this.client = axios.create({
|
|
37
|
+
baseURL,
|
|
38
|
+
headers: {
|
|
39
|
+
Accept: 'application/json',
|
|
40
|
+
'User-Agent': getProductInfo(),
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
} else {
|
|
44
|
+
this.client = param
|
|
45
|
+
}
|
|
31
46
|
|
|
32
47
|
this.client.interceptors.request.use((config) => {
|
|
33
48
|
const { method, url, data, headers, params } = config
|
|
@@ -79,15 +94,49 @@ export class UserTokenClient {
|
|
|
79
94
|
})
|
|
80
95
|
}
|
|
81
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Creates a new instance of UserTokenClient with authentication.
|
|
99
|
+
* @param baseURL - The base URL for the API.
|
|
100
|
+
* @param authConfig - The authentication configuration.
|
|
101
|
+
* @param authProvider - The authentication provider.
|
|
102
|
+
* @param scope - The scope for the authentication token.
|
|
103
|
+
* @param headers - Optional headers to propagate in the request.
|
|
104
|
+
* @returns A new instance of ConnectorClient.
|
|
105
|
+
*/
|
|
106
|
+
static async createClientWithScope (
|
|
107
|
+
baseURL: string,
|
|
108
|
+
authProvider: AuthProvider,
|
|
109
|
+
scope: string,
|
|
110
|
+
headers?: HeaderPropagationCollection
|
|
111
|
+
): Promise<UserTokenClient> {
|
|
112
|
+
// TODO: add header propagation logic
|
|
113
|
+
const axiosInstance = axios.create({
|
|
114
|
+
baseURL,
|
|
115
|
+
headers: {
|
|
116
|
+
Accept: 'application/json',
|
|
117
|
+
'Content-Type': 'application/json', // Required by transformRequest
|
|
118
|
+
'User-Agent': getProductInfo(),
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
if (authProvider) {
|
|
122
|
+
const token = await (authProvider as MsalTokenProvider).getAccessToken(scope)
|
|
123
|
+
if (token.length > 1) {
|
|
124
|
+
axiosInstance.defaults.headers.common.Authorization = `Bearer ${token}`
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return new UserTokenClient(axiosInstance)
|
|
128
|
+
}
|
|
129
|
+
|
|
82
130
|
/**
|
|
83
131
|
* Gets the user token.
|
|
84
132
|
* @param connectionName The connection name.
|
|
85
|
-
* @param
|
|
133
|
+
* @param channelIdComposite The channel ID.
|
|
86
134
|
* @param userId The user ID.
|
|
87
135
|
* @param code The optional code.
|
|
88
136
|
* @returns A promise that resolves to the user token.
|
|
89
137
|
*/
|
|
90
|
-
async getUserToken (connectionName: string,
|
|
138
|
+
async getUserToken (connectionName: string, channelIdComposite: string, userId: string, code?: string) : Promise<TokenResponse> {
|
|
139
|
+
const [channelId] = Activity.parseChannelId(channelIdComposite)
|
|
91
140
|
const params = { connectionName, channelId, userId, code }
|
|
92
141
|
const response = await this.client.get('/api/usertoken/GetToken', { params })
|
|
93
142
|
if (response?.data) {
|
|
@@ -100,10 +149,11 @@ export class UserTokenClient {
|
|
|
100
149
|
* Signs the user out.
|
|
101
150
|
* @param userId The user ID.
|
|
102
151
|
* @param connectionName The connection name.
|
|
103
|
-
* @param
|
|
152
|
+
* @param channelIdComposite The channel ID.
|
|
104
153
|
* @returns A promise that resolves when the sign-out operation is complete.
|
|
105
154
|
*/
|
|
106
|
-
async signOut (userId: string, connectionName: string,
|
|
155
|
+
async signOut (userId: string, connectionName: string, channelIdComposite: string) : Promise<void> {
|
|
156
|
+
const [channelId] = Activity.parseChannelId(channelIdComposite)
|
|
107
157
|
const params = { userId, connectionName, channelId }
|
|
108
158
|
const response = await this.client.delete('/api/usertoken/SignOut', { params })
|
|
109
159
|
if (response.status !== 200) {
|
|
@@ -137,11 +187,12 @@ export class UserTokenClient {
|
|
|
137
187
|
* Exchanges the token.
|
|
138
188
|
* @param userId The user ID.
|
|
139
189
|
* @param connectionName The connection name.
|
|
140
|
-
* @param
|
|
190
|
+
* @param channelIdComposite The channel ID.
|
|
141
191
|
* @param tokenExchangeRequest The token exchange request.
|
|
142
192
|
* @returns A promise that resolves to the exchanged token.
|
|
143
193
|
*/
|
|
144
|
-
async exchangeTokenAsync (userId: string, connectionName: string,
|
|
194
|
+
async exchangeTokenAsync (userId: string, connectionName: string, channelIdComposite: string, tokenExchangeRequest: TokenExchangeRequest) : Promise<TokenResponse> {
|
|
195
|
+
const [channelId] = Activity.parseChannelId(channelIdComposite)
|
|
145
196
|
const params = { userId, connectionName, channelId }
|
|
146
197
|
const response = await this.client.post('/api/usertoken/exchange', tokenExchangeRequest, { params })
|
|
147
198
|
if (response?.data) {
|
|
@@ -155,7 +206,7 @@ export class UserTokenClient {
|
|
|
155
206
|
* Gets the token or sign-in resource.
|
|
156
207
|
* @param userId The user ID.
|
|
157
208
|
* @param connectionName The connection name.
|
|
158
|
-
* @param
|
|
209
|
+
* @param channelIdComposite The channel ID.
|
|
159
210
|
* @param conversation The conversation reference.
|
|
160
211
|
* @param relatesTo The related conversation reference.
|
|
161
212
|
* @param code The code.
|
|
@@ -163,7 +214,8 @@ export class UserTokenClient {
|
|
|
163
214
|
* @param fwdUrl The forward URL.
|
|
164
215
|
* @returns A promise that resolves to the token or sign-in resource response.
|
|
165
216
|
*/
|
|
166
|
-
async getTokenOrSignInResource (userId: string, connectionName: string,
|
|
217
|
+
async getTokenOrSignInResource (userId: string, connectionName: string, channelIdComposite: string, conversation: ConversationReference, relatesTo: ConversationReference, code: string, finalRedirect: string = '', fwdUrl: string = '') : Promise<TokenOrSinginResourceResponse> {
|
|
218
|
+
const [channelId] = Activity.parseChannelId(channelIdComposite)
|
|
167
219
|
const state = Buffer.from(JSON.stringify({ conversation, relatesTo, connectionName, msAppId: this.msAppId })).toString('base64')
|
|
168
220
|
const params = { userId, connectionName, channelId, state, code, finalRedirect, fwdUrl }
|
|
169
221
|
const response = await this.client.get('/api/usertoken/GetTokenOrSignInResource', { params })
|
|
@@ -173,11 +225,12 @@ export class UserTokenClient {
|
|
|
173
225
|
/**
|
|
174
226
|
* Gets the token status.
|
|
175
227
|
* @param userId The user ID.
|
|
176
|
-
* @param
|
|
228
|
+
* @param channelIdComposite The channel ID.
|
|
177
229
|
* @param include The optional include parameter.
|
|
178
230
|
* @returns A promise that resolves to the token status.
|
|
179
231
|
*/
|
|
180
|
-
async getTokenStatus (userId: string,
|
|
232
|
+
async getTokenStatus (userId: string, channelIdComposite: string, include: string = null!): Promise<TokenStatus[]> {
|
|
233
|
+
const [channelId] = Activity.parseChannelId(channelIdComposite)
|
|
181
234
|
const params = { userId, channelId, include }
|
|
182
235
|
const response = await this.client.get('/api/usertoken/GetTokenStatus', { params })
|
|
183
236
|
return response.data as TokenStatus[]
|
|
@@ -187,11 +240,12 @@ export class UserTokenClient {
|
|
|
187
240
|
* Gets the AAD tokens.
|
|
188
241
|
* @param userId The user ID.
|
|
189
242
|
* @param connectionName The connection name.
|
|
190
|
-
* @param
|
|
243
|
+
* @param channelIdComposite The channel ID.
|
|
191
244
|
* @param resourceUrls The resource URLs.
|
|
192
245
|
* @returns A promise that resolves to the AAD tokens.
|
|
193
246
|
*/
|
|
194
|
-
async getAadTokens (userId: string, connectionName: string,
|
|
247
|
+
async getAadTokens (userId: string, connectionName: string, channelIdComposite: string, resourceUrls: AadResourceUrls) : Promise<Record<string, TokenResponse>> {
|
|
248
|
+
const [channelId] = Activity.parseChannelId(channelIdComposite)
|
|
195
249
|
const params = { userId, connectionName, channelId }
|
|
196
250
|
const response = await this.client.post('/api/usertoken/GetAadTokens', resourceUrls, { params })
|
|
197
251
|
return response.data as Record<string, TokenResponse>
|
|
@@ -20,19 +20,31 @@ export interface TokenResponse {
|
|
|
20
20
|
*/
|
|
21
21
|
export interface TokenExchangeRequest {
|
|
22
22
|
/**
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
* The URI for the token exchange request.
|
|
24
|
+
*/
|
|
25
25
|
uri?: string;
|
|
26
|
-
|
|
27
26
|
/**
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
* The token to be exchanged.
|
|
28
|
+
*/
|
|
30
29
|
token?: string;
|
|
30
|
+
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Represents the response for a token exchange invoke operation.
|
|
34
|
+
*/
|
|
35
|
+
export interface TokenExchangeInvokeResponse {
|
|
32
36
|
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
* The connection name associated with the token exchange response.
|
|
38
|
+
*/
|
|
39
|
+
connectionName: string
|
|
40
|
+
/**
|
|
41
|
+
* The ID associated with the token exchange response.
|
|
42
|
+
*/
|
|
43
|
+
id?: string
|
|
44
|
+
/**
|
|
45
|
+
* (Optional) Details about any failure that occurred during the token exchange.
|
|
46
|
+
*/
|
|
47
|
+
failureDetail?: string
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
/**
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
* Licensed under the MIT License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { debug } from '@microsoft/agents-activity/logger'
|
|
7
|
+
import { PagedResult, TranscriptInfo } from './transcriptLogger'
|
|
8
|
+
import { TranscriptStore } from './transcriptStore'
|
|
9
|
+
import * as fs from 'fs/promises'
|
|
10
|
+
import * as path from 'path'
|
|
11
|
+
import { EOL } from 'os'
|
|
12
|
+
import { Activity, ActivityTypes } from '@microsoft/agents-activity'
|
|
13
|
+
|
|
14
|
+
const logger = debug('agents:file-transcript-logger')
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* FileTranscriptLogger which creates a .transcript file for each conversationId.
|
|
18
|
+
* @remarks
|
|
19
|
+
* This is a useful class for unit tests.
|
|
20
|
+
*
|
|
21
|
+
* Concurrency Safety:
|
|
22
|
+
* - Uses an in-memory promise chain to serialize writes within the same Node.js process
|
|
23
|
+
* - Prevents race conditions and file corruption when multiple concurrent writes occur
|
|
24
|
+
* - Optimized for performance with minimal overhead (no file-based locking)
|
|
25
|
+
*
|
|
26
|
+
* Note: This implementation is designed for single-process scenarios. For multi-server
|
|
27
|
+
* deployments, consider using a database-backed transcript store.
|
|
28
|
+
*/
|
|
29
|
+
export class FileTranscriptLogger implements TranscriptStore {
|
|
30
|
+
private static readonly TRANSCRIPT_FILE_EXTENSION = '.transcript'
|
|
31
|
+
private static readonly MAX_FILE_NAME_SIZE = 100
|
|
32
|
+
private readonly _folder: string
|
|
33
|
+
private readonly _fileLocks: Map<string, Promise<any>> = new Map()
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Initializes a new instance of the FileTranscriptLogger class.
|
|
37
|
+
* @param folder - Folder to place the transcript files (Default current directory).
|
|
38
|
+
*/
|
|
39
|
+
constructor (folder?: string) {
|
|
40
|
+
this._folder = path.normalize(folder ?? process.cwd())
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Log an activity to the transcript.
|
|
45
|
+
* @param activity - The activity to transcribe.
|
|
46
|
+
* @returns A promise that represents the work queued to execute.
|
|
47
|
+
*/
|
|
48
|
+
async logActivity (activity: Activity): Promise<void> {
|
|
49
|
+
if (!activity) {
|
|
50
|
+
throw new Error('activity is required.')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const transcriptFile = this.getTranscriptFile(activity.channelId!, activity.conversation?.id!)
|
|
54
|
+
|
|
55
|
+
if (activity.type === ActivityTypes.Message) {
|
|
56
|
+
const sender = activity.from?.name ?? activity.from?.id ?? activity.from?.role
|
|
57
|
+
logger.debug(`${sender} [${activity.type}] ${activity.text}`)
|
|
58
|
+
} else {
|
|
59
|
+
const sender = activity.from?.name ?? activity.from?.id ?? activity.from?.role
|
|
60
|
+
logger.debug(`${sender} [${activity.type}]`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await this.withFileLock(transcriptFile, async () => {
|
|
64
|
+
const maxRetries = 3
|
|
65
|
+
for (let i = 1; i <= maxRetries; i++) {
|
|
66
|
+
try {
|
|
67
|
+
switch (activity.type) {
|
|
68
|
+
case ActivityTypes.MessageDelete:
|
|
69
|
+
return await this.messageDeleteAsync(activity, transcriptFile)
|
|
70
|
+
|
|
71
|
+
case ActivityTypes.MessageUpdate:
|
|
72
|
+
return await this.messageUpdateAsync(activity, transcriptFile)
|
|
73
|
+
|
|
74
|
+
default: // Append activity
|
|
75
|
+
return await this.logActivityToFile(activity, transcriptFile)
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// Try again
|
|
79
|
+
logger.warn(`Try ${i} - Failed to log activity because:`, error)
|
|
80
|
+
if (i === maxRetries) {
|
|
81
|
+
throw error
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Gets from the store activities that match a set of criteria.
|
|
90
|
+
* @param channelId - The ID of the channel the conversation is in.
|
|
91
|
+
* @param conversationId - The ID of the conversation.
|
|
92
|
+
* @param continuationToken - The continuation token (if available).
|
|
93
|
+
* @param startDate - A cutoff date. Activities older than this date are not included.
|
|
94
|
+
* @returns A promise that resolves with the matching activities.
|
|
95
|
+
*/
|
|
96
|
+
async getTranscriptActivities (
|
|
97
|
+
channelId: string,
|
|
98
|
+
conversationId: string,
|
|
99
|
+
continuationToken?: string,
|
|
100
|
+
startDate?: Date
|
|
101
|
+
): Promise<PagedResult<Activity>> {
|
|
102
|
+
const transcriptFile = this.getTranscriptFile(channelId, conversationId)
|
|
103
|
+
if (!await this.pathExists(transcriptFile)) {
|
|
104
|
+
logger.debug(`Transcript file does not exist: ${this.protocol(transcriptFile)}`)
|
|
105
|
+
return { items: [], continuationToken: undefined }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const transcript = await this.loadTranscriptAsync(transcriptFile)
|
|
109
|
+
const filterDate = startDate ?? new Date(0)
|
|
110
|
+
const items = transcript.filter(activity => {
|
|
111
|
+
const activityDate = activity.timestamp ? new Date(activity.timestamp) : new Date(0)
|
|
112
|
+
return activityDate >= filterDate
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
return { items, continuationToken: undefined }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Gets the conversations on a channel from the store.
|
|
120
|
+
* @param channelId - The ID of the channel.
|
|
121
|
+
* @param continuationToken - Continuation token (if available).
|
|
122
|
+
* @returns A promise that resolves with all transcripts for the given ChannelID.
|
|
123
|
+
*/
|
|
124
|
+
async listTranscripts (channelId: string, continuationToken?: string): Promise<PagedResult<TranscriptInfo>> {
|
|
125
|
+
const channelFolder = this.getChannelFolder(channelId)
|
|
126
|
+
if (!await this.pathExists(channelFolder)) {
|
|
127
|
+
logger.debug(`Channel folder does not exist: ${this.protocol(channelFolder)}`)
|
|
128
|
+
return { items: [], continuationToken: undefined }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const files = await fs.readdir(channelFolder)
|
|
132
|
+
const items: TranscriptInfo[] = []
|
|
133
|
+
for (const file of files) {
|
|
134
|
+
if (!file.endsWith('.transcript')) {
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const filePath = path.join(channelFolder, file)
|
|
139
|
+
const stats = await fs.stat(filePath)
|
|
140
|
+
|
|
141
|
+
items.push({
|
|
142
|
+
channelId,
|
|
143
|
+
id: path.parse(file).name,
|
|
144
|
+
created: stats.birthtime
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { items, continuationToken: undefined }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Deletes conversation data from the store.
|
|
153
|
+
* @param channelId - The ID of the channel the conversation is in.
|
|
154
|
+
* @param conversationId - The ID of the conversation to delete.
|
|
155
|
+
* @returns A promise that represents the work queued to execute.
|
|
156
|
+
*/
|
|
157
|
+
async deleteTranscript (channelId: string, conversationId: string): Promise<void> {
|
|
158
|
+
const file = this.getTranscriptFile(channelId, conversationId)
|
|
159
|
+
await this.withFileLock(file, async () => {
|
|
160
|
+
if (!await this.pathExists(file)) {
|
|
161
|
+
logger.debug(`Transcript file does not exist: ${this.protocol(file)}`)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
await fs.unlink(file)
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Loads a transcript from a file.
|
|
170
|
+
*/
|
|
171
|
+
private async loadTranscriptAsync (transcriptFile: string): Promise<Activity[]> {
|
|
172
|
+
if (!await this.pathExists(transcriptFile)) {
|
|
173
|
+
return []
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const json = await fs.readFile(transcriptFile, 'utf-8')
|
|
177
|
+
const result = JSON.parse(json)
|
|
178
|
+
return result.map(Activity.fromObject)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Executes a file operation with exclusive locking per file.
|
|
183
|
+
* This ensures that concurrent writes to the same transcript file are serialized.
|
|
184
|
+
*/
|
|
185
|
+
private async withFileLock<T> (transcriptFile: string, operation: () => Promise<T>): Promise<T> {
|
|
186
|
+
// Get the current lock chain for this file
|
|
187
|
+
const existingLock = this._fileLocks.get(transcriptFile) ?? Promise.resolve()
|
|
188
|
+
|
|
189
|
+
// Create a new lock that waits for the existing one and then performs the operation
|
|
190
|
+
const newLock = existingLock.then(async () => {
|
|
191
|
+
return await operation()
|
|
192
|
+
}).catch(error => {
|
|
193
|
+
logger.warn('Error in write chain:', error)
|
|
194
|
+
throw error
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// Update the lock chain
|
|
198
|
+
this._fileLocks.set(transcriptFile, newLock)
|
|
199
|
+
|
|
200
|
+
// Wait for this operation to complete
|
|
201
|
+
try {
|
|
202
|
+
return await newLock
|
|
203
|
+
} finally {
|
|
204
|
+
// Clean up if this was the last operation in the chain
|
|
205
|
+
if (this._fileLocks.get(transcriptFile) === newLock) {
|
|
206
|
+
this._fileLocks.delete(transcriptFile)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Performs the actual write operation to the transcript file.
|
|
213
|
+
*/
|
|
214
|
+
private async logActivityToFile (activity: Activity, transcriptFile: string): Promise<void> {
|
|
215
|
+
const activityStr = JSON.stringify(activity)
|
|
216
|
+
|
|
217
|
+
if (!await this.pathExists(transcriptFile)) {
|
|
218
|
+
const folder = path.dirname(transcriptFile)
|
|
219
|
+
if (!await this.pathExists(folder)) {
|
|
220
|
+
await fs.mkdir(folder, { recursive: true })
|
|
221
|
+
}
|
|
222
|
+
await fs.writeFile(transcriptFile, `[${activityStr}]`, 'utf-8')
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Use file handle to append efficiently
|
|
227
|
+
const fileHandle = await fs.open(transcriptFile, 'r+')
|
|
228
|
+
try {
|
|
229
|
+
const stats = await fileHandle.stat()
|
|
230
|
+
|
|
231
|
+
// Seek to before the closing bracket
|
|
232
|
+
const position = Math.max(0, stats.size - 1)
|
|
233
|
+
|
|
234
|
+
// Write the comma, new activity, and closing bracket
|
|
235
|
+
const appendContent = `,${EOL}${activityStr}]`
|
|
236
|
+
await fileHandle.write(appendContent, position)
|
|
237
|
+
// Truncate any remaining content (in case the file had trailing data)
|
|
238
|
+
await fileHandle.truncate(position + Buffer.byteLength(appendContent))
|
|
239
|
+
} finally {
|
|
240
|
+
await fileHandle.close()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Updates a message in the transcript.
|
|
246
|
+
*/
|
|
247
|
+
private async messageUpdateAsync (activity: Activity, transcriptFile: string): Promise<void> {
|
|
248
|
+
// Load all activities
|
|
249
|
+
const transcript = await this.loadTranscriptAsync(transcriptFile)
|
|
250
|
+
|
|
251
|
+
for (let i = 0; i < transcript.length; i++) {
|
|
252
|
+
const originalActivity = transcript[i]
|
|
253
|
+
if (originalActivity.id === activity.id) {
|
|
254
|
+
// Clone and update the activity
|
|
255
|
+
const updatedActivity: Activity = { ...activity } as Activity
|
|
256
|
+
updatedActivity.type = originalActivity.type // Fixup original type (should be Message)
|
|
257
|
+
updatedActivity.localTimestamp = originalActivity.localTimestamp
|
|
258
|
+
updatedActivity.timestamp = originalActivity.timestamp
|
|
259
|
+
transcript[i] = updatedActivity
|
|
260
|
+
|
|
261
|
+
const json = JSON.stringify(transcript)
|
|
262
|
+
await fs.writeFile(transcriptFile, json, 'utf-8')
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Deletes a message from the transcript (tombstones it).
|
|
270
|
+
*/
|
|
271
|
+
private async messageDeleteAsync (activity: Activity, transcriptFile: string): Promise<void> {
|
|
272
|
+
// Load all activities
|
|
273
|
+
const transcript = await this.loadTranscriptAsync(transcriptFile)
|
|
274
|
+
|
|
275
|
+
// If message delete comes in, tombstone the message in the transcript
|
|
276
|
+
for (let index = 0; index < transcript.length; index++) {
|
|
277
|
+
const originalActivity = transcript[index]
|
|
278
|
+
if (originalActivity.id === activity.id) {
|
|
279
|
+
// Tombstone the original message
|
|
280
|
+
transcript[index] = {
|
|
281
|
+
type: ActivityTypes.MessageDelete,
|
|
282
|
+
id: originalActivity.id,
|
|
283
|
+
from: {
|
|
284
|
+
id: 'deleted',
|
|
285
|
+
role: originalActivity.from?.role
|
|
286
|
+
},
|
|
287
|
+
recipient: {
|
|
288
|
+
id: 'deleted',
|
|
289
|
+
role: originalActivity.recipient?.role
|
|
290
|
+
},
|
|
291
|
+
locale: originalActivity.locale,
|
|
292
|
+
localTimestamp: originalActivity.timestamp,
|
|
293
|
+
timestamp: originalActivity.timestamp,
|
|
294
|
+
channelId: originalActivity.channelId,
|
|
295
|
+
conversation: originalActivity.conversation,
|
|
296
|
+
serviceUrl: originalActivity.serviceUrl,
|
|
297
|
+
replyToId: originalActivity.replyToId
|
|
298
|
+
} as Activity
|
|
299
|
+
|
|
300
|
+
const json = JSON.stringify(transcript)
|
|
301
|
+
await fs.writeFile(transcriptFile, json, 'utf-8')
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Sanitizes a string by removing invalid characters.
|
|
309
|
+
*/
|
|
310
|
+
private static sanitizeString (str: string, invalidChars: string[]): string {
|
|
311
|
+
if (!str?.trim()) {
|
|
312
|
+
return str
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Preemptively check for : in string and replace with _
|
|
316
|
+
let result = str.replaceAll(':', '_')
|
|
317
|
+
|
|
318
|
+
// Remove invalid characters
|
|
319
|
+
for (const invalidChar of invalidChars) {
|
|
320
|
+
result = result.replaceAll(invalidChar, '')
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return result
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Gets the transcript file path for a conversation.
|
|
328
|
+
*/
|
|
329
|
+
private getTranscriptFile (channelId: string, conversationId: string): string {
|
|
330
|
+
if (!channelId?.trim()) {
|
|
331
|
+
throw new Error('channelId is required.')
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!conversationId?.trim()) {
|
|
335
|
+
throw new Error('conversationId is required.')
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Get invalid filename characters (cross-platform)
|
|
339
|
+
const invalidChars = this.getInvalidFileNameChars()
|
|
340
|
+
let fileName = FileTranscriptLogger.sanitizeString(conversationId, invalidChars)
|
|
341
|
+
|
|
342
|
+
const maxLength = FileTranscriptLogger.MAX_FILE_NAME_SIZE - FileTranscriptLogger.TRANSCRIPT_FILE_EXTENSION.length
|
|
343
|
+
if (fileName && fileName.length > maxLength) {
|
|
344
|
+
fileName = fileName.substring(0, maxLength)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const channelFolder = this.getChannelFolder(channelId)
|
|
348
|
+
return path.join(channelFolder, fileName + FileTranscriptLogger.TRANSCRIPT_FILE_EXTENSION)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Gets the channel folder path, creating it if necessary.
|
|
353
|
+
*/
|
|
354
|
+
private getChannelFolder (channelId: string): string {
|
|
355
|
+
if (!channelId?.trim()) {
|
|
356
|
+
throw new Error('channelId is required.')
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const invalidChars = this.getInvalidPathChars()
|
|
360
|
+
const folderName = FileTranscriptLogger.sanitizeString(channelId, invalidChars)
|
|
361
|
+
return path.join(this._folder, folderName)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Checks if a file or directory exists.
|
|
366
|
+
*/
|
|
367
|
+
private async pathExists (path: string): Promise<boolean> {
|
|
368
|
+
try {
|
|
369
|
+
await fs.stat(path)
|
|
370
|
+
return true
|
|
371
|
+
} catch {
|
|
372
|
+
return false
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Gets invalid filename characters for the current platform.
|
|
378
|
+
*/
|
|
379
|
+
private getInvalidFileNameChars (): string[] {
|
|
380
|
+
// Windows invalid filename chars: < > : " / \ | ? *
|
|
381
|
+
// Unix systems are more permissive, but / is always invalid
|
|
382
|
+
const invalid = this.getInvalidPathChars()
|
|
383
|
+
if (process.platform === 'win32') {
|
|
384
|
+
return [...invalid, '/', '\\']
|
|
385
|
+
} else {
|
|
386
|
+
return [...invalid, '/']
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Gets invalid path characters for the current platform.
|
|
392
|
+
*/
|
|
393
|
+
private getInvalidPathChars (): string[] {
|
|
394
|
+
// Similar to filename chars but allows directory separators in the middle
|
|
395
|
+
if (process.platform === 'win32') {
|
|
396
|
+
return ['<', '>', ':', '"', '|', '?', '*', '\0']
|
|
397
|
+
} else {
|
|
398
|
+
// Unix/Linux: only null byte is invalid in paths
|
|
399
|
+
return ['\0']
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Adds file:// protocol to a file path.
|
|
405
|
+
*/
|
|
406
|
+
private protocol (filePath: string): string {
|
|
407
|
+
return `file://${filePath.replace(/\\/g, '/')}`
|
|
408
|
+
}
|
|
409
|
+
}
|