botbuilder-azure
Version:
Azure extensions for Microsoft BotBuilder.
490 lines (432 loc) • 17.5 kB
text/typescript
/**
* @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>;
}