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:
- 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
- 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.