@microsoft/agents-hosting 1.1.0-alpha.85 → 1.1.1
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 +4 -4
- package/dist/src/app/auth/handlers/agenticAuthorization.js +4 -4
- package/dist/src/app/auth/handlers/agenticAuthorization.js.map +1 -1
- package/dist/src/app/auth/handlers/azureBotAuthorization.d.ts +4 -0
- package/dist/src/app/auth/handlers/azureBotAuthorization.js +3 -2
- package/dist/src/app/auth/handlers/azureBotAuthorization.js.map +1 -1
- package/dist/src/auth/authProvider.d.ts +6 -3
- package/dist/src/auth/msalTokenProvider.d.ts +10 -3
- package/dist/src/auth/msalTokenProvider.js +30 -11
- package/dist/src/auth/msalTokenProvider.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.js +10 -8
- package/dist/src/cloudAdapter.js.map +1 -1
- 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/package.json +4 -4
- package/src/app/auth/handlers/agenticAuthorization.ts +1 -0
- package/src/app/auth/handlers/azureBotAuthorization.ts +9 -2
- package/src/auth/authProvider.ts +6 -3
- package/src/auth/msalTokenProvider.ts +30 -11
- package/src/cards/cardFactory.ts +3 -2
- package/src/cloudAdapter.ts +7 -6
- package/src/transcript/fileTranscriptLogger.ts +409 -0
|
@@ -129,17 +129,17 @@ export class MsalTokenProvider implements AuthProvider {
|
|
|
129
129
|
return token?.accessToken as string
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
public async getAgenticInstanceToken (agentAppInstanceId: string): Promise<string> {
|
|
132
|
+
public async getAgenticInstanceToken (tenantId: string, agentAppInstanceId: string): Promise<string> {
|
|
133
133
|
logger.debug('Getting agentic instance token')
|
|
134
134
|
if (!this.connectionSettings) {
|
|
135
135
|
throw new Error('Connection settings must be provided when calling getAgenticInstanceToken')
|
|
136
136
|
}
|
|
137
|
-
const appToken = await this.getAgenticApplicationToken(agentAppInstanceId)
|
|
137
|
+
const appToken = await this.getAgenticApplicationToken(tenantId, agentAppInstanceId)
|
|
138
138
|
const cca = new ConfidentialClientApplication({
|
|
139
139
|
auth: {
|
|
140
140
|
clientId: agentAppInstanceId,
|
|
141
141
|
clientAssertion: appToken,
|
|
142
|
-
authority:
|
|
142
|
+
authority: this.resolveAuthority(tenantId),
|
|
143
143
|
},
|
|
144
144
|
system: this.sysOptions
|
|
145
145
|
})
|
|
@@ -156,17 +156,36 @@ export class MsalTokenProvider implements AuthProvider {
|
|
|
156
156
|
return token.accessToken
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* This method can optionally accept a tenant ID that overrides the tenant ID in the connection settings, if the connection settings authority contains "common".
|
|
161
|
+
* @param tenantId
|
|
162
|
+
* @returns
|
|
163
|
+
*/
|
|
164
|
+
private resolveAuthority (tenantId?: string) : string {
|
|
165
|
+
// if for some reason the agentic tenant ID is not in the message, fall back to the original configured auth settings
|
|
166
|
+
if (!tenantId) {
|
|
167
|
+
return this.connectionSettings?.authority ? `${this.connectionSettings.authority}/${this.connectionSettings?.tenantId}` : `https://login.microsoftonline.com/${this.connectionSettings?.tenantId || 'botframework.com'}`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (this.connectionSettings?.tenantId === 'common') {
|
|
171
|
+
return this.connectionSettings?.authority ? `${this.connectionSettings.authority}/${tenantId}` : `https://login.microsoftonline.com/${tenantId}`
|
|
172
|
+
} else {
|
|
173
|
+
return this.connectionSettings?.authority ? `${this.connectionSettings.authority}/${this.connectionSettings?.tenantId}` : `https://login.microsoftonline.com/${this.connectionSettings?.tenantId || 'botframework.com'}`
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
159
177
|
/**
|
|
160
178
|
* Does a direct HTTP call to acquire a token for agentic scenarios - do not use this directly!
|
|
161
179
|
* This method will be removed once MSAL is updated with the necessary features.
|
|
162
180
|
* (This is required in order to pass additional parameters into the auth call)
|
|
181
|
+
* @param tenantId
|
|
163
182
|
* @param clientId
|
|
164
183
|
* @param clientAssertion
|
|
165
184
|
* @param scopes
|
|
166
185
|
* @param tokenBodyParameters
|
|
167
186
|
* @returns
|
|
168
187
|
*/
|
|
169
|
-
private async acquireTokenByForAgenticScenarios (clientId: string, clientAssertion: string | undefined, scopes: string[], tokenBodyParameters: { [key: string]: any }): Promise<string | null> {
|
|
188
|
+
private async acquireTokenByForAgenticScenarios (tenantId: string, clientId: string, clientAssertion: string | undefined, scopes: string[], tokenBodyParameters: { [key: string]: any }): Promise<string | null> {
|
|
170
189
|
if (!this.connectionSettings) {
|
|
171
190
|
throw new Error('Connection settings must be provided when calling getAgenticInstanceToken')
|
|
172
191
|
}
|
|
@@ -177,7 +196,7 @@ export class MsalTokenProvider implements AuthProvider {
|
|
|
177
196
|
return this._agenticTokenCache.get(cacheKey) as string
|
|
178
197
|
}
|
|
179
198
|
|
|
180
|
-
const url = `${this.
|
|
199
|
+
const url = `${this.resolveAuthority(tenantId)}/oauth2/v2.0/token`
|
|
181
200
|
|
|
182
201
|
const data: { [key: string]: any } = {
|
|
183
202
|
client_id: clientId,
|
|
@@ -210,12 +229,12 @@ export class MsalTokenProvider implements AuthProvider {
|
|
|
210
229
|
return token.data.access_token
|
|
211
230
|
}
|
|
212
231
|
|
|
213
|
-
public async getAgenticUserToken (agentAppInstanceId: string, agenticUserId: string, scopes: string[]): Promise<string> {
|
|
232
|
+
public async getAgenticUserToken (tenantId: string, agentAppInstanceId: string, agenticUserId: string, scopes: string[]): Promise<string> {
|
|
214
233
|
logger.debug('Getting agentic user token')
|
|
215
|
-
const agentToken = await this.getAgenticApplicationToken(agentAppInstanceId)
|
|
216
|
-
const instanceToken = await this.getAgenticInstanceToken(agentAppInstanceId)
|
|
234
|
+
const agentToken = await this.getAgenticApplicationToken(tenantId, agentAppInstanceId)
|
|
235
|
+
const instanceToken = await this.getAgenticInstanceToken(tenantId, agentAppInstanceId)
|
|
217
236
|
|
|
218
|
-
const token = await this.acquireTokenByForAgenticScenarios(
|
|
237
|
+
const token = await this.acquireTokenByForAgenticScenarios(tenantId, agentToken, instanceToken, scopes, {
|
|
219
238
|
user_id: agenticUserId,
|
|
220
239
|
user_federated_identity_credential: instanceToken,
|
|
221
240
|
grant_type: 'user_fic',
|
|
@@ -228,12 +247,12 @@ export class MsalTokenProvider implements AuthProvider {
|
|
|
228
247
|
return token
|
|
229
248
|
}
|
|
230
249
|
|
|
231
|
-
public async getAgenticApplicationToken (agentAppInstanceId: string): Promise<string> {
|
|
250
|
+
public async getAgenticApplicationToken (tenantId: string, agentAppInstanceId: string): Promise<string> {
|
|
232
251
|
if (!this.connectionSettings?.clientId) {
|
|
233
252
|
throw new Error('Connection settings must be provided when calling getAgenticApplicationToken')
|
|
234
253
|
}
|
|
235
254
|
logger.debug('Getting agentic application token')
|
|
236
|
-
const token = await this.acquireTokenByForAgenticScenarios(this.connectionSettings.clientId, undefined, ['api://AzureAdTokenExchange/.default'], {
|
|
255
|
+
const token = await this.acquireTokenByForAgenticScenarios(tenantId, this.connectionSettings.clientId, undefined, ['api://AzureAdTokenExchange/.default'], {
|
|
237
256
|
grant_type: 'client_credentials',
|
|
238
257
|
fmi_path: agentAppInstanceId,
|
|
239
258
|
})
|
package/src/cards/cardFactory.ts
CHANGED
|
@@ -215,9 +215,10 @@ export class CardFactory {
|
|
|
215
215
|
* @param title The title of the card.
|
|
216
216
|
* @param text The optional text for the card.
|
|
217
217
|
* @param signingResource The signing resource.
|
|
218
|
+
* @param enableSso The option to enable SSO when authenticating using AAD. Defaults to true.
|
|
218
219
|
* @returns The OAuth card attachment.
|
|
219
220
|
*/
|
|
220
|
-
static oauthCard (connectionName: string, title: string, text: string, signingResource: SignInResource) : Attachment {
|
|
221
|
+
static oauthCard (connectionName: string, title: string, text: string, signingResource: SignInResource, enableSso: boolean = true) : Attachment {
|
|
221
222
|
const card: Partial<OAuthCard> = {
|
|
222
223
|
buttons: [{
|
|
223
224
|
type: ActionTypes.Signin,
|
|
@@ -226,7 +227,7 @@ export class CardFactory {
|
|
|
226
227
|
channelData: undefined
|
|
227
228
|
}],
|
|
228
229
|
connectionName,
|
|
229
|
-
tokenExchangeResource: signingResource.tokenExchangeResource,
|
|
230
|
+
tokenExchangeResource: enableSso ? signingResource.tokenExchangeResource : undefined,
|
|
230
231
|
tokenPostResource: signingResource.tokenPostResource,
|
|
231
232
|
}
|
|
232
233
|
if (text) {
|
package/src/cloudAdapter.ts
CHANGED
|
@@ -124,18 +124,20 @@ export class CloudAdapter extends BaseAdapter {
|
|
|
124
124
|
const tokenProvider = this.connectionManager.getTokenProviderFromActivity(identity, activity)
|
|
125
125
|
if (activity.isAgenticRequest()) {
|
|
126
126
|
logger.debug('Activity is from an agentic source, using special scope', activity.recipient)
|
|
127
|
+
const agenticInstanceId = activity.getAgenticInstanceId()
|
|
128
|
+
const agenticUserId = activity.getAgenticUser()
|
|
127
129
|
|
|
128
|
-
if (activity.recipient?.role === RoleTypes.AgenticIdentity &&
|
|
130
|
+
if (activity.recipient?.role?.toLowerCase() === RoleTypes.AgenticIdentity.toLowerCase() && agenticInstanceId) {
|
|
129
131
|
// get agentic instance token
|
|
130
|
-
const token = await tokenProvider.getAgenticInstanceToken(activity.
|
|
132
|
+
const token = await tokenProvider.getAgenticInstanceToken(activity.getAgenticTenantId() ?? '', agenticInstanceId)
|
|
131
133
|
connectorClient = ConnectorClient.createClientWithToken(
|
|
132
134
|
activity.serviceUrl!,
|
|
133
135
|
token,
|
|
134
136
|
headers
|
|
135
137
|
)
|
|
136
|
-
} else if (activity.recipient?.role === RoleTypes.AgenticUser
|
|
138
|
+
} else if (activity.recipient?.role?.toLowerCase() === RoleTypes.AgenticUser.toLowerCase() && agenticInstanceId && agenticUserId) {
|
|
137
139
|
const scope = tokenProvider.connectionSettings?.scope ?? ApxProductionScope
|
|
138
|
-
const token = await tokenProvider.getAgenticUserToken(activity.
|
|
140
|
+
const token = await tokenProvider.getAgenticUserToken(activity.getAgenticTenantId() ?? '', agenticInstanceId, agenticUserId, [scope])
|
|
139
141
|
|
|
140
142
|
connectorClient = ConnectorClient.createClientWithToken(
|
|
141
143
|
activity.serviceUrl!,
|
|
@@ -156,7 +158,6 @@ export class CloudAdapter extends BaseAdapter {
|
|
|
156
158
|
headers
|
|
157
159
|
)
|
|
158
160
|
}
|
|
159
|
-
|
|
160
161
|
return connectorClient
|
|
161
162
|
}
|
|
162
163
|
|
|
@@ -351,7 +352,7 @@ export class CloudAdapter extends BaseAdapter {
|
|
|
351
352
|
await this.runMiddleware(context, logic)
|
|
352
353
|
const invokeResponse = this.processTurnResults(context)
|
|
353
354
|
logger.debug('Activity Response (invoke/expect replies): ', invokeResponse)
|
|
354
|
-
return end(invokeResponse?.status ?? StatusCodes.OK,
|
|
355
|
+
return end(invokeResponse?.status ?? StatusCodes.OK, invokeResponse?.body, true)
|
|
355
356
|
}
|
|
356
357
|
|
|
357
358
|
await this.runMiddleware(context, logic)
|
|
@@ -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
|
+
}
|