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