-
Notifications
You must be signed in to change notification settings - Fork 5
/
dialogue.js
195 lines (184 loc) · 6.25 KB
/
dialogue.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
'use strict'
const _ = require('lodash')
const Base = require('./base')
const Path = require('./path')
/**
* Dialogues control which paths are available and for how long. Passing
* messages into a dialogue will match against the current path and route any
* replies.
*
* Where paths are self-replicating steps, the dialogue persists along the
* journey.
*
* @param {Response} res Hubot Response object
* @param {Object} [options] Key/val options for config
* @param {boolean} [options.sendReplies] Toggle replying/sending (prefix with "@user")
* @param {number} [options.timeout] Allowed time to reply (in miliseconds) before cancelling listeners
* @param {string} [options.timeoutText] What to send when timeout reached, set null to not send
* @param {string} [key] Key name for this instance
*
* @example <caption>listener sets up dialogue with user on match (10 second timeout)</caption>
* robot.hear(/hello/, (res) => {
* let dlg = new Dialogue(res, { timeout: 10000 })
* // ...proceed to add paths
* })
*/
class Dialogue extends Base {
constructor (res, ...args) {
super('dialogue', res.robot, ...args)
this.defaults({
sendReplies: false,
timeout: parseInt(process.env.DIALOGUE_TIMEOUT || 30000),
timeoutText: process.env.DIALOGUE_TIMEOUT_TEXT ||
'Timed out! Please start again.'
})
res.dialogue = this
this.res = res
this.Path = Path
this.path = null
this.ended = false
}
/**
* Shutdown and emit status (for scene to disengage participants).
*
* @return {boolean} Shutdown status, false if was already ended
*/
end () {
if (this.ended) return false
if (this.countdown !== undefined) this.clearTimeout()
if (this.path != null) {
this.log.debug(`Dialog ended ${this.path.closed ? '' : 'in'}complete`)
} else {
this.log.debug('Dialog ended before paths added')
}
this.emit('end', this.res)
this.ended = true
return this.ended
}
/**
* Send or reply with message as configured (@user reply or send to room).
*
* @param {string} strings Message strings
* @return {Promise} Resolves with result of send (respond middleware context)
*/
send (...strings) {
let sent
if (this.config.sendReplies) sent = this.res.reply(...strings)
else sent = this.res.send(...strings)
return sent.then((result) => {
this.emit('send', result.response, {
strings: result.strings,
method: result.method,
received: this.res
})
return result
})
}
/**
* Default timeout method sends message, unless null or method overriden.
*
* If given a method it will call that or can be reassigned as a new function.
*
* @param {Function} [override] - New function to call (optional)
*/
onTimeout (override) {
if (override != null) this.onTimeout = override
else if (this.config.timeoutText != null) this.send(this.config.timeoutText)
}
/**
* Stop countdown for matching dialogue branches.
*/
clearTimeout () {
clearTimeout(this.countdown)
delete this.countdown
}
/**
* Start (or restart) countdown for matching dialogue branches.
*
* Catches the onTimeout method because it can be overriden and may throw.
*/
startTimeout () {
if (this.countdown !== undefined) this.clearTimeout()
this.countdown = setTimeout(() => {
this.emit('timeout', this.res)
try {
this.onTimeout()
} catch (err) {
this.error(err)
}
delete this.countdown
return this.end()
}, this.config.timeout)
return this.countdown
}
/**
* Add a dialogue path, with branches to follow and a prompt (optional).
*
* Any new path added overwrites the previous. If a path isn't given a key but
* the parent dialogue has one, it will be given to the path.
*
* @param {string} [prompt] To send on path setup (e.g. presenting options)
* @param {array} [branches] Array of args for each branch, each containing:<br>
* - RegExp for listener<br>
* - String to send and/or<br>
* - Function to call on match
* @param {Object} [options] Key/val options for path
* @param {string} [key] Key name for this path
* @return {Promise} Resolves when sends complete or immediately
*
* @example
* let dlg = new Dialogue(res)
* let path = dlg.addPath('Turn left or right?', [
* [ /left/, 'Ok, going left!' ]
* [ /right/, 'Ok, going right!' ]
* ], 'which-way')
*/
addPath (...args) {
let result
if (_.isString(args[0])) result = this.send(args.shift())
this.path = new this.Path(this.robot, ...args)
if (!this.path.key && this.key) this.path.key = this.key
this.emit('path', this.path)
if (this.path.branches.length) this.startTimeout()
return Promise.resolve(result).then(() => this.path)
}
/**
* Add a branch to dialogue path, which is usually added first, but will be
* created if not.
*
* @param {RegExp} regex Matching pattern
* @param {string} [message] Message text for response on match
* @param {Function} [callback] Function called when matched
*/
addBranch (...args) {
if (this.path == null) this.addPath()
this.path.addBranch(...args)
this.startTimeout()
}
/**
* Process incoming message for match against path branches.
*
* If matched, restart timeout. If no additional paths or branches added (by
* matching branch handler), end dialogue.
*
* Overrides any prior response with current one.
*
* @param {Response} res Hubot Response object
* @return {Promise} Resolves when matched/catch handler complete
*
* @todo Test with handler using res.http/get to populate new path
*/
receive (res) {
if (this.ended || this.path == null) return Promise.resolve(false) // dialogue is over
this.log.debug(`Dialogue received ${this.res.message.text}`)
res.dialogue = this
this.res = res
return this.path.match(res).then((result) => {
this.log.debug(`Path match result: ${this.res.match} (handler returned: ${result})`)
if (this.res.match) this.clearTimeout()
if (this.path.closed) this.end()
return result
})
}
}
module.exports = Dialogue