├── .gitignore ├── .npmignore ├── CHANGELOG ├── LICENSE ├── Makefile ├── README.md ├── docs ├── SettingUpYourSparkBot.md └── img │ ├── card-submission.png │ ├── postman-create-webhook-all-all.png │ └── spark4devs-create-webhook-all-all - Copy.png ├── index.js ├── package.json ├── quickstart ├── README.md ├── express-webhook.js ├── onCardSubmission-webhook.js ├── onCommand-webhook.js ├── onEvent-all-all.js ├── onEvent-check-secret.js ├── onEvent-messages-created.js └── onMessage-asCommand.js ├── sparkbot ├── interpreter.js ├── package.json ├── registration.js ├── router.js ├── utils.js └── webhook.js └── tests ├── README.md ├── express-webhook.js ├── onCardSubmission-webhook.js ├── onCardSubmission.js ├── onCommand-webhook.js ├── onCommand.js ├── onEvent-all-all.js ├── onEvent-check-secret.js ├── onEvent-messages-created.js ├── onMessage-asCommand.js ├── onMessage.js └── test-registration.js /.gitignore: -------------------------------------------------------------------------------- 1 | # node artefacts 2 | node_modules/ 3 | npm-debug.log 4 | package-lock.json 5 | 6 | # IDE 7 | .idea/ 8 | .vscode/ 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .idea/ 3 | .vscode 4 | 5 | # Build artefacts 6 | Makefile 7 | node_modules/ 8 | npm-debug.log 9 | 10 | # Library documentation & tests 11 | docs/ 12 | tests/ 13 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 2 | v2.3 - support for the API emulator 3 | - migrated from 'https' to 'got' 4 | 5 | v2.2 - new WEBEX_API env variable 6 | - support for an alternative HTTPS endpoint 7 | - examples: api emulator on Heroku, Glitch or over ngrok 8 | 9 | v2.1 - official support for webhook automation 10 | - robustified 11 | 12 | v2.0 - Support for Adaptive Cards 13 | - support for AttachmentActions 14 | 15 | v1.0 - Webex Teams rebrand 16 | - Added support for 'webex.bot' domain 17 | - Added support for ACCESS_TOKEN 18 | - SPARK_TOKEN deprecated, but still supported 19 | - Documentation updates (removed Spark mentions) 20 | 21 | v0.12 - Legacy Cisco Spark release 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2019 Cisco Systems 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | # Change to your docker account if you plan to package and release your own docker image 3 | DOCKER_ACCOUNT=objectisadvantag 4 | # Set this the your Host interface if you use DockerToolbox, otherwise leave it to 127.0.01 5 | # DOCKER_HOST_IPADDRESS=127.0.0.1 6 | DOCKER_HOST_IPADDRESS=192.168.99.100 7 | 8 | 9 | default: dev 10 | 11 | dev: 12 | DEBUG=sparkbot* node tests/express-webhook.js 13 | 14 | run: 15 | (lt -s sparkbot -p 8080 &) 16 | node tests/express-webhook.js 17 | 18 | dimage: 19 | docker build -t $(DOCKER_ACCOUNT)/node-sparkbot . 20 | 21 | ddev: 22 | docker run -it -p 8080:8080 $(DOCKER_ACCOUNT)/node-sparkbot 23 | 24 | drun: 25 | (lt -s sparkbot -l $(DOCKER_HOST_IPADDRESS) -p 8080 &) 26 | docker run -it -p 8080:8080 $(DOCKER_ACCOUNT)/node-sparkbot 27 | 28 | 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build Webex ChatBots in JavaScript 2 | 3 | Yet [another opiniated framework](https://github.com/CiscoDevNet/awesome-webex#bot-frameworks) to build [Webex Teams Bots](https://developer.webex.com/bots.html) in Node.js: 4 | - simple design to both learn and experiment Webhooks concepts in a snatch, 5 | - flexibility to let your bot listen to raw Webhook events, or directly respond to commands, 6 | - supports [Webex Cards](https://github.com/CiscoDevNet/node-sparkbot#capture-inputs-submitted-via-cards) and [webhook check (creation/update)](https://github.com/CiscoDevNet/node-sparkbot#auto-register-webhooks), 7 | - leveraged by a few [DevNet learning labs](https://learninglabs.cisco.com/tracks/collab-cloud/spark-apps/collab-spark-botl-ngrok/step/1), and [bot samples](https://github.com/CiscoDevNet/node-sparkbot-samples). 8 | 9 | This project focusses on the [framework itself](#architecture) and its [testing companions](./tests/README.md). 10 | 11 | If you're looking for ready-to-run Chatbots built with the library, jump to the [node-sparkbot-samples repo](https://github.com/CiscoDevNet/node-sparkbot-samples). 12 | 13 | 14 | ## Quickstart 15 | 16 | Copy a sample from [Quickstart](quickstart/). 17 | 18 | For **Mac, Linux and bash users**, open a terminal and type: 19 | 20 | ```shell 21 | git clone https://github.com/CiscoDevNet/node-sparkbot 22 | cd node-sparkbot 23 | npm install 24 | DEBUG=sparkbot* node tests/onEvent-all-all.js 25 | ``` 26 | 27 | For **Windows users**, open a command shell and type: 28 | 29 | ```shell 30 | git clone https://github.com/CiscoDevNet/node-sparkbot 31 | cd node-sparkbot 32 | npm install 33 | set DEBUG=sparkbot* 34 | node tests/onEvent-all-all.js 35 | ``` 36 | 37 | **Done, your bot is live** 38 | 39 | Let's check it's live by hitting its healthcheck endpoint: 40 | 41 | ``` 42 | # simply run: curl http://localhost:8080 43 | # or if you like formatting, install jq and run: 44 | $ curl http://localhost:8080 | jq -C 45 | { 46 | "message": "Congrats, your Webex Teams webhook is up and running", 47 | "since": "2016-09-23T07:46:52.397Z", 48 | "tip": "Register your bot as a WebHook to start receiving events: https://developer.webex.com/endpoint-webhooks-post.html", 49 | "listeners": [ 50 | "messages/created" 51 | ], 52 | "token": false, 53 | "account": {}, 54 | "interpreter": {}, 55 | "commands": [], 56 | } 57 | ``` 58 | 59 | **Congrats, your bot is now up and running** 60 | 61 | Now, let's make Webex post events to our bot. 62 | - if your bot is running on a local machine, you need to [expose your bot to the internet](docs/SettingUpYourSparkBot.md#expose-you-bot). 63 | - and lastly, [register your bot by creating a Webhook](docs/SettingUpYourSparkBot.md#register-your-bot-as-a-spark-webhook). 64 | 65 | Note that if you want your bot to respond to commands, add a Webex Teams API token on the command line (see below). 66 | 67 | Finally, we suggest you take a look at the [tests](tests/README.md) as they provide a great way to discover the framework features. 68 | 69 | 70 | ### Respond to commands 71 | 72 | At startup, the library looks for the ACCESS_TOKEN environment variable. 73 | > Note that ACCESS_TOKEN is still accepted but deprecated as of v1.x of the framework 74 | 75 | If present, the library will leverage the token to retreive new message contents, and automatically detect the Webex Teams account associated to the token and take initialization options to fit common scenarios, see [Account detection](#account-type-detection). 76 | 77 | As messages flow in, the library automatically removes bot mentions when relevant, so that you can focus on the command itself. 78 | 79 | ```shell 80 | DEBUG=sparkbot* ACCCESS_TOKEN=Very_Secret node tests/onCommand.js 81 | 82 | ... 83 | sparkbot webhook instantiated with default configuration +0ms 84 | sparkbot addMessagesCreatedListener: listener registered +89ms 85 | sparkbot bot started on port: 8080 +8ms 86 | sparkbot:interpreter bot account detected, name: CiscoDevNet (bot) +1s 87 | ``` 88 | 89 | 90 | ### Capture inputs submitted via Cards 91 | 92 | We'll use a sample card that proposes a single `name` entry field. 93 | 94 | ![](docs/img/card-submission.png) 95 | 96 | To create the card, create a Webex Teams space and place the following requests with the roomId of your space, typically via Postman. 97 | Note: you can use this [postman collection](https://www.getpostman.com/collections/1f5e101d8290a5303c90) to experiment with Cards. After importing the collection, make sure to add a BOT_TOKEN variable to your Postman environment. 98 | 99 | ``` 100 | POST https://api.ciscospark.com/v1/messages 101 | Authorization: Bearer YOUR_BOT_TOKEN 102 | {{ 103 | "roomId": "{{_room}}", 104 | "markdown": "[Learn more](https://adaptivecards.io) about Adaptive Cards.", 105 | "attachments": [ 106 | { 107 | "contentType": "application/vnd.microsoft.card.adaptive", 108 | "content": { 109 | "type": "AdaptiveCard", 110 | "version": "1.0", 111 | "body": [ 112 | { 113 | "type": "TextBlock", 114 | "text": "Please enter your name:" 115 | }, 116 | { 117 | "type": "Input.Text", 118 | "id": "name", 119 | "title": "New Input.Toggle", 120 | "placeholder": "name" 121 | } 122 | ], 123 | "actions": [ 124 | { 125 | "type": "Action.Submit", 126 | "title": "Submit" 127 | } 128 | ] 129 | } 130 | } 131 | ] 132 | } 133 | ``` 134 | 135 | As cards are submitted, the [onCardSubmission()]() function invokes your custom code logic. 136 | Note: if a PUBLIC_URL environment variable is present, the example will automatically create a Webhook. 137 | 138 | ```shell 139 | DEBUG=sparkbot ACCESS_TOKEN=Y2QzMz_typically_a_bot_token_JlODAzZmItYTVm PUBLIC_URL="https://d3fc85fe.ngrok.io" node tests/onCardSubmission-webhook.js 140 | 141 | ... 142 | sparkbot webhook instantiated with default configuration +0ms 143 | sparkbot bot started on port: 8080 +37ms 144 | sparkbot webhook already exists with same properties, no creation needed +664ms 145 | webhook successfully checked, with id: Y2lzY29zcGFyazovL3VzL1dFQkhPT0svYTkyYWU5NjMtOTNmYS00YTE0LWEwOGItYTMzMjQ5MzA3MGQ4 146 | sparkbot webhook invoked +9s 147 | sparkbot calling listener for resource/event: attachmentActions/created, with data context: Y2lzY29zcGFyazovL3VzL0FUVEFDSE1FTlRfQUNUSU9OLzkyNmEzNDYwLWMzMjktMTFlOS05OGQyLTg5ZDBlNGQyZDU1Mg +6ms 148 | new attachmentActions from personId: Y2lzY29zcGFyazovL3VzL1BFT1BMRS85MmIzZGQ5YS02NzVkLTRhNDEtOGM0MS0yYWJkZjg5ZjQ0ZjQ , with inputs 149 | name: CiscoDevNet 150 | ``` 151 | 152 | 153 | Here is the code used by the sample, hou can check the full code sample here: 154 | 155 | ```javascript 156 | // Starts your Webhook with a default configuration where the Webex API access token is read from ACCESS_TOKEN 157 | const SparkBot = require("../sparkbot/webhook"); 158 | const bot = new SparkBot(); 159 | 160 | // Create webhook 161 | const publicURL = process.env.PUBLIC_URL || "https://d3fc85fe.ngrok.io"; 162 | bot.secret = process.env.WEBHOOK_SECRET || "not THAT secret"; 163 | bot.createOrUpdateWebhook("register-bot", publicURL, "attachmentActions", "created", null, bot.secret); 164 | 165 | // Process new card submissions 166 | bot.onCardSubmission(function (trigger, attachmentActions) { 167 | 168 | console.log(`new attachmentActions from personId: ${trigger.data.personId} , with inputs`); 169 | Object.keys(attachmentActions.inputs).forEach(prop => { 170 | console.log(` ${prop}: ${attachmentActions.inputs[prop]}`); 171 | }); 172 | }); 173 | ``` 174 | 175 | 176 | ## Architecture 177 | 178 | The library supports the full set of Webex Teams webhooks, see https://developer.webex.com/webhooks-explained.html 179 | 180 | As Webex fires Webhook events, the related listener functions are called. 181 | 182 | You can register to listen to a WebHook event by calling the function **".on(,)"** 183 | 184 | Note that the library implements a shortcut that lets you listen to all Webhook events. Check sample code [onEvent-all-all.js](tests/onEvent-all-all.js). 185 | 186 | ```javascript 187 | var SparkBot = require("sparkbot"); 188 | var bot = new SparkBot(); 189 | 190 | bot.onEvent("all", "all", function(trigger) { 191 | 192 | // YOUR CODE HERE 193 | console.log("EVENT: " + trigger.resource + "/" + trigger.event + ", with data id: " + trigger.data.id + ", triggered by person id:" + trigger.actorId); 194 | }); 195 | ``` 196 | 197 | This other example code [onEvent-messages-created.js](tests/onEvent-messages-created.js) illustrates how to listen to (Messages/Created) Webhook events. 198 | 199 | ```javascript 200 | // Starts your Webhook with default configuration where the Webex Teams API access token is read from the ACCESS_TOKEN env variable 201 | var SparkBot = require("sparkbot"); 202 | var bot = new SparkBot(); 203 | 204 | bot.onEvent("messages", "created", function(trigger) { 205 | console.log("new message from: " + trigger.data.personEmail + ", in room: " + trigger.data.roomId); 206 | bot.decryptMessage(trigger, function (err, message) { 207 | if (err) { 208 | console.log("could not fetch message contents, err: " + err.message); 209 | return; 210 | } 211 | 212 | // YOUR CODE HERE 213 | console.log("processing message contents: " + message.text); 214 | }); 215 | }); 216 | ``` 217 | 218 | 219 | ### New message listener 220 | 221 | The library also provides a shortcut easy way to listen to only new messages, via the .onMessage() function. 222 | 223 | Note that this function is not only a shorcut to create a **".on('messages', 'created')"** listener. 224 | It also automatically fetches for you the text of the new message by requesting Webex Teams for the message details. 225 | As the message is fetched behind the scene, you should position a ACCESS_TOKEN env variable when starting up your bot. 226 | 227 | Check the [onMessage.js](tests/onMessage.js) for an example: 228 | 229 | ```javascript 230 | // Starts your Webhook with default configuration where the Webex Teams API access token is read from the ACCESS_TOKEN env variable 231 | SparkBot = require("node-sparkbot"); 232 | var bot = new SparkBot(); 233 | 234 | bot.onMessage(function(trigger, message) { 235 | 236 | // ADD YOUR CUSTOM CODE HERE 237 | console.log("new message from: " + trigger.data.personEmail + ", text: " + message.text); 238 | }); 239 | ``` 240 | 241 | Note that most of the time, you'll want to check for the presence of a keyword to take action. 242 | To that purpose, you can check this example: [onMessage-asCommand](tests/onMessage-asCommand.js). 243 | 244 | ```javascript 245 | // Starts your Webhook with default configuration where the Webex Teams API access token is read from the ACCESS_TOKEN env variable 246 | var SparkBot = require("node-sparkbot"); 247 | var bot = new SparkBot(); 248 | 249 | bot.onMessage(function (trigger, message) { 250 | console.log("new message from: " + trigger.data.personEmail + ", text: " + message.text); 251 | 252 | var command = bot.asCommand(message); 253 | if (command) { 254 | // // ADD YOUR CUSTOM CODE HERE 255 | console.log("detected command: " + command.keyword + ", with args: " + JSON.stringify(command.args)); 256 | } 257 | }); 258 | ``` 259 | 260 | _Note that The onCommand function below is pretty powerful, as it not only checks for command keywords, but also removes any mention of your bot, as this mention is a Webex pre-requisite for your bot to receive a message in a group space, 261 | but it meaningless for your bot to process the message._ 262 | 263 | Well that said, we're ready to go thru the creation of interactive assistants. 264 | 265 | 266 | ### Interactive assistants 267 | 268 | To implement an interative assistant, you would typically: 269 | - respond to commands (keywords) via an `onCommand()` listener function 270 | - with an option to trim mention if your bot is mentionned in a group room 271 | - and an option to specify a fallback command 272 | - please check [onCommand](tests/onCommand.js) sample 273 | - respond to attachementActions submissions (cards) via an `onCardSubmission()` listener function 274 | - please check [onCardSubmission](tests/onCardSubmission.js) sample 275 | - manually create a webhook via a POST /webhooks request against the Webex REST API 276 | - OR use the `createOrUpdateWebhook()` function 277 | - please check [onCommand-webhook](tests/onCommand-webhook.js) or [onCardSubmission-webhook](tests/onCardSubmission-webhook.js) samples 278 | 279 | 280 | ### Healthcheck 281 | 282 | The library automatically exposes an healthcheck endpoint. 283 | As such, hitting GET / will respond a 200 OK with an attached JSON payload. 284 | 285 | The healcheck JSON payload will give you extra details: 286 | - token:true // if a token was detected at launch 287 | - account // detailled info about the Webex Teams account tied to the token 288 | - listeners // events for which your bot has registered a listener 289 | - commands // commands for which your bot is ready to be activated 290 | - interpreter // preferences for the command interpreter 291 | 292 | 293 | ```json 294 | // Example of a JSON healthcheck 295 | { 296 | "message": "Congrats, your Webex Teams bot is up and running", 297 | "since": "2016-09-01T13:15:39.425Z", 298 | "tip": "Register webhooks for your bot to start receiving events: https://developer.webex.com/endpoint-webhooks-post.html" 299 | "listeners": [ 300 | "messages/created" 301 | ], 302 | "token": true, 303 | "account": { 304 | "type": "human", 305 | "person": { 306 | "id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS85MmIzZGQ5YS02NzVkLTRhNDEtOGM0MS0yYWJkZjg5ZjQ0ZjQ", 307 | "emails": [ 308 | "stsfartz@cisco.com" 309 | ], 310 | "displayName": "Stève Sfartz", 311 | "avatar": "https://1efa7a94ed216783e352-c62266528714497a17239ececf39e9e2.ssl.cf1.rackcdn.com/V1~c2582d2fb9d11e359e02b12c17800f09~aqSu09sCTVOOx45HJCbWHg==~1600", 312 | "created": "2016-02-04T15:46:20.321Z" 313 | } 314 | }, 315 | "interpreter": { 316 | "prefix": "/", 317 | "trimMention": true, 318 | "ignoreSelf": false 319 | }, 320 | "commands": [ 321 | "help" 322 | ] 323 | } 324 | ``` 325 | 326 | 327 | ### Account Type Detection 328 | 329 | If a Webex teams API access token has been specified at launch, the library will request details about the Webex Teams account. 330 | From the person details provided, the library will infer if the token matches a individual (HUMAN) or a bot account (MACHINE). 331 | Because of restrictions concerning bots, the library will setup a default behavior from the account type, unless configuration parameters have already been provided at startup. 332 | 333 | _Note that the automatic account detection procedure is done asynchronously at launch._ 334 | 335 | Here is the set of extra information and behaviors that relate to the automatic bot detection: 336 | - your bot is populated with an account property 337 | - your bot is added a nickName property 338 | - a type is attached to your bot instance [HUMAN | MACHINE | OTHER] 339 | 340 | 341 | ### Authenticating Requests via payload signature 342 | 343 | To ensure paylods received by your bots come from Webex, you can supply a secret parameter at Webhook creation. 344 | Every payload posted to your bot will then contain an extra HTTP header "X-Spark-Signature" containing an HMAC-SHA1 signature of the payload. 345 | 346 | Once you've created the webhook for your bot with a secret parameter, 347 | you can either specify a SECRET environment variable on the command line or in your code. 348 | 349 | Command line example: 350 | ```shell 351 | DEBUG=sparkbot* ACCESS_TOKEN=your_bot_token WEBHOOK_SECRET=your_secret node tests/onEvent-check-secret.js 352 | ... 353 | ``` 354 | 355 | Code example: 356 | ```javascript 357 | // Starts your Webhook with default configuration where the Webex Teams API access token is read from the ACCESS_TOKEN env variable 358 | SparkBot = require("node-sparkbot"); 359 | var bot = new SparkBot(); 360 | bot.secret = "not THAT secret" 361 | ... 362 | ``` 363 | 364 | Note that it is a HIGHLY RECOMMENDED however not mandatory security practice to set up a SECRET. 365 | If your bot has been started with a secret, then the processing will abort if the incoming payload signature is not present or do not fit. 366 | However, the bot framework defines a flag so that you can ignore signature check failures when a SECRET is defined. 367 | 368 | 369 | ### Auto-Register Webhooks 370 | 371 | At startup, node-sparkbot can automatically create a Webhook for your bot, or verify if a webhook already exists with the specified name. 372 | Here are a few of the options supported: 373 | * Simplissime registration where defaults apply (all, all, no filter, no secret), and no callback 374 | > bot.createOrUpdateWebhook("register-bot", "https://f6d5d937.ngrok.io"); 375 | * Registration with no filter, no secret, and no callback 376 | > bot.createOrUpdateWebhook("register-bot", "https://f6d5d937.ngrok.io", "all", "all"); 377 | * Registration with a filter, no secret, no callback 378 | > bot.createOrUpdateWebhook("register-bot", "https://f6d5d937.ngrok.io", "all", "all", "roomId=XXXXXXXXXXXXXXX"); 379 | * Registration with no filter, but a secret and a callback 380 | > bot.createOrUpdateWebhook("register-bot", publicURL, "all", "all", null, bot.secret, function (err, webhook) { ... } 381 | 382 | You can check [onCommand-webhook.js](tests/onCommand-webhook.js) for an example. 383 | 384 | ```javascript 385 | bot.createOrUpdateWebhook("register-bot", "https://f6d5d937.ngrok.io", "all", "all", null, bot.secret, function (err, webhook) { 386 | console.log("webhook successfully created, id: " + webhook.id); 387 | }); 388 | ``` 389 | 390 | 391 | ### Minimal footprint 392 | 393 | node-sparkbot makes a minimal use of third party libraries : 394 | - debug: as we need a customizable logger 395 | - express & body-parser: as its Web API foundation 396 | - htmlparser2: to filter out the bot mention from the message contents 397 | 398 | Morever, node-sparkbot does not embedd any Webex Teams client SDK, 399 | so that you can choose your favorite (ie, among ciscospark, node-sparky or node-sparkclient...) to interact with Webex. 400 | 401 | 402 | ## Contribute 403 | 404 | Feedback, issues, thoughts... please use [github issues](https://github.com/CiscoDevNet/node-sparkbot/issues/new). 405 | 406 | Interested in contributing code? 407 | - Check for open issues or create a new one. 408 | - Submit a pull request. Just make sure to reference the issue. 409 | -------------------------------------------------------------------------------- /docs/SettingUpYourSparkBot.md: -------------------------------------------------------------------------------- 1 | # How to setup your Webex Teams bot 2 | 3 | This guide details how to have your local bot (ie, running on a dev machine or a private network), talk to the Webex cloud platform. 4 | 5 | 1. Start you bot 6 | 2. Check your bot is healthy 7 | 3. Expose your bot 8 | 4. Create a Spark Webhook 9 | 10 | 11 | ## Start you bot 12 | 13 | **Skip this (if you have already started your bot)** 14 | 15 | Here are the steps to install this project samples and run them 16 | 17 | 1. Clone the repo 18 | 2. Install dependencies 19 | 3. Run an example from the tests 20 | 21 | ``` bash 22 | # Clone repo 23 | > git clone https://github.com/CiscoDevNet/node-sparkbot 24 | # Install dependencies 25 | > cd sparkbot-webhook-samples 26 | > npm install 27 | # Run an example 28 | > DEBUG=sparkbot*,samples* node tests/onEvent-all-all.js 29 | ... 30 | bot started at http://localhost:8080/ 31 | GET / for health checks 32 | POST / receives Webex webhook events 33 | ``` 34 | 35 | ## Check your bot is healthy 36 | 37 | Hit localhost to check your bot is running, either via [your Web browser](http://localhost:8080) or via CURL (see below) 38 | You should get back an JSON payload with your bot properties. 39 | 40 | ``` bash 41 | # Ping your bot 42 | > curl http://localhost:8080 43 | ... 44 | { 45 | "message": "Congrats, your webhook is up and running", 46 | "since": "2016-09-01T13:15:39.425Z", 47 | "listeners": [ 48 | ... 49 | ``` 50 | 51 | 52 | ## Expose you bot 53 | 54 | To expose your boot on the internet, we'll leverage a tunnelling tool. 55 | You may pick any tool, the steps below leverage localtunnel. 56 | 57 | There you need to choose a unique subdomain name, which localtunnel will use to create your bot public internel URL. 58 | 59 | Replace **** in the following steps by your bot subdomain name. 60 | 61 | Note : make sure you hit your bot **secured** HTTPS endpoint. 62 | 63 | 64 | ``` bash 65 | # Install local tunnel 66 | > npm install localtunnel -g 67 | # Create the tunnel 68 | > lt -s -p 8080 69 | your url is: http://.localtunnel.me 70 | 71 | # In another terminal, check your bot is accessible 72 | > curl https://.localtunnel.me/ 73 | { 74 | "message": "Congrats, your Cisco Spark webhook is up and running", 75 | "since": "2016-09-01T13:15:39.425Z", 76 | "listeners": [ 77 | "messages/created" 78 | ], 79 | "token": true, 80 | "account": { 81 | "type": "human", 82 | "person": { 83 | "id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS85MmIzZGQ5YS02NzVkLTRhNDEtOGM0MS0yYWJkZjg5ZjQ0ZjQ", 84 | "emails": [ 85 | "stsfartz@cisco.com" 86 | ], 87 | "displayName": "Stève Sfartz", 88 | "avatar": "https://1efa7a94ed216783e352-c62266528714497a17239ececf39e9e2.ssl.cf1.rackcdn.com/V1~c2582d2fb9d11e359e02b12c17800f09~aqSu09sCTVOOx45HJCbWHg==~1600", 89 | "created": "2016-02-04T15:46:20.321Z" 90 | } 91 | }, 92 | "interpreter": { 93 | "prefix": "/", 94 | "trimMention": true, 95 | "ignoreSelf": false 96 | }, 97 | "commands": [ 98 | "help" 99 | ], 100 | "tip": "Register your bot as a WebHook to start receiving events: https://developer.ciscospark.com/endpoint-webhooks-post.html" 101 | } 102 | ``` 103 | 104 | 105 | ## Register your bot with a WebHook 106 | 107 | Last step, is to create a Webex Teams Webhook for your bot. 108 | 109 | This can be done via the Webex Developer Portal / [Create a WebHook](https://developer.webex.com/endpoint-webhooks-post.html) interactive documentation, 110 | but also via Postman or a CURL command as will see right after. 111 | 112 | ### via the interactive documentation 113 | 114 | For the scope of this example, we'll associate our bot to all resources and events. 115 | 116 | Note: even if our webhook can process all events, you can register a webhook with a more limited set of events. Then Webex will then invoke your webhook only if those events happen (whatever your bot can process). 117 | 118 | ![](img/spark4devs-create-webhook-all-all.png) 119 | 120 | 121 | ### via CURL 122 | 123 | As an alternative, you can run this CURL command. 124 | 125 | ``` bash 126 | > curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer YOUR_BOT_TOKEN" -d '{ 127 | "name": "Bot Sample", 128 | "resource": "all", 129 | "event": "all", 130 | "targetUrl": "https://yourbot.localtunnel.me/" 131 | }' "https://api.ciscospark.com/v1/webhooks/" 132 | ``` 133 | 134 | 135 | ### via postman 136 | 137 | Or you can also create this webhook via Postman. 138 | 139 | ![](img/postman-create-webhook-all-all.png) 140 | -------------------------------------------------------------------------------- /docs/img/card-submission.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiscoDevNet/node-sparkbot/0ffdcaf3700e467484672eb54d437f1b69fcf24a/docs/img/card-submission.png -------------------------------------------------------------------------------- /docs/img/postman-create-webhook-all-all.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiscoDevNet/node-sparkbot/0ffdcaf3700e467484672eb54d437f1b69fcf24a/docs/img/postman-create-webhook-all-all.png -------------------------------------------------------------------------------- /docs/img/spark4devs-create-webhook-all-all - Copy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiscoDevNet/node-sparkbot/0ffdcaf3700e467484672eb54d437f1b69fcf24a/docs/img/spark4devs-create-webhook-all-all - Copy.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./sparkbot'); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-sparkbot", 3 | "version": "v2.3.0", 4 | "description": "Build Webex chatbots in Node.js", 5 | "main": "index.js", 6 | "keywords": [ 7 | "Webex", 8 | "Webhook", 9 | "Node", 10 | "Express", 11 | "ChatBot" 12 | ], 13 | "scripts": { 14 | "start": "node tests/onCardSubmission-webhook.js" 15 | }, 16 | "homepage": "https://github.com/CiscoDevNet/node-sparkbot#readme", 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/CiscoDevNet/node-sparkbot.git" 20 | }, 21 | "author": "Stève Sfartz (Cisco DevNet)", 22 | "license": "MIT", 23 | "dependencies": { 24 | "body-parser": "^1.19.0", 25 | "debug": "^4.1.1", 26 | "express": "^4.17.1", 27 | "got": "^9.6.0", 28 | "htmlparser2": "^3.10.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /quickstart/README.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | If you're looking for **end-to-end bot use-cases**, go straight to the [node-sparkbot-samples](https://github.com/CiscoDevNet/node-sparkbot-samples). 4 | 5 | To start with the framework, start from the `onEvent-all-all.js` example to run some code that listens to Webex Teams event, 6 | and create a [Webex Teams Webhook](https://developer.webex.com/webhooks-explained.html) to start receiving notifications. 7 | 8 | Check the instructions on the [main project page](https://github.com/CiscoDevNet/node-sparkbot/tree/master/#quickstart) to run the code on your local machine. 9 | -------------------------------------------------------------------------------- /quickstart/express-webhook.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook based on pure Express.js. 8 | * 9 | * goal here is to illustrate how to create a bot without any library 10 | * 11 | */ 12 | 13 | const express = require("express"); 14 | const app = express(); 15 | 16 | const bodyParser = require("body-parser"); 17 | app.use(bodyParser.urlencoded({extended: true})); 18 | app.use(bodyParser.json()); 19 | 20 | const debug = require("debug")("samples"); 21 | 22 | const started = Date.now(); 23 | app.route("/") 24 | // healthcheck 25 | .get(function (req, res) { 26 | res.json({ 27 | message: "Congrats, your bot is up and running", 28 | since: new Date(started).toISOString(), 29 | code: "express-all-in-one.js", 30 | tip: "Register your bot as a WebHook to start receiving events: https://developer.webex.com/endpoint-webhooks-post.html" 31 | }); 32 | }) 33 | 34 | // webhook endpoint 35 | .post(function (req, res) { 36 | 37 | // analyse incoming payload, should conform to Webex Teams webhook trigger specifications 38 | debug("DEBUG: webhook invoked"); 39 | if (!req.body || !Utils.checkWebhookEvent(req.body)) { 40 | console.log("WARNING: Unexpected payload POSTed, aborting..."); 41 | res.status(400).json({message: "Bad payload for Webhook", 42 | details: "either the bot is misconfigured or Webex Teams is running a new API version"}); 43 | return; 44 | } 45 | 46 | // event is ready to be processed, let's send a response to Webex without waiting any longer 47 | res.status(200).json({message: "message is being processed by webhook"}); 48 | 49 | // process incoming resource/event, see https://developer.webex.com/webhooks-explained.html 50 | processWebhookEvent(req.body); 51 | }); 52 | 53 | 54 | // Starts the Bot service 55 | // 56 | // [WORKAROUND] in some container situation (ie, Cisco Shipped), we need to use an OVERRIDE_PORT to force our bot to start and listen to the port defined in the Dockerfile (ie, EXPOSE), 57 | // and not the PORT dynamically assigned by the host or scheduler. 58 | const port = process.env.OVERRIDE_PORT || process.env.PORT || 8080; 59 | app.listen(port, function () { 60 | console.log("Webex Teams bot started at http://localhost:" + port + "/"); 61 | console.log(" GET / for health checks"); 62 | console.log(" POST / to procress new Webhook events"); 63 | }); 64 | 65 | 66 | // Invoked when the webhook is triggered 67 | function processWebhookEvent(trigger) { 68 | 69 | // 70 | // YOUR CODE HERE 71 | // 72 | console.log("EVENT: " + trigger.resource + "/" + trigger.event + ", with data id: " + trigger.data.id + ", triggered by person id:" + trigger.actorId); 73 | 74 | } -------------------------------------------------------------------------------- /quickstart/onCardSubmission-webhook.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook that leverages a simple library (batteries included) 8 | * note: 9 | * - this example requires that you've set an ACCESS_TOKEN env variable with a Bot access token so that you can submit data from your Webex User account 10 | * - the code creates or updates a webhook that posts data to a publically accessible URL 11 | * 12 | */ 13 | 14 | 15 | // Starts your Webhook with a default configuration where the Webex API access token is read from ACCESS_TOKEN 16 | const SparkBot = require("node-sparkbot"); 17 | const bot = new SparkBot(); 18 | 19 | // Create webhook 20 | const publicURL = process.env.PUBLIC_URL || "https://d3fc85fe.ngrok.io"; 21 | bot.secret = process.env.WEBHOOK_SECRET || "not THAT secret"; 22 | bot.createOrUpdateWebhook("register-bot", publicURL, "attachmentActions", "created", null, bot.secret, function (err, webhook) { 23 | if (err) { 24 | console.error("could not create Webhook, err: " + err); 25 | 26 | // Fail fast 27 | process.exit(1); 28 | } 29 | 30 | console.log("webhook successfully checked, with id: " + webhook.id); 31 | }); 32 | 33 | bot.onCardSubmission(function (trigger, attachmentActions) { 34 | 35 | // 36 | // ADD YOUR CUSTOM CODE HERE 37 | // 38 | console.log(`new attachmentActions from personId: ${trigger.data.personId} , with inputs`); 39 | Object.keys(attachmentActions.inputs).forEach(prop => { 40 | console.log(` ${prop}: ${attachmentActions.inputs[prop]}`); 41 | }); 42 | 43 | }); 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /quickstart/onCommand-webhook.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook that leverages a simple library (batteries included) 8 | * 9 | * note : this example requires that you've set a ACCESS_TOKEN env variable 10 | * 11 | */ 12 | 13 | const SparkBot = require("node-sparkbot"); 14 | 15 | // Starts your Webhook with default configuration where the Webex Teams API access token is read from the SPARK_TOKEN env variable 16 | const bot = new SparkBot(); 17 | 18 | // Registers the bot to the Webex platform to start receiving notifications 19 | // We list here various options to register your bot: pick one and update the code with your bot name and its public endpoint 20 | 21 | // Simplissime registration where defaults apply (all, all, no filter, no secret), and no callback 22 | //bot.createOrUpdateWebhook("register-bot", "https://f6d5d937.ngrok.io"); 23 | 24 | // Registration without any filter, secret, and callback 25 | //bot.createOrUpdateWebhook("register-bot", "https://f6d5d937.ngrok.io", "all", "all"); 26 | 27 | // Registration with a filter, no secret, no callback 28 | //bot.createOrUpdateWebhook("register-bot", "https://f6d5d937.ngrok.io", "all", "all", "roomId=XXXXXXXXXXXXXXX"); 29 | 30 | // Registration with no filter, but a secret and a callback 31 | // note that the secret needs to be known to the bot so that it can check the payload signatures 32 | const publicURL = process.env.PUBLIC_URL || "https://f6d5d937.ngrok.io"; 33 | bot.secret = process.env.WEBHOOK_SECRET || "not THAT secret"; 34 | bot.createOrUpdateWebhook("register-bot", publicURL, "all", "all", null, bot.secret, function (err, webhook) { 35 | if (err) { 36 | console.error("could not create Webhook, err: " + err); 37 | 38 | // Fail fast 39 | process.exit(1); 40 | } 41 | 42 | console.log("webhook successfully checked, with id: " + webhook.id); 43 | }); 44 | 45 | // Registration with no filter, but a secret and a callback 46 | // bot name and public endpoint are read from env variables, the WEBHOOK_SECRET env variable is used to initialize the secret 47 | // make sure to initialize these env variables 48 | // - BOT_NAME="register-bot" 49 | // - WEBHOOK_SECRET="not THAT secret" 50 | // - PUBLIC_URL="https://f6d5d937.ngrok.io" 51 | // example: 52 | // DEBUG=sparkbot* BOT_NAME="register-bot" PUBLIC_URL="https://f6d5d937.ngrok.io" WEBHOOK_SECRET="not THAT secret" ACCESS_TOKEN="MjdkYjRhNGItM2E1ZS00YmZjLTk2ZmQtO" node tests/onCommand-register.js 53 | //bot.createOrUpdateWebhook(process.env.BOT_NAME, process.env.PUBLIC_URL, "all", "all", null, bot.secret, function (err, webhook) { 54 | // if (err) { 55 | // console.log("Could not register the bot, please check your env variables are all set: ACCESS_TOKEN, BOT_NAME, PUBLIC_URL"); 56 | // return; 57 | // } 58 | // console.log("webhook successfully created, id: " + webhook.id); 59 | //}); 60 | 61 | 62 | // Override default prefix "/" to "" so that our bot will obey to "help"" instead of "/help" 63 | bot.interpreter.prefix=""; 64 | 65 | bot.onCommand("help", function(command) { 66 | // ADD YOUR CUSTOM CODE HERE 67 | console.log("new command: " + command.keyword + ", from: " + command.message.personEmail + ", with args: " + JSON.stringify(command.args)); 68 | }); 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /quickstart/onEvent-all-all.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a bot that listens to all Webex Teams Webhook events 8 | * 9 | */ 10 | 11 | // Starts your Webhook with default configuration 12 | const SparkBot = require("node-sparkbot"); 13 | const bot = new SparkBot(); 14 | 15 | bot.onEvent("all", "all", function(trigger) { 16 | 17 | // 18 | // YOUR CODE HERE 19 | // 20 | console.log("New event (" + trigger.resource + "/" + trigger.event + "), with data id: " + trigger.data.id + ", triggered by person id:" + trigger.actorId); 21 | console.log("Learn more about Webhooks: at https://developer.webex.com/webhooks-explained.html"); 22 | }); 23 | 24 | -------------------------------------------------------------------------------- /quickstart/onEvent-check-secret.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook that leverages a simple library (batteries included) 8 | * 9 | */ 10 | 11 | // Starts your Webhook with default configuration 12 | const SparkBot = require("node-sparkbot"); 13 | const bot = new SparkBot(); 14 | 15 | // Specify the secret to check against incoming payloads 16 | bot.secret = "not THAT secret" 17 | 18 | bot.onEvent("all", "all", function(trigger) { 19 | 20 | // 21 | // YOUR CODE HERE 22 | // 23 | console.log("EVENT: " + trigger.resource + "/" + trigger.event + ", with data id: " + trigger.data.id + ", triggered by person id:" + trigger.actorId); 24 | 25 | }); 26 | 27 | -------------------------------------------------------------------------------- /quickstart/onEvent-messages-created.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook that leverages a simple library (batteries included) 8 | * 9 | * note : this example requires that you've set an ACCESS_TOKEN env variable 10 | * 11 | */ 12 | 13 | // Starts your Webhook with default configuration where the Webex Teams API access token is read from the ACCESS_TOKEN env variable 14 | const SparkBot = require("node-sparkbot"); 15 | const bot = new SparkBot(); 16 | 17 | bot.onEvent("messages", "created", function(trigger) { 18 | console.log("new message from: " + trigger.data.personEmail + ", in room: " + trigger.data.roomId); 19 | 20 | bot.decryptMessage(trigger, function (err, message) { 21 | 22 | if (err) { 23 | console.log("could not fetch message contents, err: " + err.message); 24 | return; 25 | } 26 | 27 | // 28 | // YOUR CODE HERE 29 | // 30 | console.log("processing message contents: " + message.text); 31 | 32 | }); 33 | 34 | }); 35 | 36 | -------------------------------------------------------------------------------- /quickstart/onMessage-asCommand.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook that leverages a simple library (batteries included) 8 | * 9 | * note : this example requires that you've set an ACCESS_TOKEN env variable 10 | * 11 | */ 12 | 13 | // Starts your Webhook with default configuration where the Webex Teams API access token is read from the ACCESS_TOKEN env variable 14 | const SparkBot = require("node-sparkbot"); 15 | const bot = new SparkBot(); 16 | 17 | bot.onMessage(function (trigger, message) { 18 | 19 | // 20 | // ADD YOUR CUSTOM CODE HERE 21 | // 22 | console.log("new message from: " + trigger.data.personEmail + ", text: " + message.text); 23 | 24 | let command = bot.asCommand(message); 25 | if (command) { 26 | console.log("detected command: " + command.keyword + ", with args: " + JSON.stringify(command.args)); 27 | } 28 | }); 29 | 30 | -------------------------------------------------------------------------------- /sparkbot/interpreter.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | var got = require("got"); 7 | var htmlparser = require("htmlparser2"); 8 | 9 | var debug = require("debug")("sparkbot:interpreter"); 10 | var fine = require("debug")("sparkbot:interpreter:fine"); 11 | 12 | 13 | 14 | var sparkAccounts = ["machine", "human", "unknown"]; 15 | 16 | /* Helper library to interpret commands as they flow in 17 | * Please invoke with a valid Webhook configuration 18 | */ 19 | function CommandInterpreter(config) { 20 | this.token = config.token; 21 | if (!this.token) { 22 | debug("token required, skipping interpreter initialization..."); 23 | return; 24 | } 25 | 26 | // A bot should ignore its own messages by default 27 | // Only case when this would be set to true is for a developer to test a bot with its personal developer token 28 | if (typeof this.ignoreSelf != "boolean") { 29 | this.ignoreSelf = true; 30 | } 31 | 32 | // No prefix to commands by default 33 | this.prefix = config.commandPrefix ? config.commandPrefix : ""; 34 | 35 | // Bot Mentions should be trimmed by default 36 | if (typeof this.trimMention != "boolean") { 37 | this.trimMention = true; 38 | } 39 | 40 | // Let's identify the account type and finalize configuration in there 41 | this.accountType = "unknown"; 42 | this.person = null; 43 | var self = this; 44 | detectAccount(this.token, function (err, account, people) { 45 | if (err) { 46 | debug("could not retreive account type, err: " + err + ", continuing..."); 47 | return; 48 | } 49 | 50 | self.accountType = account; 51 | self.person = people; 52 | 53 | // Decode account id identifier to better trim mentions (see trimMention) 54 | var temp = Buffer.from(people.id, 'base64'); 55 | self.person.rawId = temp.toString().substring(23); 56 | 57 | // Infer how Webex would generate a nickname for the bot, 58 | // which is approximate as the nick name would depend on the name of other room members... 59 | var splitted = people.displayName.split(' '); 60 | self.nickName = splitted[0]; 61 | }); 62 | 63 | } 64 | 65 | 66 | // Return a string in which webhook mentions are removed 67 | // Note : we need to start from the HTML text as it only includes mentions for sure. In practice, the plain text message may include a nickname 68 | function trimMention(person, message) { 69 | 70 | // If the message does not contain HTML, no need parsing it for Mentions 71 | if (!message.html) { 72 | return message.text; 73 | } 74 | 75 | var buffer = ""; 76 | var skip = 0; 77 | var group = 0; 78 | var parser = new htmlparser.Parser({ 79 | onopentag: function (tagname, attribs) { 80 | fine("opening brace name: " + tagname + ", with args: " + JSON.stringify(attribs)); 81 | if (tagname === "spark-mention") { 82 | if (attribs["data-object-type"] == "person" && attribs["data-object-id"] == person.id) { 83 | skip++; // to skip next text as bot was mentionned 84 | } 85 | 86 | // [Workaround] for Mac clients, see issue https://github.com/CiscoDevNet/node-sparkbot/issues/1 87 | if (attribs["data-object-type"] == "person" && attribs["data-object-id"] == person.rawId) { 88 | skip++; // to skip next text as bot was mentionned 89 | } 90 | } 91 | }, 92 | ontext: function (text) { 93 | if (!skip) { 94 | fine("appending: " + text); 95 | if (group > 0) { 96 | buffer += " "; 97 | } 98 | buffer += text.trim(); 99 | group++; 100 | } 101 | else { 102 | skip--; // skipped, let's continue HTML parsing in case other bot mentions appear 103 | group = 0; 104 | } 105 | }, 106 | onclosetag: function (tagname) { 107 | fine("closing brace name: " + tagname); 108 | } 109 | }, { decodeEntities: true }); 110 | parser.parseComplete(message.html); 111 | 112 | debug("trimed: " + buffer); 113 | return buffer; 114 | } 115 | 116 | 117 | // checks if a command can be extracted, if so, returns it, and null otherwise. 118 | // extra features 119 | // - can trim bot name when the bot is mentionned 120 | // - can filter out messages from bot 121 | CommandInterpreter.prototype.extract = function (message) { 122 | // If the message comes from the bot, ignore it 123 | if (this.ignoreSelf && (message.personId === this.person.id)) { 124 | debug("bot is writing => ignoring"); 125 | return null; 126 | } 127 | 128 | 129 | // If the message does not contain any text, simply ignore it 130 | // GTK: happens in case of a pure file attachement for example 131 | var text = message.text; 132 | if (!text) { 133 | debug("no text in message => ignoring"); 134 | return null; 135 | } 136 | 137 | // Remove mention if in a group room 138 | if ((message.roomType == "group") && this.trimMention) { 139 | if (message.mentionedPeople && (message.mentionedPeople.length > 0)) { 140 | fine("removing bot mentions if present in: " + text); 141 | text = trimMention(this.person, message); 142 | } 143 | } 144 | 145 | // Remove extra whitespaces 146 | text = text.replace(/\s\s+/g, " "); 147 | if (!text) { 148 | debug("no text in message after trimming => ignoring"); 149 | return null; 150 | } 151 | 152 | // If it is not a command, ignore it 153 | var prefixLength = 0; 154 | if (this.prefix) { 155 | prefixLength = this.prefix.length; 156 | 157 | // Check if prefix matches 158 | if (this.prefix != text.substring(0, prefixLength)) { 159 | debug("text does not start with the command prefix: " + this.prefix + " => ignoring..."); 160 | return null; 161 | } 162 | } 163 | 164 | // Extract command 165 | var splitted = text.substring(prefixLength).split(' '); 166 | var keyword = splitted[0]; 167 | if (!keyword) { 168 | debug("empty command, ignoring"); 169 | return null; 170 | } 171 | splitted.shift(); 172 | 173 | var command = { "keyword": keyword, "args": splitted, "message": message }; 174 | debug("detected command: " + command.keyword + ", with args: " + JSON.stringify(command.args) + ", in message: " + command.message.id); 175 | return command; 176 | } 177 | 178 | 179 | 180 | // Detects account type by invoking the Webex Teams People ressource 181 | // - HUMAN if the token corresponds to a bot account, 182 | // - BOT otherwise 183 | // 184 | // cb function signature should be (err, type, account) where type: HUMAN|BOT, account: People JSON structure 185 | // 186 | function detectAccount(token, cb) { 187 | fine("checking account"); 188 | 189 | const client = got.extend({ 190 | baseUrl: process.env.WEBEX_API || 'https://api.ciscospark.com/v1', 191 | headers: { 192 | 'authorization': 'Bearer ' + token 193 | }, 194 | json: true 195 | }); 196 | 197 | (async () => { 198 | try { 199 | const response = await client.get('/people/me'); 200 | fine(`/people/me received a ${response.statusCode}`); 201 | 202 | switch (response.statusCode) { 203 | case 200: 204 | break; // we're good, let's proceed 205 | 206 | case 401: 207 | debug("Webex Teams authentication failed: 401, bad token"); 208 | cb(new Error("response status: " + response.statusCode + ", bad token"), null, null); 209 | return; 210 | 211 | default: 212 | debug("could not retreive Webex Teams account, status code: " + response.statusCode); 213 | cb(new Error("response status: " + response.statusCode), null, null); 214 | return; 215 | } 216 | 217 | // Robustify 218 | const payload = response.body; 219 | if (!payload.emails) { 220 | debug("could not retreive Webex Teams account, unexpected payload"); 221 | cb(new Error("unexpected payload: not json"), null, null); 222 | return; 223 | } 224 | var email = payload.emails[0]; 225 | if (!email) { 226 | debug("could not retreive Webex Teams account, unexpected payload: no email"); 227 | cb(new Error("unexpected payload: no email"), null, null); 228 | return; 229 | } 230 | 231 | // Check if email corresponds to a bot 232 | var splitted = email.split("@"); 233 | if (!splitted || (splitted.length != 2)) { 234 | debug("could not retreive Spark account, malformed email"); 235 | cb(new Error("unexpected payload: malformed email"), null, null); 236 | return; 237 | } 238 | var domain = splitted[1]; 239 | // [COMPATIBILITY] Keeping sparkbot.io for backward compatibility 240 | if (('webex.bot' === domain) || ('sparkbot.io' === domain)) { 241 | debug("bot account detected, name: " + payload.displayName); 242 | cb(null, "machine", payload); 243 | return; 244 | } 245 | 246 | debug("human account detected, name: " + payload.displayName); 247 | cb(null, "human", payload); 248 | 249 | } catch (error) { 250 | fine(`error in /people/me, code: ${error.code}`) 251 | debug("cannot find a Webex Teams account for specified token, error: " + error.message); 252 | cb(new Error("cannot find Webex Teams account for specified token"), null, null); 253 | } 254 | })(); 255 | } 256 | 257 | 258 | module.exports = CommandInterpreter; -------------------------------------------------------------------------------- /sparkbot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "./webhook.js" 3 | } -------------------------------------------------------------------------------- /sparkbot/registration.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | const got = require("got"); 7 | 8 | const debug = require("debug")("sparkbot:utils"); 9 | const fine = require("debug")("sparkbot:utils:fine"); 10 | 11 | const Registration = {}; 12 | module.exports = Registration; 13 | 14 | 15 | // Create a WebHook, see https://developer.webex.com/endpoint-webhooks-post.html for specs 16 | // 17 | // cb function signature should be (err, webhook) 18 | // 19 | Registration.createWebhook = function (token, name, targetUrl, resource, event, filter, secret, cb) { 20 | 21 | // Build the post string from an object 22 | const post_data = { 23 | 'name': name, 24 | 'resource': resource, 25 | 'event': event, 26 | 'targetUrl': targetUrl 27 | }; 28 | if (filter) { 29 | post_data.filter = filter; 30 | } 31 | if (secret) { 32 | post_data.secret = secret; 33 | } 34 | 35 | // Request instance 36 | const client = got.extend({ 37 | baseUrl: process.env.WEBEX_API || 'https://api.ciscospark.com/v1', 38 | headers: { 39 | 'authorization': 'Bearer ' + token 40 | } 41 | }); 42 | 43 | // Invoke Webex API 44 | const path = '/webhooks'; 45 | (async () => { 46 | try { 47 | const response = await client.post(path, { 48 | body: post_data, 49 | json: true, 50 | responseType: 'json' 51 | }); 52 | fine(`POST ${path} received a: ${response.statusCode}`); 53 | 54 | switch (response.statusCode) { 55 | case 200: 56 | break; // we're good, let's proceed 57 | 58 | case 401: 59 | debug("Webex authentication failed: 401, bad token"); 60 | if (cb) cb(new Error("response status: " + response.statusCode + ", bad token"), null); 61 | return; 62 | 63 | default: 64 | debug("could not create WebHook, status code: " + response.statusCode); 65 | if (cb) cb(new Error("response status: " + response.statusCode), null); 66 | return; 67 | } 68 | 69 | // [TODO] Robustify by checking the payload format 70 | 71 | // Return 72 | const webhook = response.body; 73 | fine("webhook created, id: " + webhook.id); 74 | if (cb) cb(null, webhook); 75 | 76 | } catch (error) { 77 | fine(`error while invoking: ${path}, code: ${error.code}`) 78 | debug("cannot create the webhook, error: " + error.message); 79 | if (cb) cb(new Error("cannot create the webhook"), null); 80 | } 81 | })(); 82 | } 83 | 84 | 85 | // Deletes a WebHook 86 | // 87 | // cb function signature should be (err, statusCode) 88 | // 89 | Registration.deleteWebhook = function (token, webhookId, cb) { 90 | 91 | // Request instance 92 | const client = got.extend({ 93 | baseUrl: process.env.WEBEX_API || 'https://api.ciscospark.com/v1', 94 | headers: { 95 | 'authorization': 'Bearer ' + token 96 | }, 97 | json: true 98 | }); 99 | 100 | // Invoke Webex API 101 | const resource = '/webhooks/' + webhookId; 102 | (async () => { 103 | try { 104 | const response = await client.delete(resource); 105 | fine(`DELETE ${resource} received a: ${response.statusCode}`); 106 | 107 | switch (response.statusCode) { 108 | case 204: 109 | break; // we're good, let's proceed 110 | 111 | case 401: 112 | debug("Webex authentication failed: 401, bad token"); 113 | if (cb) cb(new Error("response status: " + response.statusCode + ", bad token"), null); 114 | return; 115 | 116 | default: 117 | debug("could not delete Webhook, status code: " + response.statusCode); 118 | if (cb) cb(new Error("response status: " + response.statusCode), null); 119 | return; 120 | } 121 | 122 | if (cb) cb(null, 204); 123 | 124 | } catch (error) { 125 | fine(`error in ${resource}, code: ${error.code}`) 126 | ddebug("cannot delete the webhook, error: " + error.message); 127 | if (cb) cb(new Error("cannot delete the webhook"), null); 128 | } 129 | })(); 130 | } 131 | 132 | 133 | 134 | // Lists WebHooks 135 | // 136 | // cb function signature should be (err, webhooks) 137 | // 138 | Registration.listWebhooks = function (token, cb) { 139 | 140 | // Request instance 141 | const client = got.extend({ 142 | baseUrl: process.env.WEBEX_API || 'https://api.ciscospark.com/v1', 143 | headers: { 144 | 'authorization': 'Bearer ' + token 145 | }, 146 | json: true 147 | }); 148 | 149 | // Invoke Webex API 150 | const resource = '/webhooks'; 151 | (async () => { 152 | try { 153 | const response = await client.get(resource); 154 | fine(`GET ${resource} received a: ${response.statusCode}`); 155 | 156 | switch (response.statusCode) { 157 | case 200: 158 | break; // we're good, let's proceed 159 | 160 | case 401: 161 | debug("Webex authentication failed: 401, bad token"); 162 | if (cb) cb(new Error("response status: " + response.statusCode + ", bad token"), null); 163 | return; 164 | 165 | default: 166 | debug("could not retreive Webhook, status code: " + response.statusCode); 167 | if (cb) cb(new Error("response status: " + response.statusCode), null); 168 | return; 169 | } 170 | 171 | // [TODO] Robustify by checking the payload format 172 | var payload = response.body; 173 | if (!payload || !payload.items) { 174 | debug("could not retreive Webhooks, malformed payload: "); 175 | if (cb) cb(new Error("Could not retreive Webhooks, malformed payload"), null); 176 | return; 177 | } 178 | 179 | // Return webhooks 180 | if (cb) cb(null, payload.items); 181 | 182 | } catch (error) { 183 | fine(`error in ${resource}, code: ${error.code}`) 184 | debug("cannot list webhooks, error: " + error.message); 185 | if (cb) cb(new Error("cannot lists webhooks"), null); 186 | } 187 | })(); 188 | 189 | } 190 | 191 | -------------------------------------------------------------------------------- /sparkbot/router.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | var debug = require("debug")("sparkbot:router"); 7 | var fine = require("debug")("sparkbot:router:fine"); 8 | 9 | 10 | /* 11 | * Listener to new (messages/created) Webhook events which does routing based on command keyword 12 | */ 13 | function CommandRouter(webhook) { 14 | this.commands = {}; 15 | 16 | // add router as (messages/created) listener 17 | if (!webhook) { 18 | debug("webhook required, skipping router initialization..."); 19 | return; 20 | } 21 | this.webhook = webhook; 22 | 23 | var self = this; 24 | webhook.onMessage(function(trigger, message) { 25 | var command = webhook.asCommand(message); 26 | if (!command || !command.keyword) { 27 | debug("could not interpret message as a command, aborting..."); 28 | return; 29 | } 30 | 31 | fine("new command: " + command.keyword + ", with args: " + JSON.stringify(command.args)); 32 | var listener = self.commands[command.keyword]; 33 | if (!listener) { 34 | fine("no listener for command: " + command.keyword); 35 | 36 | // Looking for a fallback listener 37 | listener = self.commands["fallback"]; 38 | if (listener) { 39 | debug("found fallback listener => invoking"); 40 | listener(command); 41 | } 42 | return; 43 | } 44 | 45 | debug("firing new command: " + command.keyword); 46 | listener(command); 47 | }); 48 | } 49 | 50 | 51 | CommandRouter.prototype.addCommand = function (command, listener) { 52 | // Robustify 53 | if (!command ||!listener) { 54 | debug("addCommand: bad arguments, aborting..."); 55 | return; 56 | } 57 | 58 | debug("added listener for command: " + command); 59 | this.commands[command] = listener; 60 | } 61 | 62 | 63 | module.exports = CommandRouter; -------------------------------------------------------------------------------- /sparkbot/utils.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | const got = require("got"); 7 | const crypto = require("crypto"); 8 | 9 | const debug = require("debug")("sparkbot:utils"); 10 | const fine = require("debug")("sparkbot:utils:fine"); 11 | 12 | const Utils = {}; 13 | module.exports = Utils; 14 | 15 | 16 | // Returns true if specified JSON data complies with the Webhook documentation 17 | // see https://developer.webex.com/webhooks-explained.html 18 | // 19 | // { 20 | // "id":"Y2lzY29zcGFyazovL3VzL1dFQkhPT0svZjRlNjA1NjAtNjYwMi00ZmIwLWEyNWEtOTQ5ODgxNjA5NDk3", // webhook id 21 | // "created":"2016-08-23T16:26:02.754Z" // wehook creation date (does not change, not attached to the event) 22 | // "name":"Guild Chat to http://requestb.in/1jw0w3x1", // as specified at creation 23 | // "targetUrl":"https://mybot.localtunnel.me/", // as specified at creation 24 | // "filter":"roomId=Y2lzY29zcGFyazovL3VzL1JPT00vY2RlMWRkNDAtMmYwZC0xMWU1LWJhOWMtN2I2NTU2ZDIyMDdi", // optional, as specified at creation 25 | // "resource":"messages", // actual resource that triggered the webhook (different from specified at creation if 'all' was specified) 26 | // "event":"created", // actual event that triggered the webhook (different from specified at creation if 'all' was specified) 27 | // "actorId":"Y2lzY29zcGFyazovL3VzL1dFQkhPT0svZjRlNjA1NjAtNjYwMi353454123E1221", // actual actor who triggered the webhook (source event) 28 | // "data":{ 29 | // ... 30 | // EVENT SPECIFIC 31 | // ... 32 | // } 33 | // } 34 | const supportedResources = ["attachmentActions", "memberships", "messages", "rooms"]; 35 | const supportedEvents = ["created", "deleted", "updated"]; 36 | Utils.checkWebhookEvent = function (payload) { 37 | if (!payload || !payload.id 38 | || !payload.name 39 | || !payload.created 40 | || !payload.targetUrl 41 | || !payload.resource 42 | || !payload.event 43 | || !payload.actorId 44 | || !payload.data 45 | || !payload.status 46 | || !payload.createdBy 47 | // Extract fields introduced (not checked for now) 48 | //|| !payload.appId 49 | //|| !payload.orgId 50 | //|| !payload.ownedBy 51 | ) { 52 | debug("received payload is not compliant with Webhook specifications"); 53 | return false; 54 | } 55 | 56 | if (supportedResources.indexOf(payload.resource) == -1) { 57 | debug("incoming resource '" + payload.resource + "' does not comply with webhook specifications"); 58 | return false; 59 | } 60 | if (supportedEvents.indexOf(payload.event) == -1) { 61 | debug("incoming event '" + payload.event + "' does not comply with webhook specifications"); 62 | return false; 63 | } 64 | if ((payload.resource == "messages") && (payload.event == "updated")) { 65 | debug("event 'updated' is not expected for 'messages' resource"); 66 | return false; 67 | } 68 | if ((payload.resource == "rooms") && (payload.event == "deleted")) { 69 | debug("event 'deleted' is not expected for 'rooms' resource"); 70 | return false; 71 | } 72 | if ((payload.resource == "attachmentActions") && (payload.event == "updated")) { 73 | debug("event 'updated' is not expected for 'attachmentActions' resource"); 74 | return false; 75 | } 76 | if ((payload.resource == "attachmentActions") && (payload.event == "deleted")) { 77 | debug("event 'deleted' is not expected for 'attachmentActions' resource"); 78 | return false; 79 | } 80 | 81 | return true; 82 | }; 83 | 84 | 85 | 86 | // Returns a message if the payload complies with the documentation, undefined otherwise 87 | // see https://developer.webex.com/endpoint-messages-messageId-get.html for more information 88 | // { 89 | // "id" : "46ef3f0a-e810-460c-ad37-c161adb48195", 90 | // "personId" : "49465565-f6db-432f-ab41-34b15f544a36", 91 | // "personEmail" : "matt@example.com", 92 | // "roomId" : "24aaa2aa-3dcc-11e5-a152-fe34819cdc9a", 93 | // "text" : "PROJECT UPDATE - A new project project plan has been published on Box", 94 | // "files" : [ "http://www.example.com/images/media.png" ], 95 | // "toPersonId" : "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9mMDZkNzFhNS0wODMzLTRmYTUtYTcyYS1jYzg5YjI1ZWVlMmX", 96 | // "toPersonEmail" : "julie@example.com", 97 | // "created" : "2015-10-18T14:26:16+00:00" 98 | // } 99 | function checkMessageDetails(payload) { 100 | if (!payload 101 | || !payload.id 102 | || !payload.personId 103 | || !payload.personEmail 104 | // As of July 2016, Message Details has been enriched with the Room type, 105 | // note that Outgoing integrations do not receive the Room type property yet. 106 | || !payload.roomType 107 | || !payload.roomId 108 | || !payload.created) { 109 | debug("message structure is not compliant: missing property"); 110 | return false; 111 | } 112 | if (!payload.text && !payload.files) { 113 | debug("message structure is not compliant: no text nor file in there"); 114 | return false; 115 | } 116 | return true; 117 | } 118 | 119 | 120 | // Reads message text by requesting Webex Teams API as webhooks only receives message identifiers 121 | Utils.readMessage = function (messageId, token, cb) { 122 | if (!messageId || !token) { 123 | debug("undefined messageId or token, cannot read message details"); 124 | cb(new Error("undefined messageId or token, cannot read message details"), null); 125 | return; 126 | } 127 | 128 | // Retreive text for message id 129 | fine("requesting message details for id: " + messageId); 130 | 131 | const client = got.extend({ 132 | baseUrl: process.env.WEBEX_API || 'https://api.ciscospark.com/v1', 133 | headers: { 134 | 'authorization': 'Bearer ' + token 135 | }, 136 | json: true 137 | }); 138 | 139 | const resource = '/messages/' + messageId; 140 | (async () => { 141 | try { 142 | const response = await client.get(resource); 143 | fine(`GET ${resource} received a: ${response.statusCode}`); 144 | 145 | switch (response.statusCode) { 146 | case 200: 147 | break; // we're good, let's continue 148 | 149 | case 401: 150 | debug("error 401, invalid token"); 151 | debug("? Did you picked a valid access token, worth checking this"); 152 | cb(new Error("Could not fetch message details, statusCode: " + response.statusCode), null); 153 | return; 154 | 155 | case 404: 156 | // happens when the message details cannot be accessed, either because no message exists for the specified id, 157 | // or because the webhook was created with an access token different from the bot (which then can see the events triggered but not decrypt sensitive contents) 158 | debug("error 404, could not find the message with id: " + messageId); 159 | debug("? Did you create the Webhook with the same token you configured this bot with ? If so, message may have been deleted before you got the chance to read it"); 160 | cb(new Error("Could not fetch message details, statusCode: " + response.statusCode), null); 161 | return; 162 | 163 | default: 164 | debug("error " + response.statusCode + ", could not retreive message details with id: " + messageId); 165 | cb(new Error("Could not fetch message details, statusCode: " + response.statusCode), null); 166 | return; 167 | } 168 | 169 | // Robustify 170 | const message = response.body; 171 | if (!checkMessageDetails(message)) { 172 | debug("unexpected message format"); 173 | cb(new Error("unexpected message format while retreiving message id: " + messageId), null); 174 | return; 175 | } 176 | 177 | fine("pushing message details to callback function"); 178 | cb(null, message); 179 | 180 | } catch (error) { 181 | fine(`error in ${resource}, code: ${error.code}`) 182 | debug("error while retreiving message details with id: " + messageId + ", error: " + error.message); 183 | cb(new Error("error while retreiving message"), null); 184 | } 185 | })(); 186 | } 187 | 188 | // Returns true if the request has been signed with the specified secret 189 | // see Webex Teams API to authenticate requests : https://developer.webex.com/webhooks-explained.html#auth 190 | Utils.checkSignature = function (secret, req) { 191 | var signature = req.headers["x-spark-signature"]; 192 | if (!signature) { 193 | fine("header X-Spark-Signature not found"); 194 | return false; 195 | } 196 | 197 | // compute HMAC_SHA1 signature 198 | var computed = crypto.createHmac('sha1', secret).update(JSON.stringify(req.body)).digest('hex'); 199 | 200 | // check signature 201 | if (signature != computed) { 202 | fine("signatures do not match"); 203 | return false; 204 | } 205 | 206 | return true; 207 | } 208 | 209 | 210 | // Returns an attachmentActions if the payload complies with the documentation, undefined otherwise 211 | // see https://developer.webex.com/docs/api/v1/attachment-actions/get-attachment-action-details for more information 212 | // { 213 | // "id": "Y2lzY29zcGFyazovL3VzL0NBTExTLzU0MUFFMzBFLUUyQzUtNERENi04NTM4LTgzOTRDODYzM0I3MQo", 214 | // "personId": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS83MTZlOWQxYy1jYTQ0LTRmZ", 215 | // "roomId": "L3VzL1BFT1BMRS80MDNlZmUwNy02Yzc3LTQyY2UtOWI", 216 | // "type": "submit", 217 | // "messageId": "GFyazovL3VzL1BFT1BMRS80MDNlZmUwNy02Yzc3LTQyY2UtOWI4NC", 218 | // "inputs": { 219 | // "Name": "John Andersen", 220 | // "Url": "https://example.com", 221 | // "Email": "john.andersen@example.com", 222 | // "Tel": "+1 408 526 7209" 223 | // }, 224 | // "created": "2016-05-10T19:41:00.100Z" 225 | // } 226 | function checkAttachmentActionsDetails(payload) { 227 | if (!payload || !payload.id 228 | || !payload.personId 229 | || !payload.roomId 230 | || !payload.type 231 | || !payload.messageId 232 | || !payload.inputs 233 | || !payload.created) { 234 | debug("attachmentActions structure is not compliant: missing property"); 235 | return false; 236 | } 237 | if (Object.keys(payload.inputs).length == 0) { 238 | debug("attachmentActions structure is not compliant: no inputs"); 239 | return false; 240 | } 241 | return true; 242 | } 243 | 244 | 245 | // Reads submitted data by requesting Webex Teams API as webhooks only receives attachmentActions identifiers 246 | Utils.readAttachmentActions = function (attachmentActionsId, token, cb) { 247 | if (!attachmentActionsId || !token) { 248 | debug("undefined attachmentActionsId or token, cannot read message details"); 249 | cb(new Error("undefined attachmentActionsId or token, cannot read message details"), null); 250 | return; 251 | } 252 | 253 | // Retreive contents for attachmentActions 254 | fine("requesting attachmentActions details for id: " + attachmentActionsId); 255 | const client = got.extend({ 256 | baseUrl: process.env.WEBEX_API || 'https://api.ciscospark.com/v1', 257 | headers: { 258 | 'authorization': 'Bearer ' + token 259 | }, 260 | json: true 261 | }); 262 | 263 | const resource = '/attachment/actions/' + attachmentActionsId; 264 | (async () => { 265 | try { 266 | const response = await client.get(resource); 267 | fine(`GET ${resource} received a: ${response.statusCode}`); 268 | 269 | switch (response.statusCode) { 270 | case 200: 271 | break; // we're good, let's continue 272 | 273 | case 401: 274 | debug("error 401, invalid token"); 275 | debug("? Did you picked a valid access token, worth checking this"); 276 | cb(new Error("Could not fetch attachmentActions details, statusCode: " + response.statusCode), null); 277 | return; 278 | 279 | case 404: 280 | // happens when the attachmentActions details cannot be accessed, either because no attachmentActions exists for the specified id, 281 | // or because the webhook was created with an access token different from the bot (which then can see the events triggered but not decrypt sensitive contents) 282 | debug("error 404, could not find the attachmentActions with id: " + attachmentActionsId); 283 | debug("? Did you create the Webhook with the same token you configured this bot with ? If so, message may have been deleted before you got the chance to read it"); 284 | cb(new Error("Could not fetch attachmentActions details, statusCode: " + response.statusCode), null); 285 | return; 286 | 287 | default: 288 | debug("error " + response.statusCode + ", could not retreive attachmentActions details with id: " + attachmentActionsId); 289 | cb(new Error("Could not fetch attachmentActions details, statusCode: " + response.statusCode), null); 290 | return; 291 | } 292 | 293 | // Robustify 294 | fine("parsing JSON"); 295 | const attachmentActions = response.body; 296 | if (!checkAttachmentActionsDetails(attachmentActions)) { 297 | debug("unexpected attachmentActions format"); 298 | cb(new Error("unexpected format while retreiving attachmentActions id: " + attachmentActionsId), null); 299 | return; 300 | } 301 | 302 | fine("pushing attachmentActions details to callback function"); 303 | cb(null, attachmentActions); 304 | 305 | } catch (error) { 306 | fine(`error in ${resource}, code: ${error.code}`) 307 | debug("error while retreiving attachmentActions details with id: " + attachmentActionsId + ", error: " + error); 308 | cb(new Error("error while retreiving attachmentActions"), null); 309 | } 310 | })(); 311 | } 312 | 313 | 314 | // Returns true if the request has been signed with the specified secret 315 | // see Webex Teams API to authenticate requests : https://developer.webex.com/webhooks-explained.html#auth 316 | Utils.checkSignature = function (secret, req) { 317 | var signature = req.headers["x-spark-signature"]; 318 | if (!signature) { 319 | fine("header X-Spark-Signature not found"); 320 | return false; 321 | } 322 | 323 | // compute HMAC_SHA1 signature 324 | var computed = crypto.createHmac('sha1', secret).update(JSON.stringify(req.body)).digest('hex'); 325 | 326 | // check signature 327 | if (signature != computed) { 328 | fine("signatures do not match"); 329 | return false; 330 | } 331 | 332 | return true; 333 | } 334 | -------------------------------------------------------------------------------- /sparkbot/webhook.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | var express = require("express"); 7 | var app = express(); 8 | var bodyParser = require("body-parser"); 9 | app.use(bodyParser.urlencoded({ extended: true })); 10 | app.use(bodyParser.json()); 11 | 12 | var debug = require("debug")("sparkbot"); 13 | var fine = require("debug")("sparkbot:fine"); 14 | 15 | var Utils = require("./utils"); 16 | var CommandInterpreter = require("./interpreter"); 17 | var CommandRouter = require("./router"); 18 | var Registration = require("./registration"); 19 | 20 | 21 | /* Creates a Webex Teams webhook with specified configuration structure: 22 | * 23 | * { 24 | * port, // int: local port on which the webhook is accessible 25 | * path, // string: path to which new webhook POST events are expected 26 | * token, // string: Webex Teams API access token 27 | * secret, // string: (optional) webhook secret used to sign payloads 28 | * softSecretCheck, // boolean: does not aborts payload processing if the payload signature check fails 29 | * trimMention // boolean: filters out mentions if token owner is a bot 30 | * ignoreSelf // boolean: ignores message created by token owner 31 | * } 32 | * 33 | * If no configuration is specified, the defaults below apply: 34 | * { 35 | * port : process.env.PORT || 8080, 36 | * path : process.env.WEBHOOK_PATH || "/" , 37 | * token : process.env.ACCESS_TOKEN, 38 | * secret : process.env.WEBHOOK_SECRET, 39 | * softSecretCheck : will default to false if a secret is defined, 40 | * trimMention : will default to true, 41 | * commandPrefix : process.env.COMMAND_PREFIX || "/", 42 | * ignoreSelf : will default to true if a bot is used, and false otherwise 43 | * } 44 | * 45 | */ 46 | function Webhook(config) { 47 | // Inject defaults if no configuration specified 48 | if (!config) { 49 | debug("webhook instantiated with default configuration"); 50 | config = { 51 | // [WORKAROUND] in some container situation (ie, Cisco Shipped), we need to use an OVERRIDE_PORT to force our bot to start and listen to the port defined in the Dockerfile (ie, EXPOSE), 52 | // and not the PORT dynamically assigned by the host or scheduler. 53 | port: process.env.OVERRIDE_PORT || process.env.PORT, 54 | path: process.env.WEBHOOK_PATH, 55 | // [COMPAT] SPARK_TOKEN deprecated but still supported 56 | token: process.env.ACCESS_TOKEN || process.env.SPARK_TOKEN, 57 | secret: process.env.WEBHOOK_SECRET, 58 | commandPrefix: process.env.COMMAND_PREFIX || "/" 59 | }; 60 | } 61 | 62 | // Robustify: it is usually safer not to read ourselves, especially if no commandPrefix is specified 63 | if (!config.ignoreSelf && !config.commandPrefix) { 64 | debug("WARNING: configuration does not prevent for reading from yourself => possible infinite loop, continuing..."); 65 | } 66 | 67 | // Abort if mandatory config parameters are not present 68 | if (!config.port) { 69 | fine("no port specified, applying default"); 70 | config.port = 8080; 71 | } 72 | if (!config.path) { 73 | fine("no path specified, applying default"); 74 | config.path = "/"; 75 | } 76 | 77 | // If an access token is specified, create a command interpreter 78 | if (!config.token) { 79 | debug("no access token specified, will not fetch message contents and room titles, nor interpret commands"); 80 | } 81 | this.token = config.token; 82 | 83 | // Apply secret if specified 84 | if (config.secret) { 85 | this.secret = config.secret; 86 | // Defaults to false when a secret is specified 87 | if (!config.softSecretCheck) { 88 | this.softSecretCheck = false; 89 | } 90 | } 91 | if (config.softSecretCheck) { 92 | this.softSecretCheck = config.softSecretCheck; 93 | } 94 | 95 | 96 | // Webhook listeners 97 | this.listeners = {}; 98 | var self = this; 99 | function fire(trigger) { 100 | // Retreive listener for incoming event 101 | var entry = trigger.resource + "/" + trigger.event; 102 | var listener = self.listeners[entry]; 103 | if (!listener) { 104 | debug("no listener found for resource/event: " + entry); 105 | return; 106 | } 107 | 108 | // Invoke listener 109 | debug("calling listener for resource/event: " + entry + ", with data context: " + trigger.data.id); 110 | listener(trigger); 111 | } 112 | 113 | // Initialize command processors 114 | this.interpreter = new CommandInterpreter(config); 115 | this.router = new CommandRouter(this); 116 | 117 | // Webhook API routes 118 | started = Date.now(); 119 | app.route(config.path) 120 | .get(function (req, res) { 121 | debug("healthcheck hitted"); 122 | var package = require("../package.json"); 123 | res.json({ 124 | message: "Congrats, your bot is up and running", 125 | since: new Date(started).toISOString(), 126 | framework: package.name + ", " + package.version, 127 | tip: "Don't forget to create WebHooks to start receiving events from Webex: https://developer.webex.com/endpoint-webhooks-post.html", 128 | webhook: { 129 | secret: (self.secret != null), 130 | softSecretCheck: self.softSecretCheck, 131 | listeners: Object.keys(self.listeners), 132 | }, 133 | token: (self.token != null), 134 | account: { 135 | type: self.interpreter.accountType, 136 | nickName: self.interpreter.nickName, 137 | person: self.interpreter.person 138 | }, 139 | interpreter: { 140 | prefix: self.interpreter.prefix, 141 | trimMention: self.interpreter.trimMention, 142 | ignoreSelf: self.interpreter.ignoreSelf 143 | }, 144 | commands: Object.keys(self.router.commands) 145 | }); 146 | }) 147 | .post(function (req, res) { 148 | debug("webhook invoked"); 149 | 150 | // analyse incoming payload, should conform to Webex Teams webhook specifications 151 | if (!req.body || !Utils.checkWebhookEvent(req.body)) { 152 | debug("unexpected payload POSTed, aborting..."); 153 | res.status(400).json({ 154 | message: "Bad payload for Webhook", 155 | details: "either the bot is misconfigured or Webex is running a new API version" 156 | }); 157 | return; 158 | } 159 | 160 | // event is ready to be processed, let's send a response to Webex without waiting any longer 161 | res.status(200).json({ message: "notification received and being processed by webhook" }); 162 | 163 | // process HMAC-SHA1 signature if a secret has been specified 164 | // [NOTE@ for security reasons, we check the secret AFTER responding to Webex 165 | if (self.secret) { 166 | if (!Utils.checkSignature(self.secret, req)) { 167 | if (!self.softSecretCheck) { 168 | debug("HMAC-SHA1 signature does not match secret, aborting payload processing"); 169 | return; 170 | } 171 | else { 172 | fine("HMAC-SHA1 signature does not match secret, continuing...."); 173 | } 174 | } 175 | fine("signature check ok, continuing...") 176 | } 177 | 178 | // process incoming resource/event, see https://developer.webex.com/webhooks-explained.html 179 | fire(req.body); 180 | }); 181 | 182 | // Start bot 183 | app.listen(config.port, function () { 184 | debug("bot started on port: " + config.port); 185 | }).on('error', (err) => { 186 | console.log(`cannot launch bot, err: ${err.message}`); 187 | console.log("existing...") 188 | process.exit(1); 189 | }); 190 | } 191 | 192 | 193 | // Registers a listener for new (resource, event) POSTed to our webhook 194 | Webhook.prototype.onEvent = function (resource, event, listener) { 195 | if (!listener) { 196 | debug("on: listener registration error. Please specify a listener for resource/event"); 197 | return; 198 | } 199 | // check (resource, event) conforms to Webhook specifications, see https://developer.webex.com/webhooks-explained.html 200 | if (!resource || !event) { 201 | debug("on: listener registration error. please specify a resource/event for listener"); 202 | return; 203 | } 204 | 205 | switch (resource) { 206 | case "all": 207 | if (event != "all") { 208 | debug("on: listener registration error. Bad configuration: only 'all' events is suported for 'all' resources"); 209 | debug("WARNING: listener not registered for resource/event: " + resource + "/" + event); 210 | return; 211 | } 212 | 213 | addAttachmentActionsCreatedListener(this, listener); 214 | addMessagesCreatedListener(this, listener); 215 | addMessagesDeletedListener(this, listener); 216 | addRoomsCreatedListener(this, listener); 217 | addRoomsUpdatedListener(this, listener); 218 | addMembershipsCreatedListener(this, listener); 219 | addMembershipsUpdatedListener(this, listener); 220 | addMembershipsDeletedListener(this, listener); 221 | return; 222 | 223 | case "attachmentActions": 224 | if (event == "all") { 225 | addAttachmentActionsCreatedListener(this, listener); 226 | return; 227 | } 228 | if (event == "created") { 229 | addAttachmentActionsCreatedListener(this, listener); 230 | return; 231 | } 232 | break; 233 | 234 | case "messages": 235 | if (event == "all") { 236 | addMessagesCreatedListener(this, listener); 237 | addMessagesDeletedListener(this, listener); 238 | return; 239 | } 240 | if (event == "created") { 241 | addMessagesCreatedListener(this, listener); 242 | return; 243 | } 244 | if (event == "deleted") { 245 | addMessagesDeletedListener(this, listener); 246 | return; 247 | }; 248 | break; 249 | 250 | case "memberships": 251 | if (event == "all") { 252 | addMembershipsCreatedListener(this, listener); 253 | addMembershipsUpdatedListener(this, listener); 254 | addMembershipsDeletedListener(this, listener); 255 | return; 256 | } 257 | if (event == "created") { 258 | addMembershipsCreatedListener(this, listener); 259 | return; 260 | } 261 | if (event == "updated") { 262 | addMembershipsUpdatedListener(this, listener); 263 | return; 264 | }; 265 | if (event == "deleted") { 266 | addMembershipsDeletedListener(this, listener); 267 | return; 268 | }; 269 | break; 270 | 271 | case "rooms": 272 | if (event == "all") { 273 | addRoomsCreatedListener(this, listener); 274 | addRoomsUpdatedListener(this, listener); 275 | return; 276 | } 277 | if (event == "created") { 278 | addRoomsCreatedListener(this, listener); 279 | return; 280 | } 281 | if (event == "updated") { 282 | addRoomsUpdatedListener(this, listener); 283 | return; 284 | }; 285 | break; 286 | 287 | default: 288 | break; 289 | } 290 | 291 | debug("on: listener registration error, bad configuration. Resource: '" + resource + "' and event: '" + event + "' do not comply with Webex Teams webhook specifications."); 292 | } 293 | 294 | 295 | // Helper function to retreive message details from a (messages/created) Webhook trigger. 296 | // Expected callback function signature (err, message). 297 | Webhook.prototype.decryptMessage = function (trigger, cb) { 298 | if (!this.token) { 299 | debug("no access token configured, cannot read message details.") 300 | cb(new Error("no access token configured, cannot decrypt message"), null); 301 | return; 302 | } 303 | 304 | Utils.readMessage(trigger.data.id, this.token, cb); 305 | } 306 | 307 | 308 | // Utility function to be notified only as new messages are posted into spaces against which your Webhook has registered to. 309 | // The callback function will directly receive the message contents : combines .on('messages', 'created', ...) and .decryptMessage(...). 310 | // Expects a callback function with signature (err, trigger, message). 311 | // Returns true or false whether registration was successful 312 | Webhook.prototype.onMessage = function (cb) { 313 | 314 | // check args 315 | if (!cb) { 316 | debug("no callback function, aborting callback registration...") 317 | return false; 318 | } 319 | 320 | // Abort if webhook cannot request Webex for messages details 321 | var token = this.token; 322 | if (!token) { 323 | debug("no access token specified, will not read message details, aborting callback registration...") 324 | return false; 325 | } 326 | 327 | addMessagesCreatedListener(this, function (trigger) { 328 | Utils.readMessage(trigger.data.id, token, function (err, message) { 329 | if (err) { 330 | debug("could not fetch message details, err: " + JSON.stringify(err) + ", listener not fired..."); 331 | //cb (err, trigger, null); 332 | return; 333 | } 334 | 335 | // Fire listener 336 | cb(trigger, message); 337 | }); 338 | }); 339 | 340 | return true; 341 | } 342 | 343 | 344 | // Transforms a message into a Command structure 345 | Webhook.prototype.asCommand = function (message) { 346 | if (!message) { 347 | debug("no message to interpret, aborting...") 348 | return null; 349 | } 350 | 351 | return this.interpreter.extract(message); 352 | } 353 | 354 | 355 | 356 | // Shortcut to be notified only as new commands are posted into spaces your Webhook has registered against. 357 | // The callback function will directly receive the message contents : combines .on('messages', 'created', ...) .decryptMessage(...) and .extractCommand(...). 358 | // The expected callback function signature is: function(err, command). 359 | // Note that you may register a "fallback" listener by registering the "fallback" command 360 | Webhook.prototype.onCommand = function (command, cb) { 361 | if (!command || !cb) { 362 | debug("wrong arguments for .onCommand, aborting...") 363 | return; 364 | } 365 | 366 | this.router.addCommand(command, cb); 367 | } 368 | 369 | 370 | // Utility function to be notified only as new cards are submitted 371 | // The callback function will directly receive the submitted data contents : combines .on('attachmentActions', 'created', ...) and .decryptMessage(...). 372 | // Expects a callback function with signature (err, trigger, submission). 373 | // Returns true or false whether registration was successful 374 | Webhook.prototype.onCardSubmission = function (cb) { 375 | 376 | // check args 377 | if (!cb) { 378 | debug("no callback function, aborting callback registration...") 379 | return false; 380 | } 381 | 382 | // Abort if webhook cannot request Webex for messages details 383 | var token = this.token; 384 | if (!token) { 385 | debug("no access token specified, will not be able to read submitted details, aborting callback registration...") 386 | return false; 387 | } 388 | 389 | addAttachmentActionsCreatedListener(this, function (trigger) { 390 | Utils.readAttachmentActions(trigger.data.id, token, function (err, attachmentActions) { 391 | if (err) { 392 | debug("could not fetch attachmentActions details, err: " + JSON.stringify(err) + ", listener not fired..."); 393 | //cb (err, trigger, null); 394 | return; 395 | } 396 | 397 | // Fire listener 398 | cb(trigger, attachmentActions); 399 | }); 400 | }); 401 | 402 | return true; 403 | } 404 | 405 | 406 | // Creates or updates a webhook to Webex 407 | // returns the webhook created or updated 408 | // see https://developer.webex.com/endpoint-webhooks-post.html for arguments 409 | Webhook.prototype.createOrUpdateWebhook = function (name, targetUrl, resource, event, filter, secret, cb) { 410 | if (!name || !targetUrl) { 411 | debug("bad arguments for createOrUpdateWebhook, aborting webhook creation...") 412 | if (cb) cb(new Error("bad arguments for createOrUpdateWebhook"), null); 413 | return; 414 | } 415 | 416 | if (!resource) { 417 | resource = "all"; 418 | event = "all"; 419 | } 420 | if (!event) { 421 | event = "all"; 422 | } 423 | 424 | // Check if a webhook already exists with the same name 425 | var token = this.token; 426 | Registration.listWebhooks(token, function (err, webhooks) { 427 | if (err) { 428 | debug("could not retreive webhooks, aborting webhook creation or update..."); 429 | if (cb) cb(new Error("could not retreive the list of webhooks, check your token"), null); 430 | return; 431 | } 432 | 433 | var webhook = null; 434 | webhooks.forEach(function (elem) { 435 | if (elem.name === name) { 436 | webhook = elem; 437 | } 438 | }); 439 | 440 | // if found, check if webhook is different 441 | if (webhook) { 442 | var identical = compareWebhooks(webhook, name, targetUrl, resource, event, filter, secret); 443 | if (identical) { 444 | debug("webhook already exists with same properties, no creation needed"); 445 | if (cb) cb(null, webhook); 446 | return; 447 | } 448 | 449 | // delete the webhook that pre-exists 450 | Registration.deleteWebhook(token, webhook.id, function (err, code) { 451 | if (err != null) { 452 | debug("could not delete existing webhook") 453 | if (cb) cb(new Error('webhook with same name already exists and could not be updated'), null); 454 | return; 455 | } 456 | 457 | Registration.createWebhook(token, name, targetUrl, resource, event, filter, secret, function (err, webhook) { 458 | if (err != null) { 459 | debug("could not create webhook") 460 | if (cb) cb(new Error('could NOT create webhook'), null); 461 | return; 462 | } 463 | 464 | fine("webhook successfully updated"); 465 | if (cb) cb(null, webhook); 466 | return; 467 | }); 468 | }); 469 | } 470 | 471 | // create Webhook 472 | else { 473 | Registration.createWebhook(token, name, targetUrl, resource, event, filter, secret, function (err, webhook) { 474 | if (err != null) { 475 | debug("could not create webhook") 476 | if (cb) cb(new Error('could NOT create webhook'), null); 477 | return; 478 | } 479 | 480 | fine("webhook successfully created"); 481 | if (cb) cb(null, webhook); 482 | return; 483 | }); 484 | } 485 | }); 486 | } 487 | 488 | 489 | module.exports = Webhook 490 | 491 | 492 | // 493 | // Internals 494 | // 495 | 496 | function addAttachmentActionsCreatedListener(webhook, listener) { 497 | webhook.listeners["attachmentActions/created"] = listener; 498 | fine("addAttachmentActionsCreatedListener: listener registered"); 499 | } 500 | 501 | function addMessagesCreatedListener(webhook, listener) { 502 | webhook.listeners["messages/created"] = listener; 503 | fine("addMessagesCreatedListener: listener registered"); 504 | } 505 | 506 | function addMessagesDeletedListener(webhook, listener) { 507 | webhook.listeners["messages/deleted"] = listener; 508 | fine("addMessagesDeletedListener: listener registered"); 509 | } 510 | 511 | function addRoomsCreatedListener(webhook, listener) { 512 | webhook.listeners["rooms/created"] = listener; 513 | fine("addRoomsCreatedListener: listener registered"); 514 | } 515 | 516 | function addRoomsUpdatedListener(webhook, listener) { 517 | webhook.listeners["rooms/updated"] = listener; 518 | fine("addRoomsUpdatedListener: listener registered"); 519 | } 520 | 521 | function addMembershipsCreatedListener(webhook, listener) { 522 | webhook.listeners["memberships/created"] = listener; 523 | fine("addMembershipsCreatedListener: listener registered"); 524 | } 525 | 526 | function addMembershipsUpdatedListener(webhook, listener) { 527 | webhook.listeners["memberships/updated"] = listener; 528 | fine("addMembershipsUpdatedListener: listener registered"); 529 | } 530 | 531 | function addMembershipsDeletedListener(webhook, listener) { 532 | webhook.listeners["memberships/deleted"] = listener; 533 | fine("addMembershipsDeletedListener: listener registered"); 534 | } 535 | 536 | // returns true if webhooks are identical 537 | function compareWebhooks(webhook, name, targetUrl, resource, event, filter, secret) { 538 | if ((webhook.name !== name) 539 | || (webhook.targetUrl !== targetUrl) 540 | || (webhook.resource !== resource) 541 | || (webhook.event !== event)) { 542 | return false; 543 | } 544 | 545 | // they look pretty identifty, let's check optional fields 546 | if (filter) { 547 | if (filter !== webhook.filter) { 548 | fine("webhook look pretty similar BUT filter is different"); 549 | return false; 550 | } 551 | } 552 | else { 553 | if (webhook.filter) { 554 | fine("webhook look pretty similar BUT filter is different"); 555 | return false; 556 | } 557 | } 558 | 559 | if (secret) { 560 | if (secret !== webhook.secret) { 561 | fine("webhook look pretty similar BUT secret is different"); 562 | return false; 563 | } 564 | } 565 | else { 566 | if (webhook.secret) { 567 | fine("webhook look pretty similar BUT secret is different"); 568 | return false; 569 | } 570 | } 571 | return true; 572 | } 573 | 574 | 575 | 576 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Library tests 2 | 3 | A set of examples to discover the framework's features : 4 | 5 | - [onEvent-all-all](onEvent-all-all.js), [onEvent-messages-created](onEvent-messages-created.js): examples of listeners to specific Webhook (Resources/Event) triggers. Leverages node-sparkbot function: webhook.onEvent(). 6 | 7 | - [onMessage](onMessage.js): examples of listeners invoked when new message contents are succesfully fetched from Webex. Leverages node-sparkbot function: webhook.onMessage(). 8 | 9 | - [onMessage-asCommand](onMessage-asCommand.js): illustrates how to interpret the message as a bot command. Leverages node-sparkbot function: webhook.onMessage(). 10 | 11 | - [onCommand](onCommand.js): shortcut to listen to a specific command. Leverages node-sparkbot function: webhook.onCommand(). 12 | 13 | - [onCommand-webhook](onCommand-webhook.js): example of an automated creation of a webhook. 14 | 15 | 16 | You may also check [express-webhook](express-webhook.js) which illustrates how to create a bot without any library : 17 | 18 | - [express-webhook](express-webhook.js): a simple HTTP service based on Express, listening to incoming Resource/Events from Webex Teams 19 | 20 | 21 | ## Run locally 22 | 23 | Each sample can be launched from the same set of command lines install then run calls. 24 | 25 | Note that the ACCESS_TOKEN env variable is required to run all samples that read message contents. 26 | 27 | Once your bot is started, read this [guide to expose it publically and create a Webex Teams webhook](../docs/SettingUpYourSparkBot.md). 28 | 29 | 30 | ```shell 31 | # Installation 32 | git clone https://github.com/CiscoDevNet/node-sparkbot 33 | cd node-sparkbot 34 | npm install 35 | 36 | # Run 37 | cd tests 38 | DEBUG=sparkbot* ACCESS_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX node onCommand.js 39 | ... 40 | Webex Teams Bot started at http://localhost:8080/ 41 | GET / for Healthcheck 42 | POST / to receive Webhook events 43 | ``` 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /tests/express-webhook.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook based on pure Express.js. 8 | * 9 | * goal here is to illustrate how to create a bot without any library 10 | * 11 | */ 12 | 13 | var express = require("express"); 14 | var app = express(); 15 | 16 | var bodyParser = require("body-parser"); 17 | app.use(bodyParser.urlencoded({extended: true})); 18 | app.use(bodyParser.json()); 19 | 20 | var debug = require("debug")("samples"); 21 | var Utils = require("../sparkbot/utils"); 22 | 23 | 24 | var started = Date.now(); 25 | app.route("/") 26 | // healthcheck 27 | .get(function (req, res) { 28 | res.json({ 29 | message: "Congrats, your Webex Teams bot is up and running", 30 | since: new Date(started).toISOString(), 31 | code: "express-all-in-one.js", 32 | tip: "Register your bot as a WebHook to start receiving events: https://developer.webex.com/endpoint-webhooks-post.html" 33 | }); 34 | }) 35 | 36 | // webhook endpoint 37 | .post(function (req, res) { 38 | 39 | // analyse incoming payload, should conform to Webex Teams webhook event specifications 40 | debug("DEBUG: webhook invoked"); 41 | if (!req.body || !Utils.checkWebhookEvent(req.body)) { 42 | console.log("WARNING: Unexpected payload POSTed, aborting..."); 43 | res.status(400).json({message: "Bad payload for Webhook", 44 | details: "either the bot is misconfigured or Webex Teams is running a new API version"}); 45 | return; 46 | } 47 | 48 | // event is ready to be processed, let's send a response to Webex without waiting any longer 49 | res.status(200).json({message: "message is being processed by webhook"}); 50 | 51 | // process incoming resource/event, see https://developer.webex.com/webhooks-explained.html 52 | processWebhookEvent(req.body); 53 | }); 54 | 55 | 56 | // Starts the Bot service 57 | // 58 | // [WORKAROUND] in some container situation (ie, Cisco Shipped), we need to use an OVERRIDE_PORT to force our bot to start and listen to the port defined in the Dockerfile (ie, EXPOSE), 59 | // and not the PORT dynamically assigned by the host or scheduler. 60 | var port = process.env.OVERRIDE_PORT || process.env.PORT || 8080; 61 | app.listen(port, function () { 62 | console.log("not started at http://localhost:" + port + "/"); 63 | console.log(" GET / for health checks"); 64 | console.log(" POST / to procress new Webhook events"); 65 | }); 66 | 67 | 68 | // Invoked when the webhook is triggered 69 | function processWebhookEvent(trigger) { 70 | 71 | // 72 | // YOUR CODE HERE 73 | // 74 | console.log("EVENT: " + trigger.resource + "/" + trigger.event + ", with data id: " + trigger.data.id + ", triggered by person id:" + trigger.actorId); 75 | 76 | } -------------------------------------------------------------------------------- /tests/onCardSubmission-webhook.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook that leverages a simple library (batteries included) 8 | * note: 9 | * - this example requires that you've set an ACCESS_TOKEN env variable with a Bot access token so that you can submit data from your Webex User account 10 | * - the code creates or updates a webhook that posts data to a publically accessible URL 11 | * 12 | */ 13 | 14 | 15 | // Starts your Webhook with a default configuration where the Webex API access token is read from ACCESS_TOKEN 16 | const SparkBot = require("../sparkbot/webhook"); 17 | const bot = new SparkBot(); 18 | 19 | // Create webhook 20 | const publicURL = process.env.PUBLIC_URL || "https://d3fc85fe.ngrok.io"; 21 | bot.secret = process.env.WEBHOOK_SECRET || "not THAT secret"; 22 | bot.createOrUpdateWebhook("register-bot", publicURL, "attachmentActions", "created", null, bot.secret, function (err, webhook) { 23 | if (err) { 24 | console.error("could not create Webhook, err: " + err); 25 | 26 | // Fail fast 27 | process.exit(1); 28 | } 29 | 30 | console.log("webhook successfully checked, with id: " + webhook.id); 31 | }); 32 | 33 | bot.onCardSubmission(function (trigger, attachmentActions) { 34 | 35 | // 36 | // ADD YOUR CUSTOM CODE HERE 37 | // 38 | console.log(`new attachmentActions from personId: ${trigger.data.personId} , with inputs`); 39 | Object.keys(attachmentActions.inputs).forEach(prop => { 40 | console.log(` ${prop}: ${attachmentActions.inputs[prop]}`); 41 | }); 42 | 43 | }); 44 | 45 | 46 | 47 | bot.onMessage(function (trigger, message) { 48 | 49 | // 50 | // ADD YOUR CUSTOM CODE HERE 51 | // 52 | console.log("new message from: " + trigger.data.personEmail + ", text: " + message.text); 53 | }); 54 | 55 | -------------------------------------------------------------------------------- /tests/onCardSubmission.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook that leverages a simple library (batteries included) 8 | * note : this example requires that you've set a ACCESS_TOKEN env variable 9 | * 10 | */ 11 | 12 | // Starts your Webhook with default configuration where the Webex Teams API access token is read from the ACCESS_TOKEN env variable 13 | const SparkBot = require("../sparkbot/webhook"); 14 | const bot = new SparkBot(); 15 | 16 | bot.onCardSubmission(function (trigger, attachmentActions) { 17 | 18 | // 19 | // ADD YOUR CUSTOM CODE HERE 20 | // 21 | console.log(`new attachmentActions from: ${trigger.data.personEmail} , with inputs`); 22 | attachmentActions.inputs.keys().forEach(prop => { 23 | console.log(` ${prop}: ${attachmentActions.inputs[prop]}`); 24 | }); 25 | 26 | }); 27 | 28 | -------------------------------------------------------------------------------- /tests/onCommand-webhook.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook that leverages a simple library (batteries included) 8 | * 9 | * note : this example requires that you've set an ACCESS_TOKEN env variable 10 | * 11 | */ 12 | 13 | var SparkBot = require("../sparkbot/webhook"); 14 | 15 | // Starts your Webhook with default configuration where the Webex Teams API access token is read from the ACCESS_TOKEN env variable 16 | var bot = new SparkBot(); 17 | 18 | // Registers the bot to the Webex Teams Service to start receiving notifications 19 | // We list here various options to register your bot: pick one and update the code with your bot name and its public endpoint 20 | 21 | // Simplissime registration where defaults apply (all, all, no filter, no secret), and no callback 22 | //bot.createOrUpdateWebhook("register-bot", "https://f6d5d937.ngrok.io"); 23 | 24 | // Registration without any filter, secret, and callback 25 | //bot.createOrUpdateWebhook("register-bot", "https://f6d5d937.ngrok.io", "all", "all"); 26 | 27 | // Registration with a filter, no secret, no callback 28 | //bot.createOrUpdateWebhook("register-bot", "https://f6d5d937.ngrok.io", "all", "all", "roomId=XXXXXXXXXXXXXXX"); 29 | 30 | // Registration with no filter, but a secret and a callback 31 | // note that the secret needs to be known to the bot so that it can check the payload signatures 32 | var publicURL = process.env.PUBLIC_URL || "https://f6d5d937.ngrok.io"; 33 | bot.secret = process.env.WEBHOOK_SECRET || "not THAT secret"; 34 | bot.createOrUpdateWebhook("register-bot", publicURL, "all", "all", null, bot.secret, function (err, webhook) { 35 | if (err) { 36 | console.error("could not create Webhook, err: " + err); 37 | 38 | // Fail fast 39 | process.exit(1); 40 | } 41 | 42 | console.log("webhook successfully checked, with id: " + webhook.id); 43 | }); 44 | 45 | // Registration with no filter, but a secret and a callback 46 | // bot name and public endpoint are read from env variables, the WEBHOOK_SECRET env variable is used to initialize the secret 47 | // make sure to initialize these env variables 48 | // - BOT_NAME="register-bot" 49 | // - WEBHOOK_SECRET="not THAT secret" 50 | // - PUBLIC_URL="https://f6d5d937.ngrok.io" 51 | // example: 52 | // DEBUG=sparkbot* BOT_NAME="register-bot" PUBLIC_URL="https://f6d5d937.ngrok.io" WEBHOOK_SECRET="not THAT secret" ACCESS_TOKEN="MjdkYjRhNGItM2E1ZS00YmZjLTk2ZmQtO" node tests/onCommand-register.js 53 | //bot.createOrUpdateWebhook(process.env.BOT_NAME, process.env.PUBLIC_URL, "all", "all", null, bot.secret, function (err, webhook) { 54 | // if (err) { 55 | // console.log("Could not register the bot, please check your env variables are all set: ACCESS_TOKEN, BOT_NAME, PUBLIC_URL"); 56 | // return; 57 | // } 58 | // console.log("webhook successfully created, id: " + webhook.id); 59 | //}); 60 | 61 | 62 | // Override default prefix "/" to "" so that our bot will obey to "help"" instead of "/help" 63 | bot.interpreter.prefix=""; 64 | 65 | bot.onCommand("help", function(command) { 66 | // ADD YOUR CUSTOM CODE HERE 67 | console.log("new command: " + command.keyword + ", from: " + command.message.personEmail + ", with args: " + JSON.stringify(command.args)); 68 | }); 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /tests/onCommand.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook that leverages a simple library (batteries included) 8 | * 9 | * note : this example requires that you've set a ACCESS_TOKEN env variable 10 | * 11 | */ 12 | 13 | var SparkBot = require("../sparkbot/webhook"); 14 | 15 | // Starts your Webhook with default configuration where the Webex Teams API access token is read from the ACCESS_TOKEN env variable 16 | var bot = new SparkBot(); 17 | 18 | bot.onCommand("help", function(command) { 19 | 20 | // 21 | // ADD YOUR CUSTOM CODE HERE 22 | // 23 | console.log("new command: " + command.keyword + ", from: " + command.message.personEmail + ", with args: " + JSON.stringify(command.args)); 24 | 25 | }); 26 | -------------------------------------------------------------------------------- /tests/onEvent-all-all.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook that leverages a simple library (batteries included) 8 | * 9 | */ 10 | 11 | var SparkBot = require("../sparkbot/webhook"); 12 | 13 | // Starts your Webhook with default configuration 14 | var bot = new SparkBot(); 15 | 16 | bot.onEvent("all", "all", function(trigger) { 17 | 18 | // 19 | // YOUR CODE HERE 20 | // 21 | console.log("EVENT: " + trigger.resource + "/" + trigger.event + ", with data id: " + trigger.data.id + ", triggered by person id:" + trigger.actorId); 22 | 23 | }); 24 | 25 | -------------------------------------------------------------------------------- /tests/onEvent-check-secret.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook that leverages a simple library (batteries included) 8 | * 9 | */ 10 | 11 | var SparkBot = require("../sparkbot/webhook"); 12 | 13 | // Starts your Webhook with default configuration 14 | var bot = new SparkBot(); 15 | 16 | // Specify the secret to check against incoming payloads 17 | bot.secret = "not THAT secret" 18 | 19 | bot.onEvent("all", "all", function(trigger) { 20 | 21 | // 22 | // YOUR CODE HERE 23 | // 24 | console.log("EVENT: " + trigger.resource + "/" + trigger.event + ", with data id: " + trigger.data.id + ", triggered by person id:" + trigger.actorId); 25 | 26 | }); 27 | 28 | -------------------------------------------------------------------------------- /tests/onEvent-messages-created.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook that leverages a simple library (batteries included) 8 | * 9 | * note : this example requires that you've set an ACCESS_TOKEN env variable 10 | * 11 | */ 12 | 13 | var SparkBot = require("../sparkbot/webhook"); 14 | 15 | // Starts your Webhook with default configuration where the Webex Teams API access token is read from the ACCESS_TOKEN env variable 16 | var bot = new SparkBot(); 17 | 18 | bot.onEvent("messages", "created", function(trigger) { 19 | console.log("new message from: " + trigger.data.personEmail + ", in room: " + trigger.data.roomId); 20 | 21 | bot.decryptMessage(trigger, function (err, message) { 22 | 23 | if (err) { 24 | console.log("could not fetch message contents, err: " + err.message); 25 | return; 26 | } 27 | 28 | // 29 | // YOUR CODE HERE 30 | // 31 | console.log("processing message contents: " + message.text); 32 | 33 | }); 34 | 35 | }); 36 | 37 | -------------------------------------------------------------------------------- /tests/onMessage-asCommand.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook that leverages a simple library (batteries included) 8 | * 9 | * note : this example requires that you've set a ACCESS_TOKEN env variable 10 | * 11 | */ 12 | 13 | var SparkBot = require("../sparkbot/webhook"); 14 | 15 | // Starts your Webhook with default configuration where the Webex Teams API access token is read from the ACCESS_TOKEN env variable 16 | var bot = new SparkBot(); 17 | 18 | bot.onMessage(function (trigger, message) { 19 | 20 | // 21 | // ADD YOUR CUSTOM CODE HERE 22 | // 23 | console.log("new message from: " + trigger.data.personEmail + ", text: " + message.text); 24 | 25 | var command = bot.asCommand(message); 26 | if (command) { 27 | console.log("detected command: " + command.keyword + ", with args: " + JSON.stringify(command.args)); 28 | } 29 | }); 30 | 31 | -------------------------------------------------------------------------------- /tests/onMessage.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | /* 7 | * a Webex Teams webhook that leverages a simple library (batteries included) 8 | * 9 | * note : this example requires that you've set a ACCESS_TOKEN env variable 10 | * 11 | */ 12 | 13 | var SparkBot = require("../sparkbot/webhook"); 14 | 15 | // Starts your Webhook with default configuration where the Webex Teams API access token is read from the SPARK_TOKEN env variable 16 | var bot = new SparkBot(); 17 | 18 | bot.onMessage(function(trigger, message) { 19 | 20 | // 21 | // ADD YOUR CUSTOM CODE HERE 22 | // 23 | console.log("new message from: " + trigger.data.personEmail + ", text: " + message.text); 24 | 25 | }); 26 | 27 | -------------------------------------------------------------------------------- /tests/test-registration.js: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Cisco Systems 3 | // Licensed under the MIT License 4 | // 5 | 6 | var SparkBot = require("../sparkbot/webhook"); 7 | 8 | // Starts your Webhook with default configuration where the Webex Teams API access token is read from the ACCESS_TOKEN env variable 9 | var bot = new SparkBot(); 10 | 11 | var registration = require("../sparkbot/registration.js"); 12 | registration.createWebhook(bot.token, "Test sparkbot", "https://requestb.in/123456", "all", "all", null, bot.secret, 13 | function(err, webhook) { 14 | console.log("done, webhook created"); 15 | registration.listWebhooks(bot.token, function (err, webhooks) { 16 | console.log("webhooks list: " + JSON.stringify(webhooks)); 17 | registration.deleteWebhook(bot.token, webhook.id, function (err, code) { 18 | console.log("done, webhook deleted"); 19 | registration.listWebhooks(bot.token, function (err, webhooks) { 20 | console.log("webhooks list: " + JSON.stringify(webhooks)); 21 | }); 22 | }); 23 | }); 24 | }); 25 | 26 | bot.onCommand("help", function(command) { 27 | 28 | // 29 | // ADD YOUR CUSTOM CODE HERE 30 | // 31 | console.log("new command: " + command.keyword + ", from: " + command.message.personEmail + ", with args: " + JSON.stringify(command.args)); 32 | 33 | }); 34 | 35 | 36 | --------------------------------------------------------------------------------