Ok, this post is fairly technical. Read on if you are interested in how bots work in Cisco Spark and how to use them. Disclaimer: the information here is not Cisco official, only my personal evaluations. I have not been directly involved in Cisco’s evaluations and decisions. My views may not represent the Spark API team’s views and may not be accurate.
Some people have been confused about how API bots work in Cisco Spark. The early APIs only supported integrations (using OAUTH2 tokens), but many people tried to make bots. They wanted to add a bot to a room and then communicate with it (i.e. exactly what bots are made for). That doesn’t work that well with integrations as the app basically act on behalf of a user and does not really have an identity of its own. In my earlier post, I explained the difference between a bot and an integration in general. Here I will deep-dive on how bots work in Cisco Spark and show how I added bot support in the Spark Army Knife app.
First of all, if you haven’t done so yet, send a direct message to armyknife@sparkbot.io. Here you will see the bot in action. The bot will respond to you with a link to authorise the app as an integration (see the screenshot).
The bot part of the app will only talk to you in that 1:1 room. You can add it to a group room (see second screenshot below), but it will just send a brief hello message and then automatically leave the room (this group room behaviour is not a Cisco Spark feature, but rather my implementation as you will see below.)
A privacy and security side note: once you have authorised the Spark Army Knife (pressing Accept on the request for access), Spark will start sending notifications to the Spark Army Knife app for all messages in all your rooms. Only messages from you will be retrieved to check if you type a command to the app starting with a ‘/‘. If you use the VIP tracking feature, message ids will be stored for messages from these users. Message texts are never stored.
Back in June, I demonstrated the Spark Army Knife app to the collaboration senior technical leader group (about 60 principal engineers and above from all parts of the Cisco Collaboration Technology Group). As you may know, the Spark Army Knife app runs on Google AppEngine and although no sensitive data are stored directly in the Google datastore, the OAuth tokens are (and must be for the app to work). Seeing the Spark Army Knife in action makes it very real how you can tap all messages from all rooms of a user without anybody else in those rooms knowing what is going on. Security, data protection, and privacy are important issues in the Cisco Spark service, and the senior technical architects typically have strong opinions, so we had a fun (and fairly heated) discussion around these issues.
An interesting side note: The corporate compliance functionality that allows an external app to scan for offending language or illegal files etc (offered to Cisco Spark corporate customers) is actually implemented as an outgoing webhook (aka an integration of the simple type). However, as opposed to the OAuth integration you can do with the developer APIs, you can use https://developer.ciscospark.com/ to list memberships in a room and see a membership entry for this compliance webhook. If you create an integration in a room from https://web.ciscospark.com/, you will also see a membership entry where the email of the integration “user” is based on your email address and a long number. If the integration has an identity that is visible as a membership, why isn’t it a bot? The main answer is that a bot has a security token that it can use to do many API operations, a simple integration is typically going in one direction and the receiving app does not present any security token. However, by making two simple integrations, both an incoming and an outgoing, you can actually create bot functionality!
In reality, nothing prevents a user from doing copy and paste or take screenshots and share sensitive information with other parties. However, the magnitude and simplicity of how an API integration can systematically eavesdrop on all your communication makes it easier to do a mistake by granting access to a malicious app. And also interesting, a full integration created using the APIs will not show up as a membership entry in the room’s membership list. The app acts fully on behalf of the user and whether the user takes a backup of all communication for personal purposes or forwards it on twitter, it is the user’s responsibility.
The only way to completely close all possibilities for apps to do malicious things is to not offer apps any access at all, i.e. forget about the APIs. Then you don’t get any of the benefits, so is there a middle way? The logical answer is a multi-level security model where apps are screened by the user, by the user’s IT organisation (that can approve/reject apps available), and by Cisco in an app store model.
The security and privacy issues also influence the bot design. When you add a bot to a room (like armyknife@sparkbot.io), it will not receive all messages from that room, but only messages where the bot has been @mentioned. As a bot is independent of users, it can live as an app on a server anywhere, and it can be added by all users in a room, its actions cannot be traced back to a single user like an integration using OAuth. You can still use a multi-level app security model, but members of a room where a bot is added may want to know what that bot is doing. It feels like an intruder. By only sending messages with @bot to the bot app, you get the necessary transparency while still getting the bot chat functionality. The alternative approach to transparency could be quite messy, how do you accurately and securely describe what a bot is doing with files and messages to everybody in the room?
When implementing bots, there are a few interesting things you should be aware of. Here are the steps you need to go through:
- Register a new bot at https://developer.ciscospark.com/add-bot.html. The display name is important as it will be used when somebody types in @. The Spark clients will then try to predict which user you are about to mention (don’t use a slash in the name!). The bot username is in the format of an email address with @sparkbot.io as domain. As you saw above with the armyknife@sparkbot.io, this is how users can add the bot to a room. When you create the bot, you will get an access token. This is the token you will use in the Authorization Bearer http header when sending requests to Spark.
- You can now send requests to Spark as your bot. You can use the test mode on https://developer.ciscospark.com/endpoint-people-me-get.html to try it out. Replace the token (not the string ‘Bearer’) in the Authorization Request Header with your bot access token (the one already there is your logged-in user’s token). You should get back information about your bot’s email address, avatar, display name, id, and when it was created.
- Make sure you have an app somewhere that can respond to POST requests with json content from Spark. As a minimum, return http status code 204, but you probably want to log something, maybe dump the json body, so you can see that something happens. At the end of this post you will see how you can get the Spark Army Knife code and set up your own app.
- Go to https://developer.ciscospark.com/endpoint-webhooks-post.html to create a new webhook. This is where you tell Spark where to send messages to the bot (@mentions from group rooms and all messages in direct/1:1 rooms). Give the webhook a descriptive name (for you), set the target URL to where your app is expecting POST requests, and specify which resources and events you want to get notified about. If you just want to receive messages, use resource=messages and event=created. For Spark Army Knife, the URL is https://spark-army-knife.greger.io/bot, and I used resource=all and event=all, which gives you some interesting possibilities that I will cover later.
- Add your bot to a 1:1 room and send a message. Your bot should get its first message(s)!
An interesting gotcha: You will get a new message notification from Spark (resource=messages, event=created). In data[‘id], you will find the id of the message, and you need to send a GET /v1/messages/<id> to retrieve the message text. If you get a message from a direct room, you will not be @mentioned, but you will in a group room, so for the messages in a group room, the first word(s) will be your display name. This is important if you parse the text to look for commands.
The bot support for the Spark Army Knife has been split into two files: the request handler (webapp2) and the on_bot_post() function that does the processing. As you may remember, the Spark Army Knife is a python app running on Google AppEngine. An earlier post shows how to get started with Spark and Google AppEngine. Right below you find the webapp2.RequestHandler for POST bot callbacks (remember to add an entry in your app.yaml to /bot).
#!/usr/bin/env python # import cgi import wsgiref.handlers import logging import json from actingweb import actor from actingweb import auth from actingweb import config from on_aw import on_aw_bot import webapp2 class MainPage(webapp2.RequestHandler): def post(self, path): """Handles POST callbacks for bots.""" Config = config.config() if not Config.bot['token'] or len(Config.bot['token']) == 0: self.response.set_status(404) return check = auth.auth(id=None) check.oauth.token = Config.bot['token'] ret = on_aw_bot.on_bot_post(req=self, auth=check, path=path) if ret and ret >= 100 and ret < 999: self.response.set_status(ret) return elif ret: self.response.set_status(204) return else: self.response.set_status(404) return application = webapp2.WSGIApplication([ webapp2.Route(r'/bot<:/?><path:(.*)>', MainPage, name='MainPage'), ], debug=True)
This version of Spark Army Knife has been upgraded to the latest version of the ActingWeb library with some interesting bot to bot communication functionality, but you don’t have to worry about that here. There is a new section in actingweb/config.py where you configure your bot.
self.bot = { 'token': '', 'email': '', }
After a check on Config.bot[‘token’], a new auth object is created (with id=None as we don’t know anything about the user yet) and its oauth functionality is initialised with the bot’s token. Then the on_bot_post() gets the webapp2 request, the auth object, and any path information that may have been in the request (POST /bot/path, not uses here).
The Spark Army Knife app only uses the bot functionality to offer a simple way of signing up to and to authorise the app. The bot will send a welcome message and an authorisation link when it is added to a direct/1:1 room, and just a “how-to-use” message and then leave immediately if it is added to a group room. This simple functionality illustrates the bot concepts nicely though.
First an overview of the functionality:
- Parse the json body
- Try to identify the user as an existing user (myself)
- Then do various things based on whether the message to the bot was about rooms, memberships, or messages
- Room created: if the bot was added to a new direct room, send an authorisation message
- Membership created: if the bot was added to a group room, send how to use message and leave
- Message created: if the message is in a group room (@mention), send how to use message, if /init is sent in a direct/1:1 room, send an authorisation message
Here is the code with explanations following below.
#!/usr/bin/env python import webapp2 import logging import time import json from actingweb import actor from actingweb import oauth from actingweb import config from spark import ciscospark __all__ = [ 'on_bot_post', ] def on_bot_post(req, auth, path): """Called on POSTs to /bot.""" Config = config.config() spark = ciscospark.ciscospark(auth=auth, actorId=None) rawbody = req.request.body.decode('utf-8', 'ignore') if not rawbody or len(rawbody) == 0: return False try: body = json.loads(rawbody) logging.debug('Bot callback: ' + rawbody) data = body['data'] person = body['actorId'] except: return 405 myself = actor.actor() myself.get_from_property(name='oauthId', value=person) if myself.id: logging.debug('Found actor(' + myself.id + ')') # Only do something if we cannot associate this with somebody if not myself.id and body['resource'] == 'rooms': if body['event'] == 'created': room = data['id'] type = data['type'] if type == 'direct': personData = spark.getPerson(person) personEmail = personData['emails'][0] myself.create(url=Config.root, creator= personEmail, passphrase=Config.newToken()) url = Config.root + myself.id myself.setProperty('chatRoomId', room) spark.postMessage( room, "**Welcome to Spark Army Knife, " + personEmail + "!**\n\n Please authorize the app by clicking the following link: " + url + "/www", markdown=True) return True if body['resource'] == 'memberships': if body['event'] == 'created': room = data['roomId'] personEmail = data['personEmail'] if personEmail == Config.bot['email']: roomData = spark.getRoom(room) if roomData['type'] == 'group': spark.postMessage(room, "**Welcome to Spark Army Knife!**\n\n To use, please create a 1:1 room with " + Config.bot['email'] + ". If already done without success, type /init in that room.", markdown=True) spark.deleteMember(data['id']) return True if body['resource'] == 'messages': msg = data['id'] if body['event'] == 'created': room = data['roomId'] personEmail = data['personEmail'] msgData = spark.getMessage(msg) roomData = spark.getRoom(room) if msgData and roomData and 'text' in msgData and 'title' in roomData and personEmail != Config.bot['email']: logging.debug(msgData['text']) words = msgData['text'].split(' ') if len(words) > 2: return True txt = msgData['text'].split('/') if len(txt) >= 2: cmd = txt[1] else: cmd = txt[0] if data['roomType'] == 'direct' and cmd == 'init': if not myself.id: myself.create(url=Config.root, creator= personEmail, passphrase=Config.newToken()) url = Config.root + myself.id myself.setProperty('chatRoomId', room) spark.postMessage(room, "**Welcome to Spark Army Knife, " + personEmail + "!**\n\n Please authorize the app by clicking the following link: " + url + "/www", markdown=True) elif len(txt) == 1 or (len(txt) == 2 and data['roomType'] != 'direct'): spark.postMessage(room, "**Welcome to Spark Army Knife, " + personEmail + "!**\n\n To use, please create a 1:1 room with " + Config.bot['email'] + ". You can type /init to re-authorize your account. /help for help.", markdown=True) return True
If you haven’t looked at Spark Army Knife code before, the ciscospark library is just a simple library exposing functions like getMessage(), getRoom(), and so on by using GET, POST, DELETE API requests. The json returned from the requests are passed back directly as a python dictionary. Make requests to Spark APIs any way you want. The actor object is a representation of the user that abstracts away all the nitty gritty stuff like database persistence and a lot more as found in the ActingWeb library. If the Spark id cannot be found for an existing user, a new actor is created. If you don’t use the ActingWeb library, you should still be able to understand where to create a new user in your app using whatever persistence you want.
As I promised, you now see why it is useful to subscribe to all resources and all events. If the bot is added to a direct/1:1 room, the bot will get three messages in rapid succession: a) Room created, b) Membership created, and c) Message created. If the bot is added to a group room, it may get all three messages, but only b and c if it is added to an existing group. (Also, it will get a membership deleted message once it leaves the room, but it is not processed in this code.) This behaviour allows you to send an automatic message when the bot is added to a direct room by processing the room created event and not the membership created event.
Let’s look at the details. The json parsing is straight-forward, just note that all messages have a data sub-structure with information about the room, membership, or message. Thus, data[‘id’] will be the id of the resource created. The body[‘actorId’] will be the Spark user that has triggered the action. This means that if user ABC adds the bot user to a room, the bot will receive a message with resource=memberships and event=created, and with data[‘personEmail’] containing the email address of your bot.
{ “resource”: “memberships”, “event”: “created”, “actorId”: “ABC”, …, “data”: { “id”: “new_membership”, “personEmail”: “bot_email”, … } }
After json parsing, an empty actor object is instantiated, and the Spark id is looked up in the actor attribute/value datastore by looking for attributes with name=oauthId and value set to the Spark id. If the attribute/value pair is found, the user associated with the id will be automatically loaded into the actor object by the ActingWeb library. For example, the user’s stored OAuth token will be available, so the bot could also do API requests on behalf of the user (an integration!).
person = body['actorId'] … myself = actor.actor() myself.get_from_property(name='oauthId', value=person) if myself.id: logging.debug('Found actor(' + myself.id + ')')
The next section handles room created events where no user has been identified, i.e. an unknown user has just added armyknife@sparkbot.io to a direct/1:1 room. One thing to be aware of is that although one of the users leaves the room, the room will continue to exist and the next time a direct/1:1 is established to exchange messages between the two users (in this case, one user and the bot), the SAME room is used. Thus, the rooms created event will not be triggered more than the very first time a user messages the bot!
# Only do something if we cannot associate this with somebody if not myself.id and body['resource'] == 'rooms': if body['event'] == 'created': room = data['id'] type = data['type'] if type == 'direct': personData = spark.getPerson(person) personEmail = personData['emails'][0] myself.create(url=Config.root, creator= personEmail, passphrase=Config.newToken()) url = Config.root + myself.id myself.setProperty('chatRoomId', room) spark.postMessage( room, "**Welcome to Spark Army Knife, " + personEmail + "!**\n\n Please authorize the app by clicking the following link: " + url + "/www", markdown=True) return True
As you can see, the data[‘id’] attribute is here the room id. The room type is either direct or group, and the bot should only respond to rooms created events for direct rooms. One API request is sent to retrieve more information about the person who created the room (note that the emails attribute is an array of email addresses stored on the user).
Then a new user (actor) is created, the id of this room is stored for later, and finally a link is created where the user can initiate the OAuth authorisation needed by the integration part of the Spark Army Knife.
The memberships created event handling is even simpler. A a safety, the email address of the user being added to the room is verified to be the bot’s. Since the rooms created event is used for direct rooms, only group rooms need to handled here and a “how to use” message is sent to the room, before the bot deletes it’s own (just created) room membership. Note that the bot will also get a memberships deleted event when deleteMember() is done. However, messages of this type are just ignored.
if body['resource'] == 'memberships': if body['event'] == 'created': room = data['roomId'] personEmail = data['personEmail'] if personEmail == Config.bot['email']: roomData = spark.getRoom(room) if roomData['type'] == 'group': spark.postMessage(room, "**Welcome to Spark Army Knife!**\n\n To use, please create a 1:1 room with " + Config.bot['email'] + ". If already done without success, type /init in that room.", markdown=True) spark.deleteMember(data['id']) return True
Finally, the messages created events are handled (see code below). Both direct and group messages (with @mention_bot) will be handled here, but the latter will rarely arrive before the bot deletes itself from the group room. But the code below takes into account the fact that “your_bots_displayname” will be the first part of the message in group rooms (note that a / in your bot’s display name will break the code, though
The main idea here is that the “how to use” message is sent as a response to any message in a direct room (like “hey you”) and single-word messages like “@armyknife hey” in a group room (try it out in group rooms by commenting out the above spark.deleteMember() call). If “/init” is typed in a direct room, the authorisation link is sent.
if body['resource'] == 'messages': msg = data['id'] if body['event'] == 'created': room = data['roomId'] personEmail = data['personEmail'] msgData = spark.getMessage(msg) roomData = spark.getRoom(room) if msgData and roomData and 'text' in msgData and 'title' in roomData and personEmail != Config.bot['email']: logging.debug(msgData['text']) words = msgData['text'].split(' ') if len(words) > 2: return True txt = msgData['text'].split('/') if len(txt) >= 2: cmd = txt[1] else: cmd = txt[0] if data['roomType'] == 'direct' and cmd == 'init': if not myself.id: myself.create(url=Config.root, creator= personEmail, passphrase=Config.newToken()) url = Config.root + myself.id myself.setProperty('chatRoomId', room) spark.postMessage(room, "**Welcome to Spark Army Knife, " + personEmail + "!**\n\n Please authorize the app by clicking the following link: " + url + "/www", markdown=True) elif len(txt) == 1 or (len(txt) == 2 and data['roomType'] != 'direct'): spark.postMessage(room, "**Welcome to Spark Army Knife, " + personEmail + "!**\n\n To use, please create a 1:1 room with " + Config.bot['email'] + ". You can type /init to re-authorize your account. /help for help.", markdown=True) return True
As you can see, writing a small bot is very simple. Here you have also seen how you can use the Spark id to tie together a bot message from a user to a box-integration app that also has the user’s OAuth authorisation. This way you can play around with lots of possibilities. You can for example write a bot-integration that will only register webhooks in rooms where the bot is a member. The webhook and the messages will be processed on behalf of the user in the integration part of the app, thus all messages in the room will be available (not only @mentions of the bot). Combining a bot and an integration like this gives you a whole lot of new possibilities!
Get the entire and latest Spark Army Knife code so you can deploy your own Google AppEngine Spark app powered by ActingWeb! If you want to make sure to get the exact code discussed here, download the code snapshot.