1 | /**
|
2 | * @module botkit
|
3 | */
|
4 | /**
|
5 | * Copyright (c) Microsoft Corporation. All rights reserved.
|
6 | * Licensed under the MIT License.
|
7 | */
|
8 | import { Botkit, BotkitMessage } from './core';
|
9 | import { Activity, ConversationAccount, ConversationReference, ConversationParameters, TurnContext } from 'botbuilder';
|
10 | import { DialogTurnResult, Dialog } from 'botbuilder-dialogs';
|
11 |
|
12 | /**
|
13 | * A base class for a `bot` instance, an object that contains the information and functionality for taking action in response to an incoming message.
|
14 | * Note that adapters are likely to extend this class with additional platform-specific methods - refer to the adapter documentation for these extensions.
|
15 | */
|
16 | export class BotWorker {
|
17 | private _controller: Botkit;
|
18 | private _config: any;
|
19 |
|
20 | /**
|
21 | * Create a new BotWorker instance. Do not call this directly - instead, use [controller.spawn()](#spawn).
|
22 | * @param controller A pointer to the main Botkit controller
|
23 | * @param config An object typically containing { dialogContext, reference, context, activity }
|
24 | */
|
25 | public constructor(controller: Botkit, config) {
|
26 | this._controller = controller;
|
27 | this._config = {
|
28 | ...config
|
29 | };
|
30 | }
|
31 |
|
32 | /**
|
33 | * Get a reference to the main Botkit controller.
|
34 | */
|
35 | public get controller(): Botkit {
|
36 | return this._controller;
|
37 | }
|
38 |
|
39 | /**
|
40 | * Get a value from the BotWorker's configuration.
|
41 | *
|
42 | * ```javascript
|
43 | * let original_context = bot.getConfig('context');
|
44 | * await original_context.sendActivity('send directly using the adapter instead of Botkit');
|
45 | * ```
|
46 | *
|
47 | * @param {string} key The name of a value stored in the configuration
|
48 | * @returns {any} The value stored in the configuration (or null if absent)
|
49 | */
|
50 | public getConfig(key?: string): any {
|
51 | if (key) {
|
52 | return this._config[key];
|
53 | } else {
|
54 | return this._config;
|
55 | }
|
56 | }
|
57 |
|
58 | /**
|
59 | * Send a message using whatever context the `bot` was spawned in or set using [changeContext()](#changecontext) --
|
60 | * or more likely, one of the platform-specific helpers like
|
61 | * [startPrivateConversation()](../reference/slack.md#startprivateconversation) (Slack),
|
62 | * [startConversationWithUser()](../reference/twilio-sms.md#startconversationwithuser) (Twilio SMS),
|
63 | * and [startConversationWithUser()](../reference/facebook.md#startconversationwithuser) (Facebook Messenger).
|
64 | * Be sure to check the platform documentation for others - most adapters include at least one.
|
65 | *
|
66 | * Simple use in event handler (acts the same as bot.reply)
|
67 | * ```javascript
|
68 | * controller.on('event', async(bot, message) => {
|
69 | *
|
70 | * await bot.say('I received an event!');
|
71 | *
|
72 | * });
|
73 | * ```
|
74 | *
|
75 | * Use with a freshly spawned bot and bot.changeContext:
|
76 | * ```javascript
|
77 | * let bot = controller.spawn(OPTIONS);
|
78 | * bot.changeContext(REFERENCE);
|
79 | * bot.say('ALERT! I have some news.');
|
80 | * ```
|
81 | *
|
82 | * Use with multi-field message object:
|
83 | * ```javascript
|
84 | * controller.on('event', async(bot, message) => {
|
85 | * bot.say({
|
86 | * text: 'I heard an event',
|
87 | * attachments: [
|
88 | * title: message.type,
|
89 | * text: `The message was of type ${ message.type }`,
|
90 | * // ...
|
91 | * ]
|
92 | * });
|
93 | * });
|
94 | * ```
|
95 | *
|
96 | * @param message A string containing the text of a reply, or more fully formed message object
|
97 | * @returns Return value will contain the results of the send action, typically `{id: <id of message>}`
|
98 | */
|
99 | public async say(message: Partial<BotkitMessage> | string): Promise<any> {
|
100 | return new Promise((resolve, reject) => {
|
101 | const activity = this.ensureMessageFormat(message);
|
102 |
|
103 | this._controller.middleware.send.run(this, activity, async (err, bot, activity) => {
|
104 | if (err) {
|
105 | return reject(err);
|
106 | }
|
107 | resolve(await this.getConfig('context').sendActivity(activity));
|
108 | });
|
109 | });
|
110 | };
|
111 |
|
112 | /**
|
113 | * Reply to an incoming message.
|
114 | * Message will be sent using the context of the source message, which may in some cases be different than the context used to spawn the bot.
|
115 | *
|
116 | * Note that like [bot.say()](#say), `reply()` can take a string or a message object.
|
117 | *
|
118 | * ```javascript
|
119 | * controller.on('event', async(bot, message) => {
|
120 | *
|
121 | * await bot.reply(message, 'I received an event and am replying to it.');
|
122 | *
|
123 | * });
|
124 | * ```
|
125 | *
|
126 | * @param src An incoming message, usually passed in to a handler function
|
127 | * @param resp A string containing the text of a reply, or more fully formed message object
|
128 | * @returns Return value will contain the results of the send action, typically `{id: <id of message>}`
|
129 | */
|
130 | public async reply(src: Partial<BotkitMessage>, resp: Partial<BotkitMessage> | string): Promise<any> {
|
131 | let activity = this.ensureMessageFormat(resp);
|
132 |
|
133 | // Get conversation reference from src
|
134 | const reference = TurnContext.getConversationReference(src.incoming_message);
|
135 |
|
136 | activity = TurnContext.applyConversationReference(activity, reference);
|
137 |
|
138 | return this.say(activity);
|
139 | }
|
140 |
|
141 | /**
|
142 | * Begin a pre-defined dialog by specifying its id. The dialog will be started in the same context (same user, same channel) in which the original incoming message was received.
|
143 | * [See "Using Dialogs" in the core documentation.](../index.md#using-dialogs)
|
144 | *
|
145 | * ```javascript
|
146 | * controller.hears('hello', 'message', async(bot, message) => {
|
147 | * await bot.beginDialog(GREETINGS_DIALOG);
|
148 | * });
|
149 | * ```
|
150 | * @param id id of dialog
|
151 | * @param options object containing options to be passed into the dialog
|
152 | */
|
153 | public async beginDialog(id: string, options?: any): Promise<void> {
|
154 | if (this._config.dialogContext) {
|
155 | await this._config.dialogContext.beginDialog(id + ':botkit-wrapper', {
|
156 | user: this.getConfig('context').activity.from.id,
|
157 | channel: this.getConfig('context').activity.conversation.id,
|
158 | ...options
|
159 | });
|
160 |
|
161 | // make sure we save the state change caused by the dialog.
|
162 | // this may also get saved again at end of turn
|
163 | await this._controller.saveState(this);
|
164 | } else {
|
165 | throw new Error('Call to beginDialog on a bot that did not receive a dialogContext during spawn');
|
166 | }
|
167 | }
|
168 |
|
169 | /**
|
170 | * Cancel any and all active dialogs for the current user/context.
|
171 | */
|
172 | public async cancelAllDialogs(): Promise<DialogTurnResult> {
|
173 | if (this._config.dialogContext) {
|
174 | return this._config.dialogContext.cancelAllDialogs();
|
175 | }
|
176 | }
|
177 |
|
178 | /**
|
179 | * Get a reference to the active dialog
|
180 | * @returns a reference to the active dialog or undefined if no dialog is active
|
181 | */
|
182 | public getActiveDialog(): Dialog | undefined {
|
183 | return this.getConfig('dialogContext').activeDialog;
|
184 | }
|
185 |
|
186 | /**
|
187 | * Check if any dialog is active or not
|
188 | * @returns true if there is an active dialog, otherwise false
|
189 | */
|
190 | public hasActiveDialog(): boolean {
|
191 | return !!this.getActiveDialog();
|
192 | }
|
193 |
|
194 | /**
|
195 | * Check to see if a given dialog is currently active in the stack
|
196 | * @param id The id of a dialog to look for in the dialog stack
|
197 | * @returns true if dialog with id is located anywhere in the dialog stack
|
198 | */
|
199 | public isDialogActive(id: string): boolean {
|
200 | if (this.getConfig('dialogContext').stack.length) {
|
201 | return (this.getConfig('dialogContext').stack.filter((d) => d.id === id).length > 0);
|
202 | }
|
203 | return false;
|
204 | }
|
205 |
|
206 | /**
|
207 | * Replace any active dialogs with a new a pre-defined dialog by specifying its id. The dialog will be started in the same context (same user, same channel) in which the original incoming message was received.
|
208 | * [See "Using Dialogs" in the core documentation.](../index.md#using-dialogs)
|
209 | *
|
210 | * ```javascript
|
211 | * controller.hears('hello', 'message', async(bot, message) => {
|
212 | * await bot.replaceDialog(GREETINGS_DIALOG);
|
213 | * });
|
214 | * ```
|
215 | * @param id id of dialog
|
216 | * @param options object containing options to be passed into the dialog
|
217 | */
|
218 | public async replaceDialog(id: string, options?: any): Promise<void> {
|
219 | if (this._config.dialogContext) {
|
220 | await this._config.dialogContext.replaceDialog(id + ':botkit-wrapper', {
|
221 | user: this.getConfig('context').activity.from.id,
|
222 | channel: this.getConfig('context').activity.conversation.id,
|
223 | ...options
|
224 | });
|
225 |
|
226 | // make sure we save the state change caused by the dialog.
|
227 | // this may also get saved again at end of turn
|
228 | await this._controller.saveState(this);
|
229 | } else {
|
230 | throw new Error('Call to beginDialog on a bot that did not receive a dialogContext during spawn');
|
231 | }
|
232 | }
|
233 |
|
234 | /**
|
235 | * Alter the context in which a bot instance will send messages.
|
236 | * Use this method to create or adjust a bot instance so that it can send messages to a predefined user/channel combination.
|
237 | *
|
238 | * ```javascript
|
239 | * // get the reference field and store it.
|
240 | * const saved_reference = message.reference;
|
241 | *
|
242 | * // later on...
|
243 | * let bot = await controller.spawn();
|
244 | * bot.changeContext(saved_reference);
|
245 | * bot.say('Hello!');
|
246 | * ```
|
247 | *
|
248 | * @param reference A [ConversationReference](https://docs.microsoft.com/en-us/javascript/api/botframework-schema/conversationreference?view=botbuilder-ts-latest), most likely captured from an incoming message and stored for use in proactive messaging scenarios.
|
249 | */
|
250 | public async changeContext(reference: Partial<ConversationReference>): Promise<BotWorker> {
|
251 | // change context of outbound activities to use this new address
|
252 | this._config.reference = reference;
|
253 |
|
254 | // Create an activity using this reference
|
255 | const activity = TurnContext.applyConversationReference(
|
256 | { type: 'message' },
|
257 | reference,
|
258 | true
|
259 | );
|
260 |
|
261 | // create a turn context
|
262 | const turnContext = new TurnContext(this.getConfig('adapter'), activity as Activity);
|
263 |
|
264 | // create a new dialogContext so beginDialog works.
|
265 | const dialogContext = await this._controller.dialogSet.createContext(turnContext);
|
266 |
|
267 | this._config.context = turnContext;
|
268 | this._config.dialogContext = dialogContext;
|
269 | this._config.activity = activity;
|
270 |
|
271 | return this;
|
272 | }
|
273 |
|
274 | public async startConversationWithUser(reference: any): Promise<void> {
|
275 | // this code is mostly copied from BotFrameworkAdapter.createConversation
|
276 |
|
277 | if (!reference.serviceUrl) { throw new Error('bot.startConversationWithUser(): missing serviceUrl.'); }
|
278 |
|
279 | // Create conversation
|
280 | const parameters: ConversationParameters = { bot: reference.bot, members: [reference.user], isGroup: false, activity: null, channelData: null };
|
281 | const client = this.getConfig('adapter').createConnectorClient(reference.serviceUrl);
|
282 |
|
283 | // Mix in the tenant ID if specified. This is required for MS Teams.
|
284 | if (reference.conversation && reference.conversation.tenantId) {
|
285 | // Putting tenantId in channelData is a temporary solution while we wait for the Teams API to be updated
|
286 | parameters.channelData = { tenant: { id: reference.conversation.tenantId } };
|
287 |
|
288 | // Permanent solution is to put tenantId in parameters.tenantId
|
289 | parameters.tenantId = reference.conversation.tenantId;
|
290 | }
|
291 |
|
292 | const response = await client.conversations.createConversation(parameters);
|
293 |
|
294 | // Initialize request and copy over new conversation ID and updated serviceUrl.
|
295 | const request: Partial<Activity> = TurnContext.applyConversationReference(
|
296 | { type: 'event', name: 'createConversation' },
|
297 | reference,
|
298 | true
|
299 | );
|
300 |
|
301 | const conversation: ConversationAccount = {
|
302 | // fallback to existing conversation id because Emulator will respond without a response.id AND needs to stay in same channel.
|
303 | // This should be fixed by Emulator. https://github.com/microsoft/BotFramework-Emulator/issues/2097
|
304 | id: response.id || reference.conversation.id,
|
305 | isGroup: false,
|
306 | conversationType: null,
|
307 | tenantId: null,
|
308 | name: null
|
309 | };
|
310 | request.conversation = conversation;
|
311 |
|
312 | if (response.serviceUrl) { request.serviceUrl = response.serviceUrl; }
|
313 |
|
314 | // Create context and run middleware
|
315 | const turnContext: TurnContext = this.getConfig('adapter').createContext(request);
|
316 |
|
317 | // create a new dialogContext so beginDialog works.
|
318 | const dialogContext = await this._controller.dialogSet.createContext(turnContext);
|
319 |
|
320 | this._config.context = turnContext;
|
321 | this._config.dialogContext = dialogContext;
|
322 | this._config.activity = request;
|
323 | }
|
324 |
|
325 | /**
|
326 | * Take a crudely-formed Botkit message with any sort of field (may just be a string, may be a partial message object)
|
327 | * and map it into a beautiful BotFramework Activity.
|
328 | * Any fields not found in the Activity definition will be moved to activity.channelData.
|
329 | * @params message a string or partial outgoing message object
|
330 | * @returns a properly formed Activity object
|
331 | */
|
332 | public ensureMessageFormat(message: Partial<BotkitMessage> | string): Partial<Activity> {
|
333 | if (typeof (message) === 'string') {
|
334 | return {
|
335 | type: 'message',
|
336 | text: message,
|
337 | channelData: {}
|
338 | };
|
339 | } else {
|
340 | // set up a base message activity
|
341 | // https://docs.microsoft.com/en-us/javascript/api/botframework-schema/activity?view=botbuilder-ts-latest
|
342 | const activity: Partial<Activity> = {
|
343 | type: message.type || 'message',
|
344 | text: message.text,
|
345 |
|
346 | action: message.action,
|
347 | attachmentLayout: message.attachmentLayout,
|
348 | attachments: message.attachments,
|
349 |
|
350 | channelData: {
|
351 | ...message.channelData
|
352 | },
|
353 | channelId: message.channelId,
|
354 | code: message.code,
|
355 | conversation: message.conversation,
|
356 |
|
357 | deliveryMode: message.deliveryMode,
|
358 | entities: message.entities,
|
359 | expiration: message.expiration,
|
360 | from: message.from,
|
361 | historyDisclosed: message.historyDisclosed,
|
362 | id: message.id,
|
363 | importance: message.importance,
|
364 | inputHint: message.inputHint,
|
365 | label: message.label,
|
366 | listenFor: message.listenFor,
|
367 | locale: message.locale,
|
368 | localTimestamp: message.localTimestamp,
|
369 | localTimezone: message.localTimezone,
|
370 | membersAdded: message.membersAdded,
|
371 | membersRemoved: message.membersRemoved,
|
372 | name: message.name,
|
373 | reactionsAdded: message.reactionsAdded,
|
374 | reactionsRemoved: message.reactionsRemoved,
|
375 | recipient: message.recipient,
|
376 | relatesTo: message.relatesTo,
|
377 | replyToId: message.replyToId,
|
378 | semanticAction: message.semanticAction,
|
379 | serviceUrl: message.serviceUrl,
|
380 | speak: message.speak,
|
381 | suggestedActions: message.suggestedActions,
|
382 | summary: message.summary,
|
383 | textFormat: message.textFormat,
|
384 | textHighlights: message.textHighlights,
|
385 | timestamp: message.timestamp,
|
386 | topicName: message.topicName,
|
387 | value: message.value,
|
388 | valueType: message.valueType
|
389 | };
|
390 |
|
391 | // Now, copy any additional fields not in the activity into channelData
|
392 | // This way, any fields added by the developer to the root object
|
393 | // end up in the approved channelData location.
|
394 | for (const key in message) {
|
395 | if (key !== 'channelData' && !Object.prototype.hasOwnProperty.call(activity, key)) {
|
396 | activity.channelData[key] = message[key];
|
397 | }
|
398 | }
|
399 | return activity;
|
400 | }
|
401 | }
|
402 |
|
403 | /**
|
404 | * Set the http response status code for this turn
|
405 | *
|
406 | * ```javascript
|
407 | * controller.on('event', async(bot, message) => {
|
408 | * // respond with a 500 error code for some reason!
|
409 | * bot.httpStatus(500);
|
410 | * });
|
411 | * ```
|
412 | *
|
413 | * @param status {number} a valid http status code like 200 202 301 500 etc
|
414 | */
|
415 | public httpStatus(status: number): void {
|
416 | this.getConfig('context').turnState.set('httpStatus', status);
|
417 | }
|
418 |
|
419 | /**
|
420 | * Set the http response body for this turn.
|
421 | * Use this to define the response value when the platform requires a synchronous response to the incoming webhook.
|
422 | *
|
423 | * Example handling of a /slash command from Slack:
|
424 | * ```javascript
|
425 | * controller.on('slash_command', async(bot, message) => {
|
426 | * bot.httpBody('This is a reply to the slash command.');
|
427 | * })
|
428 | * ```
|
429 | *
|
430 | * @param body (any) a value that will be returned as the http response body
|
431 | */
|
432 | public httpBody(body: any): void {
|
433 | this.getConfig('context').turnState.set('httpBody', body);
|
434 | }
|
435 | }
|
436 |
|
\ | No newline at end of file |