-
Notifications
You must be signed in to change notification settings - Fork 5
/
transcript.js
265 lines (248 loc) · 9.59 KB
/
transcript.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
'use strict'
const _ = require('lodash')
const Base = require('./base')
_.mixin({
'hasKeys': (obj, keys) => _.size(_.difference(keys, _.keys(obj))) === 0
})
_.mixin({
'pickHas': (obj, pickKeys) => _.omitBy(_.pick(obj, pickKeys), _.isUndefined)
})
/**
* Transcripts record conversation events, including meta about the user,
* message and current module.
*
* Transcripts are searchable, to provide context from conversation history with
* a given user, or based on any other attribute, such as listener ID.
*
* Different instances can be configured to record an overview or drilled down
* analytics for a specific module’s interactions using its key.
*
* @param {Robot} robot Hubot Robot instance
* @param {Object} [options] Key/val options for config
* @param {Object} [options.save] Store records in hubot brain
* @param {array} [options.events] Event names to record
* @param {array} [options.responseAtts] Response keys or paths to record
* @param {array} [options.instanceAtts] Module instance keys or paths to record
* @param {string} [key] Key name for this instance
*
* @todo Add config to record response middleware context including listener ID
*
* @example <caption>transcript to record room name when match emitted</caption>
* let matchRecordRooms = new Transcript(robot, {
* responseAtts: ['message.room']
* events: ['match']
* })
* // does not start recording until calling one of the record methods, like:
* matchRecordRooms.recordAll()
*/
class Transcript extends Base {
constructor (...args) {
super('transcript', ...args)
this.defaults({
save: true,
events: ['match', 'mismatch', 'catch', 'send'],
instanceAtts: ['name', 'key', 'id'],
responseAtts: ['match'],
messageAtts: ['user.id', 'user.name', 'room', 'text']
})
if (this.config.instanceAtts != null) _.castArray(this.config.instanceAtts)
if (this.config.responseAtts != null) _.castArray(this.config.responseAtts)
if (this.config.messageAtts != null) _.castArray(this.config.messageAtts)
if (this.config.save) {
if (!this.robot.brain.get('transcripts')) {
this.robot.brain.set('transcripts', [])
}
this.records = this.robot.brain.get('transcripts')
}
if (this.records == null) this.records = []
}
/**
* Record given event details in array, save to hubot brain if configured to.
*
* Events emitted by Playbook always include module instance as first param.
*
* This is only called internally on watched events after running `recordAll`,
* `recordDialogue`, `recordScene` or `recordDirector`
*
* @param {string} event The event name
* @param {*} args... Args passed with the event, usually consists of:<br>
* - Playbook module instance<br>
* - Hubot response object<br>
* - other additional (special context) arguments
*/
recordEvent (event, ...args) {
let instance, response
if (_.hasKeys(args[0], ['name', 'id', 'config'])) instance = args.shift()
if (_.hasKeys(args[0], ['robot', 'message'])) response = args.shift()
const record = {time: _.now(), event}
if (this.key != null) record.key = this.key
if ((instance != null) && (this.config.instanceAtts != null)) {
record.instance = _.pickHas(instance, this.config.instanceAtts)
}
if ((response != null) && (this.config.responseAtts != null)) {
record.response = _.pickHas(response, this.config.responseAtts)
}
if ((response != null) && (this.config.messageAtts != null)) {
record.message = _.pickHas(response.message, this.config.messageAtts)
}
if (!_.isEmpty(args)) {
if (event === 'send' && args[0].strings) record.strings = args[0].strings
else record.other = args
}
this.records.push(record)
this.emit('record', record)
}
/**
* Record events emitted by all Playbook modules and/or the robot itself
* (still only applies to configured event types).
*/
recordAll () {
_.castArray(this.config.events).map((event) =>
this.robot.on(event, (...args) =>
this.recordEvent(event, ...args))
)
}
/**
* @todo Re-instate `recordListener` when regular listeners emit event with
* context containing options and ID.
*/
/*
recordListener (context) {
}
*/
/**
* Record events emitted by a given dialogue and it's path/s.
*
* Whenever a path is added to a dialogue, event handlers are added on the
* path for the configured events.
*
* @param {Dialogue} dialogue The Dialogue instance
*/
recordDialogue (dialogue) {
let dialogueEvents = _.intersection(this.config.events, ['end', 'send', 'timeout', 'path'])
let pathEvents = _.intersection(this.config.events, ['match', 'catch', 'mismatch'])
dialogueEvents.map((event) => {
dialogue.on(event, (...args) => this.recordEvent(event, dialogue, ...args))
})
dialogue.on('path', (path) => {
pathEvents.map((event) => {
path.on(event, (...args) => this.recordEvent(event, path, ...args))
})
})
}
/**
* Record events emitted by a given scene and any dialogue it enters, captures
* configured events from scene and its created dialogues and paths.
*
* @param {Scene} scene The Scnee instance
*/
recordScene (scene) {
scene.on('enter', (res) => {
if (_.includes(this.config.events, 'enter')) this.recordEvent('enter', scene, res)
this.recordDialogue(res.dialogue)
})
scene.on('exit', (...args) => {
if (_.includes(this.config.events, 'exit')) this.recordEvent('exit', scene, ...args)
})
}
/**
* Record allow/deny events emitted by a given director. Ignores configured
* events because director has distinct events.
*
* @param {Director} director The Director instance
*/
recordDirector (director) {
director.on('allow', (...args) => this.recordEvent('allow', director, ...args))
director.on('deny', (...args) => this.recordEvent('deny', director, ...args))
}
/**
* Filter records matching a subset, e.g. user name or instance key.
*
* Optionally return the whole record or values for a given key.
*
* @param {Object} subsetMatch Key/s:value/s to match (accepts path key)
* @param {string} [returnPath] Key or path within record to return
* @return {array} Whole records or selected values found
*
* @example
* transcript.findRecords({
* message: { user: { name: 'jon' } }
* })
* // returns array of recorded event objects
*
* transcript.findRecords({
* message: { user: { name: 'jon' } }
* }, 'message.text')
* // returns array of message text attribute from recroded events
*/
findRecords (subsetMatch, returnPath) {
let found = _.filter(this.records, subsetMatch)
if (returnPath == null) return found
let foundAtPath = found.map((record) => _(record).at(returnPath).head())
_.remove(foundAtPath, _.isUndefined)
return foundAtPath
}
/**
* Alias for findRecords for just response match attributes with a given
* instance key, useful for simple lookups of information provided by users
* within a specific conversation.
*
* @param {string} instanceKey Recorded instance key to lookup
* @param {string} [userId] Filter results by a user ID
* @param {integer} [captureGroup] Filter match by regex capture group subset
* @return {array} Contains full match or just capture group
*
* @example <caption>find answers from a specific dialogue path</caption>
* const transcript = new Transcript(robot)
* robot.hear(/color/, (res) => {
* let favColor = new Dialogue(res, 'fav-color')
* transcript.recordDialogue(favColor)
* favColor.addPath([
* [ /my favorite color is (.*)/, 'duly noted' ]
* ])
* favColor.receive(res)
* })
* robot.respond(/what is my favorite color/, (res) => {
* let colorMatches = transcript.findKeyMatches('fav-color', 1)
* # ^ word we're looking for from capture group is at index: 1
* if (colorMatches.length) {
* res.reply(`I remember, it's ${ colorMatches.pop() }`)
* } else {
* res.reply("I don't know!?")
* }
* })
*
*/
findKeyMatches (instanceKey, ...args) {
let userId = (_.isString(args[0])) ? args.shift() : null
let captureGroup = (_.isInteger(args[0])) ? args.shift() : null
let subset = { instance: { key: instanceKey } }
let path = 'response.match'
if (userId != null) _.extend(subset, { message: { user: { id: userId } } })
if (captureGroup != null) path += `[${captureGroup}]`
return this.findRecords(subset, path)
}
/**
* Alias for findRecords for just response match attributes with a given
* listener ID, useful for lookups of matches from a specific listener.
*
* @param {string} listenerId Listener ID match to lookup
* @param {string} [userId] Filter results by a user ID
* @param {integer} [captureGroup] Filter match by regex capture group subset
* @return {array} Contains full match or just capture group
*
* @todo Re-instate `findIdMatches` when `recordListener` is funtional
*/
/*
findIdMatches (listenerId, ...args) {
let userId = (_.isString(args[0])) ? args.shift() : null
let captureGroup = (_.isInteger(args[0])) ? args.shift() : null
let subset = { listener: { options: { id: listenerId } } }
let path = 'response.match'
if (userId != null) subset.message = { user: { id: userId } }
if (captureGroup != null) path += `[${captureGroup}]`
return this.findRecords(subset, path)
}
*/
}
module.exports = Transcript