UNPKG

8.98 kBPlain TextView Raw
1/**
2 * @module botbuilder
3 */
4/**
5 * Copyright (c) Microsoft Corporation. All rights reserved.
6 * Licensed under the MIT License.
7 */
8import { join, parse } from 'path';
9import { mkdirp, pathExists, readdir, readFile, remove, writeFile } from 'fs-extra';
10import { Activity, PagedResult, TranscriptInfo, TranscriptStore } from 'botbuilder-core';
11
12// eslint-disable-next-line @typescript-eslint/no-var-requires
13const filenamify = require('filenamify');
14
15/**
16 * @private
17 * The number of .net ticks at the unix epoch.
18 */
19const epochTicks = 621355968000000000;
20
21/**
22 * @private
23 * There are 10000 .net ticks per millisecond.
24 */
25const ticksPerMillisecond = 10000;
26
27/**
28 * @private
29 * @param timestamp A date used to calculate future ticks.
30 */
31function getTicks(timestamp: Date): string {
32 const ticks: number = epochTicks + timestamp.getTime() * ticksPerMillisecond;
33
34 return ticks.toString(16);
35}
36
37/**
38 * @private
39 * @param ticks A string containing ticks.
40 */
41function readDate(ticks: string): Date {
42 const t: number = Math.round((parseInt(ticks, 16) - epochTicks) / ticksPerMillisecond);
43
44 return new Date(t);
45}
46
47/**
48 * @private
49 * @param date A date used to create a filter.
50 * @param fileName The filename containing the timestamp string
51 */
52function withDateFilter(date: Date, fileName: string): any {
53 if (!date) {
54 return true;
55 }
56
57 const ticks: string = fileName.split('-')[0];
58 return readDate(ticks) >= date;
59}
60
61/**
62 * @private
63 * @param expression A function that will be used to test items.
64 */
65function includeWhen(expression: any): any {
66 let shouldInclude = false;
67
68 return (item: any): boolean => {
69 return shouldInclude || (shouldInclude = expression(item));
70 };
71}
72
73/**
74 * @private
75 * @param json A JSON string to be parsed into an activity.
76 */
77function parseActivity(json: string): Activity {
78 const activity: Activity = JSON.parse(json);
79 activity.timestamp = new Date(activity.timestamp);
80
81 return activity;
82}
83
84/**
85 * The file transcript store stores transcripts in file system with each activity as a file.
86 *
87 * @remarks
88 * This class provides an interface to log all incoming and outgoing activities to the filesystem.
89 * It implements the features necessary to work alongside the TranscriptLoggerMiddleware plugin.
90 * When used in concert, your bot will automatically log all conversations.
91 *
92 * Below is the boilerplate code needed to use this in your app:
93 * ```javascript
94 * const { FileTranscriptStore, TranscriptLoggerMiddleware } = require('botbuilder');
95 *
96 * adapter.use(new TranscriptLoggerMiddleware(new FileTranscriptStore(__dirname + '/transcripts/')));
97 * ```
98 */
99export class FileTranscriptStore implements TranscriptStore {
100 private static readonly PageSize: number = 20;
101
102 private readonly rootFolder: string;
103
104 /**
105 * Creates an instance of FileTranscriptStore.
106 * @param folder Root folder where transcript will be stored.
107 */
108 constructor(folder: string) {
109 if (!folder) {
110 throw new Error('Missing folder.');
111 }
112
113 this.rootFolder = folder;
114 }
115
116 /**
117 * Log an activity to the transcript.
118 * @param activity Activity being logged.
119 */
120 public async logActivity(activity: Activity): Promise<void> {
121 if (!activity) {
122 throw new Error('activity cannot be null for logActivity()');
123 }
124
125 const conversationFolder: string = this.getTranscriptFolder(activity.channelId, activity.conversation.id);
126 const activityFileName: string = this.getActivityFilename(activity);
127
128 return this.saveActivity(activity, conversationFolder, activityFileName);
129 }
130
131 /**
132 * Get all activities associated with a conversation id (aka get the transcript).
133 * @param channelId Channel Id.
134 * @param conversationId Conversation Id.
135 * @param continuationToken (Optional) Continuation token to page through results.
136 * @param startDate (Optional) Earliest time to include.
137 */
138 public async getTranscriptActivities(
139 channelId: string,
140 conversationId: string,
141 continuationToken?: string,
142 startDate?: Date
143 ): Promise<PagedResult<Activity>> {
144 if (!channelId) {
145 throw new Error('Missing channelId');
146 }
147
148 if (!conversationId) {
149 throw new Error('Missing conversationId');
150 }
151
152 const pagedResult: PagedResult<Activity> = { items: [], continuationToken: undefined };
153 const transcriptFolder: string = this.getTranscriptFolder(channelId, conversationId);
154
155 const exists = await pathExists(transcriptFolder);
156 if (!exists) {
157 return pagedResult;
158 }
159
160 const transcriptFolderContents = await readdir(transcriptFolder);
161 const include = includeWhen((fileName) => !continuationToken || parse(fileName).name === continuationToken);
162 const items = transcriptFolderContents.filter(
163 (transcript) => transcript.endsWith('.json') && withDateFilter(startDate, transcript) && include(transcript)
164 );
165
166 pagedResult.items = await Promise.all(
167 items
168 .slice(0, FileTranscriptStore.PageSize)
169 .sort()
170 .map(async (activityFilename) => {
171 const json = await readFile(join(transcriptFolder, activityFilename), 'utf8');
172 return parseActivity(json);
173 })
174 );
175 const { length } = pagedResult.items;
176 if (length === FileTranscriptStore.PageSize && items[length]) {
177 pagedResult.continuationToken = parse(items[length]).name;
178 }
179 return pagedResult;
180 }
181
182 /**
183 * List all the logged conversations for a given channelId.
184 * @param channelId Channel Id.
185 * @param continuationToken (Optional) Continuation token to page through results.
186 */
187 public async listTranscripts(channelId: string, continuationToken?: string): Promise<PagedResult<TranscriptInfo>> {
188 if (!channelId) {
189 throw new Error('Missing channelId');
190 }
191
192 const pagedResult: PagedResult<TranscriptInfo> = { items: [], continuationToken: undefined };
193 const channelFolder: string = this.getChannelFolder(channelId);
194
195 const exists = await pathExists(channelFolder);
196 if (!exists) {
197 return pagedResult;
198 }
199 const channels = await readdir(channelFolder);
200 const items = channels.filter(includeWhen((di) => !continuationToken || di === continuationToken));
201 pagedResult.items = items
202 .slice(0, FileTranscriptStore.PageSize)
203 .map((i) => ({ channelId: channelId, id: i, created: null }));
204 const { length } = pagedResult.items;
205 if (length === FileTranscriptStore.PageSize && items[length]) {
206 pagedResult.continuationToken = items[length];
207 }
208
209 return pagedResult;
210 }
211
212 /**
213 * Delete a conversation and all of it's activities.
214 * @param channelId Channel Id where conversation took place.
215 * @param conversationId Id of the conversation to delete.
216 */
217 public async deleteTranscript(channelId: string, conversationId: string): Promise<void> {
218 if (!channelId) {
219 throw new Error('Missing channelId');
220 }
221
222 if (!conversationId) {
223 throw new Error('Missing conversationId');
224 }
225
226 const transcriptFolder: string = this.getTranscriptFolder(channelId, conversationId);
227
228 return remove(transcriptFolder);
229 }
230
231 /**
232 * Saves the [Activity](xref:botframework-schema.Activity) as a JSON file.
233 * @param activity The [Activity](xref:botframework-schema.Activity) to transcript.
234 * @param transcriptPath The path where the transcript will be saved.
235 * @param activityFilename The name for the file.
236 */
237 private async saveActivity(activity: Activity, transcriptPath: string, activityFilename: string): Promise<void> {
238 const json: string = JSON.stringify(activity, null, '\t');
239
240 const exists = await pathExists(transcriptPath);
241 if (!exists) {
242 await mkdirp(transcriptPath);
243 }
244 return writeFile(join(transcriptPath, activityFilename), json, 'utf8');
245 }
246
247 /**
248 * @private
249 */
250 private getActivityFilename(activity: Activity): string {
251 return `${getTicks(activity.timestamp)}-${this.sanitizeKey(activity.id)}.json`;
252 }
253
254 /**
255 * @private
256 */
257 private getChannelFolder(channelId: string): string {
258 return join(this.rootFolder, this.sanitizeKey(channelId));
259 }
260
261 /**
262 * @private
263 */
264 private getTranscriptFolder(channelId: string, conversationId: string): string {
265 return join(this.rootFolder, this.sanitizeKey(channelId), this.sanitizeKey(conversationId));
266 }
267
268 /**
269 * @private
270 */
271 private sanitizeKey(key: string): string {
272 return filenamify(key);
273 }
274}