├── .gitignore ├── LICENSE ├── README.MD ├── botframework.plug ├── botframework.py └── static └── example.png /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Igor Vasilcovsky 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # Bot Framework Backend for ErrBot 2 | 3 | Backend for [Bot Framework](https://botframework.com) that brings ErrBot to the channels: 4 | * Microsoft Teams 5 | * Skype (COM component is not required anymore) 6 | * Skype for Business 7 | * Facebook Messenger 8 | * Twilio 9 | * Kik 10 | * SMS 11 | * Telegram 12 | * Slack 13 | * and more 14 | 15 | ![Example](https://raw.github.com/vasilcovsky/errbot-backend-botframework/master/static/example.png) 16 | 17 | ## Installation 18 | 19 | You would need Azure account (sorry). But don't worry you don't need to pay or bring your infrastructure to Azure, just account. 20 | 21 | * Go to [Azure Portal](https://portal.azure.com) 22 | * Go to "Create New Resource" - AI + Cognitive Services - **Bot Channel Registration** 23 | * Fill the form and obtain the application id and secret 24 | 25 | Download backend: 26 | ``` 27 | git clone https://github.com/vasilcovsky/errbot-backend-botframework.git 28 | ``` 29 | 30 | In errbot's config.py 31 | ``` 32 | BACKEND = 'BotFramework' 33 | BOT_EXTRA_BACKEND_DIR = '/path-to/errbot-backend-botframework/' 34 | 35 | BOT_IDENTITY = { 36 | 'appId': '', 37 | 'appPassword': '' 38 | } 39 | ``` 40 | 41 | Start bot and activate Webserver plugin (**required**) 42 | 43 | Final steps: 44 | * Go to [Azure Portal](https://portal.azure.com) 45 | * Open settings 46 | * Specify Messaging Endpoint: https://errbot-url/botframework 47 | * Add channels 48 | * Enjoy 49 | 50 | -------------------------------------------------------------------------------- /botframework.plug: -------------------------------------------------------------------------------- 1 | [Core] 2 | Name = BotFramework 3 | Module = botframework 4 | 5 | [Documentation] 6 | Description = Backend implementation for Bot Framework (https://www.botframework.com) -------------------------------------------------------------------------------- /botframework.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import datetime 4 | import requests 5 | 6 | from time import sleep 7 | from urllib.parse import urljoin 8 | from collections import namedtuple 9 | 10 | from flask import request 11 | from errbot.core import ErrBot 12 | from errbot.core_plugins import flask_app 13 | from errbot.backends.base import Message, Person 14 | 15 | log = logging.getLogger('errbot.backends.botframework') 16 | authtoken = namedtuple('AuthToken', 'access_token, expired_at') 17 | activity = namedtuple('Activity', 'post_url, payload') 18 | 19 | 20 | def from_now(seconds): 21 | now = datetime.datetime.now() 22 | return now + datetime.timedelta(seconds=seconds) 23 | 24 | 25 | def auth(appId, appPasswd): 26 | form = { 27 | 'grant_type': 'client_credentials', 28 | 'scope': 'https://api.botframework.com/.default', 29 | 'client_id': appId, 30 | 'client_secret': appPasswd, 31 | } 32 | 33 | r = requests.post( 34 | 'https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token', 35 | data=form 36 | ).json() 37 | 38 | expires_in = r['expires_in'] 39 | expired_at = from_now(expires_in) 40 | token = authtoken(r['access_token'], expired_at) 41 | 42 | return token 43 | 44 | 45 | class Conversation: 46 | """ Wrapper on Activity object. 47 | 48 | See more: 49 | https://docs.microsoft.com/en-us/bot-framework/rest-api/bot-framework-rest-connector-api-reference#activity-object 50 | """ 51 | 52 | def __init__(self, conversation): 53 | self._conversation = conversation 54 | 55 | @property 56 | def conversation(self): 57 | return self._conversation['conversation'] 58 | 59 | @property 60 | def conversation_id(self): 61 | return self.conversation['id'] 62 | 63 | @property 64 | def activity_id(self): 65 | return self._conversation['id'] 66 | 67 | @property 68 | def service_url(self): 69 | return self._conversation['serviceUrl'] 70 | 71 | @property 72 | def reply_url(self): 73 | url = 'v3/conversations/{}/activities/{}'.format( 74 | self.conversation_id, 75 | self.activity_id 76 | ) 77 | 78 | return urljoin(self.service_url, url) 79 | 80 | 81 | class Identifier(Person): 82 | def __init__(self, obj_or_json): 83 | if isinstance(obj_or_json, str): 84 | subject = json.loads(obj_or_json) 85 | else: 86 | subject = obj_or_json 87 | 88 | self._subject = subject 89 | self._id = subject.get('id', '') 90 | self._name = subject.get('name', '') 91 | 92 | def __str__(self): 93 | return json.dumps({ 94 | 'id': self._id, 95 | 'name': self._name 96 | }) 97 | 98 | def __eq__(self, other): 99 | return str(self) == str(other) 100 | 101 | @property 102 | def subject(self): 103 | return self._subject 104 | 105 | @property 106 | def userid(self): 107 | return self._id 108 | 109 | @property 110 | def aclattr(self): 111 | return self._id 112 | 113 | @property 114 | def person(self): 115 | return self._name 116 | 117 | @property 118 | def nick(self): 119 | return self._name 120 | 121 | @property 122 | def fullname(self): 123 | return self._name 124 | 125 | @property 126 | def client(self): 127 | return '' 128 | 129 | 130 | class BotFramework(ErrBot): 131 | """Errbot Backend for Bot Framework""" 132 | 133 | def __init__(self, config): 134 | super(BotFramework, self).__init__(config) 135 | 136 | identity = config.BOT_IDENTITY 137 | self._appId = identity.get('appId', None) 138 | self._appPassword = identity.get('appPassword', None) 139 | self._token = None 140 | self._emulator_mode = self._appId is None or self._appPassword is None 141 | 142 | self.bot_identifier = None 143 | 144 | def _set_bot_identifier(self, identifier): 145 | self.bot_identifier = identifier 146 | 147 | def _ensure_token(self): 148 | """Keep OAuth token valid""" 149 | now = datetime.datetime.now() 150 | if not self._token or self._token.expired_at <= now: 151 | self._token = auth(self._appId, self._appPassword) 152 | return self._token.access_token 153 | 154 | def _build_reply(self, msg): 155 | conversation = msg.extras['conversation'] 156 | payload = { 157 | 'type': 'message', 158 | 'conversation': conversation.conversation, 159 | 'from': msg.to.subject, 160 | 'recipient': msg.frm.subject, 161 | 'replyToId': conversation.conversation_id, 162 | 'text': msg.body 163 | } 164 | return activity(conversation.reply_url, payload) 165 | 166 | def _build_feedback(self, msg): 167 | conversation = msg.extras['conversation'] 168 | payload = { 169 | 'type': 'typing', 170 | 'conversation': conversation.conversation, 171 | 'from': msg.to.subject, 172 | 'replyToId': conversation.conversation_id, 173 | } 174 | return activity(conversation.reply_url, payload) 175 | 176 | def _send_reply(self, response): 177 | """Post response to callback url 178 | 179 | Send a reply to URL indicated in serviceUrl from 180 | Bot Framework's request object. 181 | 182 | @param response: activity object 183 | """ 184 | headers = { 185 | 'Content-Type': 'application/json' 186 | } 187 | 188 | if not self._emulator_mode: 189 | access_token = self._ensure_token() 190 | headers['Authorization'] = 'Bearer ' + access_token 191 | 192 | r = requests.post( 193 | response.post_url, 194 | data=json.dumps(response.payload), 195 | headers=headers 196 | ) 197 | 198 | r.raise_for_status() 199 | 200 | def serve_forever(self): 201 | self._init_handler(self) 202 | self.connect_callback() 203 | 204 | try: 205 | while True: 206 | sleep(1) 207 | except KeyboardInterrupt: 208 | log.info("Interrupt received, shutting down") 209 | finally: 210 | self.disconnect_callback() 211 | self.shutdown() 212 | 213 | def send_message(self, msg): 214 | response = self._build_reply(msg) 215 | self._send_reply(response) 216 | super(BotFramework, self).send_message(msg) 217 | 218 | def build_identifier(self, user): 219 | return Identifier(user) 220 | 221 | def build_reply(self, msg, text=None, private=False, threaded=False): 222 | return Message( 223 | body=text, 224 | parent=msg, 225 | frm=msg.frm, 226 | to=msg.to, 227 | extras=msg.extras, 228 | ) 229 | 230 | def send_feedback(self, msg): 231 | feedback = self._build_feedback(msg) 232 | self._send_reply(feedback) 233 | 234 | def build_conversation(self, conv): 235 | return Conversation(conv) 236 | 237 | def change_presence(self, status, message): 238 | pass 239 | 240 | def query_room(self, room): 241 | return None 242 | 243 | def rooms(self): 244 | return [] 245 | 246 | @property 247 | def mode(self): 248 | return 'BotFramework' 249 | 250 | def _init_handler(self, errbot): 251 | @flask_app.route('/botframework', methods=['GET', 'OPTIONS']) 252 | def get_botframework(): 253 | return '' 254 | 255 | @flask_app.route('/botframework', methods=['POST']) 256 | def post_botframework(): 257 | req = request.json 258 | log.debug('received request: type=[%s] channel=[%s]', 259 | req['type'], req['channelId']) 260 | if req['type'] == 'message': 261 | msg = Message(req['text']) 262 | msg.frm = errbot.build_identifier(req['from']) 263 | msg.to = errbot.build_identifier(req['recipient']) 264 | msg.extras['conversation'] = errbot.build_conversation(req) 265 | 266 | errbot._set_bot_identifier(msg.to) 267 | 268 | errbot.send_feedback(msg) 269 | errbot.callback_message(msg) 270 | return '' 271 | -------------------------------------------------------------------------------- /static/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vasilcovsky/errbot-backend-botframework/da15011afcd66a83e1a26dba5ab37a1f8b36bcfc/static/example.png --------------------------------------------------------------------------------