From c71d48cddc7962e8b5a74e586d23b22cd182a03e Mon Sep 17 00:00:00 2001 From: Shun Rao Date: Tue, 5 Nov 2019 17:22:45 -0800 Subject: [PATCH 1/4] Add initial webhooks example project --- examples/example-webhooks-slack/README.md | 26 +++++ .../example-webhooks-slack/manage_webhooks.py | 96 ++++++++++++++++ .../example-webhooks-slack/requirements.txt | 3 + examples/example-webhooks-slack/server.py | 106 ++++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 examples/example-webhooks-slack/README.md create mode 100755 examples/example-webhooks-slack/manage_webhooks.py create mode 100644 examples/example-webhooks-slack/requirements.txt create mode 100755 examples/example-webhooks-slack/server.py diff --git a/examples/example-webhooks-slack/README.md b/examples/example-webhooks-slack/README.md new file mode 100644 index 00000000..01ad8a14 --- /dev/null +++ b/examples/example-webhooks-slack/README.md @@ -0,0 +1,26 @@ +## Webhooks server +Start the server before creating any webhooks +``` +ASANA_ACCESS_TOKEN= SLACK_TOKEN= ./server.py +``` + +## Manage Webhooks +List existing webhooks: +``` +ASANA_ACCESS_TOKEN= ./manage_webhooks.py list +``` + +Create a new webhook: +``` +ASANA_ACCESS_TOKEN= ./manage_webhooks.py create --resource --target +``` + +Delete a webhook by ID: +``` +ASANA_ACCESS_TOKEN= ./manage_webhooks.py delete --id +``` + +Delete ALL your webhooks: +``` +ASANA_ACCESS_TOKEN= ./manage_webhooks.py delete --all +``` diff --git a/examples/example-webhooks-slack/manage_webhooks.py b/examples/example-webhooks-slack/manage_webhooks.py new file mode 100755 index 00000000..ad0203aa --- /dev/null +++ b/examples/example-webhooks-slack/manage_webhooks.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +""" +An example script showing ways to manage your webhooks using the +python-asana client library, see README.md or run ./manage_webhooks.py -h +for usage examples +""" + +import argparse +import os + +import asana + +WORKSPACE_ID = "15793206719" + +client = asana.Client.access_token(os.environ.get("ASANA_ACCESS_TOKEN")) +WORKSPACE_NAME = client.workspaces.find_by_id(WORKSPACE_ID).get("name") + + +def list_webhooks(): + print( + "Displaying webhooks you own associated with workspace: {}".format( + WORKSPACE_NAME + ) + ) + webhooks = client.webhooks.get_all({"workspace": WORKSPACE_ID}) + for w in webhooks: + print(w) + + +def delete_webhook(gid): + print("Deleting webhook: {}".format(gid)) + client.webhooks.delete_by_id(gid) + + +def delete_all_webhooks(): + print( + "Deleting all webhooks you own associated with workspace: {}".format( + WORKSPACE_NAME + ) + ) + webhooks = client.webhooks.get_all({"workspace": WORKSPACE_ID}) + for w in webhooks: + client.webhooks.delete_by_id(w.get("gid")) + + +def create_webhook(resource, target): + print( + "Creating webhook on {}, make sure that the server at {} is ready to accept requests!".format( + resource, target + ) + ) + client.webhooks.create({"resource": resource, "target": target}) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers( + help="What you want to do with Asana webhooks", dest="command", required=True + ) + + list_parser = subparsers.add_parser("list", help="List existing webhooks") + + create_parser = subparsers.add_parser("create", help="Create a new webhook") + create_parser.add_argument( + "--resource", + type=str, + help="A resource ID to subscribe to. The resource can be a task or project", + required=True, + ) + create_parser.add_argument( + "--target", + type=str, + help="The webhook URL to receive the HTTP POST", + required=True, + ) + + delete_parser = subparsers.add_parser( + "delete", help="Delete one or all existing webhooks" + ) + delete_group = delete_parser.add_mutually_exclusive_group(required=True) + delete_group.add_argument( + "--id", type=str, help="The ID of the webhook you want to delete" + ) + delete_group.add_argument("--all", action="store_true") + + args = parser.parse_args() + + if args.command == "list": + list_webhooks() + elif args.command == "create": + create_webhook(args.resource, args.target) + elif args.command == "delete": + if args.all: + delete_all_webhooks() + else: + delete_webhook(args.id) diff --git a/examples/example-webhooks-slack/requirements.txt b/examples/example-webhooks-slack/requirements.txt new file mode 100644 index 00000000..83f2228e --- /dev/null +++ b/examples/example-webhooks-slack/requirements.txt @@ -0,0 +1,3 @@ +flask +asana +slackclient \ No newline at end of file diff --git a/examples/example-webhooks-slack/server.py b/examples/example-webhooks-slack/server.py new file mode 100755 index 00000000..dfb917d2 --- /dev/null +++ b/examples/example-webhooks-slack/server.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 + +""" +This script runs a Flask server that handles webhook requests from Asana, +and sends a Slack notification whenever the server receives and event indicating +that a milestone was completed. + +Instructions: +1. Set the ASANA_ACCESS_TOKEN environment variable to your Personal Access Token +2. Set the SLACK_TOKEN environment variable to your Slack API token +3. Create a webhook using for your project using manage_webhooks.py +""" + +import hmac +import os +import sys +from hashlib import sha256 + +import asana +from flask import Flask, Response, abort, request +import slack + +SECRET_FILE = "/tmp/asana_webhook_secret" + +# We need an Asana client to fetch the full names of tasks and users +# since webhook events are "skinny" +client = asana.Client.access_token(os.environ.get("ASANA_ACCESS_TOKEN")) +slack_client = slack.WebClient(token=os.environ.get("SLACK_TOKEN")) + +app = Flask(__name__) + + +def load_webhook_secret(): + with open(SECRET_FILE, "rb+") as f: + return f.read().strip() + + +def set_webhook_secret(value): + with open(SECRET_FILE, "w+") as f: + f.write(value) + + +def validate_request(req): + # Takes a flask request and computes a SHA256 HMAC using the webhook + # secret we received from Asana and compares it with the signature provided + # in the request as described in https://developers.asana.com/docs/#asana-webhooks. + # Returns whether the two signatures match. + + request_signature = request.headers.get("X-Hook-Signature") + computed_signature = hmac.new( + load_webhook_secret(), msg=req.data, digestmod=sha256 + ).hexdigest() + + return hmac.compare_digest(computed_signature, request_signature) + + +def notify_slack(user, milestone): + # Send a message to Slack for a milestone completion + message = "{} just completed the milestone {}!".format( + user.get("name"), milestone.get("name") + ) + print('Sending message to Slack: "{}"'.format(message)) + try: + slack_client.chat_postMessage(channel="#asana-notifications", text=message) + except slack.errors.SlackApiError: + print("Error sending message to Slack.") + + +@app.route("/slack_webhook", methods=["POST"]) +def slack_webhook(): + + request_secret = request.headers.get("X-Hook-Secret") + if request_secret: + # On webhook creation, the server will be sent a request for which we respond with 200 OK + # and a matching X-Hook-Secret header to confirm that we are accepting webhook requests + + # Note: resetting the local webhook secret whenever we get a new request to change + set_webhook_secret(request_secret) + + resp = Response() + resp.headers["X-Hook-Secret"] = load_webhook_secret() + return resp + elif not validate_request(request): + # Return 403 Forbidden if the request signature doesn't match + abort(403) + + events = request.json.get("events") + for event in events: + # Check if the event is the completion of a milestone + if ( + event["resource"].get("resource_subtype") == "marked_complete" + and event["parent"].get("resource_subtype") == "milestone" + ): + # The event only provides an id, so we need to get the full task + # and user objects to use in the notification + gid = event["parent"].get("gid") + user_id = event["user"].get("gid") + user = client.users.find_by_id(user_id) + milestone = client.tasks.find_by_id(gid) + notify_slack(user, milestone) + + return Response() + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=3000, debug=True) From c9fe2fc20b85c2f11663e12e8cb25e7b20bd1e72 Mon Sep 17 00:00:00 2001 From: Shun Rao Date: Wed, 6 Nov 2019 14:05:12 -0800 Subject: [PATCH 2/4] update README --- examples/example-webhooks-slack/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/example-webhooks-slack/README.md b/examples/example-webhooks-slack/README.md index 01ad8a14..5af42eb6 100644 --- a/examples/example-webhooks-slack/README.md +++ b/examples/example-webhooks-slack/README.md @@ -1,3 +1,8 @@ +# Overview + +This project shows how Asana webhooks can be used to send a notification to Slack whenever a milestone is completed. +It also contains examples of how to manage webhooks through the Python Asana client. + ## Webhooks server Start the server before creating any webhooks ``` From 1e62eef59dd345e89a2b3036d4b52f2cca71240c Mon Sep 17 00:00:00 2001 From: Shun Rao Date: Thu, 7 Nov 2019 14:23:34 -0800 Subject: [PATCH 3/4] Update README.md --- examples/example-webhooks-slack/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/example-webhooks-slack/README.md b/examples/example-webhooks-slack/README.md index 5af42eb6..d4f9b134 100644 --- a/examples/example-webhooks-slack/README.md +++ b/examples/example-webhooks-slack/README.md @@ -1,6 +1,6 @@ # Overview -This project shows how Asana webhooks can be used to send a notification to Slack whenever a milestone is completed. +This project shows how Asana webhooks can be used to send a celebratory message to Slack whenever a milestone is completed. It also contains examples of how to manage webhooks through the Python Asana client. ## Webhooks server From 4f37500d02698e86cd7624e8ee16facd13d21523 Mon Sep 17 00:00:00 2001 From: Shun Rao Date: Fri, 8 Nov 2019 12:54:47 -0800 Subject: [PATCH 4/4] Better README & small clarity changes --- examples/example-webhooks-slack/README.md | 17 +++++++++++------ examples/example-webhooks-slack/server.py | 4 ++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/examples/example-webhooks-slack/README.md b/examples/example-webhooks-slack/README.md index d4f9b134..fcbb7ecf 100644 --- a/examples/example-webhooks-slack/README.md +++ b/examples/example-webhooks-slack/README.md @@ -4,28 +4,33 @@ This project shows how Asana webhooks can be used to send a celebratory message It also contains examples of how to manage webhooks through the Python Asana client. ## Webhooks server -Start the server before creating any webhooks +Start the server before creating any webhooks. **The server needs to be DNS-addressable or have a public IP address for Asana to reach it. For development, you can use [ngrok](https://ngrok.com/) to quickly expose to your localhost to the web.** + ``` ASANA_ACCESS_TOKEN= SLACK_TOKEN= ./server.py ``` ## Manage Webhooks -List existing webhooks: +**List existing webhooks:** ``` ASANA_ACCESS_TOKEN= ./manage_webhooks.py list ``` -Create a new webhook: +**Create a new webhook:** ``` -ASANA_ACCESS_TOKEN= ./manage_webhooks.py create --resource --target +ASANA_ACCESS_TOKEN= ./manage_webhooks.py create --resource --target http://:3000/receive_asana_webhook ``` -Delete a webhook by ID: +**Note:** In this example, the target URL would point to the Flask endpoint defined in `server.py`. + + + +**Delete a webhook by ID:** ``` ASANA_ACCESS_TOKEN= ./manage_webhooks.py delete --id ``` -Delete ALL your webhooks: +**Delete ALL your webhooks:** ``` ASANA_ACCESS_TOKEN= ./manage_webhooks.py delete --all ``` diff --git a/examples/example-webhooks-slack/server.py b/examples/example-webhooks-slack/server.py index dfb917d2..9159e21a 100755 --- a/examples/example-webhooks-slack/server.py +++ b/examples/example-webhooks-slack/server.py @@ -66,8 +66,8 @@ def notify_slack(user, milestone): print("Error sending message to Slack.") -@app.route("/slack_webhook", methods=["POST"]) -def slack_webhook(): +@app.route("/receive_asana_webhook", methods=["POST"]) +def receive_asana_webhook(): request_secret = request.headers.get("X-Hook-Secret") if request_secret: