Wie man Funktionen in ClickUp hinzufügt

Automatisierung mit Azure Functions

In diesem Beitrag erklären wir, wie wir Funktionen zu ClickUp mit Hilfe einer Azure Function hinzufügen, die über ClickUp Automations nicht umsetzbar sind. Wir behandeln dabei das Aufsetzen einer Azure Function, das Einrichten von Webhooks mit ClickUp und die damit verbundenen Kosten. Die hier erstellte Azure Function wird in Python geschrieben kann aber in jeder bei Azure Functions verfügbaren Programmiersprache umgesetzt werden.

Problemstellung

Bevor eine Software entwickelt werden kann, ist es wichtig, sich zu überlegen, was das eigentliche Problem ist, das man damit lösen möchte. In unserem Fall handelt es sich dabei um eine Automatisierung eines manuellen Schrittes beim Erstellen von Abwesenheit-Tasks in ClickUp, welcher von den ClickUp-internen Automations nicht umgesetzt werden kann: Wird ein Task in der Urlaubs-/Krankenstands-Liste erstellt, soll Folgendes passieren:

  1. Ist noch kein tag (im Sinne von Etikett) vergeben, soll anhand des Task-Namens ein tag vergeben werden:
    • bei "krank" oder ähnlichen Schlüsselwörtern wird der tag "Krankenstand" vergeben
    • bei "Urlaub" oder ähnlichen Schlüsselwörtern wird der tag "Urlaub" vergeben
    • bei Sonderurlauben (z.B. Umzug, Geburt, Beerdingung, usw.) wird der tag "ef" vergeben
  2. Ist der Task keiner Person zugeteilt, wird der Task dem Ersteller bzw. der Erstellerin zugeteilt.

Umsetzung

Um solche Automationen umzusetzen, ist es möglich, in ClickUp Webhooks einzurichten - dies kann entweder über eine Automatisierung gemacht werden oder über die Webhook API. Da wir die Azure Function unabhängig von den ClickUp-Einstellungen aufsetzen wollen, verwenden wir in diesem Fall die Webhook API (das hat Auswirkungen auf den Code, siehe weiter unten "Task updaten").

Einrichten der Azure Function

Wir verwenden zum Einrichten der Azure Function die offizielle Dokumentation von Microsoft. Als Auslöser für unsere Azure Function verwenden wir einen HTTP Trigger und als authLevel anonymous. Da ClickUp Webhooks immer nur HTTP POST verwenden, entfernen wir die GET Methode aus function.json, abgesehen davon bleiben alle automatisch generierten Einstellungen gleich.

Einrichten des Webhooks

ClickUp Webhooks, die über die API erstellt werden, werden durch eine Signatur abgesichert - dadurch kann überprüft werden, ob der eingehende Request tatsächlich von ClickUp von dem registrierten Webhook kommt. Um diesen Webhook funktionsübergreifend verwenden zu können, wird er außerhalb der main Funktion definiert. Folgende Schritte sind daher beim Starten der Function App notwendig:

ClickUp API Einrichten

Für die meisten Funktionen des ClickUp APIs wird sowohl ein Authentifizierungs-Header als auch die Team ID benötigt. Die Authentifizierungs-Methode ist je nach Anwendungsfall unterschiedlich (siehe auch jsapi.apiary.io), zum Entwickeln und Testen bietet sich der Personal Token an, da damit Fehler im Authentifizierungsprozess ausgeschlossen werden können. Um die Team ID von ClickUp zu bekommen, verwenden wir den ClickUp Teams Endpunkt.

Laden eines bereits vorhandenen Webhooks

Wurde bereits ein Webhook erstellt, können wir diesen über die ClickUp API laden. Wir gehen davon aus, dass wir maximal einen Webhook pro Endpunkt verwenden, ansonsten müsste die folgende Funktion mit mehreren Webhooks pro Endpunkt umgehen können:

def get_webhook():
    data = clickup.get_webhooks()
    for webhook in data["webhooks"]:
        if "endpoint" in webhook and webhook["endpoint"] == "myEndpointURL":
            return webhook
    return None

Anlegen von einem Webhook

Für den Fall, dass kein Webhook für unseren Endpunkt gefunden wurde, müssen wir einen neuen Webhook erstellen:

def create_webhook():
    # this webhook should be triggered when a task is created
    values = {
        "endpoint": "myEndpointURL",
        "events": [
            "taskCreated"
        ]   
    }
    
    # clickup is a wrapper around the API calls 
    resp = clickup.post_webhook(json.dumps(values))
    if resp.status_code == 200:
        data = json.loads(resp.text)
        if "webhook" in data:
            return data["webhook"]
    return None

Vorhandene Webhooks entfernen

Sollte beim Laden oder Erstellen ein Fehler aufgetreten sein, brauchen wir eine Funktion, die sicherstellt, dass wir keine Webhooks registriert haben, welche Daten schicken. Daher schreiben wir eine Funktion, die alle Webhooks für diesen Endpunkt entfernt:

def cleanup_webhooks():
    # clickup is a wrapper around the API calls
    data = clickup.get_webhooks()
    for webhook in data["webhooks"]:
        if "endpoint" in webhook and webhook["enpoint"] == "myEndpointURL":
            clickup.delete_webhook(webhook["id"])

Environment Variablen auslesen

Einstellungen und Geheimnisse können bei Azure Functions in Environment Variablen gespeichert werden. Zusammen mit den bereits definierten Funktionen sieht der Code außerhalb der main Funktion so aus:

token = os.getenv("TOKEN", "")
urlaub_list_id = os.getenv("URLAUB_LIST_ID", "")
bitperfect_space_id = os.getenv("BITPERFECT_SPACE_ID", "")

clickup = ClickupAPI(token, bitperfect_space_id)
task_webhook = get_webhook()
if not task_webhook:
    task_webhook = create_webhook()
    if not task_webhook:
        cleanup_webhooks()
        raise EnvironmentError("could not establish webhook")

Funktion ausführen

Der bisherige Code wird genau ein Mal beim Hochfahren der Azure Function ausgeführt. Nun sehen wir uns den Code an, den wir bei jedem Auslösen der Azure Function ausführen.

Autorisierung überprüfen

Bevor wir den Request anderweitig behandeln, überprüfen wir, ob er überhaupt von einem von uns registrierten Webhook stammt - wir definieren dafür die verify_signature Funktion:

def verify_signature(msg, sec, sig):
    message = bytes(msg, 'utf-8')
    secret = bytes(sec, 'utf-8')
    hash = hmac.new(secret, message, hashlib.sha256)
    return sig == hash.hexdigest()

Die Struktur der main Funktion ändert sich daraufhin folgendermaßen:

def main(req: func.HttpRequest) -> func.HttpResponse:
    logging.info("Function is beeing executed")
    signature = req.headers.get("X-Signature")
    body = req.get_json()
    if signature:
        if not verify_signature(json.dumps(body, separators=(',', ':'), ensure_ascii=False), task_webhook["secret"], signature):
            logging.warning(f"Request with Invalid Signature: {req}")
            # to prevent external services to try accessing the endpoint again we send status_code 200
            return func.HttpResponse("Signature Invalid", status_code=200)
        else:
            request_handler = AbscenseRequestHandler(body, clickup, list_id)
            if request_handler.handle():
                logging.info("Request handled successfully")
                return func.HttpResponse("ok", status_code=200)
            else:
                logging.warning(f"Error while handling request: {body}")
                # this is status_code 200 because otherwise we would receive the same request again
                return func.HttpResponse("unable to handle request", status_code=200)
    else:
        logging.warning("Request without Signature received")
        return func.HttpResponse("Unauthorized", status_code=401)

Task updaten

In der main Funktion wird bei erfolgreicher Autorisierung eine Instanz des AbscenseRequestHandlers angelegt. Diese Klasse verwenden wir, um den Request abzuhandeln - dafür wird die handle Methode verwendet. Zuerst überprüfen wir, ob der Task sich überhaupt in der Abwesenheitsliste befindet - nur für solche Task soll die Automatisierung greifen. Ist das der Fall, setzen wir auf Basis von vordefinierten Schlüsselwörter den passenden tag und anschließend weisen wir den Task dem oder der Ersteller*in zu. Unsere Klasse sieht daraufhin folgendermaßen aus:

class AbscenseRequestHandler:
    def __init__(self, request_body, clickup, urlaub_list_id):
        self.request_body = request_body
        self.clickup: ClickupAPI = clickup
        self.urlaub_list_id = urlaub_list_id
        self.urlaub_keywords = ["urlaub", "holiday"]
        self.illness_keywords = ["krank", "krankenstand", "illness"]
        self.ef_keywords = ["ef", "erlaubte fehlstunden", "fehlstunden", "erlaubt", "geburt", "umzug", "tod", "beerdigung"]

    def handle(self):
        if self.is_in_abscence_list():
            task = self.clickup.get_task(self.request_body["task_id"])
            if not self.set_tag(task):
                return False
            if not self.add_assignee(task):
                return False
            return True
        else:
            return False

    def set_tag(self, task):
        if self.is_in_list(self.urlaub_keywords, task["name"].lower()):
            return self.clickup.add_tag(task["id"], "urlaub")
        elif self.is_in_list(self.illness_keywords, task["name"].lower()):
            return self.clickup.add_tag(task["id"], "krankenstand")
        elif self.is_in_list(self.ef_keywords, task["name"].lower()):
            return self.clickup.add_tag(task["id"], "ef")
        else:
            return True

    @staticmethod
    def is_in_list(keywords, name):
        for keyword in keywords:
            if keyword in name:
                return True
        return False

    def add_assignee(self, task):
        if not task["assignees"]:
            return self.clickup.update_tasks(task["id"], json.dumps({"assignees": {"add": [task["creator"]["id"]]}}))
        return True

    def is_in_abscence_list(self):
        if "history_items" in self.request_body:
            for entry in self.request_body["history_items"]:
                return "parent_id" in entry and entry["parent_id"] == self.urlaub_list_id
        return False

Deployment

Über VS-Code oder Github Action wird die Azure Function deployed und neu angelegte Tasks sofort den definierten Kategorien zugewiesen.

Kosten

Die Kosten für eine derartige Automatisierung hängen immer von der Komplexität der ausgeführten Funktion ab. Es empfiehlt sich, die durchschnittliche Dauer einer Function aus dem Azure-Portal auszulesen und diese dann im Kostenrechner einzutragen. In unserem Fall wird diese Funktion unter 1000 Mal ausgeführt, bei einer durchschnittlichen Dauer von 1000ms fallen damit keine Kosten an.

Benötigen Sie Unterstützung beim Automatisieren manueller Prozesse? Unsere Experten beraten Sie gerne in einem unverbindlichen und kostenlosen Erstgespräch über Ihre Möglichkeiten.