00 Feature

Introduction

Recently, I published a post about implementing an Approval Workflow on Azure Durable Functions with SendGrid. In essence, this post is not very different to that one. However, I wanted to demonstrate the same pattern on Azure Durable Functions, but now using Slack as a means of approval. My aim is to show how easy it is to implement this pattern by using a Restful API instead of an Azure Functions binding. What you see here could easily be implemented with your own custom APIs as well :).

Scenario

In my previous post, I show how Furry Models Australia streamlined an approval process for aspiring cats to join the exclusive model agency by implementing a serverless solution on Azure Durable Functions and SendGrid. Now, after a great success, they’ve launched a new campaign targeting rabbits. However, for this campaign they need some customisation. The (rabbit) managers of this campaign have started to collaborate internally with Slack instead of email. Their aim is to significantly improve their current approval process based on phone and pigeon post by having an automated serverless workflow which leverages Slack as their internal messaging platform.

11 Sorry

Pre-requisites

To build this solution, we need:

  • Slack
    • Workspace: In case you don’t have one, you would need to create a workspace on Slack, and you will need permissions to manage apps in the workspace.
    • Channel: On that workspace, you need to create a channel where all approval requests will be sent to.
    • App: Once you have admin privileges on your Slack workspace, you should create a Slack App.
    • Incoming Webhook: On your Slack app, you would need to activate incoming webhooks and then activate a new webhook. The incoming webhook will post messages to the channel you have just created. For that, you must authorise the app to post messages to the channel. Once you have authorised it, you should be able to get the Webhook URL. You will need this URL to configure your Durable Function to post an approval request message every time an application has been received.
    • Message Template: To be able to send interactive button messages to Slack we need to have the appropriate message template.
    • Interactive Components: The webhook configured above enables you to post messages to Slack. Now you need a way to get the response from Slack, for this you can use interactive message buttons. To configure the interactive message button, you must provide a request URL. This request URL will be the URL of the HttpTrigger Azure function that will handle the approval selection.
  • Azure Storage Account: The solution requires a Storage Account with 3 blob containers: requestsapproved, and rejected. The requests container should have public access level so blobs can be viewed without a SAS token. For your own solution, you could make this more secure.

Solution Overview

The figure bellow, shows an overview of the solution we will build based on Durable Functions. As you can see, the workflow is very similar to the one implemented previously. Pictures of the aspiring rabbits are to be dropped in an Azure storage account blob container called requests. At the end of the approval workflow, pictures should be moved to the approved or rejected blob containers accordingly.

20 Solution Overview

The steps of the process are described as follows:

  1. The process is being triggered by an Azure Function with the BlobTrigger input binding monitoring the requests blob container. This function also implements the DurableOrchestrationClient attribute to instantiate a Durable Function orchestration
  2. The DurableOrchestrationClient starts the orchestration.
  3. Then, the Durable Function orchestration calls another function with the ActivityTrigger input binding, which is in charge of sending the approval request to Slack as a Slack interactive message.
  4. The interactive message is posted on Slack. This interactive message includes a callbackId field in which we send the orchestration instance id.
  5. Then, in the orchestration, a timer is created so that the approval workflow does not run forever, and in case no approval is received before a timeout, the request is rejected.
  6. The (rabbit) user receives the interactive message on Slack, and decides whether the aspiring rabbit deserves to join Furry Models, by clicking either the Approve or Reject button. The slack interactive message button will send the response to the configured URL on the Interactive Component of the Slack App (this is the URL of the HttpTrigger function which handles the Slack approval response). The response contains the callbackId field which will allow the correlation in the next step.
  7. The HttpTrigger function receives the response which contains the selection and the callbackId. This function gets the orchestration instance id from the callbackId and checks the status of that instance; if it’s not running, it returns an error message to the user. If it’s running, it raises an event to the corresponding orchestration instance.
  8. The corresponding orchestration instance receives the external event.
  9. The workflow continues when the external event is received or when the timer finishes; whatever happens first. If the timer finishes before a selection is received, the application is automatically rejected.
  10. The orchestration calls another ActivityTrigger function to move the blob to the corresponding container (approved or rejected).
  11. The orchestration finishes.

A sample of the Slack interactive message is shown below.

31 Sample Message

Then, when the user clicks on any of the buttons, it will call the HttpTrigger function described in the step 7 above. Depending on the selection and the status of the orchestration, it will receive the corresponding response:

32 Sample Response

The Solution

The implemented solution code can be found in this GitHub repo. I’ve used the Azure Functions Runtime v2. I will highlight some relevant bits of the code below, and I hope that the code is self-explanatory 😉:

TriggerApprovalByBlob.cs

This BlobTrigger function is triggered when a blob is created in a blob container and starts the Durable Function ochestration (Step 1 above)

using System.IO;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Host;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs.Extensions.Http;
using PacodelaCruz.DurableFunctions.Models;
namespace PacodelaCruz.DurableFunctions.Approval
{
public static class TriggerApprovalByBlob
{
/// <summary>
/// Function triggered by a Blob Storage file which starts a Durable Function Orchestration
/// and sends the blob metadata as context
/// </summary>
/// <param name="requestBlob"></param>
/// <param name="name"></param>
/// <param name="orchestrationClient"></param>
/// <param name="log"></param>
[FunctionName("TriggerApprovalByBlob")]
public static async void Run([BlobTrigger("requests/{name}", Connection = "Blob:StorageConnection")]Stream requestBlob, string name, [OrchestrationClient] DurableOrchestrationClient orchestrationClient, TraceWriter log)
{
log.Info($"C# Blob trigger function Processed blob\n Name:{name} \n Size: {requestBlob.Length} Bytes");
string blobStorageBasePath = Environment.GetEnvironmentVariable("Blob:StorageBasePath", EnvironmentVariableTarget.Process);
string requestor = "";
string subject = "";
// If the blob name containes a '+' sign, it identifies the first part of the blob name as the requestor and the remaining as the subject. Otherwise, the requestor is unknown and the subject is the full blobname.
if (name.Contains("+"))
{
requestor = Uri.UnescapeDataString(name.Substring(0, name.LastIndexOf("+")));
subject = name.Substring(name.LastIndexOf("+") + 1);
}
else
{
requestor = "unknown";
subject = name;
}
ApprovalRequestMetadata requestMetadata = new ApprovalRequestMetadata()
{
ApprovalType = "Cat",
ReferenceUrl = $"{blobStorageBasePath}requests/{name}",
Subject = subject,
Requestor = requestor
};
string instanceId = await orchestrationClient.StartNewAsync("OrchestrateRequestApproval", requestMetadata);
log.Info($"Durable Function Ochestration started: {instanceId}");
}
}
}

OrchestrateRequestApproval.cs

This is the Durable Function orchestration which handles the workflow and is started by the step 2 above.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Host;
using System.Threading;
using PacodelaCruz.DurableFunctions.Models;
namespace PacodelaCruz.DurableFunctions.Approval
{
public static class RequestApprovalOrchestration
{
/// <summary>
/// Durable Orchestration
/// Orchestrates a Request Approval Process using the Durable Functions Human Interaction Pattern
/// The Approval Request can be sent via Email using SendGrid or via Slack.
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
[FunctionName("OrchestrateRequestApproval")]
public static async Task<bool> Run([OrchestrationTrigger] DurableOrchestrationContext context)
{
var isApproved = false;
string meansOfApproval = Environment.GetEnvironmentVariable("Workflow:MeansOfApproval");
ApprovalRequestMetadata approvalRequestMetadata = context.GetInput<ApprovalRequestMetadata>();
approvalRequestMetadata.InstanceId = context.InstanceId;
// Check whether the approval request is to be sent via Email or Slack based on App Settings
if (meansOfApproval.Equals("email", StringComparison.OrdinalIgnoreCase))
{
await context.CallActivityAsync("SendApprovalRequestViaEmail", approvalRequestMetadata);
}
else
{
await context.CallActivityAsync("SendApprovalRequestViaSlack", approvalRequestMetadata);
}
// Wait for Response as an external event or a time out.
// The approver has a limit to approve otherwise the request will be rejected.
using (var timeoutCts = new CancellationTokenSource())
{
int timeout;
if (!int.TryParse(Environment.GetEnvironmentVariable("Workflow:Timeout"), out timeout))
timeout = 5;
DateTime expiration = context.CurrentUtcDateTime.AddMinutes(timeout);
Task timeoutTask = context.CreateTimer(expiration, timeoutCts.Token);
// This event can come from a click on the Email sent via SendGrid or a selection on the message sent via Slack.
Task<bool> approvalResponse = context.WaitForExternalEvent<bool>("ReceiveApprovalResponse");
Task winner = await Task.WhenAny(approvalResponse, timeoutTask);
ApprovalResponseMetadata approvalResponseMetadata = new ApprovalResponseMetadata()
{
ReferenceUrl = approvalRequestMetadata.ReferenceUrl
};
if (winner == approvalResponse)
{
if (approvalResponse.Result)
{
approvalResponseMetadata.DestinationContainer = "approved";
}
else
{
approvalResponseMetadata.DestinationContainer = "rejected";
}
}
else
{
approvalResponseMetadata.DestinationContainer = "rejected";
}
if (!timeoutTask.IsCompleted)
{
// All pending timers must be completed or cancelled before the function exits.
timeoutCts.Cancel();
}
// Once the approval process has been finished, the Blob is to be moved to the corresponding container.
await context.CallActivityAsync<string>("MoveBlob", approvalResponseMetadata);
return isApproved;
}
}
}
}

SendApprovalRequestViaSlack.cs

ActivityTrigger function which sends the approval request via Slack as an Interactive Message (Step 3 above).

using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Host;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Auth;
using PacodelaCruz.DurableFunctions.Models;
namespace PacodelaCruz.DurableFunctions.Approval
{
public static class SendApprovalRequestViaSlack
{
/// <summary>
/// Activity Function.
/// Sends an Approval Request to a Slack App using an interactive button template.
/// More information at https://api.slack.com/docs/message-buttons
/// </summary>
/// <param name="requestMetadata"></param>
/// <returns></returns>
[FunctionName("SendApprovalRequestViaSlack")]
public static async Task<string> Run([ActivityTrigger] ApprovalRequestMetadata requestMetadata, TraceWriter log)
{
string approvalRequestUrl = Environment.GetEnvironmentVariable("Slack:ApprovalUrl", EnvironmentVariableTarget.Process);
string approvalMessageTemplate = Environment.GetEnvironmentVariable("Slack:ApprovalMessageTemplate", EnvironmentVariableTarget.Process);
Uri uri = new Uri(requestMetadata.ReferenceUrl);
string approvalMessage = string.Format(approvalMessageTemplate, requestMetadata.ReferenceUrl, requestMetadata.ApprovalType, requestMetadata.InstanceId, requestMetadata.Requestor, requestMetadata.Subject);
string resultContent;
using (var client = new HttpClient())
{
client.BaseAddress = new Uri(approvalRequestUrl);
var content = new StringContent(approvalMessage, UnicodeEncoding.UTF8, "application/json");
var result = await client.PostAsync(approvalRequestUrl, content);
resultContent = await result.Content.ReadAsStringAsync();
if (result.StatusCode != HttpStatusCode.OK)
{
throw new HttpRequestException(resultContent);
}
}
log.Info($"Message regarding {requestMetadata.Subject} sent to Slack!");
return resultContent;
}
}
}

ProcessSlackApprovals.cs

HttpTrigger function that handles the response of the interactive messages from Slack (Step 7 above).

using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Host;
using Newtonsoft.Json;
namespace PacodelaCruz.DurableFunctions.Approval
{
public static class ProcessSlackApprovals
{
/// <summary>
/// Processes Slack Interactive Message Responses.
/// Responses are received as 'application/x-www-form-urlencoded'
/// Routes the response to the corresponding Durable Function orchestration instance
/// More information at https://api.slack.com/docs/message-buttons
/// I'm using AuthorizationLevel.Anonymous just for demostration purposes, but you most probably want to authenticate and authorise the call.
/// </summary>
/// <param name="req"></param>
/// <param name="log"></param>
/// <returns></returns>
[FunctionName("ProcessSlackApprovals")]
public static async Task<HttpResponseMessage> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, methods: "post", Route = "slackapproval")] HttpRequestMessage req, [OrchestrationClient] DurableOrchestrationClient orchestrationClient, TraceWriter log)
{
var formData = await req.Content.ReadAsFormDataAsync();
string payload = formData.Get("payload");
dynamic response = JsonConvert.DeserializeObject(payload);
string callbackId = response.callback_id;
string[] callbackIdParts = callbackId.Split('#');
string approvalType = callbackIdParts[0];
log.Info($"Received a Slack Response with callbackid {callbackId}");
string instanceId = callbackIdParts[1];
string from = Uri.UnescapeDataString(callbackIdParts[2]);
string name = callbackIdParts[3];
bool isApproved = false;
log.Info($"instaceId:'{instanceId}', from:'{from}', name:'{name}', response:'{response.actions[0].value}'");
var status = await orchestrationClient.GetStatusAsync(instanceId);
log.Info($"Orchestration status: '{status}'");
if (status.RuntimeStatus == OrchestrationRuntimeStatus.Running || status.RuntimeStatus == OrchestrationRuntimeStatus.Pending)
{
string selection = response.actions[0].value;
string emoji = "";
if (selection == "Approve")
{
isApproved = true;
emoji = ":rabbit:";
}
else
{
emoji = ":rabbit2:";
}
await orchestrationClient.RaiseEventAsync(instanceId, "ReceiveApprovalResponse", isApproved);
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent($"Thanks for your selection! Your selection for *'{name}'* was *'{selection}'* {emoji}") };
}
else
{
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent($"The approval request has expired! :crying_cat_face:") };
}
}
}
}

MoveBlob.cs

ActivityTrigger function that moves the blob to the corresponding container (Step 10 above).

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Host;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Auth;
using Microsoft.WindowsAzure.Storage.Blob;
using PacodelaCruz.DurableFunctions.Models;
namespace PacodelaCruz.DurableFunctions.Approval
{
public static class MoveBlob
{
/// <summary>
/// Moves a Blob from one container to other based on metadata
/// </summary>
/// <param name="responseMetadata"></param>
/// <param name="log"></param>
[FunctionName("MoveBlob")]
public static void Run([ActivityTrigger] ApprovalResponseMetadata responseMetadata, TraceWriter log)
{
log.Info($"Moving Blob {responseMetadata.ReferenceUrl} to {responseMetadata.DestinationContainer}");
try
{
CloudStorageAccount account = CloudStorageAccount.Parse(System.Environment.GetEnvironmentVariable("Blob:StorageConnection", EnvironmentVariableTarget.Process));
var client = account.CreateCloudBlobClient();
var sourceBlob = client.GetBlobReferenceFromServerAsync(new Uri(responseMetadata.ReferenceUrl)).Result;
var destinationContainer = client.GetContainerReference(responseMetadata.DestinationContainer);
var destinationBlob = destinationContainer.GetBlobReference(sourceBlob.Name);
destinationBlob.StartCopyAsync(sourceBlob.Uri);
Task.Delay(TimeSpan.FromSeconds(15)).Wait();
sourceBlob.DeleteAsync();
log.Info($"Blob '{responseMetadata.ReferenceUrl}' moved to container '{responseMetadata.DestinationContainer}'");
}
catch (Exception ex)
{
log.Error(ex.ToString());
throw;
}
}
}
}
view raw MoveBlob.cs hosted with ❤ by GitHub

local.settings.json

These are the settings which configure the behaviour of the solution, including the storage account connection strings, the Slack incoming webhook URL, templates for the interactive message, among others.

You would need to implement these as app settings when deploying to Azure

{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net",
"AzureWebJobsDashboard": "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net",
"Blob:StorageConnection": "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net",
"Blob:StorageBasePath": "https://...blob.core.windows.net/",
"Workflow:Timeout": "5",
"Workflow:MeansOfApproval": "Slack",
"Slack:ApprovalUrl": "https://hooks.slack.com/services/...",
"Slack:ApprovalMessageTemplate": "{{\"text\":\"A new application from *{3}* has been received. {0}\",\"attachments\":[{{\"text\":\"Does *{4}* deserve to join this exclusive agency?\",\"fallback\":\"You are unable to choose an activity\",\"callback_id\":\"{1}#{2}#{3}#{4}\",\"color\":\"#3AA3E3\",\"attachment_type\":\"default\",\"actions\":[{{\"name\":\"approve\",\"text\":\"Approve\",\"type\":\"button\",\"value\":\"Approve\"}},{{\"name\":\"approve\",\"text\":\"Reject\",\"type\":\"button\",\"value\":\"Reject\"}}]}}]}}"
}
}

Wrapping up

In this post, I’ve shown how to implement an Approval Workflow (Human Interaction pattern) on Azure Durable Functions with Slack. On the way, we’ve also seen how to create Slack Apps with interactive messages. What you read here can easily be implemented using your own custom APIs. What we’ve covered should allow you to build serverless approval workflows on Azure with different means of approval. I hope you’ve found the posts of this series useful.

Happy clouding!

Cross-posted on Deloitte Engineering Blog Follow me on @pacodelacruz