1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 | import { join, parse } from 'path';
|
9 | import { mkdirp, pathExists, readdir, readFile, remove, writeFile } from 'fs-extra';
|
10 | import { Activity, PagedResult, TranscriptInfo, TranscriptStore } from 'botbuilder-core';
|
11 |
|
12 |
|
13 | const filenamify = require('filenamify');
|
14 |
|
15 |
|
16 |
|
17 |
|
18 |
|
19 | const epochTicks = 621355968000000000;
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 | const ticksPerMillisecond = 10000;
|
26 |
|
27 |
|
28 |
|
29 |
|
30 |
|
31 | function getTicks(timestamp: Date): string {
|
32 | const ticks: number = epochTicks + timestamp.getTime() * ticksPerMillisecond;
|
33 |
|
34 | return ticks.toString(16);
|
35 | }
|
36 |
|
37 |
|
38 |
|
39 |
|
40 |
|
41 | function 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 |
|
49 |
|
50 |
|
51 |
|
52 | function 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 |
|
63 |
|
64 |
|
65 | function 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 |
|
75 |
|
76 |
|
77 | function 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 |
|
86 |
|
87 |
|
88 |
|
89 |
|
90 |
|
91 |
|
92 |
|
93 |
|
94 |
|
95 |
|
96 |
|
97 |
|
98 |
|
99 | export class FileTranscriptStore implements TranscriptStore {
|
100 | private static readonly PageSize: number = 20;
|
101 |
|
102 | private readonly rootFolder: string;
|
103 |
|
104 | |
105 |
|
106 |
|
107 |
|
108 | constructor(folder: string) {
|
109 | if (!folder) {
|
110 | throw new Error('Missing folder.');
|
111 | }
|
112 |
|
113 | this.rootFolder = folder;
|
114 | }
|
115 |
|
116 | |
117 |
|
118 |
|
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 |
|
133 |
|
134 |
|
135 |
|
136 |
|
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 |
|
184 |
|
185 |
|
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 |
|
214 |
|
215 |
|
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 |
|
233 |
|
234 |
|
235 |
|
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 |
|
249 |
|
250 | private getActivityFilename(activity: Activity): string {
|
251 | return `${getTicks(activity.timestamp)}-${this.sanitizeKey(activity.id)}.json`;
|
252 | }
|
253 |
|
254 | |
255 |
|
256 |
|
257 | private getChannelFolder(channelId: string): string {
|
258 | return join(this.rootFolder, this.sanitizeKey(channelId));
|
259 | }
|
260 |
|
261 | |
262 |
|
263 |
|
264 | private getTranscriptFolder(channelId: string, conversationId: string): string {
|
265 | return join(this.rootFolder, this.sanitizeKey(channelId), this.sanitizeKey(conversationId));
|
266 | }
|
267 |
|
268 | |
269 |
|
270 |
|
271 | private sanitizeKey(key: string): string {
|
272 | return filenamify(key);
|
273 | }
|
274 | }
|