├── .gitignore ├── CHANGELOG.rst ├── LICENSE ├── README.rst ├── __init__.py ├── example ├── README.rst ├── example.py └── requirements.txt ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── conftest.py ├── data │ ├── twitter_like_event.json │ └── url_challenge.json ├── test_events.py └── test_server.py └── twitterwebhooks ├── __init__.py ├── server.py └── version.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .cache 3 | .idea 4 | dist 5 | *.log 6 | env 7 | .tox 8 | *.un~ 9 | 0/ 10 | tests/.cache 11 | .coverage 12 | .cache 13 | .DS_Store 14 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ============ 3 | 4 | v1.0.0 (2018-12-31) 5 | --------------------- 6 | 7 | - Initial release 🎉 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Roach 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.rst: -------------------------------------------------------------------------------- 1 | Twitter Webhooks for Python 2 | =================================== 3 | 4 | .. image:: https://badge.fury.io/py/twitterwebhooks.svg 5 | :target: https://badge.fury.io/py/twitterwebhooks 6 | 7 | .. image:: https://img.shields.io/badge/Tweet--lightgrey.svg?logo=twitter&style=social 8 | :target: https://twitter.com/intent/tweet?&text=%22Checking%20out%20Python%20Twitter%20Webhooks!%20@roach%22&url=https://github.com/Roach/python-twitter-webhooks 9 | 10 | The Twitter Webhook Adapter is a Python-based solution to receive and parse events 11 | from Twitter's Webhook API. This library uses an event emitter framework to allow 12 | you to easily process Twitter events by simply attaching functions 13 | to event listeners. 14 | 15 | This adapter enhances and simplifies Twitter's Webhook API by incorporating useful best practices, patterns, and opportunities to abstract out common tasks. 16 | 17 | 💡 This project is based on `Slack's Events API Adapter for Python`_ . 18 | 19 | .. _Slack's Events API Adapter for Python: https://github.com/slackapi/python-slack-events-api 20 | 21 | 22 | 🤖 Installation 23 | ---------------- 24 | 25 | .. code:: shell 26 | 27 | pip install twitterwebhooks 28 | 29 | 30 | **🎉 Once your webhook has been registered and user subscriptions are set up, you will begin receiving Twitter Events** 31 | 32 | ⚠️ Ngrok is a great tool for developing Webhook style apps, but it's not recommended to use ngrok 33 | for production apps. 34 | 35 | 🤖 Usage 36 | ---------- 37 | **⚠️ Keep your app's credentials safe!** 38 | 39 | - For development, keep them in virtualenv variables. 40 | 41 | - For production, use a secure data store. 42 | 43 | - Never post your app's credentials to github. 44 | 45 | .. code:: python 46 | 47 | TWITTER_CONSUMER_SECRET = os.environ["TWITTER_CONSUMER_SECRET"] 48 | 49 | Create a Webhook server for receiving actions via the Events API 50 | ----------------------------------------------------------------------- 51 | **Using the built-in Flask server:** 52 | 53 | .. code:: python 54 | 55 | from twitterwebhoooks import TwitterWebhookAdapter 56 | 57 | events_adapter = TwitterWebhookAdapter(CONSUMER_SECRET, "/webhooks/twitter") 58 | 59 | 60 | @events_adapter.on("favorite_events") 61 | def handle_message(event_data): 62 | event = event_data['event'] 63 | faved_status = event['favorited_status'] 64 | faved_status_id = faved_status['id'] 65 | faved_status_screen_name = faved_status['user']['screen_name'] 66 | faved_by_screen_name = event['user']['screen_name'] 67 | print("@{} faved @{}'s tweet: {}".format(faved_by_screen_name, faved_status_screen_name, faved_status_id)) 68 | print(json.dumps(event_data, indent=4, sort_keys=True)) 69 | 70 | 71 | # Start the server on port 3000 72 | events_adapter.start(port=3000) 73 | 74 | 75 | **Using your existing Flask instance:** 76 | 77 | 78 | .. code:: python 79 | 80 | from flask import Flask 81 | from twitterwebhoooks import TwitterWebhookAdapter 82 | 83 | 84 | # This `app` represents your existing Flask app 85 | app = Flask(__name__) 86 | 87 | 88 | # An example of one of your Flask app's routes 89 | @app.route("/") 90 | def hello(): 91 | return "Hello there!" 92 | 93 | 94 | # Bind the Events API route to your existing Flask app by passing the server 95 | # instance as the last param, or with `server=app`. 96 | events_adapter = TwitterWebhookAdapter(CONSUMER_SECRET, "/webhooks/twitter", app) 97 | 98 | 99 | @events_adapter.on("favorite_events") 100 | def handle_message(event_data): 101 | event = event_data['event'] 102 | faved_status = event['favorited_status'] 103 | faved_status_id = faved_status['id'] 104 | faved_status_screen_name = faved_status['user']['screen_name'] 105 | faved_by_screen_name = event['user']['screen_name'] 106 | print("@{} faved @{}'s tweet: {}".format(faved_by_screen_name, faved_status_screen_name, faved_status_id)) 107 | print(json.dumps(event_data, indent=4, sort_keys=True)) 108 | 109 | 110 | # Start the server on port 3000 111 | if __name__ == "__main__": 112 | app.run(port=3000) 113 | 114 | 115 | 🤖 Example event listeners 116 | ----------------------------- 117 | 118 | See `example.py`_ for usage examples. 119 | 120 | .. _example.py: /example/ 121 | 122 | 🤔 Support 123 | ----------- 124 | 125 | Need help? Open an issue or bug @Roach on Twitter 126 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Roach/python-twitter-webhooks/6bbe3205f362891aac9a88b00ec0f6bb776036d6/__init__.py -------------------------------------------------------------------------------- /example/README.rst: -------------------------------------------------------------------------------- 1 | Twitter Webhook Examples 2 | ============================= 3 | 4 | This example app shows how easy it is to implement the Twitter Webhooks API Adapter 5 | to receive Twitter Webhook events 6 | 7 | 🤖 Setup and running the app 8 | ------------------------------ 9 | 10 | **Set up your Python environment:** 11 | 12 | We're using virtualenv to keep the dependencies and environmental variables specific to this app. See `virtualenv`_ docs for more info. 13 | 14 | .. _virtualenv: https://virtualenv.pypa.io 15 | 16 | This example app works best in Python 2.7. If 2.7 is your default version, create a virtual environment by running: 17 | 18 | .. code:: 19 | 20 | virtualenv env 21 | 22 | Otherwise, if Python 3+ is your default, specify the path to your 2.7 instance: 23 | 24 | .. code:: 25 | 26 | virtualenv -p /your/path/to/python2 env 27 | 28 | Then initialize the virtualenv: 29 | 30 | .. code:: 31 | 32 | source env/bin/activate 33 | 34 | 35 | **Install the app's dependencies:** 36 | 37 | .. code:: 38 | 39 | pip install -r requirements.txt 40 | 41 | 42 | **🤖 Start ngrok** 43 | 44 | In order for Twitter to contact your local server, you'll need to run a tunnel. We 45 | recommend ngrok or localtunnel. We're going to use ngrok for this example. 46 | 47 | If you don't have ngrok, `download it here`_. 48 | 49 | .. _download it here: https://ngrok.com 50 | 51 | 💡 Twitter requires event requests be delivered over SSL, so you'll want to 52 | use the HTTPS URL provided by ngrok. 53 | 54 | Run ngrok and copy the **HTTPS** URL 55 | 56 | .. code:: 57 | 58 | ngrok http 3000 59 | 60 | .. code:: 61 | 62 | ngrok by @inconshreveable (Ctrl+C to quit) 63 | 64 | Session status online 65 | Version 2.1.18 66 | Region United States (us) 67 | Web Interface http://127.0.0.1:4040 68 | 69 | Forwarding http://h7465j.ngrok.io -> localhost:9292 70 | Forwarding https://h7465j.ngrok.io -> localhost:9292 71 | 72 | **🤖 Run the app:** 73 | 74 | You'll need to have your server and ngrok running to complete your app's Event 75 | Subscription setup 76 | 77 | .. code:: 78 | 79 | python example.py 80 | 81 | 82 | 83 | 🤔 Support 84 | ------------ 85 | 86 | Need help? Open an issue or bug @Roach on Twitter 87 | -------------------------------------------------------------------------------- /example/example.py: -------------------------------------------------------------------------------- 1 | from TwitterAPI import TwitterAPI 2 | from twitterwebhooks import TwitterWebhookAdapter 3 | import os 4 | import json 5 | 6 | CONSUMER_KEY = os.environ.get('CONSUMER_KEY', None) 7 | CONSUMER_SECRET = os.environ.get('CONSUMER_SECRET', None) 8 | ACCESS_TOKEN = os.environ.get('ACCESS_TOKEN', None) 9 | ACCESS_TOKEN_SECRET = os.environ.get('ACCESS_TOKEN_SECRET', None) 10 | 11 | events_adapter = TwitterWebhookAdapter(CONSUMER_SECRET, "/webhooks/twitter") 12 | twtr = TwitterAPI(CONSUMER_KEY, CONSUMER_SECRET, ACCESS_TOKEN, ACCESS_TOKEN_SECRET) 13 | 14 | logger = events_adapter.server.logger 15 | 16 | 17 | def get_account_id(): 18 | # Helper for fetching the bot's ID 19 | credentials = twtr.request('account/verify_credentials').json() 20 | return credentials['id'] 21 | 22 | 23 | def send_dm(recipient_id, message_text): 24 | # Helper for sending DMs 25 | event = { 26 | "event": { 27 | "type": "message_create", 28 | "message_create": { 29 | "target": { 30 | "recipient_id": recipient_id 31 | }, 32 | "message_data": { 33 | "text": message_text 34 | } 35 | } 36 | } 37 | } 38 | 39 | r = twtr.request('direct_messages/events/new', json.dumps(event)) 40 | response_json = r.json() 41 | return response_json 42 | 43 | 44 | # Fetch the account's ID so we can optionally ignore 45 | # messages sent from the bot 46 | BOT_ID = get_account_id() 47 | 48 | 49 | @events_adapter.on("direct_message_events") 50 | def handle_message(event_data): 51 | event = event_data['event'] 52 | if event['type'] == 'message_create': 53 | recipient_id = event['message_create']['target']['recipient_id'] 54 | sender_id = event['message_create']['sender_id'] 55 | sender_screen_name = event_data['users'][sender_id]['screen_name'] 56 | recipient_screen_name = event_data['users'][recipient_id]['screen_name'] 57 | message_text = event['message_create']['message_data']['text'] 58 | 59 | # Filter out bot messages 60 | if str(sender_id) == str(BOT_ID): 61 | print("IGNORING [Event {}] Incoming DM: To {} from {} \"{}\"".format( 62 | event['id'], 63 | recipient_screen_name, 64 | sender_screen_name, 65 | message_text 66 | )) 67 | else: 68 | print("[Event {}] Incoming DM: To {} from {} \"{}\"".format( 69 | event['id'], 70 | recipient_screen_name, 71 | sender_screen_name, 72 | message_text 73 | )) 74 | try: 75 | dm_id = send_dm(sender_id, "ACK! {}".format(event['id']))['event']['id'] 76 | print("Send DM: {}".format(dm_id)) 77 | except Exception as e: 78 | print("An error occurred sending DM: {}".format(e)) 79 | 80 | 81 | @events_adapter.on("favorite_events") 82 | def handle_message(event_data): 83 | event = event_data['event'] 84 | faved_status = event['favorited_status'] 85 | faved_status_id = faved_status['id'] 86 | faved_status_screen_name = faved_status['user']['screen_name'] 87 | faved_by_screen_name = event['user']['screen_name'] 88 | print("@{} faved @{}'s tweet: {}".format(faved_by_screen_name, faved_status_screen_name, faved_status_id)) 89 | print(json.dumps(event_data, indent=4, sort_keys=True)) 90 | 91 | @events_adapter.on("any") 92 | def handle_message(event_data): 93 | # Loop through events array and log received events 94 | for s in filter(lambda x: '_event' in x, list(event_data)): 95 | print("[any] Received event: {}".format(s)) 96 | 97 | 98 | # Handler for error events 99 | @events_adapter.on("error") 100 | def error_handler(err): 101 | print("ERROR: " + str(err)) 102 | 103 | 104 | # Once we have our event listeners configured, we can start the 105 | # Flask server with the default `/events` endpoint on port 3000 106 | events_adapter.start(port=3000) 107 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | twitterapi 2 | twitterwebhooks -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | ipdb==0.9.3 2 | ipython==7.16.3 3 | pdbpp==0.8.3 4 | pytest>=3.2.0 5 | pytest-flask==0.11 6 | pytest-mock>=1.6.3 7 | pytest-cov==2.5.1 8 | pytest-pythonpath==0.7.1 9 | testfixtures==5.3.1 10 | tox==2.9.1 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.0 2 | pyee==5.0.0 3 | flask==2.3.2 4 | packaging==16.8 5 | pyparsing==2.1.10 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.rst 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import codecs 3 | import os 4 | import re 5 | 6 | here = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | setup(name='twitterwebhooks', 9 | version='1.0.0', 10 | description='Python Twitter Webhooks', 11 | url='http://github.com/roach/python-twitter-webhooks', 12 | author='@roach', 13 | author_email='roach@roach.wtf', 14 | license='MIT', 15 | packages=['twitterwebhooks'], 16 | install_requires=[ 17 | 'flask', 18 | 'pyee', 19 | 'requests', 20 | ], 21 | zip_safe=False) 22 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | from base64 import b64encode, b64decode 3 | from hashlib import sha256 4 | import hmac 5 | import pytest 6 | from twitterwebhooks import TwitterWebhookAdapter 7 | 8 | 9 | def create_signature(consumer_secret, request_data): 10 | h = hmac.new( 11 | bytes(consumer_secret, 'utf-8'), 12 | request_data, 13 | digestmod=sha256 14 | ) 15 | return "sha256={}".format(b64encode(h.digest()).decode("utf-8")) 16 | 17 | 18 | def load_event_fixture(event, as_string=True): 19 | filename = "tests/data/{}.json".format(event) 20 | with open(filename) as json_data: 21 | event_data = json.load(json_data) 22 | if not as_string: 23 | return event_data 24 | else: 25 | return json.dumps(event_data) 26 | 27 | 28 | def event_with_bad_token(): 29 | event_data = load_event_fixture('twitter_like_event', as_string=False) 30 | event_data['token'] = "bad_token" 31 | return json.dumps(event_data) 32 | 33 | 34 | def pytest_namespace(): 35 | return { 36 | 'twitter_like_event_fixture': load_event_fixture('twitter_like_event'), 37 | 'url_challenge_fixture': load_event_fixture('url_challenge'), 38 | 'bad_token_fixture': event_with_bad_token(), 39 | 'create_signature': create_signature 40 | } 41 | 42 | 43 | @pytest.fixture 44 | def app(): 45 | adapter = TwitterWebhookAdapter("CONSUMER_SECRET") 46 | app = adapter.server 47 | app.testing = True 48 | return app 49 | -------------------------------------------------------------------------------- /tests/data/twitter_like_event.json: -------------------------------------------------------------------------------- 1 | { 2 | "for_user_id": "228925627", 3 | "favorite_events": [ 4 | { 5 | "created_at": "Mon Sep 03 01:24:41 +0000 2018", 6 | "timestamp_ms": 1535937881233, 7 | "favorited_status": { 8 | "created_at": "Sun Sep 02 06:20:05 +0000 2018", 9 | "id": 1036136664609898496, 10 | "text": "Marathoning Harry Potter and building Hogwarts https://t.co/B31zNyN6P2", 11 | "user": { 12 | "id": 228925627, 13 | "id_str": "228925627", 14 | "name": "ɥɔɐoɹ", 15 | "screen_name": "roach" 16 | } 17 | }, 18 | "user": { 19 | "id": 14255640, 20 | "id_str": "14255640", 21 | "name": "🤖nyx Mueller", 22 | "screen_name": "onyx" 23 | } 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /tests/data/url_challenge.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", 3 | "challenge": "valid_challenge_token", 4 | "type": "url_verification" 5 | } -------------------------------------------------------------------------------- /tests/test_events.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from twitterwebhooks import TwitterWebhookAdapter 3 | 4 | ADAPTER = TwitterWebhookAdapter('CONSUMER_SECRET', '/webhooks/twitter') 5 | 6 | 7 | def test_event_emission(client): 8 | # Events should trigger an event 9 | @ADAPTER.on('reaction_added') 10 | def event_handler(event): 11 | assert event["reaction"] == 'grinning' 12 | 13 | data = bytes(pytest.twitter_like_event_fixture, 'ascii') 14 | signature = pytest.create_signature(ADAPTER.consumer_secret, data) 15 | res = client.post( 16 | '/webhooks/twitter', 17 | data=data, 18 | content_type='application/json', 19 | headers={ 20 | 'X-Twitter-Webhooks-Signature': signature 21 | } 22 | ) 23 | 24 | assert res.status_code == 200 25 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | import json 2 | from flask import Flask 3 | import pytest 4 | from twitterwebhooks import TwitterWebhookAdapter 5 | from twitterwebhooks.server import TwitterWebhookAdapterException 6 | 7 | 8 | def test_existing_flask(): 9 | valid_flask = Flask(__name__) 10 | valid_adapter = TwitterWebhookAdapter("CONSUMER_SECRET", "/webhooks/twitter", valid_flask) 11 | assert isinstance(valid_adapter, TwitterWebhookAdapter) 12 | 13 | 14 | def test_server_not_flask(): 15 | with pytest.raises(TypeError) as e: 16 | invalid_flask = "I am not a Flask" 17 | TwitterWebhookAdapter("CONSUMER_SECRET", "/webhooks/twitter", invalid_flask) 18 | assert e.value.args[0] == 'Server must be an instance of Flask' 19 | 20 | 21 | def test_event_endpoint_get(client): 22 | # GET on '/webhooks/twitter' should 404 23 | res = client.get('/webhooks/twitter') 24 | assert res.status_code == 404 25 | 26 | # TODO: Update this test for Twitter webhook registration handshake 27 | # def test_url_challenge(client): 28 | # adapter = TwitterWebhookAdapter("CONSUMER_SECRET") 29 | # data = bytes(pytest.twitter_like_event_fixture, 'ascii') 30 | # signature = pytest.create_signature(adapter.consumer_secret, data) 31 | # 32 | # res = client.post( 33 | # '/webhooks/twitter', 34 | # data=data, 35 | # content_type='application/json', 36 | # headers={ 37 | # 'X-Twitter-Webhooks-Signature': signature 38 | # } 39 | # ) 40 | # assert res.status_code == 200 41 | # assert bytes.decode(res.data) == "valid_challenge_token" 42 | 43 | 44 | def test_invalid_request_signature(client): 45 | # Verify [package metadata header is set 46 | adapter = TwitterWebhookAdapter("CONSUMER_SECRET") 47 | 48 | data = bytes(pytest.twitter_like_event_fixture, 'ascii') 49 | signature = "bad signature" 50 | 51 | with pytest.raises(TwitterWebhookAdapterException) as excinfo: 52 | res = client.post( 53 | '/webhooks/twitter', 54 | data=data, 55 | content_type='application/json', 56 | headers={ 57 | 'X-Twitter-Webhooks-Signature': signature 58 | } 59 | ) 60 | 61 | assert str(excinfo.value) == 'Invalid request signature' 62 | 63 | 64 | def test_server_start(mocker): 65 | # Verify server started with correct params 66 | adapter = TwitterWebhookAdapter("CONSUMER_SECRET") 67 | mocker.spy(adapter , 'server') 68 | adapter.start(port=3000) 69 | adapter.server.run.assert_called_once_with(debug=False, host='127.0.0.1', port=3000) 70 | 71 | 72 | def test_default_exception_msg(mocker): 73 | with pytest.raises(TwitterWebhookAdapterException) as excinfo: 74 | raise TwitterWebhookAdapterException 75 | 76 | assert str(excinfo.value) == 'An error occurred in the TwitterWebhookAdapter library' 77 | -------------------------------------------------------------------------------- /twitterwebhooks/__init__.py: -------------------------------------------------------------------------------- 1 | from pyee import EventEmitter 2 | from .server import WebhookServer 3 | 4 | 5 | class TwitterWebhookAdapter(EventEmitter): 6 | # Initialize the Webhook server 7 | # If no endpoint is provided, default to listening on '/webhooks/twitter' 8 | def __init__(self, consumer_secret, endpoint="/webhooks/twitter", server=None, **kwargs): 9 | EventEmitter.__init__(self) 10 | self.consumer_secret = consumer_secret 11 | self.server = WebhookServer(consumer_secret, endpoint, self, server, **kwargs) 12 | 13 | def start(self, host='127.0.0.1', port=None, debug=False, **kwargs): 14 | """ 15 | Start the built in webserver, bound to the host and port you'd like. 16 | Default host is `127.0.0.1` and port 8080. 17 | 18 | :param host: The host you want to bind the build in webserver to 19 | :param port: The port number you want the webserver to run on 20 | :param debug: Set to `True` to enable debug level logging 21 | :param kwargs: Additional arguments you'd like to pass to Flask 22 | """ 23 | self.server.run(host=host, port=port, debug=debug, **kwargs) 24 | -------------------------------------------------------------------------------- /twitterwebhooks/server.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, make_response 2 | import base64 3 | from hashlib import sha256 4 | import hmac 5 | import json 6 | 7 | 8 | class WebhookServer(Flask): 9 | 10 | def __init__(self, consumer_secret, endpoint, emitter, server): 11 | self.consumer_secret = consumer_secret 12 | self.emitter = emitter 13 | self.endpoint = endpoint 14 | 15 | # If a server is passed in, bind the event handler routes to it, 16 | # otherwise create a new Flask instance. 17 | if server: 18 | if isinstance(server, Flask): 19 | self.bind_route(server) 20 | else: 21 | raise TypeError("Server must be an instance of Flask") 22 | else: 23 | Flask.__init__(self, __name__) 24 | self.bind_route(self) 25 | 26 | def create_signature(self, crc): 27 | # Generate CRC signature to confirm webhook URL 28 | validation = hmac.new( 29 | key=bytes(self.consumer_secret, 'utf-8'), 30 | msg=bytes(crc, 'utf-8'), 31 | digestmod=sha256 32 | ) 33 | digested = base64.b64encode(validation.digest()) 34 | response = { 35 | 'response_token': 'sha256=' + format(str(digested)[2:-1]) 36 | } 37 | 38 | return json.dumps(response) 39 | 40 | def verify_request(self, request_data): 41 | signature = request.headers["X-Twitter-Webhooks-Signature"] 42 | try: 43 | crc = base64.b64decode(signature[7:]) # strip out the first 7 characters 44 | h = hmac.new( 45 | bytes(self.consumer_secret, 'ascii'), 46 | request_data, 47 | digestmod=sha256 48 | ) 49 | return hmac.compare_digest(h.digest(), crc) 50 | except base64.binascii.Error as err: 51 | return False 52 | 53 | def bind_route(self, server): 54 | @server.route(self.endpoint, methods=['GET']) 55 | def crc_handshake(): 56 | if "crc_token" in request.args: 57 | crc = request.args['crc_token'] 58 | self.emitter.emit("crc", crc) 59 | return make_response(self.create_signature(crc), 200) 60 | else: 61 | return make_response("These are not the twitter bots you're looking for.", 404) 62 | 63 | @server.route(self.endpoint, methods=['POST']) 64 | def event(): 65 | # Verify the request signature using the app's signing secret 66 | # emit an error if the signature can't be verified 67 | if not self.verify_request(request.get_data()): 68 | twtr_exception = TwitterWebhookAdapterException('Invalid request signature') 69 | self.emitter.emit('error', twtr_exception) 70 | return make_response("", 403) 71 | 72 | # Parse the Event payload and emit the event to the event listener 73 | request_json = request.get_json() 74 | for event_type in filter(lambda events: '_event' in events, list(request_json)): 75 | for specific_event in request_json[event_type]: 76 | event_data = { 77 | 'for_user_id': int(request_json['for_user_id']), 78 | 'event': specific_event, 79 | } 80 | if 'users' in request_json: 81 | event_data['users'] = request_json['users'] 82 | 83 | self.emitter.emit(event_type, event_data) 84 | self.emitter.emit('any', request_json) 85 | response = make_response("", 200) 86 | return response 87 | 88 | 89 | class TwitterWebhookAdapterException(Exception): 90 | """ 91 | Base exception for all errors raised by the TwitterWebhookAdapter library 92 | """ 93 | def __init__(self, msg=None): 94 | if msg is None: 95 | # default error message 96 | msg = "An error occurred in the TwitterWebhookAdapter library" 97 | super(TwitterWebhookAdapterException, self).__init__(msg) 98 | -------------------------------------------------------------------------------- /twitterwebhooks/version.py: -------------------------------------------------------------------------------- 1 | # see: http://legacy.python.org/dev/peps/pep-0440/#public-version-identifiers 2 | __version__ = '1.0.0' --------------------------------------------------------------------------------