diff --git a/README.md b/README.md index 8ea19f5..8c979c3 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,14 @@ npx speedyhelper setup ## In a nutshell -Speedybot is a "toolkit" to take you from zero to a non-trivial bot as quickly as possible. Dive in immediately and focus on the stuff that matters-- features, workflows/integrations, content, & interactivity, etc +Speedybot is a "toolkit" to take you from zero to a non-trivial bot as quickly as possible w/ a buttery-smooth developer experience (live-reload, intelli-sense, etc). Dive in immediately and focus on the stuff that matters-- content, workflows/integrations, processing files, etc Speedybot instruments on top of the incredibly useful **[webex-node-bot-framework](https://github.com/WebexSamples/webex-node-bot-framework)** and steps through the fastest path to a working bot and provides some convenience features -Even if you don't use all of speedybot's features, if nothing else there are several helper utillties that are useful for crafting a rich conversation agent-- more details **[here](https://github.com/valgaze/speedybot/blob/master/docs/util.md)** Speedybot has one required dependency +Speedybot also makes it easy to get up and running fast without worrying about tunneling, webhooks, etc. + +Speedybot can also give your bot $uperpowers-- **[see here for details on $uperpowers](https://github.com/valgaze/speedybot/blob/master/docs/superpowers.md)** + ## Adding a new chat handler @@ -55,6 +58,124 @@ Example handler: } ``` +## $uperpowers + +Speedybot can also give your bot $uperpowers-- **[see here for details on $uperpowers](https://github.com/valgaze/speedybot/blob/master/docs/superpowers.md)** + +
$uperpowers sample + +```ts +import { $ } from 'speedybot' + +export default { + keyword: ['$', '$uperpowers', '$uperpower', '$superpower'], + async handler(bot, trigger) { + + // ## 0) Wrap the bot object in $ to give it $uperpowers, ex $(bot) + const $bot = $(bot) + + // ## 1) Contexts: set, remove, and list + // Contexts persist between "turns" of chat + // Note: contexts can optionally store data + // If you just need to stash information attached to a user, see "$(bot).saveData" below + await $bot.saveContext('mycontext1') + await $bot.saveContext('mycontext2', { data: new Date().toISOString()}) + + const mycontext2 = await $bot.getContext('mycontext2') + $bot.log('# mycontext2', mycontext2) // { data: '2021-11-05T05:03:58.755Z'} + + // Contexts: list active contexts + const allContexts = await $bot.getAllContexts() // ['mycontext1', 'mycontext2'] + bot.say(`Contexts: ${JSON.stringify(allContexts)}`) + + // Contexts: check if context is active + const isActive = await $bot.contextActive('mycontext1') + $bot.log(`mycontext1 is active, ${isActive}`) // 'mycontext1 is active, true' + + // Contexts: remove context + await $bot.deleteContext('mycontext1') + + const isStillActive = await $bot.contextActive('mycontext1') + $bot.log(`mycontext1 is active, ${isStillActive}`) // 'mycontext1 is active, false' + + // ## 2) Helpers to add variation and rich content + + // sendRandom: Sends a random string from a list + $bot.sendRandom(['Hey!','Hello!!','Hiya!']) + + // sendTemplate: like sendRandom but replace $[variable_name] with a value + const utterances = ['Hey how are you $[name]?', `$[name]! How's it going?`, '$[name]'] + const template = { name: 'Joey'} + $bot.sendTemplate(utterances, template) + + // sendURL: Sends a URL in a clickable card + $bot.sendURL('https://www.youtube.com/watch?v=3GwjfUFyY6M', 'Go Celebrate') + + // snippet: Generate a snippet that will render data in markdown-friendly format + const JSONData = {a: 1, b:2, c:3, d:4} + + $bot.sendSnippet(JSONData, `**Here's some JSON, you'll love it**`) // send to room + + // Snippet to a specifc room or specific email + // const snippet = $bot.snippet(JSONData) + // $bot.send({markdown: snippet, roomId:trigger.message.roomId, text: 'Your client does not render markdown :('}) // send to a specific room + // $bot.send({markdown: snippet, toPersonEmail:'joe@joe.com', text: 'Your client does not render markdown :('}) // send to a specific person + + + // ## 3) Save data between conversation "runs" + + interface SpecialUserData { + specialValue: string; + userId: String; + } + const specialData:SpecialUserData = { + specialValue: Math.random().toString(36).slice(2), + userId: trigger.personId, + } + + // Save the data + await $bot.saveData('userData', specialData) + + // Retrieve the data (returns null if does not exist) + const dataRes = await $bot.getData('userData') + + if (dataRes) { + // These are now "typed" + const theValue = dataRes.specialValue + const id = dataRes.userId + $bot.log(`Your specal value was ${theValue} and your id is ${id}`) + + // destroy data + $bot.deleteData('userData') + } + + // ## 4) Integrate with 3rd-parties: $bot.get, $bot.post, etc + + // ex. get external data + // Opts are axios request config (for bearer tokens, proxies, unique config, etc) + const res = await $bot.get('https://randomuser.me/api/') + bot.say({markdown: $bot.snippet(res.data)}) + + // ## 4) Files & attachments + + // Send a local file + // Provide a path/filename, will be attached to message + $bot.sendFile(__dirname, 'assets', 'speedybot.pdf') + + // Send a publicly accessible URL file + // Supported filetypes: ['doc', 'docx' , 'xls', 'xlsx', 'ppt', 'pptx', 'pdf', 'jpg', 'jpeg', 'bmp', 'gif', 'png'] + $bot.sendDataFromUrl('https://drive.google.com/uc?export=download&id=1VI4I4pYVVdMnB6YOQuSejVcrSwN0cotd') + + // // experimental (fileystem write): send arbitrary JSON back as a file + // $bot.sendDataAsFile(JSON.stringify({a:1,b:2}), '.json') + + // For an example involving parse'able spreadsheets (.xlsx), see here: https://github.com/valgaze/speedybot-superpowers + }, + helpText: 'A demo of $uperpowers' +} +``` +
+ ## Special keywords There are a few "special" keywords you can use to "listen" to special events: @@ -74,7 +195,7 @@ There are a few "special" keywords you can use to "listen" to special events: ex. Tell the bot "sendcard" to get a card, type into the card & tap submit, catch submission using *<@submit>* and echo back to user ```ts -import { Card } from 'speedybot' +import { SpeedyCard } from 'speedybot' export default [{ keyword: '<@submit>', handler(bot, trigger) { diff --git a/docs/assets/healthcheck.gif b/docs/assets/healthcheck.gif new file mode 100644 index 0000000..387df06 Binary files /dev/null and b/docs/assets/healthcheck.gif differ diff --git a/docs/assets/speedybot.pdf b/docs/assets/speedybot.pdf new file mode 100644 index 0000000..0722191 Binary files /dev/null and b/docs/assets/speedybot.pdf differ diff --git a/docs/assets/speedybot_superpowers.gif b/docs/assets/speedybot_superpowers.gif new file mode 100644 index 0000000..f52aac6 Binary files /dev/null and b/docs/assets/speedybot_superpowers.gif differ diff --git a/docs/how-to.md b/docs/how-to.md index f5f5f5d..bfb446d 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -56,25 +56,23 @@ export default handlers = [ }, { keyword: '<@fileupload>', - handler(bot, trigger) { - const files = trigger.message.files || [] - - bot.say(`(**Note:** These files are not publicly accessible)\n ${files.length > 1 ? 'These files were' : 'This file was'} uploaded successfully!`) - - files.forEach(async (file, idx) => { - // Note the URL here will fail for user because they require an Authorization - - await bot.say(`${idx + 1}: ${file}`) - }) - - if (files.length === 1) { - bot.dm(trigger.person.id, `Sending a file back at ya!`) - bot.dm(trigger.person.id, { file: 'https://camo.githubusercontent.com/b846bfa57dd26af4e1526abe1173e0b332b75af5d642564b2ab1d0c12a482290/68747470733a2f2f692e696d6775722e636f6d2f56516f5866486e2e676966' }) + async handler(bot, trigger) { + const [file] = trigger.message.files + const fileData = await $(bot).getFile(file) // "getFile" $uperpower + const {extension, type, fileName, data, markdownSnippet} = fileData + + bot.say(`You uploaded '${fileName}'`) + + if (type === 'application/json') { + bot.say({markdown: markdownSnippet}) + } else if (type === 'text/plain' || type === 'text/csv') { + bot.say(data) + console.log("#", data) + } else { + bot.say(`// todo: add *.${extension} support`) } - // ex. From here, you could download the content of the files (with an Authorization header) - // Pass onto another service for analysis/etc }, - helpText: `A special handler that fires anytime a user submits a file` + helpText: `A special handler that fires anytime a user submits data (you can only trigger this handler by tapping Submit in a card)` } ] ``` diff --git a/docs/resources.md b/docs/resources.md index 0ec83e2..3d2f3e1 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -8,6 +8,8 @@ ### Useful reading +- speedybot $uperpowers: **[docs/superpowers.md](./superpowers.md)** + - https://developer.webex.com/blog/from-zero-to-webex-teams-chatbot-in-15-minutes - https://developer.webex.com/blog/introducing-the-webex-teams-bot-framework-for-node-js @@ -17,3 +19,5 @@ - https://developer.webex.com/blog/five-tips-for-well-behaved-webex-bots - https://github.com/WebexSamples/webex-node-bot-framework/blob/master/docs/buttons-and-cards-example.md + +- https://developer.webex.com/blog/uploading-local-files-to-spark \ No newline at end of file diff --git a/docs/superpowers.md b/docs/superpowers.md new file mode 100644 index 0000000..1dafa58 --- /dev/null +++ b/docs/superpowers.md @@ -0,0 +1,188 @@ +![sb](./assets/speedybot_superpowers.gif) + +Speedybot $uperpowers: various helper utilities to give your bot $uperpowers when interacting with 3rd-party integrations, files, external resources, and adds the capability get/set/delete conversational "contexts." To give your bot instance $uperpowers, just wrap it in an ```$``` + + +##### Samples +- **[Kitchen sink](#kitchen-sink)** +- **[Extract uploaded file](#get-uploaded-file-details)** +- **[Extract uploaded file (ex spreadsheets)](#retrieve-raw-file-data)** + + +## Kitchen sink + + (not including file handling) + +```ts +import { $ } from 'speedybot' + +export default { + keyword: ['$', '$uperpowers', '$uperpower', '$superpower'], + async handler(bot, trigger) { + + // ## 0) Wrap the bot object in $ to give it $uperpowers, ex $(bot) + const $bot = $(bot) + + // ## 1) Contexts: set, remove, and list + // Contexts persist between "turns" of chat + // Note: contexts can optionally store data + // If you just need to stash information attached to a user, see "$(bot).saveData" below + await $bot.saveContext('mycontext1') + await $bot.saveContext('mycontext2', { data: new Date().toISOString()}) + + const mycontext2 = await $bot.getContext('mycontext2') + $bot.log('# mycontext2', mycontext2) // { data: '2021-11-05T05:03:58.755Z'} + + // Contexts: list active contexts + const allContexts = await $bot.getAllContexts() // ['mycontext1', 'mycontext2'] + bot.say(`Contexts: ${JSON.stringify(allContexts)}`) + + // Contexts: check if context is active + const isActive = await $bot.contextActive('mycontext1') + $bot.log(`mycontext1 is active, ${isActive}`) // 'mycontext1 is active, true' + + // Contexts: remove context + await $bot.deleteContext('mycontext1') + + const isStillActive = await $bot.contextActive('mycontext1') + $bot.log(`mycontext1 is active, ${isStillActive}`) // 'mycontext1 is active, false' + + // ## 2) Helpers to add variation and rich content + + // sendRandom: Sends a random string from a list + $bot.sendRandom(['Hey!','Hello!!','Hiya!']) + + // sendTemplate: like sendRandom but replace $[variable_name] with a value + const utterances = ['Hey how are you $[name]?', `$[name]! How's it going?`, '$[name]'] + const template = { name: 'Joey'} + $bot.sendTemplate(utterances, template) + + // sendURL: Sends a URL in a clickable card + $bot.sendURL('https://www.youtube.com/watch?v=3GwjfUFyY6M', 'Go Celebrate') + + // snippet: Generate a snippet that will render data in markdown-friendly format + const JSONData = {a: 1, b:2, c:3, d:4} + + $bot.sendSnippet(JSONData, `**Here's some JSON, you'll love it**`) // send to room + + // Snippet to a specifc room or specific email + // const snippet = $bot.snippet(JSONData) + // $bot.send({markdown: snippet, roomId:trigger.message.roomId, text: 'Your client does not render markdown :('}) // send to a specific room + // $bot.send({markdown: snippet, toPersonEmail:'joe@joe.com', text: 'Your client does not render markdown :('}) // send to a specific person + + + // ## 3) Save data between conversation "runs" + + interface SpecialUserData { + specialValue: string; + userId: String; + } + const specialData:SpecialUserData = { + specialValue: Math.random().toString(36).slice(2), + userId: trigger.personId, + } + + // Save the data + await $bot.saveData('userData', specialData) + + // Retrieve the data (returns null if does not exist) + const dataRes = await $bot.getData('userData') + + if (dataRes) { + // These are now "typed" + const theValue = dataRes.specialValue + const id = dataRes.userId + $bot.log(`Your specal value was ${theValue} and your id is ${id}`) + + // destroy data + $bot.deleteData('userData') + } + + // ## 4) Integrate with 3rd-parties: $bot.get, $bot.post, etc + + // ex. get external data + // Opts are axios request config (for bearer tokens, proxies, unique config, etc) + const res = await $bot.get('https://randomuser.me/api/') + bot.say({markdown: $bot.snippet(res.data)}) + + // ## 4) Files & attachments + + // Send a local file + // Provide a path/filename, will be attached to message + $bot.sendFile(__dirname, 'assets', 'speedybot.pdf') + + // Send a publically accessible URL file + // Supported filetypes: ['doc', 'docx' , 'xls', 'xlsx', 'ppt', 'pptx', 'pdf', 'jpg', 'jpeg', 'bmp', 'gif', 'png'] + $bot.sendDataFromUrl('https://drive.google.com/uc?export=download&id=1VI4I4pYVVdMnB6YOQuSejVcrSwN0cotd') + + // // experimental (fileystem write): send arbitrary JSON back as a file + // $bot.sendDataAsFile(JSON.stringify({a:1,b:2}), '.json') + + // For an example involving parse'able spreadsheets (.xlsx), see here: https://github.com/valgaze/speedybot-superpowers + }, + helpText: 'A demo of $uperpowers' +} +``` + +## Get uploaded file details + +Important note: If you attempt to display a snippet of an uploaded file (like a user-submitted list), note that message length is limited to 7439 characters before encryptions & 10000 after encryption + +```ts +import { $ } from 'speedybot' + +export default { + keyword: '<@fileupload>', + async handler(bot, trigger) { + const supportedFiles = ['json', 'txt', 'csv'] + // take 1st file uploaded, note this is just a URL + const [file] = trigger.message.files + + // Retrieve file data + const fileData = await $(bot).getFile(file) + const { extension, type, } = fileData + + if (supportedFiles.includes(extension)) { + const {data} = fileData + // bot.snippet will format json or text data into markdown format + bot.say({markdown: $(bot).snippet(data))}) + } else { + bot.say(`Sorry, somebody needs to add support to handle *.${extension} (${type}) files`) + } + }, + helpText: `Special handler that's fired when the user uploads a file to your bot (by default supports json/csv/txt)` +} +``` + +## Retrieve raw file data + +(ex spreadsheets) + +```ts +import { $ } from 'speedybot' + +export default { + keyword: '<@fileupload>', + async handler(bot, trigger) { + const supportedFiles = ['xlsx'] + + // take 1st file uploaded, note this is just a URL + const [file] = trigger.message.files + + // Retrieve file data (note response type) + const fileData = await $(bot).getFile(file, {responseType: 'arraybuffer'}) + const { extension, type, } = fileData + + if (supportedFiles.includes(extension)) { + const {data} = fileData + // Transform data with a library like SheetJS: https://www.npmjs.com/package/xlsx + + // See <@fileupload> handler here: https://github.com/valgaze/speedybot-superpowers + + } else { + bot.say(`Sorry, somebody needs to add support to handle *.${extension} files`) + } + }, + helpText: `Special handler that's fired when the user uploads a file to your bot (by default supports json/csv/txt)` +} +``` diff --git a/package-lock.json b/package-lock.json index 7fdbe07..f9087d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -937,6 +937,14 @@ "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", "dev": true }, + "axios": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", + "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", + "requires": { + "follow-redirects": "^1.14.4" + } + }, "b64-lite": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/b64-lite/-/b64-lite-1.4.0.tgz", @@ -1672,6 +1680,11 @@ "path-exists": "^4.0.0" } }, + "follow-redirects": { + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz", + "integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==" + }, "for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -1693,17 +1706,6 @@ "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", "dev": true }, - "form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dev": true, - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - } - }, "fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -3298,6 +3300,17 @@ "uuid": "^3.3.2" }, "dependencies": { + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", diff --git a/package.json b/package.json index 47d1d26..267ff18 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "author": "valgaze@gmail.com", "license": "MIT", "dependencies": { + "axios": "^0.24.0", "simple-log-colors": "^1.1.0" }, "peerDependencies": { diff --git a/settings/handlers.ts b/settings/handlers.ts index 869a724..48ba0c2 100644 --- a/settings/handlers.ts +++ b/settings/handlers.ts @@ -1,4 +1,4 @@ -import { BotHandler } from './../src' // import { BotHandler } from 'speedybot' +import { $, BotHandler } from './../src' // import { BotHandler } from 'speedybot' import Namegamehandler from './namegame' /** @@ -26,6 +26,13 @@ const handlers: BotHandler[] = [ }, helpText: `A handler that greets the user` }, + { + keyword: '<@fileupload>', + handler(bot, trigger) { + + }, + helpText: `Special handler that's fired when the user uploads a file to your bot (by default supports json/csv/txt)` + }, { keyword: ['sendfile'], handler(bot, trigger) { @@ -38,7 +45,7 @@ const handlers: BotHandler[] = [ helpText: `A handler that attaches a file in a direct message` }, { - keyword: ['ping', 'pong', 'x'], + keyword: ['ping', 'pong'], handler(bot, trigger) { const normalized = trigger.text.toLowerCase() if (normalized === 'ping') { @@ -58,6 +65,28 @@ const handlers: BotHandler[] = [ }, helpText: `A special handler that fires anytime a user submits data (you can only trigger this handler by tapping Submit in a card)` }, + { + keyword: '<@fileupload>', + async handler(bot, trigger) { + const supportedFiles = ['json', 'txt', 'csv'] + + // take 1st file uploaded, note this is just a URL & not authenticated + const [file] = trigger.message.files + + // Retrieve file data + const fileData = await $(bot).getFile(file) + const { extension } = fileData + + if (supportedFiles.includes(extension)) { + const {data} = fileData + // bot.snippet will format json or text data into markdown format + bot.say({markdown: $(bot).snippet(data)}) + } else { + bot.say(`Sorry, somebody needs to add support to handle *.${extension} files`) + } + }, + helpText: 'A special handler that will activate whenever a file is uploaded' + }, Namegamehandler, // You can also include single-file handlers in your list ] diff --git a/src/cards.ts b/src/cards.ts index 9a58106..86ba1e1 100644 --- a/src/cards.ts +++ b/src/cards.ts @@ -85,6 +85,11 @@ export interface AttachmentData { bot.sendCard(cardPayload.render(), 'Your client doesnt appear to support adaptive cards') * ``` */ +export interface SelectorPayload { + id: string; + type: string; + label?: string; +} export class SpeedyCard { public title = '' public subtitle = '' @@ -104,6 +109,8 @@ export class SpeedyCard { public tableData: string[][] = [] public attachedData: AttachmentData = {} public needsSubmit = false + public dateData: Partial = {} + public timeData: Partial = {} public json:EasyCardSpec = { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", @@ -187,6 +194,26 @@ export class SpeedyCard { return this } + setDate(id="selectedDate", label: string='Select a date') { + const payload = { + "type": "Input.Date", + id, + label + } + this.dateData = payload + return this + } + + setTime(id="selectedTime", label: string = 'Select a time') { + const payload = { + "type": "Input.Time", + id, + label + } + this.timeData = payload + return this + } + render() { if (this.title) { const payload:TextBlock = { @@ -204,7 +231,7 @@ export class SpeedyCard { const payload:TextBlock = { type: 'TextBlock', text: this.subtitle, - size: "Small", + size: "Medium", isSubtle: true, wrap:true, weight: 'Lighter', @@ -265,6 +292,37 @@ export class SpeedyCard { this.json.body.push(payload) } + if (Object.keys(this.dateData).length) { + const { id, type, label} = this.dateData + if (label) { + this.json.body.push({ + "type": "TextBlock", + "text": label, + "wrap": true + }) + } + if (id && type) { + this.json.body.push({id, type}) + } + this.needsSubmit = true + } + + + if (Object.keys(this.timeData).length) { + const { id, type, label} = this.timeData + if (label) { + this.json.body.push({ + "type": "TextBlock", + "text": label, + "wrap": true + }) + } + if (id && type) { + this.json.body.push({id, type}) + } + this.needsSubmit = true + } + if (this.needsSubmit) { interface SubmitPayload { @@ -282,7 +340,7 @@ export class SpeedyCard { this.json.actions = [payload] } else { if (this.attachedData && Object.keys(this.attachedData).length) { - bad(`attachedData ignore, you must call at least either .setInput() or .setChoices to pass through data with an adaptive card`) + bad(`attachedData ignore, you must call at least either .setInput(), .setChoices, .setDate, .setTime, to pass through data with an adaptive card`) } } diff --git a/src/framework.ts b/src/framework.ts index 44e2204..301a530 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -68,6 +68,8 @@ export interface BotInst { // methods implode(): Promise; say(format:string, msg?: string | object): Promise + say(object): Promise + say({markdown: string}): Promise sayWithLocalFile(message: string | object, filename: string): Promise reply(replyTo: string | object, message: string | object, format?: string): Promise dm(person: string, format: string | object, ...rest: any): void; @@ -84,15 +86,17 @@ export interface BotInst { exit(): Promise; // storage - store(key: string, val: any): Promise; - recall(key: string): Promise; - forget(key: string): Promise + store(key: string, val: any): Promise; + recall(key?: string): Promise; + forget(key: string): Promise; } -export interface ToMessage extends Message { +export interface ToMessage extends Partial { toPersonId?: string; toPersonEmail?: string; + files?: string[] | any[] } + export interface Message { id?: string; roomId?: string; @@ -103,6 +107,7 @@ export interface Message { markdown?: string; html?: string; created?: string; + files: string[]; } export interface Room { @@ -187,6 +192,8 @@ export interface WebexInst { remove(membership: (Message | string | number)): Promise, update(membership: (Message | string | number)): Promise, }, + + request(payload: any): Promise; // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/request/index.d.ts // catch-all [key: string]: any } @@ -249,17 +256,4 @@ export interface WebhookHandler { export const passThru = (bot: BotInst, trigger: Trigger) => { // HACK: pass the button-tap value through the handler system return bot.framework.onMessageCreated(trigger.message) -} - -/** - * const alerter = { - * keyword: '<@webhook>' - * route: '/my_webhook_route' - * handler(req, res) { - * const {body} = req - * this.send({toPersonEmail: 'joe@joeys.com', text:`Webhook alert!, `}) - * } - * } - * - * - */ \ No newline at end of file +} \ No newline at end of file diff --git a/src/helpers.ts b/src/helpers.ts index 5343846..18e102c 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,5 +1,13 @@ -import { Trigger } from './framework' -import { loud } from './logger' +import axios, { Method, AxiosRequestConfig, AxiosResponse } from 'axios' +import { createReadStream, unlink, writeFileSync } from 'fs' +import { SpeedyCard } from './index' +import { BotInst, Trigger, ToMessage, Message } from './framework' +import { log, loud } from './logger' +import { resolve } from 'path' + + + + /** * @param list * Pick an item from the list @@ -114,12 +122,17 @@ See here for more details: https://github.com/valgaze/speedybot/blob/master/docs } -export const jsonSnippet = (payload) => { - const escaped = ` -\`\`\`json -${JSON.stringify(payload, null, 2)} +export const snippet = (data: string | object, dataType='json'): string => { + const msg = ` +\`\`\`${dataType} +${dataType === 'json' ? JSON.stringify(data, null, 2) : data} \`\`\`` - return { markdown: escaped } + return msg +} + + +export const htmlSnippet = (data: string | object): string => { + return snippet(data, 'html') } // Alias store/recall @@ -170,4 +183,293 @@ export class Locker { snapShot() { return JSON.parse(JSON.stringify(this.state)) } +} + + +// Get uploaded files +export interface SpeedyFileData { + data: T; + extension: string; + fileName: string; + type: string; + markdownSnippet: string; +} + + +export const extractFileData = (contentDisposition: string): { fileName: string, extension: string } => { + // header >> 'content-disposition': 'attachment; filename="a.json"', + const fileName = contentDisposition.split(';')[1].split('=')[1].replace(/\"/g, '') + const extension = fileName.split('.').pop() || '' + return { + fileName, + extension + } +} + +export class $Botutils { + public token:string + public botRef: BotInst; + public request: (payload: any) => Promise; + public ContextKey = '_context_' + // https://developer.webex.com/docs/basics + public supportedExtensions = ['doc', 'docx' , 'xls', 'xlsx', 'ppt', 'pptx', 'pdf', 'jpg', 'jpeg', 'bmp', 'gif', 'png'] + constructor(botRef: BotInst | any){ + this.botRef = botRef + this.token = botRef.framework.options.token + this.request = axios + } + + snippet(ref: (string | object)): string { + return snippet(ref) + } + + htmlSnippet(ref: (string | object), dataType='html'): string { + return snippet(ref, dataType) + } + + public async get(url:string, config:AxiosRequestConfig={}):Promise> { + return this.request({url, method: 'GET', ...config}) + } + + public async post(url, config:AxiosRequestConfig={}):Promise { + return this.request({url, method: 'POST', ...config}) + } + + public async getFile(fileUrl: string, opts:AxiosRequestConfig={}): Promise> { + /** + * Bummer: need to add additional dependency :/ + * "request" is available in framework, however painful to get streams + deprecated: https://github.com/request/request/issues/3142 + * + */ + let requestOpts = { + method: 'GET' as Method, + url: fileUrl, + headers: { + Authorization: `Bearer ${this.token}`, + }, + ...opts + } + try { + const res = await axios(requestOpts) + const { headers, data } = res + const { fileName, extension } = extractFileData(headers['content-disposition']) + const type = headers['content-type'] + + const payload = { + data, + extension, + fileName, + type, + markdownSnippet: (type === 'application/json' || (typeof data === 'string' && data.length < 900)) ? this.snippet(data) : '' + } + + return payload + + } catch(e) { + throw e + } + } + + // public markdownTable(data: any) { + // // todo + // return data + // } + + public async send(payload: ToMessage) { + return this.botRef.webex.messages.create(payload) + } + + public genContextName(key:string) { + return `${this.ContextKey}_${key}` + } + + public degenContextName(key:string) { + return key.replace(`${this.ContextKey}_`,'') + } + + public async saveContext(key: string, data?:T): Promise{ + let writeData = data ? data : { _active: true } + return this.saveData(`${this.genContextName(key)}`, writeData) + } + + public async getContext(key:string):Promise { + const res = await this.getData(this.genContextName(key)) + return res + } + + public async contextActive(key:string):Promise { + const ctx = await this.getContext(key) + return ctx ? true : false + } + + public async deleteContext(key:string):Promise { + const res = await this.deleteData(this.genContextName(key)) + return res + } + + public async getAllContexts(): Promise { + const fullRef = await this.botRef.recall() + const keys = Object.keys(fullRef) || [] + const actives = keys.filter(key => key.includes(this.ContextKey)) + .map(key => this.degenContextName(key)) + return actives + } + + public async sendURL(url: string, title?:string) { + const card = new SpeedyCard() + if (title) { + card.setTitle(title).setUrl(url) + } else { + card.setSubtitle(url).setUrl(url, 'Open') + } + this.botRef.sendCard(card.render(), url) + } + + public async saveData(key: string, data): Promise{ + return this.botRef.store(key, data) + } + + public async deleteData(key: string): Promise{ + return new Promise(async resolve => { + try { + const res = await this.botRef.forget(key) + resolve(res) + } catch(e) { + resolve(null) + } + }) + } + + /** + * + * Storage aliases + * getData: bot.recall + * deleteData: bot.forget don't throw, resolve to null + * + */ + public async getData(key:string): Promise { + return new Promise(async resolve => { + try { + const res = await this.botRef.recall(key) + resolve(res) + } catch(e) { + resolve(null) + } + }) + } + public resolveFilePath(...filePieces: string[]) { + return resolve(...filePieces) + } + + public prepareLocalFile(...filePieces: string[]) { + const target = resolve(...filePieces) + const stream = createReadStream(target) + return stream + } + + public sendFile(...filePieces: string[]) { + try { + const stream = this.prepareLocalFile(...filePieces) + this.botRef.uploadStream(stream) + } catch(e) { + throw e + } + } + + public async sendDataAsFile(data: T, extensionOrFileName: string, config: FileConfig ={}, fallbackText=' ') { + // 🦆: HACK HACK HACK for "files": https://developer.webex.com/docs/basics + // todo: get rid of filesystem write + const fullFileName = this.handleExt(extensionOrFileName) + try { + writeFileSync(fullFileName, data) + const stream = createReadStream(fullFileName) + await this.botRef.webex.messages.create({roomId: this.botRef.room.id, files: [stream], text:fallbackText}) + this.killFile(fullFileName) + } catch(e) { + throw e + } + } + + public killFile(path:string) { + return new Promise((resolve, reject) => { + unlink(path, (err) => { + if (err) { + resolve(err) + } else { + resolve({}) + } + }) + }) + } + + public async sendDataFromUrl(resourceUrl: string, fallbackText=' ') { + return this.botRef.webex.messages.create({roomId: this.botRef.room.id, files: [resourceUrl], text:fallbackText}) + } + + public async sendSnippet(data: string | object, label='', dataType='json', fallbackText='It appears your client does not support markdown') { + let markdown + if (dataType === 'json') { + markdown = this.snippet(data) + } else { + markdown = this.htmlSnippet(data) + } + + if (label) { + markdown = label + ' \n ' + markdown + } + return this.botRef.webex.messages.create({roomId: this.botRef.room.id, markdown, text: fallbackText}) + } + + public handleExt(input: string):string { + const hasDot = input.indexOf('.') > -1 + let fileName = '' + const [prefix, ext] = input.split('.') + + if (hasDot) { + if (!prefix) { + // '.json' case, generate prefix + fileName = `${this.generateFileName()}.${ext}` + } else { + // 'a.json' case, pass through + fileName = input + } + } else { + // 'json' case, generate prefix, add . + fileName = `${this.generateFileName()}.${prefix}` + } + return fileName + } + + public generateFileName(): string { + return `${this.rando()}_${this.rando()}` + } + + public rando(): string { + return `${Math.random().toString(36).slice(2)}` + } + + // Alias to other helpers + + public sendTemplate(utterances: string | string[], template: { [key: string]: any }): Promise { + const res = fillTemplate(utterances, template) + return this.botRef.webex.messages.create({roomId: this.botRef.room.id, text: res}) + } + + public sendRandom(utterances: string[]) { + const res = pickRandom(utterances) + return this.botRef.webex.messages.create({roomId: this.botRef.room.id, text: res}) + } + + public log(...payload) { + return log(...payload) + } +} + +export interface FileConfig { + type?: 'json' | 'buffer' | 'text' +} + +export const $ = (botRef: BotInst | any):$Botutils => { + // memo? + return new $Botutils(botRef) } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index da5e398..ab49738 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ export { SpeedybotConfig } from './speedybot' export { FrameworkInst, BotHandler,WebhookHandler, Message, ToMessage, BotInst, Trigger, passThru } from './framework' export { bad, help, ascii_art, log, good, askQuestion, loud } from './logger' // helpers -export { fillTemplate, pickRandom, jsonSnippet, Storage, Locker } from './helpers' +export { fillTemplate, pickRandom, snippet, Storage, Locker, $ } from './helpers' // make adaptive cards less painful w/ base templates export { SpeedyCard } from './cards' export const placeholder = '__REPLACE__ME__' diff --git a/src/speedybot.ts b/src/speedybot.ts index c417398..fdee946 100644 --- a/src/speedybot.ts +++ b/src/speedybot.ts @@ -1,5 +1,5 @@ import { FrameworkInst, BotHandler, ToMessage, BotInst, Trigger, WebhookHandler } from './framework' -import { ValidatewebhookUrl, pickRandom } from './helpers' +import { ValidatewebhookUrl, pickRandom, snippet} from './helpers' import { placeholder, ascii_art, SpeedyCard } from './' // TODO: make peer dependency import Botframework from 'webex-node-bot-framework' @@ -194,6 +194,11 @@ export class Speedybot { helpText: `Get help info` } } + + public snippet(data: object | string):string { + return snippet(data) + } + defaultHealthcheck() { return { keyword: ['healthcheck'], diff --git a/test/card.test.ts b/test/card.test.ts index ccc7e27..0a59d77 100644 --- a/test/card.test.ts +++ b/test/card.test.ts @@ -31,47 +31,56 @@ test("Kitchen sink", (t) => { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.0", - "body": [{ - "type": "TextBlock", - "text": "System is 👍", - "weight": "Bolder", - "size": "Large", - "wrap": true - }, { - "type": "TextBlock", - "text": "If you see this card, everything is working", - "size": "Small", - "isSubtle": true, - "wrap": true, - "weight": "Lighter" - }, { - "type": "FactSet", - "facts": [{ - "title": "Bot's Uptime", - "value": "12.492006583s" - }] - }, { - "type": "Image", - "url": "https://i.imgur.com/SW78JRd.jpg", - "horizontalAlignment": "Center", - "size": "Large" - }, { - "type": "Input.Text", - "placeholder": "What's on your mind?", - "id": "inputData" - }], - "actions": [{ - "type": "Action.Submit", - "title": "Submit", - "data": { - "mySpecialData": { - "a": 1, - "b": 2 + "body": [ + { + "type": "TextBlock", + "text": "System is 👍", + "weight": "Bolder", + "size": "Large", + "wrap": true + }, + { + "type": "TextBlock", + "text": "If you see this card, everything is working", + "size": "Medium", + "isSubtle": true, + "wrap": true, + "weight": "Lighter" + }, + { + "type": "FactSet", + "facts": [ + { + "title": "Bot's Uptime", + "value": "12.492006583s" + } + ] + }, + { + "type": "Image", + "url": "https://i.imgur.com/SW78JRd.jpg", + "horizontalAlignment": "Center", + "size": "Large" + }, + { + "type": "Input.Text", + "placeholder": "What's on your mind?", + "id": "inputData" + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "data": { + "mySpecialData": { + "a": 1, + "b": 2 + } } } - }] + ] } - const cardPayload = new SpeedyCard().setTitle('System is 👍') .setSubtitle('If you see this card, everything is working') .setImage('https://i.imgur.com/SW78JRd.jpg') @@ -84,6 +93,90 @@ test("Kitchen sink", (t) => { t.end(); }); +test("Date and Time Pickers", (t) => { + const expected = { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "TextBlock", + "text": "xxx", + "weight": "Bolder", + "size": "Large", + "wrap": true + }, + { + "type": "TextBlock", + "text": "Hi subtlte", + "size": "Medium", + "isSubtle": true, + "wrap": true, + "weight": "Lighter" + }, + { + "type": "Input.ChoiceSet", + "id": "selectedChoice", + "value": "0", + "isMultiSelect": false, + "isVisible": true, + "choices": [ + { + "title": "a", + "value": "0" + }, + { + "title": "b", + "value": "1" + }, + { + "title": "c", + "value": "2" + } + ] + }, + { + "type": "TextBlock", + "text": "Choose yer date", + "wrap": true + }, + { + "id": "selectedDate", + "type": "Input.Date" + }, + { + "type": "TextBlock", + "text": "Select a time", + "wrap": true + }, + { + "id": "selectedTime", + "type": "Input.Time" + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "data": { + "a": 1 + } + } + ] +} + +const myCard = new SpeedyCard().setTitle('xxx') + .setSubtitle('Hi subtlte') + .setData({a:1}) + .setTime('selectedTime') + .setDate('selectedDate', 'Choose yer date') + .setChoices(['a','b','c'], {id: 'selectedChoice'}) + + const actual = myCard.render() + t.deepEqual(actual, expected); + t.end(); +}); + test("teardown", function (t) { t.end(); }); \ No newline at end of file diff --git a/test/csv_sample.csv b/test/csv_sample.csv new file mode 100644 index 0000000..2009ee6 --- /dev/null +++ b/test/csv_sample.csv @@ -0,0 +1,2 @@ +a,1 +b,2 \ No newline at end of file diff --git a/test/files.test.ts b/test/files.test.ts new file mode 100644 index 0000000..7d8b3bc --- /dev/null +++ b/test/files.test.ts @@ -0,0 +1,60 @@ +import test from "tape"; +// import { resolve } from 'fs' +import { $, placeholder} from './../src' + +const fakeBot = {framework: { + options: { + token: placeholder + } +}} +const inst = $(fakeBot) +test("setup", function (t) { + t.end(); +}); + +test("$uperpower, handleExt helper passes through full filename", (t) => { + const sample = 'a.json' + const actual = inst.handleExt(sample) + t.deepEqual(actual, sample); + t.end(); +}); + +test("$uperpower, handleExt helper generates a file name when given just an extension", (t) => { + const sample = '.json' + const actual = inst.handleExt('a.json') + const result = actual.includes(sample) && actual.length > sample.length + + t.equal(result, true); + t.end(); +}); + +test("$uperpower, handleExt helper generates a file name when given just an extension without a dot", (t) => { + const sample = '.json' + const actual = inst.handleExt('.json') + console.log("#", actual) + const result = actual.includes(sample) && actual.length > sample.length + + t.equal(result, true); + t.end(); +}); + + +test("$uperpower, random name will generate distinct names", (t) => { + const sample = inst.generateFileName() + const sample2 = inst.generateFileName() + t.notEqual(sample, sample2) + t.end(); +}); + +test("$uperpower context: name ops are inverses of each other", (t) => { + const key = 'my_key' + const sample = inst.genContextName(key) + const sample2 = inst.degenContextName(sample) + t.equal(key, sample2) + t.end(); +}); + + +test("teardown", function (t) { + t.end(); +}); \ No newline at end of file