diff --git a/examples/example-webhooks-slack/README.md b/examples/example-webhooks-slack/README.md new file mode 100644 index 00000000..fcbb7ecf --- /dev/null +++ b/examples/example-webhooks-slack/README.md @@ -0,0 +1,36 @@ +# Overview + +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 +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:** +``` +ASANA_ACCESS_TOKEN= ./manage_webhooks.py list +``` + +**Create a new webhook:** +``` +ASANA_ACCESS_TOKEN= ./manage_webhooks.py create --resource --target http://:3000/receive_asana_webhook +``` + +**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:** +``` +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..9159e21a --- /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("/receive_asana_webhook", methods=["POST"]) +def receive_asana_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)