├── .env.example ├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── index.js ├── package.json ├── screenshot.png └── www ├── images ├── blacksquare.png ├── greysquare.png ├── oblique.png ├── refresh_dark.png └── settings_dark.png └── index.html /.env.example: -------------------------------------------------------------------------------- 1 | # Store config settings in the variables below 2 | # or via environment variables of the same name 3 | # 4 | # DO NOT PUT SECRETS IN THIS FILE IF YOU'RE PUSHING THE CODE TO EXTERNAL REPOS 5 | # 6 | # - you can reference these variables from your code with process.env.SECRET for example 7 | # 8 | # - note that ".env" is formatted like a shell file, so you may (depending on your 9 | # platform) need to add double quotes if strings contains spaces 10 | 11 | # Webex Teams bot account access token 12 | 13 | WEBEX_ACCESS_TOKEN= 14 | 15 | # Webex JS SDK debug level 16 | # Values: error, warn, info, debug, trace 17 | 18 | WEBEX_LOG_LEVEL=error 19 | 20 | # App web server listen port 21 | 22 | PORT=3000 23 | 24 | # Public URL where this application can be reached (Optional) 25 | # If not set, the app will use Ngrok to dynamically create a temporary reverse tunnel 26 | 27 | PUBLIC_URL= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/settings.json 2 | node_modules/** 3 | .DS_Store 4 | package-lock.json 5 | .env -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceFolder}/index.js" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Cisco Systems, Inc. and/or its affiliates 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webex-card-sample 2 | 3 | ## Overview 4 | 5 | Implements a Webex Teams messaging bot, that can post an [adaptive card](https://adaptivecards.io/) 6 | containing an 'oblique strategy' scraped from [http://stoney.sb.org/eno/oblique.html](http://stoney.sb.org/eno/oblique.html). 7 | 8 | ![screenshot](screenshot.png) 9 | 10 | The adaptive card features a 'refresh' button to request a new card/strategy, and a 'settings' button that opens a sub-card where scheduling options can be selected (actually implementing persistence and auto-posting based on the schedule choices is left as an exercise). 11 | 12 | Note, this project uses the Webex Teams [node.js SDK](https://developer.webex.com/docs/sdks/node) for posting messages and listening for new membership/message/attachmentActions events via websocket. 13 | 14 | [Webex for Developers Site](https://developer.webex.com/) 15 | 16 | ## Getting started 17 | 18 | - Install Node.js 10.15.0+ (should work on any version supporting async/await) 19 | 20 | On Windows, choose the option to add to `PATH` environment variable 21 | 22 | - The project was built/tested using [Visual Studio Code](https://code.visualstudio.com/) 23 | 24 | - Clone this repo to a directory on your PC: 25 | 26 | ```bash 27 | git clone https://github.com/CiscoDevNet/webex-card-sample.git 28 | ``` 29 | 30 | - Dependency Installation: 31 | 32 | ```bash 33 | cd webex-card-sample 34 | npm install 35 | ``` 36 | 37 | - Open the project in VS Code: 38 | 39 | ```bash 40 | code . 41 | ``` 42 | 43 | - Create a [Webex Teams bot](https://developer.webex.com/my-apps/new) and copy the bot access token 44 | 45 | - In VS Code: 46 | 47 | 1. Rename the `.env.example` file as `.env`, and open it for editing: 48 | 49 | - Paste in your bot access token 50 | 51 | - (Optional) Paste in your app's publically reachable URL. If not specified, the app will use Ngrok to dynamically create a reverse tunnel instead (see caveats in [Hints](#hints) below) 52 | 53 | - Be sure to save the file 54 | 55 | 2. Run the sample by pressing **F5**, or by opening the Debug panel and clicking the green 'Launch' arrow 56 | 57 | - In your favorite Webex Teams client, add the new bot to a test space/room - it should automatically post a strategy when added. Sending any message to the bot (remember to @mention the bot if in a group space) will trigger it to send a new strategy 58 | 59 | - After a strategy is posted, you can click on the 'refresh' icon to request another strategy. You can click on 'settings', play with the options and click the 'save' button, however the sample app does not implement actually saving/scheduling posts automatically (it just prints the data to the console) 60 | 61 | ## Hints 62 | 63 | - As the application creates cards featuring some images (for background and button icons) which must be served by a publically reachable web server, this sample uses the [Ngrok for node](https://www.npmjs.com/package/ngrok) package to create a reverse proxy tunnel on startup - this may have implications for your firewall/security policy. 64 | 65 | A production application would typically host an application like this on a cloud platform or have an IT-vetted reverse proxy configured, etc 66 | 67 | - As a result of the above, any card assets which are statically served by this app (i.e. title, background and icon images) will only appear in the Webex Teams client when the app is running. Further, as the public URL provided by Ngrok is different on each run, the asset URLs present in previously posted cards will no longer work when the app is restarted later 68 | 69 | [![published](https://static.production.devnetcloud.com/codeexchange/assets/images/devnet-published.svg)](https://developer.cisco.com/codeexchange/github/repo/CiscoDevNet/webex-card-sample) -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019 Cisco and/or its affiliates. 2 | 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | // SOFTWARE. 18 | 19 | // See README.md for configuration and launch instructions 20 | 21 | // Tested using: 22 | // 23 | // Ubuntu Linux 19.04 24 | // Node.js 10.15.0 25 | 26 | require( 'dotenv' ).config(); 27 | const express = require( 'express' ); 28 | const webex = require( 'webex/env' ); 29 | const ngrok = require( 'ngrok' ); 30 | var request = require( 'request-promise-native' ); 31 | 32 | if ( process.env.WEBEX_ACCESS_TOKEN === '') { 33 | console.log( 'Token missing: please provide a valid Webex Teams user or bot access token in .env or via WEBEX_ACCESS_TOKEN environment variable'); 34 | process.exit(1); 35 | } 36 | 37 | const app = express(); 38 | const port = process.env.PORT; 39 | 40 | var publicUrl, botId; 41 | 42 | async function sendStrategy( event ) { 43 | 44 | // Ignore memberships events for users other than our bot 45 | if ( event.resource == 'memberships' && event.data.personId !== botId ) return; 46 | // Ignore messages sent by our bot 47 | if ( event.resource == 'messages' && event.data.personId == botId ) return; 48 | 49 | try { 50 | 51 | // Retrieve the strategy from Oblique Strategies 52 | let html = await request( 'http://stoney.sb.org/eno/oblique.html' ); 53 | 54 | // Scrape the strategy text out of the HTML page 55 | strategy = html.substring( html.indexOf( '

')+4, html.indexOf( '

') ); 56 | } 57 | catch ( err ) { 58 | 59 | strategy = `Error retrieving strategy: ${ err }`; 60 | console.log( strategy ); 61 | } 62 | 63 | message = { 64 | 'roomId': event.data.roomId, 65 | 'markdown': `"${ strategy }"`, 66 | 'attachments': [ ] 67 | }; 68 | 69 | // Card attachment based on the schema at https://adaptivecards.io/explorer/ 70 | // The WYSISWYG designer is helpful as well: https://adaptivecards.io/designer/ 71 | attachment = { 72 | "contentType": "application/vnd.microsoft.card.adaptive", 73 | "content": { 74 | "type": "AdaptiveCard", 75 | "version": "1.0", 76 | "backgroundImage": `${ publicUrl }/images/blacksquare.png`, 77 | "body": [ 78 | { 79 | "type": "Image", 80 | "url": `${ publicUrl }/images/oblique.png` 81 | }, 82 | { 83 | "type": "TextBlock", 84 | "text": `"${ strategy }"`, 85 | "size": "large", 86 | "weight": "bolder", 87 | "color": "light", 88 | "horizontalAlignment": "center" 89 | } 90 | ], 91 | "actions": [ 92 | { 93 | "type": "Action.Submit", 94 | "title": "", 95 | "id": "actRefresh", 96 | "iconUrl": `${ publicUrl }/images/refresh_dark.png`, 97 | "data": { 98 | "action": "refresh" 99 | } 100 | }, 101 | { 102 | "type": "Action.ShowCard", 103 | "title": "", 104 | "id": "actSettings", 105 | "iconUrl": `${ publicUrl }/images/settings_dark.png`, 106 | "card": { 107 | "type": "AdaptiveCard", 108 | "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", 109 | "body": [ 110 | { 111 | "type": "TextBlock", 112 | "text": "Settings", 113 | "color": "Light", 114 | "weight": "Bolder" 115 | }, 116 | { 117 | "type": "TextBlock", 118 | "text": "Automatically send a new strategy on:", 119 | "color": "Light" 120 | }, 121 | { 122 | "type": "Input.ChoiceSet", 123 | "id": "days", 124 | "choices": [ 125 | { 126 | "title": "Sun", 127 | "value": "sunday" 128 | }, 129 | { 130 | "title": "Mon", 131 | "value": "monday" 132 | }, 133 | { 134 | "title": "Tues", 135 | "value": "tuesday" 136 | }, 137 | { 138 | "title": "Wed", 139 | "value": "wednesday" 140 | }, 141 | { 142 | "title": "Thur", 143 | "value": "thursday" 144 | }, 145 | { 146 | "title": "Fri", 147 | "value": "friday" 148 | }, 149 | { 150 | "title": "Sat", 151 | "value": "saturday" 152 | } 153 | ], 154 | "isMultiSelect": true 155 | }, 156 | { 157 | "type": "TextBlock", 158 | "text": "at:", 159 | "color": "Light" 160 | }, 161 | { 162 | "type": "Input.Time", 163 | "id": "time", 164 | "value": "10:00" 165 | } 166 | ], 167 | "backgroundImage": `${ publicUrl }/images/greysquare.png`, 168 | "actions": [ 169 | { 170 | "type": "Action.Submit", 171 | "id": "actSave", 172 | "title": "Save", 173 | "data": { 174 | "action": "settings" 175 | } 176 | } 177 | ] 178 | } 179 | } 180 | ] 181 | } 182 | }; 183 | 184 | message.attachments.push( attachment ); 185 | 186 | try { 187 | 188 | await webex.messages.create( message ); 189 | } 190 | catch ( err ) { 191 | 192 | console.log( `Error creating message: ${ err }`); 193 | } 194 | } 195 | 196 | async function handleAttachmentActions( event ) { 197 | 198 | switch ( event.data.inputs.action ) { 199 | 200 | case 'refresh': 201 | 202 | sendStrategy( event ); 203 | break; 204 | 205 | case 'settings': 206 | 207 | // Saving the settings and scheduling automatic strategy posts 208 | // is left as an exercise for the reader ;) 209 | console.log( event.data.inputs ) 210 | break; 211 | } 212 | } 213 | 214 | async function setupWebexListeners() { 215 | 216 | try { 217 | 218 | await webex.memberships.listen(); 219 | 220 | // Send a strategy when we're added to a new room 221 | webex.memberships.on( 'created', ( event ) => sendStrategy( event ) ); 222 | 223 | await webex.messages.listen(); 224 | 225 | // Post a new strategy on receiving any message (use @message in group spaces) 226 | webex.messages.on('created', ( event ) => sendStrategy( event ) ); 227 | 228 | await webex.attachmentActions.listen(); 229 | 230 | // Handle adaptive card attachments events 231 | webex.attachmentActions.on('created', ( event ) => handleAttachmentActions( event ) ); 232 | } 233 | catch( err ) { 234 | 235 | console.error( `Unable to register for Webex events: ${ err }` ); 236 | } 237 | } 238 | 239 | // Main program 240 | // Wrap in an async function so we can await certain operations 241 | (async function() { 242 | 243 | // Use ngrok to create a public tunnel and URL 244 | publicUrl = process.env.PUBLIC_URL ? process.env.PUBLIC_URL : await ngrok.connect( port ); 245 | // if (!process.env.publicUrl) publicUrl = await ngrok.connect( port ); 246 | 247 | console.log( 'Public URL: ' + publicUrl ); 248 | 249 | // Retrieve bot person details, i.e. id 250 | let me = await webex.people.get( 'me' ); 251 | botId = me.id; 252 | 253 | setupWebexListeners(); 254 | 255 | // Serve static files/images from the www folder 256 | app.use( express.static( 'www' ) ); 257 | 258 | // Startup the Express web server 259 | app.listen( port, () => console.log( `Bot listening on port ${port}!` ) ); 260 | 261 | })(); 262 | 263 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webex-card-sample", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/CiscoDevNet/webex-card-sample.git" 12 | }, 13 | "author": "dstaudt@cisco.com", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/CiscoDevNet/webex-card-sample/issues" 17 | }, 18 | "homepage": "https://github.com/CiscoDevNet/webex-card-sample#readme", 19 | "dependencies": { 20 | "body-parser": "^1.19.0", 21 | "dotenv": "^8.2.0", 22 | "express": "^4.17.1", 23 | "ngrok": "^3.2.7", 24 | "request-promise-native": "^1.0.8", 25 | "webex": "^1.80.84" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiscoDevNet/webex-card-sample/8d7c5286f883d519c9419cfeeb84f1728f08ef5b/screenshot.png -------------------------------------------------------------------------------- /www/images/blacksquare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiscoDevNet/webex-card-sample/8d7c5286f883d519c9419cfeeb84f1728f08ef5b/www/images/blacksquare.png -------------------------------------------------------------------------------- /www/images/greysquare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiscoDevNet/webex-card-sample/8d7c5286f883d519c9419cfeeb84f1728f08ef5b/www/images/greysquare.png -------------------------------------------------------------------------------- /www/images/oblique.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiscoDevNet/webex-card-sample/8d7c5286f883d519c9419cfeeb84f1728f08ef5b/www/images/oblique.png -------------------------------------------------------------------------------- /www/images/refresh_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiscoDevNet/webex-card-sample/8d7c5286f883d519c9419cfeeb84f1728f08ef5b/www/images/refresh_dark.png -------------------------------------------------------------------------------- /www/images/settings_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiscoDevNet/webex-card-sample/8d7c5286f883d519c9419cfeeb84f1728f08ef5b/www/images/settings_dark.png -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | webex-card-sample app is up and running --------------------------------------------------------------------------------