UNPKG

botbuilder-azure

Version:

Azure extensions for Microsoft BotBuilder.

490 lines (432 loc) 17.5 kB
/** * @module botbuilder-azure */ /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import * as azure from 'azure-storage'; import { Activity, PagedResult, TranscriptInfo, TranscriptStore } from 'botbuilder'; import { escape } from 'querystring'; import { BlobStorageSettings } from './blobStorage'; const ContainerNameCheck = new RegExp('^[a-z0-9](?!.*--)[a-z0-9-]{1,61}[a-z0-9]$'); /** * @private * Unique ket used to access the static <code>checkedCollections</code> * property of the AzureBlobTranscriptStore. Accessing it is necessary for * proper testing and debugging. */ export const checkedCollectionsKey = Symbol('checkedCollectionsKey'); /** * Stores transcripts in an Azure Blob container. * * @remarks * Each activity is stored as JSON blob with a structure of * `container/{channelId]/{conversationId}/{Timestamp.ticks}-{activity.id}.json`. * * @deprecated This class is deprecated in favor of [BlobsTranscriptStore](xref:botbuilder-azure-blobs.BlobsTranscriptStore) */ export class AzureBlobTranscriptStore implements TranscriptStore { /** * @private * Internal dictionary with the containers where entities will be stored. */ private static [checkedCollectionsKey]: { [key: string]: Promise<azure.BlobService.ContainerResult> } = {}; private readonly settings: BlobStorageSettings; private client: BlobServiceAsync; private pageSize = 20; /** * Creates a new AzureBlobTranscriptStore instance. * * @param settings Settings required for configuring an instance of BlobStorage */ constructor(settings: BlobStorageSettings) { if (!settings) { throw new Error('The settings parameter is required.'); } if (!settings.containerName) { throw new Error('The containerName is required.'); } if (!this.checkContainerName(settings.containerName)) { throw new Error('Invalid container name.'); } this.settings = { ...settings }; this.client = this.createBlobService(this.settings); } /** * Log an activity to the transcript. * * @param activity Activity being logged. */ async logActivity(activity: Activity): Promise<void> { if (!activity) { throw new Error('Missing activity.'); } const blobName: string = this.getBlobName(activity); const data: string = JSON.stringify(activity); const container = await this.ensureContainerExists(); const block = await this.client.createBlockBlobFromTextAsync(container.name, blobName, data, null); const meta = this.client.setBlobMetadataAsync(container.name, blobName, { fromid: activity.from.id, recipientid: activity.recipient.id, timestamp: activity.timestamp.toJSON(), }); const props = this.client.setBlobPropertiesAsync(container.name, blobName, { contentType: 'application/json', }); await Promise.all([block, meta, props]); // Concurrent } /** * Get activities for a conversation (Aka the transcript) * * @param channelId Channel Id. * @param conversationId Conversation Id. * @param continuationToken Continuation token to page through results. * @param startDate Earliest time to include. * @returns The PagedResult of activities. */ async getTranscriptActivities( channelId: string, conversationId: string, continuationToken?: string, startDate?: Date ): Promise<PagedResult<Activity>> { if (!channelId) { throw new Error('Missing channelId'); } if (!conversationId) { throw new Error('Missing conversationId'); } if (!startDate) { startDate = new Date(0); } // tslint:disable-next-line:prefer-template const prefix: string = this.getDirName(channelId, conversationId) + '/'; const token: azure.common.ContinuationToken = null; const container = await this.ensureContainerExists(); const activityBlobs = await this.getActivityBlobs( [], container.name, prefix, continuationToken, startDate, token ); const activities = await Promise.all(activityBlobs.map((blob) => this.blobToActivity(blob))); const pagedResult: PagedResult<Activity> = { items: activities, continuationToken: undefined }; if (pagedResult.items.length === this.pageSize) { pagedResult.continuationToken = activityBlobs.slice(-1).pop().name; } return pagedResult; } /** * List conversations in the channelId. * * @param channelId Channel Id. * @param continuationToken ContinuationToken token to page through results. * @returns A promise representation of [PagedResult<TranscriptInfo>](xref:botbuilder-core.PagedResult) */ async listTranscripts(channelId: string, continuationToken?: string): Promise<PagedResult<TranscriptInfo>> { if (!channelId) { throw new Error('Missing channelId'); } // tslint:disable-next-line:prefer-template const prefix: string = this.getDirName(channelId) + '/'; const token: azure.common.ContinuationToken = null; const container = await this.ensureContainerExists(); const transcripts = await this.getTranscriptsFolders( [], container.name, prefix, continuationToken, channelId, token ); const pagedResult: PagedResult<TranscriptInfo> = { items: transcripts, continuationToken: undefined }; if (pagedResult.items.length === this.pageSize) { pagedResult.continuationToken = transcripts.slice(-1).pop().id; } return pagedResult; } /** * Delete a specific conversation and all of it's activities. * * @param channelId Channel Id where conversation took place. * @param conversationId Id of the conversation to delete. */ async deleteTranscript(channelId: string, conversationId: string): Promise<void> { if (!channelId) { throw new Error('Missing channelId'); } if (!conversationId) { throw new Error('Missing conversationId'); } // tslint:disable-next-line:prefer-template const prefix: string = this.getDirName(channelId, conversationId) + '/'; const token: azure.common.ContinuationToken = null; const container = await this.ensureContainerExists(); const conversationBlobs = await this.getConversationsBlobs([], container.name, prefix, token); await Promise.all( conversationBlobs.map((blob) => this.client.deleteBlobIfExistsAsync(blob.container, blob.name)) ); } /** * Parse a BlobResult as an [Activity](xref:botframework-schema.Activity). * * @param blob BlobResult to parse as an [Activity](xref:botframework-schema.Activity). * @returns The parsed [Activity](xref:botframework-schema.Activity). */ private async blobToActivity(blob: azure.BlobService.BlobResult): Promise<Activity> { const content = await this.client.getBlobToTextAsync(blob.container, blob.name); const activity: Activity = JSON.parse(content as any) as Activity; activity.timestamp = new Date(activity.timestamp); return activity; } /** * @private */ private async getActivityBlobs( blobs: azure.BlobService.BlobResult[], container: string, prefix: string, continuationToken: string, startDate: Date, token: azure.common.ContinuationToken ): Promise<azure.BlobService.BlobResult[]> { const listBlobResult = await this.client.listBlobsSegmentedWithPrefixAsync(container, prefix, token, { include: 'metadata', }); listBlobResult.entries.some((blob) => { const timestamp: Date = new Date(blob.metadata.timestamp); if (timestamp >= startDate) { if (continuationToken) { if (blob.name === continuationToken) { continuationToken = null; } } else { blob.container = container; blobs.push(blob); return blobs.length === this.pageSize; } } return false; }); if (listBlobResult.continuationToken && blobs.length < this.pageSize) { await this.getActivityBlobs( blobs, container, prefix, continuationToken, startDate, listBlobResult.continuationToken ); } return blobs; } /** * @private */ private async getTranscriptsFolders( transcripts: TranscriptInfo[], container: string, prefix: string, continuationToken: string, channelId: string, token: azure.common.ContinuationToken ): Promise<TranscriptInfo[]> { const result = await this.client.listBlobDirectoriesSegmentedWithPrefixAsync(container, prefix, token); result.entries.some((blob) => { const conversation: TranscriptInfo = { channelId: channelId, id: blob.name .split('/') .filter((part: string) => part) .slice(-1) .pop(), created: undefined, }; if (continuationToken) { if (conversation.id === continuationToken) { continuationToken = null; } } else { transcripts.push(conversation); return transcripts.length === this.pageSize; } return false; }); if (result.continuationToken && transcripts.length < this.pageSize) { await this.getTranscriptsFolders( transcripts, container, prefix, continuationToken, channelId, result.continuationToken ); } return transcripts; } /** * @private */ private async getConversationsBlobs( blobs: azure.BlobService.BlobResult[], container: string, prefix: string, token: azure.common.ContinuationToken ): Promise<azure.BlobService.BlobResult[]> { const result = await this.client.listBlobsSegmentedWithPrefixAsync(container, prefix, token, null); blobs = blobs.concat( result.entries.map((blob: azure.BlobService.BlobResult) => { blob.container = container; return blob; }) ); if (result.continuationToken) { await this.getConversationsBlobs(blobs, container, prefix, result.continuationToken); } return blobs; } /** * Check if a container name is valid. * * @param container String representing the container name to validate. * @returns A boolean value that indicates whether or not the name is valid. */ private checkContainerName(container: string): boolean { return ContainerNameCheck.test(container); } /** * Get the blob name based on the [Activity](xref:botframework-schema.Activity). * * @param activity [Activity](xref:botframework-schema.Activity) to get the blob name from. * @returns The blob name. */ private getBlobName(activity: Activity): string { const channelId: string = this.sanitizeKey(activity.channelId); const conversationId: string = this.sanitizeKey(activity.conversation.id); const timestamp: string = this.sanitizeKey(this.getTicks(activity.timestamp)); const activityId: string = this.sanitizeKey(activity.id); return `${channelId}/${conversationId}/${timestamp}-${activityId}.json`; } /** * Get the directory name. * * @param channelId Channel Id. * @param conversationId Id of the conversation to get the directory name from. * @returns The sanitized directory name. */ private getDirName(channelId: string, conversationId?: string): string { if (!conversationId) { return this.sanitizeKey(channelId); } return `${this.sanitizeKey(channelId)}/${this.sanitizeKey(conversationId)}`; } /** * Escape a given key to be compatible for use with BlobStorage. * * @param key Key to be sanitized(scaped). * @returns The sanitized key. */ private sanitizeKey(key: string): string { return escape(key); } /** * @private */ private getTicks(timestamp: Date): string { const epochTicks = 621355968000000000; // the number of .net ticks at the unix epoch const ticksPerMillisecond = 10000; // there are 10000 .net ticks per millisecond const ticks: number = epochTicks + timestamp.getTime() * ticksPerMillisecond; return ticks.toString(16); } /** * Delay Container creation if it does not exist. * * @returns A promise representing the asynchronous operation. */ private ensureContainerExists(): Promise<azure.BlobService.ContainerResult> { const key: string = this.settings.containerName; if (!AzureBlobTranscriptStore[checkedCollectionsKey][key]) { AzureBlobTranscriptStore[checkedCollectionsKey][key] = this.client.createContainerIfNotExistsAsync(key); } return AzureBlobTranscriptStore[checkedCollectionsKey][key]; } /** * Create a Blob Service. * * @param param0 Settings required for configuring the Blob Service. * @param param0.storageAccountOrConnectionString Storage account or connection string. * @param param0.storageAccessKey Storage access key. * @param param0.host Blob Service host. * @returns The BlobService created. */ private createBlobService({ storageAccountOrConnectionString, storageAccessKey, host, }: BlobStorageSettings): BlobServiceAsync { if (!storageAccountOrConnectionString) { throw new Error('The storageAccountOrConnectionString parameter is required.'); } const blobService: azure.BlobService = azure .createBlobService(storageAccountOrConnectionString, storageAccessKey, host) .withFilter(new azure.LinearRetryPolicyFilter(5, 500)); // The perfect use case for a Proxy return new Proxy(<BlobServiceAsync>{}, { get(target: azure.services.blob.blobservice.BlobService, p: PropertyKey): Promise<any> { const prop = p.toString().endsWith('Async') ? p.toString().replace('Async', '') : p; return target[p] || (target[p] = denodeify(blobService, blobService[prop])); }, }) as BlobServiceAsync; // eslint-disable-next-line @typescript-eslint/ban-types function denodeify<T>(thisArg: any, fn: Function): (...args: any[]) => Promise<T> { return (...args: any[]): Promise<T> => { return new Promise<T>((resolve: any, reject: any): void => { args.push((error: Error, result: any) => (error ? reject(error) : resolve(result))); fn.apply(thisArg, args); }); }; } } } /** * @private * Promise based methods created using denodeify function */ interface BlobServiceAsync extends azure.BlobService { createContainerIfNotExistsAsync(container: string): Promise<azure.BlobService.ContainerResult>; deleteContainerIfExistsAsync(container: string): Promise<boolean>; createBlockBlobFromTextAsync( container: string, blob: string, text: string | Buffer, options: azure.BlobService.CreateBlobRequestOptions ): Promise<azure.BlobService.BlobResult>; getBlobMetadataAsync(container: string, blob: string): Promise<azure.BlobService.BlobResult>; setBlobMetadataAsync( container: string, blob: string, metadata: { [index: string]: string } ): Promise<azure.BlobService.BlobResult>; getBlobPropertiesAsync(container: string, blob: string): Promise<azure.BlobService.BlobResult>; setBlobPropertiesAsync( container: string, blob: string, propertiesAndOptions: azure.BlobService.SetBlobPropertiesRequestOptions ): Promise<azure.BlobService.BlobResult>; getBlobToTextAsync(container: string, blob: string): Promise<azure.BlobService.BlobToText>; deleteBlobIfExistsAsync(container: string, blob: string): Promise<boolean>; listBlobsSegmentedWithPrefixAsync( container: string, prefix: string, currentToken: azure.common.ContinuationToken, options: azure.BlobService.ListBlobsSegmentedRequestOptions ): Promise<azure.BlobService.ListBlobsResult>; listBlobDirectoriesSegmentedWithPrefixAsync( container: string, prefix: string, currentToken: azure.common.ContinuationToken ): Promise<azure.BlobService.ListBlobsResult>; }